feat: add TallyNote local reimbursement ledger
TallyNote release / linux-x64 (push) Failing after 2m41s
TallyNote release / linux-x64 (push) Failing after 2m41s
This commit is contained in:
@@ -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("原始发票文件已丢失,无法重新取得");
|
||||
});
|
||||
});
|
||||
@@ -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("普通备注");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user