Move app packages under internal
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user