- 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>
438 lines
11 KiB
Go
438 lines
11 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/testutil"
|
|
)
|
|
|
|
func hashKey(key string) string {
|
|
h := sha256.Sum256([]byte(key))
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
func TestAPIKeyRepository_Create(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { testutil.CleanupTestKeys(t, db) })
|
|
|
|
repo := NewAPIKeyRepository(db)
|
|
ctx := context.Background()
|
|
|
|
t.Run("creates key with all fields", func(t *testing.T) {
|
|
expires := time.Now().Add(24 * time.Hour)
|
|
key := &domain.APIKey{
|
|
Name: "test-repo-create",
|
|
KeyPrefix: "abc12345",
|
|
Scopes: []domain.Scope{domain.ScopeProjectsRead, domain.ScopeKeysWrite},
|
|
ProjectIDs: []domain.ProjectID{"proj-a", "proj-b"},
|
|
AllowedIPs: []string{"192.168.1.0/24", "10.0.0.1"},
|
|
ExpiresAt: &expires,
|
|
CreatedBy: "test-user",
|
|
}
|
|
keyHash := hashKey("test-key-123")
|
|
|
|
err := repo.Create(ctx, key, keyHash)
|
|
if err != nil {
|
|
t.Fatalf("Create() error = %v", err)
|
|
}
|
|
|
|
if key.ID == "" {
|
|
t.Error("ID should be set after create")
|
|
}
|
|
|
|
// Verify via GetByHash
|
|
retrieved, err := repo.GetByHash(ctx, keyHash)
|
|
if err != nil {
|
|
t.Fatalf("GetByHash() error = %v", err)
|
|
}
|
|
|
|
if retrieved.Name != "test-repo-create" {
|
|
t.Errorf("Name = %q, want %q", retrieved.Name, "test-repo-create")
|
|
}
|
|
if len(retrieved.Scopes) != 2 {
|
|
t.Errorf("Scopes length = %d, want 2", len(retrieved.Scopes))
|
|
}
|
|
if len(retrieved.ProjectIDs) != 2 {
|
|
t.Errorf("ProjectIDs length = %d, want 2", len(retrieved.ProjectIDs))
|
|
}
|
|
if len(retrieved.AllowedIPs) != 2 {
|
|
t.Errorf("AllowedIPs length = %d, want 2", len(retrieved.AllowedIPs))
|
|
}
|
|
})
|
|
|
|
t.Run("creates key with minimal fields", func(t *testing.T) {
|
|
key := &domain.APIKey{
|
|
Name: "test-repo-minimal",
|
|
KeyPrefix: "min12345",
|
|
Scopes: []domain.Scope{domain.ScopeProjectsRead},
|
|
CreatedBy: "test",
|
|
}
|
|
keyHash := hashKey("minimal-key-456")
|
|
|
|
err := repo.Create(ctx, key, keyHash)
|
|
if err != nil {
|
|
t.Fatalf("Create() error = %v", err)
|
|
}
|
|
|
|
retrieved, _ := repo.GetByHash(ctx, keyHash)
|
|
if retrieved.ExpiresAt != nil {
|
|
t.Error("ExpiresAt should be nil for keys without expiration")
|
|
}
|
|
if len(retrieved.ProjectIDs) != 0 {
|
|
t.Error("ProjectIDs should be empty")
|
|
}
|
|
if len(retrieved.AllowedIPs) != 0 {
|
|
t.Error("AllowedIPs should be empty")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAPIKeyRepository_GetByHash(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { testutil.CleanupTestKeys(t, db) })
|
|
|
|
repo := NewAPIKeyRepository(db)
|
|
ctx := context.Background()
|
|
|
|
// Create a test key
|
|
keyHash := hashKey("get-by-hash-key")
|
|
key := &domain.APIKey{
|
|
Name: "test-get-hash",
|
|
KeyPrefix: "geth1234",
|
|
Scopes: []domain.Scope{domain.ScopeAdmin},
|
|
CreatedBy: "test",
|
|
}
|
|
_ = repo.Create(ctx, key, keyHash)
|
|
|
|
t.Run("finds existing key", func(t *testing.T) {
|
|
retrieved, err := repo.GetByHash(ctx, keyHash)
|
|
if err != nil {
|
|
t.Fatalf("GetByHash() error = %v", err)
|
|
}
|
|
if retrieved.Name != "test-get-hash" {
|
|
t.Errorf("Name = %q, want %q", retrieved.Name, "test-get-hash")
|
|
}
|
|
})
|
|
|
|
t.Run("returns error for nonexistent hash", func(t *testing.T) {
|
|
_, err := repo.GetByHash(ctx, hashKey("nonexistent"))
|
|
if err != domain.ErrKeyNotFound {
|
|
t.Errorf("GetByHash() error = %v, want %v", err, domain.ErrKeyNotFound)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAPIKeyRepository_Get(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { testutil.CleanupTestKeys(t, db) })
|
|
|
|
repo := NewAPIKeyRepository(db)
|
|
ctx := context.Background()
|
|
|
|
// Create a test key
|
|
key := &domain.APIKey{
|
|
Name: "test-get-by-id",
|
|
KeyPrefix: "getid123",
|
|
Scopes: []domain.Scope{domain.ScopeProjectsRead},
|
|
CreatedBy: "test",
|
|
}
|
|
_ = repo.Create(ctx, key, hashKey("get-by-id-key"))
|
|
|
|
t.Run("finds existing key", func(t *testing.T) {
|
|
retrieved, err := repo.Get(ctx, key.ID)
|
|
if err != nil {
|
|
t.Fatalf("Get() error = %v", err)
|
|
}
|
|
if retrieved.Name != "test-get-by-id" {
|
|
t.Errorf("Name = %q, want %q", retrieved.Name, "test-get-by-id")
|
|
}
|
|
})
|
|
|
|
t.Run("returns error for nonexistent ID", func(t *testing.T) {
|
|
_, err := repo.Get(ctx, "00000000-0000-0000-0000-000000000000")
|
|
if err != domain.ErrKeyNotFound {
|
|
t.Errorf("Get() error = %v, want %v", err, domain.ErrKeyNotFound)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAPIKeyRepository_List(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { testutil.CleanupTestKeys(t, db) })
|
|
|
|
repo := NewAPIKeyRepository(db)
|
|
ctx := context.Background()
|
|
|
|
// Create test keys
|
|
for i := 0; i < 3; i++ {
|
|
key := &domain.APIKey{
|
|
Name: "test-list-" + string(rune('a'+i)),
|
|
KeyPrefix: "list1234",
|
|
Scopes: []domain.Scope{domain.ScopeProjectsRead},
|
|
CreatedBy: "test",
|
|
}
|
|
_ = repo.Create(ctx, key, hashKey("list-key-"+string(rune('a'+i))))
|
|
}
|
|
|
|
keys, err := repo.List(ctx)
|
|
if err != nil {
|
|
t.Fatalf("List() error = %v", err)
|
|
}
|
|
|
|
// Count our test keys
|
|
testKeyCount := 0
|
|
for _, k := range keys {
|
|
if len(k.Name) >= 10 && k.Name[:10] == "test-list-" {
|
|
testKeyCount++
|
|
}
|
|
}
|
|
|
|
if testKeyCount != 3 {
|
|
t.Errorf("List() returned %d test keys, want 3", testKeyCount)
|
|
}
|
|
}
|
|
|
|
func TestAPIKeyRepository_Revoke(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { testutil.CleanupTestKeys(t, db) })
|
|
|
|
repo := NewAPIKeyRepository(db)
|
|
ctx := context.Background()
|
|
|
|
t.Run("revokes existing key", func(t *testing.T) {
|
|
key := &domain.APIKey{
|
|
Name: "test-revoke",
|
|
KeyPrefix: "rev12345",
|
|
Scopes: []domain.Scope{domain.ScopeProjectsRead},
|
|
CreatedBy: "test",
|
|
}
|
|
keyHash := hashKey("revoke-key")
|
|
repo.Create(ctx, key, keyHash)
|
|
|
|
err := repo.Revoke(ctx, key.ID)
|
|
if err != nil {
|
|
t.Fatalf("Revoke() error = %v", err)
|
|
}
|
|
|
|
// Verify revoked
|
|
retrieved, _ := repo.Get(ctx, key.ID)
|
|
if retrieved.RevokedAt == nil {
|
|
t.Error("RevokedAt should be set after revoke")
|
|
}
|
|
})
|
|
|
|
t.Run("returns error for nonexistent key", func(t *testing.T) {
|
|
err := repo.Revoke(ctx, "00000000-0000-0000-0000-000000000000")
|
|
if err != domain.ErrKeyNotFound {
|
|
t.Errorf("Revoke() error = %v, want %v", err, domain.ErrKeyNotFound)
|
|
}
|
|
})
|
|
|
|
t.Run("returns error for already revoked key", func(t *testing.T) {
|
|
key := &domain.APIKey{
|
|
Name: "test-revoke-twice",
|
|
KeyPrefix: "rev21234",
|
|
Scopes: []domain.Scope{domain.ScopeProjectsRead},
|
|
CreatedBy: "test",
|
|
}
|
|
_ = repo.Create(ctx, key, hashKey("revoke-twice-key"))
|
|
|
|
// First revoke
|
|
_ = repo.Revoke(ctx, key.ID)
|
|
|
|
// Second revoke should fail
|
|
err := repo.Revoke(ctx, key.ID)
|
|
if err != domain.ErrKeyNotFound {
|
|
t.Errorf("Second Revoke() error = %v, want %v", err, domain.ErrKeyNotFound)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAPIKeyRepository_UpdateLastUsed(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { testutil.CleanupTestKeys(t, db) })
|
|
|
|
repo := NewAPIKeyRepository(db)
|
|
ctx := context.Background()
|
|
|
|
key := &domain.APIKey{
|
|
Name: "test-last-used",
|
|
KeyPrefix: "lu123456",
|
|
Scopes: []domain.Scope{domain.ScopeProjectsRead},
|
|
CreatedBy: "test",
|
|
}
|
|
repo.Create(ctx, key, hashKey("last-used-key"))
|
|
|
|
// Initial state - no last_used_at
|
|
retrieved, _ := repo.Get(ctx, key.ID)
|
|
if retrieved.LastUsedAt != nil {
|
|
t.Error("LastUsedAt should be nil initially")
|
|
}
|
|
|
|
// Update last used
|
|
err := repo.UpdateLastUsed(ctx, key.ID)
|
|
if err != nil {
|
|
t.Fatalf("UpdateLastUsed() error = %v", err)
|
|
}
|
|
|
|
// Verify updated
|
|
retrieved, _ = repo.Get(ctx, key.ID)
|
|
if retrieved.LastUsedAt == nil {
|
|
t.Error("LastUsedAt should be set after update")
|
|
}
|
|
if time.Since(*retrieved.LastUsedAt) > time.Minute {
|
|
t.Error("LastUsedAt should be recent")
|
|
}
|
|
}
|
|
|
|
func TestAPIKeyRepository_ScopeArrayHandling(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { testutil.CleanupTestKeys(t, db) })
|
|
|
|
repo := NewAPIKeyRepository(db)
|
|
ctx := context.Background()
|
|
|
|
tests := []struct {
|
|
name string
|
|
scopes []domain.Scope
|
|
}{
|
|
{"single scope", []domain.Scope{domain.ScopeProjectsRead}},
|
|
{"multiple scopes", []domain.Scope{domain.ScopeProjectsRead, domain.ScopeProjectsExecute, domain.ScopeKeysWrite}},
|
|
{"admin scope", []domain.Scope{domain.ScopeAdmin}},
|
|
{"all scopes", []domain.Scope{domain.ScopeProjectsRead, domain.ScopeProjectsExecute, domain.ScopeKeysRead, domain.ScopeKeysWrite, domain.ScopeAdmin}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
key := &domain.APIKey{
|
|
Name: "test-scopes-" + tt.name,
|
|
KeyPrefix: "sc123456",
|
|
Scopes: tt.scopes,
|
|
CreatedBy: "test",
|
|
}
|
|
repo.Create(ctx, key, hashKey("scopes-"+tt.name))
|
|
|
|
retrieved, _ := repo.Get(ctx, key.ID)
|
|
if len(retrieved.Scopes) != len(tt.scopes) {
|
|
t.Errorf("Scopes length = %d, want %d", len(retrieved.Scopes), len(tt.scopes))
|
|
}
|
|
|
|
// Verify each scope
|
|
for _, expected := range tt.scopes {
|
|
found := false
|
|
for _, actual := range retrieved.Scopes {
|
|
if actual == expected {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("Missing scope: %q", expected)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAPIKeyRepository_ProjectIDArrayHandling(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { testutil.CleanupTestKeys(t, db) })
|
|
|
|
repo := NewAPIKeyRepository(db)
|
|
ctx := context.Background()
|
|
|
|
tests := []struct {
|
|
name string
|
|
projectIDs []domain.ProjectID
|
|
}{
|
|
{"nil projects", nil},
|
|
{"empty projects", []domain.ProjectID{}},
|
|
{"single project", []domain.ProjectID{"proj-a"}},
|
|
{"multiple projects", []domain.ProjectID{"proj-a", "proj-b", "proj-c"}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
key := &domain.APIKey{
|
|
Name: "test-projects-" + tt.name,
|
|
KeyPrefix: "pr123456",
|
|
Scopes: []domain.Scope{domain.ScopeProjectsRead},
|
|
ProjectIDs: tt.projectIDs,
|
|
CreatedBy: "test",
|
|
}
|
|
repo.Create(ctx, key, hashKey("projects-"+tt.name))
|
|
|
|
retrieved, _ := repo.Get(ctx, key.ID)
|
|
|
|
expectedLen := 0
|
|
if tt.projectIDs != nil {
|
|
expectedLen = len(tt.projectIDs)
|
|
}
|
|
|
|
if len(retrieved.ProjectIDs) != expectedLen {
|
|
t.Errorf("ProjectIDs length = %d, want %d", len(retrieved.ProjectIDs), expectedLen)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAPIKeyRepository_AllowedIPsArrayHandling(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { testutil.CleanupTestKeys(t, db) })
|
|
|
|
repo := NewAPIKeyRepository(db)
|
|
ctx := context.Background()
|
|
|
|
tests := []struct {
|
|
name string
|
|
allowedIPs []string
|
|
}{
|
|
{"nil IPs", nil},
|
|
{"empty IPs", []string{}},
|
|
{"single IP", []string{"192.168.1.100"}},
|
|
{"CIDR", []string{"10.0.0.0/8"}},
|
|
{"mixed IPs and CIDRs", []string{"192.168.1.0/24", "10.0.0.1", "2001:db8::/32"}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
key := &domain.APIKey{
|
|
Name: "test-ips-" + tt.name,
|
|
KeyPrefix: "ip123456",
|
|
Scopes: []domain.Scope{domain.ScopeProjectsRead},
|
|
AllowedIPs: tt.allowedIPs,
|
|
CreatedBy: "test",
|
|
}
|
|
if err := repo.Create(ctx, key, hashKey("ips-"+tt.name)); err != nil {
|
|
t.Fatalf("Create() error = %v", err)
|
|
}
|
|
|
|
retrieved, err := repo.Get(ctx, key.ID)
|
|
if err != nil {
|
|
t.Fatalf("Get() error = %v", err)
|
|
}
|
|
|
|
expectedLen := 0
|
|
if tt.allowedIPs != nil {
|
|
expectedLen = len(tt.allowedIPs)
|
|
}
|
|
|
|
if len(retrieved.AllowedIPs) != expectedLen {
|
|
t.Errorf("AllowedIPs length = %d, want %d", len(retrieved.AllowedIPs), expectedLen)
|
|
}
|
|
|
|
// Verify content preserved
|
|
for i, expected := range tt.allowedIPs {
|
|
if i < len(retrieved.AllowedIPs) && retrieved.AllowedIPs[i] != expected {
|
|
t.Errorf("AllowedIPs[%d] = %q, want %q", i, retrieved.AllowedIPs[i], expected)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|