Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 0 additions & 31 deletions apps/api/src/orchestrators/saveAnswerReceiptsD2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -695,37 +695,6 @@ describe("D2 replay receipts on real PostgreSQL (#669 Phase D2)", () => {
expect(fresh.serverVersion).toBe(2);
}, 60_000);

it("UQ: the composite PK rejects a duplicate replay key at the database", async () => {
const { attemptId } = await newRichAttemptFixture("uq");
const savedAt = new Date(STARTED_AT.getTime() + 1000);
await saveViaCanonicalSeam(db, {
attemptId,
answer: richAnswer("first"),
clientSeq: 1,
baseVersion: 0,
now: savedAt,
});

const repo = createAttemptRepo(db);
try {
await repo.appendAnswerSaveReceipt(ctx, {
attemptId,
questionId: QUESTION_ID,
clientSeq: 1,
answerIdentity: "f".repeat(64),
acceptedVersion: 9,
savedAt,
});
expect.unreachable("duplicate receipt insert must violate the PK");
} catch (err) {
// Drizzle wraps the PG error; the constraint name lives on the cause.
const cause = (err as { cause?: { message?: string } }).cause;
expect(`${(err as Error).message} ${cause?.message ?? ""}`).toMatch(
/exam_answer_save_receipts_pk/,
);
}
}, 60_000);

it("R7: a backfilled legacy receipt still replays through the production adapter", async () => {
const { attemptId } = await newRichAttemptFixture("legacy");
const legacySavedAt = new Date("2026-02-01T00:30:00.000Z");
Expand Down
43 changes: 14 additions & 29 deletions apps/api/src/routes/answerRichClosure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,32 +269,17 @@ describe("rich answer canonical closure (save-answer route)", () => {
return { answers: attempt?.answers ?? null, receiptCount: receipts.length };
}

it("rejects a U+0000 rich answer with structured INVALID_ANSWER and zero durable write (D-F01)", async () => {
const before = await durableState();
const res = await saveAnswer(
{
docVersion: 1,
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "a\u0000b" }] },
],
},
20,
);
expect(res.statusCode, res.body).toBe(200);
expect(res.json()).toMatchObject({
accepted: false,
reason: "INVALID_ANSWER",
});
expect(await durableState()).toEqual(before);
});

