package handlers import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/service" ) func TestVerifySignature_ValidSignature(t *testing.T) { h := &WoodpeckerWebhookHandler{webhookSecret: "test-secret"} body := []byte(`{"event":"push","repo":{"name":"test"},"build":{"status":"success"}}`) // Generate a valid signature mac := hmac.New(sha256.New, []byte("test-secret")) mac.Write(body) signature := "sha256=" + hex.EncodeToString(mac.Sum(nil)) if !h.verifySignature(body, signature) { t.Error("expected valid signature to pass verification") } } func TestVerifySignature_InvalidSignature(t *testing.T) { h := &WoodpeckerWebhookHandler{webhookSecret: "test-secret"} body := []byte(`{"event":"push","repo":{"name":"test"},"build":{"status":"success"}}`) if h.verifySignature(body, "sha256=invalid") { t.Error("expected invalid signature to fail verification") } } func TestVerifySignature_EmptySignature(t *testing.T) { h := &WoodpeckerWebhookHandler{webhookSecret: "test-secret"} body := []byte(`{"event":"push"}`) if h.verifySignature(body, "") { t.Error("expected empty signature to fail verification") } } func TestVerifySignature_WrongSecret(t *testing.T) { h := &WoodpeckerWebhookHandler{webhookSecret: "test-secret"} body := []byte(`{"event":"push"}`) // Generate signature with different secret mac := hmac.New(sha256.New, []byte("wrong-secret")) mac.Write(body) signature := "sha256=" + hex.EncodeToString(mac.Sum(nil)) if h.verifySignature(body, signature) { t.Error("expected signature with wrong secret to fail verification") } } func TestVerifySignature_WithoutPrefix(t *testing.T) { h := &WoodpeckerWebhookHandler{webhookSecret: "test-secret"} body := []byte(`{"event":"push"}`) // Generate valid signature without sha256= prefix mac := hmac.New(sha256.New, []byte("test-secret")) mac.Write(body) signature := hex.EncodeToString(mac.Sum(nil)) // Should still work - we strip the prefix if !h.verifySignature(body, signature) { t.Error("expected signature without prefix to pass verification") } } func TestVerifySignature_TamperedBody(t *testing.T) { h := &WoodpeckerWebhookHandler{webhookSecret: "test-secret"} originalBody := []byte(`{"event":"push","repo":{"name":"test"}}`) tamperedBody := []byte(`{"event":"push","repo":{"name":"hacked"}}`) // Generate signature for original body mac := hmac.New(sha256.New, []byte("test-secret")) mac.Write(originalBody) signature := "sha256=" + hex.EncodeToString(mac.Sum(nil)) // Verify against tampered body should fail if h.verifySignature(tamperedBody, signature) { t.Error("expected tampered body to fail verification") } } func TestWoodpeckerWebhookHandler_SetOperationService(t *testing.T) { h := &WoodpeckerWebhookHandler{logger: slog.Default()} t.Run("sets non-nil service", func(t *testing.T) { repo := newMockOperationRepo() opSvc := service.NewOperationService(repo) result := h.SetOperationService(opSvc) if h.operationService != opSvc { t.Error("expected operation service to be set") } if result != h { t.Error("expected fluent return of handler") } }) t.Run("ignores nil service", func(t *testing.T) { repo := newMockOperationRepo() opSvc := service.NewOperationService(repo) h.SetOperationService(opSvc) h.SetOperationService(nil) if h.operationService != opSvc { t.Error("nil should not clear operation service") } }) } func TestWoodpeckerWebhookHandler_TracksOperation(t *testing.T) { opRepo := newMockOperationRepo() opSvc := service.NewOperationService(opRepo) h := &WoodpeckerWebhookHandler{ operationService: opSvc, logger: slog.Default(), registryURL: "registry.test:5000", defaultDomain: "test.ai", clusterIP: "1.2.3.4", } payload := WoodpeckerPayload{ Event: "push", Repo: WoodpeckerRepo{ Name: "my-project", FullName: "org/my-project", }, Build: WoodpeckerBuild{ Number: 42, Status: "success", Branch: "main", Commit: "abc12345def67890", }, } body, _ := json.Marshal(payload) req := httptest.NewRequest(http.MethodPost, "/webhooks/woodpecker", strings.NewReader(string(body))) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() h.HandleWebhook(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. Body: %s", rec.Code, rec.Body.String()) } // Verify operation was created if opRepo.count() != 1 { t.Fatalf("expected 1 operation, got %d", opRepo.count()) } // Verify operation details for _, op := range opRepo.operations { if op.Type != domain.OperationTypeBuild { t.Errorf("expected operation type build, got %s", op.Type) } if op.ProjectID != "my-project" { t.Errorf("expected project_id my-project, got %s", op.ProjectID) } if op.Status != domain.OperationStatusCompleted { t.Errorf("expected operation status completed, got %s", op.Status) } if op.ExternalRef != "build#42" { t.Errorf("expected external_ref build#42, got %s", op.ExternalRef) } } // Verify response contains operation_id var resp struct { Data map[string]any `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } opID, ok := resp.Data["operation_id"] if !ok { t.Error("response missing operation_id field") } if opID == "" { t.Error("operation_id should not be empty") } } func TestWoodpeckerWebhookHandler_LinksToParentOperation(t *testing.T) { opRepo := newMockOperationRepo() opSvc := service.NewOperationService(opRepo) // Create a parent operation (component.add) that has the same commit SHA parentID, _ := opSvc.StartOperation( t.Context(), "my-project", domain.OperationTypeComponentAdd, map[string]any{"type": "service", "name": "auth-api"}, "", ) // Set the commit SHA on the parent (simulates component add creating a commit) opSvc.SetCommitSHA(t.Context(), parentID, "abc12345def67890") opSvc.CompleteOperation(t.Context(), parentID, nil) h := &WoodpeckerWebhookHandler{ operationService: opSvc, logger: slog.Default(), registryURL: "registry.test:5000", } payload := WoodpeckerPayload{ Event: "push", Repo: WoodpeckerRepo{ Name: "my-project", FullName: "org/my-project", }, Build: WoodpeckerBuild{ Number: 43, Status: "success", Branch: "main", Commit: "abc12345def67890", }, } body, _ := json.Marshal(payload) req := httptest.NewRequest(http.MethodPost, "/webhooks/woodpecker", strings.NewReader(string(body))) rec := httptest.NewRecorder() h.HandleWebhook(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. Body: %s", rec.Code, rec.Body.String()) } // Should now have 2 operations: parent (component.add) and child (build) if opRepo.count() != 2 { t.Fatalf("expected 2 operations, got %d", opRepo.count()) } // Find the build operation and verify it's linked to parent for _, op := range opRepo.operations { if op.Type == domain.OperationTypeBuild { if op.TriggeredBy != parentID { t.Errorf("expected triggered_by %s, got %s", parentID, op.TriggeredBy) } return } } t.Error("build operation not found") } func TestWoodpeckerWebhookHandler_RecordsFailedBuilds(t *testing.T) { opRepo := newMockOperationRepo() opSvc := service.NewOperationService(opRepo) h := &WoodpeckerWebhookHandler{ operationService: opSvc, logger: slog.Default(), } payload := WoodpeckerPayload{ Event: "push", Repo: WoodpeckerRepo{Name: "my-project", FullName: "org/my-project"}, Build: WoodpeckerBuild{ Number: 99, Status: "failure", Branch: "main", Commit: "abc123", }, } body, _ := json.Marshal(payload) req := httptest.NewRequest(http.MethodPost, "/webhooks/woodpecker", strings.NewReader(string(body))) rec := httptest.NewRecorder() h.HandleWebhook(rec, req) // Failed builds are now recorded for visibility if opRepo.count() != 1 { t.Errorf("expected 1 operation for failed build, got %d", opRepo.count()) } // Verify the operation was marked as failed for _, op := range opRepo.operations { if op.Type != domain.OperationTypeCIBuild { t.Errorf("expected operation type ci.build, got %s", op.Type) } if op.Status != domain.OperationStatusFailed { t.Errorf("expected operation status failed, got %s", op.Status) } } // Verify response indicates build was recorded var resp struct { Data map[string]any `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if resp.Data["status"] != "recorded" { t.Errorf("expected status 'recorded', got %v", resp.Data["status"]) } } func TestWoodpeckerWebhookHandler_IgnoresPendingBuilds(t *testing.T) { opRepo := newMockOperationRepo() opSvc := service.NewOperationService(opRepo) h := &WoodpeckerWebhookHandler{ operationService: opSvc, logger: slog.Default(), } payload := WoodpeckerPayload{ Event: "push", Repo: WoodpeckerRepo{Name: "my-project"}, Build: WoodpeckerBuild{ Status: "pending", Branch: "main", Commit: "abc123", }, } body, _ := json.Marshal(payload) req := httptest.NewRequest(http.MethodPost, "/webhooks/woodpecker", strings.NewReader(string(body))) rec := httptest.NewRecorder() h.HandleWebhook(rec, req) // Pending/running builds are ignored (only success and failure are handled) if opRepo.count() != 0 { t.Errorf("expected no operations for pending build, got %d", opRepo.count()) } }