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>
286 lines
8.1 KiB
Go
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())
|
|
}
|
|
}
|