388 lines
13 KiB
Go
388 lines
13 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"
|
|
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 {
|
|
return buildBranchUpdatedPayloadWithChangedFiles(repoID, branch, before, after, []interface{}{})
|
|
}
|
|
|
|
// buildBranchUpdatedPayloadWithChangedFiles creates a branch.updated webhook
|
|
// body carrying the given changed_files hint entries.
|
|
func buildBranchUpdatedPayloadWithChangedFiles(repoID, branch, before, after string, changedFiles []interface{}) []byte {
|
|
payload := map[string]interface{}{
|
|
"type": "branch.updated",
|
|
"repo_id": repoID,
|
|
"branch": branch,
|
|
"before": before,
|
|
"after": after,
|
|
"changed_files": changedFiles,
|
|
}
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
panic("buildBranchUpdatedPayloadWithChangedFiles: 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: "<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")
|
|
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 != "<p>Original HTML body</p>" {
|
|
t.Fatalf("OriginalBody: got %q, want HTML preferred", job.OriginalBody)
|
|
}
|
|
}
|
|
|
|
// TestGitoHTTPWebhookChangedFilesAreHintOnly verifies that changed_files
|
|
// travels HTTP -> adapter -> bridge -> scanner intact, but the enqueue
|
|
// decision still comes from the scanner's verified active-index scan: the
|
|
// hint here names an unrelated file (not the milestone the scanner matches),
|
|
// yet the job is still enqueued from the scanner's own result.
|
|
func TestGitoHTTPWebhookChangedFilesAreHintOnly(t *testing.T) {
|
|
const (
|
|
secret = "test-webhook-secret"
|
|
repoID = "nomadcode"
|
|
branch = "develop"
|
|
before = "0000000000000000000000000000000000000000"
|
|
after = "abc123def456"
|
|
)
|
|
|
|
scanner := &fakeScanner{out: matchedScanOutput()}
|
|
enq := &fakeEnqueuer{}
|
|
reader := &fakeReader{item: workitem.WorkItem{DescriptionText: "body"}}
|
|
|
|
bridge, err := gitosync.NewBridge(
|
|
scanner, reader, enq,
|
|
gitosync.NewInMemoryRevisionStore(),
|
|
gitosync.BridgeConfig{RepoID: repoID, Branch: branch, TodoStateID: "state-todo"},
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewBridge: %v", err)
|
|
}
|
|
|
|
adapter := &gitoBridgeHTTPAdapter{bridge: bridge}
|
|
handler := apphttp.NewHandler(nil, nil, nil, nil)
|
|
handler.SetGitoWebhookConfig(apphttp.GitoWebhookConfig{Secret: secret, RepoID: repoID, Branch: branch})
|
|
handler.SetGitoBranchEventHandler(adapter)
|
|
|
|
changedFiles := []interface{}{
|
|
map[string]interface{}{"path": "README.md", "change_type": "modified"},
|
|
}
|
|
body := buildBranchUpdatedPayloadWithChangedFiles(repoID, branch, before, after, changedFiles)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/integrations/gito/webhook", bytes.NewReader(body))
|
|
req.Header.Set("X-Gito-Signature", signBody(secret, body))
|
|
req.Header.Set("X-Gito-Event", "branch.updated")
|
|
req.Header.Set("X-Gito-Delivery", "delivery-changed-files-hint")
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ReceiveGitoWebhook(w, req)
|
|
|
|
if w.Code != http.StatusAccepted {
|
|
t.Fatalf("response status: got %d, want %d", w.Code, http.StatusAccepted)
|
|
}
|
|
|
|
// The hint reached the scanner unchanged.
|
|
if len(scanner.lastEvent.ChangedFiles) != 1 || scanner.lastEvent.ChangedFiles[0].Path != "README.md" {
|
|
t.Fatalf("scanner.lastEvent.ChangedFiles: got %+v, want [README.md]", scanner.lastEvent.ChangedFiles)
|
|
}
|
|
// The enqueue decision came from the scanner's own (matched) result, not
|
|
// from filtering on the unrelated hint.
|
|
if len(enq.jobs) != 1 {
|
|
t.Fatalf("enqueued jobs: got %d, want 1 (hint must not gate enqueue)", len(enq.jobs))
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|