- 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>
341 lines
8.6 KiB
Go
341 lines
8.6 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
)
|
|
|
|
// MockAPIKeyRepository implements port.APIKeyRepository for testing.
|
|
type MockAPIKeyRepository struct {
|
|
keys map[domain.APIKeyID]*domain.APIKey
|
|
keysByHash map[string]*domain.APIKey
|
|
createErr error
|
|
lastUsedCalls int
|
|
lastUsedErr error
|
|
}
|
|
|
|
func NewMockAPIKeyRepository() *MockAPIKeyRepository {
|
|
return &MockAPIKeyRepository{
|
|
keys: make(map[domain.APIKeyID]*domain.APIKey),
|
|
keysByHash: make(map[string]*domain.APIKey),
|
|
}
|
|
}
|
|
|
|
func (m *MockAPIKeyRepository) Create(ctx context.Context, key *domain.APIKey, keyHash string) error {
|
|
if m.createErr != nil {
|
|
return m.createErr
|
|
}
|
|
key.ID = domain.APIKeyID("key-" + key.Name)
|
|
key.CreatedAt = time.Now()
|
|
m.keys[key.ID] = key
|
|
m.keysByHash[keyHash] = key
|
|
return nil
|
|
}
|
|
|
|
func (m *MockAPIKeyRepository) GetByHash(ctx context.Context, keyHash string) (*domain.APIKey, error) {
|
|
key, ok := m.keysByHash[keyHash]
|
|
if !ok {
|
|
return nil, domain.ErrKeyNotFound
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
func (m *MockAPIKeyRepository) Get(ctx context.Context, id domain.APIKeyID) (*domain.APIKey, error) {
|
|
key, ok := m.keys[id]
|
|
if !ok {
|
|
return nil, domain.ErrKeyNotFound
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
func (m *MockAPIKeyRepository) List(ctx context.Context) ([]*domain.APIKey, error) {
|
|
result := make([]*domain.APIKey, 0, len(m.keys))
|
|
for _, k := range m.keys {
|
|
result = append(result, k)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (m *MockAPIKeyRepository) Revoke(ctx context.Context, id domain.APIKeyID) error {
|
|
key, ok := m.keys[id]
|
|
if !ok {
|
|
return domain.ErrKeyNotFound
|
|
}
|
|
now := time.Now()
|
|
key.RevokedAt = &now
|
|
return nil
|
|
}
|
|
|
|
func (m *MockAPIKeyRepository) UpdateLastUsed(ctx context.Context, id domain.APIKeyID) error {
|
|
m.lastUsedCalls++
|
|
if m.lastUsedErr != nil {
|
|
return m.lastUsedErr
|
|
}
|
|
key, ok := m.keys[id]
|
|
if !ok {
|
|
return domain.ErrKeyNotFound
|
|
}
|
|
now := time.Now()
|
|
key.LastUsedAt = &now
|
|
return nil
|
|
}
|
|
|
|
func TestAPIKeyService_Create(t *testing.T) {
|
|
repo := NewMockAPIKeyRepository()
|
|
svc := NewAPIKeyService(repo, "admin-secret")
|
|
|
|
t.Run("creates key successfully", func(t *testing.T) {
|
|
result, err := svc.Create(context.Background(), CreateKeyRequest{
|
|
Name: "test-key",
|
|
Scopes: []domain.Scope{domain.ScopeProjectsRead},
|
|
ExpiresIn: 24 * time.Hour,
|
|
CreatedBy: "test-user",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Create() error = %v", err)
|
|
}
|
|
|
|
if result.Key.Name != "test-key" {
|
|
t.Errorf("Key.Name = %q, want %q", result.Key.Name, "test-key")
|
|
}
|
|
|
|
if result.Secret == "" {
|
|
t.Error("Secret should not be empty")
|
|
}
|
|
|
|
if len(result.Key.KeyPrefix) != 8 {
|
|
t.Errorf("KeyPrefix length = %d, want 8", len(result.Key.KeyPrefix))
|
|
}
|
|
|
|
if result.Key.ExpiresAt == nil {
|
|
t.Error("ExpiresAt should be set")
|
|
}
|
|
})
|
|
|
|
t.Run("creates key without expiration", func(t *testing.T) {
|
|
result, err := svc.Create(context.Background(), CreateKeyRequest{
|
|
Name: "never-expires",
|
|
Scopes: []domain.Scope{domain.ScopeAdmin},
|
|
CreatedBy: "admin",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Create() error = %v", err)
|
|
}
|
|
|
|
if result.Key.ExpiresAt != nil {
|
|
t.Error("ExpiresAt should be nil for keys without expiration")
|
|
}
|
|
})
|
|
|
|
t.Run("creates key with project restrictions", func(t *testing.T) {
|
|
result, err := svc.Create(context.Background(), CreateKeyRequest{
|
|
Name: "restricted-key",
|
|
Scopes: []domain.Scope{domain.ScopeProjectsRead},
|
|
ProjectIDs: []domain.ProjectID{"proj-a", "proj-b"},
|
|
CreatedBy: "test",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Create() error = %v", err)
|
|
}
|
|
|
|
if len(result.Key.ProjectIDs) != 2 {
|
|
t.Errorf("ProjectIDs length = %d, want 2", len(result.Key.ProjectIDs))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAPIKeyService_Get(t *testing.T) {
|
|
repo := NewMockAPIKeyRepository()
|
|
svc := NewAPIKeyService(repo, "admin-secret")
|
|
|
|
// Create a key first
|
|
createResult, _ := svc.Create(context.Background(), CreateKeyRequest{
|
|
Name: "get-test",
|
|
Scopes: []domain.Scope{domain.ScopeProjectsRead},
|
|
CreatedBy: "test",
|
|
})
|
|
|
|
t.Run("gets existing key", func(t *testing.T) {
|
|
key, err := svc.Get(context.Background(), createResult.Key.ID)
|
|
if err != nil {
|
|
t.Fatalf("Get() error = %v", err)
|
|
}
|
|
|
|
if key.Name != "get-test" {
|
|
t.Errorf("Name = %q, want %q", key.Name, "get-test")
|
|
}
|
|
})
|
|
|
|
t.Run("returns error for nonexistent key", func(t *testing.T) {
|
|
_, err := svc.Get(context.Background(), "nonexistent")
|
|
if err != domain.ErrKeyNotFound {
|
|
t.Errorf("Get() error = %v, want %v", err, domain.ErrKeyNotFound)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAPIKeyService_List(t *testing.T) {
|
|
repo := NewMockAPIKeyRepository()
|
|
svc := NewAPIKeyService(repo, "admin-secret")
|
|
|
|
// Create some keys
|
|
for i := 0; i < 3; i++ {
|
|
_, _ = svc.Create(context.Background(), CreateKeyRequest{
|
|
Name: "list-key-" + string(rune('a'+i)),
|
|
Scopes: []domain.Scope{domain.ScopeProjectsRead},
|
|
CreatedBy: "test",
|
|
})
|
|
}
|
|
|
|
keys, err := svc.List(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("List() error = %v", err)
|
|
}
|
|
|
|
if len(keys) != 3 {
|
|
t.Errorf("List() returned %d keys, want 3", len(keys))
|
|
}
|
|
}
|
|
|
|
func TestAPIKeyService_Revoke(t *testing.T) {
|
|
repo := NewMockAPIKeyRepository()
|
|
svc := NewAPIKeyService(repo, "admin-secret")
|
|
|
|
// Create a key
|
|
createResult, _ := svc.Create(context.Background(), CreateKeyRequest{
|
|
Name: "revoke-test",
|
|
Scopes: []domain.Scope{domain.ScopeProjectsRead},
|
|
CreatedBy: "test",
|
|
})
|
|
|
|
t.Run("revokes existing key", func(t *testing.T) {
|
|
err := svc.Revoke(context.Background(), createResult.Key.ID)
|
|
if err != nil {
|
|
t.Fatalf("Revoke() error = %v", err)
|
|
}
|
|
|
|
// Verify revoked
|
|
key, _ := svc.Get(context.Background(), createResult.Key.ID)
|
|
if key.RevokedAt == nil {
|
|
t.Error("RevokedAt should be set after revoke")
|
|
}
|
|
})
|
|
|
|
t.Run("returns error for nonexistent key", func(t *testing.T) {
|
|
err := svc.Revoke(context.Background(), "nonexistent")
|
|
if err != domain.ErrKeyNotFound {
|
|
t.Errorf("Revoke() error = %v, want %v", err, domain.ErrKeyNotFound)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAPIKeyService_UpdateLastUsed(t *testing.T) {
|
|
repo := NewMockAPIKeyRepository()
|
|
svc := NewAPIKeyService(repo, "admin-secret")
|
|
|
|
// Create a key
|
|
createResult, _ := svc.Create(context.Background(), CreateKeyRequest{
|
|
Name: "last-used-test",
|
|
Scopes: []domain.Scope{domain.ScopeProjectsRead},
|
|
CreatedBy: "test",
|
|
})
|
|
|
|
err := svc.UpdateLastUsed(context.Background(), createResult.Key.ID)
|
|
if err != nil {
|
|
t.Fatalf("UpdateLastUsed() error = %v", err)
|
|
}
|
|
|
|
// Verify updated
|
|
key, _ := svc.Get(context.Background(), createResult.Key.ID)
|
|
if key.LastUsedAt == nil {
|
|
t.Error("LastUsedAt should be set after update")
|
|
}
|
|
}
|
|
|
|
func TestAPIKeyService_ValidateAdminKey(t *testing.T) {
|
|
svc := NewAPIKeyService(nil, "super-secret-admin")
|
|
|
|
tests := []struct {
|
|
key string
|
|
want bool
|
|
}{
|
|
{"super-secret-admin", true},
|
|
{"wrong-key", false},
|
|
{"", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
if got := svc.ValidateAdminKey(tt.key); got != tt.want {
|
|
t.Errorf("ValidateAdminKey(%q) = %v, want %v", tt.key, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAPIKeyService_ValidateAdminKey_NoAdmin(t *testing.T) {
|
|
svc := NewAPIKeyService(nil, "")
|
|
|
|
// When no admin key is set, validation should always fail
|
|
if svc.ValidateAdminKey("anything") {
|
|
t.Error("ValidateAdminKey should return false when no admin key is set")
|
|
}
|
|
}
|
|
|
|
func TestAPIKeyService_AdminKey(t *testing.T) {
|
|
svc := NewAPIKeyService(nil, "my-admin-key")
|
|
|
|
if got := svc.AdminKey(); got != "my-admin-key" {
|
|
t.Errorf("AdminKey() = %q, want %q", got, "my-admin-key")
|
|
}
|
|
}
|
|
|
|
func TestParseExpiration(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want time.Duration
|
|
wantErr bool
|
|
}{
|
|
{"", 0, false},
|
|
{"never", 0, false},
|
|
{"30d", 30 * 24 * time.Hour, false},
|
|
{"60d", 60 * 24 * time.Hour, false},
|
|
{"90d", 90 * 24 * time.Hour, false},
|
|
{"1y", 365 * 24 * time.Hour, false},
|
|
{"invalid", 0, true},
|
|
{"10d", 0, true}, // Not a supported format
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got, err := ParseExpiration(tt.input)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("ParseExpiration(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
|
continue
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("ParseExpiration(%q) = %v, want %v", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHashKey(t *testing.T) {
|
|
// Same input should produce same hash
|
|
hash1 := hashKey("test-key")
|
|
hash2 := hashKey("test-key")
|
|
if hash1 != hash2 {
|
|
t.Error("Same input should produce same hash")
|
|
}
|
|
|
|
// Different input should produce different hash
|
|
hash3 := hashKey("different-key")
|
|
if hash1 == hash3 {
|
|
t.Error("Different input should produce different hash")
|
|
}
|
|
|
|
// Hash should be 64 hex characters (SHA-256)
|
|
if len(hash1) != 64 {
|
|
t.Errorf("Hash length = %d, want 64", len(hash1))
|
|
}
|
|
}
|