nomadcode/services/core/cmd/server/main_test.go
toki 0d619b446c feat(sync): roadmap identity backfill 기능과 git backfiller를 추가한다
roadmap sync 과정에서 origin 프로젝트 간행본의 identity
정보를 채우는 backfill 파이프라인을 구현한다.

- git_backfiller: git 레이블/태그 간행본의 identity 백필
- migration: backfill 완료 단계 테이블 스키마 추가
- pipeline/service: backfill 호출 흐름 연동
- identity_write: backfill 결과 쓰기 통합
2026-07-03 04:19:10 +09:00

409 lines
14 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/roadmapsyncpipeline"
"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")
}
}
// TestCreationSyncBackfillerWiredWhenGitoCheckoutConfigured exercises the same
// construction branch run() uses to wire roadmapsyncpipeline's identity
// backfiller (see the `cfg.GitoDevelopRepoPath != ""` block in run()): a
// configured local develop checkout must produce a usable GitBackfiller, and
// an empty checkout path must be rejected so a misconfigured deployment fails
// fast instead of silently running with no backfiller.
func TestCreationSyncBackfillerWiredWhenGitoCheckoutConfigured(t *testing.T) {
svc := roadmapsyncpipeline.NewService(nil, nil, "")
backfiller, err := roadmapsyncpipeline.NewGitBackfiller(t.TempDir(), "origin", "develop")
if err != nil {
t.Fatalf("NewGitBackfiller with configured checkout path: %v", err)
}
svc.SetBackfiller(backfiller)
if _, err := roadmapsyncpipeline.NewGitBackfiller("", "origin", "develop"); err == nil {
t.Fatal("expected NewGitBackfiller to reject an empty develop checkout path")
}
}