Files
wmailhub/tests/helpers.ts
T
bigcode 421cfb2f04 feat: v1.1.0 账号化重构 + 三级 Webhook + 设置中心 + Vite 工具链
- 账号化:节点更名账号,/nodes -> /accounts;添加账号直达 Cloudflare OAuth,自动创建账号并同步账号下全部域名(catch-all 邮箱随同步创建)
- 域名:移除手工添加;点击域名『设置 Email Workers』= 部署账号级 Email Worker + Email Routing + catch-all;支持按账号筛选
- Webhook 三级模型:邮箱 -> 域名 -> 全局,收信取第一个命中者;支持自定义请求头与 key(默认 Authorization: Bearer <key>,兼做 X-WmailHub-Signature 签名);独立 webhooks 实体与 /webhooks、/keys 页面删除,Key 管理收进设置模态窗口
- 设置中心:全局 Webhook / 默认 Node Ingress 地址 / 修改密码 / API & MCP Keys(生成、撤销、删除)
- UI:Header 标题副标题紧凑化(修复溢出),LOGO 加版本号 tag(v1.1.0 取自 package.json),各列表页搜索框
- 工具链:前端构建 Bun.build -> Vite(vite 8 + @vitejs/plugin-react)
- 后端:/api/settings、POST /api/domains/:id/setup、0002 迁移(投递行固化三级目标配置);MCP 去 list_webhooks(5 工具);迁移执行器加 _migrations 记录
- 测试:44/44(新增 settings 套件、webhook 三级优先级/SSRF/签名);UI 冒烟 11/11
2026-09-30 11:37:38 +08:00

179 lines
6.0 KiB
TypeScript

