rdev/internal/handlers/woodpecker_webhook_test.go
jordan 62460bf098 feat: complete template upgrade - chassis framework, UI library, auth, app-nextjs, OpenAPI, and cookbook
Weeks 1-7 of the template upgrade plan:
- pkg/api: typed HTTPError with sentinels, Wrap/WrapMiddleware, Bind, health probes, OpenAPI schema/param builders
- skeleton/packages: ui (design tokens, components), layout (DashboardShell), auth (AuthProvider, ProtectedRoute), api-client
- skeleton/pkg: httperror, app/handler, app/bind, app/health, auth (JWT/API key middleware)
- components/app-nextjs: Next.js 14 App Router template with dashboard, server actions, auth
- cookbooks/feature-development.md with test and validation scripts
- Handler tests for components, project management, and woodpecker webhook
- 3 rounds of code review fixes applied

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 00:46:51 -07:00

286 lines
8.1 KiB
Go

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, slog.Default())
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, slog.Default())
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, slog.Default())
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, slog.Default())
// 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_IgnoresNonSuccessBuilds(t *testing.T) {
opRepo := newMockOperationRepo()
opSvc := service.NewOperationService(opRepo, slog.Default())
h := &WoodpeckerWebhookHandler{
operationService: opSvc,
logger: slog.Default(),
}
payload := WoodpeckerPayload{
Event: "push",
Repo: WoodpeckerRepo{Name: "my-project"},
Build: WoodpeckerBuild{
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)
// Non-success builds are ignored, so no operation should be created
if opRepo.count() != 0 {
t.Errorf("expected no operations for failed build, got %d", opRepo.count())
}
}