Files
TallyNote/tests/download-audit.test.ts
T
Qiufeng 9719429f4a
TallyNote release / linux-x64 (push) Failing after 2m41s
feat: add TallyNote local reimbursement ledger
2026-08-29 01:02:29 +08:00

59 lines
4.2 KiB
TypeScript

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");
});
});