rdev/internal/handlers/webhooks_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

610 lines
15 KiB
Go

package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/domain"
)
// mockWebhookRepository implements port.WebhookRepository for testing.
type mockWebhookRepository struct {
webhooks []*domain.Webhook
deliveries []*domain.WebhookDelivery
err error
}
func (m *mockWebhookRepository) Create(ctx context.Context, webhook *domain.Webhook) error {
if m.err != nil {
return m.err
}
webhook.CreatedAt = time.Now()
webhook.UpdatedAt = time.Now()
m.webhooks = append(m.webhooks, webhook)
return nil
}
func (m *mockWebhookRepository) GetByID(ctx context.Context, id domain.WebhookID) (*domain.Webhook, error) {
if m.err != nil {
return nil, m.err
}
for _, w := range m.webhooks {
if w.ID == id {
return w, nil
}
}
return nil, domain.ErrWebhookNotFound
}
func (m *mockWebhookRepository) ListByProject(ctx context.Context, projectID string) ([]*domain.Webhook, error) {
if m.err != nil {
return nil, m.err
}
var result []*domain.Webhook
for _, w := range m.webhooks {
if w.ProjectID == projectID {
result = append(result, w)
}
}
return result, nil
}
func (m *mockWebhookRepository) ListEnabledByProjectAndEvent(ctx context.Context, projectID string, eventType domain.WebhookEventType) ([]*domain.Webhook, error) {
if m.err != nil {
return nil, m.err
}
var result []*domain.Webhook
for _, w := range m.webhooks {
if w.ProjectID == projectID && w.Enabled {
for _, e := range w.Events {
if e == eventType {
result = append(result, w)
break
}
}
}
}
return result, nil
}
func (m *mockWebhookRepository) Update(ctx context.Context, webhook *domain.Webhook) error {
if m.err != nil {
return m.err
}
for i, w := range m.webhooks {
if w.ID == webhook.ID {
m.webhooks[i] = webhook
return nil
}
}
return domain.ErrWebhookNotFound
}
func (m *mockWebhookRepository) Delete(ctx context.Context, id domain.WebhookID) error {
if m.err != nil {
return m.err
}
for i, w := range m.webhooks {
if w.ID == id {
m.webhooks = append(m.webhooks[:i], m.webhooks[i+1:]...)
return nil
}
}
return domain.ErrWebhookNotFound
}
func (m *mockWebhookRepository) RecordDelivery(ctx context.Context, delivery *domain.WebhookDelivery) error {
if m.err != nil {
return m.err
}
m.deliveries = append(m.deliveries, delivery)
return nil
}
func (m *mockWebhookRepository) GetDeliveries(ctx context.Context, webhookID domain.WebhookID, filters *domain.WebhookDeliveryFilters) ([]*domain.WebhookDelivery, error) {
if m.err != nil {
return nil, m.err
}
var result []*domain.WebhookDelivery
for _, d := range m.deliveries {
if d.WebhookID == webhookID {
result = append(result, d)
}
}
return result, nil
}
func (m *mockWebhookRepository) CleanupOldDeliveries(ctx context.Context, olderThanDays int) (int64, error) {
return 0, m.err
}
func TestWebhookHandler_Create(t *testing.T) {
projectRepo := newMockProjectRepo()
projectRepo.Register(context.Background(), &domain.Project{ID: "proj-1", Name: "Test Project"})
tests := []struct {
name string
projectID string
body CreateWebhookRequest
wantStatus int
}{
{
name: "valid webhook",
projectID: "proj-1",
body: CreateWebhookRequest{
URL: "https://example.com/webhook",
Events: []string{"command.started", "command.completed"},
},
wantStatus: http.StatusCreated,
},
{
name: "with custom secret",
projectID: "proj-1",
body: CreateWebhookRequest{
URL: "https://example.com/webhook",
Events: []string{"command.started"},
Secret: "my-secret-key",
},
wantStatus: http.StatusCreated,
},
{
name: "missing url",
projectID: "proj-1",
body: CreateWebhookRequest{
Events: []string{"command.started"},
},
wantStatus: http.StatusBadRequest,
},
{
name: "invalid url",
projectID: "proj-1",
body: CreateWebhookRequest{
URL: "not-a-valid-url",
Events: []string{"command.started"},
},
wantStatus: http.StatusBadRequest,
},
{
name: "missing events",
projectID: "proj-1",
body: CreateWebhookRequest{
URL: "https://example.com/webhook",
Events: []string{},
},
wantStatus: http.StatusBadRequest,
},
{
name: "invalid event type",
projectID: "proj-1",
body: CreateWebhookRequest{
URL: "https://example.com/webhook",
Events: []string{"invalid.event"},
},
wantStatus: http.StatusBadRequest,
},
{
name: "project not found",
projectID: "unknown",
body: CreateWebhookRequest{
URL: "https://example.com/webhook",
Events: []string{"command.started"},
},
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
webhookRepo := &mockWebhookRepository{}
h := NewWebhookHandler(webhookRepo, projectRepo)
r := chi.NewRouter()
r.Post("/projects/{id}/webhooks/", h.Create)
body, _ := json.Marshal(tt.body)
req := httptest.NewRequest(http.MethodPost, "/projects/"+tt.projectID+"/webhooks/", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("Create() status = %d, want %d, body: %s", w.Code, tt.wantStatus, w.Body.String())
}
if tt.wantStatus == http.StatusCreated {
var resp struct {
Data CreateWebhookResponse `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if resp.Data.Secret == "" {
t.Error("Secret should be returned on creation")
}
}
})
}
}
func TestWebhookHandler_List(t *testing.T) {
projectRepo := newMockProjectRepo()
projectRepo.Register(context.Background(), &domain.Project{ID: "proj-1", Name: "Test Project"})
webhookRepo := &mockWebhookRepository{
webhooks: []*domain.Webhook{
{
ID: "wh-1",
ProjectID: "proj-1",
URL: "https://example.com/webhook1",
Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted},
Enabled: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
{
ID: "wh-2",
ProjectID: "proj-1",
URL: "https://example.com/webhook2",
Events: []domain.WebhookEventType{domain.WebhookEventCommandCompleted},
Enabled: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
},
}
tests := []struct {
name string
projectID string
wantStatus int
wantCount int
}{
{
name: "list webhooks",
projectID: "proj-1",
wantStatus: http.StatusOK,
wantCount: 2,
},
{
name: "project not found",
projectID: "unknown",
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := NewWebhookHandler(webhookRepo, projectRepo)
r := chi.NewRouter()
r.Get("/projects/{id}/webhooks/", h.List)
req := httptest.NewRequest(http.MethodGet, "/projects/"+tt.projectID+"/webhooks/", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("List() status = %d, want %d", w.Code, tt.wantStatus)
}
if tt.wantStatus == http.StatusOK {
var resp struct {
Data struct {
Webhooks []*WebhookDTO `json:"webhooks"`
Total int `json:"total"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if resp.Data.Total != tt.wantCount {
t.Errorf("List() count = %d, want %d", resp.Data.Total, tt.wantCount)
}
}
})
}
}
func TestWebhookHandler_Get(t *testing.T) {
projectRepo := newMockProjectRepo()
projectRepo.Register(context.Background(), &domain.Project{ID: "proj-1", Name: "Test Project"})
webhookRepo := &mockWebhookRepository{
webhooks: []*domain.Webhook{
{
ID: "wh-123",
ProjectID: "proj-1",
URL: "https://example.com/webhook",
Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted},
Enabled: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
},
}
tests := []struct {
name string
projectID string
webhookID string
wantStatus int
}{
{
name: "existing webhook",
projectID: "proj-1",
webhookID: "wh-123",
wantStatus: http.StatusOK,
},
{
name: "webhook not found",
projectID: "proj-1",
webhookID: "wh-unknown",
wantStatus: http.StatusNotFound,
},
{
name: "project not found",
projectID: "unknown",
webhookID: "wh-123",
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := NewWebhookHandler(webhookRepo, projectRepo)
r := chi.NewRouter()
r.Get("/projects/{id}/webhooks/{webhookId}", h.Get)
req := httptest.NewRequest(http.MethodGet, "/projects/"+tt.projectID+"/webhooks/"+tt.webhookID, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("Get() status = %d, want %d", w.Code, tt.wantStatus)
}
})
}
}
func TestWebhookHandler_Update(t *testing.T) {
projectRepo := newMockProjectRepo()
projectRepo.Register(context.Background(), &domain.Project{ID: "proj-1", Name: "Test Project"})
tests := []struct {
name string
projectID string
webhookID string
body UpdateWebhookRequest
wantStatus int
}{
{
name: "update url",
projectID: "proj-1",
webhookID: "wh-123",
body: UpdateWebhookRequest{
URL: "https://new-url.com/webhook",
},
wantStatus: http.StatusOK,
},
{
name: "disable webhook",
projectID: "proj-1",
webhookID: "wh-123",
body: UpdateWebhookRequest{
Enabled: boolPtr(false),
},
wantStatus: http.StatusOK,
},
{
name: "invalid url",
projectID: "proj-1",
webhookID: "wh-123",
body: UpdateWebhookRequest{
URL: "not-a-url",
},
wantStatus: http.StatusBadRequest,
},
{
name: "webhook not found",
projectID: "proj-1",
webhookID: "wh-unknown",
body: UpdateWebhookRequest{
URL: "https://example.com",
},
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
webhookRepo := &mockWebhookRepository{
webhooks: []*domain.Webhook{
{
ID: "wh-123",
ProjectID: "proj-1",
URL: "https://example.com/webhook",
Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted},
Enabled: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
},
}
h := NewWebhookHandler(webhookRepo, projectRepo)
r := chi.NewRouter()
r.Put("/projects/{id}/webhooks/{webhookId}", h.Update)
body, _ := json.Marshal(tt.body)
req := httptest.NewRequest(http.MethodPut, "/projects/"+tt.projectID+"/webhooks/"+tt.webhookID, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("Update() status = %d, want %d, body: %s", w.Code, tt.wantStatus, w.Body.String())
}
})
}
}
func TestWebhookHandler_Delete(t *testing.T) {
projectRepo := newMockProjectRepo()
projectRepo.Register(context.Background(), &domain.Project{ID: "proj-1", Name: "Test Project"})
tests := []struct {
name string
projectID string
webhookID string
wantStatus int
}{
{
name: "delete existing webhook",
projectID: "proj-1",
webhookID: "wh-123",
wantStatus: http.StatusOK,
},
{
name: "webhook not found",
projectID: "proj-1",
webhookID: "wh-unknown",
wantStatus: http.StatusNotFound,
},
{
name: "project not found",
projectID: "unknown",
webhookID: "wh-123",
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
webhookRepo := &mockWebhookRepository{
webhooks: []*domain.Webhook{
{
ID: "wh-123",
ProjectID: "proj-1",
URL: "https://example.com/webhook",
Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted},
Enabled: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
},
}
h := NewWebhookHandler(webhookRepo, projectRepo)
r := chi.NewRouter()
r.Delete("/projects/{id}/webhooks/{webhookId}", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/projects/"+tt.projectID+"/webhooks/"+tt.webhookID, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("Delete() status = %d, want %d", w.Code, tt.wantStatus)
}
})
}
}
func TestWebhookHandler_GetDeliveries(t *testing.T) {
projectRepo := newMockProjectRepo()
projectRepo.Register(context.Background(), &domain.Project{ID: "proj-1", Name: "Test Project"})
webhookRepo := &mockWebhookRepository{
webhooks: []*domain.Webhook{
{
ID: "wh-123",
ProjectID: "proj-1",
URL: "https://example.com/webhook",
Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted},
Enabled: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
},
deliveries: []*domain.WebhookDelivery{
{
ID: "del-1",
WebhookID: "wh-123",
EventType: domain.WebhookEventCommandStarted,
Payload: `{"test": true}`,
ResponseStatus: 200,
Success: true,
DeliveredAt: time.Now(),
},
},
}
tests := []struct {
name string
projectID string
webhookID string
query string
wantStatus int
}{
{
name: "get deliveries",
projectID: "proj-1",
webhookID: "wh-123",
query: "",
wantStatus: http.StatusOK,
},
{
name: "with filters",
projectID: "proj-1",
webhookID: "wh-123",
query: "?success=true&limit=10",
wantStatus: http.StatusOK,
},
{
name: "webhook not found",
projectID: "proj-1",
webhookID: "wh-unknown",
wantStatus: http.StatusNotFound,
},
{
name: "project not found",
projectID: "unknown",
webhookID: "wh-123",
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := NewWebhookHandler(webhookRepo, projectRepo)
r := chi.NewRouter()
r.Get("/projects/{id}/webhooks/{webhookId}/deliveries", h.GetDeliveries)
req := httptest.NewRequest(http.MethodGet, "/projects/"+tt.projectID+"/webhooks/"+tt.webhookID+"/deliveries"+tt.query, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("GetDeliveries() status = %d, want %d", w.Code, tt.wantStatus)
}
})
}
}
func boolPtr(b bool) *bool {
return &b
}