Files
endorphin-tabularium-docs/apps/docs/tests/documents-api.test.ts
T

259 lines
17 KiB
TypeScript

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<string, string>;
query?: Record<string, string | string[]> };
function call(handler: (req: NextApiRequest, res: NextApiResponse) => unknown, options: Options = {}) {
let status = 200;
let body: unknown;
const headers: Record<string, string> = {};
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<string, string> }) => 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<string, string | string[]>[] = [
{ 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");
}));