feat: add TallyNote local reimbursement ledger
TallyNote release / linux-x64 (push) Failing after 2m41s

This commit is contained in:
Qiufeng
2026-08-29 01:02:29 +08:00
commit 9719429f4a
62 changed files with 29200 additions and 0 deletions
+393
View File
@@ -0,0 +1,393 @@
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();
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("原始发票文件已丢失,无法重新取得");
});
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { amountToCents, centsToAmount, exportRequestSchema } from "../shared/contracts.js";
import { zonedMonthBounds } from "../server/app.js";
import { safeExcelText } from "../server/exporter.js";
import { safeStoragePath, sanitizeOriginalName } from "../server/files.js";
describe("金额", () => {
it("按分精确转换并格式化", () => {
expect(amountToCents("12.3")).toBe(1230);
expect(amountToCents("0.01")).toBe(1);
expect(centsToAmount(1234)).toBe("12.34");
expect(() => amountToCents("12.345")).toThrow();
expect(() => amountToCents("0")).toThrow();
});
});
describe("时区月份", () => {
it("按 Asia/Shanghai 返回 UTC 月份边界", () => {
const [start, end] = zonedMonthBounds("2026-08", "Asia/Shanghai");
expect(new Date(start).toISOString()).toBe("2026-07-31T16:00:00.000Z");
expect(new Date(end).toISOString()).toBe("2026-08-31T16:00:00.000Z");
});
});
describe("导出选项", () => {
it("默认不包含 manifest.json,并支持显式开启", () => {
expect(exportRequestSchema.parse({ ids: ["00000000-0000-4000-8000-000000000001"] }).includeManifest).toBe(false);
expect(exportRequestSchema.parse({ month: "2026-08", status: "unreimbursed" }).includeManifest).toBe(false);
expect(exportRequestSchema.parse({ ids: ["00000000-0000-4000-8000-000000000001"], includeManifest: true }).includeManifest).toBe(true);
});
});
describe("文件和导出安全", () => {
it("不让用户文件名参与路径", () => {
expect(sanitizeOriginalName("../../秘密\u0000.png")).toBe("秘密.png");
expect(safeStoragePath("/tmp/tallynote-files", "ab/example.png")).toBe("/tmp/tallynote-files/ab/example.png");
expect(() => safeStoragePath("/tmp/tallynote-files", "../outside")).toThrow();
});
it("阻止 Excel 公式注入", () => {
expect(safeExcelText("=HYPERLINK(\"https://example.com\")")).toBe("'=HYPERLINK(\"https://example.com\")");
expect(safeExcelText("普通备注")).toBe("普通备注");
});
});
+58
View File
@@ -0,0 +1,58 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createHash, randomUUID } from "node:crypto";
import { mkdir, symlink, unlink as unlinkFile, writeFile } from "node:fs/promises";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { buildApp } from "../server/app.js";
import { loadConfig, prepareDataDirectories } from "../server/config.js";
import { openDatabase } from "../server/db/index.js";
import { hashPassword } from "../server/security.js";
const proof = Buffer.from("download-proof");
describe("下载审计", () => {
let dataDir: string;
let config: ReturnType<typeof loadConfig>;
let database: ReturnType<typeof openDatabase>;
let app: Awaited<ReturnType<typeof buildApp>>;
let cookies = "";
let csrf = "";
let attachmentId = "";
beforeEach(async () => {
dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-download-audit-"));
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3993";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
config = loadConfig(); prepareDataDirectories(config); database = openDatabase(config); app = await buildApp(database, config);
const adminId = randomUUID();
database.sqlite.prepare("INSERT INTO admins(id, username, username_norm, display_name, password_hash, status, must_change_password, auth_version, version, created_at) VALUES (?, 'download-admin', 'download-admin', '下载管理员', ?, 'active', 0, 1, 1, ?)").run(adminId, await hashPassword("DownloadPassword!2026"), Date.now());
const login = await app.inject({ method: "POST", url: "/api/auth/login", headers: { origin: config.publicOrigin }, payload: { username: "download-admin", password: "DownloadPassword!2026" } });
const raw = login.headers["set-cookie"];
cookies = (Array.isArray(raw) ? raw : [raw ?? ""]).map((value) => value.split(";", 1)[0]).join("; ");
csrf = /(?:^|; )tally_csrf=([^;]+)/.exec(cookies)?.[1] ?? "";
const expenseId = randomUUID(); attachmentId = randomUUID(); const storagePath = "dd/proof.bin"; 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, adminId, now, adminId);
await mkdir(path.join(config.filesDir, "dd"), { recursive: true }); await writeFile(path.join(config.filesDir, storagePath), proof, { mode: 0o600 });
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', ?, 'proof.png', 'image/png', ?, ?, ?, ?)").run(attachmentId, expenseId, storagePath, proof.length, createHash("sha256").update(proof).digest("hex"), now, adminId);
});
afterEach(async () => { await app.close(); database.sqlite.close(); rmSync(dataDir, { recursive: true, force: true }); for (const key of ["TALLYNOTE_DATA_DIR", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE"]) delete process.env[key]; });
it("读取附件后记录 preview 审计事件", async () => {
const response = await app.inject({ method: "GET", url: `/api/attachments/${attachmentId}/content`, headers: { cookie: cookies } });
expect(response.statusCode).toBe(200);
const event = database.sqlite.prepare("SELECT action, outcome FROM audit_events WHERE action='expense.attachment_previewed' ORDER BY id DESC LIMIT 1").get() as { action: string; outcome: string };
expect(event).toEqual({ action: "expense.attachment_previewed", outcome: "success" });
});
it("附件路径是符号链接时拒绝读取", async () => {
const outside = path.join(dataDir, "outside-secret.txt");
await writeFile(outside, "must-not-leak");
const target = path.join(config.filesDir, "dd", "proof.bin");
await unlinkFile(target);
await symlink(outside, target);
const response = await app.inject({ method: "GET", url: `/api/attachments/${attachmentId}/content`, headers: { cookie: cookies } });
expect(response.statusCode).toBe(410);
expect(response.body).not.toContain("must-not-leak");
});
});
+9
View File
@@ -0,0 +1,9 @@
import { expect, test } from "@playwright/test";
test("未登录时显示中文登录入口", async ({ page }) => {
await page.goto("/");
await expect(page.getByText("TallyNote")).toBeVisible();
await expect(page.getByLabel("用户名")).toBeVisible();
await expect(page.getByLabel("密码")).toBeVisible();
await expect(page.getByRole("button", { name: "登录" })).toBeVisible();
});
+188
View File
@@ -0,0 +1,188 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import ExcelJS from "exceljs";
import yauzl from "yauzl";
import { loadConfig, prepareDataDirectories } from "../server/config.js";
import { openDatabase } from "../server/db/index.js";
import { buildExportJob, insertExportJob, type ExportSnapshot } from "../server/exporter.js";
const proofBytes = Buffer.from("export-proof-bytes");
function zipEntries(buffer: Buffer): Promise<Map<string, Buffer>> {
return new Promise((resolve, reject) => {
yauzl.fromBuffer(buffer, { lazyEntries: true, validateEntrySizes: true }, (error, zip) => {
if (error || !zip) {
reject(error ?? new Error("无法读取导出 ZIP"));
return;
}
const entries = new Map<string, Buffer>();
let settled = false;
const fail = (reason: Error) => {
if (settled) return;
settled = true;
zip.close();
reject(reason);
};
zip.on("error", fail);
zip.on("end", () => {
if (settled) return;
settled = true;
resolve(entries);
});
zip.on("entry", (entry) => {
zip.openReadStream(entry, (streamError, stream) => {
if (streamError || !stream) {
fail(streamError ?? new Error("无法读取 ZIP 条目"));
return;
}
const chunks: Buffer[] = [];
stream.on("data", (chunk: Buffer | string) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
stream.on("error", fail);
stream.on("end", () => {
entries.set(entry.fileName, Buffer.concat(chunks));
if (!settled) zip.readEntry();
});
});
});
zip.readEntry();
});
});
}
describe("导出 ZIP 产物", () => {
let dataDir: string;
let config: ReturnType<typeof loadConfig>;
let database: ReturnType<typeof openDatabase>;
let adminId: string;
beforeEach(async () => {
dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-export-"));
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_COOKIE_SECURE = "false";
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3998";
config = loadConfig();
prepareDataDirectories(config);
database = openDatabase(config);
adminId = randomUUID();
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(adminId, "export-admin", "export-admin", "导出测试管理员", "not-a-password-hash", Date.now());
});
afterEach(async () => {
database.sqlite.close();
await rm(dataDir, { recursive: true, force: true });
});
async function createJob(includeManifest: boolean): Promise<{ jobId: string; expenseId: string; reason: string }> {
const expenseId = randomUUID();
const attachmentId = randomUUID();
const paidAt = Date.parse("2026-08-27T04:00:00.000Z");
const reason = "供应商仅提供收据,无法补开发票";
const storagePath = "aa/payment.png";
await mkdir(path.join(config.filesDir, "aa"), { recursive: true });
await writeFile(path.join(config.filesDir, storagePath), proofBytes, { mode: 0o600 });
const sha256 = createHash("sha256").update(proofBytes).digest("hex");
database.sqlite.prepare(`
INSERT INTO expenses(id, paid_at, amount_cents, note, invoice_missing_reason, status, version,
created_at, created_by, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?, 'unreimbursed', 1, ?, ?, ?, ?)
`).run(expenseId, paidAt, 1234, "导出无发票测试", reason, Date.now(), adminId, Date.now(), adminId);
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', ?, ?, 'image/png', ?, ?, ?, ?)
`).run(attachmentId, expenseId, storagePath, "付款截图.png", proofBytes.length, sha256, Date.now(), adminId);
const snapshot: ExportSnapshot = {
includeManifest,
expenses: [{
id: expenseId,
paidAt,
amountCents: 1234,
note: "导出无发票测试",
invoiceMissingReason: reason,
status: "unreimbursed",
attachments: [{
id: attachmentId,
kind: "payment_proof",
originalName: "付款截图.png",
mimeType: "image/png",
storagePath,
sizeBytes: proofBytes.length,
sha256,
}],
}],
};
const jobId = insertExportJob(database.sqlite, config, {
adminId,
sessionHash: "session-hash",
selection: { ids: [expenseId], includeManifest },
snapshot,
});
await buildExportJob(database.sqlite, config, jobId);
return { jobId, expenseId, reason };
}
it("Excel 包含无发票原因列和合计,默认不生成 manifest", async () => {
const { jobId, reason } = await createJob(false);
const job = database.sqlite.prepare("SELECT status, file_path AS filePath FROM export_jobs WHERE id=?").get(jobId) as { status: string; filePath: string };
expect(job.status).toBe("ready");
const archive = await zipEntries(await readFile(path.join(config.exportsDir, job.filePath)));
expect([...archive.keys()]).toContain("报销清单.xlsx");
expect(archive.has("manifest.json")).toBe(false);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(archive.get("报销清单.xlsx")!);
const sheet = workbook.getWorksheet("报销清单")!;
expect(sheet.getCell("I1").value).toBe("无发票原因");
expect(sheet.getCell("I2").value).toBe(reason);
expect(sheet.getCell("C2").value).toBe(12.34);
expect(sheet.getCell("C3").value).toBe(12.34);
expect([...archive.keys()].some((name) => name.endsWith("/付款凭证/付款截图.png"))).toBe(true);
});
it("开启 manifest 时包含原因和附件元数据,重复构建不会破坏 ZIP", async () => {
const { jobId, expenseId, reason } = await createJob(true);
await Promise.all([buildExportJob(database.sqlite, config, jobId), buildExportJob(database.sqlite, config, jobId)]);
const job = database.sqlite.prepare("SELECT status, file_path AS filePath FROM export_jobs WHERE id=?").get(jobId) as { status: string; filePath: string };
expect(job.status).toBe("ready");
const archive = await zipEntries(await readFile(path.join(config.exportsDir, job.filePath)));
const manifest = JSON.parse(archive.get("manifest.json")!.toString("utf8")) as { records: Array<{ id: string; invoiceMissingReason: string; attachments: Array<{ originalName: string }> }> };
expect(manifest.records).toHaveLength(1);
expect(manifest.records[0]).toMatchObject({ id: expenseId, invoiceMissingReason: reason });
expect(manifest.records[0]!.attachments[0]!.originalName).toBe("付款截图.png");
});
it("导出错误不泄露本地路径或内部附件标识", async () => {
const expenseId = randomUUID();
const missingId = 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, adminId, now, adminId);
const storagePath = "bb/proof.png";
await mkdir(path.join(config.filesDir, "bb"), { recursive: true });
await writeFile(path.join(config.filesDir, storagePath), proofBytes, { mode: 0o600 });
const digest = createHash("sha256").update(proofBytes).digest("hex");
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', ?, 'proof.png', 'image/png', ?, ?, ?, ?)
`).run(randomUUID(), expenseId, storagePath, proofBytes.length, digest, now, adminId);
const brokenSnapshot: ExportSnapshot = {
includeManifest: false,
expenses: [{ id: missingId, paidAt: now, amountCents: 100, note: "broken", invoiceMissingReason: null, status: "unreimbursed", attachments: [{ id: randomUUID(), kind: "payment_proof", originalName: "missing.png", mimeType: "image/png", storagePath: "cc/does-not-exist.png", sizeBytes: 12, sha256: "d".repeat(64) }] }],
};
database.sqlite.prepare("INSERT INTO expenses(id, paid_at, amount_cents, note, status, version, created_at, created_by, updated_at, updated_by) VALUES (?, ?, 100, 'broken', 'unreimbursed', 1, ?, ?, ?, ?)").run(missingId, now, now, adminId, now, adminId);
const brokenJob = insertExportJob(database.sqlite, config, { adminId, sessionHash: "audit-session", selection: { ids: [missingId] }, snapshot: brokenSnapshot });
await buildExportJob(database.sqlite, config, brokenJob);
const failed = database.sqlite.prepare("SELECT error_message AS errorMessage FROM export_jobs WHERE id=?").get(brokenJob) as { errorMessage: string };
expect(failed.errorMessage).toBe("导出失败:附件文件缺失或校验不通过");
expect(failed.errorMessage).not.toContain("does-not-exist");
});
});
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import Database from "better-sqlite3";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { loadConfig, prepareDataDirectories } from "../server/config.js";
import { openDatabase } from "../server/db/index.js";
describe("数据库迁移", () => {
it("从 0000 旧库升级时保留记录并幂等应用新字段", () => {
const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-migration-"));
const previousDataDir = process.env.TALLYNOTE_DATA_DIR;
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3997";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
let migrated: ReturnType<typeof openDatabase> | undefined;
try {
const config = loadConfig();
prepareDataDirectories(config);
const legacy = new Database(config.dbPath);
legacy.exec(readFileSync(path.join(config.migrationsDir, "0000_initial.sql"), "utf8"));
legacy.exec("CREATE TABLE schema_migrations (name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL) STRICT");
legacy.prepare("INSERT INTO schema_migrations(name, applied_at) VALUES ('0000_initial.sql', ?)").run(Date.now());
legacy.prepare(`
INSERT INTO admins(id, username, username_norm, display_name, password_hash, status,
must_change_password, auth_version, version, created_at)
VALUES ('legacy-admin', 'legacy', 'legacy', '旧管理员', 'hash', 'active', 0, 1, 1, ?)
`).run(Date.now());
legacy.prepare(`
INSERT INTO expenses(id, paid_at, amount_cents, note, status, version, created_at, created_by,
updated_at, updated_by)
VALUES ('00000000-0000-4000-8000-000000000099', ?, 100, '旧账目', 'unreimbursed', 1, ?, 'legacy-admin', ?, 'legacy-admin')
`).run(Date.now(), Date.now(), Date.now());
legacy.close();
migrated = openDatabase(config);
const columns = migrated.sqlite.prepare("PRAGMA table_info(expenses)").all() as Array<{ name: string }>;
expect(columns.some((column) => column.name === "invoice_missing_reason")).toBe(true);
expect(migrated.sqlite.prepare("SELECT name FROM schema_migrations ORDER BY name").all()).toEqual([
{ name: "0000_initial.sql" },
{ name: "0001_invoice_missing_reason.sql" },
{ name: "0002_update_jobs.sql" },
{ name: "0003_update_job_ownership.sql" },
]);
const updateColumns = migrated.sqlite.prepare("PRAGMA table_info(update_jobs)").all() as Array<{ name: string }>;
expect(updateColumns.map((column) => column.name)).toEqual(expect.arrayContaining(["admin_id", "session_hash", "request_id", "requested_at", "started_at"]));
expect(migrated.sqlite.prepare("SELECT note, invoice_missing_reason AS reason FROM expenses WHERE id='00000000-0000-4000-8000-000000000099'").get()).toEqual({ note: "旧账目", reason: null });
migrated.sqlite.close();
migrated = openDatabase(config);
expect(migrated.sqlite.prepare("SELECT COUNT(*) AS count FROM schema_migrations WHERE name='0001_invoice_missing_reason.sql'").get()).toEqual({ count: 1 });
} finally {
migrated?.sqlite.close();
if (previousDataDir === undefined) delete process.env.TALLYNOTE_DATA_DIR;
else process.env.TALLYNOTE_DATA_DIR = previousDataDir;
rmSync(dataDir, { recursive: true, force: true });
}
});
});
+64
View File
@@ -0,0 +1,64 @@
import { afterEach, describe, expect, it } from "vitest";
import { chmodSync, mkdirSync, symlinkSync, writeFileSync, statSync } from "node:fs";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { loadConfig, prepareDataDirectories } from "../server/config.js";
const keys = ["TALLYNOTE_DATA_DIR", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_TRUST_PROXY", "NODE_ENV", "TALLYNOTE_ENV", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY", "TALLYNOTE_UPDATE_PUBLIC_KEY_FILE"];
afterEach(() => { for (const key of keys) delete process.env[key]; });
describe("部署安全配置", () => {
it("公网 HTTP 或 HTTPS 非安全 Cookie 一律拒绝", () => {
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://example.test";
expect(() => loadConfig()).toThrow(/HTTPS/);
process.env.TALLYNOTE_PUBLIC_ORIGIN = "https://example.test";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
expect(() => loadConfig()).toThrow(/安全 Cookie/);
});
it("生产环境不接受任意 trust proxy", () => {
process.env.NODE_ENV = "production";
process.env.TALLYNOTE_TRUST_PROXY = "true";
expect(() => loadConfig()).toThrow(/代理跳数/);
process.env.TALLYNOTE_TRUST_PROXY = "1";
process.env.TALLYNOTE_PUBLIC_ORIGIN = "https://example.test";
expect(loadConfig().trustProxy).toBe(1);
});
it("systemd 更新必须绑定主机白名单并默认要求签名", () => {
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
process.env.TALLYNOTE_PUBLIC_ORIGIN = "https://example.test";
process.env.TALLYNOTE_COOKIE_SECURE = "true";
expect(() => loadConfig()).toThrow(/ALLOWED_HOSTS/);
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
const config = loadConfig();
expect(config.updateRequireSignature).toBe(true);
});
it("收紧已有数据目录和数据库文件权限,并拒绝符号链接", () => {
const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-permissions-"));
try {
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3994";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
const config = loadConfig();
mkdirSync(config.filesDir, { recursive: true });
mkdirSync(config.stagingDir, { recursive: true });
mkdirSync(config.exportsDir, { recursive: true });
writeFileSync(config.dbPath, "placeholder");
chmodSync(config.dataDir, 0o777); chmodSync(config.filesDir, 0o777); chmodSync(config.dbPath, 0o666);
prepareDataDirectories(config);
expect(statSync(config.dataDir).mode & 0o777).toBe(0o700);
expect(statSync(config.filesDir).mode & 0o777).toBe(0o700);
expect(statSync(config.dbPath).mode & 0o777).toBe(0o600);
const linked = path.join(dataDir, "linked");
symlinkSync(config.filesDir, linked);
process.env.TALLYNOTE_DATA_DIR = linked;
expect(() => prepareDataDirectories(loadConfig())).toThrow(/符号链接/);
} finally {
rmSync(dataDir, { recursive: true, force: true });
}
});
});
+134
View File
@@ -0,0 +1,134 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { chmodSync, mkdtempSync, readFileSync, statSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { buildApp } from "../server/app.js";
import { loadConfig, prepareDataDirectories } from "../server/config.js";
import { openDatabase } from "../server/db/index.js";
import { hashPassword } from "../server/security.js";
import { detectPlatform } from "../server/update.js";
describe("更新 API", () => {
let dataDir: string;
let config: ReturnType<typeof loadConfig>;
let database: ReturnType<typeof openDatabase>;
let app: Awaited<ReturnType<typeof buildApp>>;
const originalFetch = globalThis.fetch;
beforeEach(async () => {
dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-update-api-"));
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3995";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
process.env.TALLYNOTE_UPDATE_METADATA_URL = "https://updates.example/latest";
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
// This API fixture focuses on queue ownership; the signature path is
// covered by update.test.ts with a generated Ed25519 key.
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false";
config = loadConfig();
prepareDataDirectories(config);
database = openDatabase(config);
app = await buildApp(database, config);
});
afterEach(async () => {
globalThis.fetch = originalFetch;
await app.close();
database.sqlite.close();
rmSync(dataDir, { recursive: true, force: true });
for (const key of ["TALLYNOTE_DATA_DIR", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_METADATA_URL", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY"]) delete process.env[key];
});
async function login(username = "update-admin") {
const adminId = randomUUID();
const password = "UpdateApiPassword!2026";
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(adminId, username, username, `更新测试管理员-${username}`, passwordHash, Date.now());
const response = await app.inject({ method: "POST", url: "/api/auth/login", headers: { origin: config.publicOrigin }, payload: { username, password } });
const raw = response.headers["set-cookie"];
const cookies = (Array.isArray(raw) ? raw : [raw ?? ""]).map((value) => value.split(";", 1)[0]).join("; ");
const csrf = /(?:^|; )tally_csrf=([^;]+)/.exec(cookies)?.[1] ?? "";
return { cookies, csrf };
}
function mockRelease() {
const digest = "c".repeat(64);
const asset = `tallynote-1.1.0-${detectPlatform().target}-glibc.tar.gz`;
globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS")
? new Response(`${digest} ${asset}\n`, { status: 200 })
: new Response(JSON.stringify({ tag_name: "v1.1.0", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: asset, browser_download_url: `https://updates.example/${asset}` }] }), { status: 200 })) as typeof fetch;
}
it("检查 release、创建受保护请求文件并拒绝重复任务", async () => {
const session = await login();
mockRelease();
const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
expect(checked.statusCode).toBe(200);
expect(checked.json().latest).toMatchObject({ version: "1.1.0", compatible: true, integrityReady: true, isNewer: true });
expect(checked.headers["cache-control"]).toBe("no-store");
const tooSoon = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
expect(tooSoon.statusCode).toBe(429);
expect(tooSoon.headers["retry-after"]).toBeDefined();
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.0", confirm: true } });
expect(applied.statusCode).toBe(202);
const jobId = applied.json().job.id as string;
const request = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; expectedSha256: string; currentLink: string };
expect(request).toMatchObject({ jobId, expectedSha256: "c".repeat(64), currentLink: config.currentLink });
expect(statSync(config.updateRequestPath).mode & 0o777).toBe(0o600);
mockRelease();
const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.0", confirm: true } });
expect(duplicate.statusCode).toBe(409);
expect(duplicate.json().error.code).toBe("UPDATE_IN_PROGRESS");
const status = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } });
expect(status.json().job).toMatchObject({ id: jobId, status: "queued" });
const audit = database.sqlite.prepare("SELECT action FROM audit_events WHERE action LIKE 'update.%' ORDER BY id").all() as Array<{ action: string }>;
expect(audit.map((row) => row.action)).toEqual(expect.arrayContaining(["update.checked", "update.apply_requested"]));
});
it("缺少确认或未启用 systemd 时不接受更新", async () => {
const session = await login();
const invalid = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.0" } });
expect(invalid.statusCode).toBe(400);
process.env.TALLYNOTE_UPDATE_STRATEGY = "disabled";
const disabledConfig = loadConfig();
expect(disabledConfig.updateStrategy).toBe("disabled");
});
it("更新任务只对发起管理员可见,并隐藏内部错误详情", async () => {
const owner = await login("update-owner");
const other = await login("update-other");
mockRelease();
const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: {} });
expect(checked.statusCode).toBe(200);
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: { version: "1.1.0", confirm: true } });
expect(applied.statusCode).toBe(202);
const jobId = applied.json().job.id as string;
database.sqlite.prepare("UPDATE update_jobs SET error_message=? WHERE id=?").run("/var/lib/tallynote/secret-command-output", jobId);
const hiddenStatus = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: other.cookies } });
expect(hiddenStatus.statusCode).toBe(200);
expect(hiddenStatus.json().job).toBeNull();
const hiddenDetail = await app.inject({ method: "GET", url: `/api/update/jobs/${jobId}`, headers: { cookie: other.cookies } });
expect(hiddenDetail.statusCode).toBe(404);
const ownDetail = await app.inject({ method: "GET", url: `/api/update/jobs/${jobId}`, headers: { cookie: owner.cookies } });
expect(ownDetail.statusCode).toBe(200);
expect(ownDetail.json().job.errorMessage).toBe("更新失败,请查看服务器日志或重试");
});
it("应用前重新校验失败时写入失败审计", async () => {
const session = await login("update-audit");
globalThis.fetch = (async () => new Response("upstream unavailable", { status: 503 })) as typeof fetch;
const response = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.0", confirm: true } });
expect(response.statusCode).toBe(502);
const audit = database.sqlite.prepare("SELECT outcome FROM audit_events WHERE action='update.apply_requested' ORDER BY id DESC LIMIT 1").get() as { outcome: string } | undefined;
expect(audit?.outcome).toBe("failure");
});
});
+294
View File
@@ -0,0 +1,294 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdir, readlink, symlink, writeFile, readFile, stat, readdir } from "node:fs/promises";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createHash, generateKeyPairSync, sign } from "node:crypto";
import {
atomicSwitchRelease,
createSafeArchive,
detectPlatform,
downloadReleaseAsset,
fetchReleaseMetadata,
fetchReleaseText,
extractSafeArchive,
isNewerVersion,
normalizeReleasePermissions,
sanitizeAssetName,
selectReleaseAsset,
validateHttpsUrl,
} from "../server/update.js";
import { runUpdate } from "../server/cli/update.js";
import { validateUpdateRequest } from "../server/cli/update.js";
import { checkForUpdate, verifyReleaseSignature } from "../server/update-service.js";
import { loadConfig, prepareDataDirectories } from "../server/config.js";
import { openDatabase } from "../server/db/index.js";
const envKeys = ["TALLYNOTE_DATA_DIR", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_METADATA_URL", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY"];
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
for (const key of envKeys) delete process.env[key];
});
describe("更新安全工具", () => {
it("严格比较 SemVer、平台和 HTTPS 白名单", () => {
expect(isNewerVersion("1.0.0", "1.1.0")).toBe(true);
expect(isNewerVersion("1.0.0", "1.0.0-beta.1")).toBe(false);
expect(detectPlatform("linux", "x86_64").target).toBe("linux-x64");
const release = {
version: "1.2.0",
assets: [
{ name: "tallynote-1.2.0-linux-arm64-glibc.tar.gz", url: "https://updates.example/arm" },
{ name: "tallynote-1.2.0-linux-x64-glibc.tar.gz", url: "https://updates.example/x64" },
],
};
expect(selectReleaseAsset(release, detectPlatform("linux", "x86_64"))?.name).toContain("linux-x64");
expect(selectReleaseAsset({ version: "1.2.0", assets: [{ name: "tallynote-1.2.0-linux-arm64-glibc.tar.gz", url: "https://updates.example/arm" }] }, detectPlatform("linux", "x86_64"))).toBeUndefined();
expect(() => validateHttpsUrl("http://updates.example/x64", { allowedHosts: ["updates.example"] })).toThrow();
expect(() => sanitizeAssetName("../release.tar.gz")).toThrow();
});
it("验证 SHA256SUMS 的 Ed25519 detached signature", () => {
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
const payload = "a".repeat(64) + " tallynote.tar.gz\n";
const signature = sign(null, Buffer.from(payload), privateKey).toString("base64");
const pem = publicKey.export({ type: "spki", format: "pem" }).toString();
expect(verifyReleaseSignature(payload, signature, pem)).toBe(true);
expect(verifyReleaseSignature(payload, sign(null, Buffer.from(payload), privateKey), pem)).toBe(true);
expect(verifyReleaseSignature(payload + "tampered", signature, pem)).toBe(false);
});
it("拒绝把队列文件重定向到另一更新源", () => {
process.env.TALLYNOTE_DATA_DIR = "/tmp/tallynote-request-test";
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3997";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
process.env.TALLYNOTE_UPDATE_METADATA_URL = "https://updates.example/latest";
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "true";
const config = loadConfig();
const base = {
jobId: "00000000-0000-4000-8000-000000000001",
version: "1.1.0",
assetUrl: "https://updates.example/app.tar.gz",
assetName: "app.tar.gz",
expectedSha256: "a".repeat(64),
requestedAt: Date.now(),
currentLink: config.currentLink,
releasesDir: config.releasesDir,
dataDir: config.dataDir,
};
expect(() => validateUpdateRequest({ ...base, metadataUrl: "https://evil.example/latest" }, config)).toThrow(/请求源|主机/);
expect(() => validateUpdateRequest({ ...base, metadataUrl: "https://updates.example/latest", requestedAt: Date.now() - 2 * 24 * 60 * 60 * 1000 }, config)).toThrow(/过期/);
});
it("读取 metadata 和 SHA256 sidecar 时限制重定向主机", async () => {
const digest = "a".repeat(64);
globalThis.fetch = (async (input: string | URL) => {
const url = input.toString();
if (url.endsWith("/latest")) {
return new Response(JSON.stringify({ tag_name: "v1.2.0", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "app-linux-x64.tar.gz", browser_download_url: "https://updates.example/app-linux-x64.tar.gz" }] }), { status: 200, headers: { "content-type": "application/json" } });
}
return new Response(`${digest} app-linux-x64.tar.gz\n`, { status: 200 });
}) as typeof fetch;
const metadata = await fetchReleaseMetadata("https://updates.example/latest", { allowedHosts: ["updates.example"] });
expect(metadata.version).toBe("1.2.0");
expect((await fetchReleaseText("https://updates.example/SHA256SUMS", { allowedHosts: ["updates.example"] })).trim()).toContain(digest);
});
it("对没有 Content-Length 的 metadata 和 sidecar 响应执行流式大小限制", async () => {
const oversized = "x".repeat(2 * 1024 * 1024 + 1);
globalThis.fetch = (async (input: string | URL) => {
const url = input.toString();
return url.endsWith("/latest")
? new Response(oversized, { status: 200 })
: new Response(oversized, { status: 200 });
}) as typeof fetch;
await expect(fetchReleaseMetadata("https://updates.example/latest", { allowedHosts: ["updates.example"] })).rejects.toThrow("更新发布信息不可用");
await expect(fetchReleaseText("https://updates.example/SHA256SUMS", { allowedHosts: ["updates.example"], maxBytes: 1024 })).rejects.toThrow("更新校验文件过大");
});
it("不会把 SHA256SUMS.sig 误当成摘要清单", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "tallynote-update-sidecar-order-"));
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3998";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
process.env.TALLYNOTE_UPDATE_METADATA_URL = "https://updates.example/latest";
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false";
const config = loadConfig();
prepareDataDirectories(config);
const database = openDatabase(config);
const digest = "e".repeat(64);
const assetName = `tallynote-1.2.1-${detectPlatform().target}-glibc.tar.gz`;
globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS.sig")
? new Response("not-a-digest")
: input.toString().endsWith("SHA256SUMS")
? new Response(`${digest} ${assetName}\n`)
: new Response(JSON.stringify({ tag_name: "v1.2.1", assets: [{ name: "SHA256SUMS.sig", browser_download_url: "https://updates.example/SHA256SUMS.sig" }, { name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: assetName, browser_download_url: `https://updates.example/${assetName}` }] })));
try {
const result = await checkForUpdate(database.sqlite, config);
expect(result.latest).toMatchObject({ compatible: true, integrityReady: true });
} finally {
database.sqlite.close();
await rm(dataDir, { recursive: true, force: true });
}
});
it("下载流限制大小并返回摘要", async () => {
const bytes = Buffer.from("release-bytes");
const destinationRoot = await mkdtemp(path.join(tmpdir(), "tallynote-update-download-"));
try {
globalThis.fetch = (async () => new Response(bytes, { status: 200, headers: { "content-length": String(bytes.length) } })) as typeof fetch;
const result = await downloadReleaseAsset("https://updates.example/release.tar.gz", path.join(destinationRoot, "release.tar.gz"), { allowedHosts: ["updates.example"], maxBytes: 1024 });
expect(result.size).toBe(bytes.length);
expect(result.sha256).toBe(createHash("sha256").update(bytes).digest("hex"));
} finally {
await rm(destinationRoot, { recursive: true, force: true });
}
});
it("原子切换 current 符号链接并保留旧版本", async () => {
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-switch-"));
try {
const releases = path.join(root, "releases");
const current = path.join(root, "current");
const old = path.join(releases, "1.0.0");
const staged = path.join(root, "staged");
await mkdir(path.join(old, "dist"), { recursive: true });
await writeFile(path.join(old, "dist", "marker"), "old");
await mkdir(path.join(staged, "dist"), { recursive: true });
await writeFile(path.join(staged, "dist", "marker"), "new");
await symlink(old, current);
const result = await atomicSwitchRelease(staged, current, releases, "1.1.0");
expect(await readlink(current)).toBe(path.join(releases, "1.1.0"));
expect(result.previousTarget).toBe(path.relative(root, old));
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("runUpdate 校验摘要、解包并原子替换目录", async () => {
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-run-"));
try {
const source = path.join(root, "source");
const current = path.join(root, "current");
const staging = path.join(root, "staging");
const backup = path.join(root, "backups", "old.tar.gz");
await mkdir(path.join(source, "dist"), { recursive: true });
await writeFile(path.join(source, "dist", "marker"), "new");
await mkdir(path.join(current, "dist"), { recursive: true });
await writeFile(path.join(current, "dist", "marker"), "old");
const archive = path.join(root, "release.tar.gz");
await createSafeArchive(source, archive);
const bytes = await readFile(archive);
const digest = createHash("sha256").update(bytes).digest("hex");
const fetchImpl = (async () => new Response(bytes, { status: 200, headers: { "content-length": String(bytes.length) } })) as typeof fetch;
const result = await runUpdate({
assetUrl: "https://updates.example/release.tar.gz",
assetName: "release.tar.gz",
version: "1.1.0",
expectedSha256: digest,
currentVersion: "1.0.0",
currentDir: current,
stagingDir: staging,
backupArchivePath: backup,
allowedHosts: ["updates.example"],
fetchImpl,
});
expect(result.version).toBe("1.1.0");
expect(await readFile(path.join(current, "dist", "marker"), "utf8")).toBe("new");
expect((await stat(backup)).size).toBeGreaterThan(0);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("流式解包在展开大小上限前拒绝高压缩比归档,并修正发布树权限", async () => {
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-stream-"));
try {
const source = path.join(root, "source");
const destination = path.join(root, "destination");
await mkdir(path.join(source, "dist", "server"), { recursive: true });
await mkdir(path.join(source, "bin"), { recursive: true });
await mkdir(path.join(source, "scripts"), { recursive: true });
await mkdir(path.join(source, "runtime", "bin"), { recursive: true });
await writeFile(path.join(source, "dist", "server", "large.js"), Buffer.alloc(2 * 1024 * 1024, 0x41));
await writeFile(path.join(source, "bin", "tallynote"), "#!/bin/sh\n");
await writeFile(path.join(source, "scripts", "runner.sh"), "#!/bin/sh\n");
await writeFile(path.join(source, "runtime", "bin", "node"), "node");
const archive = path.join(root, "release.tar.gz");
await createSafeArchive(source, archive);
expect((await stat(archive)).size).toBeLessThan(64 * 1024);
await expect(extractSafeArchive(archive, destination, { maxBytes: 1024 * 1024 })).rejects.toThrow(/大小限制/);
expect(await stat(destination).catch(() => null)).toBeNull();
await extractSafeArchive(archive, destination, { maxBytes: 4 * 1024 * 1024 });
await normalizeReleasePermissions(destination);
expect((await stat(path.join(destination, "dist"))).mode & 0o777).toBe(0o755);
expect((await stat(path.join(destination, "dist", "server", "large.js"))).mode & 0o777).toBe(0o644);
expect((await stat(path.join(destination, "bin", "tallynote"))).mode & 0o777).toBe(0o755);
expect((await stat(path.join(destination, "scripts", "runner.sh"))).mode & 0o777).toBe(0o755);
expect((await stat(path.join(destination, "runtime", "bin", "node"))).mode & 0o777).toBe(0o755);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("流式创建备份遵守大小上限并清理失败的临时文件", async () => {
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-archive-"));
try {
const source = path.join(root, "source");
const archive = path.join(root, "backup.tar.gz");
await mkdir(source, { recursive: true });
await writeFile(path.join(source, "large.bin"), Buffer.alloc(128 * 1024, 0x42));
await expect(createSafeArchive(source, archive, { maxBytes: 1024 })).rejects.toThrow(/大小限制/);
expect(await stat(archive).catch(() => null)).toBeNull();
expect((await readdir(root)).filter((name) => name.includes(".part-")).length).toBe(0);
await createSafeArchive(source, archive, { maxBytes: 256 * 1024 });
expect((await stat(archive)).size).toBeGreaterThan(0);
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
describe("更新元数据缓存", () => {
it("选择当前平台资产并要求 SHA256 sidecar", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "tallynote-update-cache-"));
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3996";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
process.env.TALLYNOTE_UPDATE_METADATA_URL = "https://updates.example/latest";
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "true";
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
const publicPem = publicKey.export({ type: "spki", format: "pem" }).toString();
process.env.TALLYNOTE_UPDATE_PUBLIC_KEY = publicPem;
const config = loadConfig();
prepareDataDirectories(config);
const database = openDatabase(config);
const digest = "b".repeat(64);
const platformAsset = `tallynote-1.1.0-${detectPlatform().target}-glibc.tar.gz`;
const sums = `${digest} ${platformAsset}\n`;
const signature = sign(null, Buffer.from(sums), privateKey);
globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS.sig")
? new Response(signature)
: input.toString().endsWith("SHA256SUMS")
? new Response(sums)
: new Response(JSON.stringify({ tag_name: "v1.1.0", published_at: "2026-08-28T00:00:00Z", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "SHA256SUMS.sig", browser_download_url: "https://updates.example/SHA256SUMS.sig" }, { name: platformAsset, browser_download_url: `https://updates.example/${platformAsset}` }] }), { status: 200 })) as typeof fetch;
try {
const result = await checkForUpdate(database.sqlite, config);
expect(result.latest).toMatchObject({ version: "1.1.0", compatible: true, integrityReady: true, signatureReady: true, isNewer: true });
const cached = database.sqlite.prepare("SELECT value FROM system_settings WHERE key='update.release.v1'").get() as { value: string };
expect(JSON.parse(cached.value).asset.sha256).toBe(digest);
} finally {
database.sqlite.close();
await rm(dataDir, { recursive: true, force: true });
}
});
});