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.
This commit is contained in:
parent
2d9cf3c7a8
commit
d0d4c810ae
1 changed files with 314 additions and 0 deletions
314
services/core/cmd/server/main_test.go
Normal file
314
services/core/cmd/server/main_test.go
Normal file
|
|
@ -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: "<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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue