package auth import ( "context" "fmt" "testing" "time" "github.com/orchard9/rdev/internal/testutil" ) func TestAPIKey_IsExpired(t *testing.T) { now := time.Now() past := now.Add(-1 * time.Hour) future := now.Add(1 * time.Hour) tests := []struct { name string key *APIKey want bool }{ {"nil expiration", &APIKey{ExpiresAt: nil}, false}, {"expired", &APIKey{ExpiresAt: &past}, true}, {"not expired", &APIKey{ExpiresAt: &future}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := tt.key.IsExpired(); got != tt.want { t.Errorf("IsExpired() = %v, want %v", got, tt.want) } }) } } func TestAPIKey_IsRevoked(t *testing.T) { now := time.Now() tests := []struct { name string key *APIKey want bool }{ {"not revoked", &APIKey{RevokedAt: nil}, false}, {"revoked", &APIKey{RevokedAt: &now}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := tt.key.IsRevoked(); got != tt.want { t.Errorf("IsRevoked() = %v, want %v", got, tt.want) } }) } } func TestAPIKey_IsActive(t *testing.T) { now := time.Now() past := now.Add(-1 * time.Hour) future := now.Add(1 * time.Hour) tests := []struct { name string key *APIKey want bool }{ {"active", &APIKey{ExpiresAt: &future, RevokedAt: nil}, true}, {"expired", &APIKey{ExpiresAt: &past, RevokedAt: nil}, false}, {"revoked", &APIKey{ExpiresAt: &future, RevokedAt: &now}, false}, {"never expires", &APIKey{ExpiresAt: nil, RevokedAt: nil}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := tt.key.IsActive(); got != tt.want { t.Errorf("IsActive() = %v, want %v", got, tt.want) } }) } } func TestService_IsAdminKey(t *testing.T) { svc := NewService(nil, "admin-secret") tests := []struct { name string key string want bool }{ {"matches admin key", "admin-secret", true}, {"wrong key", "wrong-key", false}, {"empty key", "", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := svc.IsAdminKey(tt.key); got != tt.want { t.Errorf("IsAdminKey(%q) = %v, want %v", tt.key, got, tt.want) } }) } } func TestService_IsAdminKey_NoAdminKey(t *testing.T) { svc := NewService(nil, "") if svc.IsAdminKey("anything") { t.Error("IsAdminKey should return false when no admin key is set") } } // Integration tests - require database func TestService_Create(t *testing.T) { db := testutil.TestDB(t) t.Cleanup(func() { testutil.CleanupTestKeys(t, db) }) svc := NewService(db, "admin-key") t.Run("creates key with valid scopes", func(t *testing.T) { resp, err := svc.Create(context.Background(), CreateKeyRequest{ Name: "test-key-1", Scopes: []Scope{ScopeProjectsRead}, ExpiresIn: 24 * time.Hour, CreatedBy: "test", }) if err != nil { t.Fatalf("Create() error = %v", err) } if resp.Secret == "" { t.Error("Create() returned empty secret") } if !ValidateKeyFormat(resp.Secret) { t.Errorf("Create() returned invalid key format: %q", resp.Secret) } if resp.Key.Name != "test-key-1" { t.Errorf("Key.Name = %q, want %q", resp.Key.Name, "test-key-1") } if len(resp.Key.Scopes) != 1 || resp.Key.Scopes[0] != ScopeProjectsRead { t.Errorf("Key.Scopes = %v, want [%v]", resp.Key.Scopes, ScopeProjectsRead) } if resp.Key.ExpiresAt == nil { t.Error("Key.ExpiresAt should not be nil") } }) t.Run("rejects invalid scopes", func(t *testing.T) { _, err := svc.Create(context.Background(), CreateKeyRequest{ Name: "test-key-invalid", Scopes: []Scope{Scope("invalid:scope")}, CreatedBy: "test", }) if err == nil { t.Error("Create() should reject invalid scopes") } }) t.Run("creates key with no expiration", func(t *testing.T) { resp, err := svc.Create(context.Background(), CreateKeyRequest{ Name: "test-key-no-expire", Scopes: []Scope{ScopeAdmin}, ExpiresIn: 0, CreatedBy: "test", }) if err != nil { t.Fatalf("Create() error = %v", err) } if resp.Key.ExpiresAt != nil { t.Error("Key.ExpiresAt should be nil for no expiration") } }) t.Run("creates key with project restrictions", func(t *testing.T) { resp, err := svc.Create(context.Background(), CreateKeyRequest{ Name: "test-key-projects", Scopes: []Scope{ScopeProjectsRead}, ProjectIDs: []string{"proj-a", "proj-b"}, ExpiresIn: 24 * time.Hour, CreatedBy: "test", }) if err != nil { t.Fatalf("Create() error = %v", err) } if len(resp.Key.ProjectIDs) != 2 { t.Errorf("Key.ProjectIDs length = %d, want 2", len(resp.Key.ProjectIDs)) } }) } func TestService_Validate(t *testing.T) { db := testutil.TestDB(t) t.Cleanup(func() { testutil.CleanupTestKeys(t, db) }) svc := NewService(db, "admin-key-test") t.Run("validates admin key", func(t *testing.T) { key, err := svc.Validate(context.Background(), "admin-key-test") if err != nil { t.Fatalf("Validate() error = %v", err) } if key.ID != "admin" { t.Errorf("Key.ID = %q, want %q", key.ID, "admin") } if !HasScope(key.Scopes, ScopeAdmin) { t.Error("Admin key should have admin scope") } }) t.Run("validates created key", func(t *testing.T) { // Create a key first resp, err := svc.Create(context.Background(), CreateKeyRequest{ Name: "test-validate-key", Scopes: []Scope{ScopeProjectsRead, ScopeKeysRead}, ExpiresIn: 24 * time.Hour, CreatedBy: "test", }) if err != nil { t.Fatalf("Create() error = %v", err) } // Validate it key, err := svc.Validate(context.Background(), resp.Secret) if err != nil { t.Fatalf("Validate() error = %v", err) } if key.Name != "test-validate-key" { t.Errorf("Key.Name = %q, want %q", key.Name, "test-validate-key") } if len(key.Scopes) != 2 { t.Errorf("Key.Scopes length = %d, want 2", len(key.Scopes)) } }) t.Run("rejects invalid format", func(t *testing.T) { _, err := svc.Validate(context.Background(), "not-a-valid-key") if err != ErrKeyNotFound { t.Errorf("Validate() error = %v, want %v", err, ErrKeyNotFound) } }) t.Run("rejects unknown key", func(t *testing.T) { // Valid format but not in database _, err := svc.Validate(context.Background(), "rdev_sk_abc12345_0123456789abcdef0123456789abcdef") if err != ErrKeyNotFound { t.Errorf("Validate() error = %v, want %v", err, ErrKeyNotFound) } }) } func TestService_List(t *testing.T) { db := testutil.TestDB(t) t.Cleanup(func() { testutil.CleanupTestKeys(t, db) }) svc := NewService(db, "admin-key") // Create some test keys for i := 0; i < 3; i++ { _, err := svc.Create(context.Background(), CreateKeyRequest{ Name: fmt.Sprintf("test-list-key-%d", i), Scopes: []Scope{ScopeProjectsRead}, ExpiresIn: 24 * time.Hour, CreatedBy: "test", }) if err != nil { t.Fatalf("Create() error = %v", err) } } keys, err := svc.List(context.Background()) if err != nil { t.Fatalf("List() error = %v", err) } // Should have at least our 3 test keys testKeyCount := 0 for _, k := range keys { if k.Name[:10] == "test-list-" { testKeyCount++ } } if testKeyCount != 3 { t.Errorf("List() returned %d test keys, want 3", testKeyCount) } } func TestService_Get(t *testing.T) { db := testutil.TestDB(t) t.Cleanup(func() { testutil.CleanupTestKeys(t, db) }) svc := NewService(db, "admin-key") t.Run("gets existing key", func(t *testing.T) { resp, err := svc.Create(context.Background(), CreateKeyRequest{ Name: "test-get-key", Scopes: []Scope{ScopeProjectsRead}, ExpiresIn: 24 * time.Hour, CreatedBy: "test", }) if err != nil { t.Fatalf("Create() error = %v", err) } key, err := svc.Get(context.Background(), resp.Key.ID) if err != nil { t.Fatalf("Get() error = %v", err) } if key.Name != "test-get-key" { t.Errorf("Key.Name = %q, want %q", key.Name, "test-get-key") } }) t.Run("returns error for unknown key", func(t *testing.T) { _, err := svc.Get(context.Background(), "00000000-0000-0000-0000-000000000000") if err != ErrKeyNotFound { t.Errorf("Get() error = %v, want %v", err, ErrKeyNotFound) } }) } func TestService_Revoke(t *testing.T) { db := testutil.TestDB(t) t.Cleanup(func() { testutil.CleanupTestKeys(t, db) }) svc := NewService(db, "admin-key") t.Run("revokes existing key", func(t *testing.T) { resp, err := svc.Create(context.Background(), CreateKeyRequest{ Name: "test-revoke-key", Scopes: []Scope{ScopeProjectsRead}, ExpiresIn: 24 * time.Hour, CreatedBy: "test", }) if err != nil { t.Fatalf("Create() error = %v", err) } err = svc.Revoke(context.Background(), resp.Key.ID) if err != nil { t.Fatalf("Revoke() error = %v", err) } // Validate should fail _, err = svc.Validate(context.Background(), resp.Secret) if err != ErrKeyRevoked { t.Errorf("Validate() after revoke error = %v, want %v", err, ErrKeyRevoked) } }) t.Run("returns error for unknown key", func(t *testing.T) { err := svc.Revoke(context.Background(), "00000000-0000-0000-0000-000000000000") if err != ErrKeyNotFound { t.Errorf("Revoke() error = %v, want %v", err, ErrKeyNotFound) } }) t.Run("idempotent for already revoked", func(t *testing.T) { resp, err := svc.Create(context.Background(), CreateKeyRequest{ Name: "test-revoke-twice", Scopes: []Scope{ScopeProjectsRead}, ExpiresIn: 24 * time.Hour, CreatedBy: "test", }) if err != nil { t.Fatalf("Create() error = %v", err) } // Revoke once if err := svc.Revoke(context.Background(), resp.Key.ID); err != nil { t.Fatalf("First Revoke() error = %v", err) } // Revoke again - should return not found (no rows affected) err = svc.Revoke(context.Background(), resp.Key.ID) if err != ErrKeyNotFound { t.Errorf("Second Revoke() error = %v, want %v", err, ErrKeyNotFound) } }) } func TestAPIKey_IsIPAllowed(t *testing.T) { tests := []struct { name string allowedIPs []string clientIP string want bool }{ { name: "no restrictions - any IP allowed", allowedIPs: nil, clientIP: "192.168.1.100", want: true, }, { name: "empty restrictions - any IP allowed", allowedIPs: []string{}, clientIP: "10.0.0.5", want: true, }, { name: "single IP match", allowedIPs: []string{"192.168.1.100"}, clientIP: "192.168.1.100", want: true, }, { name: "single IP no match", allowedIPs: []string{"192.168.1.100"}, clientIP: "192.168.1.101", want: false, }, { name: "CIDR match", allowedIPs: []string{"192.168.1.0/24"}, clientIP: "192.168.1.55", want: true, }, { name: "CIDR no match", allowedIPs: []string{"192.168.1.0/24"}, clientIP: "192.168.2.1", want: false, }, { name: "multiple CIDRs - first matches", allowedIPs: []string{"10.0.0.0/8", "192.168.0.0/16"}, clientIP: "10.50.25.100", want: true, }, { name: "multiple CIDRs - second matches", allowedIPs: []string{"10.0.0.0/8", "192.168.0.0/16"}, clientIP: "192.168.50.1", want: true, }, { name: "multiple CIDRs - none match", allowedIPs: []string{"10.0.0.0/8", "192.168.0.0/16"}, clientIP: "172.16.0.1", want: false, }, { name: "mixed IP and CIDR - IP matches", allowedIPs: []string{"10.0.0.0/8", "172.16.0.1"}, clientIP: "172.16.0.1", want: true, }, { name: "mixed IP and CIDR - CIDR matches", allowedIPs: []string{"10.0.0.0/8", "172.16.0.1"}, clientIP: "10.1.2.3", want: true, }, { name: "IPv6 CIDR", allowedIPs: []string{"2001:db8::/32"}, clientIP: "2001:db8:1234:5678::1", want: true, }, { name: "IPv6 no match", allowedIPs: []string{"2001:db8::/32"}, clientIP: "2001:db9::1", want: false, }, { name: "invalid client IP", allowedIPs: []string{"192.168.1.0/24"}, clientIP: "not-an-ip", want: false, }, { name: "invalid CIDR in allowlist (fallback to IP parse)", allowedIPs: []string{"invalid/cidr", "192.168.1.100"}, clientIP: "192.168.1.100", want: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { key := &APIKey{AllowedIPs: tt.allowedIPs} if got := key.IsIPAllowed(tt.clientIP); got != tt.want { t.Errorf("IsIPAllowed(%q) = %v, want %v", tt.clientIP, got, tt.want) } }) } } func TestService_CreateWithAllowedIPs(t *testing.T) { db := testutil.TestDB(t) t.Cleanup(func() { testutil.CleanupTestKeys(t, db) }) svc := NewService(db, "admin-key") t.Run("creates key with IP restrictions", func(t *testing.T) { resp, err := svc.Create(context.Background(), CreateKeyRequest{ Name: "test-ip-key", Scopes: []Scope{ScopeProjectsRead}, AllowedIPs: []string{"192.168.1.0/24", "10.0.0.1"}, ExpiresIn: 24 * time.Hour, CreatedBy: "test", }) if err != nil { t.Fatalf("Create() error = %v", err) } if len(resp.Key.AllowedIPs) != 2 { t.Errorf("Key.AllowedIPs length = %d, want 2", len(resp.Key.AllowedIPs)) } // Verify via Get key, err := svc.Get(context.Background(), resp.Key.ID) if err != nil { t.Fatalf("Get() error = %v", err) } if len(key.AllowedIPs) != 2 { t.Errorf("Retrieved Key.AllowedIPs length = %d, want 2", len(key.AllowedIPs)) } // Verify via Validate validatedKey, err := svc.Validate(context.Background(), resp.Secret) if err != nil { t.Fatalf("Validate() error = %v", err) } if len(validatedKey.AllowedIPs) != 2 { t.Errorf("Validated Key.AllowedIPs length = %d, want 2", len(validatedKey.AllowedIPs)) } // Verify IP checking works if !validatedKey.IsIPAllowed("192.168.1.50") { t.Error("IsIPAllowed should return true for IP in allowed CIDR") } if !validatedKey.IsIPAllowed("10.0.0.1") { t.Error("IsIPAllowed should return true for explicitly allowed IP") } if validatedKey.IsIPAllowed("172.16.0.1") { t.Error("IsIPAllowed should return false for IP not in allowed list") } }) t.Run("creates key with no IP restrictions", func(t *testing.T) { resp, err := svc.Create(context.Background(), CreateKeyRequest{ Name: "test-no-ip-key", Scopes: []Scope{ScopeProjectsRead}, ExpiresIn: 24 * time.Hour, CreatedBy: "test", }) if err != nil { t.Fatalf("Create() error = %v", err) } if len(resp.Key.AllowedIPs) != 0 { t.Errorf("Key.AllowedIPs should be empty, got %v", resp.Key.AllowedIPs) } // Verify via Validate validatedKey, err := svc.Validate(context.Background(), resp.Secret) if err != nil { t.Fatalf("Validate() error = %v", err) } // Any IP should be allowed if !validatedKey.IsIPAllowed("1.2.3.4") { t.Error("IsIPAllowed should return true when no restrictions set") } }) }