diff --git a/apps/labeler/console/src/api/client.test.ts b/apps/labeler/console/src/api/client.test.ts index 182e52ae7..6cea96110 100644 --- a/apps/labeler/console/src/api/client.test.ts +++ b/apps/labeler/console/src/api/client.test.ts @@ -48,6 +48,27 @@ describe("fixture client", () => { expect(ids).toEqual(ids.toSorted((a, b) => b - a)); expect(page.items.some((d) => d.status === "new")).toBe(true); }); + + it("lists reconsiderations with an open and a resolved case", async () => { + const page = await apiClient.listReconsiderations(); + expect(page.items.length).toBeGreaterThan(0); + expect(page.items.some((r) => r.state === "open")).toBe(true); + expect(page.items.some((r) => r.state === "resolved" && r.outcome !== null)).toBe(true); + }); + + it("returns a reconsideration case with its oldest-first note thread", async () => { + const [first] = (await apiClient.listReconsiderations()).items; + expect(first).toBeDefined(); + const detail = await apiClient.getReconsideration(first!.id); + expect(detail?.reconsideration.id).toBe(first!.id); + expect(detail!.notes.length).toBeGreaterThan(0); + const timestamps = detail!.notes.map((n) => Date.parse(n.createdAt)); + expect(timestamps).toEqual(timestamps.toSorted((a, b) => a - b)); + }); + + it("returns null for an unknown reconsideration id", async () => { + expect(await apiClient.getReconsideration("recon_missing")).toBeNull(); + }); }); describe("fetch client label actions", () => { @@ -279,4 +300,110 @@ describe("fetch client label actions", () => { }), ).rejects.toThrow("key already used"); }); + + it("GETs the reconsideration list", async () => { + stubFetch(() => Response.json({ data: { items: [{ id: "recon_1", state: "open" }] } })); + const page = await fetchClient.listReconsiderations({ cursor: "c1", limit: 25 }); + expect(page.items).toEqual([{ id: "recon_1", state: "open" }]); + const url = calls[0]!.url; + expect(url).toContain("/admin/api/reconsiderations?"); + expect(url).toContain("cursor=c1"); + expect(url).toContain("limit=25"); + }); + + it("GETs one reconsideration, mapping 404 to null", async () => { + stubFetch(() => new Response(null, { status: 404 })); + expect(await fetchClient.getReconsideration("recon_missing")).toBeNull(); + expect(calls[0]!.url).toBe("/admin/api/reconsiderations/recon_missing"); + }); + + it("POSTs an open with the CSRF header, JSON content type, and threaded key", async () => { + stubFetch(() => Response.json({ data: { actionId: "oact_1", reconsiderationId: "recon_1" } })); + const result = await fetchClient.openReconsideration({ + assessmentId: "asmt_target", + note: "publisher appealed", + reason: "opening", + idempotencyKey: input.idempotencyKey, + }); + expect(result).toMatchObject({ reconsiderationId: "recon_1" }); + const call = calls[0]!; + expect(call.url).toBe("/admin/api/reconsiderations/open"); + expect(call.init.method).toBe("POST"); + const headers = new Headers(call.init.headers); + expect(headers.get("X-EmDash-Request")).toBe("1"); + expect(headers.get("Content-Type")).toBe("application/json"); + expect(JSON.parse(call.init.body as string)).toMatchObject({ + assessmentId: "asmt_target", + note: "publisher appealed", + reason: "opening", + idempotencyKey: input.idempotencyKey, + }); + }); + + it("POSTs a note to the case note route", async () => { + stubFetch(() => Response.json({ data: { actionId: "oact_1", noteId: "rnote_1" } })); + await fetchClient.addReconsiderationNote("recon_1", { + note: "another note", + reason: "context", + idempotencyKey: input.idempotencyKey, + }); + expect(calls[0]!.url).toBe("/admin/api/reconsiderations/recon_1/note"); + expect(calls[0]!.init.method).toBe("POST"); + expect(JSON.parse(calls[0]!.init.body as string)).toMatchObject({ note: "another note" }); + }); + + it("POSTs a resolve to the resolve route with the outcome", async () => { + stubFetch(() => Response.json({ data: { actionId: "oact_1", outcome: "granted" } })); + await fetchClient.resolveReconsideration("recon_1", { + outcome: "granted", + reason: "false positive", + idempotencyKey: input.idempotencyKey, + }); + expect(calls[0]!.url).toBe("/admin/api/reconsiderations/recon_1/resolve"); + expect(calls[0]!.init.method).toBe("POST"); + expect(JSON.parse(calls[0]!.init.body as string).outcome).toBe("granted"); + }); + + it("surfaces the server 409 open-exists message", async () => { + stubFetch(() => + Response.json( + { + error: { + code: "RECONSIDERATION_OPEN_EXISTS", + message: "An open reconsideration already exists for this subject", + }, + }, + { status: 409 }, + ), + ); + await expect( + fetchClient.openReconsideration({ + assessmentId: "asmt_target", + note: "n", + reason: "r", + idempotencyKey: input.idempotencyKey, + }), + ).rejects.toThrow("An open reconsideration already exists for this subject"); + }); + + it("surfaces the server 409 already-resolved message on a resolve", async () => { + stubFetch(() => + Response.json( + { + error: { + code: "RECONSIDERATION_RESOLVED", + message: "Reconsideration is already resolved", + }, + }, + { status: 409 }, + ), + ); + await expect( + fetchClient.resolveReconsideration("recon_1", { + outcome: "denied", + reason: "r", + idempotencyKey: input.idempotencyKey, + }), + ).rejects.toThrow("Reconsideration is already resolved"); + }); }); diff --git a/apps/labeler/console/src/api/client.ts b/apps/labeler/console/src/api/client.ts index a0ac5069f..768c53e57 100644 --- a/apps/labeler/console/src/api/client.ts +++ b/apps/labeler/console/src/api/client.ts @@ -4,6 +4,8 @@ import { FIXTURE_FINDINGS_BY_ASSESSMENT, FIXTURE_LABELS_BY_ASSESSMENT, FIXTURE_OPERATOR_ACTIONS, + FIXTURE_RECONSIDERATION_DETAIL, + FIXTURE_RECONSIDERATIONS, FIXTURE_SUBJECT_HISTORY, FIXTURE_SYSTEM_STATUS, } from "../fixtures/index.js"; @@ -25,6 +27,7 @@ import type { ListAssessmentsParams, ListAuditLogParams, ListDeadLettersParams, + ListReconsiderationsParams, OperatorAction, OverrideActionInput, OverrideEffectPreviewParams, @@ -32,6 +35,14 @@ import type { OverrideRetractResult, OperatorFinding, Page, + ReconsiderationDetail, + ReconsiderationNoteInput, + ReconsiderationNoteResult, + ReconsiderationOpenInput, + ReconsiderationOpenResult, + ReconsiderationResolveInput, + ReconsiderationResolveResult, + ReconsiderationView, RerunResult, SubjectHistoryView, SubjectLabel, @@ -54,6 +65,8 @@ export interface LabelerConsoleClient { getSubjectLabels(uri: string, cid?: string): Promise; listAuditLog(params?: ListAuditLogParams): Promise>; listDeadLetters(params?: ListDeadLettersParams): Promise>; + listReconsiderations(params?: ListReconsiderationsParams): Promise>; + getReconsideration(id: string): Promise; getSystemStatus(): Promise; whoami(): Promise; previewEffect(params: EffectPreviewParams): Promise; @@ -72,6 +85,15 @@ export interface LabelerConsoleClient { resumeAutomation(input: AutomationToggleInput): Promise; retryDeadLetter(id: number, input: DeadLetterActionInput): Promise; quarantineDeadLetter(id: number, input: DeadLetterActionInput): Promise; + openReconsideration(input: ReconsiderationOpenInput): Promise; + addReconsiderationNote( + id: string, + input: ReconsiderationNoteInput, + ): Promise; + resolveReconsideration( + id: string, + input: ReconsiderationResolveInput, + ): Promise; } /** The admin-only emergency endpoints, keyed by action and direction. */ @@ -182,6 +204,18 @@ export function createFetchClient(): LabelerConsoleClient { const response = await consoleApiFetch(`/dead-letters?${search.toString()}`); return parseJson(response, "Failed to load dead letters"); }, + async listReconsiderations(params = {}) { + const search = new URLSearchParams(); + if (params.cursor) search.set("cursor", params.cursor); + if (params.limit) search.set("limit", String(params.limit)); + const response = await consoleApiFetch(`/reconsiderations?${search.toString()}`); + return parseJson(response, "Failed to load reconsiderations"); + }, + async getReconsideration(id) { + const response = await consoleApiFetch(`/reconsiderations/${encodeURIComponent(id)}`); + if (response.status === 404) return null; + return parseJson(response, "Failed to load reconsideration"); + }, async getSystemStatus() { const response = await consoleApiFetch("/status"); return parseJson(response, "Failed to load system status"); @@ -259,6 +293,23 @@ export function createFetchClient(): LabelerConsoleClient { "Failed to quarantine dead letter", ); }, + async openReconsideration(input) { + return postAction("/reconsiderations/open", input, "Failed to open reconsideration"); + }, + async addReconsiderationNote(id, input) { + return postAction( + `/reconsiderations/${encodeURIComponent(id)}/note`, + input, + "Failed to add note", + ); + }, + async resolveReconsideration(id, input) { + return postAction( + `/reconsiderations/${encodeURIComponent(id)}/resolve`, + input, + "Failed to resolve reconsideration", + ); + }, }; } @@ -294,6 +345,12 @@ export function createFixtureClient(): LabelerConsoleClient { async listDeadLetters() { return { items: [...FIXTURE_DEAD_LETTERS] }; }, + async listReconsiderations() { + return { items: [...FIXTURE_RECONSIDERATIONS] }; + }, + async getReconsideration(id) { + return FIXTURE_RECONSIDERATION_DETAIL[id] ?? null; + }, async getSystemStatus() { return FIXTURE_SYSTEM_STATUS; }, @@ -411,6 +468,34 @@ export function createFixtureClient(): LabelerConsoleClient { cts: new Date().toISOString(), }; }, + async openReconsideration(input) { + return { + actionId: "oact_fixture", + reconsiderationId: "recon_fixture", + uri: "at://did:plc:x/com.emdashcms.experimental.package.release/rk1", + cid: "bafyfixture", + triggeringAssessmentId: input.assessmentId, + cts: new Date().toISOString(), + }; + }, + async addReconsiderationNote(id) { + return { + actionId: "oact_fixture", + reconsiderationId: id, + noteId: "rnote_fixture", + cts: new Date().toISOString(), + }; + }, + async resolveReconsideration(id, input) { + return { + actionId: "oact_fixture", + reconsiderationId: id, + outcome: input.outcome, + uri: "at://did:plc:x/com.emdashcms.experimental.package.release/rk1", + cid: "bafyfixture", + cts: new Date().toISOString(), + }; + }, }; } diff --git a/apps/labeler/console/src/api/types.ts b/apps/labeler/console/src/api/types.ts index bd65ecb5d..4b66e0241 100644 --- a/apps/labeler/console/src/api/types.ts +++ b/apps/labeler/console/src/api/types.ts @@ -394,6 +394,116 @@ export interface DeadLetterActionResult { cts: string; } +/** A reconsideration case's lifecycle state (mirrors the server's + * `ReconsiderationState`). A case opens `open` and terminates `resolved`. */ +export type ReconsiderationState = "open" | "resolved"; + +/** The terminal decision on a resolved case (mirrors the server's + * `ReconsiderationOutcome`). `withdrawn` fires no publisher notice. */ +export type ReconsiderationOutcome = "granted" | "denied" | "withdrawn"; + +/** + * A reconsideration case from `GET /admin/api/reconsiderations` — mirrors the + * server's `serializeReconsideration`. Every field is operator-only (Access-edge + * + reviewer gated), including the actor provenance. Humans carry an email, + * service tokens a common name. `outcome`/`resolvedBy*`/`resolvedAt` are non-null + * only once resolved. */ +export interface ReconsiderationView { + id: string; + subjectUri: string; + subjectCid: string; + triggeringAssessmentId: string; + state: ReconsiderationState; + outcome: ReconsiderationOutcome | null; + openedById: string; + openedByEmail: string | null; + openedByCommonName: string | null; + openedByRole: OperatorRole; + openedAt: string; + resolvedById: string | null; + resolvedByEmail: string | null; + resolvedByCommonName: string | null; + resolvedAt: string | null; + outcomeActionId: string | null; +} + +/** A private reconsideration note (mirrors `serializeReconsiderationNote`). The + * `note` text is operator-only and never enters publisher notice copy. */ +export interface ReconsiderationNoteView { + id: string; + reconsiderationId: string; + authorId: string; + authorEmail: string | null; + authorCommonName: string | null; + authorRole: OperatorRole; + note: string; + createdAt: string; +} + +/** One case plus its private note thread (oldest-first) from + * `GET /admin/api/reconsiderations/:id`. */ +export interface ReconsiderationDetail { + reconsideration: ReconsiderationView; + notes: ReconsiderationNoteView[]; +} + +/** Body for `POST /admin/api/reconsiderations/open`. `note` is required + * non-empty (≤10000 chars); `idempotencyKey` is minted client-side (ULID) per + * dialog open and reused across retries so a network retry replays rather than + * double-opens. */ +export interface ReconsiderationOpenInput { + assessmentId: string; + note: string; + reason: string; + idempotencyKey: string; +} + +/** Body for `POST /admin/api/reconsiderations/:id/note`. Allowed in any state. */ +export interface ReconsiderationNoteInput { + note: string; + reason: string; + idempotencyKey: string; +} + +/** Body for `POST /admin/api/reconsiderations/:id/resolve`. `note` is an + * optional final note; the server rejects a resolve of an already-resolved case + * with a 409 `RECONSIDERATION_RESOLVED`. */ +export interface ReconsiderationResolveInput { + outcome: ReconsiderationOutcome; + note?: string; + reason: string; + idempotencyKey: string; +} + +/** Idempotent open result — the new case's id and subject, plus the triggering + * assessment it was opened from. */ +export interface ReconsiderationOpenResult { + actionId: string; + reconsiderationId: string; + uri: string; + cid: string; + triggeringAssessmentId: string; + cts: string; +} + +/** Idempotent note result — the appended note's id. */ +export interface ReconsiderationNoteResult { + actionId: string; + reconsiderationId: string; + noteId: string; + cts: string; +} + +/** Idempotent resolve result — the case's terminal outcome and subject. */ +export interface ReconsiderationResolveResult { + actionId: string; + reconsiderationId: string; + outcome: ReconsiderationOutcome; + uri: string; + cid: string; + cts: string; +} + export interface ListAssessmentsParams { state?: PublicAssessmentState; cursor?: string; @@ -409,3 +519,8 @@ export interface ListDeadLettersParams { cursor?: string; limit?: number; } + +export interface ListReconsiderationsParams { + cursor?: string; + limit?: number; +} diff --git a/apps/labeler/console/src/components/OpenReconsiderationDialog.test.tsx b/apps/labeler/console/src/components/OpenReconsiderationDialog.test.tsx new file mode 100644 index 000000000..893bd1339 --- /dev/null +++ b/apps/labeler/console/src/components/OpenReconsiderationDialog.test.tsx @@ -0,0 +1,79 @@ +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { apiClient } from "../api/client.js"; +import { ASSESSMENT_GAMMA } from "../fixtures/index.js"; +import { renderRoute } from "../test/harness.js"; + +vi.mock("../api/client.js", async () => { + const actual = await vi.importActual("../api/client.js"); + return { ...actual, apiClient: actual.createFixtureClient() }; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const DETAIL_PATH = `/assessments/${ASSESSMENT_GAMMA.id}`; + +async function openTheDialog(): Promise { + renderRoute(DETAIL_PATH); + fireEvent.click(await screen.findByRole("button", { name: "Open reconsideration" })); + return screen.findByRole("alertdialog"); +} + +describe("OpenReconsiderationDialog (via AssessmentDetail)", () => { + it("opens a case for this assessment and navigates to the new case detail", async () => { + const open = vi.spyOn(apiClient, "openReconsideration").mockResolvedValue({ + actionId: "oact_1", + reconsiderationId: "recon_new", + uri: ASSESSMENT_GAMMA.uri, + cid: ASSESSMENT_GAMMA.cid, + triggeringAssessmentId: ASSESSMENT_GAMMA.id, + cts: "", + }); + const dialog = await openTheDialog(); + + fireEvent.change( + within(dialog).getByPlaceholderText("Why this assessment is being reconsidered"), + { target: { value: "publisher appealed the block" } }, + ); + fireEvent.change(within(dialog).getByPlaceholderText("Why this case is being opened"), { + target: { value: "opening for review" }, + }); + fireEvent.click(within(dialog).getByRole("button", { name: "Open reconsideration" })); + + await waitFor(() => { + expect(open).toHaveBeenCalledWith( + expect.objectContaining({ + assessmentId: ASSESSMENT_GAMMA.id, + note: "publisher appealed the block", + reason: "opening for review", + }), + ); + }); + // Navigation lands on the new case; getReconsideration("recon_new") is absent + // from the fixtures, so the detail resolves not-found — proof we navigated. + expect(await screen.findByText("Reconsideration not found.")).toBeTruthy(); + }); + + it("surfaces the server 409 open-exists message inline", async () => { + vi.spyOn(apiClient, "openReconsideration").mockRejectedValue( + new Error("An open reconsideration already exists for this subject"), + ); + const dialog = await openTheDialog(); + + fireEvent.change( + within(dialog).getByPlaceholderText("Why this assessment is being reconsidered"), + { target: { value: "note" } }, + ); + fireEvent.change(within(dialog).getByPlaceholderText("Why this case is being opened"), { + target: { value: "reason" }, + }); + fireEvent.click(within(dialog).getByRole("button", { name: "Open reconsideration" })); + + expect( + await within(dialog).findByText("An open reconsideration already exists for this subject"), + ).toBeTruthy(); + }); +}); diff --git a/apps/labeler/console/src/components/OpenReconsiderationDialog.tsx b/apps/labeler/console/src/components/OpenReconsiderationDialog.tsx new file mode 100644 index 000000000..39b3ad56e --- /dev/null +++ b/apps/labeler/console/src/components/OpenReconsiderationDialog.tsx @@ -0,0 +1,108 @@ +import { Button, Dialog, InputArea } from "@cloudflare/kumo"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { ulid } from "ulidx"; + +import { apiClient } from "../api/client.js"; + +interface OpenReconsiderationDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** The assessment whose subject the case is opened for. */ + assessmentId: string; + subjectUri: string; + invalidateKeys: readonly (readonly unknown[])[]; +} + +/** + * Opens a reconsideration case for this assessment's subject, with a required + * first note and reason. There is no by-subject lookup — a subject that already + * has an open case surfaces the server's 409 inline. On success it navigates to + * the new case detail. The idempotency key is minted per open and reused across + * retries so a network retry replays rather than double-opens. + */ +export function OpenReconsiderationDialog({ + open, + onOpenChange, + assessmentId, + subjectUri, + invalidateKeys, +}: OpenReconsiderationDialogProps) { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const [note, setNote] = useState(""); + const [reason, setReason] = useState(""); + const [idempotencyKey, setIdempotencyKey] = useState(""); + + useEffect(() => { + if (!open) return; + setIdempotencyKey(ulid()); + setNote(""); + setReason(""); + }, [open]); + + const mutation = useMutation({ + mutationFn: () => apiClient.openReconsideration({ assessmentId, note, reason, idempotencyKey }), + onSuccess: async (result) => { + await Promise.all( + invalidateKeys.map((queryKey) => queryClient.invalidateQueries({ queryKey })), + ); + onOpenChange(false); + await navigate({ to: "/reconsiderations/$id", params: { id: result.reconsiderationId } }); + }, + }); + + const canSubmit = note.trim().length > 0 && reason.trim().length > 0 && !mutation.isPending; + + return ( + + + Open reconsideration + + {subjectUri} + + +

+ Opens a case to reconsider this release's assessment. A subject can have only one open + case at a time. +

+ + + + + + {mutation.isError && ( +

+ {mutation.error instanceof Error ? mutation.error.message : "Action failed"} +

+ )} + +
+ +
+
+
+ ); +} diff --git a/apps/labeler/console/src/components/ReconsiderationBadges.tsx b/apps/labeler/console/src/components/ReconsiderationBadges.tsx new file mode 100644 index 000000000..f2829fe46 --- /dev/null +++ b/apps/labeler/console/src/components/ReconsiderationBadges.tsx @@ -0,0 +1,39 @@ +import { Badge } from "@cloudflare/kumo"; + +import type { ReconsiderationOutcome, ReconsiderationState } from "../api/types.js"; + +type BadgeVariant = "neutral" | "info" | "success" | "warning" | "error"; + +const STATE_VARIANT: Record = { + open: "warning", + resolved: "neutral", +}; + +const STATE_LABEL: Record = { + open: "Open", + resolved: "Resolved", +}; + +const OUTCOME_VARIANT: Record = { + granted: "success", + denied: "error", + withdrawn: "neutral", +}; + +const OUTCOME_LABEL: Record = { + granted: "Granted", + denied: "Denied", + withdrawn: "Withdrawn", +}; + +export function ReconsiderationStateBadge({ state }: { state: ReconsiderationState }) { + return ( + + {STATE_LABEL[state]} + + ); +} + +export function ReconsiderationOutcomeBadge({ outcome }: { outcome: ReconsiderationOutcome }) { + return {OUTCOME_LABEL[outcome]}; +} diff --git a/apps/labeler/console/src/components/ReconsiderationDialogs.test.tsx b/apps/labeler/console/src/components/ReconsiderationDialogs.test.tsx new file mode 100644 index 000000000..abff0106b --- /dev/null +++ b/apps/labeler/console/src/components/ReconsiderationDialogs.test.tsx @@ -0,0 +1,182 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { apiClient } from "../api/client.js"; +import { renderWithClient } from "../test/harness.js"; +import { ReconsiderationNoteDialog } from "./ReconsiderationNoteDialog.js"; +import { ReconsiderationResolveDialog } from "./ReconsiderationResolveDialog.js"; + +vi.mock("../api/client.js", async () => { + const actual = await vi.importActual("../api/client.js"); + return { ...actual, apiClient: actual.createFixtureClient() }; +}); + +const RECON_ID = "recon_test"; +const SUBJECT_URI = "at://did:plc:x/com.emdashcms.experimental.package.release/rk1"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("ReconsiderationNoteDialog", () => { + it("threads the note and reason, keeping submit disabled until both are set", async () => { + const addNote = vi + .spyOn(apiClient, "addReconsiderationNote") + .mockResolvedValue({ + actionId: "oact_1", + reconsiderationId: RECON_ID, + noteId: "rnote_1", + cts: "", + }); + renderWithClient( + {}} + reconsiderationId={RECON_ID} + subjectUri={SUBJECT_URI} + invalidateKeys={[]} + />, + ); + + const submit = screen.getByRole("button", { name: "Add note" }) as HTMLButtonElement; + expect(submit.disabled).toBe(true); + + fireEvent.change(screen.getByPlaceholderText("A private note for the case thread"), { + target: { value: "publisher contests the finding" }, + }); + expect(submit.disabled).toBe(true); + fireEvent.change(screen.getByPlaceholderText("Why this note is being recorded"), { + target: { value: "recording the dispute" }, + }); + expect(submit.disabled).toBe(false); + fireEvent.click(submit); + + await waitFor(() => { + expect(addNote).toHaveBeenCalledWith( + RECON_ID, + expect.objectContaining({ + note: "publisher contests the finding", + reason: "recording the dispute", + }), + ); + }); + expect(addNote.mock.calls[0]![1].idempotencyKey.length).toBeGreaterThan(0); + }); + + it("surfaces the server error message on failure", async () => { + vi.spyOn(apiClient, "addReconsiderationNote").mockRejectedValue(new Error("boom")); + renderWithClient( + {}} + reconsiderationId={RECON_ID} + subjectUri={SUBJECT_URI} + invalidateKeys={[]} + />, + ); + fireEvent.change(screen.getByPlaceholderText("A private note for the case thread"), { + target: { value: "note" }, + }); + fireEvent.change(screen.getByPlaceholderText("Why this note is being recorded"), { + target: { value: "reason" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Add note" })); + expect(await screen.findByText("boom")).toBeTruthy(); + }); +}); + +describe("ReconsiderationResolveDialog", () => { + it("resolves with the default granted outcome and reason, threading an idempotency key", async () => { + const resolve = vi.spyOn(apiClient, "resolveReconsideration").mockResolvedValue({ + actionId: "oact_1", + reconsiderationId: RECON_ID, + outcome: "granted", + uri: SUBJECT_URI, + cid: "bafy", + cts: "", + }); + renderWithClient( + {}} + reconsiderationId={RECON_ID} + subjectUri={SUBJECT_URI} + invalidateKeys={[]} + />, + ); + + const submit = screen.getByRole("button", { name: "Resolve" }) as HTMLButtonElement; + expect(submit.disabled).toBe(true); + fireEvent.change(screen.getByPlaceholderText("Why this outcome was reached"), { + target: { value: "finding confirmed as false positive" }, + }); + expect(submit.disabled).toBe(false); + fireEvent.click(submit); + + await waitFor(() => { + expect(resolve).toHaveBeenCalledWith( + RECON_ID, + expect.objectContaining({ + outcome: "granted", + reason: "finding confirmed as false positive", + }), + ); + }); + // No final note was entered, so the optional field is omitted from the body. + expect(resolve.mock.calls[0]![1].note).toBeUndefined(); + }); + + it("includes the optional final note when entered", async () => { + const resolve = vi.spyOn(apiClient, "resolveReconsideration").mockResolvedValue({ + actionId: "oact_1", + reconsiderationId: RECON_ID, + outcome: "granted", + uri: SUBJECT_URI, + cid: "bafy", + cts: "", + }); + renderWithClient( + {}} + reconsiderationId={RECON_ID} + subjectUri={SUBJECT_URI} + invalidateKeys={[]} + />, + ); + fireEvent.change(screen.getByPlaceholderText("A closing note for the case thread"), { + target: { value: "closing note" }, + }); + fireEvent.change(screen.getByPlaceholderText("Why this outcome was reached"), { + target: { value: "reason" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Resolve" })); + + await waitFor(() => { + expect(resolve).toHaveBeenCalledWith( + RECON_ID, + expect.objectContaining({ note: "closing note" }), + ); + }); + }); + + it("surfaces the server 409 already-resolved message inline", async () => { + vi.spyOn(apiClient, "resolveReconsideration").mockRejectedValue( + new Error("Reconsideration is already resolved"), + ); + renderWithClient( + {}} + reconsiderationId={RECON_ID} + subjectUri={SUBJECT_URI} + invalidateKeys={[]} + />, + ); + fireEvent.change(screen.getByPlaceholderText("Why this outcome was reached"), { + target: { value: "reason" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Resolve" })); + expect(await screen.findByText("Reconsideration is already resolved")).toBeTruthy(); + }); +}); diff --git a/apps/labeler/console/src/components/ReconsiderationNoteDialog.tsx b/apps/labeler/console/src/components/ReconsiderationNoteDialog.tsx new file mode 100644 index 000000000..496f7cf3e --- /dev/null +++ b/apps/labeler/console/src/components/ReconsiderationNoteDialog.tsx @@ -0,0 +1,101 @@ +import { Button, Dialog, InputArea } from "@cloudflare/kumo"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { ulid } from "ulidx"; + +import { apiClient } from "../api/client.js"; + +interface ReconsiderationNoteDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + reconsiderationId: string; + /** The case subject, shown for context. */ + subjectUri: string; + /** TanStack Query keys to invalidate on success so the thread re-renders. */ + invalidateKeys: readonly (readonly unknown[])[]; +} + +/** + * Appends one private note to a reconsideration case. A note is allowed in any + * state (a resolved case still accepts post-hoc audit notes). A required note + * body and reason gate submit; the idempotency key is minted per open and reused + * across retries so a network retry replays rather than double-appends. + */ +export function ReconsiderationNoteDialog({ + open, + onOpenChange, + reconsiderationId, + subjectUri, + invalidateKeys, +}: ReconsiderationNoteDialogProps) { + const queryClient = useQueryClient(); + const [note, setNote] = useState(""); + const [reason, setReason] = useState(""); + const [idempotencyKey, setIdempotencyKey] = useState(""); + + useEffect(() => { + if (!open) return; + setIdempotencyKey(ulid()); + setNote(""); + setReason(""); + }, [open]); + + const mutation = useMutation({ + mutationFn: () => + apiClient.addReconsiderationNote(reconsiderationId, { note, reason, idempotencyKey }), + onSuccess: async () => { + await Promise.all( + invalidateKeys.map((queryKey) => queryClient.invalidateQueries({ queryKey })), + ); + onOpenChange(false); + }, + }); + + const canSubmit = note.trim().length > 0 && reason.trim().length > 0 && !mutation.isPending; + + return ( + + + Add note + + {subjectUri} + + + + + + + {mutation.isError && ( +

+ {mutation.error instanceof Error ? mutation.error.message : "Action failed"} +

+ )} + +
+ +
+
+
+ ); +} diff --git a/apps/labeler/console/src/components/ReconsiderationResolveDialog.tsx b/apps/labeler/console/src/components/ReconsiderationResolveDialog.tsx new file mode 100644 index 000000000..2c9bbf7aa --- /dev/null +++ b/apps/labeler/console/src/components/ReconsiderationResolveDialog.tsx @@ -0,0 +1,137 @@ +import { Button, Dialog, InputArea, Select } from "@cloudflare/kumo"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { ulid } from "ulidx"; + +import { apiClient } from "../api/client.js"; +import type { ReconsiderationOutcome } from "../api/types.js"; + +interface ReconsiderationResolveDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + reconsiderationId: string; + /** The case subject, shown for context. */ + subjectUri: string; + invalidateKeys: readonly (readonly unknown[])[]; +} + +const OUTCOMES: readonly ReconsiderationOutcome[] = ["granted", "denied", "withdrawn"]; + +const OUTCOME_LABEL: Record = { + granted: "Granted", + denied: "Denied", + withdrawn: "Withdrawn", +}; + +const OUTCOME_HELP: Record = { + granted: "The reconsideration succeeds — the publisher is notified of the granted outcome.", + denied: "The reconsideration is refused — the publisher is notified of the denied outcome.", + withdrawn: "The case is closed with no decision. No publisher notice is sent.", +}; + +/** + * Resolves an open reconsideration: an outcome (granted / denied / withdrawn), + * an optional final note, and a required reason. A `granted` / `denied` resolve + * fires the publisher outcome notice; `withdrawn` notifies nothing. The server + * rejects a resolve of an already-resolved case with a 409, surfaced inline. + */ +export function ReconsiderationResolveDialog({ + open, + onOpenChange, + reconsiderationId, + subjectUri, + invalidateKeys, +}: ReconsiderationResolveDialogProps) { + const queryClient = useQueryClient(); + const [outcome, setOutcome] = useState("granted"); + const [note, setNote] = useState(""); + const [reason, setReason] = useState(""); + const [idempotencyKey, setIdempotencyKey] = useState(""); + + useEffect(() => { + if (!open) return; + setIdempotencyKey(ulid()); + setOutcome("granted"); + setNote(""); + setReason(""); + }, [open]); + + const mutation = useMutation({ + mutationFn: () => + apiClient.resolveReconsideration(reconsiderationId, { + outcome, + ...(note.trim().length > 0 ? { note } : {}), + reason, + idempotencyKey, + }), + onSuccess: async () => { + await Promise.all( + invalidateKeys.map((queryKey) => queryClient.invalidateQueries({ queryKey })), + ); + onOpenChange(false); + }, + }); + + const canSubmit = reason.trim().length > 0 && !mutation.isPending; + + return ( + + + Resolve reconsideration + + {subjectUri} + + + +

{OUTCOME_HELP[outcome]}

+ + + + + + {mutation.isError && ( +

+ {mutation.error instanceof Error ? mutation.error.message : "Action failed"} +

+ )} + +
+ +
+
+
+ ); +} diff --git a/apps/labeler/console/src/components/Sidebar.tsx b/apps/labeler/console/src/components/Sidebar.tsx index ad4cb5d0b..b6a782f65 100644 --- a/apps/labeler/console/src/components/Sidebar.tsx +++ b/apps/labeler/console/src/components/Sidebar.tsx @@ -1,5 +1,11 @@ import { Sidebar as KumoSidebar, useSidebar } from "@cloudflare/kumo"; -import { ClockCounterClockwise, Envelope, ShieldCheck, SquaresFour } from "@phosphor-icons/react"; +import { + ClockCounterClockwise, + Envelope, + Scales, + ShieldCheck, + SquaresFour, +} from "@phosphor-icons/react"; import { useLocation } from "@tanstack/react-router"; import * as React from "react"; @@ -43,6 +49,7 @@ export function SidebarNav() { { to: "/", label: "Dashboard", icon: SquaresFour }, { to: "/assessments", label: "Assessments", icon: ShieldCheck }, { to: "/dead-letters", label: "Dead letters", icon: Envelope }, + { to: "/reconsiderations", label: "Reconsiderations", icon: Scales }, { to: "/audit", label: "Audit log", icon: ClockCounterClockwise }, ]; diff --git a/apps/labeler/console/src/fixtures/index.ts b/apps/labeler/console/src/fixtures/index.ts index 7a0ef33fa..630b3a9eb 100644 --- a/apps/labeler/console/src/fixtures/index.ts +++ b/apps/labeler/console/src/fixtures/index.ts @@ -17,6 +17,12 @@ export { export { FIXTURE_DEAD_LETTERS } from "./dead-letters.js"; export { FIXTURE_LABELS_BY_ASSESSMENT } from "./labels.js"; export { FIXTURE_OPERATOR_ACTIONS } from "./operator-actions.js"; +export { + FIXTURE_RECONSIDERATION_DETAIL, + FIXTURE_RECONSIDERATIONS, + RECONSIDERATION_BETA_GRANTED, + RECONSIDERATION_GAMMA_OPEN, +} from "./reconsiderations.js"; export { FIXTURE_SUBJECT_HISTORY } from "./subject-history.js"; export { FIXTURE_SUBJECTS, diff --git a/apps/labeler/console/src/fixtures/reconsiderations.ts b/apps/labeler/console/src/fixtures/reconsiderations.ts new file mode 100644 index 000000000..9eceb27d5 --- /dev/null +++ b/apps/labeler/console/src/fixtures/reconsiderations.ts @@ -0,0 +1,112 @@ +import type { + ReconsiderationDetail, + ReconsiderationNoteView, + ReconsiderationView, +} from "../api/types.js"; +import { ASSESSMENT_BETA, ASSESSMENT_GAMMA } from "./assessments.js"; +import { SUBJECT_BETA, SUBJECT_GAMMA } from "./subjects.js"; + +/** An open case for the blocked gamma release — a publisher has asked the + * reviewers to reconsider the malware finding. */ +export const RECONSIDERATION_GAMMA_OPEN: ReconsiderationView = { + id: "recon_01J9ZM8R3N5U9W1Y3T5X7Z9C2D", + subjectUri: SUBJECT_GAMMA.uri, + subjectCid: SUBJECT_GAMMA.cid, + triggeringAssessmentId: ASSESSMENT_GAMMA.id, + state: "open", + outcome: null, + openedById: "access|8f2c1a90-6b3d-4e57-9a12-77c0de9b4a11", + openedByEmail: "reviewer@emdashcms.com", + openedByCommonName: null, + openedByRole: "reviewer", + openedAt: "2026-07-13T10:05:00.000Z", + resolvedById: null, + resolvedByEmail: null, + resolvedByCommonName: null, + resolvedAt: null, + outcomeActionId: null, +}; + +/** A resolved (granted) case for the warned beta release. */ +export const RECONSIDERATION_BETA_GRANTED: ReconsiderationView = { + id: "recon_01J9YJ4G6P8R0T2V4X6Z8B0D3E", + subjectUri: SUBJECT_BETA.uri, + subjectCid: SUBJECT_BETA.cid, + triggeringAssessmentId: ASSESSMENT_BETA.id, + state: "resolved", + outcome: "granted", + openedById: "access|8f2c1a90-6b3d-4e57-9a12-77c0de9b4a11", + openedByEmail: "reviewer@emdashcms.com", + openedByCommonName: null, + openedByRole: "reviewer", + openedAt: "2026-07-12T09:00:00.000Z", + resolvedById: "access|2a7d4c31-9e02-4b18-b5f6-1c8e0a4f2b90", + resolvedByEmail: "admin@emdashcms.com", + resolvedByCommonName: null, + resolvedAt: "2026-07-12T15:30:00.000Z", + outcomeActionId: "oact_01J9YK5H7Q9S1U3W5Y7A9C1E4F", +}; + +const GAMMA_NOTES: ReconsiderationNoteView[] = [ + { + id: "rnote_01J9ZM9A0B2C4E6G8J0M2P4R6T", + reconsiderationId: RECONSIDERATION_GAMMA_OPEN.id, + authorId: RECONSIDERATION_GAMMA_OPEN.openedById, + authorEmail: "reviewer@emdashcms.com", + authorCommonName: null, + authorRole: "reviewer", + note: "Publisher disputes the malware flag; says the payload is a security-research proof-of-concept.", + createdAt: "2026-07-13T10:05:00.000Z", + }, + { + id: "rnote_01J9ZMB1C3D5F7H9K1N3Q5S7U", + reconsiderationId: RECONSIDERATION_GAMMA_OPEN.id, + authorId: "access|2a7d4c31-9e02-4b18-b5f6-1c8e0a4f2b90", + authorEmail: "admin@emdashcms.com", + authorCommonName: null, + authorRole: "admin", + note: "Re-running the scanner against the pinned artifact before deciding.", + createdAt: "2026-07-13T11:20:00.000Z", + }, +]; + +const BETA_NOTES: ReconsiderationNoteView[] = [ + { + id: "rnote_01J9YJ5B2D4F6H8K0M2P4R6T8V", + reconsiderationId: RECONSIDERATION_BETA_GRANTED.id, + authorId: RECONSIDERATION_BETA_GRANTED.openedById, + authorEmail: "reviewer@emdashcms.com", + authorCommonName: null, + authorRole: "reviewer", + note: "Low-quality packaging warning contested — the manifest was fixed in a follow-up release.", + createdAt: "2026-07-12T09:00:00.000Z", + }, + { + id: "rnote_01J9YK6C3E5G7J9L1N3Q5S7U9W", + reconsiderationId: RECONSIDERATION_BETA_GRANTED.id, + authorId: "access|2a7d4c31-9e02-4b18-b5f6-1c8e0a4f2b90", + authorEmail: "admin@emdashcms.com", + authorCommonName: null, + authorRole: "admin", + note: "Confirmed the warning no longer applies. Granting.", + createdAt: "2026-07-12T15:30:00.000Z", + }, +]; + +/** Newest first, matching the labeler's `getReconsiderationsPage` ordering. */ +export const FIXTURE_RECONSIDERATIONS: readonly ReconsiderationView[] = [ + RECONSIDERATION_GAMMA_OPEN, + RECONSIDERATION_BETA_GRANTED, +]; + +/** Case detail (case + oldest-first note thread) keyed by case id. */ +export const FIXTURE_RECONSIDERATION_DETAIL: Record = { + [RECONSIDERATION_GAMMA_OPEN.id]: { + reconsideration: RECONSIDERATION_GAMMA_OPEN, + notes: GAMMA_NOTES, + }, + [RECONSIDERATION_BETA_GRANTED.id]: { + reconsideration: RECONSIDERATION_BETA_GRANTED, + notes: BETA_NOTES, + }, +}; diff --git a/apps/labeler/console/src/reconsiderations.ts b/apps/labeler/console/src/reconsiderations.ts new file mode 100644 index 000000000..7b2d67159 --- /dev/null +++ b/apps/labeler/console/src/reconsiderations.ts @@ -0,0 +1,12 @@ +/** + * Presentation helper for reconsideration actor provenance. Humans carry an + * email, service tokens a common name; both fall back to the raw Access subject + * id so a row always renders someone. + */ +export function reconsiderationActorName(actor: { + email: string | null; + commonName: string | null; + id: string; +}): string { + return actor.email ?? actor.commonName ?? actor.id; +} diff --git a/apps/labeler/console/src/router.tsx b/apps/labeler/console/src/router.tsx index bc3e5ebeb..02dd55e3f 100644 --- a/apps/labeler/console/src/router.tsx +++ b/apps/labeler/console/src/router.tsx @@ -7,6 +7,8 @@ import { auditLogRoute } from "./routes/AuditLog.js"; import { dashboardRoute } from "./routes/Dashboard.js"; import { deadLetterQueueRoute } from "./routes/DeadLetterQueue.js"; import { notFoundRoute } from "./routes/NotFound.js"; +import { reconsiderationDetailRoute } from "./routes/ReconsiderationDetail.js"; +import { reconsiderationsRoute } from "./routes/Reconsiderations.js"; import { rootRoute, shellRoute } from "./routes/root.js"; import { subjectHistoryRoute } from "./routes/SubjectHistory.js"; @@ -17,6 +19,8 @@ const shellRoutes = shellRoute.addChildren([ subjectHistoryRoute, auditLogRoute, deadLetterQueueRoute, + reconsiderationsRoute, + reconsiderationDetailRoute, notFoundRoute, ]); diff --git a/apps/labeler/console/src/routes/AssessmentDetail.tsx b/apps/labeler/console/src/routes/AssessmentDetail.tsx index 8a5f90321..788955094 100644 --- a/apps/labeler/console/src/routes/AssessmentDetail.tsx +++ b/apps/labeler/console/src/routes/AssessmentDetail.tsx @@ -8,6 +8,7 @@ import type { IssuableLabel } from "../api/types.js"; import { AssessmentActionDialog } from "../components/AssessmentActionDialog.js"; import { FindingCard } from "../components/FindingCard.js"; import { LabelActionDialog } from "../components/LabelActionDialog.js"; +import { OpenReconsiderationDialog } from "../components/OpenReconsiderationDialog.js"; import { OverrideDialog } from "../components/OverrideDialog.js"; import { QueryError } from "../components/QueryError.js"; import { StateBadge } from "../components/StateBadge.js"; @@ -76,6 +77,7 @@ function AssessmentDetail() { const [rerunOpen, setRerunOpen] = useState(false); const [overrideOpen, setOverrideOpen] = useState(false); const [overrideRetractOpen, setOverrideRetractOpen] = useState(false); + const [openReconOpen, setOpenReconOpen] = useState(false); if (isAssessmentError) { return ; @@ -165,6 +167,9 @@ function AssessmentDetail() { + {activeBlocks.length > 0 && ( + {isOpen && ( + + )} + + )} + +
+

Notes

+ {notes.length === 0 ? ( +

No notes on this case.

+ ) : ( +
+ {notes.map((note) => ( + + ))} +
+ )} +
+ + {canAct && ( + <> + + + + )} + + ); +} + +export const reconsiderationDetailRoute = createRoute({ + getParentRoute: () => shellRoute, + path: "/reconsiderations/$id", + component: ReconsiderationDetail, +}); diff --git a/apps/labeler/console/src/routes/Reconsiderations.tsx b/apps/labeler/console/src/routes/Reconsiderations.tsx new file mode 100644 index 000000000..ea180ba6c --- /dev/null +++ b/apps/labeler/console/src/routes/Reconsiderations.tsx @@ -0,0 +1,117 @@ +import { LayerCard, Loader, Table } from "@cloudflare/kumo"; +import { useQuery } from "@tanstack/react-query"; +import { createRoute, Link } from "@tanstack/react-router"; + +import { apiClient } from "../api/client.js"; +import { QueryError } from "../components/QueryError.js"; +import { + ReconsiderationOutcomeBadge, + ReconsiderationStateBadge, +} from "../components/ReconsiderationBadges.js"; +import { reconsiderationActorName } from "../reconsiderations.js"; +import { shellRoute } from "./root.js"; + +function Reconsiderations() { + const { data, isLoading, isError, error } = useQuery({ + queryKey: ["reconsiderations"], + queryFn: () => apiClient.listReconsiderations(), + }); + + return ( +
+

Reconsiderations

+

+ Cases where a publisher has asked reviewers to reconsider an assessment. Open a case from an + assessment; resolve it granted, denied, or withdrawn. +

+ + {isError ? ( + + ) : ( + + {isLoading || !data ? ( +
+ +
+ ) : data.items.length === 0 ? ( +
No reconsiderations.
+ ) : ( + + + + Subject + State + Outcome + Opened + Resolved + + + + {data.items.map((recon) => ( + + + + {recon.subjectUri.split("/").pop()} + + + + + + + {recon.outcome ? ( + + ) : ( + + )} + + + + {reconsiderationActorName({ + email: recon.openedByEmail, + commonName: recon.openedByCommonName, + id: recon.openedById, + })} + + + {new Date(recon.openedAt).toLocaleString()} + + + + {recon.resolvedAt ? ( + <> + + {reconsiderationActorName({ + email: recon.resolvedByEmail, + commonName: recon.resolvedByCommonName, + id: recon.resolvedById ?? "", + })} + + + {new Date(recon.resolvedAt).toLocaleString()} + + + ) : ( + + )} + + + ))} + +
+ )} +
+ )} +
+ ); +} + +export const reconsiderationsRoute = createRoute({ + getParentRoute: () => shellRoute, + path: "/reconsiderations", + component: Reconsiderations, +}); diff --git a/apps/labeler/console/src/test/axe.test.tsx b/apps/labeler/console/src/test/axe.test.tsx index 4065568cd..60e0a9e0c 100644 --- a/apps/labeler/console/src/test/axe.test.tsx +++ b/apps/labeler/console/src/test/axe.test.tsx @@ -9,7 +9,9 @@ import { DeadLetterActions } from "../components/DeadLetterActions.js"; import { EmergencyActionDialog } from "../components/EmergencyActionDialog.js"; import { LabelActionDialog } from "../components/LabelActionDialog.js"; import { OverrideDialog } from "../components/OverrideDialog.js"; -import { ASSESSMENT_GAMMA, SUBJECT_ALPHA } from "../fixtures/index.js"; +import { ReconsiderationNoteDialog } from "../components/ReconsiderationNoteDialog.js"; +import { ReconsiderationResolveDialog } from "../components/ReconsiderationResolveDialog.js"; +import { ASSESSMENT_GAMMA, RECONSIDERATION_GAMMA_OPEN, SUBJECT_ALPHA } from "../fixtures/index.js"; import { RELEASE_ISSUABLE_LABELS } from "../labels.js"; import { ADMIN_IDENTITY, renderRoute, renderWithClient } from "./harness.js"; @@ -79,6 +81,20 @@ describe("axe: routes", () => { expect(await axe(container)).toHaveNoViolations(); }); + it("Reconsiderations", async () => { + const { container } = renderRoute("/reconsiderations"); + await screen.findByRole("heading", { name: "Reconsiderations", level: 1 }); + await screen.findByRole("table"); + expect(await axe(container)).toHaveNoViolations(); + }); + + it("ReconsiderationDetail", async () => { + const { container } = renderRoute(`/reconsiderations/${RECONSIDERATION_GAMMA_OPEN.id}`); + await screen.findByRole("heading", { name: "Reconsideration", level: 1 }); + await screen.findByRole("button", { name: "Resolve" }); + expect(await axe(container)).toHaveNoViolations(); + }); + it("NotFound", async () => { const { container } = renderRoute("/no-such-page"); await screen.findByRole("heading", { name: "Not found", level: 1 }); @@ -160,4 +176,30 @@ describe("axe: dialogs", () => { await within(panel).findByText("Retry dead letter"); expect(await axe(panel)).toHaveNoViolations(); }); + + it("ReconsiderationNoteDialog", async () => { + renderWithClient( + {}} + reconsiderationId="recon_1" + subjectUri={RELEASE_URI} + invalidateKeys={[]} + />, + ); + await expectDialogClean("alertdialog"); + }); + + it("ReconsiderationResolveDialog", async () => { + renderWithClient( + {}} + reconsiderationId="recon_1" + subjectUri={RELEASE_URI} + invalidateKeys={[]} + />, + ); + await expectDialogClean("alertdialog"); + }); }); diff --git a/apps/labeler/console/src/test/harness.tsx b/apps/labeler/console/src/test/harness.tsx index 5a2ac92bd..c93c96d5c 100644 --- a/apps/labeler/console/src/test/harness.tsx +++ b/apps/labeler/console/src/test/harness.tsx @@ -11,6 +11,8 @@ import { auditLogRoute } from "../routes/AuditLog.js"; import { dashboardRoute } from "../routes/Dashboard.js"; import { deadLetterQueueRoute } from "../routes/DeadLetterQueue.js"; import { notFoundRoute } from "../routes/NotFound.js"; +import { reconsiderationDetailRoute } from "../routes/ReconsiderationDetail.js"; +import { reconsiderationsRoute } from "../routes/Reconsiderations.js"; import { rootRoute, shellRoute } from "../routes/root.js"; import { subjectHistoryRoute } from "../routes/SubjectHistory.js"; @@ -22,6 +24,8 @@ const routeTree = rootRoute.addChildren([ subjectHistoryRoute, auditLogRoute, deadLetterQueueRoute, + reconsiderationsRoute, + reconsiderationDetailRoute, notFoundRoute, ]), ]); diff --git a/apps/labeler/console/src/test/keyboard.test.tsx b/apps/labeler/console/src/test/keyboard.test.tsx index 28bf87173..4c944707f 100644 --- a/apps/labeler/console/src/test/keyboard.test.tsx +++ b/apps/labeler/console/src/test/keyboard.test.tsx @@ -10,6 +10,8 @@ import { DeadLetterActions } from "../components/DeadLetterActions.js"; import { EmergencyActionDialog } from "../components/EmergencyActionDialog.js"; import { LabelActionDialog } from "../components/LabelActionDialog.js"; import { OverrideDialog } from "../components/OverrideDialog.js"; +import { ReconsiderationNoteDialog } from "../components/ReconsiderationNoteDialog.js"; +import { ReconsiderationResolveDialog } from "../components/ReconsiderationResolveDialog.js"; import { RELEASE_ISSUABLE_LABELS } from "../labels.js"; import { renderWithClient } from "./harness.js"; @@ -115,6 +117,30 @@ const CASES: { /> ), }, + { + name: "ReconsiderationNoteDialog", + role: "alertdialog", + build: (props) => ( + + ), + }, + { + name: "ReconsiderationResolveDialog", + role: "alertdialog", + build: (props) => ( + + ), + }, ]; describe("keyboard: dialog focus management", () => { diff --git a/apps/labeler/console/src/test/reconsiderations.test.tsx b/apps/labeler/console/src/test/reconsiderations.test.tsx new file mode 100644 index 000000000..d4ff16742 --- /dev/null +++ b/apps/labeler/console/src/test/reconsiderations.test.tsx @@ -0,0 +1,74 @@ +import { screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { apiClient } from "../api/client.js"; +import { RECONSIDERATION_BETA_GRANTED, RECONSIDERATION_GAMMA_OPEN } from "../fixtures/index.js"; +import { renderRoute, REVIEWER_IDENTITY } from "./harness.js"; + +vi.mock("../api/client.js", async () => { + const actual = await vi.importActual("../api/client.js"); + return { ...actual, apiClient: actual.createFixtureClient() }; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("Reconsiderations list", () => { + it("renders a case per row with its state and outcome, linking to the detail", async () => { + renderRoute("/reconsiderations"); + await screen.findByRole("heading", { name: "Reconsiderations", level: 1 }); + const table = await screen.findByRole("table"); + + const openLink = within(table).getByRole("link", { + name: RECONSIDERATION_GAMMA_OPEN.subjectUri.split("/").pop(), + }); + expect(openLink.getAttribute("href")).toContain( + `/reconsiderations/${RECONSIDERATION_GAMMA_OPEN.id}`, + ); + expect(within(table).getByText("Open")).toBeTruthy(); + expect(within(table).getByText("Granted")).toBeTruthy(); + }); + + it("shows the empty state when there are no cases", async () => { + vi.spyOn(apiClient, "listReconsiderations").mockResolvedValue({ items: [] }); + renderRoute("/reconsiderations"); + await screen.findByRole("heading", { name: "Reconsiderations", level: 1 }); + expect(await screen.findByText("No reconsiderations.")).toBeTruthy(); + }); +}); + +describe("Reconsideration detail", () => { + it("shows the note thread and offers Resolve for an open case", async () => { + vi.spyOn(apiClient, "whoami").mockResolvedValue(REVIEWER_IDENTITY); + renderRoute(`/reconsiderations/${RECONSIDERATION_GAMMA_OPEN.id}`); + await screen.findByRole("heading", { name: "Reconsideration", level: 1 }); + + expect(await screen.findByRole("button", { name: "Add note" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Resolve" })).toBeTruthy(); + expect(screen.getByText(/Publisher disputes the malware flag/)).toBeTruthy(); + // The triggering assessment cross-links to its detail. + const link = screen.getByRole("link", { + name: RECONSIDERATION_GAMMA_OPEN.triggeringAssessmentId, + }); + expect(link.getAttribute("href")).toContain( + `/assessments/${RECONSIDERATION_GAMMA_OPEN.triggeringAssessmentId}`, + ); + }); + + it("hides Resolve for a resolved case and shows its outcome", async () => { + vi.spyOn(apiClient, "whoami").mockResolvedValue(REVIEWER_IDENTITY); + renderRoute(`/reconsiderations/${RECONSIDERATION_BETA_GRANTED.id}`); + await screen.findByRole("heading", { name: "Reconsideration", level: 1 }); + + await screen.findByRole("button", { name: "Add note" }); + expect(screen.queryByRole("button", { name: "Resolve" })).toBeNull(); + expect(screen.getByText("Granted")).toBeTruthy(); + }); + + it("renders a not-found state for an unknown case id", async () => { + vi.spyOn(apiClient, "getReconsideration").mockResolvedValue(null); + renderRoute("/reconsiderations/recon_missing"); + expect(await screen.findByText("Reconsideration not found.")).toBeTruthy(); + }); +});