mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
* feat(autopilots): webhook event filters per trigger (MUL-2334 follow-up) Adds schema-backed event/action filtering to webhook triggers so operators can declare exactly which GitHub (or generic) events should spawn autopilot runs. Events outside the declared scope are recorded as ignored with reason 'event_filtered' — visible in the delivery log but without expensive run/task creation. Closes #3093 (supersedes the description-parsing approach from that PR). Backend: - Migration 108 adds event_filters JSONB to autopilot_trigger - sqlc queries updated for CREATE / UPDATE / LIST / GET - HandleAutopilotWebhook filters against trigger.event_filters before dispatch - Create/Update trigger handlers accept event_filters in the request body - Response shape includes event_filters so the UI can render it Frontend: - New WebhookEventFilterSection component in the autopilot dialog - Inputs for event name + comma-separated actions - i18n strings added (en + zh-Hans) Tests: - Unit tests for splitWebhookEvent and webhookEventAllowedByTriggerScope - Handler-level integration tests for filtered / allowed / no-filter paths co-authored-by: ZephaniaCN <agent/autopilot-webhook-filter> * fix: recognize gitlab/bitbucket/gitea as providers in splitWebhookEvent TestSplitWebhookEvent failed because only 'github' was recognized as a provider prefix. Extract isKnownProvider() to handle gitlab, bitbucket, and gitea as well. * fix(autopilots): address PR #3231 review for webhook event filters Must-fix from PR #3231 review: 1. event_filters now uses typed []WebhookEventFilter at the HTTP boundary instead of []byte. encoding/json was base64-encoding the field on the way out, so the UI could not .map() the response, and a real JSON array on the way in failed to decode. Response field also decodes the stored JSONB into a typed slice before serialising back. 2. UpdateAutopilotTriggerRequest.EventFilters is *[]WebhookEventFilter with tri-state PATCH semantics: nil pointer = leave alone, [] = clear, [...] = replace. The handler marshals an explicit empty slice to the JSONB literal `[]` so COALESCE overwrites instead of preserves. AutopilotDialog now PATCHes the webhook trigger when event_filters change in edit mode (previously the toast said "updated" while the backend was unchanged). 3. webhookEventAllowedByTriggerScope no longer short-circuits to false on the first event-name match whose actions don't line up. Earlier code silently shadowed any later filter that shared the same event name with disjoint actions. Robustness: validateWebhookEventFilters rejects empty event names / actions at write time, and the matcher fails closed on malformed stored bytes instead of widening the allowlist. Tests: handler tests now post real JSON arrays (the prior []byte path masked the contract bug). Adds round-trip / clear-with-[] / preserve- when-omitted / replace / invalid-filter / filters-on-schedule coverage, plus matcher tests for same-event multi-filter and malformed-deny. Migration renamed 108 → 110 to avoid colliding with main's 108_task_token (came in via the merge from main).
974 lines
37 KiB
Go
974 lines
37 KiB
Go
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."
|