package domain_test import ( "testing" "time" "github.com/orchard9/rdev/internal/domain" ) // ============================================================================= // APIKey Tests // ============================================================================= func TestAPIKey_IsExpired(t *testing.T) { tests := []struct { name string expiresAt *time.Time want bool }{ { name: "nil expiration never expires", expiresAt: nil, want: false, }, { name: "future expiration not expired", expiresAt: timePtr(time.Now().Add(time.Hour)), want: false, }, { name: "past expiration is expired", expiresAt: timePtr(time.Now().Add(-time.Hour)), want: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { key := &domain.APIKey{ExpiresAt: tt.expiresAt} if got := key.IsExpired(); got != tt.want { t.Errorf("IsExpired() = %v, want %v", got, tt.want) } }) } } func TestAPIKey_IsRevoked(t *testing.T) { tests := []struct { name string revokedAt *time.Time want bool }{ { name: "nil revocation not revoked", revokedAt: nil, want: false, }, { name: "set revocation is revoked", revokedAt: timePtr(time.Now()), want: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { key := &domain.APIKey{RevokedAt: tt.revokedAt} if got := key.IsRevoked(); got != tt.want { t.Errorf("IsRevoked() = %v, want %v", got, tt.want) } }) } } func TestAPIKey_IsActive(t *testing.T) { now := time.Now() tests := []struct { name string expiresAt *time.Time revokedAt *time.Time want bool }{ { name: "active when no expiration and not revoked", expiresAt: nil, revokedAt: nil, want: true, }, { name: "active when future expiration and not revoked", expiresAt: timePtr(now.Add(time.Hour)), revokedAt: nil, want: true, }, { name: "inactive when expired", expiresAt: timePtr(now.Add(-time.Hour)), revokedAt: nil, want: false, }, { name: "inactive when revoked", expiresAt: nil, revokedAt: timePtr(now), want: false, }, { name: "inactive when both expired and revoked", expiresAt: timePtr(now.Add(-time.Hour)), revokedAt: timePtr(now), want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { key := &domain.APIKey{ ExpiresAt: tt.expiresAt, RevokedAt: tt.revokedAt, } if got := key.IsActive(); got != tt.want { t.Errorf("IsActive() = %v, want %v", got, tt.want) } }) } } func TestAPIKey_HasScope(t *testing.T) { tests := []struct { name string scopes []domain.Scope check domain.Scope want bool }{ { name: "empty scopes has nothing", scopes: []domain.Scope{}, check: domain.ScopeProjectsRead, want: false, }, { name: "exact match", scopes: []domain.Scope{domain.ScopeProjectsRead}, check: domain.ScopeProjectsRead, want: true, }, { name: "no match", scopes: []domain.Scope{domain.ScopeProjectsRead}, check: domain.ScopeProjectsExecute, want: false, }, { name: "admin grants any scope", scopes: []domain.Scope{domain.ScopeAdmin}, check: domain.ScopeProjectsExecute, want: true, }, { name: "multiple scopes match", scopes: []domain.Scope{domain.ScopeProjectsRead, domain.ScopeProjectsExecute}, check: domain.ScopeProjectsExecute, want: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { key := &domain.APIKey{Scopes: tt.scopes} if got := key.HasScope(tt.check); got != tt.want { t.Errorf("HasScope(%v) = %v, want %v", tt.check, got, tt.want) } }) } } func TestAPIKey_HasAnyScope(t *testing.T) { tests := []struct { name string scopes []domain.Scope check []domain.Scope want bool }{ { name: "empty check returns false", scopes: []domain.Scope{domain.ScopeProjectsRead}, check: []domain.Scope{}, want: false, }, { name: "matches first scope", scopes: []domain.Scope{domain.ScopeProjectsRead}, check: []domain.Scope{domain.ScopeProjectsRead, domain.ScopeProjectsExecute}, want: true, }, { name: "matches second scope", scopes: []domain.Scope{domain.ScopeProjectsExecute}, check: []domain.Scope{domain.ScopeProjectsRead, domain.ScopeProjectsExecute}, want: true, }, { name: "no match", scopes: []domain.Scope{domain.ScopeKeysWrite}, check: []domain.Scope{domain.ScopeProjectsRead, domain.ScopeProjectsExecute}, want: false, }, { name: "admin matches any", scopes: []domain.Scope{domain.ScopeAdmin}, check: []domain.Scope{domain.ScopeProjectsRead}, want: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { key := &domain.APIKey{Scopes: tt.scopes} if got := key.HasAnyScope(tt.check...); got != tt.want { t.Errorf("HasAnyScope(%v) = %v, want %v", tt.check, got, tt.want) } }) } } func TestAPIKey_HasProjectAccess(t *testing.T) { tests := []struct { name string scopes []domain.Scope projectIDs []domain.ProjectID checkID domain.ProjectID want bool }{ { name: "nil project list grants all access", scopes: []domain.Scope{domain.ScopeProjectsRead}, projectIDs: nil, checkID: "proj-1", want: true, }, { name: "admin grants all access", scopes: []domain.Scope{domain.ScopeAdmin}, projectIDs: []domain.ProjectID{"proj-1"}, checkID: "proj-2", want: true, }, { name: "explicit project in list", scopes: []domain.Scope{domain.ScopeProjectsRead}, projectIDs: []domain.ProjectID{"proj-1", "proj-2"}, checkID: "proj-1", want: true, }, { name: "project not in list", scopes: []domain.Scope{domain.ScopeProjectsRead}, projectIDs: []domain.ProjectID{"proj-1", "proj-2"}, checkID: "proj-3", want: false, }, { name: "empty project list denies all", scopes: []domain.Scope{domain.ScopeProjectsRead}, projectIDs: []domain.ProjectID{}, checkID: "proj-1", want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { key := &domain.APIKey{ Scopes: tt.scopes, ProjectIDs: tt.projectIDs, } if got := key.HasProjectAccess(tt.checkID); got != tt.want { t.Errorf("HasProjectAccess(%v) = %v, want %v", tt.checkID, got, tt.want) } }) } } func TestAPIKey_IsIPAllowed(t *testing.T) { tests := []struct { name string allowedIPs []string clientIP string want bool }{ { name: "nil allowed IPs allows all", allowedIPs: nil, clientIP: "192.168.1.100", want: true, }, { name: "empty allowed IPs allows all", allowedIPs: []string{}, clientIP: "192.168.1.100", want: true, }, { name: "exact IP match", allowedIPs: []string{"192.168.1.100"}, clientIP: "192.168.1.100", want: true, }, { name: "exact 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.100", want: true, }, { name: "CIDR no match", allowedIPs: []string{"192.168.1.0/24"}, clientIP: "192.168.2.100", want: false, }, { name: "multiple CIDRs first match", allowedIPs: []string{"10.0.0.0/8", "192.168.0.0/16"}, clientIP: "10.1.2.3", want: true, }, { name: "multiple CIDRs second match", allowedIPs: []string{"10.0.0.0/8", "192.168.0.0/16"}, clientIP: "192.168.5.10", want: true, }, { name: "multiple CIDRs no match", allowedIPs: []string{"10.0.0.0/8", "192.168.0.0/16"}, clientIP: "172.16.0.1", want: false, }, { name: "IPv6 CIDR match", allowedIPs: []string{"2001:db8::/32"}, clientIP: "2001:db8::1", want: true, }, { name: "IPv6 CIDR no match", allowedIPs: []string{"2001:db8::/32"}, clientIP: "2001:db9::1", want: false, }, { name: "mixed IPv4 and IPv6", allowedIPs: []string{"192.168.1.0/24", "2001:db8::/32"}, clientIP: "2001:db8::100", want: true, }, { name: "invalid client IP", allowedIPs: []string{"192.168.1.0/24"}, clientIP: "not-an-ip", want: false, }, { name: "single IP with /32 CIDR", allowedIPs: []string{"192.168.1.100/32"}, clientIP: "192.168.1.100", want: true, }, { name: "single IP with /32 CIDR no match", allowedIPs: []string{"192.168.1.100/32"}, clientIP: "192.168.1.101", want: false, }, { name: "localhost IPv4", allowedIPs: []string{"127.0.0.1"}, clientIP: "127.0.0.1", want: true, }, { name: "localhost IPv6", allowedIPs: []string{"::1"}, clientIP: "::1", want: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { key := &domain.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) } }) } } // ============================================================================= // Helpers // ============================================================================= func timePtr(t time.Time) *time.Time { return &t }