Files
TallyNote/tests/api.test.ts
T
Qiufeng 12495fb6a4
TallyNote release / linux-x64 (push) Successful in 6m24s
release: 1.1.0
2026-08-31 20:11:34 +08:00

490 lines
24 KiB
TypeScript

import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { loadConfig, prepareDataDirectories } from "../server/config.js";
import { openDatabase } from "../server/db/index.js";
import { buildApp } from "../server/app.js";
import { hashPassword } from "../server/security.js";
const tinyPng = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64");
function multipart(parts: Array<{ name: string; value?: string; filename?: string; contentType?: string; data?: Buffer }>): { body: Buffer; contentType: string } {
const boundary = `----tallynote-${randomUUID()}`;
const chunks: Buffer[] = [];
for (const part of parts) {
chunks.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${part.name}"${part.filename ? `; filename="${part.filename}"` : ""}${part.filename ? `\r\nContent-Type: ${part.contentType || "application/octet-stream"}` : ""}\r\n\r\n`));
chunks.push(part.data ?? Buffer.from(part.value ?? ""));
chunks.push(Buffer.from("\r\n"));
}
chunks.push(Buffer.from(`--${boundary}--\r\n`));
return { body: Buffer.concat(chunks), contentType: `multipart/form-data; boundary=${boundary}` };
}
describe("TallyNote API", () => {
let dataDir: string;
let app: Awaited<ReturnType<typeof buildApp>>;
let database: ReturnType<typeof openDatabase>;
let config: ReturnType<typeof loadConfig>;
beforeEach(async () => {
dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-api-"));
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3999";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
config = loadConfig();
// API tests must not depend on the ignored, locally generated dist/ tree.
config.webDir = path.join(dataDir, "missing-web");
prepareDataDirectories(config);
database = openDatabase(config);
app = await buildApp(database, config);
});
afterEach(async () => {
await app.close();
database.sqlite.close();
rmSync(dataDir, { recursive: true, force: true });
});
async function seedAdmin() {
const id = randomUUID();
const password = "ApiTestPassword!2026";
const now = Date.now();
const passwordHash = await hashPassword(password);
database.sqlite.prepare(`
INSERT INTO admins(id, username, username_norm, display_name, password_hash, status,
must_change_password, auth_version, version, created_at)
VALUES (?, ?, ?, ?, ?, 'active', 0, 1, 1, ?)
`).run(id, "api-admin", "api-admin", "API 测试管理员", passwordHash, now);
return { id, password };
}
async function login() {
const admin = await seedAdmin();
const response = await app.inject({
method: "POST",
url: "/api/auth/login",
headers: { origin: config.publicOrigin },
payload: { username: "api-admin", password: admin.password },
});
expect(response.statusCode).toBe(200);
const rawCookies = response.headers["set-cookie"];
const cookies = (Array.isArray(rawCookies) ? rawCookies : [rawCookies ?? ""]).map((cookie) => cookie.split(";", 1)[0]).join("; ");
const csrf = /(?:^|; )tally_csrf=([^;]+)/.exec(cookies)?.[1];
expect(csrf).toBeTruthy();
return { admin, cookies, csrf: csrf! };
}
it("未初始化时健康检查为 false,且错误包含 requestId", async () => {
const health = await app.inject({ method: "GET", url: "/health" });
expect(health.statusCode).toBe(200);
expect(health.json()).toEqual({ status: "ok", initialized: false });
expect(health.headers["content-security-policy"]).toContain("frame-ancestors 'none'");
expect(health.headers["x-frame-options"]).toBe("DENY");
const missing = await app.inject({ method: "GET", url: "/api/nope" });
expect(missing.statusCode).toBe(404);
expect(missing.json().error.requestId).toBeTruthy();
});
it("拒绝没有 Origin 的写请求", async () => {
const response = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "x", password: "x" } });
expect(response.statusCode).toBe(403);
expect(response.json().error.code).toBe("ORIGIN_FORBIDDEN");
});
it("将非法 JSON、伪造请求 ID 处理为结构化 400", async () => {
const response = await app.inject({
method: "POST",
url: "/api/auth/login",
headers: { origin: config.publicOrigin, "content-type": "application/json", "x-request-id": "attacker" },
payload: "{",
});
expect(response.statusCode).toBe(400);
expect(response.json().error.code).toBe("INVALID_JSON");
expect(response.json().error.requestId).not.toBe("attacker");
expect(response.json().error.requestId).toMatch(/^[0-9a-f-]{36}$/);
});
it("登录入口使用小 body limit,避免未认证大 JSON 消耗内存", async () => {
const response = await app.inject({
method: "POST",
url: "/api/auth/login",
headers: { origin: config.publicOrigin, "content-type": "application/json" },
payload: { username: "x", password: "x", padding: "x".repeat(20_000) },
});
expect(response.statusCode).toBe(413);
expect(response.json().error.code).toBe("REQUEST_TOO_LARGE");
});
it("下发服务器时区,并禁止当前管理员重置自己", async () => {
const session = await login();
const status = await app.inject({ method: "GET", url: "/api/auth/status" });
expect(status.json()).toEqual({ initialized: true, timezone: config.timezone });
const reset = await app.inject({
method: "POST",
url: `/api/admins/${session.admin.id}/reset-password`,
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf },
payload: { version: 1 },
});
expect(reset.statusCode).toBe(409);
expect(reset.json().error.code).toBe("SELF_RESET_FORBIDDEN");
});
it("重复设置相同报销状态是幂等操作", async () => {
const session = await login();
const expenseId = randomUUID();
const now = Date.now();
database.sqlite.prepare(`
INSERT INTO expenses(id, paid_at, amount_cents, note, status, version, created_at, created_by,
updated_at, updated_by, reimbursed_at, reimbursed_by)
VALUES (?, ?, 1234, '幂等测试', 'reimbursed', 1, ?, ?, ?, ?, ?, ?)
`).run(expenseId, now, now, session.admin.id, now, session.admin.id, now - 1000, session.admin.id);
const response = await app.inject({
method: "POST",
url: `/api/expenses/${expenseId}/status`,
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf },
payload: { status: "reimbursed", version: 1 },
});
expect(response.statusCode).toBe(200);
expect(response.json().expense.version).toBe(1);
const row = database.sqlite.prepare("SELECT version, reimbursed_at AS reimbursedAt FROM expenses WHERE id=?").get(expenseId) as { version: number; reimbursedAt: number };
expect(row).toEqual({ version: 1, reimbursedAt: now - 1000 });
});
it("新建账目拒绝未知 multipart 字段", async () => {
const session = await login();
const boundary = "----tallynote-test-boundary";
const payload = [
`--${boundary}`,
'Content-Disposition: form-data; name="unexpected"',
"",
"value",
`--${boundary}--`,
"",
].join("\r\n");
const response = await app.inject({
method: "POST",
url: "/api/expenses",
headers: {
origin: config.publicOrigin,
cookie: session.cookies,
"x-csrf-token": session.csrf,
"content-type": `multipart/form-data; boundary=${boundary}`,
},
payload,
});
expect(response.statusCode).toBe(400);
expect(response.json().error.code).toBe("UNKNOWN_FIELD");
});
it("附件记录存在但文件缺失时返回 410", async () => {
const session = await login();
const expenseId = randomUUID();
const attachmentId = randomUUID();
const now = Date.now();
database.sqlite.prepare(`
INSERT INTO expenses(id, paid_at, amount_cents, note, status, version, created_at, created_by,
updated_at, updated_by)
VALUES (?, ?, 100, '缺失附件测试', 'unreimbursed', 1, ?, ?, ?, ?)
`).run(expenseId, now, now, session.admin.id, now, session.admin.id);
database.sqlite.prepare(`
INSERT INTO attachments(id, expense_id, kind, storage_path, original_name, mime_type,
size_bytes, sha256, created_at, created_by)
VALUES (?, ?, 'payment_proof', 'aa/missing.png', 'missing.png', 'image/png', 10, ?, ?, ?)
`).run(attachmentId, expenseId, "0".repeat(64), now, session.admin.id);
const response = await app.inject({
method: "GET",
url: `/api/attachments/${attachmentId}/content`,
headers: { cookie: session.cookies },
});
expect(response.statusCode).toBe(410);
expect(response.json().error.code).toBe("ATTACHMENT_MISSING");
});
it("无发票时必须填写原因,并在账目中保存", async () => {
const session = await login();
const form = multipart([
{ name: "paidAt", value: "2026-08-27T12:00:00.000Z" },
{ name: "amount", value: "12.34" },
{ name: "note", value: "无票测试" },
{ name: "invoiceMissingReason", value: "商家无法开具发票" },
{ name: "paymentProofs", filename: "proof.png", contentType: "image/png", data: tinyPng },
]);
const response = await app.inject({
method: "POST",
url: "/api/expenses",
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": form.contentType },
payload: form.body,
});
expect(response.statusCode).toBe(201);
const expense = response.json().expense;
expect(expense.invoiceCount).toBe(0);
expect(expense.invoiceMissingReason).toBe("商家无法开具发票");
});
it("无发票且未填写原因时拒绝新建", async () => {
const session = await login();
const form = multipart([
{ name: "paidAt", value: "2026-08-27T12:00:00.000Z" },
{ name: "amount", value: "12.34" },
{ name: "paymentProofs", filename: "proof.png", contentType: "image/png", data: tinyPng },
]);
const response = await app.inject({
method: "POST",
url: "/api/expenses",
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": form.contentType },
payload: form.body,
});
expect(response.statusCode).toBe(400);
expect(response.json().error.code).toBe("INVOICE_OR_REASON_REQUIRED");
});
it("有发票时拒绝同时填写无发票原因", async () => {
const session = await login();
const form = multipart([
{ name: "paidAt", value: "2026-08-27T12:00:00.000Z" },
{ name: "amount", value: "12.34" },
{ name: "invoiceMissingReason", value: "供应商无法开票" },
{ name: "paymentProofs", filename: "proof.png", contentType: "image/png", data: tinyPng },
{ name: "invoices", filename: "invoice.xml", contentType: "application/xml", data: Buffer.from("<invoice />") },
]);
const response = await app.inject({
method: "POST",
url: "/api/expenses",
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": form.contentType },
payload: form.body,
});
expect(response.statusCode).toBe(400);
expect(response.json().error.code).toBe("INVOICE_REASON_WITH_INVOICE");
});
it("编辑无票账目时可更新原因,但不能清空为无原因", async () => {
const session = await login();
const form = multipart([
{ name: "paidAt", value: "2026-08-27T12:00:00.000Z" },
{ name: "amount", value: "12.34" },
{ name: "invoiceMissingReason", value: "暂时无法取得" },
{ name: "paymentProofs", filename: "proof.png", contentType: "image/png", data: tinyPng },
]);
const created = await app.inject({
method: "POST",
url: "/api/expenses",
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": form.contentType },
payload: form.body,
});
expect(created.statusCode).toBe(201);
const expense = created.json().expense;
const rejected = await app.inject({
method: "PATCH",
url: `/api/expenses/${expense.id}`,
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf },
payload: { paidAt: "2026-08-27T12:00:00.000Z", amount: "12.34", note: "无票测试", invoiceMissingReason: null, version: expense.version },
});
expect(rejected.statusCode).toBe(400);
expect(rejected.json().error.code).toBe("INVOICE_OR_REASON_REQUIRED");
const updated = await app.inject({
method: "PATCH",
url: `/api/expenses/${expense.id}`,
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf },
payload: { paidAt: "2026-08-27T12:00:00.000Z", amount: "12.34", note: "无票测试", invoiceMissingReason: "供应商仅提供收据", version: expense.version },
});
expect(updated.statusCode).toBe(200);
expect(updated.json().expense.invoiceMissingReason).toBe("供应商仅提供收据");
});
it("已有发票时编辑拒绝填写无发票原因", async () => {
const session = await login();
const form = multipart([
{ name: "paidAt", value: "2026-08-27T12:00:00.000Z" },
{ name: "amount", value: "12.34" },
{ name: "paymentProofs", filename: "proof.png", contentType: "image/png", data: tinyPng },
{ name: "invoices", filename: "invoice.xml", contentType: "application/xml", data: Buffer.from("<invoice />") },
]);
const created = await app.inject({
method: "POST",
url: "/api/expenses",
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": form.contentType },
payload: form.body,
});
expect(created.statusCode).toBe(201);
const expense = created.json().expense;
const response = await app.inject({
method: "PATCH",
url: `/api/expenses/${expense.id}`,
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf },
payload: { paidAt: "2026-08-27T12:00:00.000Z", amount: "12.34", note: "保留发票", invoiceMissingReason: "不应填写", version: expense.version },
});
expect(response.statusCode).toBe(400);
expect(response.json().error.code).toBe("INVOICE_REASON_WITH_INVOICE");
});
it("删除最后一张发票时要求并原子保存无发票原因", async () => {
const session = await login();
const form = multipart([
{ name: "paidAt", value: "2026-08-27T12:00:00.000Z" },
{ name: "amount", value: "12.34" },
{ name: "note", value: "删除发票测试" },
{ name: "paymentProofs", filename: "proof.png", contentType: "image/png", data: tinyPng },
{ name: "invoices", filename: "invoice.xml", contentType: "application/xml", data: Buffer.from("<invoice />") },
]);
const created = await app.inject({
method: "POST",
url: "/api/expenses",
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": form.contentType },
payload: form.body,
});
expect(created.statusCode).toBe(201);
const expense = created.json().expense as { id: string; version: number; invoiceCount: number; attachments: Array<{ id: string; kind: string }> };
const invoice = expense.attachments.find((item) => item.kind === "invoice");
expect(invoice).toBeTruthy();
const rejected = await app.inject({
method: "DELETE",
url: `/api/attachments/${invoice!.id}`,
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf },
payload: { version: expense.version },
});
expect(rejected.statusCode).toBe(409);
expect(rejected.json().error.code).toBe("INVOICE_OR_REASON_REQUIRED");
const deleted = await app.inject({
method: "DELETE",
url: `/api/attachments/${invoice!.id}`,
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf },
payload: { version: expense.version, invoiceMissingReason: "供应商仅提供收据,无法补开发票" },
});
expect(deleted.statusCode).toBe(200);
const updated = deleted.json().expense;
expect(updated.invoiceCount).toBe(0);
expect(updated.invoiceMissingReason).toBe("供应商仅提供收据,无法补开发票");
expect(updated.version).toBe(expense.version + 1);
expect(updated.attachments.some((item: { id: string }) => item.id === invoice!.id)).toBe(false);
});
it("发票字节丢失时仍可删除附件元数据并保存原因", async () => {
const session = await login();
const form = multipart([
{ name: "paidAt", value: "2026-08-27T12:00:00.000Z" },
{ name: "amount", value: "8.00" },
{ name: "paymentProofs", filename: "proof.png", contentType: "image/png", data: tinyPng },
{ name: "invoices", filename: "invoice.xml", contentType: "application/xml", data: Buffer.from("<invoice />") },
]);
const created = await app.inject({
method: "POST",
url: "/api/expenses",
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": form.contentType },
payload: form.body,
});
expect(created.statusCode).toBe(201);
const expense = created.json().expense as { id: string; version: number; attachments: Array<{ id: string; kind: string }> };
const invoice = expense.attachments.find((item) => item.kind === "invoice")!;
const stored = database.sqlite.prepare("SELECT storage_path AS storagePath FROM attachments WHERE id=?").get(invoice.id) as { storagePath: string };
rmSync(path.join(config.filesDir, stored.storagePath), { force: true });
const deleted = await app.inject({
method: "DELETE",
url: `/api/attachments/${invoice.id}`,
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf },
payload: { version: expense.version, invoiceMissingReason: "原始发票文件已丢失,无法重新取得" },
});
expect(deleted.statusCode).toBe(200);
expect(deleted.json().expense.invoiceCount).toBe(0);
expect(deleted.json().expense.invoiceMissingReason).toBe("原始发票文件已丢失,无法重新取得");
});
it("组合 multipart 编辑一次提交字段和附件,并只递增一次版本", async () => {
const session = await login();
const initial = multipart([
{ name: "paidAt", value: "2026-08-27T12:00:00.000Z" },
{ name: "amount", value: "12.34" },
{ name: "note", value: "组合编辑前" },
{ name: "invoiceMissingReason", value: "供应商暂未开票" },
{ name: "paymentProofs", filename: "proof-a.png", contentType: "image/png", data: tinyPng },
]);
const created = await app.inject({
method: "POST",
url: "/api/expenses",
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": initial.contentType },
payload: initial.body,
});
expect(created.statusCode).toBe(201);
const before = created.json().expense as { id: string; version: number; paymentProofCount: number; invoiceCount: number };
const edit = multipart([
{ name: "paidAt", value: "2026-08-28T13:30:00.000Z" },
{ name: "amount", value: "18.90" },
{ name: "note", value: "组合编辑后" },
{ name: "version", value: String(before.version) },
{ name: "paymentProofs", filename: "proof-b.png", contentType: "image/png", data: tinyPng },
{ name: "invoices", filename: "invoice.xml", contentType: "application/xml", data: Buffer.from("<invoice />") },
]);
const updated = await app.inject({
method: "PATCH",
url: `/api/expenses/${before.id}`,
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": edit.contentType },
payload: edit.body,
});
expect(updated.statusCode).toBe(200);
const result = updated.json().expense as { version: number; amountCents: number; note: string; invoiceMissingReason: string | null; paymentProofCount: number; invoiceCount: number; attachments: Array<{ originalName: string }> };
expect(result).toMatchObject({ version: before.version + 1, amountCents: 1890, note: "组合编辑后", invoiceMissingReason: null, paymentProofCount: 2, invoiceCount: 1 });
expect(result.attachments.map((item) => item.originalName)).toEqual(expect.arrayContaining(["proof-a.png", "proof-b.png", "invoice.xml"]));
const auditCount = (database.sqlite.prepare("SELECT COUNT(*) AS count FROM audit_events WHERE target_id=? AND action='expense.updated'").get(before.id) as { count: number }).count;
expect(auditCount).toBe(1);
});
it("组合编辑版本冲突或金额非法时不落附件也不改变账目", async () => {
const session = await login();
const initial = multipart([
{ name: "paidAt", value: "2026-08-27T12:00:00.000Z" },
{ name: "amount", value: "12.34" },
{ name: "invoiceMissingReason", value: "暂时无法取得" },
{ name: "paymentProofs", filename: "proof.png", contentType: "image/png", data: tinyPng },
]);
const created = await app.inject({
method: "POST",
url: "/api/expenses",
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": initial.contentType },
payload: initial.body,
});
const before = created.json().expense as { id: string; version: number; amountCents: number; paymentProofCount: number };
const conflictForm = multipart([
{ name: "paidAt", value: "2026-08-29T12:00:00.000Z" },
{ name: "amount", value: "20.00" },
{ name: "note", value: "不应保存" },
{ name: "invoiceMissingReason", value: "暂时无法取得" },
{ name: "version", value: String(before.version + 1) },
{ name: "paymentProofs", filename: "orphan.png", contentType: "image/png", data: tinyPng },
]);
const conflictResponse = await app.inject({
method: "PATCH",
url: `/api/expenses/${before.id}`,
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": conflictForm.contentType },
payload: conflictForm.body,
});
expect(conflictResponse.statusCode).toBe(409);
const afterConflict = app.inject({ method: "GET", url: `/api/expenses/${before.id}`, headers: { cookie: session.cookies } });
const current = (await afterConflict).json().expense;
expect(current).toMatchObject({ version: before.version, amountCents: before.amountCents, paymentProofCount: before.paymentProofCount });
expect(current.attachments.some((item: { originalName: string }) => item.originalName === "orphan.png")).toBe(false);
const invalidForm = multipart([
{ name: "paidAt", value: "2026-08-29T12:00:00.000Z" },
{ name: "amount", value: "1000000000000.00" },
{ name: "invoiceMissingReason", value: "暂时无法取得" },
{ name: "version", value: String(before.version) },
{ name: "paymentProofs", filename: "invalid.png", contentType: "image/png", data: tinyPng },
]);
const invalidResponse = await app.inject({
method: "PATCH",
url: `/api/expenses/${before.id}`,
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": invalidForm.contentType },
payload: invalidForm.body,
});
expect(invalidResponse.statusCode).toBe(400);
const afterInvalid = (await app.inject({ method: "GET", url: `/api/expenses/${before.id}`, headers: { cookie: session.cookies } })).json().expense;
expect(afterInvalid).toMatchObject({ version: before.version, amountCents: before.amountCents, paymentProofCount: before.paymentProofCount });
expect(afterInvalid.attachments.some((item: { originalName: string }) => item.originalName === "invalid.png")).toBe(false);
});
});