nomadcode/services/core/cmd/server/main_test.go
toki 528e9d6966 feat(roadmapsync): plane origin authoring roundtrip 기능 및 마일스톤 ID 지원
- roadmapsync pipeline에 retry 로직 및 개발 일치 검증 로직 추가
- gitosync scanner에 slug 재조정 및 plane 매칭 로직 강화
- roadmap sync identity 관리를 위한 migration(00009) 및 SQL 쿼리 추가
- milestone parser 테스트 및 retry 테스트 보강
- agent-ops 문서(PHASE, SDD, milestones) 갱신
- agent-task archive 및 작업 디렉터리 추가
2026-06-21 07:59:56 +09:00

321 lines
9.9 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 {
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: "<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)
}
}
// 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")
}
}