rdev/internal/handlers/woodpecker_webhook_test.go
jordan d69da6d627 feat: add structured logging infrastructure and SDLC extensions
Major changes:
- Add internal/logging package with field constants, context propagation,
  sensitive data auto-redaction, and per-component log levels
- Add worker timeout constants (TimeoutQuickOp, TimeoutHealthCheck, etc.)
- Extend SDLC with callback handlers, generate endpoints, and executor
- Add new cookbook trees for aeries and slackpath progression
- Add skeleton templates for queue, realtime, and microservices
- Add worker component template with async job processing
- Refactor services and handlers to use new logging infrastructure
- Split component.go into component_infra.go and component_listing.go

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 22:56:04 -07:00

338 lines
9.5 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)
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())
}
}