59 lines
4.2 KiB
TypeScript
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");
|
|
});
|
|
});
|