mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
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]>
587 lines
14 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|