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" apphttp "github.com/nomadcode/nomadcode-core/internal/http" "github.com/nomadcode/nomadcode-core/internal/roadmapsync" "github.com/nomadcode/nomadcode-core/internal/scheduler" "github.com/nomadcode/nomadcode-core/internal/workitem" ) // --------------------------------------------------------------------------- // 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{ { Path: scanned.Path, MilestoneID: "gito-http-webhook-consumer-readiness", Markdown: "# Milestone Title\n\nbody content", HasIdentity: true, Identity: scanned.Identity, }, }, } } // --------------------------------------------------------------------------- // 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") req.Header.Set("X-Gito-Delivery", "delivery-1") 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. // RoadmapRevision should match the HTTP payload's `after` value (passed through scanner). if job.RoadmapRevision != after { t.Fatalf("RoadmapRevision: got %q, want %q (HTTP payload `after`)", job.RoadmapRevision, after) } 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") req.Header.Set("X-Gito-Delivery", "delivery-invalid-signature") 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") req.Header.Set("X-Gito-Delivery", "delivery-off-target") 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") } }