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>; let database: ReturnType; let config: ReturnType; 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("显式允许的公网 HTTP 不会把静态资源升级到 HTTPS", async () => { const publicHttpConfig = { ...config, publicOrigin: "http://192.0.2.10:3999", isLocalOrigin: false, allowInsecureHttp: true, cookieSecure: false, }; const publicHttpApp = await buildApp(database, publicHttpConfig); try { const response = await publicHttpApp.inject({ method: "GET", url: "/health" }); expect(response.statusCode).toBe(200); expect(response.headers["content-security-policy"]).not.toContain("upgrade-insecure-requests"); expect(response.headers["strict-transport-security"]).toBeUndefined(); expect(response.headers["cross-origin-opener-policy"]).toBeUndefined(); expect(response.headers["origin-agent-cluster"]).toBeUndefined(); } finally { await publicHttpApp.close(); } }); it("HTTPS 仍保留传输安全响应头", async () => { const secureConfig = { ...config, publicOrigin: "https://example.test:3999", isLocalOrigin: false, allowInsecureHttp: false, cookieSecure: true, }; const secureApp = await buildApp(database, secureConfig); try { const response = await secureApp.inject({ method: "GET", url: "/health" }); expect(response.statusCode).toBe(200); expect(response.headers["content-security-policy"]).toContain("upgrade-insecure-requests"); expect(response.headers["strict-transport-security"]).toContain("max-age="); expect(response.headers["cross-origin-opener-policy"]).toBe("same-origin"); expect(response.headers["origin-agent-cluster"]).toBe("?1"); } finally { await secureApp.close(); } }); 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("") }, ]); 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("") }, ]); 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("") }, ]); 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("") }, ]); 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("") }, ]); 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); }); });