rdev/internal/adapter/postgres/webhook_test.go
jordan 72d16929ca feat: Implement hexagonal architecture with services, webhooks, queue, and telemetry
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>
2026-01-25 19:57:46 -07:00

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)
}
})
}