137 lines
8.5 KiB
TypeScript
137 lines
8.5 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 { createDocumentKey, authenticateDocumentKey, revokeDocumentKey, listDocumentKeys } from "../src/lib/document-keys";
|
|
import { allDocuments, documentHistory, readDocument, saveDocument, 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: "" }, 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);
|
|
}));
|