package domain_test import ( "errors" "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.ScopeKeysManage}, 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) } }) } } // ============================================================================= // CommandResult Tests // ============================================================================= func TestCommandResult_Success(t *testing.T) { tests := []struct { name string exitCode int err error want bool }{ { name: "success with zero exit code and no error", exitCode: 0, err: nil, want: true, }, { name: "failure with non-zero exit code", exitCode: 1, err: nil, want: false, }, { name: "failure with error", exitCode: 0, err: errors.New("execution failed"), want: false, }, { name: "failure with both error and non-zero exit", exitCode: 127, err: errors.New("command not found"), want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := &domain.CommandResult{ ExitCode: tt.exitCode, Error: tt.err, } if got := result.Success(); got != tt.want { t.Errorf("Success() = %v, want %v", got, tt.want) } }) } } // ============================================================================= // ProjectStatus Tests // ============================================================================= func TestProjectStatus_IsAvailable(t *testing.T) { tests := []struct { status domain.ProjectStatus want bool }{ {domain.ProjectStatusRunning, true}, {domain.ProjectStatusPending, false}, {domain.ProjectStatusFailed, false}, {domain.ProjectStatusNotFound, false}, {domain.ProjectStatusUnknown, false}, {domain.ProjectStatusError, false}, } for _, tt := range tests { t.Run(string(tt.status), func(t *testing.T) { if got := tt.status.IsAvailable(); got != tt.want { t.Errorf("ProjectStatus(%q).IsAvailable() = %v, want %v", tt.status, got, tt.want) } }) } } func TestProjectStatus_IsTerminal(t *testing.T) { tests := []struct { status domain.ProjectStatus want bool }{ {domain.ProjectStatusRunning, false}, {domain.ProjectStatusPending, false}, {domain.ProjectStatusFailed, true}, {domain.ProjectStatusNotFound, true}, {domain.ProjectStatusUnknown, false}, {domain.ProjectStatusError, false}, } for _, tt := range tests { t.Run(string(tt.status), func(t *testing.T) { if got := tt.status.IsTerminal(); got != tt.want { t.Errorf("ProjectStatus(%q).IsTerminal() = %v, want %v", tt.status, got, tt.want) } }) } } // ============================================================================= // Error Variables Tests // ============================================================================= func TestErrorVariables_AreDistinct(t *testing.T) { // Verify all domain errors are distinct and can be matched with errors.Is allErrors := []error{ domain.ErrProjectNotFound, domain.ErrProjectNotRunning, domain.ErrCommandNotFound, domain.ErrCommandTimeout, domain.ErrCommandCancelled, domain.ErrLimitExceeded, domain.ErrInvalidCommand, domain.ErrCommandSanitization, domain.ErrKeyNotFound, domain.ErrKeyRevoked, domain.ErrKeyExpired, domain.ErrKeyInvalid, domain.ErrUnauthorized, domain.ErrForbidden, domain.ErrInsufficientScope, domain.ErrRateLimited, domain.ErrDatabaseConnection, domain.ErrKubernetesError, } // Each error should only match itself for i, err1 := range allErrors { for j, err2 := range allErrors { if i == j { if !errors.Is(err1, err2) { t.Errorf("error %v should match itself", err1) } } else { if errors.Is(err1, err2) { t.Errorf("error %v should not match %v", err1, err2) } } } } } func TestErrorVariables_CanBeWrapped(t *testing.T) { // Domain errors should be usable as base errors for wrapping wrapped := errors.Join(domain.ErrProjectNotFound, errors.New("additional context")) if !errors.Is(wrapped, domain.ErrProjectNotFound) { t.Error("wrapped error should match base domain error") } } // ============================================================================= // Type Constants Tests // ============================================================================= func TestScopeConstants(t *testing.T) { // Verify scope constants have expected values (for documentation) expectedScopes := map[domain.Scope]string{ domain.ScopeAdmin: "admin", domain.ScopeProjectsRead: "projects:read", domain.ScopeProjectsExecute: "projects:execute", domain.ScopeKeysManage: "keys:manage", } for scope, expected := range expectedScopes { if string(scope) != expected { t.Errorf("Scope %v = %q, want %q", scope, string(scope), expected) } } } func TestCommandTypeConstants(t *testing.T) { expectedTypes := map[domain.CommandType]string{ domain.CommandTypeClaude: "claude", domain.CommandTypeShell: "shell", domain.CommandTypeGit: "git", } for cmdType, expected := range expectedTypes { if string(cmdType) != expected { t.Errorf("CommandType %v = %q, want %q", cmdType, string(cmdType), expected) } } } func TestProjectStatusConstants(t *testing.T) { expectedStatuses := map[domain.ProjectStatus]string{ domain.ProjectStatusRunning: "running", domain.ProjectStatusPending: "pending", domain.ProjectStatusFailed: "failed", domain.ProjectStatusNotFound: "not_found", domain.ProjectStatusUnknown: "unknown", domain.ProjectStatusError: "error", } for status, expected := range expectedStatuses { if string(status) != expected { t.Errorf("ProjectStatus %v = %q, want %q", status, string(status), expected) } } } // ============================================================================= // Type Instantiation Tests // ============================================================================= func TestProject_CanBeInstantiated(t *testing.T) { proj := domain.Project{ ID: "test-project", Name: "Test Project", Description: "A test project", PodName: "test-pod", Status: domain.ProjectStatusRunning, Workspace: "/workspace", } if proj.ID != "test-project" { t.Errorf("Project.ID = %q, want %q", proj.ID, "test-project") } } func TestCommand_CanBeInstantiated(t *testing.T) { now := time.Now() cmd := domain.Command{ ID: "cmd-1", ProjectID: "proj-1", Type: domain.CommandTypeClaude, Args: []string{"--version"}, StartedAt: now, } if cmd.ID != "cmd-1" { t.Errorf("Command.ID = %q, want %q", cmd.ID, "cmd-1") } if len(cmd.Args) != 1 { t.Errorf("Command.Args length = %d, want 1", len(cmd.Args)) } } func TestOutputLine_CanBeInstantiated(t *testing.T) { now := time.Now() line := domain.OutputLine{ Stream: "stdout", Line: "Hello, world!", Timestamp: now, } if line.Stream != "stdout" { t.Errorf("OutputLine.Stream = %q, want %q", line.Stream, "stdout") } } // ============================================================================= // Helpers // ============================================================================= func timePtr(t time.Time) *time.Time { return &t }