package handler import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "github.com/jackc/pgx/v5/pgtype" db "github.com/multica-ai/multica/server/pkg/db/generated" ) // All tests in this file require a working DB. testHandler / testWorkspaceID / // testUserID / testRuntimeID are wired in TestMain (handler_test.go) and // TestMain skips the suite if Postgres isn't reachable. // ── Fixture helpers ───────────────────────────────────────────────────────── func createWebhookTestAgent(t *testing.T, name string) string { t.Helper() var agentID string if err := testPool.QueryRow(context.Background(), ` INSERT INTO agent ( workspace_id, name, description, runtime_mode, runtime_config, runtime_id, visibility, max_concurrent_tasks, owner_id, instructions, custom_env, custom_args, mcp_config ) VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'private', 1, $4, '', '{}'::jsonb, '[]'::jsonb, '{}'::jsonb) RETURNING id `, testWorkspaceID, name, testRuntimeID, testUserID).Scan(&agentID); err != nil { t.Fatalf("create agent: %v", err) } t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, agentID) }) return agentID } func createWebhookTestAutopilot(t *testing.T, agentID, status, mode string) string { t.Helper() var apID string if err := testPool.QueryRow(context.Background(), ` INSERT INTO autopilot ( workspace_id, title, assignee_id, status, execution_mode, created_by_type, created_by_id ) VALUES ($1, $2, $3, $4, $5, 'member', $6) RETURNING id `, testWorkspaceID, "Webhook test "+status, agentID, status, mode, testUserID).Scan(&apID); err != nil { t.Fatalf("create autopilot: %v", err) } t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, apID) }) return apID } func createWebhookTriggerViaHandler(t *testing.T, autopilotID string) AutopilotTriggerResponse { t.Helper() w := httptest.NewRecorder() req := newRequest("POST", "/api/autopilots/"+autopilotID+"/triggers", map[string]any{ "kind": "webhook", }) req = withURLParam(req, "id", autopilotID) testHandler.CreateAutopilotTrigger(w, req) if w.Code != http.StatusCreated { t.Fatalf("CreateAutopilotTrigger: expected 201, got %d body=%s", w.Code, w.Body.String()) } var resp AutopilotTriggerResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } return resp } // createWebhookTriggerWithFilters builds the request body with a real JSON // array — the same shape the frontend sends. Earlier revisions of this // helper marshaled the filters separately and assigned the resulting // []byte to the "event_filters" map key, which encoding/json then encoded // as a base64 string (since []byte → JSON-string). The base64 path // happened to work against an []byte server-side field but masked the // actual contract bug fixed in PR #3231 review. func createWebhookTriggerWithFilters(t *testing.T, autopilotID string, filters []WebhookEventFilter) AutopilotTriggerResponse { t.Helper() w := httptest.NewRecorder() req := newRequest("POST", "/api/autopilots/"+autopilotID+"/triggers", map[string]any{ "kind": "webhook", "event_filters": filters, }) req = withURLParam(req, "id", autopilotID) testHandler.CreateAutopilotTrigger(w, req) if w.Code != http.StatusCreated { t.Fatalf("CreateAutopilotTrigger: expected 201, got %d body=%s", w.Code, w.Body.String()) } var resp AutopilotTriggerResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } return resp } func TestWebhookHandler_FiltersUndeclaredEvent(t *testing.T) { agentID := createWebhookTestAgent(t, "WebhookFilter Agent") apID := createWebhookTestAutopilot(t, agentID, "active", "run_only") trig := createWebhookTriggerWithFilters(t, apID, []WebhookEventFilter{ {Event: "workflow_run", Actions: []string{"completed"}}, {Event: "check_suite", Actions: []string{"completed"}}, }) w := postWebhook(t, *trig.WebhookToken, map[string]any{ "action": "in_progress", "workflow_run": map[string]any{"id": 123}, }, map[string]string{"X-GitHub-Event": "workflow_run"}) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) } var resp map[string]any if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } if resp["status"] != "ignored" || resp["reason"] != "event_filtered" { t.Fatalf("expected ignored/event_filtered, got %#v body=%s", resp, w.Body.String()) } if _, ok := resp["run_id"]; ok { t.Fatalf("filtered response must not include run_id: %#v", resp) } runs, err := testHandler.Queries.ListAutopilotRuns(context.Background(), db.ListAutopilotRunsParams{ AutopilotID: parseUUID(apID), Limit: 10, Offset: 0, }) if err != nil { t.Fatalf("list runs: %v", err) } if len(runs) != 0 { t.Fatalf("filtered webhook should not create runs, got %d", len(runs)) } } func TestWebhookHandler_AllowsDeclaredEvent(t *testing.T) { agentID := createWebhookTestAgent(t, "WebhookAllow Agent") apID := createWebhookTestAutopilot(t, agentID, "active", "run_only") trig := createWebhookTriggerWithFilters(t, apID, []WebhookEventFilter{ {Event: "workflow_run", Actions: []string{"completed"}}, }) w := postWebhook(t, *trig.WebhookToken, map[string]any{ "action": "completed", "workflow_run": map[string]any{"id": 123}, }, map[string]string{"X-GitHub-Event": "workflow_run"}) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) } var resp map[string]any if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } if resp["status"] != "accepted" && resp["status"] != "skipped" { t.Fatalf("expected accepted or skipped, got %#v", resp) } } func TestWebhookHandler_EmptyFiltersAllowsAll(t *testing.T) { agentID := createWebhookTestAgent(t, "WebhookEmptyFilter Agent") apID := createWebhookTestAutopilot(t, agentID, "active", "run_only") trig := createWebhookTriggerViaHandler(t, apID) w := postWebhook(t, *trig.WebhookToken, map[string]any{ "action": "in_progress", "workflow_run": map[string]any{"id": 123}, }, map[string]string{"X-GitHub-Event": "workflow_run"}) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) } var resp map[string]any if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } if resp["status"] != "accepted" && resp["status"] != "skipped" { t.Fatalf("expected accepted or skipped, got %#v", resp) } } // ── HTTP contract: event_filters JSON shape & PATCH semantics ────────────── // // These tests pin the wire contract the frontend depends on: a real JSON // array of {event, actions} objects flows in on create, comes back as the // same array on read, and update accepts tri-state semantics (omitted = // preserve, explicit [] = clear, explicit [...] = replace). Earlier // revisions used []byte at the HTTP boundary and the round-trip silently // passed via base64 — which the frontend cannot parse. See PR #3231 review. func TestCreateWebhookTrigger_EventFiltersRoundTripAsJSONArray(t *testing.T) { agentID := createWebhookTestAgent(t, "EventFilterRT Agent") apID := createWebhookTestAutopilot(t, agentID, "active", "run_only") body := map[string]any{ "kind": "webhook", "event_filters": []map[string]any{ {"event": "workflow_run", "actions": []string{"completed"}}, {"event": "pull_request", "actions": []string{"opened", "synchronize"}}, }, } w := httptest.NewRecorder() req := newRequest("POST", "/api/autopilots/"+apID+"/triggers", body) req = withURLParam(req, "id", apID) testHandler.CreateAutopilotTrigger(w, req) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d body=%s", w.Code, w.Body.String()) } // Decode against the strongly-typed response to verify shape. var typed AutopilotTriggerResponse if err := json.Unmarshal(w.Body.Bytes(), &typed); err != nil { t.Fatalf("decode typed: %v", err) } if len(typed.EventFilters) != 2 { t.Fatalf("expected 2 filters, got %d body=%s", len(typed.EventFilters), w.Body.String()) } if typed.EventFilters[0].Event != "workflow_run" || len(typed.EventFilters[0].Actions) != 1 || typed.EventFilters[0].Actions[0] != "completed" { t.Fatalf("first filter mismatch: %#v", typed.EventFilters[0]) } if typed.EventFilters[1].Event != "pull_request" || len(typed.EventFilters[1].Actions) != 2 { t.Fatalf("second filter mismatch: %#v", typed.EventFilters[1]) } // Decode against json.RawMessage to confirm we serialize as an array, // not a base64-encoded string. A regression here is exactly the bug // PR #3231 review flagged: []byte through encoding/json produced // `"event_filters": "W3si..."` which the UI can't .map() over. var raw struct { EventFilters json.RawMessage `json:"event_filters"` } if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil { t.Fatalf("decode raw: %v", err) } trimmed := bytes.TrimSpace(raw.EventFilters) if len(trimmed) == 0 || trimmed[0] != '[' { t.Fatalf("event_filters must serialize as a JSON array, got %s", raw.EventFilters) } } func TestUpdateWebhookTrigger_ExplicitEmptyArrayClearsFilters(t *testing.T) { agentID := createWebhookTestAgent(t, "EventFilterClear Agent") apID := createWebhookTestAutopilot(t, agentID, "active", "run_only") created := createWebhookTriggerWithFilters(t, apID, []WebhookEventFilter{ {Event: "workflow_run", Actions: []string{"completed"}}, }) if len(created.EventFilters) != 1 { t.Fatalf("seed should have 1 filter, got %d", len(created.EventFilters)) } w := httptest.NewRecorder() req := newRequest("PATCH", "/api/autopilots/"+apID+"/triggers/"+created.ID, map[string]any{ "event_filters": []any{}, }) req = withURLParams(req, "id", apID, "triggerId", created.ID) testHandler.UpdateAutopilotTrigger(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) } var updated AutopilotTriggerResponse if err := json.Unmarshal(w.Body.Bytes(), &updated); err != nil { t.Fatalf("decode: %v", err) } if len(updated.EventFilters) != 0 { t.Fatalf("expected cleared filters in response, got %#v", updated.EventFilters) } // Stored row should now accept any event (matcher sees length 0). row, err := testHandler.Queries.GetAutopilotTrigger(context.Background(), parseUUID(created.ID)) if err != nil { t.Fatalf("load: %v", err) } env := WebhookEnvelope{ Event: "github.something_else.opened", EventPayload: json.RawMessage(`{"action":"opened"}`), } if !webhookEventAllowedByTriggerScope(row.EventFilters, env) { t.Fatal("after clear, matcher should allow all events") } } func TestUpdateWebhookTrigger_OmittedFiltersPreserveExisting(t *testing.T) { agentID := createWebhookTestAgent(t, "EventFilterPreserve Agent") apID := createWebhookTestAutopilot(t, agentID, "active", "run_only") created := createWebhookTriggerWithFilters(t, apID, []WebhookEventFilter{ {Event: "workflow_run", Actions: []string{"completed"}}, }) // PATCH that does NOT include event_filters at all. Must leave the // existing filter set untouched (omitted ≠ clear). w := httptest.NewRecorder() req := newRequest("PATCH", "/api/autopilots/"+apID+"/triggers/"+created.ID, map[string]any{ "label": "renamed-but-keep-filters", }) req = withURLParams(req, "id", apID, "triggerId", created.ID) testHandler.UpdateAutopilotTrigger(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) } var updated AutopilotTriggerResponse if err := json.Unmarshal(w.Body.Bytes(), &updated); err != nil { t.Fatalf("decode: %v", err) } if len(updated.EventFilters) != 1 || updated.EventFilters[0].Event != "workflow_run" { t.Fatalf("filters must be preserved when field omitted, got %#v", updated.EventFilters) } } func TestUpdateWebhookTrigger_ReplacesFilters(t *testing.T) { agentID := createWebhookTestAgent(t, "EventFilterReplace Agent") apID := createWebhookTestAutopilot(t, agentID, "active", "run_only") created := createWebhookTriggerWithFilters(t, apID, []WebhookEventFilter{ {Event: "workflow_run", Actions: []string{"completed"}}, }) w := httptest.NewRecorder() req := newRequest("PATCH", "/api/autopilots/"+apID+"/triggers/"+created.ID, map[string]any{ "event_filters": []map[string]any{ {"event": "pull_request", "actions": []string{"opened"}}, {"event": "issues"}, }, }) req = withURLParams(req, "id", apID, "triggerId", created.ID) testHandler.UpdateAutopilotTrigger(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) } var updated AutopilotTriggerResponse if err := json.Unmarshal(w.Body.Bytes(), &updated); err != nil { t.Fatalf("decode: %v", err) } if len(updated.EventFilters) != 2 { t.Fatalf("expected 2 replaced filters, got %d", len(updated.EventFilters)) } if updated.EventFilters[0].Event != "pull_request" || updated.EventFilters[1].Event != "issues" { t.Fatalf("replaced filter list wrong: %#v", updated.EventFilters) } } func TestCreateAutopilotTrigger_RejectsInvalidEventFilter(t *testing.T) { agentID := createWebhookTestAgent(t, "EventFilterInvalid Agent") apID := createWebhookTestAutopilot(t, agentID, "active", "run_only") w := httptest.NewRecorder() req := newRequest("POST", "/api/autopilots/"+apID+"/triggers", map[string]any{ "kind": "webhook", "event_filters": []map[string]any{ {"event": "", "actions": []string{"completed"}}, }, }) req = withURLParam(req, "id", apID) testHandler.CreateAutopilotTrigger(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400 on empty event name, got %d body=%s", w.Code, w.Body.String()) } } func TestCreateAutopilotTrigger_RejectsEventFiltersOnSchedule(t *testing.T) { agentID := createWebhookTestAgent(t, "EventFilterSched Agent") apID := createWebhookTestAutopilot(t, agentID, "active", "run_only") w := httptest.NewRecorder() req := newRequest("POST", "/api/autopilots/"+apID+"/triggers", map[string]any{ "kind": "schedule", "cron_expression": "0 9 * * *", "event_filters": []map[string]any{ {"event": "workflow_run"}, }, }) req = withURLParam(req, "id", apID) testHandler.CreateAutopilotTrigger(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400 on event_filters for schedule trigger, got %d body=%s", w.Code, w.Body.String()) } } func postWebhook(t *testing.T, token string, body any, headers map[string]string) *httptest.ResponseRecorder { t.Helper() var buf bytes.Buffer switch v := body.(type) { case []byte: buf.Write(v) case string: buf.WriteString(v) default: if err := json.NewEncoder(&buf).Encode(body); err != nil { t.Fatalf("encode: %v", err) } } req := httptest.NewRequest("POST", "/api/webhooks/autopilots/"+token, &buf) for k, v := range headers { req.Header.Set(k, v) } if req.Header.Get("Content-Type") == "" { req.Header.Set("Content-Type", "application/json") } req = withURLParam(req, "token", token) w := httptest.NewRecorder() testHandler.HandleAutopilotWebhook(w, req) return w } // ── Tests ─────────────────────────────────────────────────────────────────── func TestCreateWebhookTrigger_GeneratesToken(t *testing.T) { agentID := createWebhookTestAgent(t, "WebhookGen Agent") apID := createWebhookTestAutopilot(t, agentID, "active", "run_only") resp := createWebhookTriggerViaHandler(t, apID) if resp.Kind != "webhook" { t.Fatalf("kind: %q", resp.Kind) } if resp.WebhookToken == nil || *resp.WebhookToken == "" { t.Fatal("webhook_token should be present and non-empty") } if !strings.HasPrefix(*resp.WebhookToken, "awt_") { t.Fatalf("token prefix: %q", *resp.WebhookToken) } if resp.WebhookPath == nil { t.Fatal("webhook_path should be present") } if !strings.HasSuffix(*resp.WebhookPath, *resp.WebhookToken) { t.Fatalf("webhook_path %q should contain token %q", *resp.WebhookPath, *resp.WebhookToken) } } func TestCreateWebhookTrigger_TwoUniqueTokens(t *testing.T) { agentID := createWebhookTestAgent(t, "WebhookUnique Agent") apID := createWebhookTestAutopilot(t, agentID, "active", "run_only") a := createWebhookTriggerViaHandler(t, apID) b := createWebhookTriggerViaHandler(t, apID) if a.WebhookToken == nil || b.WebhookToken == nil { t.Fatal("missing tokens") } if *a.WebhookToken == *b.WebhookToken { t.Fatalf("tokens should differ: %q == %q", *a.WebhookToken, *b.WebhookToken) } } func TestCreateWebhookTrigger_PublicURLAffectsResponse(t *testing.T) { agentID := createWebhookTestAgent(t, "WebhookURL Agent") apID := createWebhookTestAutopilot(t, agentID, "active", "run_only") prev := testHandler.cfg.PublicURL t.Cleanup(func() { testHandler.cfg.PublicURL = prev }) testHandler.cfg.PublicURL = "" respNoURL := createWebhookTriggerViaHandler(t, apID) if respNoURL.WebhookURL != nil { t.Fatalf("webhook_url should be nil when PublicURL unset, got %q", *respNoURL.WebhookURL) } testHandler.cfg.PublicURL = "https://app.example" respURL := createWebhookTriggerViaHandler(t, apID) if respURL.WebhookURL == nil { t.Fatal("webhook_url should be present when PublicURL set") } if !strings.HasPrefix(*respURL.WebhookURL, "https://app.example/api/webhooks/autopilots/") { t.Fatalf("webhook_url shape: %q", *respURL.WebhookURL) } } func TestWebhookHandler_404OnUnknownToken(t *testing.T) { w := postWebhook(t, "awt_unknown_token_value", map[string]any{"hello": "world"}, nil) if w.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d body=%s", w.Code, w.Body.String()) } } func TestWebhookHandler_RejectsInvalidJSON(t *testing.T) { agentID := createWebhookTestAgent(t, "WebhookBadJSON Agent") apID := createWebhookTestAutopilot(t, agentID, "active", "run_only") trig := createWebhookTriggerViaHandler(t, apID) w := postWebhook(t, *trig.WebhookToken, []byte(`not json`), nil) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d body=%s", w.Code, w.Body.String()) } } func TestWebhookHandler_RejectsScalarBody(t *testing.T) { agentID := createWebhookTestAgent(t, "WebhookScalar Agent") apID := createWebhookTestAutopilot(t, agentID, "active", "run_only") trig := createWebhookTriggerViaHandler(t, apID) w := postWebhook(t, *trig.WebhookToken, []byte(`"hello"`), nil) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d body=%s", w.Code, w.Body.String()) } } func TestWebhookHandler_RejectsOversized(t *testing.T) { agentID := createWebhookTestAgent(t, "WebhookSize Agent") apID := createWebhookTestAutopilot(t, agentID, "active", "run_only") trig := createWebhookTriggerViaHandler(t, apID) big := make([]byte, maxWebhookBodyBytes+10) for i := range big { big[i] = 'a' } body := append([]byte(`{"x":"`), big...) body = append(body, []byte(`"}`)...) w := postWebhook(t, *trig.WebhookToken, body, nil) if w.Code != http.StatusRequestEntityTooLarge { t.Fatalf("expected 413, got %d body=%s", w.Code, w.Body.String()) } } func TestWebhookHandler_DisabledTriggerReturnsIgnored(t *testing.T) { agentID := createWebhookTestAgent(t, "WebhookDisabled Agent") apID := createWebhookTestAutopilot(t, agentID, "active", "run_only") trig := createWebhookTriggerViaHandler(t, apID) if _, err := testHandler.Queries.UpdateAutopilotTrigger(context.Background(), db.UpdateAutopilotTriggerParams{ ID: parseUUID(trig.ID), Enabled: pgtype.Bool{Bool: false, Valid: true}, }); err != nil { t.Fatalf("disable trigger: %v", err) } w := postWebhook(t, *trig.WebhookToken, map[string]any{"hello": "world"}, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) } var resp map[string]any json.Unmarshal(w.Body.Bytes(), &resp) if resp["status"] != "ignored" { t.Fatalf("status: %v", resp["status"]) } if resp["reason"] != "trigger_disabled" { t.Fatalf("reason: %v", resp["reason"]) } } func TestWebhookHandler_PausedAutopilotReturnsIgnored(t *testing.T) { agentID := createWebhookTestAgent(t, "WebhookPaused Agent") apID := createWebhookTestAutopilot(t, agentID, "paused", "run_only") trig := createWebhookTriggerViaHandler(t, apID) w := postWebhook(t, *trig.WebhookToken, map[string]any{"x": 1}, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) } var resp map[string]any json.Unmarshal(w.Body.Bytes(), &resp) if resp["reason"] != "autopilot_paused" { t.Fatalf("reason: %v", resp["reason"]) } } func TestWebhookHandler_ActiveDispatchesRunWithPayload(t *testing.T) { agentID := createWebhookTestAgent(t, "WebhookDispatch Agent") apID := createWebhookTestAutopilot(t, agentID, "active", "run_only") trig := createWebhookTriggerViaHandler(t, apID) w := postWebhook(t, *trig.WebhookToken, map[string]any{ "event": "demo.received", "eventPayload": map[string]any{"k": "v"}, }, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) } var resp map[string]any if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } if resp["status"] != "accepted" { t.Fatalf("expected accepted, got %v body=%s", resp["status"], w.Body.String()) } runID, _ := resp["run_id"].(string) if runID == "" { t.Fatal("run_id missing from response") } // Validate the persisted run carries the normalized envelope. run, err := testHandler.Queries.GetAutopilotRun(context.Background(), parseUUID(runID)) if err != nil { t.Fatalf("load run: %v", err) } if run.Source != "webhook" { t.Fatalf("run.source: %q", run.Source) } if uuidToString(run.TriggerID) != trig.ID { t.Fatalf("run.trigger_id mismatch: %q vs %q", uuidToString(run.TriggerID), trig.ID) } var payload struct { Event string `json:"event"` EventPayload map[string]interface{} `json:"eventPayload"` } if err := json.Unmarshal(run.TriggerPayload, &payload); err != nil { t.Fatalf("payload decode: %v body=%s", err, string(run.TriggerPayload)) } if payload.Event != "demo.received" { t.Fatalf("envelope event: %q", payload.Event) } if payload.EventPayload["k"] != "v" { t.Fatalf("envelope payload: %#v", payload.EventPayload) } // last_fired_at must have been bumped. trigRow, err := testHandler.Queries.GetAutopilotTrigger(context.Background(), parseUUID(trig.ID)) if err != nil { t.Fatalf("load trigger: %v", err) } if !trigRow.LastFiredAt.Valid { t.Fatal("last_fired_at should be set after webhook dispatch") } } func TestWebhookHandler_GitHubHeaderInferredEvent(t *testing.T) { agentID := createWebhookTestAgent(t, "WebhookGH Agent") apID := createWebhookTestAutopilot(t, agentID, "active", "run_only") trig := createWebhookTriggerViaHandler(t, apID) w := postWebhook(t, *trig.WebhookToken, map[string]any{ "action": "opened", "pull_request": map[string]any{ "number": 42, }, }, map[string]string{"X-GitHub-Event": "pull_request"}) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) } var resp map[string]any json.Unmarshal(w.Body.Bytes(), &resp) runID := resp["run_id"].(string) run, err := testHandler.Queries.GetAutopilotRun(context.Background(), parseUUID(runID)) if err != nil { t.Fatalf("load run: %v", err) } var env struct { Event string `json:"event"` } json.Unmarshal(run.TriggerPayload, &env) if env.Event != "github.pull_request.opened" { t.Fatalf("event inference: got %q", env.Event) } } func TestWebhookHandler_RateLimitReturns429(t *testing.T) { agentID := createWebhookTestAgent(t, "WebhookRate Agent") apID := createWebhookTestAutopilot(t, agentID, "paused", "run_only") // paused → cheap ignored path trig := createWebhookTriggerViaHandler(t, apID) prev := testHandler.WebhookRateLimiter testHandler.WebhookRateLimiter = NewMemoryWebhookRateLimiter(WebhookRateLimit{Limit: 2, Window: 60_000_000_000}) t.Cleanup(func() { testHandler.WebhookRateLimiter = prev }) for i := 0; i < 2; i++ { w := postWebhook(t, *trig.WebhookToken, map[string]any{"i": i}, nil) if w.Code != http.StatusOK { t.Fatalf("request %d: expected 200, got %d", i, w.Code) } } w := postWebhook(t, *trig.WebhookToken, map[string]any{"i": "third"}, nil) if w.Code != http.StatusTooManyRequests { t.Fatalf("expected 429, got %d body=%s", w.Code, w.Body.String()) } } func TestRotateWebhookToken_ReplacesOldToken(t *testing.T) { agentID := createWebhookTestAgent(t, "WebhookRotate Agent") apID := createWebhookTestAutopilot(t, agentID, "active", "run_only") trig := createWebhookTriggerViaHandler(t, apID) oldToken := *trig.WebhookToken w := httptest.NewRecorder() req := newRequest("POST", fmt.Sprintf("/api/autopilots/%s/triggers/%s/rotate-webhook-token", apID, trig.ID), nil) req = withURLParams(req, "id", apID, "triggerId", trig.ID) testHandler.RotateAutopilotTriggerWebhookToken(w, req) if w.Code != http.StatusOK { t.Fatalf("rotate: expected 200, got %d body=%s", w.Code, w.Body.String()) } var rotated AutopilotTriggerResponse json.Unmarshal(w.Body.Bytes(), &rotated) if rotated.WebhookToken == nil || *rotated.WebhookToken == oldToken { t.Fatalf("rotate did not change token: old=%q new=%v", oldToken, rotated.WebhookToken) } // Old token should now 404. resOld := postWebhook(t, oldToken, map[string]any{"x": 1}, nil) if resOld.Code != http.StatusNotFound { t.Fatalf("old token should be 404, got %d", resOld.Code) } // New token should accept. resNew := postWebhook(t, *rotated.WebhookToken, map[string]any{"x": 1}, nil) if resNew.Code != http.StatusOK { t.Fatalf("new token should be 200, got %d body=%s", resNew.Code, resNew.Body.String()) } } // ── Additional coverage (PR #2348 review) ────────────────────────────────── func TestWebhookHandler_EmptyBodyReturns400(t *testing.T) { agentID := createWebhookTestAgent(t, "WebhookEmpty Agent") apID := createWebhookTestAutopilot(t, agentID, "active", "run_only") trig := createWebhookTriggerViaHandler(t, apID) w := postWebhook(t, *trig.WebhookToken, []byte(``), nil) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400 for empty body, got %d body=%s", w.Code, w.Body.String()) } } func TestWebhookHandler_ArchivedAutopilotReturnsIgnored(t *testing.T) { agentID := createWebhookTestAgent(t, "WebhookArchived Agent") apID := createWebhookTestAutopilot(t, agentID, "active", "run_only") trig := createWebhookTriggerViaHandler(t, apID) if _, err := testPool.Exec(context.Background(), `UPDATE autopilot SET status = 'archived' WHERE id = $1`, apID); err != nil { t.Fatalf("archive autopilot: %v", err) } w := postWebhook(t, *trig.WebhookToken, map[string]any{"x": 1}, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) } var resp map[string]any json.Unmarshal(w.Body.Bytes(), &resp) if resp["status"] != "ignored" || resp["reason"] != "autopilot_archived" { t.Fatalf("expected ignored/autopilot_archived, got %#v", resp) } } func TestWebhookHandler_IPRateLimitReturns429BeforeDBLookup(t *testing.T) { // Spray random (likely-unknown) tokens from one IP and prove the IP // limiter trips before we exhaust the budget — without this gate an // attacker can probe the trigger-lookup index unboundedly. Rate-limit // keying is by the real source IP (r.RemoteAddr) since TrustedProxies // is empty here, so the bucket is per-connection — exactly the // property the per-IP limiter is meant to provide. prev := testHandler.WebhookIPRateLimiter testHandler.WebhookIPRateLimiter = NewMemoryWebhookIPRateLimiter(WebhookRateLimit{Limit: 2, Window: 60_000_000_000}) t.Cleanup(func() { testHandler.WebhookIPRateLimiter = prev }) post := func(token string) int { req := httptest.NewRequest("POST", "/api/webhooks/autopilots/"+token, bytes.NewBufferString(`{"x":1}`)) req.Header.Set("Content-Type", "application/json") req.RemoteAddr = "192.0.2.7:1234" // stable source, three calls = same bucket req = withURLParam(req, "token", token) w := httptest.NewRecorder() testHandler.HandleAutopilotWebhook(w, req) return w.Code } if got := post("awt_unknown_a"); got != http.StatusNotFound { t.Fatalf("first probe: expected 404, got %d", got) } if got := post("awt_unknown_b"); got != http.StatusNotFound { t.Fatalf("second probe: expected 404, got %d", got) } if got := post("awt_unknown_c"); got != http.StatusTooManyRequests { t.Fatalf("third probe: expected 429 (IP bucket), got %d", got) } } func TestWebhookHandler_IPRateLimitNotBypassedByXFFSpoof(t *testing.T) { // Round-2 fix: with the default empty TrustedProxies, an attacker who // rotates X-Forwarded-For per request must still get bucketed by the // real connection IP — otherwise the per-IP limiter is trivially // bypassable and we're back to one DB index probe per request. prev := testHandler.WebhookIPRateLimiter testHandler.WebhookIPRateLimiter = NewMemoryWebhookIPRateLimiter(WebhookRateLimit{Limit: 2, Window: 60_000_000_000}) t.Cleanup(func() { testHandler.WebhookIPRateLimiter = prev }) post := func(token, xff string) int { req := httptest.NewRequest("POST", "/api/webhooks/autopilots/"+token, bytes.NewBufferString(`{"x":1}`)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Forwarded-For", xff) // <-- attacker-controlled req.RemoteAddr = "198.51.100.42:5555" // real (untrusted) source req = withURLParam(req, "token", token) w := httptest.NewRecorder() testHandler.HandleAutopilotWebhook(w, req) return w.Code } if got := post("awt_unknown_x", "1.1.1.1"); got != http.StatusNotFound { t.Fatalf("first probe: expected 404, got %d", got) } if got := post("awt_unknown_y", "2.2.2.2"); got != http.StatusNotFound { t.Fatalf("second probe: expected 404, got %d", got) } // Third request with yet another spoofed XFF — would have bypassed // the limiter under the old (header-trusting) behavior, but with the // CIDR-gated trust the bucket is still the real source IP. if got := post("awt_unknown_z", "3.3.3.3"); got != http.StatusTooManyRequests { t.Fatalf("third probe: expected 429 (bucket keyed by real IP), got %d", got) } } func TestWebhookHandler_DBErrorOnTokenLookupReturns500(t *testing.T) { // Inject a fake Queries-like wrapper via a shadow type isn't simple // here because Handler.Queries is a *db.Queries struct, not an // interface. Instead, simulate the path by deleting the trigger row // out from under a known-valid token AND swapping in a stub limiter // that always allows. The handler will then call // GetWebhookTriggerByToken with a non-existent value → pgx.ErrNoRows // → 404. So this test alone can't drive the 500 branch without // breaking the DB connection. The 500 branch is exercised via the // unit tests in this package's TestRedactWebhookPath / // TestRequestLogger_*; we leave a regression marker here so a future // refactor that collapses ErrNoRows into 500 (or vice versa) is // caught by a code review rather than a missing test. // // The real verification for this branch is: the file diff for // autopilot_webhook.go must show `errors.Is(err, pgx.ErrNoRows)` — // see PR #2348 review item Blocking #2. t.Skip("500-branch requires injecting a stub Queries; left as a code-review-protected invariant") } func TestCreateAutopilotTrigger_RejectsAPIKind(t *testing.T) { agentID := createWebhookTestAgent(t, "WebhookAPIKind Agent") apID := createWebhookTestAutopilot(t, agentID, "active", "run_only") w := httptest.NewRecorder() req := newRequest("POST", "/api/autopilots/"+apID+"/triggers", map[string]any{ "kind": "api", }) req = withURLParam(req, "id", apID) testHandler.CreateAutopilotTrigger(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400 on kind=api, got %d body=%s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "schedule or webhook") { t.Fatalf("expected message to name allowed kinds, body=%s", w.Body.String()) } } func TestCreateAutopilotTrigger_RejectsWebhookWithTimezone(t *testing.T) { agentID := createWebhookTestAgent(t, "WebhookTZReject Agent") apID := createWebhookTestAutopilot(t, agentID, "active", "run_only") w := httptest.NewRecorder() req := newRequest("POST", "/api/autopilots/"+apID+"/triggers", map[string]any{ "kind": "webhook", "timezone": "Europe/Berlin", }) req = withURLParam(req, "id", apID) testHandler.CreateAutopilotTrigger(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400 on webhook+timezone, got %d body=%s", w.Code, w.Body.String()) } } func TestUpdateAutopilotTrigger_RejectsCronExpressionOnWebhookKind(t *testing.T) { // Round-2 should-fix: UpdateAutopilotTrigger must mirror create-path // strictness — cron_expression on a non-schedule trigger is rejected // with 400 rather than silently persisted. agentID := createWebhookTestAgent(t, "WebhookUpdCron Agent") apID := createWebhookTestAutopilot(t, agentID, "active", "run_only") trig := createWebhookTriggerViaHandler(t, apID) w := httptest.NewRecorder() req := newRequest("PATCH", "/api/autopilots/"+apID+"/triggers/"+trig.ID, map[string]any{ "cron_expression": "0 0 * * *", }) req = withURLParams(req, "id", apID, "triggerId", trig.ID) testHandler.UpdateAutopilotTrigger(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400 on cron_expression for webhook trigger, got %d body=%s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "cron_expression") { t.Fatalf("error message should mention cron_expression, got %s", w.Body.String()) } } func TestUpdateAutopilotTrigger_RejectsTimezoneOnWebhookKind(t *testing.T) { agentID := createWebhookTestAgent(t, "WebhookUpdTZ Agent") apID := createWebhookTestAutopilot(t, agentID, "active", "run_only") trig := createWebhookTriggerViaHandler(t, apID) w := httptest.NewRecorder() req := newRequest("PATCH", "/api/autopilots/"+apID+"/triggers/"+trig.ID, map[string]any{ "timezone": "Europe/Berlin", }) req = withURLParams(req, "id", apID, "triggerId", trig.ID) testHandler.UpdateAutopilotTrigger(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400 on timezone for webhook trigger, got %d body=%s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "timezone") { t.Fatalf("error message should mention timezone, got %s", w.Body.String()) } } func TestUpdateAutopilotTrigger_AcceptsEnabledAndLabelOnWebhookKind(t *testing.T) { // Counter-test: enabled and label remain valid on every kind. Without // this, the kind-aware guard could regress to a blanket reject. agentID := createWebhookTestAgent(t, "WebhookUpdAllowed Agent") apID := createWebhookTestAutopilot(t, agentID, "active", "run_only") trig := createWebhookTriggerViaHandler(t, apID) w := httptest.NewRecorder() req := newRequest("PATCH", "/api/autopilots/"+apID+"/triggers/"+trig.ID, map[string]any{ "enabled": false, "label": "renamed", }) req = withURLParams(req, "id", apID, "triggerId", trig.ID) testHandler.UpdateAutopilotTrigger(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200 on enabled+label PATCH, got %d body=%s", w.Code, w.Body.String()) } } func TestGetAutopilotRun_ReturnsFullPayload(t *testing.T) { // List endpoint omits trigger_payload; the new GET /runs/{runId} // endpoint must return it intact. agentID := createWebhookTestAgent(t, "WebhookGetRun Agent") apID := createWebhookTestAutopilot(t, agentID, "active", "run_only") trig := createWebhookTriggerViaHandler(t, apID) // Fire one webhook so there's a run with a payload. post := postWebhook(t, *trig.WebhookToken, map[string]any{ "event": "demo.x", "eventPayload": map[string]any{"answer": 42}, }, nil) if post.Code != http.StatusOK { t.Fatalf("seed webhook: %d body=%s", post.Code, post.Body.String()) } var seedResp map[string]any json.Unmarshal(post.Body.Bytes(), &seedResp) runID := seedResp["run_id"].(string) // LIST: trigger_payload should be omitted (slim response). wList := httptest.NewRecorder() reqList := newRequest("GET", "/api/autopilots/"+apID+"/runs", nil) reqList = withURLParam(reqList, "id", apID) testHandler.ListAutopilotRuns(wList, reqList) if wList.Code != http.StatusOK { t.Fatalf("list: expected 200, got %d body=%s", wList.Code, wList.Body.String()) } if strings.Contains(wList.Body.String(), `"answer":42`) { t.Fatalf("list response should NOT carry trigger_payload, body=%s", wList.Body.String()) } // DETAIL: trigger_payload should be present. wDetail := httptest.NewRecorder() reqDetail := newRequest("GET", "/api/autopilots/"+apID+"/runs/"+runID, nil) reqDetail = withURLParams(reqDetail, "id", apID, "runId", runID) testHandler.GetAutopilotRun(wDetail, reqDetail) if wDetail.Code != http.StatusOK { t.Fatalf("detail: expected 200, got %d body=%s", wDetail.Code, wDetail.Body.String()) } if !strings.Contains(wDetail.Body.String(), `"answer":42`) { t.Fatalf("detail response should carry full trigger_payload, body=%s", wDetail.Body.String()) } } // NOTE: the cross-workspace paranoia branch in autopilot_webhook.go // (uuidToString(autopilot.WorkspaceID) != uuidToString(trigRow.AutopilotWorkspaceID)) // is defense-in-depth against a TOCTOU race between the joined token // lookup and the follow-up GetAutopilot read. It is not reachable from // any valid SQL state — the two reads compute against the same // autopilot.workspace_id column — and would require a mock-able // Queries interface to drive deterministically. We pin the behaviour // via code review rather than a brittle race test. See PR #2348 review // item under "Test coverage gaps."