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>
610 lines
15 KiB
Go
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
|
|
}
|