Files
wmailhub/tests/logs.test.ts
T
bigcode 8b255ef2e0 v2.5.0:操作日志 + 邮件保留 + 账号弹窗精简 + 复制按钮推广
- 操作日志:operation_logs 表(0004 迁移)记录 账号/域名/邮箱/Webhook 全部写操作,
  含操作账号(Console 登录名,系统动作为 system);/logs 日志页(类型筛选+搜索+分页);
  上限 10000 条,Cron housekeeping 超限删最旧
- 邮件保留:emails.retained;POST /api/emails/retain(单条/批量);
  TTL 清理与 Webhook 投递过期判定均跳过已保留;/inboxs 操作列图钉按钮 + 批量保留
- 添加账号弹窗:OAuth 与 API Token 同窗按钮组切换,去掉说明文字与账户 ID 字段
  (账户 ID 以 Token 实际所属账户为准);账号列表去掉 Email Worker 列
- 复制按钮推广:CopyText 组件;/domains 域名列、/inboxs 发件人与收件人(原「邮箱」改名)列
- 测试:104 bun tests(+10 日志/保留)、ui-smoke 35 项(+logs 页/新弹窗/复制/保留)
2026-10-01 00:25:38 +08:00

277 lines
12 KiB
TypeScript

/**
* 操作日志 + 邮件保留(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<LogRow | null> {
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<LogRow>();
}
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");
});
});