import assert from "node:assert/strict"; import { test } from "node:test"; import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { NextApiRequest, NextApiResponse } from "next"; import documents from "../src/pages/api/documents/index"; import batch from "../src/pages/api/documents/batch"; import item from "../src/pages/api/documents/item/[...slug]"; import sections from "../src/pages/api/documents/sections/[...slug]"; import { splitSections } from "../src/lib/document-sections"; import { openSiteDatabase } from "../src/lib/site-database"; import { createDocumentKey, authenticateDocumentKey, revokeDocumentKey, listDocumentKeys } from "../src/lib/document-keys"; import { allDocuments, documentHistory, readDocument, saveDocument, readDocumentSections, type Doc } from "../src/lib/docs"; import { newAuthConfig, signSession } from "../src/lib/auth"; const input: Doc = { slug: "endor/example", sourceSlug: "00-example", title: "Example", category: "Endor", collection: "Endor 文档", group: "讨论", description: "Description", draft: true, featured: false, content: "# Example\n\nOriginal body", revision: 0 }; type Options = { method?: string; body?: unknown; headers?: Record; query?: Record }; function call(handler: (req: NextApiRequest, res: NextApiResponse) => unknown, options: Options = {}) { let status = 200; let body: unknown; const headers: Record = {}; const response = { status(value: number) { status = value; return this; }, setHeader(name: string, value: string) { headers[name.toLowerCase()] = value; return this; }, json(value: unknown) { body = value; return this; }, end() { return this; }, }; handler({ method: options.method ?? "GET", body: options.body, headers: options.headers ?? {}, query: options.query ?? {} } as unknown as NextApiRequest, response as unknown as NextApiResponse); return { status, body, headers }; } function isolated(run: (context: { directory: string; headers: Record }) => void) { const directory = mkdtempSync(join(tmpdir(), "tabularium-doc-api-")); const previous = process.env.AUTH_CONFIG_FILE; process.env.AUTH_CONFIG_FILE = join(directory, "config.json"); writeFileSync(process.env.AUTH_CONFIG_FILE, JSON.stringify(newAuthConfig()), { mode: 0o600 }); try { const key = createDocumentKey("test uploader"); run({ directory, headers: { authorization: "Bearer " + key.token } }); } finally { if (previous === undefined) delete process.env.AUTH_CONFIG_FILE; else process.env.AUTH_CONFIG_FILE = previous; rmSync(directory, { recursive: true, force: true }); } } test("new API requires document keys, rejects browser cookies and broad web sessions", () => isolated(() => { assert.equal(call(documents).status, 401); assert.equal(call(documents, { headers: { authorization: "Bearer invalid" } }).status, 401); const config = JSON.parse(readFileSync(process.env.AUTH_CONFIG_FILE!, "utf8")); const token = signSession(config); assert.equal(call(documents, { headers: { authorization: "Bearer " + token } }).status, 401); assert.equal(call(documents, { headers: { cookie: "tabularium_session=" + token } }).status, 401); assert.deepEqual(allDocuments(), []); })); test("key scopes, revocation, expiry and secret-free listing are persistent", () => isolated(({ directory }) => { const key = createDocumentKey("reader", "read", 1); const headers = { authorization: "Bearer " + key.token }; assert.equal(call(documents, { headers }).status, 200); assert.equal(call(documents, { method: "POST", headers, body: input }).status, 403); assert.equal(call(batch, { method: "POST", headers, body: { documents: [input] } }).status, 403); assert.ok(!JSON.stringify(listDocumentKeys()).includes(key.token)); assert.ok(!readFileSync(join(directory, "login-state.sqlite")).includes(Buffer.from(key.token))); assert.equal(authenticateDocumentKey(key.token, key.expiresAt), null); revokeDocumentKey(key.id); assert.equal(call(documents, { headers }).status, 401); const revoked = listDocumentKeys().find((row) => row.id === key.id); assert.ok(revoked); assert.equal(typeof revoked.revokedAt, "number"); })); test("single save, aliases, metadata and historical content are visible without rebuilding", () => isolated(({ headers }) => { assert.equal(call(documents, { method: "POST", headers, body: input }).status, 200); assert.equal((call(item, { headers, query: { slug: input.slug.split("/") } }).body as Doc).revision, 1); const changed = { ...input, revision: 1, slug: "endor/renamed", content: "# Changed", collection: "新合集" }; assert.equal(call(documents, { method: "POST", headers, body: changed }).status, 200); assert.equal((call(item, { headers, query: { slug: input.slug.split("/") } }).body as Doc).slug, changed.slug); assert.equal((call(item, { headers, query: { slug: [input.sourceSlug], revision: "1" } }).body as Doc).content, input.content); const history = call(item, { headers, query: { slug: [input.sourceSlug], history: "1", limit: "1" } }); const page = history.body as { revisions: Doc[]; total: number; nextOffset: number }; assert.equal(page.total, 2); assert.equal(page.nextOffset, 1); assert.equal(page.revisions[0].revision, 2); assert.equal(page.revisions[0].content, undefined); assert.equal(call(item, { headers, query: { slug: [input.sourceSlug], revision: "99" } }).status, 404); assert.equal(call(item, { headers, query: { slug: [input.sourceSlug], revision: "0" } }).status, 400); })); test("pagination returns metadata only with strict bounds", () => isolated(({ headers }) => { const second = { ...input, slug: "endor/second", sourceSlug: "01-second" }; assert.equal(call(batch, { method: "POST", headers, body: { documents: [input, second] } }).status, 200); const result = call(documents, { headers, query: { limit: "1" } }); const page = result.body as { documents: Doc[]; total: number; nextOffset: number }; assert.equal(page.total, 2); assert.equal(page.nextOffset, 1); assert.equal(page.documents[0].content, undefined); assert.equal((call(documents, { headers, query: { limit: "1", offset: "1" } }).body as typeof page).documents[0].slug, second.slug); const invalidQueries: Record[] = [ { limit: "101" }, { offset: "-1" }, { limit: ["1", "2"] }, { limit: "1e2" }, ]; for (const query of invalidQueries) assert.equal(call(documents, { headers, query }).status, 400); assert.equal(result.headers["cache-control"], "private, no-store"); })); test("late batch conflict rolls back earlier rows and their revision history", () => isolated(({ headers }) => { saveDocument({ ...input, slug: "endor/existing", sourceSlug: "existing" }, 0); const conflict = { ...input, slug: "endor/existing", sourceSlug: "existing", revision: 0 }; assert.equal(call(batch, { method: "POST", headers, body: { documents: [input, conflict] } }).status, 409); assert.equal(readDocument(input.slug), null); assert.equal(documentHistory(input.sourceSlug).length, 0); assert.equal(documentHistory("existing").length, 1); assert.equal(allDocuments().length, 1); })); test("idempotent retries return the original receipt without creating another revision", () => isolated(({ headers }) => { const keyed = { ...headers, "idempotency-key": "upload-1" }; const first = call(documents, { method: "POST", headers: keyed, body: input }); const second = call(documents, { method: "POST", headers: keyed, body: input }); assert.equal(first.status, 200); assert.equal(second.status, 200); assert.equal((second.body as { replayed: boolean }).replayed, true); assert.equal(documentHistory(input.sourceSlug).length, 1); assert.equal(call(documents, { method: "POST", headers: keyed, body: { ...input, content: "Different" } }).status, 409); assert.equal(call(documents, { method: "POST", headers, body: input }).status, 409); })); test("invalid batch members, duplicate identifiers, raw images and malformed fields never write", () => isolated(({ headers }) => { for (const body of [{ documents: [] }, { documents: [input, input] }, { documents: [input], unexpected: true }, { documents: Array.from({ length: 101 }, () => input) }]) assert.equal(call(batch, { method: "POST", headers, body }).status, 400); for (const body of [{ ...input, revision: undefined }, { ...input, typo: true }, { ...input, slug: "../private" }, { ...input, content: "![raw](/figures/original.png)" }, null]) assert.equal(call(documents, { method: "POST", headers, body }).status, 400); assert.deepEqual(allDocuments(), []); assert.equal(call(documents, { method: "DELETE", headers }).status, 405); assert.equal(call(item, { headers, query: { slug: ["missing"] } }).status, 404); })); const chapterContent = "# Example\n\nIntroduction.\n\n## First\n\nFirst body.\n\n### Detail\n\nChild stays in chapter.\n\n## Second\n\nSecond body.\n"; type SectionResponse = { revision: number; replayed?: boolean; sections: { id: string; title: string; summary: string; summaryStatus?: string; content?: string }[] }; const chapterQuery = { slug: input.slug.split("/") }; test("chapter parser preserves bytes, fences, duplicate headings and CRLF", () => { const content = "# Title\r\n\r\n## Repeated\r\n~~~text\r\n## Inside fence\r\n~~~\r\n### Nested\r\n```js\r\n## Also inside\r\n````\r\n\r\n## Repeated\r\nTail"; const parts = splitSections(content); assert.equal(parts.length, 3); assert.notEqual(parts[1].id, parts[2].id); assert.equal(parts.map((p) => p.content).join(""), content); assert.equal(parts[1].title, "Repeated"); assert.ok(parts[1].content.includes("### Nested")); assert.equal(splitSections("").length, 1); assert.equal(splitSections("# Title\n\n- Item\n ## List detail\n\n> ## Quoted\n").length, 1); }); test("outline reads existing Markdown without changing it and returns only selected bodies", () => isolated(({ headers }) => { saveDocument({ ...input, content: chapterContent }, 0); const db = openSiteDatabase(); db.exec("DELETE FROM document_sections; DELETE FROM document_section_revisions;"); db.close(); const result = call(sections, { headers, query: chapterQuery }); assert.equal(result.status, 200); const outline = result.body as SectionResponse; assert.equal(outline.revision, 1); assert.equal(outline.sections.length, 3); assert.deepEqual(outline.sections.map((s) => s.title), ["导言", "First", "Second"]); assert.ok(outline.sections.every((s) => s.content === undefined && s.summaryStatus === "missing")); const selected = call(sections, { headers, query: { ...chapterQuery, section: outline.sections[1].id } }).body as SectionResponse; assert.equal(selected.sections.length, 1); assert.ok(selected.sections[0].content!.includes("### Detail")); assert.equal(readDocument(input.slug)!.content, chapterContent); assert.equal(call(sections, { headers, query: { ...chapterQuery, section: "missing" } }).status, 404); assert.equal(call(sections, { headers, query: { ...chapterQuery, revision: "0" } }).status, 400); })); test("internal summaries persist separately and chapter updates leave other bytes untouched", () => isolated(({ headers }) => { saveDocument({ ...input, content: chapterContent }, 0); const before = readDocumentSections(input.slug)!; const first = before.sections[1]; const second = before.sections[2]; const changed = first.content.replace("First body.", "Revised body."); const edited = call(sections, { method: "POST", headers, query: chapterQuery, body: { revision: 1, operations: [ { op: "update", id: first.id, content: changed, summary: "INTERNAL_SUMMARY_FIRST" }, { op: "update", id: second.id, summary: "INTERNAL_SUMMARY_SECOND" }, ] } }); assert.equal(edited.status, 200); const after = readDocumentSections(input.slug)!; assert.equal(after.revision, 2); assert.equal(after.sections[1].id, first.id); assert.equal(after.sections[0].content, before.sections[0].content); assert.equal(after.sections[2].content, before.sections[2].content); assert.equal(after.sections[1].summary, "INTERNAL_SUMMARY_FIRST"); assert.equal(readDocument(input.slug)!.content, chapterContent.replace("First body.", "Revised body.")); assert.ok(!JSON.stringify(allDocuments()).includes("INTERNAL_SUMMARY")); assert.ok(!JSON.stringify(call(item, { headers, query: chapterQuery }).body).includes("INTERNAL_SUMMARY")); assert.ok(!JSON.stringify(call(documents, { headers }).body).includes("INTERNAL_SUMMARY")); const history = readDocumentSections(input.slug, 1)!; assert.equal(history.sections[1].content, first.content); assert.equal(history.sections[1].summary, ""); assert.equal(readDocumentSections(input.slug, 2)!.sections[1].summary, "INTERNAL_SUMMARY_FIRST"); })); test("insert, move and delete chapters commit together with replay and conflict protection", () => isolated(({ headers }) => { saveDocument({ ...input, content: chapterContent }, 0); const before = readDocumentSections(input.slug)!; const first = before.sections[1]; const second = before.sections[2]; const body = { revision: 1, operations: [ { op: "move", id: second.id, after: "preamble" }, { op: "delete", id: first.id }, { op: "insert", after: second.id, content: "## New chapter\n\nNew body.\n", summary: "New material" }, ] }; const options = { method: "POST", headers: { ...headers, "idempotency-key": "chapters-1" }, query: chapterQuery, body }; assert.equal(call(sections, options).status, 200); const repeated = call(sections, options); assert.equal(repeated.status, 200); assert.equal((repeated.body as SectionResponse).replayed, true); const after = readDocumentSections(input.slug)!; assert.deepEqual(after.sections.map((s) => s.title), ["导言", "Second", "New chapter"]); assert.equal(after.sections[1].content, second.content); assert.equal(after.revision, 2); assert.equal(documentHistory(input.sourceSlug).length, 2); assert.equal(call(sections, { ...options, body: { ...body, revision: 2 } }).status, 409); assert.equal(call(sections, { ...options, headers }).status, 409); assert.equal(saveDocument({ ...readDocument(input.slug)!, description: "Changed metadata" }, 2).revision, 3); assert.equal(readDocumentSections(input.slug)!.sections[2].summary, "New material"); })); test("invalid late operation rolls back content, summary and history; read-only keys cannot edit", () => isolated(({ headers }) => { saveDocument({ ...input, content: chapterContent }, 0); const before = readDocumentSections(input.slug)!; const invalid = [ [{ op: "update", id: before.sections[1].id, summary: "Should roll back" }, { op: "delete", id: "missing" }], [{ op: "delete", id: "preamble" }], [{ op: "move", id: before.sections[1].id, after: before.sections[1].id }], [{ op: "update", id: before.sections[1].id, content: "## First\n\n## Extra\n" }], [{ op: "update", id: before.sections[1].id, content: "## First\n![raw](/figures/raw.png)\n" }], [{ op: "insert", after: "preamble", content: "## Missing summary\n" }], [{ op: "delete", id: before.sections[1].id, unexpected: true }], ]; for (const operations of invalid) { assert.equal(call(sections, { method: "POST", headers, query: chapterQuery, body: { revision: 1, operations } }).status, 400); assert.deepEqual(readDocumentSections(input.slug), before); assert.equal(documentHistory(input.sourceSlug).length, 1); } assert.equal(call(sections, { query: chapterQuery }).status, 401); const reader = createDocumentKey("chapter reader", "read"); const readHeaders = { authorization: "Bearer " + reader.token }; assert.equal(call(sections, { headers: readHeaders, query: chapterQuery }).status, 200); assert.equal(call(sections, { method: "POST", headers: readHeaders, query: chapterQuery, body: { revision: 1, operations: [] } }).status, 403); })); test("full-document saves invalidate changed chapter summaries and preserve unaffected summaries and ids", () => isolated(({ headers }) => { saveDocument({ ...input, content: chapterContent }, 0); const before = readDocumentSections(input.slug)!; assert.equal(call(sections, { method: "POST", headers, query: chapterQuery, body: { revision: 1, operations: before.sections.map((s) => ({ op: "update", id: s.id, summary: "Summary of " + s.title })) } }).status, 200); saveDocument({ ...readDocument(input.slug)!, content: chapterContent.replace("First body.", "Changed externally.") }, 2); const after = readDocumentSections(input.slug)!; assert.equal(after.sections[1].id, before.sections[1].id); assert.equal(after.sections[1].summary, ""); assert.equal(after.sections[2].summary, "Summary of Second"); assert.equal(readDocumentSections(input.slug, 2)!.sections[1].summary, "Summary of First"); }));