diff --git a/services/core/cmd/server/main_test.go b/services/core/cmd/server/main_test.go new file mode 100644 index 0000000..f92feb0 --- /dev/null +++ b/services/core/cmd/server/main_test.go @@ -0,0 +1,314 @@ +package main + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/nomadcode/nomadcode-core/internal/gitoevents" + "github.com/nomadcode/nomadcode-core/internal/gitosync" + "github.com/nomadcode/nomadcode-core/internal/roadmapsync" + "github.com/nomadcode/nomadcode-core/internal/scheduler" + "github.com/nomadcode/nomadcode-core/internal/workitem" + apphttp "github.com/nomadcode/nomadcode-core/internal/http" +) + +// --------------------------------------------------------------------------- +// Test fakes for the bridge seams +// --------------------------------------------------------------------------- + +// fakeScanner records the event passed to Scan and returns a scanned result. +type fakeScanner struct { + lastEvent gitoevents.BranchUpdatedEvent + out gitosync.ScanOutput + err error +} + +func (f *fakeScanner) Scan(_ context.Context, ev gitoevents.BranchUpdatedEvent) (gitosync.ScanOutput, error) { + f.lastEvent = ev + if f.err != nil { + return gitosync.ScanOutput{}, f.err + } + return f.out, nil +} + +// fakeEnqueuer records jobs passed to EnqueueRoadmapCreationSync. +type fakeEnqueuer struct { + jobs []scheduler.RoadmapCreationSyncJobArgs + err error +} + +func (f *fakeEnqueuer) EnqueueRoadmapCreationSync(_ context.Context, args scheduler.RoadmapCreationSyncJobArgs) error { + if f.err != nil { + return f.err + } + f.jobs = append(f.jobs, args) + return nil +} + +// fakeReader records refs passed to FetchWorkItem. +type fakeReader struct { + item workitem.WorkItem + err error + refs []workitem.Ref +} + +func (f *fakeReader) FetchWorkItem(_ context.Context, ref workitem.Ref) (workitem.WorkItem, error) { + f.refs = append(f.refs, ref) + if f.err != nil { + return workitem.WorkItem{}, f.err + } + return f.item, nil +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// signBody creates an HMAC-SHA256 signature for the given body and secret. +func signBody(secret string, body []byte) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + return hex.EncodeToString(mac.Sum(nil)) +} + +// buildBranchUpdatedPayload creates a minimal branch.updated webhook body. +func buildBranchUpdatedPayload(repoID, branch, before, after string) []byte { + payload := map[string]interface{}{ + "type": "branch.updated", + "repo_id": repoID, + "branch": branch, + "before": before, + "after": after, + "changed_files": []interface{}{}, + } + data, err := json.Marshal(payload) + if err != nil { + panic("buildBranchUpdatedPayload: marshal: " + err.Error()) + } + return data +} + +// matchedScanOutput returns a ScanOutput with one matched milestone identity. +func matchedScanOutput() gitosync.ScanOutput { + identity := roadmapsync.Identity{ + Shape: roadmapsync.ShapeMilestone, + RoadmapMilestonePath: "agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-http-webhook-consumer-readiness.md", + Provider: "plane", + Tenant: "acme", + Project: "proj-1", + WorkItemID: "wi-123", + } + normalized, err := identity.Normalize() + if err != nil { + panic("matchedScanOutput: Normalize: " + err.Error()) + } + scanned := roadmapsync.ScannedMilestone{ + Path: "agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-http-webhook-consumer-readiness.md", + Identity: normalized, + } + return gitosync.ScanOutput{ + Result: roadmapsync.ScanResult{ + Revision: roadmapsync.Revision{ + Branch: "develop", + Revision: "abc123def456", + }, + ChangedFiles: []string{"agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-http-webhook-consumer-readiness.md"}, + ScannedMilestones: []roadmapsync.ScannedMilestone{scanned}, + }, + Docs: []gitosync.ScannedMilestoneDoc{ + { + Milestone: scanned, + Markdown: "# Milestone Title\n\nbody content", + }, + }, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +// TestGitoHTTPWebhookDeliveryEnqueuesCreationSyncOnce verifies that a signed +// HTTP branch.updated delivery reaches the bridge scanner and triggers exactly +// one EnqueueRoadmapCreationSync call. +func TestGitoHTTPWebhookDeliveryEnqueuesCreationSyncOnce(t *testing.T) { + const ( + secret = "test-webhook-secret" + repoID = "nomadcode" + branch = "develop" + before = "0000000000000000000000000000000000000000" + after = "abc123def456" + ) + + // 1. Set up fakes. + scanner := &fakeScanner{ + out: matchedScanOutput(), + } + enq := &fakeEnqueuer{} + reader := &fakeReader{ + item: workitem.WorkItem{ + Title: "Gito HTTP Webhook Consumer Readiness", + DescriptionHTML: "

Original HTML body

