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>
535 lines
15 KiB
Go
535 lines
15 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/testutil"
|
|
)
|
|
|
|
func cleanupTestWebhooks(t *testing.T, db *sql.DB) {
|
|
t.Helper()
|
|
// Clean deliveries first due to foreign key constraint
|
|
_, err := db.Exec("DELETE FROM webhook_deliveries WHERE webhook_id IN (SELECT id FROM webhooks WHERE project_id LIKE 'test-%')")
|
|
if err != nil {
|
|
t.Logf("cleanup test webhook deliveries: %v", err)
|
|
}
|
|
_, err = db.Exec("DELETE FROM webhooks WHERE project_id LIKE 'test-%'")
|
|
if err != nil {
|
|
t.Logf("cleanup test webhooks: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWebhookRepository_Create(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { cleanupTestWebhooks(t, db) })
|
|
|
|
repo := NewWebhookRepository(db)
|
|
ctx := context.Background()
|
|
|
|
t.Run("creates webhook successfully", func(t *testing.T) {
|
|
webhook := &domain.Webhook{
|
|
ID: "wh-test-create-1",
|
|
ProjectID: "test-proj-webhook-1",
|
|
URL: "https://example.com/webhook",
|
|
Secret: "test-secret-123",
|
|
Events: []domain.WebhookEventType{
|
|
domain.WebhookEventCommandStarted,
|
|
domain.WebhookEventCommandCompleted,
|
|
},
|
|
Enabled: true,
|
|
}
|
|
|
|
err := repo.Create(ctx, webhook)
|
|
if err != nil {
|
|
t.Fatalf("Create() error = %v", err)
|
|
}
|
|
|
|
if webhook.CreatedAt.IsZero() {
|
|
t.Error("CreatedAt should be set after create")
|
|
}
|
|
if webhook.UpdatedAt.IsZero() {
|
|
t.Error("UpdatedAt should be set after create")
|
|
}
|
|
})
|
|
|
|
t.Run("creates webhook without secret", func(t *testing.T) {
|
|
webhook := &domain.Webhook{
|
|
ID: "wh-test-create-nosecret",
|
|
ProjectID: "test-proj-webhook-2",
|
|
URL: "https://example.com/webhook2",
|
|
Secret: "",
|
|
Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted},
|
|
Enabled: true,
|
|
}
|
|
|
|
err := repo.Create(ctx, webhook)
|
|
if err != nil {
|
|
t.Fatalf("Create() error = %v", err)
|
|
}
|
|
|
|
// Retrieve and verify secret is empty
|
|
retrieved, _ := repo.GetByID(ctx, "wh-test-create-nosecret")
|
|
if retrieved.Secret != "" {
|
|
t.Errorf("Secret = %q, want empty", retrieved.Secret)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestWebhookRepository_Update(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { cleanupTestWebhooks(t, db) })
|
|
|
|
repo := NewWebhookRepository(db)
|
|
ctx := context.Background()
|
|
|
|
t.Run("updates existing webhook", func(t *testing.T) {
|
|
// Create webhook first
|
|
webhook := &domain.Webhook{
|
|
ID: "wh-test-update-1",
|
|
ProjectID: "test-proj-update",
|
|
URL: "https://example.com/original",
|
|
Secret: "original-secret",
|
|
Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted},
|
|
Enabled: true,
|
|
}
|
|
repo.Create(ctx, webhook)
|
|
|
|
// Update it
|
|
webhook.URL = "https://example.com/updated"
|
|
webhook.Secret = "updated-secret"
|
|
webhook.Enabled = false
|
|
webhook.Events = []domain.WebhookEventType{
|
|
domain.WebhookEventCommandCompleted,
|
|
domain.WebhookEventCommandFailed,
|
|
}
|
|
|
|
originalUpdatedAt := webhook.UpdatedAt
|
|
time.Sleep(10 * time.Millisecond) // Ensure timestamp changes
|
|
|
|
err := repo.Update(ctx, webhook)
|
|
if err != nil {
|
|
t.Fatalf("Update() error = %v", err)
|
|
}
|
|
|
|
if !webhook.UpdatedAt.After(originalUpdatedAt) {
|
|
t.Error("UpdatedAt should be updated after Update()")
|
|
}
|
|
|
|
// Verify changes
|
|
retrieved, _ := repo.GetByID(ctx, "wh-test-update-1")
|
|
if retrieved.URL != "https://example.com/updated" {
|
|
t.Errorf("URL = %q, want %q", retrieved.URL, "https://example.com/updated")
|
|
}
|
|
if retrieved.Secret != "updated-secret" {
|
|
t.Errorf("Secret = %q, want %q", retrieved.Secret, "updated-secret")
|
|
}
|
|
if retrieved.Enabled {
|
|
t.Error("Enabled should be false after update")
|
|
}
|
|
if len(retrieved.Events) != 2 {
|
|
t.Errorf("Events length = %d, want 2", len(retrieved.Events))
|
|
}
|
|
})
|
|
|
|
t.Run("returns error for non-existent webhook", func(t *testing.T) {
|
|
webhook := &domain.Webhook{
|
|
ID: "wh-nonexistent",
|
|
ProjectID: "test-proj",
|
|
URL: "https://example.com",
|
|
Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted},
|
|
}
|
|
|
|
err := repo.Update(ctx, webhook)
|
|
if err != domain.ErrWebhookNotFound {
|
|
t.Errorf("Update() error = %v, want %v", err, domain.ErrWebhookNotFound)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestWebhookRepository_Delete(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { cleanupTestWebhooks(t, db) })
|
|
|
|
repo := NewWebhookRepository(db)
|
|
ctx := context.Background()
|
|
|
|
t.Run("deletes existing webhook", func(t *testing.T) {
|
|
webhook := &domain.Webhook{
|
|
ID: "wh-test-delete-1",
|
|
ProjectID: "test-proj-delete",
|
|
URL: "https://example.com/delete",
|
|
Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted},
|
|
Enabled: true,
|
|
}
|
|
repo.Create(ctx, webhook)
|
|
|
|
err := repo.Delete(ctx, "wh-test-delete-1")
|
|
if err != nil {
|
|
t.Fatalf("Delete() error = %v", err)
|
|
}
|
|
|
|
// Verify deleted
|
|
_, err = repo.GetByID(ctx, "wh-test-delete-1")
|
|
if err != domain.ErrWebhookNotFound {
|
|
t.Errorf("GetByID() after delete error = %v, want %v", err, domain.ErrWebhookNotFound)
|
|
}
|
|
})
|
|
|
|
t.Run("returns error for non-existent webhook", func(t *testing.T) {
|
|
err := repo.Delete(ctx, "wh-nonexistent")
|
|
if err != domain.ErrWebhookNotFound {
|
|
t.Errorf("Delete() error = %v, want %v", err, domain.ErrWebhookNotFound)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestWebhookRepository_GetByID(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { cleanupTestWebhooks(t, db) })
|
|
|
|
repo := NewWebhookRepository(db)
|
|
ctx := context.Background()
|
|
|
|
t.Run("gets existing webhook", func(t *testing.T) {
|
|
webhook := &domain.Webhook{
|
|
ID: "wh-test-getbyid-1",
|
|
ProjectID: "test-proj-getbyid",
|
|
URL: "https://example.com/getbyid",
|
|
Secret: "get-secret",
|
|
Events: []domain.WebhookEventType{
|
|
domain.WebhookEventCommandStarted,
|
|
domain.WebhookEventCommandCompleted,
|
|
},
|
|
Enabled: true,
|
|
}
|
|
repo.Create(ctx, webhook)
|
|
|
|
retrieved, err := repo.GetByID(ctx, "wh-test-getbyid-1")
|
|
if err != nil {
|
|
t.Fatalf("GetByID() error = %v", err)
|
|
}
|
|
|
|
if retrieved.URL != "https://example.com/getbyid" {
|
|
t.Errorf("URL = %q, want %q", retrieved.URL, "https://example.com/getbyid")
|
|
}
|
|
if retrieved.Secret != "get-secret" {
|
|
t.Errorf("Secret = %q, want %q", retrieved.Secret, "get-secret")
|
|
}
|
|
if len(retrieved.Events) != 2 {
|
|
t.Errorf("Events length = %d, want 2", len(retrieved.Events))
|
|
}
|
|
})
|
|
|
|
t.Run("returns error for non-existent webhook", func(t *testing.T) {
|
|
_, err := repo.GetByID(ctx, "wh-nonexistent")
|
|
if err != domain.ErrWebhookNotFound {
|
|
t.Errorf("GetByID() error = %v, want %v", err, domain.ErrWebhookNotFound)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestWebhookRepository_ListByProject(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { cleanupTestWebhooks(t, db) })
|
|
|
|
repo := NewWebhookRepository(db)
|
|
ctx := context.Background()
|
|
|
|
projectID := "test-proj-list"
|
|
|
|
// Create multiple webhooks
|
|
for i := 0; i < 3; i++ {
|
|
webhook := &domain.Webhook{
|
|
ID: domain.WebhookID("wh-test-list-" + string(rune('a'+i))),
|
|
ProjectID: projectID,
|
|
URL: "https://example.com/list" + string(rune('a'+i)),
|
|
Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted},
|
|
Enabled: true,
|
|
}
|
|
repo.Create(ctx, webhook)
|
|
time.Sleep(10 * time.Millisecond) // Ensure different timestamps
|
|
}
|
|
|
|
// Create webhook in different project
|
|
otherWebhook := &domain.Webhook{
|
|
ID: "wh-test-list-other",
|
|
ProjectID: "test-proj-list-other",
|
|
URL: "https://example.com/other",
|
|
Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted},
|
|
Enabled: true,
|
|
}
|
|
repo.Create(ctx, otherWebhook)
|
|
|
|
t.Run("lists all webhooks for project", func(t *testing.T) {
|
|
webhooks, err := repo.ListByProject(ctx, projectID)
|
|
if err != nil {
|
|
t.Fatalf("ListByProject() error = %v", err)
|
|
}
|
|
|
|
if len(webhooks) != 3 {
|
|
t.Errorf("ListByProject() returned %d webhooks, want 3", len(webhooks))
|
|
}
|
|
|
|
// Verify all belong to the project
|
|
for _, wh := range webhooks {
|
|
if wh.ProjectID != projectID {
|
|
t.Errorf("Webhook has ProjectID = %q, want %q", wh.ProjectID, projectID)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("returns empty slice for project with no webhooks", func(t *testing.T) {
|
|
webhooks, err := repo.ListByProject(ctx, "test-proj-no-webhooks")
|
|
if err != nil {
|
|
t.Fatalf("ListByProject() error = %v", err)
|
|
}
|
|
|
|
if len(webhooks) != 0 {
|
|
t.Errorf("ListByProject() returned %d webhooks, want 0", len(webhooks))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestWebhookRepository_ListEnabledByProjectAndEvent(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { cleanupTestWebhooks(t, db) })
|
|
|
|
repo := NewWebhookRepository(db)
|
|
ctx := context.Background()
|
|
|
|
projectID := "test-proj-enabled"
|
|
|
|
// Create webhooks with different configurations
|
|
enabledStarted := &domain.Webhook{
|
|
ID: "wh-enabled-started",
|
|
ProjectID: projectID,
|
|
URL: "https://example.com/enabled-started",
|
|
Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted},
|
|
Enabled: true,
|
|
}
|
|
repo.Create(ctx, enabledStarted)
|
|
|
|
enabledCompleted := &domain.Webhook{
|
|
ID: "wh-enabled-completed",
|
|
ProjectID: projectID,
|
|
URL: "https://example.com/enabled-completed",
|
|
Events: []domain.WebhookEventType{domain.WebhookEventCommandCompleted},
|
|
Enabled: true,
|
|
}
|
|
repo.Create(ctx, enabledCompleted)
|
|
|
|
disabledStarted := &domain.Webhook{
|
|
ID: "wh-disabled-started",
|
|
ProjectID: projectID,
|
|
URL: "https://example.com/disabled-started",
|
|
Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted},
|
|
Enabled: false,
|
|
}
|
|
repo.Create(ctx, disabledStarted)
|
|
|
|
t.Run("returns only enabled webhooks with matching event", func(t *testing.T) {
|
|
webhooks, err := repo.ListEnabledByProjectAndEvent(ctx, projectID, domain.WebhookEventCommandStarted)
|
|
if err != nil {
|
|
t.Fatalf("ListEnabledByProjectAndEvent() error = %v", err)
|
|
}
|
|
|
|
if len(webhooks) != 1 {
|
|
t.Errorf("ListEnabledByProjectAndEvent() returned %d webhooks, want 1", len(webhooks))
|
|
}
|
|
|
|
if len(webhooks) > 0 && webhooks[0].ID != "wh-enabled-started" {
|
|
t.Errorf("Webhook ID = %q, want %q", webhooks[0].ID, "wh-enabled-started")
|
|
}
|
|
})
|
|
|
|
t.Run("returns empty when no matching webhooks", func(t *testing.T) {
|
|
webhooks, err := repo.ListEnabledByProjectAndEvent(ctx, projectID, domain.WebhookEventCommandFailed)
|
|
if err != nil {
|
|
t.Fatalf("ListEnabledByProjectAndEvent() error = %v", err)
|
|
}
|
|
|
|
if len(webhooks) != 0 {
|
|
t.Errorf("ListEnabledByProjectAndEvent() returned %d webhooks, want 0", len(webhooks))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestWebhookRepository_RecordDelivery(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { cleanupTestWebhooks(t, db) })
|
|
|
|
repo := NewWebhookRepository(db)
|
|
ctx := context.Background()
|
|
|
|
// Create webhook first
|
|
webhook := &domain.Webhook{
|
|
ID: "wh-test-delivery",
|
|
ProjectID: "test-proj-delivery",
|
|
URL: "https://example.com/delivery",
|
|
Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted},
|
|
Enabled: true,
|
|
}
|
|
repo.Create(ctx, webhook)
|
|
|
|
t.Run("records successful delivery", func(t *testing.T) {
|
|
delivery := &domain.WebhookDelivery{
|
|
ID: "del-test-success",
|
|
WebhookID: "wh-test-delivery",
|
|
EventType: domain.WebhookEventCommandStarted,
|
|
Payload: `{"event":"command.started"}`,
|
|
ResponseStatus: 200,
|
|
ResponseBody: "OK",
|
|
DeliveredAt: time.Now(),
|
|
Success: true,
|
|
RetryCount: 0,
|
|
}
|
|
|
|
err := repo.RecordDelivery(ctx, delivery)
|
|
if err != nil {
|
|
t.Fatalf("RecordDelivery() error = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("records failed delivery", func(t *testing.T) {
|
|
delivery := &domain.WebhookDelivery{
|
|
ID: "del-test-failure",
|
|
WebhookID: "wh-test-delivery",
|
|
EventType: domain.WebhookEventCommandStarted,
|
|
Payload: `{"event":"command.started"}`,
|
|
ResponseStatus: 500,
|
|
ResponseBody: "Internal Server Error",
|
|
DeliveredAt: time.Now(),
|
|
Success: false,
|
|
RetryCount: 3,
|
|
ErrorMessage: "server returned 500",
|
|
}
|
|
|
|
err := repo.RecordDelivery(ctx, delivery)
|
|
if err != nil {
|
|
t.Fatalf("RecordDelivery() error = %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestWebhookRepository_GetDeliveries(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { cleanupTestWebhooks(t, db) })
|
|
|
|
repo := NewWebhookRepository(db)
|
|
ctx := context.Background()
|
|
|
|
// Create webhook
|
|
webhook := &domain.Webhook{
|
|
ID: "wh-test-get-deliveries",
|
|
ProjectID: "test-proj-get-deliveries",
|
|
URL: "https://example.com/deliveries",
|
|
Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted},
|
|
Enabled: true,
|
|
}
|
|
repo.Create(ctx, webhook)
|
|
|
|
// Create deliveries
|
|
for i := 0; i < 5; i++ {
|
|
delivery := &domain.WebhookDelivery{
|
|
ID: domain.WebhookDeliveryID("del-get-" + string(rune('a'+i))),
|
|
WebhookID: "wh-test-get-deliveries",
|
|
EventType: domain.WebhookEventCommandStarted,
|
|
Payload: `{"event":"command.started"}`,
|
|
ResponseStatus: 200,
|
|
DeliveredAt: time.Now(),
|
|
Success: i%2 == 0, // Alternate success/failure
|
|
}
|
|
_ = repo.RecordDelivery(ctx, delivery)
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
|
|
t.Run("gets all deliveries", func(t *testing.T) {
|
|
deliveries, err := repo.GetDeliveries(ctx, "wh-test-get-deliveries", nil)
|
|
if err != nil {
|
|
t.Fatalf("GetDeliveries() error = %v", err)
|
|
}
|
|
|
|
if len(deliveries) != 5 {
|
|
t.Errorf("GetDeliveries() returned %d deliveries, want 5", len(deliveries))
|
|
}
|
|
})
|
|
|
|
t.Run("filters by success", func(t *testing.T) {
|
|
success := true
|
|
deliveries, err := repo.GetDeliveries(ctx, "wh-test-get-deliveries", &domain.WebhookDeliveryFilters{
|
|
Success: &success,
|
|
Limit: 100,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GetDeliveries() error = %v", err)
|
|
}
|
|
|
|
for _, d := range deliveries {
|
|
if !d.Success {
|
|
t.Error("GetDeliveries() returned unsuccessful delivery when filtering by success=true")
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("applies limit", func(t *testing.T) {
|
|
deliveries, err := repo.GetDeliveries(ctx, "wh-test-get-deliveries", &domain.WebhookDeliveryFilters{
|
|
Limit: 2,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GetDeliveries() error = %v", err)
|
|
}
|
|
|
|
if len(deliveries) != 2 {
|
|
t.Errorf("GetDeliveries() returned %d deliveries, want 2", len(deliveries))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestWebhookRepository_CleanupOldDeliveries(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { cleanupTestWebhooks(t, db) })
|
|
|
|
repo := NewWebhookRepository(db)
|
|
ctx := context.Background()
|
|
|
|
// Create webhook
|
|
webhook := &domain.Webhook{
|
|
ID: "wh-test-cleanup",
|
|
ProjectID: "test-proj-cleanup",
|
|
URL: "https://example.com/cleanup",
|
|
Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted},
|
|
Enabled: true,
|
|
}
|
|
repo.Create(ctx, webhook)
|
|
|
|
// Create deliveries
|
|
for i := 0; i < 3; i++ {
|
|
delivery := &domain.WebhookDelivery{
|
|
ID: domain.WebhookDeliveryID("del-cleanup-" + string(rune('a'+i))),
|
|
WebhookID: "wh-test-cleanup",
|
|
EventType: domain.WebhookEventCommandStarted,
|
|
Payload: `{}`,
|
|
ResponseStatus: 200,
|
|
DeliveredAt: time.Now(),
|
|
Success: true,
|
|
}
|
|
_ = repo.RecordDelivery(ctx, delivery)
|
|
}
|
|
|
|
t.Run("cleanup runs without error", func(t *testing.T) {
|
|
deleted, err := repo.CleanupOldDeliveries(ctx, 30)
|
|
if err != nil {
|
|
t.Fatalf("CleanupOldDeliveries() error = %v", err)
|
|
}
|
|
|
|
// Newly created deliveries shouldn't be deleted
|
|
if deleted != 0 {
|
|
t.Logf("CleanupOldDeliveries() deleted %d deliveries (expected 0 for new deliveries)", deleted)
|
|
}
|
|
})
|
|
}
|