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>
664 lines
16 KiB
Go
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
|
|
}
|