Files
base/core/collection_model_auth_options_test.go
zeekayandHanzo Dev 9397feac1c Refuse an auth provider Base cannot serve
Auth is Hanzo IAM and nothing else, so the registry a provider name was
once checked against is gone, and with it every check. A collection
could declare a google login and save clean, while auth-methods answers
iam whatever that collection stores. One name validates now, and it
lives in core beside the check rather than as a literal in apis.

The tests describing the removed registry are rewritten around the
provider that exists, fixtures included. TestAnchorStatusAfterClose
demanded an unchanged anchor height across a Close that joins the
anchor goroutine, so a submit in flight still landed and it failed 4
runs in 60; it now asserts the height cannot rewind.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 21:32:48 -07:00

587 lines
14 KiB
Go

package core_test
import (
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tests"
"github.com/hanzoai/base/tools/types"
)
func TestCollectionAuthOptionsValidate(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
collection func(app core.App) (*core.Collection, error)
expectedErrors []string
}{
// authRule
{
name: "nil authRule",
collection: func(app core.App) (*core.Collection, error) {
c := core.NewAuthCollection("new_auth")
c.AuthRule = nil
return c, nil
},
expectedErrors: []string{},
},
{
name: "empty authRule",
collection: func(app core.App) (*core.Collection, error) {
c := core.NewAuthCollection("new_auth")
c.AuthRule = types.Pointer("")
return c, nil
},
expectedErrors: []string{},
},
{
name: "invalid authRule",
collection: func(app core.App) (*core.Collection, error) {
c := core.NewAuthCollection("new_auth")
c.AuthRule = types.Pointer("missing != ''")
return c, nil
},
expectedErrors: []string{"authRule"},
},
{
name: "valid authRule",
collection: func(app core.App) (*core.Collection, error) {
c := core.NewAuthCollection("new_auth")
c.AuthRule = types.Pointer("id != ''")
return c, nil
},
expectedErrors: []string{},
},
// manageRule
{
name: "nil manageRule",
collection: func(app core.App) (*core.Collection, error) {
c := core.NewAuthCollection("new_auth")
c.ManageRule = nil
return c, nil
},
expectedErrors: []string{},
},
{
name: "empty manageRule",
collection: func(app core.App) (*core.Collection, error) {
c := core.NewAuthCollection("new_auth")
c.ManageRule = types.Pointer("")
return c, nil
},
expectedErrors: []string{"manageRule"},
},
{
name: "invalid manageRule",
collection: func(app core.App) (*core.Collection, error) {
c := core.NewAuthCollection("new_auth")
c.ManageRule = types.Pointer("missing != ''")
return c, nil
},
expectedErrors: []string{"manageRule"},
},
{
name: "valid manageRule",
collection: func(app core.App) (*core.Collection, error) {
c := core.NewAuthCollection("new_auth")
c.ManageRule = types.Pointer("id != ''")
return c, nil
},
expectedErrors: []string{},
},
// oauth2
{
name: "trigger oauth2 validations",
collection: func(app core.App) (*core.Collection, error) {
c := core.NewAuthCollection("new_auth")
c.OAuth2 = core.OAuth2Config{
Enabled: true,
Providers: []core.OAuth2ProviderConfig{
{Name: "missing"},
},
}
return c, nil
},
expectedErrors: []string{"oauth2"},
},
// tokens
{
name: "trigger authToken validations",
collection: func(app core.App) (*core.Collection, error) {
c := core.NewAuthCollection("new_auth")
c.AuthToken.Secret = ""
return c, nil
},
expectedErrors: []string{"authToken"},
},
{
name: "trigger emailChangeToken validations",
collection: func(app core.App) (*core.Collection, error) {
c := core.NewAuthCollection("new_auth")
c.EmailChangeToken.Secret = ""
return c, nil
},
expectedErrors: []string{"emailChangeToken"},
},
{
name: "trigger verificationToken validations",
collection: func(app core.App) (*core.Collection, error) {
c := core.NewAuthCollection("new_auth")
c.VerificationToken.Secret = ""
return c, nil
},
expectedErrors: []string{"verificationToken"},
},
{
name: "trigger fileToken validations",
collection: func(app core.App) (*core.Collection, error) {
c := core.NewAuthCollection("new_auth")
c.FileToken.Secret = ""
return c, nil
},
expectedErrors: []string{"fileToken"},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := s.collection(app)
if err != nil {
t.Fatalf("Failed to retrieve test collection: %v", err)
}
result := app.Validate(collection)
tests.TestValidationErrors(t, result, s.expectedErrors)
})
}
}
func TestEmailTemplateValidate(t *testing.T) {
scenarios := []struct {
name string
template core.EmailTemplate
expectedErrors []string
}{
{
"zero value",
core.EmailTemplate{},
[]string{"subject", "body"},
},
{
"non-empty data",
core.EmailTemplate{
Subject: "a",
Body: "b",
},
[]string{},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result := s.template.Validate()
tests.TestValidationErrors(t, result, s.expectedErrors)
})
}
}
func TestEmailTemplateResolve(t *testing.T) {
template := core.EmailTemplate{
Subject: "test_subject {PARAM3} {PARAM1}-{PARAM2} repeat-{PARAM1}",
Body: "test_body {PARAM3} {PARAM2}-{PARAM1} repeat-{PARAM2}",
}
scenarios := []struct {
name string
placeholders map[string]any
template core.EmailTemplate
expectedSubject string
expectedBody string
}{
{
"no placeholders",
nil,
template,
template.Subject,
template.Body,
},
{
"no matching placeholders",
map[string]any{"{A}": "abc", "{B}": 456},
template,
template.Subject,
template.Body,
},
{
"at least one matching placeholder",
map[string]any{"{PARAM1}": "abc", "{PARAM2}": 456},
template,
"test_subject {PARAM3} abc-456 repeat-abc",
"test_body {PARAM3} 456-abc repeat-456",
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
subject, body := s.template.Resolve(s.placeholders)
if subject != s.expectedSubject {
t.Fatalf("Expected subject\n%v\ngot\n%v", s.expectedSubject, subject)
}
if body != s.expectedBody {
t.Fatalf("Expected body\n%v\ngot\n%v", s.expectedBody, body)
}
})
}
}
// A token secret signs JWTs the server mints, so it is server material and a
// document is not where it comes from. MarshalJSON emits none, and an
// unmarshal leaves the ones already on the model alone — while everything else
// on the same token config still loads.
func TestATokenSecretIsNotReadFromADocument(t *testing.T) {
t.Parallel()
collection := core.NewAuthCollection("test")
before := map[string]string{}
for name, config := range namedTokens(collection) {
if config.Secret == "" {
t.Fatalf("Expected the factory to mint a %s secret", name)
}
before[name] = config.Secret
}
claimed := strings.Repeat("z", 50)
raw := fmt.Sprintf(`{
"authToken":{"secret":%[1]q,"duration":111},
"fileToken":{"secret":%[1]q,"duration":222},
"emailChangeToken":{"secret":%[1]q,"duration":333},
"verificationToken":{"secret":%[1]q,"duration":444}
}`, claimed)
if err := json.Unmarshal([]byte(raw), collection); err != nil {
t.Fatal(err)
}
durations := map[string]int64{
"authToken": 111, "fileToken": 222, "emailChangeToken": 333, "verificationToken": 444,
}
for name, config := range namedTokens(collection) {
if config.Secret != before[name] {
t.Fatalf("Expected the %s secret to survive the unmarshal, got %q", name, config.Secret)
}
if config.Duration != durations[name] {
t.Fatalf("Expected %s duration %d, got %d", name, durations[name], config.Duration)
}
}
// and the same door on an existing collection, where there is no factory
// to fall back on
stored := core.NewAuthCollection("stored")
storedSecret := stored.AuthToken.Secret
stored.Id = "hbc_stored"
stored.MarkAsNotNew()
if err := json.Unmarshal([]byte(raw), stored); err != nil {
t.Fatal(err)
}
if stored.AuthToken.Secret != storedSecret {
t.Fatalf("Expected the stored secret to survive, got %q", stored.AuthToken.Secret)
}
}
// Rotation is how a secret is replaced, and the tokens signed with the old one
// stop verifying as soon as the collection carrying it is saved.
func TestRotatingTokenSecretsRefusesTheTokensSignedBefore(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
record, err := app.FindFirstRecordByFilter(collection, "1=1")
if err != nil {
t.Fatal(err)
}
token, err := record.NewAuthToken()
if err != nil {
t.Fatal(err)
}
if _, err := app.FindAuthRecordByToken(token, core.TokenTypeAuth); err != nil {
t.Fatalf("Expected the fresh token to verify, got %v", err)
}
before := namedTokens(collection)
kept := map[string]string{}
for name, config := range before {
kept[name] = config.Secret
}
collection.RotateTokenSecrets()
for name, config := range namedTokens(collection) {
if config.Secret == kept[name] {
t.Fatalf("Expected a fresh %s secret", name)
}
if len(config.Secret) != 50 {
t.Fatalf("Expected a 50 character %s secret, got %d", name, len(config.Secret))
}
}
if err := app.Save(collection); err != nil {
t.Fatal(err)
}
if _, err := app.FindAuthRecordByToken(token, core.TokenTypeAuth); err == nil {
t.Fatal("Expected the token signed with the previous secret to be refused")
}
}
func namedTokens(c *core.Collection) map[string]*core.TokenConfig {
return map[string]*core.TokenConfig{
"authToken": &c.AuthToken,
"fileToken": &c.FileToken,
"emailChangeToken": &c.EmailChangeToken,
"verificationToken": &c.VerificationToken,
}
}
func TestTokenConfigValidate(t *testing.T) {
scenarios := []struct {
name string
config core.TokenConfig
expectedErrors []string
}{
{
"zero value",
core.TokenConfig{},
[]string{"secret", "duration"},
},
{
"invalid data",
core.TokenConfig{
Secret: strings.Repeat("a", 29),
Duration: 9,
},
[]string{"secret", "duration"},
},
{
"valid data",
core.TokenConfig{
Secret: strings.Repeat("a", 30),
Duration: 10,
},
[]string{},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result := s.config.Validate()
tests.TestValidationErrors(t, result, s.expectedErrors)
})
}
}
func TestTokenConfigDurationTime(t *testing.T) {
scenarios := []struct {
config core.TokenConfig
expected time.Duration
}{
{core.TokenConfig{}, 0 * time.Second},
{core.TokenConfig{Duration: 1234}, 1234 * time.Second},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%d", i, s.config.Duration), func(t *testing.T) {
result := s.config.DurationTime()
if result != s.expected {
t.Fatalf("Expected duration %d, got %d", s.expected, result)
}
})
}
}
func TestOAuth2ConfigGetProviderConfig(t *testing.T) {
scenarios := []struct {
name string
providerName string
config core.OAuth2Config
expectedExists bool
}{
{
"zero value",
core.OAuth2ProviderIAM,
core.OAuth2Config{},
false,
},
{
"non-empty config with missing provider",
"missing",
core.OAuth2Config{Providers: []core.OAuth2ProviderConfig{{Name: core.OAuth2ProviderIAM}}},
false,
},
{
"config with existing provider",
core.OAuth2ProviderIAM,
core.OAuth2Config{Providers: []core.OAuth2ProviderConfig{{Name: core.OAuth2ProviderIAM}}},
true,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
config, exists := s.config.GetProviderConfig(s.providerName)
if exists != s.expectedExists {
t.Fatalf("Expected exists %v, got %v", s.expectedExists, exists)
}
if exists {
if config.Name != s.providerName {
t.Fatalf("Expected config with name %q, got %q", s.providerName, config.Name)
}
} else {
if config.Name != "" {
t.Fatalf("Expected empty config, got %v", config)
}
}
})
}
}
func TestOAuth2ConfigValidate(t *testing.T) {
scenarios := []struct {
name string
config core.OAuth2Config
expectedErrors []string
}{
{
"zero value (disabled)",
core.OAuth2Config{},
[]string{},
},
{
"zero value (enabled)",
core.OAuth2Config{Enabled: true},
[]string{},
},
{
"unknown provider",
core.OAuth2Config{Enabled: true, Providers: []core.OAuth2ProviderConfig{
{Name: "missing", ClientId: "abc", ClientSecret: "456"},
}},
[]string{"providers"},
},
{
"known provider with invalid data",
core.OAuth2Config{Enabled: true, Providers: []core.OAuth2ProviderConfig{
{Name: core.OAuth2ProviderIAM, ClientId: "abc", TokenURL: "!invalid!"},
}},
[]string{"providers"},
},
{
"known provider with valid data",
core.OAuth2Config{Enabled: true, Providers: []core.OAuth2ProviderConfig{
{Name: core.OAuth2ProviderIAM, ClientId: "abc", ClientSecret: "456", TokenURL: "https://example.com"},
}},
[]string{},
},
{
"known provider with valid data (duplicated)",
core.OAuth2Config{Enabled: true, Providers: []core.OAuth2ProviderConfig{
{Name: core.OAuth2ProviderIAM, ClientId: "abc1", ClientSecret: "1", TokenURL: "https://example1.com"},
{Name: core.OAuth2ProviderIAM, ClientId: "abc2", ClientSecret: "2", TokenURL: "https://example2.com"},
}},
[]string{"providers"},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result := s.config.Validate()
tests.TestValidationErrors(t, result, s.expectedErrors)
})
}
}
func TestOAuth2ProviderConfigValidate(t *testing.T) {
scenarios := []struct {
name string
config core.OAuth2ProviderConfig
expectedErrors []string
}{
{
"zero value",
core.OAuth2ProviderConfig{},
[]string{"name", "clientId", "clientSecret"},
},
{
"minimum valid data",
core.OAuth2ProviderConfig{Name: core.OAuth2ProviderIAM, ClientId: "abc", ClientSecret: "456"},
[]string{},
},
{
"non-existing provider",
core.OAuth2ProviderConfig{Name: "missing", ClientId: "abc", ClientSecret: "456"},
[]string{"name"},
},
{
"invalid urls",
core.OAuth2ProviderConfig{
Name: core.OAuth2ProviderIAM,
ClientId: "abc",
ClientSecret: "456",
AuthURL: "!invalid!",
TokenURL: "!invalid!",
UserInfoURL: "!invalid!",
},
[]string{"authURL", "tokenURL", "userInfoURL"},
},
{
"valid urls",
core.OAuth2ProviderConfig{
Name: core.OAuth2ProviderIAM,
ClientId: "abc",
ClientSecret: "456",
AuthURL: "https://example.com/a",
TokenURL: "https://example.com/b",
UserInfoURL: "https://example.com/c",
},
[]string{},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result := s.config.Validate()
tests.TestValidationErrors(t, result, s.expectedErrors)
})
}
}