/** * 操作日志 + 邮件保留(v2.5.0): * - 资源路由的写操作落 operation_logs(操作账号 / 动作 / 对象) * - POST /api/emails/retain 保留邮件;Cron TTL 清理跳过已保留 * - Webhook 投递终局写日志;日志表最多保留 10000 条(housekeeping 裁剪) */ import { describe, expect, test } from "bun:test"; import { cleanupExpiredEmails, housekeeping } from "../console/src/services/cleanup.ts"; import { ingestEmail } from "../console/src/services/email_ingest.ts"; import { processDueDeliveries } from "../console/src/services/webhook_delivery.ts"; import { authedFetch, loginCookie, makeTestEnv, TEST_MAIL, type TestEnv } from "./helpers.ts"; /** # 成员变量: 日志行结构(直接查库断言用) **/ interface LogRow { action: string; target_type: string; target_id: string | null; target_name: string | null; operator: string; } /** # 函数 * 查最新一条指定动作的日志 */ async function latestLog(t: TestEnv, action: string): Promise { return await t.env.DB.prepare( "SELECT action, target_type, target_id, target_name, operator FROM operation_logs WHERE action = ? ORDER BY created_at DESC, id DESC LIMIT 1", ) .bind(action) .first(); } describe("操作日志(账号/域名/邮箱/Webhook)", () => { test("创建邮箱写日志:operator=登录名,对象为邮箱地址", async () => { const t = await makeTestEnv(); const cookie = await loginCookie(t); const resp = await authedFetch(t, cookie, "http://localhost/api/mailboxes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ domain_id: t.domainId, address: "logger" }), }); expect(resp.status).toBe(201); const row = await latestLog(t, "mailbox.create"); expect(row?.operator).toBe("admin"); expect(row?.target_type).toBe("mailbox"); expect(row?.target_name).toBe("logger@example.test"); }); test("删除邮箱写日志;API 层无对应操作时不产生日志", async () => { const t = await makeTestEnv(); const cookie = await loginCookie(t); // 纯读操作不写日志 await authedFetch(t, cookie, "http://localhost/api/mailboxes"); expect(await latestLog(t, "mailbox.delete")).toBeNull(); const del = await authedFetch(t, cookie, `http://localhost/api/mailboxes/${t.mailboxId}`, { method: "DELETE" }); expect(del.status).toBe(200); const row = await latestLog(t, "mailbox.delete"); expect(row?.target_name).toBe("*@example.test"); expect(row?.operator).toBe("admin"); }); test("删除账号(软删)写日志;批量删除记一条汇总(含数量)", async () => { const t = await makeTestEnv(); const cookie = await loginCookie(t); await authedFetch(t, cookie, `http://localhost/api/accounts/${t.accountId}`, { method: "DELETE" }); const row = await latestLog(t, "account.delete"); expect(row?.target_name).toBe("test-account"); expect(row?.target_type).toBe("account"); const batch = await authedFetch(t, cookie, "http://localhost/api/accounts/batch-delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids: ["acct_no_such"] }), }); expect(batch.status).toBe(200); // 全部失败(deleted=0)不写日志 expect(await latestLog(t, "account.batch_delete")).toBeNull(); }); test("域名级 Webhook 保存/清除写 Webhook 记录", async () => { const t = await makeTestEnv(); const cookie = await loginCookie(t); const save = await authedFetch(t, cookie, `http://localhost/api/domains/${t.domainId}/webhook`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: "https://hooks.example.com/wmh" }), }); expect(save.status).toBe(200); const saved = await latestLog(t, "webhook.save"); expect(saved?.target_name).toBe("https://hooks.example.com/wmh"); expect(saved?.target_type).toBe("webhook"); const clear = await authedFetch(t, cookie, `http://localhost/api/domains/${t.domainId}/webhook`, { method: "DELETE" }); expect(clear.status).toBe(200); expect((await latestLog(t, "webhook.clear"))?.target_name).toBe("example.test"); }); test("Webhook 投递终局写日志:成功记 deliver,重试耗尽记 deliver_failed", async () => { const t = await makeTestEnv(); const recv = Bun.serve({ port: 0, fetch: () => new Response("no", { status: 500 }), }); const cookie = await loginCookie(t); await authedFetch(t, cookie, "http://localhost/api/settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ webhook: { url: `http://127.0.0.1:${recv.port}/hook` } }), }); await ingestEmail(t.env, { raw: new TextEncoder().encode(TEST_MAIL), mailFrom: "a@b.c", rcptTo: "hello@example.test", accountId: t.accountId, }); // 重试上限 5 次(指数退避):每轮把 next_retry_at 拨回过去,立即触发下一轮 for (let round = 0; round < 5; round++) { if (round > 0) { await t.env.DB.prepare("UPDATE webhook_deliveries SET next_retry_at = ?").bind(new Date(Date.now() - 1000).toISOString()).run(); } await processDueDeliveries(t.env, undefined, 20, true); } const failed = await latestLog(t, "webhook.deliver_failed"); expect(failed?.target_type).toBe("webhook"); expect(failed?.operator).toBe("system"); expect(await latestLog(t, "webhook.deliver")).toBeNull(); recv.stop(true); }); test("GET /api/logs:时间倒序 + target_type 筛选 + 分页", async () => { const t = await makeTestEnv(); const cookie = await loginCookie(t); // 造两类日志:1 条邮箱 + 1 条邮件删除 await authedFetch(t, cookie, "http://localhost/api/mailboxes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ domain_id: t.domainId, address: "logfilter" }), }); const email = await ingestEmail(t.env, { raw: new TextEncoder().encode(TEST_MAIL), mailFrom: "a@b.c", rcptTo: "hello@example.test", accountId: t.accountId, }); await authedFetch(t, cookie, `http://localhost/api/emails/${email.id}`, { method: "DELETE" }); const all = await authedFetch(t, cookie, "http://localhost/api/logs?limit=10"); expect(all.status).toBe(200); const allBody = (await all.json()) as { items: LogRow[]; total: number }; expect(allBody.total).toBe(2); expect(allBody.items[0]?.action).toBe("email.delete"); const filtered = await authedFetch(t, cookie, "http://localhost/api/logs?target_type=mailbox"); const filteredBody = (await filtered.json()) as { items: LogRow[]; total: number }; expect(filteredBody.total).toBe(1); expect(filteredBody.items[0]?.action).toBe("mailbox.create"); }); }); describe("邮件保留(retained,永不过期)", () => { test("保留接口置位 retained;Cron TTL 清理跳过已保留,未保留的照删", async () => { const t = await makeTestEnv(); const cookie = await loginCookie(t); const first = await ingestEmail(t.env, { raw: new TextEncoder().encode(TEST_MAIL), mailFrom: "a@b.c", rcptTo: "hello@example.test", accountId: t.accountId, }); const second = await ingestEmail(t.env, { raw: new TextEncoder().encode(TEST_MAIL), mailFrom: "a@b.c", rcptTo: "hello@example.test", accountId: t.accountId, }); const retain = await authedFetch(t, cookie, "http://localhost/api/emails/retain", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids: [first.id] }), }); expect(retain.status).toBe(200); expect(((await retain.json()) as { retained: number }).retained).toBe(1); // 重复保留幂等:不再计数 const again = await authedFetch(t, cookie, "http://localhost/api/emails/retain", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids: [first.id] }), }); expect(((await again.json()) as { retained: number }).retained).toBe(0); // 两封都改为已过期,跑清理 const past = new Date(Date.now() - 60_000).toISOString(); await t.env.DB.prepare("UPDATE emails SET expires_at = ?").bind(past).run(); const stats = { expiredEmails: 0, deferredEmails: 0, orphanAttachments: 0, orphanDeliveries: 0, housekeeping: 0, errors: [] }; await cleanupExpiredEmails(t.env, stats); expect(stats.expiredEmails).toBe(1); const kept = await t.env.DB.prepare("SELECT id FROM emails WHERE id = ?").bind(first.id).first(); expect(kept).not.toBeNull(); const gone = await t.env.DB.prepare("SELECT id FROM emails WHERE id = ?").bind(second.id).first(); expect(gone).toBeNull(); // 保留动作写日志 expect((await latestLog(t, "email.retain"))?.target_type).toBe("email"); }); test("列表接口带出 retained;已保留的过期邮件不再被标记 muted 判定依据(字段可判)", async () => { const t = await makeTestEnv(); const cookie = await loginCookie(t); const email = await ingestEmail(t.env, { raw: new TextEncoder().encode(TEST_MAIL), mailFrom: "a@b.c", rcptTo: "hello@example.test", accountId: t.accountId, }); await authedFetch(t, cookie, "http://localhost/api/emails/retain", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids: [email.id] }), }); const list = (await (await authedFetch(t, cookie, "http://localhost/api/emails")).json()) as { items: { id: string; retained: number }[]; }; expect(list.items.find((e) => e.id === email.id)?.retained).toBe(1); }); test("已保留邮件的 Webhook 投递不因 expires_at 过去而放弃", async () => { const t = await makeTestEnv(); let hits = 0; const recv = Bun.serve({ port: 0, fetch: () => { hits++; return Response.json({ ok: true }); }, }); await t.env.DB.prepare( "INSERT INTO settings (key, value) VALUES ('global_webhook', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", ) .bind(JSON.stringify({ url: `http://127.0.0.1:${recv.port}/hook`, headers: {} })) .run(); await ingestEmail(t.env, { raw: new TextEncoder().encode(TEST_MAIL), mailFrom: "a@b.c", rcptTo: "hello@example.test", accountId: t.accountId, }); // 邮件已过期 + 已保留 await t.env.DB.prepare("UPDATE emails SET expires_at = ?, retained = 1").bind(new Date(Date.now() - 60_000).toISOString()).run(); const stats = await processDueDeliveries(t.env, undefined, 20, true); expect(stats.success).toBe(1); expect(hits).toBe(1); recv.stop(true); }); }); describe("日志上限(10000 条)", () => { test("housekeeping 裁剪:只留最新 10000 条", async () => { const t = await makeTestEnv(); const now = new Date().toISOString(); const rows: string[] = []; for (let i = 0; i < 10020; i++) { rows.push(`('oplog_${String(i).padStart(6, "0")}', 'mailbox.create', 'mailbox', NULL, 'x', NULL, 'admin', '${now}')`); } t.env.DB.exec(`INSERT INTO operation_logs (id, action, target_type, target_id, target_name, detail, operator, created_at) VALUES ${rows.join(",")}`); const before = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM operation_logs").first<{ n: number }>(); expect(before?.n).toBe(10020); const stats = { expiredEmails: 0, deferredEmails: 0, orphanAttachments: 0, orphanDeliveries: 0, housekeeping: 0, errors: [] }; await housekeeping(t.env, stats); expect(stats.housekeeping).toBe(20); const after = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM operation_logs").first<{ n: number }>(); expect(after?.n).toBe(10000); // 留下的是最新的(前 20 条被裁掉) const oldest = await t.env.DB.prepare("SELECT id FROM operation_logs ORDER BY created_at ASC, id ASC LIMIT 1").first<{ id: string }>(); expect(oldest?.id).toBe("oplog_000020"); }); });