package apitokens import ( "slices" "testing" "time" "git.julianfamily.org/keepassgo/internal/vault" ) func TestTokenEntryRoundTripsThroughVaultEntry(t *testing.T) { t.Parallel() expiresAt := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC) token := Token{ ID: "token-1", Name: "Browser Connector", ClientName: "browser-extension", SecretHash: "deadbeef", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC), ExpiresAt: &expiresAt, Disabled: true, Policies: []PolicyRule{ {Effect: EffectAllow, Operation: OperationListEntries, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}}, {Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "bank-token"}}, }, } entry := token.Entry([]string{"Root", "API Tokens"}) if entry.Fields[FieldType] != EntryTypeAPIToken { t.Fatalf("FieldType = %q, want %q", entry.Fields[FieldType], EntryTypeAPIToken) } got, ok, err := TokenFromEntry(entry) if err != nil { t.Fatalf("TokenFromEntry() error = %v", err) } if !ok { t.Fatal("TokenFromEntry() ok = false, want true") } if got.ID != token.ID || got.Name != token.Name || got.ClientName != token.ClientName || got.SecretHash != token.SecretHash || !got.Disabled { t.Fatalf("TokenFromEntry() = %#v, want %#v", got, token) } if got.ExpiresAt == nil || !got.ExpiresAt.Equal(expiresAt) { t.Fatalf("ExpiresAt = %#v, want %v", got.ExpiresAt, expiresAt) } if len(got.Policies) != 2 { t.Fatalf("len(Policies) = %d, want 2", len(got.Policies)) } } func TestEntriesFiltersOnlyAPITokens(t *testing.T) { t.Parallel() model := vault.Model{ Entries: []vault.Entry{ {ID: "entry-1", Title: "Ordinary Entry"}, Token{ID: "token-1", Name: "CLI", SecretHash: "hash-1", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)}.Entry([]string{"Root", "API Tokens"}), Token{ID: "token-2", Name: "Browser", SecretHash: "hash-2", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)}.Entry([]string{"Root", "API Tokens"}), }, } got, err := Entries(model) if err != nil { t.Fatalf("Entries() error = %v", err) } if len(got) != 2 { t.Fatalf("len(Entries()) = %d, want 2", len(got)) } if got[0].Name != "Browser" || got[1].Name != "CLI" { t.Fatalf("Entries() = %#v, want Browser then CLI by name sort", got) } } func TestUpsertCreatesAndUpdatesVaultTokenEntry(t *testing.T) { t.Parallel() model := vault.Model{} token := Token{ ID: "token-1", Name: "CLI", ClientName: "grpc-cli", SecretHash: "hash-1", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC), } Upsert(&model, token) if len(model.Entries) != 1 { t.Fatalf("len(model.Entries) = %d, want 1", len(model.Entries)) } if got := model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"API Tokens"}) { t.Fatalf("ChildGroups(Root) = %v, want [API Tokens]", got) } token.ClientName = "rotated-client" token.Disabled = true Upsert(&model, token) got, err := Find(model, "token-1") if err != nil { t.Fatalf("Find() error = %v", err) } if got.ClientName != "rotated-client" || !got.Disabled { t.Fatalf("Find() = %#v, want updated client name and disabled state", got) } } func TestDeleteRemovesTokenEntryByTokenID(t *testing.T) { t.Parallel() model := vault.Model{ Entries: []vault.Entry{ Token{ID: "token-1", Name: "CLI", SecretHash: "hash-1", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)}.Entry(EntryPath), {ID: "entry-1", Title: "Regular Entry"}, }, } if err := Delete(&model, "token-1"); err != nil { t.Fatalf("Delete() error = %v", err) } if len(model.Entries) != 1 || model.Entries[0].ID != "entry-1" { t.Fatalf("model.Entries after Delete = %#v, want only regular entry", model.Entries) } if err := Delete(&model, "missing"); err != ErrTokenNotFound { t.Fatalf("Delete(missing) error = %v, want %v", err, ErrTokenNotFound) } } func TestIssueRotateDisableAndRevokeToken(t *testing.T) { t.Parallel() now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) token, secret, err := Issue("Browser Connector", "browser-extension", nil, now) if err != nil { t.Fatalf("Issue() error = %v", err) } if token.ID == "" || secret == "" { t.Fatalf("Issue() returned empty token or secret: %#v %q", token, secret) } if token.SecretHash == secret { t.Fatal("SecretHash should not equal cleartext secret") } if token.Disabled { t.Fatal("Disabled = true, want false after Issue") } rotated, newSecret, err := Rotate(token, now.Add(time.Hour)) if err != nil { t.Fatalf("Rotate() error = %v", err) } if rotated.ID != token.ID { t.Fatalf("Rotate() changed ID from %q to %q", token.ID, rotated.ID) } if newSecret == secret { t.Fatal("Rotate() returned the same cleartext secret, want a new one") } if rotated.SecretHash == token.SecretHash { t.Fatal("Rotate() left SecretHash unchanged") } disabled := Disable(rotated) if !disabled.Disabled { t.Fatal("Disable() did not set Disabled") } revoked := Revoke(disabled, now.Add(2*time.Hour)) if !revoked.Disabled { t.Fatal("Revoke() should leave token disabled") } if revoked.RevokedAt == nil || !revoked.RevokedAt.Equal(now.Add(2*time.Hour)) { t.Fatalf("RevokedAt = %#v, want %v", revoked.RevokedAt, now.Add(2*time.Hour)) } } func TestAuthenticateRejectsDisabledExpiredAndWrongSecret(t *testing.T) { t.Parallel() now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) valid, secret, err := Issue("CLI", "cli-tool", nil, now) if err != nil { t.Fatalf("Issue() error = %v", err) } expired, expiredSecret, err := Issue("Expired", "cli-tool", nil, now) if err != nil { t.Fatalf("Issue(expired) error = %v", err) } expiredAt := now.Add(-time.Minute) expired.ExpiresAt = &expiredAt disabled, disabledSecret, err := Issue("Disabled", "cli-tool", nil, now) if err != nil { t.Fatalf("Issue(disabled) error = %v", err) } disabled = Disable(disabled) tokens := []Token{expired, disabled, valid} if _, err := Authenticate(tokens, "wrong-secret", now); err != ErrInvalidToken { t.Fatalf("Authenticate(wrong-secret) error = %v, want %v", err, ErrInvalidToken) } if _, err := Authenticate([]Token{expired}, expiredSecret, now); err != ErrExpiredToken { t.Fatalf("Authenticate(expired) error = %v, want %v", err, ErrExpiredToken) } if _, err := Authenticate([]Token{disabled}, disabledSecret, now); err != ErrDisabledToken { t.Fatalf("Authenticate(disabled) error = %v, want %v", err, ErrDisabledToken) } got, err := Authenticate(tokens, secret, now) if err != nil { t.Fatalf("Authenticate(valid) error = %v", err) } if got.ID != valid.ID { t.Fatalf("Authenticate(valid).ID = %q, want %q", got.ID, valid.ID) } } func TestEvaluatePolicyDistinguishesAllowDenyAndPrompt(t *testing.T) { t.Parallel() token := Token{ ID: "token-1", Policies: []PolicyRule{ {Effect: EffectAllow, Operation: OperationListEntries, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}}, {Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "amazon"}}, }, } decision := Evaluate(token, OperationListEntries, Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}) if decision != DecisionAllow { t.Fatalf("Evaluate(allow) = %q, want %q", decision, DecisionAllow) } decision = Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "amazon", Path: []string{"Root", "Internet"}}) if decision != DecisionDeny { t.Fatalf("Evaluate(deny) = %q, want %q", decision, DecisionDeny) } decision = Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "github", Path: []string{"Root", "Internet"}}) if decision != DecisionPrompt { t.Fatalf("Evaluate(prompt) = %q, want %q", decision, DecisionPrompt) } } func TestEntryScopedDenyOverridesGroupAllow(t *testing.T) { t.Parallel() token := Token{ ID: "token-1", Policies: []PolicyRule{ {Effect: EffectAllow, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}}, {Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "bank", Path: []string{"Root", "Internet"}}}, }, } allowed := Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "forum", Path: []string{"Root", "Internet"}}) denied := Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "bank", Path: []string{"Root", "Internet"}}) if allowed != DecisionAllow || denied != DecisionDeny { t.Fatalf("Evaluate() allow/deny = %q/%q, want %q/%q", allowed, denied, DecisionAllow, DecisionDeny) } } func TestTokenEntryKeepsPoliciesStableAcrossRoundTripOrdering(t *testing.T) { t.Parallel() token := Token{ ID: "token-1", Name: "CLI", ClientName: "cli", SecretHash: "hash", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC), Policies: []PolicyRule{ {Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "bank"}}, {Effect: EffectAllow, Operation: OperationListEntries, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}}, }, } got, ok, err := TokenFromEntry(token.Entry([]string{"Root", "API Tokens"})) if err != nil || !ok { t.Fatalf("TokenFromEntry() error = %v, ok=%v", err, ok) } if !slices.EqualFunc(got.Policies, token.Policies, func(a, b PolicyRule) bool { return a.Effect == b.Effect && a.Operation == b.Operation && a.Resource.Kind == b.Resource.Kind && a.Resource.EntryID == b.Resource.EntryID && slices.Equal(a.Resource.Path, b.Resource.Path) }) { t.Fatalf("Policies after round trip = %#v, want %#v", got.Policies, token.Policies) } }