rdev/internal/domain/domain_test.go
jordan 72d16929ca feat: Implement hexagonal architecture with services, webhooks, queue, and telemetry
Major refactoring to hexagonal (ports & adapters) architecture:

- Add service layer (apikey_service, project_service) for business logic
- Add webhook system with dispatcher and delivery tracking
- Add command queue with priority-based processing
- Add rate limiting with sliding window algorithm
- Add audit logging for command execution
- Add OpenTelemetry integration (traces, metrics, spans)
- Add circuit breaker for fault tolerance
- Add cached repository wrapper for performance
- Add comprehensive validation package
- Add Kubernetes client integration for pod management
- Add database migrations (allowed_ips, audit_log, rate_limiting, queue, webhooks)
- Add network policy and PodDisruptionBudget for k8s
- Remove legacy executor and projects/registry packages
- Untrack secrets.yaml (now managed via envault)
- Add coverage.out to .gitignore
- Add e2e test infrastructure with docker-compose
- Add comprehensive documentation (API, architecture, operations, plans)
- Add golangci-lint config and pre-commit hook

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:57:46 -07:00

664 lines
16 KiB
Go

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
}