From f6fbe0cac1b4e80d54c63d0c87bff0c57567b2de Mon Sep 17 00:00:00 2001 From: nghetienhiep <13849419+nghetienhiep@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:59:15 +0000 Subject: [PATCH] feat(api): add post-merge incident reporting for already-merged PRs Adds a reporting path for the case where an already-merged rented-loop PR is later found harmful: a repo-maintainer route (POST /v1/repos/:owner/:repo/pulls/:number/incident-reports) and an internal-operator route (POST /v1/app/incident-reports), both writing a queryable audit_events row via a shared recordPostMergeIncidentReport helper. Reports are keyed to the PR's repo#number targetKey, so the existing agent/audit-feed ?pull=N view already surfaces them back to maintainers with no separate table or read route needed. Closes #5672 --- apps/loopover-ui/public/openapi.json | 234 ++++++++++++++++ src/api/routes.ts | 90 ++++++ src/db/repositories.ts | 34 +++ src/openapi/spec.ts | 59 ++++ ...routes-post-merge-incident-reports.test.ts | 257 ++++++++++++++++++ 5 files changed, 674 insertions(+) create mode 100644 test/unit/routes-post-merge-incident-reports.test.ts diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index 293bb3a837..96922b6024 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -17522,6 +17522,240 @@ } ] } + }, + "/v1/repos/{owner}/{repo}/pulls/{number}/incident-reports": { + "post": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "number", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "description": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + }, + "severity": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "critical" + ] + }, + "mergedSha": { + "type": "string" + } + }, + "required": [ + "description", + "severity" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Post-merge incident report recorded as an audit_events row (#5672), customer-facing (repo maintainer) side", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "repoFullName": { + "type": "string" + }, + "pullNumber": { + "type": "number" + }, + "id": { + "type": "string" + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "ok", + "repoFullName", + "pullNumber", + "id", + "createdAt" + ] + } + } + } + }, + "400": { + "description": "Invalid pull number or incident report body" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Insufficient role" + }, + "404": { + "description": "Pull request not found" + }, + "409": { + "description": "Pull request has not been merged" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } + }, + "/v1/app/incident-reports": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string", + "minLength": 3, + "maxLength": 200 + }, + "pullNumber": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + }, + "severity": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "critical" + ] + }, + "mergedSha": { + "type": "string" + } + }, + "required": [ + "repoFullName", + "pullNumber", + "description", + "severity" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Post-merge incident report recorded as an audit_events row (#5672), internal-operator side", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "repoFullName": { + "type": "string" + }, + "pullNumber": { + "type": "number" + }, + "id": { + "type": "string" + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "ok", + "repoFullName", + "pullNumber", + "id", + "createdAt" + ] + } + } + } + }, + "400": { + "description": "Invalid incident report body" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Insufficient app role (operator only)" + }, + "404": { + "description": "Pull request not found" + }, + "409": { + "description": "Pull request has not been merged" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } } }, "servers": [ diff --git a/src/api/routes.ts b/src/api/routes.ts index 4a2ecba0c4..5b7e930a5c 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -53,6 +53,7 @@ import { listAuditEventsForTarget, listPendingAgentActions, recordAuditEvent, + recordPostMergeIncidentReport, getContributorEvidence, getProductUsageRollupStatus, listAllPullRequestDetailSyncStates, @@ -890,6 +891,32 @@ const digestSubscriptionSchema = z }) .strict(); +const postMergeIncidentSeveritySchema = z.enum(["low", "medium", "high", "critical"]); + +const postMergeIncidentReportSchema = z + .object({ + description: z.string().min(1).max(4000), + severity: postMergeIncidentSeveritySchema, + mergedSha: z + .string() + .regex(/^[0-9a-f]{7,40}$/i) + .optional(), + }) + .strict(); + +const operatorPostMergeIncidentReportSchema = z + .object({ + repoFullName: z.string().min(3).max(200), + pullNumber: z.number().int().positive(), + description: z.string().min(1).max(4000), + severity: postMergeIncidentSeveritySchema, + mergedSha: z + .string() + .regex(/^[0-9a-f]{7,40}$/i) + .optional(), + }) + .strict(); + function contributorOpenIssueCount(issues: Array<{ repoFullName: string; state: string }>, repoFullName: string): number { const targetRepo = repoFullName.toLowerCase(); return issues.filter((issue) => issue.repoFullName.toLowerCase() === targetRepo && issue.state === "open").length; @@ -1655,6 +1682,34 @@ export function createApp() { return c.json({ ok: true, ...verified }); }); + // #5672 post-merge incident report, internal-operator side: same reporting path as the repo-scoped customer + // route (POST /v1/repos/:owner/:repo/pulls/:number/incident-reports), for an operator filing on a customer's + // behalf. Not scoped to one repo's session, so repoFullName/pullNumber travel in the body instead of the path. + app.post("/v1/app/incident-reports", async (c) => { + const forbidden = await requireAppRole(c, ["operator"]); + if (forbidden) return forbidden; + const identity = await authenticateRequestIdentity(c); + /* v8 ignore next -- requireAppRole already rejects an unauthenticated caller before this handler runs. */ + if (!identity) return c.json({ error: "unauthorized" }, 401); + const body = await c.req.json().catch(() => null); + const parsed = operatorPostMergeIncidentReportSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "invalid_incident_report", issues: parsed.error.issues }, 400); + const pullRequest = await getPullRequest(c.env, parsed.data.repoFullName, parsed.data.pullNumber); + if (!pullRequest) return c.json({ error: "pull_request_not_found" }, 404); + if (!pullRequest.mergedAt) return c.json({ error: "pull_request_not_merged" }, 409); + const report = await recordPostMergeIncidentReport(c.env, { + repoFullName: parsed.data.repoFullName, + pullNumber: parsed.data.pullNumber, + description: parsed.data.description, + severity: parsed.data.severity, + mergedSha: parsed.data.mergedSha, + reporterKind: "operator", + actor: identity.actor, + route: c.req.path, + }); + return c.json({ ok: true, repoFullName: parsed.data.repoFullName, pullNumber: parsed.data.pullNumber, ...report }); + }); + app.get("/v1/app/notification-model", async (c) => { const forbidden = await requireAppRole(c, ["maintainer", "owner", "operator"]); if (forbidden) return forbidden; @@ -2517,6 +2572,36 @@ export function createApp() { return c.json({ repoFullName: fullName, events: events.map((event) => ({ ...event, detail: event.detail === null ? null : sanitizePublicComment(event.detail) })) }); }); + // #5672 post-merge incident report, customer-facing side: a repo maintainer reports that an already-merged + // rented-loop PR was found harmful. Persists as an audit_events row keyed to this PR (same targetKey the + // audit-feed route above reads back via ?pull=N), so no separate incident table/read-route is needed here. + app.post("/v1/repos/:owner/:repo/pulls/:number/incident-reports", async (c) => { + const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; + const gate = await requireRepoMaintainer(c, fullName); + /* v8 ignore next -- unauthorized requests are rejected by the auth middleware before reaching the handler. */ + if (gate instanceof Response) return gate; + const pullNumber = Number(c.req.param("number")); + if (!Number.isInteger(pullNumber) || pullNumber <= 0) return c.json({ error: "invalid_pull_number" }, 400); + const body = await c.req.json().catch(() => null); + const parsed = postMergeIncidentReportSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "invalid_incident_report", issues: parsed.error.issues }, 400); + const pullRequest = await getPullRequest(c.env, fullName, pullNumber); + if (!pullRequest) return c.json({ error: "pull_request_not_found" }, 404); + if (!pullRequest.mergedAt) return c.json({ error: "pull_request_not_merged" }, 409); + const actor = gate.identity?.kind === "session" ? gate.identity.actor : "maintainer"; + const report = await recordPostMergeIncidentReport(c.env, { + repoFullName: fullName, + pullNumber, + description: parsed.data.description, + severity: parsed.data.severity, + mergedSha: parsed.data.mergedSha, + reporterKind: "customer", + actor, + route: c.req.path, + }); + return c.json({ ok: true, repoFullName: fullName, pullNumber, ...report }); + }); + // Maintainer activation demo (#701): a repo-specific "here's what LoopOver would have surfaced" preview // over recent PRs, plus a one-click advisory ramp. Maintainer-scoped + per-repo. Deterministic (no AI run). app.get("/v1/repos/:owner/:repo/activation-preview", async (c) => { @@ -5416,6 +5501,7 @@ function canSessionAccessPath(env: Env, identity: Extract { + const id = crypto.randomUUID(); + const createdAt = nowIso(); + await recordAuditEvent(env, { + id, + eventType: "agent.post_merge_incident_reported", + actor: report.actor, + route: report.route, + targetKey: `${report.repoFullName}#${report.pullNumber}`, + outcome: "completed", + detail: report.description, + metadata: { severity: report.severity, mergedSha: report.mergedSha ?? null, reporterKind: report.reporterKind }, + createdAt, + }); + return { id, createdAt }; +} + export async function hasRecentAuditEvent(env: Env, actor: string, eventType: string, sinceIso: string): Promise { const db = getDb(env.DB); const rows = await db diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 23e4dcfb91..3c35982fd8 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -495,6 +495,65 @@ export function buildOpenApiSpec() { 403: { description: "Insufficient role" }, }, }); + registry.registerPath({ + method: "post", + path: "/v1/repos/{owner}/{repo}/pulls/{number}/incident-reports", + request: { + params: z.object({ owner: z.string(), repo: z.string(), number: z.string() }), + body: { + content: { + "application/json": { + schema: z.object({ + description: z.string().min(1).max(4000), + severity: z.enum(["low", "medium", "high", "critical"]), + mergedSha: z.string().optional(), + }), + }, + }, + }, + }, + responses: { + 200: { + description: "Post-merge incident report recorded as an audit_events row (#5672), customer-facing (repo maintainer) side", + content: { "application/json": { schema: z.object({ ok: z.literal(true), repoFullName: z.string(), pullNumber: z.number(), id: z.string(), createdAt: z.string() }) } }, + }, + 400: { description: "Invalid pull number or incident report body" }, + 401: { description: "Unauthorized" }, + 403: { description: "Insufficient role" }, + 404: { description: "Pull request not found" }, + 409: { description: "Pull request has not been merged" }, + }, + }); + registry.registerPath({ + method: "post", + path: "/v1/app/incident-reports", + request: { + body: { + content: { + "application/json": { + schema: z.object({ + repoFullName: z.string().min(3).max(200), + pullNumber: z.number().int().positive(), + description: z.string().min(1).max(4000), + severity: z.enum(["low", "medium", "high", "critical"]), + mergedSha: z.string().optional(), + }), + }, + }, + }, + }, + responses: { + 200: { + description: "Post-merge incident report recorded as an audit_events row (#5672), internal-operator side", + content: { "application/json": { schema: z.object({ ok: z.literal(true), repoFullName: z.string(), pullNumber: z.number(), id: z.string(), createdAt: z.string() }) } }, + }, + 400: { description: "Invalid incident report body" }, + 401: { description: "Unauthorized" }, + 403: { description: "Insufficient app role (operator only)" }, + 404: { description: "Pull request not found" }, + 409: { description: "Pull request has not been merged" }, + }, + }); registry.registerPath({ method: "get", path: "/v1/app/self-dogfood/registration-pack", diff --git a/test/unit/routes-post-merge-incident-reports.test.ts b/test/unit/routes-post-merge-incident-reports.test.ts new file mode 100644 index 0000000000..003c9699b6 --- /dev/null +++ b/test/unit/routes-post-merge-incident-reports.test.ts @@ -0,0 +1,257 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { createApp } from "../../src/api/routes"; +import { createSessionForGitHubUser } from "../../src/auth/security"; +import { upsertInstallation, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; + +// #5672: reporting path for an already-merged rented-loop PR later found harmful. Two entry points -- +// a repo-maintainer (customer) route and an internal-operator route -- both persist through the same +// recordPostMergeIncidentReport helper into audit_events, keyed to the PR's `repo#number` targetKey. + +const app = createApp(); +const apiHeaders = (env: Env) => ({ authorization: `Bearer ${env.LOOPOVER_API_TOKEN}`, "content-type": "application/json" }); + +async function seedRepoWithPulls(env: Env) { + await upsertInstallation(env, { + installation: { id: 5, account: { login: "owner", id: 1, type: "User" }, repository_selection: "selected", permissions: { metadata: "read", contents: "write", pull_requests: "write", issues: "write" }, events: ["pull_request"] }, + }); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + await upsertPullRequestFromGitHub(env, "owner/repo", { + number: 7, + title: "Merged PR", + state: "closed", + merged_at: "2026-06-18T10:00:00.000Z", + user: { login: "a-miner" }, + head: { sha: "deadbeef" }, + labels: [], + body: "x", + }); + await upsertPullRequestFromGitHub(env, "owner/repo", { + number: 8, + title: "Open PR", + state: "open", + user: { login: "a-miner" }, + head: { sha: "open-sha" }, + labels: [], + body: "x", + }); +} + +async function auditRows(env: Env): Promise> { + const result = (await env.DB.prepare( + "select actor, outcome, target_key, detail, metadata_json from audit_events where event_type = 'agent.post_merge_incident_reported' order by created_at desc", + ).all()) as { results: Array<{ actor: string; outcome: string; target_key: string; detail: string; metadata_json: string }> }; + return result.results; +} + +describe("post-merge incident report routes (#5672)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("customer-facing route: POST /v1/repos/:owner/:repo/pulls/:number/incident-reports", () => { + it("records a complete audit-trail entry for a merged PR (static token, mergedSha included)", async () => { + const env = createTestEnv(); + await seedRepoWithPulls(env); + const res = await app.request( + "/v1/repos/owner/repo/pulls/7/incident-reports", + { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ description: "broke prod config", severity: "high", mergedSha: "deadbeef" }) }, + env, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean; repoFullName: string; pullNumber: number; id: string; createdAt: string }; + expect(body).toMatchObject({ ok: true, repoFullName: "owner/repo", pullNumber: 7 }); + expect(typeof body.id).toBe("string"); + expect(typeof body.createdAt).toBe("string"); + + const rows = await auditRows(env); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ actor: "maintainer", outcome: "completed", target_key: "owner/repo#7", detail: "broke prod config" }); + expect(JSON.parse(rows[0]!.metadata_json)).toMatchObject({ severity: "high", mergedSha: "deadbeef", reporterKind: "customer" }); + }); + + it("records the reporting maintainer's own login as actor for a session caller, and omits mergedSha as null", async () => { + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + await seedRepoWithPulls(env); + const { token } = await createSessionForGitHubUser(env, { login: "owner", id: 1 }); + const res = await app.request( + "/v1/repos/owner/repo/pulls/7/incident-reports", + { method: "POST", headers: { cookie: `loopover_session=${token}`, "content-type": "application/json" }, body: JSON.stringify({ description: "silent data loss", severity: "critical" }) }, + env, + ); + expect(res.status).toBe(200); + const rows = await auditRows(env); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ actor: "owner" }); + expect(JSON.parse(rows[0]!.metadata_json)).toMatchObject({ severity: "critical", mergedSha: null, reporterKind: "customer" }); + }); + + it("404s an unknown pull request", async () => { + const env = createTestEnv(); + await seedRepoWithPulls(env); + const res = await app.request( + "/v1/repos/owner/repo/pulls/999/incident-reports", + { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ description: "x", severity: "low" }) }, + env, + ); + expect(res.status).toBe(404); + await expect(res.json()).resolves.toMatchObject({ error: "pull_request_not_found" }); + expect(await auditRows(env)).toHaveLength(0); + }); + + it("409s a pull request that has not been merged", async () => { + const env = createTestEnv(); + await seedRepoWithPulls(env); + const res = await app.request( + "/v1/repos/owner/repo/pulls/8/incident-reports", + { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ description: "x", severity: "low" }) }, + env, + ); + expect(res.status).toBe(409); + await expect(res.json()).resolves.toMatchObject({ error: "pull_request_not_merged" }); + expect(await auditRows(env)).toHaveLength(0); + }); + + it("rejects a non-positive-integer pull number with 400", async () => { + const env = createTestEnv(); + await seedRepoWithPulls(env); + for (const bad of ["0", "-1", "abc", "1.5"]) { + const res = await app.request(`/v1/repos/owner/repo/pulls/${bad}/incident-reports`, { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ description: "x", severity: "low" }) }, env); + expect(res.status, `number=${bad}`).toBe(400); + await expect(res.json()).resolves.toMatchObject({ error: "invalid_pull_number" }); + } + }); + + it("rejects a schema-invalid body (missing description, bad severity, unknown field) with 400", async () => { + const env = createTestEnv(); + await seedRepoWithPulls(env); + for (const bad of [{ severity: "high" }, { description: "x", severity: "catastrophic" }, { description: "x", severity: "high", extra: true }]) { + const res = await app.request("/v1/repos/owner/repo/pulls/7/incident-reports", { method: "POST", headers: apiHeaders(env), body: JSON.stringify(bad) }, env); + expect(res.status, JSON.stringify(bad)).toBe(400); + await expect(res.json()).resolves.toMatchObject({ error: "invalid_incident_report" }); + } + expect(await auditRows(env)).toHaveLength(0); + }); + + it("rejects a body that isn't valid JSON at all", async () => { + const env = createTestEnv(); + await seedRepoWithPulls(env); + const res = await app.request("/v1/repos/owner/repo/pulls/7/incident-reports", { method: "POST", headers: apiHeaders(env), body: "{" }, env); + expect(res.status).toBe(400); + await expect(res.json()).resolves.toMatchObject({ error: "invalid_incident_report" }); + }); + + it("requires authentication and forbids a non-maintainer session", async () => { + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + await seedRepoWithPulls(env); + const noauth = await app.request("/v1/repos/owner/repo/pulls/7/incident-reports", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ description: "x", severity: "low" }) }, env); + expect([401, 403]).toContain(noauth.status); + + const { token } = await createSessionForGitHubUser(env, { login: "contributor", id: 999 }); + const forbidden = await app.request( + "/v1/repos/owner/repo/pulls/7/incident-reports", + { method: "POST", headers: { cookie: `loopover_session=${token}`, "content-type": "application/json" }, body: JSON.stringify({ description: "x", severity: "low" }) }, + env, + ); + expect([401, 403]).toContain(forbidden.status); + expect(await auditRows(env)).toHaveLength(0); + }); + }); + + describe("internal-operator route: POST /v1/app/incident-reports", () => { + it("records a complete audit-trail entry, actor from identity, mergedSha absent as null", async () => { + const env = createTestEnv(); + await seedRepoWithPulls(env); + const res = await app.request( + "/v1/app/incident-reports", + { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ repoFullName: "owner/repo", pullNumber: 7, description: "customer escalation", severity: "medium" }) }, + env, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean; repoFullName: string; pullNumber: number; id: string; createdAt: string }; + expect(body).toMatchObject({ ok: true, repoFullName: "owner/repo", pullNumber: 7 }); + + const rows = await auditRows(env); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ actor: "api", outcome: "completed", target_key: "owner/repo#7", detail: "customer escalation" }); + expect(JSON.parse(rows[0]!.metadata_json)).toMatchObject({ severity: "medium", mergedSha: null, reporterKind: "operator" }); + }); + + it("includes mergedSha when supplied", async () => { + const env = createTestEnv(); + await seedRepoWithPulls(env); + const res = await app.request( + "/v1/app/incident-reports", + { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ repoFullName: "owner/repo", pullNumber: 7, description: "x", severity: "low", mergedSha: "deadbeef" }) }, + env, + ); + expect(res.status).toBe(200); + const rows = await auditRows(env); + expect(JSON.parse(rows[0]!.metadata_json)).toMatchObject({ mergedSha: "deadbeef" }); + }); + + it("404s an unknown pull request and 409s an unmerged one", async () => { + const env = createTestEnv(); + await seedRepoWithPulls(env); + const notFound = await app.request( + "/v1/app/incident-reports", + { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ repoFullName: "owner/repo", pullNumber: 999, description: "x", severity: "low" }) }, + env, + ); + expect(notFound.status).toBe(404); + await expect(notFound.json()).resolves.toMatchObject({ error: "pull_request_not_found" }); + + const notMerged = await app.request( + "/v1/app/incident-reports", + { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ repoFullName: "owner/repo", pullNumber: 8, description: "x", severity: "low" }) }, + env, + ); + expect(notMerged.status).toBe(409); + await expect(notMerged.json()).resolves.toMatchObject({ error: "pull_request_not_merged" }); + expect(await auditRows(env)).toHaveLength(0); + }); + + it("rejects a schema-invalid body with 400", async () => { + const env = createTestEnv(); + await seedRepoWithPulls(env); + for (const bad of [{ pullNumber: 7, description: "x", severity: "low" }, { repoFullName: "owner/repo", pullNumber: 0, description: "x", severity: "low" }, { repoFullName: "owner/repo", pullNumber: 7, description: "", severity: "low" }]) { + const res = await app.request("/v1/app/incident-reports", { method: "POST", headers: apiHeaders(env), body: JSON.stringify(bad) }, env); + expect(res.status, JSON.stringify(bad)).toBe(400); + await expect(res.json()).resolves.toMatchObject({ error: "invalid_incident_report" }); + } + }); + + it("rejects a body that isn't valid JSON at all", async () => { + const env = createTestEnv(); + const res = await app.request("/v1/app/incident-reports", { method: "POST", headers: apiHeaders(env), body: "{" }, env); + expect(res.status).toBe(400); + await expect(res.json()).resolves.toMatchObject({ error: "invalid_incident_report" }); + }); + + it("is unauthorized with no identity and forbidden for a non-operator session", async () => { + const env = createTestEnv(); + await seedRepoWithPulls(env); + const noauth = await app.request("/v1/app/incident-reports", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ repoFullName: "owner/repo", pullNumber: 7, description: "x", severity: "low" }) }, env); + expect(noauth.status).toBe(401); + + const { token } = await createSessionForGitHubUser(env, { login: "not-an-operator", id: 501 }); + const forbidden = await app.request( + "/v1/app/incident-reports", + { method: "POST", headers: { cookie: `loopover_session=${token}`, "content-type": "application/json" }, body: JSON.stringify({ repoFullName: "owner/repo", pullNumber: 7, description: "x", severity: "low" }) }, + env, + ); + expect(forbidden.status).toBe(403); + expect(await auditRows(env)).toHaveLength(0); + }); + + it("rejects the shared MCP token without recording anything", async () => { + const env = createTestEnv(); + await seedRepoWithPulls(env); + const headers = { authorization: `Bearer ${env.LOOPOVER_MCP_TOKEN}`, "content-type": "application/json" }; + const res = await app.request("/v1/app/incident-reports", { method: "POST", headers, body: JSON.stringify({ repoFullName: "owner/repo", pullNumber: 7, description: "x", severity: "low" }) }, env); + expect(res.status).toBe(403); + expect(await auditRows(env)).toHaveLength(0); + }); + }); +});