package config import ( "os" "path/filepath" "testing" "time" "github.com/rcourtman/pulse-go-rewrite/internal/auth" ) func TestAPITokenRecordHasScope(t *testing.T) { record := APITokenRecord{Scopes: []string{ScopeMonitoringRead}} if !record.HasScope(ScopeMonitoringRead) { t.Fatalf("expected scope %q to be granted", ScopeMonitoringRead) } if record.HasScope(ScopeSettingsWrite) { t.Fatalf("did not expect scope %q to be granted", ScopeSettingsWrite) } record.Scopes = nil // legacy tokens with no scopes should default to wildcard if !record.HasScope(ScopeSettingsWrite) { t.Fatalf("expected wildcard to grant %q", ScopeSettingsWrite) } // Empty scope always returns true record.Scopes = []string{ScopeMonitoringRead} if !record.HasScope("") { t.Fatalf("expected empty scope to always be granted") } // Explicit wildcard scope in list grants any scope record.Scopes = []string{ScopeWildcard} if !record.HasScope(ScopeSettingsWrite) { t.Fatalf("expected wildcard scope in list to grant %q", ScopeSettingsWrite) } if !record.HasScope(ScopeMonitoringRead) { t.Fatalf("expected wildcard scope in list to grant %q", ScopeMonitoringRead) } if !IsKnownScope(ScopeMonitoringRead) { t.Fatalf("expected %q to be known scope", ScopeMonitoringRead) } if IsKnownScope("unknown:scope") { t.Fatalf("unexpected scope recognised") } } func TestTokenPrefix(t *testing.T) { tests := []struct { name string value string expected string }{ {name: "longer than 6 chars", value: "abcdefghij", expected: "abcdef"}, {name: "exactly 6 chars", value: "abcdef", expected: "abcdef"}, {name: "shorter than 6 chars", value: "abc", expected: "abc"}, {name: "empty string", value: "", expected: ""}, {name: "single char", value: "x", expected: "x"}, {name: "5 chars", value: "12345", expected: "12345"}, {name: "7 chars", value: "1234567", expected: "123456"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := tokenPrefix(tt.value) if result != tt.expected { t.Errorf("tokenPrefix(%q) = %q, want %q", tt.value, result, tt.expected) } }) } } func TestTokenSuffix(t *testing.T) { tests := []struct { name string value string expected string }{ {name: "longer than 4 chars", value: "abcdefghij", expected: "ghij"}, {name: "exactly 4 chars", value: "abcd", expected: "abcd"}, {name: "shorter than 4 chars", value: "abc", expected: "abc"}, {name: "empty string", value: "", expected: ""}, {name: "single char", value: "x", expected: "x"}, {name: "3 chars", value: "123", expected: "123"}, {name: "5 chars", value: "12345", expected: "2345"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := tokenSuffix(tt.value) if result != tt.expected { t.Errorf("tokenSuffix(%q) = %q, want %q", tt.value, result, tt.expected) } }) } } func TestNormalizeScopes(t *testing.T) { tests := []struct { name string scopes []string expected []string }{ { name: "nil returns wildcard", scopes: nil, expected: []string{ScopeWildcard}, }, { name: "empty returns wildcard", scopes: []string{}, expected: []string{ScopeWildcard}, }, { name: "single scope preserved", scopes: []string{ScopeMonitoringRead}, expected: []string{ScopeMonitoringRead}, }, { name: "multiple scopes preserved", scopes: []string{ScopeMonitoringRead, ScopeSettingsWrite}, expected: []string{ScopeMonitoringRead, ScopeSettingsWrite}, }, { name: "wildcard alone preserved", scopes: []string{ScopeWildcard}, expected: []string{ScopeWildcard}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := normalizeScopes(tt.scopes) if len(result) != len(tt.expected) { t.Fatalf("normalizeScopes(%v) length = %d, want %d", tt.scopes, len(result), len(tt.expected)) } for i, v := range result { if v != tt.expected[i] { t.Errorf("normalizeScopes(%v)[%d] = %q, want %q", tt.scopes, i, v, tt.expected[i]) } } }) } } func TestNormalizeScopes_ReturnsCopy(t *testing.T) { original := []string{ScopeMonitoringRead, ScopeSettingsWrite} result := normalizeScopes(original) // Modify result and verify original is unchanged result[0] = "modified" if original[0] != ScopeMonitoringRead { t.Errorf("normalizeScopes did not return a copy; original was modified") } } func TestIsKnownScope(t *testing.T) { tests := []struct { name string scope string expected bool }{ {name: "wildcard", scope: ScopeWildcard, expected: true}, {name: "monitoring:read", scope: ScopeMonitoringRead, expected: true}, {name: "monitoring:write", scope: ScopeMonitoringWrite, expected: true}, {name: "docker:report", scope: ScopeDockerReport, expected: true}, {name: "docker:manage", scope: ScopeDockerManage, expected: true}, {name: "host-agent:report", scope: ScopeHostReport, expected: true}, {name: "host-agent:manage", scope: ScopeHostManage, expected: true}, {name: "settings:read", scope: ScopeSettingsRead, expected: true}, {name: "settings:write", scope: ScopeSettingsWrite, expected: true}, {name: "unknown scope", scope: "unknown:scope", expected: false}, {name: "empty string", scope: "", expected: false}, {name: "partial match", scope: "monitoring", expected: false}, {name: "case sensitive", scope: "MONITORING:READ", expected: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := IsKnownScope(tt.scope) if result != tt.expected { t.Errorf("IsKnownScope(%q) = %v, want %v", tt.scope, result, tt.expected) } }) } } func TestLoadAPITokensAppliesLegacyScopes(t *testing.T) { if len(AllKnownScopes) == 0 { t.Fatal("expected known scopes to be defined") } dir := t.TempDir() persistence := NewConfigPersistence(dir) if err := persistence.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } payload := `[{"id":"legacy","name":"legacy","hash":"abc","createdAt":"2024-01-01T00:00:00Z"}]` if err := os.WriteFile(filepath.Join(dir, "api_tokens.json"), []byte(payload), 0600); err != nil { t.Fatalf("write api_tokens.json: %v", err) } tokens, err := persistence.LoadAPITokens() if err != nil { t.Fatalf("LoadAPITokens: %v", err) } if len(tokens) != 1 { t.Fatalf("expected 1 token, got %d", len(tokens)) } if len(tokens[0].Scopes) != 1 || tokens[0].Scopes[0] != ScopeWildcard { t.Fatalf("expected legacy token to default to wildcard scope, got %#v", tokens[0].Scopes) } } func TestLoadAPITokens_ErrorPaths(t *testing.T) { t.Run("nonexistent file returns empty slice", func(t *testing.T) { dir := t.TempDir() persistence := NewConfigPersistence(dir) tokens, err := persistence.LoadAPITokens() if err != nil { t.Fatalf("expected no error for missing file, got: %v", err) } if len(tokens) != 0 { t.Fatalf("expected empty slice, got %d tokens", len(tokens)) } }) t.Run("empty file returns empty slice", func(t *testing.T) { dir := t.TempDir() persistence := NewConfigPersistence(dir) if err := persistence.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Write empty file if err := os.WriteFile(filepath.Join(dir, "api_tokens.json"), []byte{}, 0600); err != nil { t.Fatalf("write file: %v", err) } tokens, err := persistence.LoadAPITokens() if err != nil { t.Fatalf("expected no error for empty file, got: %v", err) } if len(tokens) != 0 { t.Fatalf("expected empty slice, got %d tokens", len(tokens)) } }) t.Run("invalid JSON returns error", func(t *testing.T) { dir := t.TempDir() persistence := NewConfigPersistence(dir) if err := persistence.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Write invalid JSON if err := os.WriteFile(filepath.Join(dir, "api_tokens.json"), []byte("not valid json"), 0600); err != nil { t.Fatalf("write file: %v", err) } _, err := persistence.LoadAPITokens() if err == nil { t.Fatal("expected error for invalid JSON, got nil") } }) } func TestClone(t *testing.T) { t.Run("clones all fields", func(t *testing.T) { now := time.Now().UTC() original := APITokenRecord{ ID: "test-id", Name: "test-name", Hash: "test-hash", Prefix: "prefix", Suffix: "suffix", CreatedAt: now, LastUsedAt: &now, Scopes: []string{ScopeMonitoringRead, ScopeSettingsWrite}, } clone := original.Clone() if clone.ID != original.ID { t.Errorf("ID: got %q, want %q", clone.ID, original.ID) } if clone.Name != original.Name { t.Errorf("Name: got %q, want %q", clone.Name, original.Name) } if clone.Hash != original.Hash { t.Errorf("Hash: got %q, want %q", clone.Hash, original.Hash) } if clone.Prefix != original.Prefix { t.Errorf("Prefix: got %q, want %q", clone.Prefix, original.Prefix) } if clone.Suffix != original.Suffix { t.Errorf("Suffix: got %q, want %q", clone.Suffix, original.Suffix) } if !clone.CreatedAt.Equal(original.CreatedAt) { t.Errorf("CreatedAt: got %v, want %v", clone.CreatedAt, original.CreatedAt) } if clone.LastUsedAt == nil { t.Fatal("LastUsedAt should not be nil") } if !clone.LastUsedAt.Equal(*original.LastUsedAt) { t.Errorf("LastUsedAt: got %v, want %v", *clone.LastUsedAt, *original.LastUsedAt) } }) t.Run("LastUsedAt is deep copied", func(t *testing.T) { now := time.Now().UTC() original := APITokenRecord{ ID: "test-id", LastUsedAt: &now, Scopes: []string{ScopeMonitoringRead}, } clone := original.Clone() // Modify the clone's LastUsedAt newTime := now.Add(time.Hour) *clone.LastUsedAt = newTime // Original should be unchanged if !original.LastUsedAt.Equal(now) { t.Errorf("modifying clone affected original: got %v, want %v", *original.LastUsedAt, now) } }) t.Run("Scopes slice is deep copied", func(t *testing.T) { original := APITokenRecord{ ID: "test-id", Scopes: []string{ScopeMonitoringRead, ScopeSettingsWrite}, } clone := original.Clone() // Modify the clone's Scopes clone.Scopes[0] = "modified" // Original should be unchanged if original.Scopes[0] != ScopeMonitoringRead { t.Errorf("modifying clone scopes affected original: got %q, want %q", original.Scopes[0], ScopeMonitoringRead) } }) t.Run("nil LastUsedAt stays nil", func(t *testing.T) { original := APITokenRecord{ ID: "test-id", LastUsedAt: nil, Scopes: []string{ScopeMonitoringRead}, } clone := original.Clone() if clone.LastUsedAt != nil { t.Errorf("LastUsedAt should be nil, got %v", clone.LastUsedAt) } }) t.Run("empty scopes normalized to wildcard", func(t *testing.T) { original := APITokenRecord{ ID: "test-id", Scopes: nil, } clone := original.Clone() if len(clone.Scopes) != 1 || clone.Scopes[0] != ScopeWildcard { t.Errorf("empty scopes should normalize to wildcard, got %v", clone.Scopes) } }) } func TestNewAPITokenRecord(t *testing.T) { t.Run("valid token and name", func(t *testing.T) { token := "my-secret-token-12345" name := "Test Token" scopes := []string{ScopeMonitoringRead} record, err := NewAPITokenRecord(token, name, scopes) if err != nil { t.Fatalf("unexpected error: %v", err) } if record.ID == "" { t.Error("ID should be set") } if record.Name != name { t.Errorf("Name: got %q, want %q", record.Name, name) } if record.Hash == "" { t.Error("Hash should be set") } if record.Hash == token { t.Error("Hash should not equal raw token") } if record.Prefix != "my-sec" { t.Errorf("Prefix: got %q, want %q", record.Prefix, "my-sec") } if record.Suffix != "2345" { t.Errorf("Suffix: got %q, want %q", record.Suffix, "2345") } if record.CreatedAt.IsZero() { t.Error("CreatedAt should be set") } if len(record.Scopes) != 1 || record.Scopes[0] != ScopeMonitoringRead { t.Errorf("Scopes: got %v, want [%s]", record.Scopes, ScopeMonitoringRead) } }) t.Run("empty token returns ErrInvalidToken", func(t *testing.T) { _, err := NewAPITokenRecord("", "Test", nil) if err != ErrInvalidToken { t.Errorf("expected ErrInvalidToken, got %v", err) } }) t.Run("empty scopes normalized to wildcard", func(t *testing.T) { record, err := NewAPITokenRecord("some-token", "Test", nil) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(record.Scopes) != 1 || record.Scopes[0] != ScopeWildcard { t.Errorf("expected wildcard scope, got %v", record.Scopes) } }) t.Run("empty slice scopes normalized to wildcard", func(t *testing.T) { record, err := NewAPITokenRecord("some-token", "Test", []string{}) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(record.Scopes) != 1 || record.Scopes[0] != ScopeWildcard { t.Errorf("expected wildcard scope, got %v", record.Scopes) } }) t.Run("prefix and suffix set from token", func(t *testing.T) { record, err := NewAPITokenRecord("abcdefghijklmnop", "Test", nil) if err != nil { t.Fatalf("unexpected error: %v", err) } if record.Prefix != "abcdef" { t.Errorf("Prefix: got %q, want %q", record.Prefix, "abcdef") } if record.Suffix != "mnop" { t.Errorf("Suffix: got %q, want %q", record.Suffix, "mnop") } }) t.Run("short token handles prefix/suffix", func(t *testing.T) { record, err := NewAPITokenRecord("abc", "Test", nil) if err != nil { t.Fatalf("unexpected error: %v", err) } if record.Prefix != "abc" { t.Errorf("Prefix: got %q, want %q", record.Prefix, "abc") } if record.Suffix != "abc" { t.Errorf("Suffix: got %q, want %q", record.Suffix, "abc") } }) } func TestNewHashedAPITokenRecord(t *testing.T) { t.Run("valid hash", func(t *testing.T) { hash := "abcdef1234567890" name := "Test Token" created := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) scopes := []string{ScopeMonitoringRead} record, err := NewHashedAPITokenRecord(hash, name, created, scopes) if err != nil { t.Fatalf("unexpected error: %v", err) } if record.ID == "" { t.Error("ID should be set") } if record.Name != name { t.Errorf("Name: got %q, want %q", record.Name, name) } if record.Hash != hash { t.Errorf("Hash: got %q, want %q", record.Hash, hash) } if !record.CreatedAt.Equal(created) { t.Errorf("CreatedAt: got %v, want %v", record.CreatedAt, created) } }) t.Run("empty hash returns ErrInvalidToken", func(t *testing.T) { _, err := NewHashedAPITokenRecord("", "Test", time.Now(), nil) if err != ErrInvalidToken { t.Errorf("expected ErrInvalidToken, got %v", err) } }) t.Run("zero time gets set to now", func(t *testing.T) { before := time.Now().UTC() record, err := NewHashedAPITokenRecord("somehash", "Test", time.Time{}, nil) after := time.Now().UTC() if err != nil { t.Fatalf("unexpected error: %v", err) } if record.CreatedAt.Before(before) || record.CreatedAt.After(after) { t.Errorf("CreatedAt should be between %v and %v, got %v", before, after, record.CreatedAt) } }) t.Run("scopes normalized", func(t *testing.T) { record, err := NewHashedAPITokenRecord("somehash", "Test", time.Now(), nil) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(record.Scopes) != 1 || record.Scopes[0] != ScopeWildcard { t.Errorf("expected wildcard scope, got %v", record.Scopes) } }) t.Run("prefix and suffix set from hash", func(t *testing.T) { record, err := NewHashedAPITokenRecord("abcdefghijklmnop", "Test", time.Now(), nil) if err != nil { t.Fatalf("unexpected error: %v", err) } if record.Prefix != "abcdef" { t.Errorf("Prefix: got %q, want %q", record.Prefix, "abcdef") } if record.Suffix != "mnop" { t.Errorf("Suffix: got %q, want %q", record.Suffix, "mnop") } }) } func TestHasAPITokens(t *testing.T) { t.Run("empty slice returns false", func(t *testing.T) { cfg := &Config{APITokens: nil} if cfg.HasAPITokens() { t.Error("expected false for nil tokens") } cfg.APITokens = []APITokenRecord{} if cfg.HasAPITokens() { t.Error("expected false for empty tokens") } }) t.Run("non-empty slice returns true", func(t *testing.T) { cfg := &Config{ APITokens: []APITokenRecord{ {ID: "test", Hash: "hash"}, }, } if !cfg.HasAPITokens() { t.Error("expected true for non-empty tokens") } }) } func TestAPITokenCount(t *testing.T) { t.Run("empty returns 0", func(t *testing.T) { cfg := &Config{APITokens: nil} if cfg.APITokenCount() != 0 { t.Errorf("expected 0, got %d", cfg.APITokenCount()) } }) t.Run("returns correct count", func(t *testing.T) { cfg := &Config{ APITokens: []APITokenRecord{ {ID: "1", Hash: "hash1"}, {ID: "2", Hash: "hash2"}, {ID: "3", Hash: "hash3"}, }, } if cfg.APITokenCount() != 3 { t.Errorf("expected 3, got %d", cfg.APITokenCount()) } }) } func TestActiveAPITokenHashes(t *testing.T) { t.Run("empty tokens returns empty slice", func(t *testing.T) { cfg := &Config{APITokens: nil} hashes := cfg.ActiveAPITokenHashes() if len(hashes) != 0 { t.Errorf("expected empty slice, got %v", hashes) } }) t.Run("skips records with empty hash", func(t *testing.T) { cfg := &Config{ APITokens: []APITokenRecord{ {ID: "1", Hash: "hash1"}, {ID: "2", Hash: ""}, {ID: "3", Hash: "hash3"}, }, } hashes := cfg.ActiveAPITokenHashes() if len(hashes) != 2 { t.Fatalf("expected 2 hashes, got %d", len(hashes)) } if hashes[0] != "hash1" || hashes[1] != "hash3" { t.Errorf("expected [hash1, hash3], got %v", hashes) } }) t.Run("returns all valid hashes", func(t *testing.T) { cfg := &Config{ APITokens: []APITokenRecord{ {ID: "1", Hash: "hash1"}, {ID: "2", Hash: "hash2"}, }, } hashes := cfg.ActiveAPITokenHashes() if len(hashes) != 2 { t.Fatalf("expected 2 hashes, got %d", len(hashes)) } }) } func TestHasAPITokenHash(t *testing.T) { cfg := &Config{ APITokens: []APITokenRecord{ {ID: "1", Hash: "existing-hash"}, {ID: "2", Hash: "another-hash"}, }, } t.Run("returns true when hash exists", func(t *testing.T) { if !cfg.HasAPITokenHash("existing-hash") { t.Error("expected true for existing hash") } if !cfg.HasAPITokenHash("another-hash") { t.Error("expected true for another existing hash") } }) t.Run("returns false when hash doesn't exist", func(t *testing.T) { if cfg.HasAPITokenHash("nonexistent") { t.Error("expected false for nonexistent hash") } }) t.Run("returns false for empty config", func(t *testing.T) { emptyCfg := &Config{} if emptyCfg.HasAPITokenHash("any-hash") { t.Error("expected false for empty config") } }) } func TestPrimaryAPITokenHash(t *testing.T) { t.Run("empty tokens returns empty string", func(t *testing.T) { cfg := &Config{APITokens: nil} if cfg.PrimaryAPITokenHash() != "" { t.Errorf("expected empty string, got %q", cfg.PrimaryAPITokenHash()) } }) t.Run("returns first token's hash", func(t *testing.T) { cfg := &Config{ APITokens: []APITokenRecord{ {ID: "1", Hash: "first-hash"}, {ID: "2", Hash: "second-hash"}, }, } if cfg.PrimaryAPITokenHash() != "first-hash" { t.Errorf("expected first-hash, got %q", cfg.PrimaryAPITokenHash()) } }) } func TestPrimaryAPITokenHint(t *testing.T) { t.Run("empty tokens returns empty string", func(t *testing.T) { cfg := &Config{APITokens: nil} if cfg.PrimaryAPITokenHint() != "" { t.Errorf("expected empty string, got %q", cfg.PrimaryAPITokenHint()) } }) t.Run("returns prefix...suffix format when both exist", func(t *testing.T) { cfg := &Config{ APITokens: []APITokenRecord{ {ID: "1", Prefix: "abcdef", Suffix: "wxyz"}, }, } expected := "abcdef...wxyz" if cfg.PrimaryAPITokenHint() != expected { t.Errorf("expected %q, got %q", expected, cfg.PrimaryAPITokenHint()) } }) t.Run("falls back to hash truncation when no prefix", func(t *testing.T) { cfg := &Config{ APITokens: []APITokenRecord{ {ID: "1", Hash: "12345678abcdefgh", Prefix: "", Suffix: "wxyz"}, }, } expected := "1234...efgh" if cfg.PrimaryAPITokenHint() != expected { t.Errorf("expected %q, got %q", expected, cfg.PrimaryAPITokenHint()) } }) t.Run("falls back to hash truncation when no suffix", func(t *testing.T) { cfg := &Config{ APITokens: []APITokenRecord{ {ID: "1", Hash: "12345678abcdefgh", Prefix: "abcdef", Suffix: ""}, }, } expected := "1234...efgh" if cfg.PrimaryAPITokenHint() != expected { t.Errorf("expected %q, got %q", expected, cfg.PrimaryAPITokenHint()) } }) t.Run("returns empty for short hash without prefix/suffix", func(t *testing.T) { cfg := &Config{ APITokens: []APITokenRecord{ {ID: "1", Hash: "short", Prefix: "", Suffix: ""}, }, } if cfg.PrimaryAPITokenHint() != "" { t.Errorf("expected empty string for short hash, got %q", cfg.PrimaryAPITokenHint()) } }) } func TestValidateAPIToken(t *testing.T) { rawToken := "my-secret-api-token-123" hashedToken := auth.HashAPIToken(rawToken) t.Run("empty token returns nil, false", func(t *testing.T) { cfg := &Config{ APITokens: []APITokenRecord{ {ID: "1", Hash: hashedToken, Scopes: []string{ScopeWildcard}}, }, } record, valid := cfg.ValidateAPIToken("") if record != nil || valid { t.Error("expected nil, false for empty token") } }) t.Run("invalid token returns nil, false", func(t *testing.T) { cfg := &Config{ APITokens: []APITokenRecord{ {ID: "1", Hash: hashedToken, Scopes: []string{ScopeWildcard}}, }, } record, valid := cfg.ValidateAPIToken("wrong-token") if record != nil || valid { t.Error("expected nil, false for invalid token") } }) t.Run("valid token returns record, true and updates LastUsedAt", func(t *testing.T) { cfg := &Config{ APITokens: []APITokenRecord{ {ID: "1", Hash: hashedToken, Scopes: []string{ScopeMonitoringRead}}, }, } before := time.Now().UTC() record, valid := cfg.ValidateAPIToken(rawToken) after := time.Now().UTC() if !valid { t.Fatal("expected valid=true") } if record == nil { t.Fatal("expected non-nil record") } if record.ID != "1" { t.Errorf("expected ID=1, got %q", record.ID) } if record.LastUsedAt == nil { t.Fatal("LastUsedAt should be set") } if record.LastUsedAt.Before(before) || record.LastUsedAt.After(after) { t.Errorf("LastUsedAt should be between %v and %v, got %v", before, after, *record.LastUsedAt) } // Verify the config's record was also updated if cfg.APITokens[0].LastUsedAt == nil { t.Error("config's record LastUsedAt should be updated") } }) t.Run("validates against multiple tokens", func(t *testing.T) { token2 := "another-token-456" hash2 := auth.HashAPIToken(token2) cfg := &Config{ APITokens: []APITokenRecord{ {ID: "1", Hash: hashedToken, Scopes: []string{ScopeWildcard}}, {ID: "2", Hash: hash2, Scopes: []string{ScopeMonitoringRead}}, }, } record, valid := cfg.ValidateAPIToken(token2) if !valid { t.Fatal("expected valid=true for second token") } if record.ID != "2" { t.Errorf("expected ID=2, got %q", record.ID) } }) t.Run("empty config returns nil, false", func(t *testing.T) { cfg := &Config{} record, valid := cfg.ValidateAPIToken(rawToken) if record != nil || valid { t.Error("expected nil, false for empty config") } }) } func TestUpsertAPIToken(t *testing.T) { t.Run("insert new token", func(t *testing.T) { cfg := &Config{} record := APITokenRecord{ ID: "new-id", Name: "New Token", Hash: "new-hash", CreatedAt: time.Now().UTC(), } cfg.UpsertAPIToken(record) if len(cfg.APITokens) != 1 { t.Fatalf("expected 1 token, got %d", len(cfg.APITokens)) } if cfg.APITokens[0].ID != "new-id" { t.Errorf("expected ID=new-id, got %q", cfg.APITokens[0].ID) } // Should have normalized scopes if len(cfg.APITokens[0].Scopes) != 1 || cfg.APITokens[0].Scopes[0] != ScopeWildcard { t.Errorf("expected wildcard scope, got %v", cfg.APITokens[0].Scopes) } }) t.Run("update existing token by ID", func(t *testing.T) { cfg := &Config{ APITokens: []APITokenRecord{ {ID: "existing", Name: "Old Name", Hash: "old-hash", CreatedAt: time.Now().UTC()}, }, } updated := APITokenRecord{ ID: "existing", Name: "Updated Name", Hash: "updated-hash", CreatedAt: time.Now().UTC(), Scopes: []string{ScopeMonitoringRead}, } cfg.UpsertAPIToken(updated) if len(cfg.APITokens) != 1 { t.Fatalf("expected 1 token after update, got %d", len(cfg.APITokens)) } if cfg.APITokens[0].Name != "Updated Name" { t.Errorf("expected name to be updated, got %q", cfg.APITokens[0].Name) } if cfg.APITokens[0].Hash != "updated-hash" { t.Errorf("expected hash to be updated, got %q", cfg.APITokens[0].Hash) } }) t.Run("sorting happens after upsert", func(t *testing.T) { older := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) newer := time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC) cfg := &Config{ APITokens: []APITokenRecord{ {ID: "older", Name: "Older", Hash: "hash1", CreatedAt: older}, }, } cfg.UpsertAPIToken(APITokenRecord{ ID: "newer", Name: "Newer", Hash: "hash2", CreatedAt: newer, }) if len(cfg.APITokens) != 2 { t.Fatalf("expected 2 tokens, got %d", len(cfg.APITokens)) } // Newest should be first if cfg.APITokens[0].ID != "newer" { t.Errorf("expected newest token first, got %q", cfg.APITokens[0].ID) } if cfg.APITokens[1].ID != "older" { t.Errorf("expected older token second, got %q", cfg.APITokens[1].ID) } }) } func TestRemoveAPIToken(t *testing.T) { t.Run("remove existing returns true", func(t *testing.T) { cfg := &Config{ APITokens: []APITokenRecord{ {ID: "keep", Hash: "hash1"}, {ID: "remove", Hash: "hash2"}, {ID: "also-keep", Hash: "hash3"}, }, } removed := cfg.RemoveAPIToken("remove") if !removed { t.Error("expected true when removing existing token") } if len(cfg.APITokens) != 2 { t.Fatalf("expected 2 tokens remaining, got %d", len(cfg.APITokens)) } // Verify the correct token was removed for _, token := range cfg.APITokens { if token.ID == "remove" { t.Error("removed token should not be in list") } } }) t.Run("remove non-existing returns false", func(t *testing.T) { cfg := &Config{ APITokens: []APITokenRecord{ {ID: "existing", Hash: "hash1"}, }, } removed := cfg.RemoveAPIToken("nonexistent") if removed { t.Error("expected false when removing nonexistent token") } if len(cfg.APITokens) != 1 { t.Errorf("token count should be unchanged, got %d", len(cfg.APITokens)) } }) t.Run("remove from empty returns false", func(t *testing.T) { cfg := &Config{} removed := cfg.RemoveAPIToken("any") if removed { t.Error("expected false when removing from empty config") } }) } func TestSortAPITokens(t *testing.T) { t.Run("sorts newest first", func(t *testing.T) { oldest := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) middle := time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC) newest := time.Date(2024, 12, 1, 0, 0, 0, 0, time.UTC) cfg := &Config{ APITokens: []APITokenRecord{ {ID: "middle", Hash: "hash2", CreatedAt: middle}, {ID: "oldest", Hash: "hash1", CreatedAt: oldest}, {ID: "newest", Hash: "hash3", CreatedAt: newest}, }, } cfg.SortAPITokens() if cfg.APITokens[0].ID != "newest" { t.Errorf("expected newest first, got %q", cfg.APITokens[0].ID) } if cfg.APITokens[1].ID != "middle" { t.Errorf("expected middle second, got %q", cfg.APITokens[1].ID) } if cfg.APITokens[2].ID != "oldest" { t.Errorf("expected oldest last, got %q", cfg.APITokens[2].ID) } }) t.Run("updates legacy APIToken field", func(t *testing.T) { cfg := &Config{ APITokens: []APITokenRecord{ {ID: "1", Hash: "primary-hash", CreatedAt: time.Now().UTC()}, }, } cfg.SortAPITokens() if cfg.APIToken != "primary-hash" { t.Errorf("expected APIToken to be set to primary hash, got %q", cfg.APIToken) } if !cfg.APITokenEnabled { t.Error("expected APITokenEnabled to be true") } }) t.Run("empty tokens clears APIToken", func(t *testing.T) { cfg := &Config{ APIToken: "old-value", APITokenEnabled: true, APITokens: []APITokenRecord{}, } cfg.SortAPITokens() if cfg.APIToken != "" { t.Errorf("expected APIToken to be cleared, got %q", cfg.APIToken) } }) t.Run("normalizes scopes for all tokens", func(t *testing.T) { cfg := &Config{ APITokens: []APITokenRecord{ {ID: "1", Hash: "hash1", CreatedAt: time.Now().UTC(), Scopes: nil}, {ID: "2", Hash: "hash2", CreatedAt: time.Now().UTC(), Scopes: []string{}}, }, } cfg.SortAPITokens() for i, token := range cfg.APITokens { if len(token.Scopes) != 1 || token.Scopes[0] != ScopeWildcard { t.Errorf("token %d: expected wildcard scope, got %v", i, token.Scopes) } } }) }