Files
kandev/apps/web/e2e/tests/automations-webhook-alert-ingest.spec.ts
T
2026-09-18 10:38:46 +01:00

348 lines
16 KiB
TypeScript

import { request as playwrightRequest } from "@playwright/test";
import { test, expect, type Page } from "../fixtures/test-base";
import { AutomationsPage } from "../pages/automations-page";
/**
* E2E coverage for T01: the webhook trigger's three configuration-only alert
* ingest fields — dedup_key, filters, repository.selector_path.
*
* Spec: docs/plans/alert-ingest/task-01-webhook-alert-ingest-path.md
*
* The UI round-trip test drives the three new fields entirely through the
* automation editor. The three "fires for real" tests below it seed the
* config via the API (mirroring existing trigger-type precedent) and then
* POST directly at the production webhook route with the revealed secret —
* the same path an external vendor uses — so the assertions are against the
* real backend admission/dedup/filter/repository-binding logic and its
* rendering in the runs list, not a mock.
*/
async function revealWebhookSecret(
testPage: Page,
triggerCard = testPage.getByTestId("trigger-card-webhook"),
) {
const secretInput = triggerCard.getByTestId("automation-webhook-secret-input");
await expect(secretInput).toHaveValue(/^•+$/, { timeout: 10_000 });
await triggerCard.getByTestId("automation-webhook-secret-toggle").click();
await expect(secretInput).not.toHaveValue(/^•+$/);
const secret = await secretInput.inputValue();
expect(secret).toBeTruthy();
return secret;
}
// Uses an isolated APIRequestContext rather than testPage.request: the page's
// request context shares cookies with the authenticated browser session, so a
// webhook route that incorrectly required or trusted that session could still
// pass. A fresh context with no storage state proves the route works from an
// unauthenticated caller carrying only X-Webhook-Secret, matching how a real
// external vendor calls it.
async function postWebhook(webhookUrl: string, secret: string, body: unknown) {
const context = await playwrightRequest.newContext();
try {
const res = await context.post(webhookUrl, {
headers: { "X-Webhook-Secret": secret },
data: body,
});
expect(res.status()).toBe(200);
const json = (await res.json()) as { status: string };
expect(json.status).toBe("triggered");
} finally {
await context.dispose();
}
}
/** Poll the runs list by clicking Refresh until `predicate` is satisfied. */
async function waitForRuns(testPage: Page, predicate: () => Promise<boolean>) {
await expect
.poll(
async () => {
await testPage.getByRole("button", { name: "Refresh" }).click();
return predicate();
},
{ timeout: 20_000, intervals: [500, 1000, 1000, 2000] },
)
.toBe(true);
}
async function openWebhookAutomation(testPage: Page, workspaceId: string, automationId: string) {
await testPage.goto(`/settings/workspace/${workspaceId}/automations/${automationId}`);
await testPage.getByTestId("automation-editor").waitFor({ state: "visible", timeout: 15_000 });
await testPage.locator("button", { hasText: "Webhook" }).click();
const triggerCard = testPage.getByTestId("trigger-card-webhook");
await expect(triggerCard).toBeVisible();
return triggerCard;
}
/** The runs table renders only once the "Recent Runs" disclosure is expanded. */
async function expandRunsSection(testPage: Page) {
await testPage.getByText(/Recent Runs?\s*\(/).click();
await expect(testPage.getByRole("button", { name: "Refresh" })).toBeVisible({ timeout: 5_000 });
}
test.describe("automations — webhook alert ingest (T01)", () => {
test("dedup key, filters and repository selector round-trip through save/reload", async ({
testPage,
seedData,
}) => {
const automations = new AutomationsPage(testPage, seedData.workspaceId);
await automations.gotoNew();
await automations.nameInput.fill("Webhook Admission Round-trip");
await testPage.getByText("Or use a webhook instead").click();
await automations.selectWorkflow("E2E Workflow");
// Expand the webhook trigger card.
await testPage.locator("button", { hasText: "Webhook" }).click();
const triggerCard = testPage.getByTestId("trigger-card-webhook");
// Dedup key.
await triggerCard.getByPlaceholder("issue.id").fill("issue.id");
// One filter: path/op/values.
await triggerCard.getByRole("button", { name: "Add filter" }).click();
await triggerCard.getByPlaceholder("severity").fill("severity");
await triggerCard.getByRole("combobox").click();
await testPage.getByRole("option", { name: "In list", exact: true }).click();
const valuesInput = triggerCard.getByPlaceholder("critical, fatal");
await valuesInput.fill("critical, fatal");
await valuesInput.blur();
// A Crashlytics alert uses an explicit empty string to reject alerts with
// no issue identifier. Leave this scalar value blank, then verify that
// save/reload keeps the one-value filter valid.
await triggerCard.getByRole("button", { name: "Add filter" }).click();
const filterPaths = triggerCard.getByPlaceholder("severity");
await filterPaths.nth(1).fill("issue.id");
await triggerCard.getByRole("combobox").nth(1).click();
await testPage.getByRole("option", { name: "Not equals", exact: true }).click();
const emptyScalarInput = triggerCard.getByPlaceholder("critical", { exact: true });
await emptyScalarInput.focus();
await emptyScalarInput.blur();
// Repository selector.
await triggerCard.getByPlaceholder("service").fill("service");
// Save — new webhook automations show the reveal dialog, not a redirect.
await expect(automations.saveButton).toBeEnabled({ timeout: 5_000 });
await automations.saveButton.click();
await expect(testPage.getByTestId("webhook-created-dialog")).toBeVisible({ timeout: 10_000 });
await testPage.getByTestId("webhook-created-dialog-close").click();
await expect(testPage).toHaveURL(/automations$/, { timeout: 15_000 });
// Reopen and verify every field persisted.
await expect(automations.table).toBeVisible({ timeout: 10_000 });
await automations.openByName("Webhook Admission Round-trip");
await expect(testPage).toHaveURL(/automations\/[a-f0-9-]+$/, { timeout: 10_000 });
await expect(automations.editor).toBeVisible();
await testPage.locator("button", { hasText: "Webhook" }).click();
const reopened = testPage.getByTestId("trigger-card-webhook");
await expect(reopened.getByPlaceholder("issue.id")).toHaveValue("issue.id");
const reopenedFilterPaths = reopened.getByPlaceholder("severity");
await expect(reopenedFilterPaths).toHaveCount(2);
await expect(reopenedFilterPaths.nth(0)).toHaveValue("severity");
await expect(reopenedFilterPaths.nth(1)).toHaveValue("issue.id");
await expect(reopened.getByRole("combobox").nth(0)).toContainText("In list");
await expect(reopened.getByRole("combobox").nth(1)).toContainText("Not equals");
await expect(reopened.getByPlaceholder("critical, fatal")).toHaveValue("critical, fatal");
await expect(reopened.getByPlaceholder("critical", { exact: true })).toHaveValue("");
await expect(reopened.getByPlaceholder("service")).toHaveValue("service");
});
test("two POSTs resolving the same dedup key create one task; the duplicate is recorded skipped with its reason", async ({
testPage,
apiClient,
seedData,
}) => {
const automation = await apiClient.seedAutomation({
workspaceId: seedData.workspaceId,
name: "Dedup E2E",
workflowId: seedData.workflowId,
workflowStepId: seedData.startStepId,
});
await apiClient.seedTrigger({
automationId: automation.id,
type: "webhook",
config: { dedup_key: "issue.id" },
enabled: true,
});
const triggerCard = await openWebhookAutomation(testPage, seedData.workspaceId, automation.id);
const secret = await revealWebhookSecret(testPage, triggerCard);
await expandRunsSection(testPage);
const webhookUrl = `${new URL(testPage.url()).origin}/api/v1/automations/webhook/${automation.id}`;
const payload = { issue: { id: "e2e-dedup-key-001" } };
await postWebhook(webhookUrl, secret, payload);
await postWebhook(webhookUrl, secret, payload);
await waitForRuns(testPage, async () => (await testPage.getByTestId(/^run-row-/).count()) >= 2);
const rows = testPage.getByTestId(/^run-row-/);
await expect(rows).toHaveCount(2);
const duplicateRows = rows.filter({ hasText: "duplicate trigger: dedup key already fired" });
await expect(duplicateRows).toHaveCount(1);
await expect(duplicateRows.getByText("Skipped", { exact: true })).toBeVisible();
const admittedRows = rows.filter({ hasNotText: "duplicate trigger: dedup key already fired" });
await expect(admittedRows).toHaveCount(1);
await expect(admittedRows.getByText("Skipped", { exact: true })).toHaveCount(0);
});
test("a trigger with no dedup_key fires undeduplicated on repeated identical POSTs", async ({
testPage,
apiClient,
seedData,
}) => {
const automation = await apiClient.seedAutomation({
workspaceId: seedData.workspaceId,
name: "No Dedup Configured E2E",
workflowId: seedData.workflowId,
workflowStepId: seedData.startStepId,
});
await apiClient.seedTrigger({
automationId: automation.id,
type: "webhook",
config: {},
enabled: true,
});
const triggerCard = await openWebhookAutomation(testPage, seedData.workspaceId, automation.id);
const secret = await revealWebhookSecret(testPage, triggerCard);
await expandRunsSection(testPage);
const webhookUrl = `${new URL(testPage.url()).origin}/api/v1/automations/webhook/${automation.id}`;
const payload = { issue: { id: "same-value-every-time" } };
await postWebhook(webhookUrl, secret, payload);
await postWebhook(webhookUrl, secret, payload);
await waitForRuns(testPage, async () => (await testPage.getByTestId(/^run-row-/).count()) >= 2);
const rows = testPage.getByTestId(/^run-row-/);
await expect(rows).toHaveCount(2);
// Neither delivery is recorded as a duplicate — no dedup_key means today's
// behavior (fire on every POST) is unchanged.
await expect(rows.filter({ hasText: "duplicate trigger" })).toHaveCount(0);
});
test("a payload failing a filter creates no task and records the rejecting predicate", async ({
testPage,
apiClient,
seedData,
}) => {
const automation = await apiClient.seedAutomation({
workspaceId: seedData.workspaceId,
name: "Filter Rejection E2E",
workflowId: seedData.workflowId,
workflowStepId: seedData.startStepId,
});
await apiClient.seedTrigger({
automationId: automation.id,
type: "webhook",
config: { filters: [{ path: "severity", op: "eq", values: ["fatal"] }] },
enabled: true,
});
const triggerCard = await openWebhookAutomation(testPage, seedData.workspaceId, automation.id);
const secret = await revealWebhookSecret(testPage, triggerCard);
await expandRunsSection(testPage);
const webhookUrl = `${new URL(testPage.url()).origin}/api/v1/automations/webhook/${automation.id}`;
await postWebhook(webhookUrl, secret, { severity: "info" });
await waitForRuns(testPage, async () => (await testPage.getByTestId(/^run-row-/).count()) >= 1);
const rows = testPage.getByTestId(/^run-row-/);
await expect(rows).toHaveCount(1);
await expect(rows.getByText("Skipped", { exact: true })).toBeVisible();
await expect(rows.getByTestId("run-outcome")).toContainText("filter_rejected: 0");
// A filtered payload consumed no dedup key: no reason suffix is rendered.
await expect(rows.getByTestId("run-outcome-reason")).toHaveCount(0);
});
test("a payload naming a repository outside the automation's configured repositories binds nothing (security: the webhook secret alone must not select an unconfigured repository)", async ({
testPage,
apiClient,
seedData,
}) => {
const { repositories } = await apiClient.listRepositories(seedData.workspaceId);
const configuredRepo =
repositories.find((r) => r.id === seedData.repositoryId) ?? repositories[0];
expect(configuredRepo).toBeTruthy();
const automation = await apiClient.seedAutomation({
workspaceId: seedData.workspaceId,
name: "Repository Selector Security E2E",
workflowId: seedData.workflowId,
workflowStepId: seedData.startStepId,
repositoryMode: "selected",
repositoryIds: [configuredRepo!.id],
agentProfileId: seedData.agentProfileId,
executorProfileId: seedData.worktreeExecutorProfileId,
});
await apiClient.seedTrigger({
automationId: automation.id,
type: "webhook",
config: { repository: { selector_path: "service" } },
enabled: true,
});
const triggerCard = await openWebhookAutomation(testPage, seedData.workspaceId, automation.id);
const secret = await revealWebhookSecret(testPage, triggerCard);
await expandRunsSection(testPage);
const webhookUrl = `${new URL(testPage.url()).origin}/api/v1/automations/webhook/${automation.id}`;
// The payload's "service" value looks exactly like what
// resolveGitHubPRTriggerRepository would have consumed as owner/name —
// the shape a vendor console emits — and matches no configured
// repository's Name. It must never be resolved as a new repository.
await postWebhook(webhookUrl, secret, { service: "acme/unconfigured-webapp" });
// Row count flips as soon as the run is admitted, but repository_reason is
// only written moments later by the async task-creation path (BindRunTask).
// Poll for the reason itself, not just the row's existence.
await waitForRuns(testPage, async () => {
const rows = testPage.getByTestId(/^run-row-/);
if ((await rows.count()) < 1) return false;
return (await rows.first().getByTestId("run-outcome-reason").count()) > 0;
});
const noMatchRow = testPage.getByTestId(/^run-row-/).first();
await expect(noMatchRow.getByTestId("run-outcome-reason")).toContainText(
"Repository selector matched no configured repository: acme/unconfigured-webapp",
);
// The same selector path, when its resolved value matches a configured
// repository's Name exactly, DOES bind — proven in the same test so the
// comparator can't pass vacuously. Poll for the admitted row's own
// task binding (data-task-id), not just row/reason counts: the reason
// suffix already on the *other* row satisfies a count-based predicate
// before this row's async bind (BindRunTask) has run, which would let a
// regression in that bind — e.g. admitting the wrong repository — pass
// silently.
await postWebhook(webhookUrl, secret, { service: configuredRepo!.name });
let admittedTaskId = "";
await waitForRuns(testPage, async () => {
const rows = testPage.getByTestId(/^run-row-/);
if ((await rows.count()) < 2) return false;
const admitted = rows.filter({ hasNot: testPage.getByTestId("run-outcome-reason") });
if ((await admitted.count()) !== 1) return false;
admittedTaskId = (await admitted.getAttribute("data-task-id")) ?? "";
return admittedTaskId !== "";
});
const rows = testPage.getByTestId(/^run-row-/);
await expect(rows).toHaveCount(2);
// Exactly one of the two rows carries the no-match reason; the other (the
// successful bind) records nothing, because the binding is its own record.
await expect(rows.filter({ has: testPage.getByTestId("run-outcome-reason") })).toHaveCount(1);
// Prove the admitted task actually bound to configuredRepo, not merely
// that some task exists — an assertion on the row's absence of a
// no-match reason cannot distinguish the two.
const admittedTask = await apiClient.getTask(admittedTaskId);
expect(admittedTask.repositories?.map((r) => r.repository_id)).toEqual([configuredRepo!.id]);
});
});