/**
* 测试基建:内存 D1 + 临时目录 R2 + Console app + 种子数据。
* 每个 test 文件独立建 env,互不干扰。
*/
import { Database } from "bun:sqlite";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { createApp } from "../console/src/app.ts";
import { LocalD1 } from "../console/src/local/d1.ts";
import { LocalR2 } from "../console/src/local/r2.ts";
import type { Env } from "../console/src/env.ts";
import { encryptSecret } from "../console/src/db.ts";
import { hashPassword, sha256Hex, signNodeRequest } from "../shared/src/index.ts";
import { ids } from "../shared/src/index.ts";
/** # 成员变量: 迁移 SQL(按文件名顺序全部应用,一次读取) **/
export const MIGRATION_SQL = (
await Promise.all(
[...new Bun.Glob("migrations/*.sql").scanSync({ cwd: new URL("..", import.meta.url).pathname })]
.sort()
.map(async (f) => await Bun.file(new URL(`../${f}`, import.meta.url)).text()),
)
).join("\n");
/** # 成员变量: 测试邮件样例(plain + html + 1 附件) **/
export const TEST_MAIL = `From: Alice <alice@example.org>\r
To: hello@example.test\r
Cc: bob@example.net\r
Subject: Test Mail\r
Message-ID: <t-1@example.org>\r
MIME-Version: 1.0\r
Content-Type: multipart/mixed; boundary="BB"\r
\r
--BB\r
Content-Type: text/plain\r
\r
plain body\r
--BB\r
Content-Type: text/html\r
\r
<b>html body</b>\r
--BB\r
Content-Type: text/plain; name="a.txt"\r
Content-Disposition: attachment; filename="a.txt"\r
\r
attachment-content\r
--BB--\r
`;
/** # 成员变量: 测试环境(内存库 + 临时 R2 目录) **/
export interface TestEnv {
env: Env;
app: ReturnType<typeof createApp>;
db: Database;
/** 默认节点 */
nodeId: string;
/** 默认节点 credential secret(明文,用于签名) */
nodeSecret: string;
/** catch-all 域名 example.test 对应的 mailbox id */
mailboxId: string;
domainId: string;
}
/** # 函数
* 构造隔离测试环境并播种:admin 用户、node、credential、domain、catch-all mailbox
*/
export async function makeTestEnv(): Promise<TestEnv> {
const db = new Database(":memory:", { strict: true });
db.exec(MIGRATION_SQL);
const env: Env = {
DB: new LocalD1(db),
R2: new LocalR2(mkdtempSync(path.join(tmpdir(), "wmh-r2-"))),
WMAILHUB_SIM_CF: "1",
WMAILHUB_ALLOW_PRIVATE_WEBHOOKS: "1",
};
const app = createApp();
const now = new Date().toISOString();
await env.DB.prepare("INSERT INTO users (id, username, password_hash, created_at) VALUES (?, 'admin', ?, ?)")
.bind(ids.user(), await hashPassword("admin"), now)
.run();
const nodeId = "node_test1";
const nodeSecret = "wns_test_secret_for_signing";
await env.DB.prepare(
`INSERT INTO nodes (id, name, cloudflare_account_id, credential_id, status, ingress_url, ingress_secret_enc, provision_steps, created_at, updated_at)
VALUES (?, 'test-node', 'acct-1', 'cred1', 'online', 'http://127.0.0.1:18788/ingress/email', 'enc', '{}', ?, ?)`,
)
.bind(nodeId, now, now)
.run();
await env.DB.prepare(
"INSERT INTO node_credentials (id, node_id, secret_hash, secret_enc, status, created_at) VALUES ('cred1', ?, ?, ?, 'active', ?)",
)
.bind(nodeId, await sha256Hex(nodeSecret), await encryptSecret(env, nodeSecret), now)
.run();
const domainId = "dom_test1";
await env.DB.prepare(
"INSERT INTO domains (id, cloudflare_account_id, node_id, name, status, created_at, updated_at) VALUES (?, 'acct-1', ?, 'example.test', 'active', ?, ?)",
)
.bind(domainId, nodeId, now, now)
.run();
const mailboxId = "mbx_test1";
await env.DB.prepare(
"INSERT INTO mailboxes (id, domain_id, address, status, is_catch_all, created_at, updated_at) VALUES (?, ?, '*@example.test', 'active', 1, ?, ?)",
)
.bind(mailboxId, domainId, now, now)
.run();
return { env, app, db, nodeId, nodeSecret, mailboxId, domainId };
}
/** # 函数
* 构造登录会话 cookie(走真实登录接口)
*/
export async function loginCookie(test: TestEnv): Promise<string> {
const resp = await test.app.fetch(
new Request("http://localhost/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "admin" }),
}),
test.env,
);
const setCookie = resp.headers.get("Set-Cookie") ?? "";
const m = /wmh_session=([^;]+)/.exec(setCookie);
if (!m) throw new Error("login failed: no session cookie");
return `wmh_session=${m[1]}`;
}
/** # 函数
* 构造已签名的 Node 邮件转发请求
*/
export async function signedEmailRequest(
test: TestEnv,
body: Uint8Array,
overrides: {
secret?: string;
nodeId?: string;
tamperSignature?: boolean;
tamperBody?: Uint8Array;
timestamp?: string;
reuseNonce?: string;
} = {},
): Promise<Request> {
const url = `http://localhost/api/internal/nodes/${overrides.nodeId ?? test.nodeId}/emails`;
// 签名始终基于原始 body;发送 tamperBody 即构成"签名-内容不一致"的篡改请求
const headers = await signNodeRequest(
overrides.secret ?? test.nodeSecret,
overrides.nodeId ?? test.nodeId,
"POST",
url,
body,
);
headers["Content-Type"] = "message/rfc822";
headers["X-WmailHub-Mail-From"] = "alice@example.org";
headers["X-WmailHub-Rcpt-To"] = "hello@example.test";
if (overrides.tamperSignature) headers["X-WmailHub-Signature"] = "0".repeat(64);
if (overrides.timestamp) headers["X-WmailHub-Timestamp"] = overrides.timestamp;
if (overrides.reuseNonce) headers["X-WmailHub-Nonce"] = overrides.reuseNonce;
const payload = overrides.tamperBody ?? body;
const ab = payload.buffer.slice(
payload.byteOffset,
payload.byteOffset + payload.byteLength,
) as ArrayBuffer;
return new Request(url, { method: "POST", headers, body: ab });
}
/** # 函数
* 会话 GET/POST 便捷封装
*/
export function authedFetch(test: TestEnv, cookie: string, url: string, init?: RequestInit) {
const headers = new Headers(init?.headers);
headers.set("Cookie", cookie);
return test.app.fetch(new Request(url, { ...init, headers }), test.env);
}