- 删除 SimCFApi / WMAILHUB_SIM_CF / isRealOAuth / POST /api/oauth/cloudflare/sim/approve - 删除前端 OAuthSim 模拟授权页与 /oauth/sim/authorize 路由;账号页「添加账号」整页跳 Cloudflare - 迁移 0005 删除 cf_sim_resources 表 - 未配置 CF_OAUTH_CLIENT_ID/SECRET 时 start/callback 一律 503(不再降级出假账号假资源) - 修 OAuth 回调端点:跨站重定向不携带 SameSite=Strict 会话 cookie,改为由一次性 state 承担 CSRF 防护 - 测试改用 tests/cf-mock.ts 在 fetch 层拦截 Cloudflare API,断言真实 HTTP 调用(72/72 通过) - 本地 dev 无账号时只拉起 Console 并提示人工授权,不再自动造账号
229 lines
7.9 KiB
TypeScript
229 lines
7.9 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, signWorkerRequest } 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;
|
|
/** 默认账号(cloudflare_accounts) */
|
|
accountId: string;
|
|
/** Email Worker 身份(worker_id,即部署脚本名) */
|
|
workerId: string;
|
|
/** 默认 worker credential secret(明文,用于签名) */
|
|
workerSecret: string;
|
|
/** catch-all 域名 example.test 对应的 mailbox id */
|
|
mailboxId: string;
|
|
domainId: string;
|
|
}
|
|
|
|
/** # 函数
|
|
* 构造隔离测试环境并播种:admin 用户、账号、worker 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-"))),
|
|
PUBLIC_BASE_URL: "http://localhost",
|
|
// 真实 Cloudflare OAuth 客户端(测试里由 cf-mock 在传输层拦截,不外发)
|
|
CF_OAUTH_CLIENT_ID: "test-client-id",
|
|
CF_OAUTH_CLIENT_SECRET: "test-client-secret",
|
|
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 accountId = "acct_test1";
|
|
const workerId = "wmailhub-email-acct-test1";
|
|
const workerSecret = "whs_test_secret_for_signing";
|
|
await env.DB.prepare(
|
|
`INSERT INTO cloudflare_accounts (id, name, cloudflare_account_id, worker_id, status, provision_steps, created_at, updated_at)
|
|
VALUES (?, 'test-account', 'cfacct-1', ?, 'active', '{}', ?, ?)`,
|
|
)
|
|
.bind(accountId, workerId, now, now)
|
|
.run();
|
|
await env.DB.prepare(
|
|
"INSERT INTO worker_credentials (id, cloudflare_account_id, worker_id, secret_hash, secret_enc, secret_last4, status, created_at) VALUES ('wcred1', ?, ?, ?, ?, ?, 'active', ?)",
|
|
)
|
|
.bind(accountId, workerId, await sha256Hex(workerSecret), await encryptSecret(env, workerSecret), workerSecret.slice(-4), now)
|
|
.run();
|
|
|
|
const domainId = "dom_test1";
|
|
await env.DB.prepare(
|
|
"INSERT INTO domains (id, cloudflare_account_id, account_id, name, status, created_at, updated_at) VALUES (?, 'cfacct-1', ?, 'example.test', 'active', ?, ?)",
|
|
)
|
|
.bind(domainId, accountId, 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, accountId, workerId, workerSecret, 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]}`;
|
|
}
|
|
|
|
/** # 成员变量: 签名请求构造的篡改选项 **/
|
|
export interface SignOverrides {
|
|
/** 换用错误 secret(签名必然不匹配) **/
|
|
secret?: string;
|
|
/** 声明其它 worker_id **/
|
|
workerId?: string;
|
|
/** 篡改 Authorization */
|
|
tamperSignature?: boolean;
|
|
/** 发送与签名不一致的 body(body hash mismatch) **/
|
|
tamperBody?: Uint8Array;
|
|
/** 覆盖 timestamp(超窗测试) **/
|
|
timestamp?: string;
|
|
/** 覆盖 nonce(replay 测试) **/
|
|
reuseNonce?: string;
|
|
/** 覆盖协议版本 */
|
|
protocol?: string;
|
|
}
|
|
|
|
/** # 函数
|
|
* 构造已签名的 authorize 请求(docs/11:recipient/sender JSON)
|
|
*/
|
|
export async function signedAuthorizeRequest(
|
|
test: TestEnv,
|
|
recipient: string,
|
|
sender = "alice@example.org",
|
|
overrides: SignOverrides = {},
|
|
): Promise<Request> {
|
|
const url = "http://localhost/internal/email/authorize";
|
|
const body = new TextEncoder().encode(JSON.stringify({ recipient, sender, message_id: "<t-1@example.org>" }));
|
|
const headers = await signWorkerRequest(
|
|
overrides.secret ?? test.workerSecret,
|
|
overrides.workerId ?? test.workerId,
|
|
"POST",
|
|
url,
|
|
body,
|
|
);
|
|
applyOverrides(headers, overrides);
|
|
const payload = overrides.tamperBody ?? body;
|
|
return new Request(url, { method: "POST", headers, body: toArrayBuffer(payload) });
|
|
}
|
|
|
|
/** # 函数
|
|
* 构造已签名的 ingest 请求(raw MIME + 信封头)
|
|
*/
|
|
export async function signedIngestRequest(
|
|
test: TestEnv,
|
|
body: Uint8Array,
|
|
overrides: SignOverrides & { recipient?: string; sender?: string } = {},
|
|
): Promise<Request> {
|
|
const url = "http://localhost/internal/email/ingest";
|
|
const headers = await signWorkerRequest(
|
|
overrides.secret ?? test.workerSecret,
|
|
overrides.workerId ?? test.workerId,
|
|
"POST",
|
|
url,
|
|
body,
|
|
);
|
|
headers["Content-Type"] = "message/rfc822";
|
|
headers["X-WmailHub-Recipient"] = overrides.recipient ?? "hello@example.test";
|
|
headers["X-WmailHub-Sender"] = overrides.sender ?? "alice@example.org";
|
|
applyOverrides(headers, overrides);
|
|
const payload = overrides.tamperBody ?? body;
|
|
return new Request(url, { method: "POST", headers, body: toArrayBuffer(payload) });
|
|
}
|
|
|
|
/** # 函数
|
|
* 应用测试篡改覆盖(签名/body/timestamp/nonce/协议版本)
|
|
*/
|
|
function applyOverrides(headers: Record<string, string>, o: SignOverrides) {
|
|
if (o.tamperSignature) headers["X-WmailHub-Signature"] = "0".repeat(64);
|
|
if (o.timestamp) headers["X-WmailHub-Timestamp"] = o.timestamp;
|
|
if (o.reuseNonce) headers["X-WmailHub-Nonce"] = o.reuseNonce;
|
|
if (o.protocol) headers["X-WmailHub-Protocol"] = o.protocol;
|
|
}
|
|
|
|
/** # 函数
|
|
* Uint8Array -> ArrayBuffer(Request body 需要)
|
|
*/
|
|
function toArrayBuffer(payload: Uint8Array): ArrayBuffer {
|
|
return payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength) as ArrayBuffer;
|
|
}
|
|
|
|
/** # 函数
|
|
* 会话 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);
|
|
}
|