mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-30 04:20:20 +00:00
- Clone: deep copy verification for pointers and slices - NewAPITokenRecord/NewHashedAPITokenRecord: creation and validation - Config methods: HasAPITokens, APITokenCount, ActiveAPITokenHashes - Config methods: HasAPITokenHash, PrimaryAPITokenHash, PrimaryAPITokenHint - Config methods: ValidateAPIToken, UpsertAPIToken, RemoveAPIToken, SortAPITokens config package coverage: 43.5% → 46.3%
1029 lines
28 KiB
Go
1029 lines
28 KiB
Go
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)
|
|
}
|
|
}
|
|
})
|
|
}
|