it("rejects lone surrogates the same way (D-F01 family)", async () => {
const before = await durableState();
for (const [clientSeq, bad] of [
[21, "\uD800"],
[22, "\uDC00"],
] as const) {
// D-F01 at the wire: both unrepresentable families (U+0000, lone
// surrogates) go through the same structured INVALID_ANSWER + zero
// durable write mechanism, so the route-level rejection is table-driven.
it.each([
["U+0000", "a\u0000b", 20],
["lone high surrogate", "\uD800", 21],
["lone low surrogate", "\uDC00", 22],
] as const)(
"rejects a rich answer carrying %s with structured INVALID_ANSWER and zero durable write (D-F01)",
async (_family, bad, clientSeq) => {
const before = await durableState();
const res = await saveAnswer(
{
docVersion: 1,
Expand All @@ -310,9 +295,9 @@ describe("rich answer canonical closure (save-answer route)", () => {
accepted: false,
reason: "INVALID_ANSWER",
});
}
expect(await durableState()).toEqual(before);
});
expect(await durableState()).toEqual(before);
},
);

it("still accepts well-formed exotic scalars — representability, not ASCII-ness", async () => {
const res = await saveAnswer(
Expand Down
89 changes: 45 additions & 44 deletions apps/api/src/routes/questionRichContent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,13 +377,14 @@ describe("rich content write authority", () => {
});
});

it("rejects rich writes whose strings are not durably representable (D-F01 cross-writer)", async () => {
for (const [label, bad] of [
["U+0000", "\u0000"],
["lone high surrogate", "\uD800"],
["lone low surrogate", "\uDC00"],
] as const) {
const res = await createQuestion({
// D-F01 cross-writer parity: the representability rule must fire at BOTH
// rich write seams (prompt document, option document). Family enumeration
// is owned by the contracts leaf tests over the same schema object, so
// each seam here proves enforcement with one representative family.
it.each([
{
seam: "rich prompt",
makeRequest: (bad: string) => ({
type: "text_response",
contentDocument: {
...RICH_DOC,
Expand All @@ -397,43 +398,43 @@ describe("rich content write authority", () => {
options: [],
standardAnswer: null,
rubric: "r",
});
expect(res.statusCode, `${label}: ${res.body}`).toBe(400);
// The rejection is the representability rule, not an unrelated 400.
expect(res.body, label).toContain("well-formed Unicode scalar values");
}
});

it("rejects a rich option carrying an unrepresentable string (D-F01 cross-writer)", async () => {
const res = await createQuestion({
type: "single_choice",
content: "pick one",
options: [
{
id: "A",
contentDocument: {
...RICH_DOC,
content: [
{ type: "paragraph", content: [{ type: "text", text: "A" }] },
],
}),
},
{
seam: "rich option",
makeRequest: (bad: string) => ({
type: "single_choice",
content: "pick one",
options: [
{
id: "A",
contentDocument: {
...RICH_DOC,
content: [
{ type: "paragraph", content: [{ type: "text", text: "A" }] },
],
},
},
},
{
id: "B",
contentDocument: {
...RICH_DOC,
content: [
{
type: "paragraph",
content: [{ type: "text", text: "\uDC00" }],
},
],
{
id: "B",
contentDocument: {
...RICH_DOC,
content: [
{ type: "paragraph", content: [{ type: "text", text: bad }] },
],
},
},
},
],
standardAnswer: "A",
});
expect(res.statusCode, res.body).toBe(400);
expect(res.body).toContain("well-formed Unicode scalar values");
});
],
standardAnswer: "A",
}),
},
])(
"rejects $seam writes carrying an unrepresentable string (D-F01 cross-writer)",
async ({ makeRequest }) => {
const res = await createQuestion(makeRequest("\uDC00"));
expect(res.statusCode, res.body).toBe(400);
// The rejection is the representability rule, not an unrelated 400.
expect(res.body).toContain("well-formed Unicode scalar values");
},
);
});
20 changes: 0 additions & 20 deletions apps/web/src/components/exam/QuestionRenderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,26 +104,6 @@ describe("QuestionRenderer — rich text_response (issue 301)", () => {
}
}, 15000);

it("fails closed on an unexplained string in a rich slot (PC-F03: no runtime-shape legacy adoption)", async () => {
// §7 read contract: a plain string where Rich is authoritative is corrupt
// unless explicit provenance establishes legacy_plain — which no runtime
// shape provides. The rich input must show the integrity state instead of
// adopting the string as answer content.
render(
<QuestionRenderer
question={{
...baseQuestion,
type: "text_response",
answerMode: "rich",
}}
answer={"旧草稿"}
onChange={() => {}}
/>,
);
await screen.findByTestId("rich-answer-integrity-error");
expect(document.querySelector(".ProseMirror")).toBeNull();
}, 15000);

it("keeps the plain textarea when answerMode is plain", () => {
render(
<QuestionRenderer
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import type { ContentBlock, ContentDocumentV1 } from "@exam/domain";
import { describe, expect, it } from "vitest";
import { ContentRenderer } from "./ContentRenderer";
import { ContentDocumentRenderer } from "./ContentDocumentRenderer";
import { MathRenderer } from "./MathRenderer";

/**
* Adversarial security tests for the static content READ path.
Expand Down Expand Up @@ -291,37 +290,6 @@ describe("ContentRenderer — boundary fail-closed on oversize/hostile-structure
});
});

describe("MathRenderer — hostile LaTeX (trust: false)", () => {
const HOSTILE_LATEX = [
"{\\href{javascript:alert(1)}{click}}",
"\\includegraphics[width=\\linewidth]{http://evil.example/x.png}",
"\\htmlClass{x}{content}\\htmlData{trick=1}{d}",
"\\htmlId{payload}{x}",
"\\htmlStyle{background:url(javascript:alert(1))}{x}",
"\\frac{\\oops",
];

it("renders hostile and invalid LaTeX as inert source, never executable markup", () => {
for (const latex of HOSTILE_LATEX) {
const { container, unmount } = render(
<MathRenderer latex={latex} displayMode={false} />,
);
expect(container.textContent).not.toBe("");
assertInert(container);
unmount();
}
});

it("renders oversized latex as escaped text without invoking KaTeX output", () => {
const huge = "x".repeat(5001);
const { container } = render(
<MathRenderer latex={huge} displayMode={false} />,
);
expect(container.textContent).toBe(huge);
assertInert(container);
});
});

describe("dangerouslySetInnerHTML stays confined to the KaTeX seam", () => {
it("appears in exactly one content component file", () => {
const dir = join(dirname(fileURLToPath(import.meta.url)));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,53 @@ describe("MathRenderer — real React seam", () => {
});
});

describe("MathRenderer — hostile LaTeX through the live seam (trust: false)", () => {
// Full live-DOM audit (the same 16-tag active/remote-content selector set
// as assertInertHtml, plus event-handler/javascript: attributes) for the
// KaTeX trust-disallowed command families, through the real MathRenderer
// seam rather than the katexRenderToHtml policy seam above.
function assertInert(container: HTMLElement): void {
expect(container.querySelectorAll(ACTIVE_SELECTORS)).toHaveLength(0);
for (const el of Array.from(container.querySelectorAll("*"))) {
for (const attr of Array.from(el.attributes)) {
expect(/^on/i.test(attr.name)).toBe(false);
expect(/javascript:/i.test(attr.value)).toBe(false);
}
}
}

const HOSTILE_LATEX = [
"{\\href{javascript:alert(1)}{click}}",
"\\includegraphics[width=\\linewidth]{http://evil.example/x.png}",
"\\htmlClass{x}{content}\\htmlData{trick=1}{d}",
"\\htmlId{payload}{x}",
"\\htmlStyle{background:url(javascript:alert(1))}{x}",
"\\frac{\\oops",
];

it("D5B-R6: hostile and trust-disallowed LaTeX renders as inert source in the live DOM", async () => {
for (const latex of HOSTILE_LATEX) {
const { container, unmount } = render(
<MathRenderer latex={latex} displayMode={false} />,
);
await waitFor(() => {
expect(container.textContent).not.toBe("");
});
assertInert(container);
unmount();
}
});

it("D5B-R7: oversized latex is escaped verbatim without invoking KaTeX output", () => {
const huge = "x".repeat(5001);
const { container } = render(
<MathRenderer latex={huge} displayMode={false} />,
);
expect(container.textContent).toBe(huge);
assertInert(container);
});
});

describe("ContentRenderer → ContentDocumentRenderer → MathRenderer composition", () => {
function doc(blocks: ContentBlock[]): ContentDocumentV1 {
return { docVersion: 1, type: "doc", content: blocks };
Expand Down
19 changes: 7 additions & 12 deletions packages/contracts/src/__tests__/contentDocument.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,12 +404,13 @@ describe("ContentDocumentV1Schema — preflight-safe parse entry", () => {
return { docVersion: 1, type: "doc", content: [block] };
}

it("rejects a 500-level recursive grammar bomb in a controlled way (no RangeError)", () => {
expect(() =>
ContentDocumentV1Schema.safeParse(grammarBomb(500)),
).not.toThrow();
expect(ContentDocumentV1Schema.safeParse(grammarBomb(500)).success).toBe(
false,
it("rejects a 500-level recursive grammar bomb in a controlled way (no RangeError), naming the structural limit", () => {
const parsed = ContentDocumentV1Schema.safeParse(grammarBomb(500));
expect(parsed.success).toBe(false);
// The controlled rejection is the PREFLIGHT's, not a grammar mismatch —
// the issue message names the structural limit.
expect(parsed.error?.issues[0]?.message ?? "").toMatch(
/nesting exceeds|depth exceeds|structural/,
);
});

Expand All @@ -422,12 +423,6 @@ describe("ContentDocumentV1Schema — preflight-safe parse entry", () => {
expect(CreateQuestionRequestSchema.safeParse(body).success).toBe(false);
});

it("proves the preflight fired: the rejection names the structural limit, not a grammar mismatch", () => {
const parsed = ContentDocumentV1Schema.safeParse(grammarBomb(500));
const message = parsed.error?.issues[0]?.message ?? "";
expect(message).toMatch(/nesting exceeds|depth exceeds|structural/);
});

it("accepts a within-limits document the removed raw-node budget used to reject (#673 C1 / PC-F02)", () => {
// 677 plain paragraphs = 1354 grammar nodes < totalNodes(2000), ~41k
// serialized chars < serializedChars: the measured minimal failure of
Expand Down
Loading
Loading