", + DescriptionText: "Original text body", + State: "backlog", + }, + } + + // 2. Build the bridge directly with fakes. + bridge, err := gitosync.NewBridge( + scanner, + reader, + enq, + gitosync.NewInMemoryRevisionStore(), + gitosync.BridgeConfig{ + RepoID: repoID, + Branch: branch, + TodoStateID: "state-todo", + }, + nil, // logger + ) + if err != nil { + t.Fatalf("NewBridge: %v", err) + } + + // 3. Create the HTTP adapter. + adapter := &gitoBridgeHTTPAdapter{bridge: bridge} + + // 4. Set up the HTTP handler with config and event handler. + cfg := apphttp.GitoWebhookConfig{ + Secret: secret, + RepoID: repoID, + Branch: branch, + } + + handler := apphttp.NewHandler(nil, nil, nil, nil) + handler.SetGitoWebhookConfig(cfg) + handler.SetGitoBranchEventHandler(adapter) + + // 5. Build the signed request body. + body := buildBranchUpdatedPayload(repoID, branch, before, after) + + // 6. Create the signed request. + req := httptest.NewRequest(http.MethodPost, "/api/integrations/gito/webhook", bytes.NewReader(body)) + sig := signBody(secret, body) + req.Header.Set("X-Gito-Signature", sig) + req.Header.Set("X-Gito-Event", "branch.updated") + + w := httptest.NewRecorder() + + // 7. Call the handler directly. + handler.ReceiveGitoWebhook(w, req) + + if w.Code != http.StatusAccepted { + t.Fatalf("response status: got %d, want %d", w.Code, http.StatusAccepted) + } + + // 8. Verify scanner received the event. + if scanner.lastEvent.RepoID != repoID { + t.Fatalf("scanner.RepoID: got %q, want %q", scanner.lastEvent.RepoID, repoID) + } + if scanner.lastEvent.Branch != branch { + t.Fatalf("scanner.Branch: got %q, want %q", scanner.lastEvent.Branch, branch) + } + if scanner.lastEvent.After != after { + t.Fatalf("scanner.After: got %q, want %q", scanner.lastEvent.After, after) + } + if scanner.lastEvent.Before != before { + t.Fatalf("scanner.Before: got %q, want %q", scanner.lastEvent.Before, before) + } + + // 9. Verify enqueuer received exactly one job. + if len(enq.jobs) != 1 { + t.Fatalf("enqueued jobs: got %d, want 1", len(enq.jobs)) + } + + job := enq.jobs[0] + + // 10. Verify job content. + if job.RoadmapRevision != "abc123def456" { + t.Fatalf("RoadmapRevision: got %q, want %q", job.RoadmapRevision, "abc123def456") + } + if job.Expected.WorkItemID != "wi-123" { + t.Fatalf("Expected.WorkItemID: got %q, want %q", job.Expected.WorkItemID, "wi-123") + } + if job.Ref.Provider != "plane" { + t.Fatalf("Ref.Provider: got %q, want %q", job.Ref.Provider, "plane") + } + if job.Ref.ID != "wi-123" { + t.Fatalf("Ref.ID: got %q, want %q", job.Ref.ID, "wi-123") + } + if job.OriginalBody != "

Original HTML body

" { + t.Fatalf("OriginalBody: got %q, want HTML preferred", job.OriginalBody) + } +} + +// TestGitoHTTPWebhookDeliveryRejectedOnInvalidSignature verifies that an invalid +// signature results in a 401 Unauthorized response. +func TestGitoHTTPWebhookDeliveryRejectedOnInvalidSignature(t *testing.T) { + const secret = "correct-secret" + const repoID = "nomadcode" + const branch = "develop" + + handler := apphttp.NewHandler(nil, nil, nil, nil) + handler.SetGitoWebhookConfig(apphttp.GitoWebhookConfig{ + Secret: secret, + RepoID: repoID, + Branch: branch, + }) + + body := buildBranchUpdatedPayload(repoID, branch, "develop", "abc123") + req := httptest.NewRequest(http.MethodPost, "/api/integrations/gito/webhook", bytes.NewReader(body)) + // Wrong secret. + req.Header.Set("X-Gito-Signature", signBody("wrong-secret", body)) + req.Header.Set("X-Gito-Event", "branch.updated") + + w := httptest.NewRecorder() + handler.ReceiveGitoWebhook(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("response status: got %d, want %d", w.Code, http.StatusUnauthorized) + } +} + +// TestGitoHTTPWebhookDeliveryOffTargetIgnored verifies that events for a +// different repo/branch receive 202 Accepted with status "ignored". +func TestGitoHTTPWebhookDeliveryOffTargetIgnored(t *testing.T) { + const secret = "test-secret" + const repoID = "nomadcode" + const branch = "develop" + + handler := apphttp.NewHandler(nil, nil, nil, nil) + handler.SetGitoWebhookConfig(apphttp.GitoWebhookConfig{ + Secret: secret, + RepoID: repoID, + Branch: branch, + }) + + body := buildBranchUpdatedPayload("other-repo", "main", "aaa", "bbb") + sig := signBody(secret, body) + req := httptest.NewRequest(http.MethodPost, "/api/integrations/gito/webhook", bytes.NewReader(body)) + req.Header.Set("X-Gito-Signature", sig) + req.Header.Set("X-Gito-Event", "branch.updated") + + w := httptest.NewRecorder() + handler.ReceiveGitoWebhook(w, req) + + if w.Code != http.StatusAccepted { + t.Fatalf("response status: got %d, want %d", w.Code, http.StatusAccepted) + } + + var resp map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("response parse: %v", err) + } + if resp["status"] != "ignored" { + t.Fatalf("response status: got %q, want %q", resp["status"], "ignored") + } +} \ No newline at end of file