- Add ListPipelines/GetPipeline to CIProvider port with Woodpecker adapter
- Add DNS alias endpoints: GET/POST/DELETE /projects/{id}/domains
- Implement worker executor daemon, build executor, and git operations
- Add build service, worker service, and build audit tracking
- Add worker registry with PostgreSQL adapter and migration
- Add multi-provider code agent interface (Claude Code + OpenCode)
- Add create-and-build combo endpoint
- Update landing-page cookbook to reflect all gaps closed
- Fix tech debt: unified validation, auth scopes, error wrapping, slog patterns
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
412 lines
9.5 KiB
Go
412 lines
9.5 KiB
Go
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
|
|
}
|