nomadcode/services/core/cmd/server/main_test.go
toki d0d4c810ae test: add HTTP webhook delivery integration test for scan-enqueue evidence
Add TestGitoHTTPWebhookDeliveryEnqueuesCreationSyncOnce to prove that a
signed HTTP branch.updated delivery reaches gitoBridgeHTTPAdapter, triggers
the bridge scanner, and enqueues exactly one RoadmapCreationSync job.

Also adds TestGitoHTTPWebhookDeliveryRejectedOnInvalidSignature and
TestGitoHTTPWebhookDeliveryOffTargetIgnored for completeness.
2026-06-19 16:11:03 +09:00

314 lines
No EOL
9.5 KiB
Go

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: "<p>Original HTML body</p>",
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 != "<p>Original HTML body</p>" {
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")
}
}