// Copyright (c) 2026 Doforu // Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0-only). // See the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.txt /** * Regression guards for `POST /api/v1/mailboxes` — address normalisation. * * ── The incident these cases pin ───────────────────────────────────────── * * "Create mailbox" answered `400 {"error":"Validation failed"}` for input the * user could see nothing wrong with: a local part COPIED from somewhere else * frequently carries a leading or trailing space, the dashboard pasted it into * `` `${localPart}@${domain}` `` verbatim, and the resulting * `" privacy@doforu.ai"` — an address no DNS-name regex can match — was * rejected by the handler's schema. * * Two layers were wrong, and both are guarded here: * * 1. the request-body schema normalised NOTHING, so whitespace survived all * the way into the regex; * 2. the response named no field. The payload does carry `details[0].path`, * but the form dropped it and showed one generic string, so the operator * could not tell WHAT failed. * * The fix normalises the address (`z.string().trim().toLowerCase().regex(…)`) * and the handler stores/returns the normalised value — which is the same value * the mailbox id, the R2 object key and the D1 folder rows derive from. * * ── What is REAL here ──────────────────────────────────────────────────── * * This is NOT a re-implementation of the schema. The app is imported from * `./index.ts` (via a `file://` URL — see `INDEX_MODULE_URL`) and driven through * `app.request()`, so each case runs the production middleware chain * (`/api/*` locale + cors, then `requireAuth`), the production route * registration and the production zod schema. Expectations are hard-coded * literals (`"privacy@doforu.ai"`, `"Validation failed"`, `["email"]`) rather * than anything re-derived from the module, so the file cannot agree with the * bug it is meant to catch. * * ── What is stubbed, and why that is still honest ──────────────────────── * * - `fetchWithTimeout` (via `mock.module`) — the only network primitive * reachable from `index.ts`. The stub THROWS rather than answering: the * create-mailbox path is supposed to be offline, so this doubles as a * tripwire against a future refactor that starts calling out. * - `env.BUCKET` — an in-memory R2 stand-in that RECORDS every `put`/`head` * key, which is what the storage assertions are about. * - `env.DB` — a hand-written D1 shim. Drizzle issues the session lookup * through `db.prepare(...).bind(...).first()` and `initMailboxFolders` * through `db.batch([...])`; the TEXT-protocol shim shared with * `./setup-test-harness` does not model drizzle's query builder, so the * statements are served here instead. What makes that acceptable is that * the shim is VALIDATED, not trusted: the "session shim" block near the end * proves the guarded path is only reached with a live session row AND the * right cookie, that an expired row is refused, and that every rejected * path leaves storage untouched. * * `node:sqlite` is REQUIRED (see the precondition block): its job is to * actually EXECUTE the folder batch, so the `folders` rows are asserted as real * SQL effects keyed by the normalised address rather than as a call count. * Without the engine every case is skipped via `sqliteSkip` — never a silent * pass. * * To run: npm test */ import assert from "node:assert/strict"; import { describe, it, mock } from "node:test"; import { pathToFileURL } from "node:url"; import { createSqlite, hasSqliteEngine, sqliteSkip } from "./setup-test-harness"; // ── Network stub (registered BEFORE ./index is imported) ──────────── const mockFetchWithTimeout = mock.fn( async (_url: string | URL, _options: RequestInit = {}): Promise => { throw new Error( "POST /api/v1/mailboxes must not perform a network call; this stub is a tripwire", ); }, ); mock.module("./lib/fetch-with-timeout", { namedExports: { fetchWithTimeout: mockFetchWithTimeout }, }); /** * `workers/index.ts` exports `{ app, receiveEmail }` and no sub-app carrying * `POST /api/v1/mailboxes`, so the app plus a session cookie is the only way to * drive the real handler. * * The `file://` URL matters: under `node --test ` a bare `"./index"` * specifier resolves against the process CWD and dies with * ERR_MODULE_NOT_FOUND. `pathToFileURL` keeps it anchored to THIS file however * the runner was invoked. */ const INDEX_MODULE_URL = pathToFileURL(new URL("./index.ts", import.meta.url).pathname).href; /** The REAL Hono app — real routes, real middleware, real zod schema. */ const { app } = (await import(/* @vite-ignore */ INDEX_MODULE_URL)) as { app: { request(path: string, init?: RequestInit, env?: unknown): Promise; fetch(request: Request, env?: unknown): Promise; }; }; // ── Constants ─────────────────────────────────────────────────────── /** The identity every accepted input must collapse to. */ const NORMALISED_ID = "privacy@doforu.ai"; /** The normalised R2 key: `mailboxes/${email}.json`, per the route. */ const NORMALISED_KEY = `mailboxes/${NORMALISED_ID}.json`; /** `requireAuth` reads exactly this cookie (workers/lib/auth.ts, AUTH_COOKIE). */ const SESSION_COOKIE = "mailboxes_session"; const SESSION_TOKEN = "session-token-under-test"; /** English copy from shared/i18n/locales/en/api.json. */ const VALIDATION_FAILED = "Validation failed"; /** The six folders the route seeds, as written to D1. */ const EXPECTED_FOLDERS = ["archive", "draft", "inbox", "sent", "spam", "trash"]; // ── Bindings ──────────────────────────────────────────────────────── type SessionRow = { token: string; created_at: string; expires_at: string } | null; type Recorded = { /** Every R2 key seen by `head`/`put`, in call order. */ keys: string[]; /** Every `put` key, in call order. */ putKeys: string[]; /** `mailbox_id` bound by the folder batch, or `null` if none ran. */ folderMailboxId: string | null; /** Folder rows actually EXECUTED against the real engine (0 or 6). */ folderRowsWritten: number; /** How many times the session store was consulted. */ sessionLookups: number; }; type Harness = { env: { DB: unknown; BUCKET: unknown }; recorded: Recorded; }; /** * A `D1PreparedStatement`-shaped object. * * `sql` and `values` are exposed as GETTERS, which is the load-bearing detail: * drizzle's `db.batch()` reads precisely those two properties off each * statement (`node_modules/drizzle-orm/d1/session.js`), and it only uses * `prepare(...).bind(...)` when the bound parameter list is non-empty — which * holds for `initMailboxFolders`. Add them as data properties and drizzle takes * a different branch, `batch()` receives six statements with no readable SQL, * and the folder assertions quietly degrade to no-ops. */ function makeStatement(sql: string, onQuery: () => SessionRow) { const statement = { _sql: sql, _values: [] as unknown[], bind(...args: unknown[]) { statement._values = args; return statement; }, async first() { // `db.getSession` — the only `.first()` on this path. if (/FROM sessions/.test(sql)) return onQuery(); return null; }, async all() { return { results: [], success: true, meta: {} }; }, async run() { return { results: [], success: true, meta: { changes: 1 } }; }, async raw() { return []; }, }; Object.defineProperty(statement, "sql", { get: () => statement._sql }); Object.defineProperty(statement, "values", { get: () => statement._values }); return statement; } /** Insert a live `sessions` row through the harness's real SQLite helper. */ function insertSession( sqlite: ReturnType, token: string, expiresAt: Date, ): SessionRow { sqlite .prepare("INSERT INTO sessions (token, created_at, expires_at) VALUES (?, ?, ?)") .run(token, new Date().toISOString(), expiresAt.toISOString()); const row = sqlite .prepare("SELECT token, created_at, expires_at FROM sessions WHERE token = ?") .all(token)[0]; return row ? (row as unknown as SessionRow) : null; } /** * Build the bindings for one request. * * Both modes answer the session lookup the same way; they differ in whether the * folder batch is EXECUTED: * * - `{ sessionRow }` — lookup answered from a plain object, batch counted but * not run. Used where the case is about R2 keys or validation. * - `{ sqlite, token }` — lookup read back out of the real engine and the six * folder rows executed for real, so the ids in `folders` are the product of * genuine SQL. Used by the two cases that assert D1 contents. * * `sessionRow: null` means "this token is not in the store" — the negative * control that keeps the whole file from passing on a bypassed guard. */ function createHarness( options: { sessionRow: SessionRow } | { sqlite: ReturnType; token: string }, ): Harness { const recorded: Recorded = { keys: [], putKeys: [], folderMailboxId: null, folderRowsWritten: 0, sessionLookups: 0, }; const sqlite = "sqlite" in options ? options.sqlite : undefined; const sessionRow = "sessionRow" in options ? options.sessionRow : insertSession(options.sqlite, options.token, new Date(Date.now() + 3_600_000)); const db = { prepare: (sql: string) => makeStatement(sql, () => { recorded.sessionLookups += 1; return sessionRow; }), async batch(statements: Array<{ sql?: string; values?: unknown[] }>) { // `db.batch()` consumes the SAME prepared statements this shim handed // out, reading `sql` / `values` off each one. Note that the statements // reached this point through `db.insert(folders).values(…) // .onConflictDoNothing()` — i.e. a real drizzle query built against the // production `schema.ts` — so the SQL/parameters inspected below are // generated by drizzle, not by this file. for (const statement of statements) { const sql = String(statement.sql ?? ""); const bound = statement.values ?? []; if (!/folders/.test(sql)) continue; if (recorded.folderMailboxId === null) { recorded.folderMailboxId = String(bound[0]); } if (sqlite) { // D1 numbers its placeholders (`?1`…`?4`); node:sqlite does not. sqlite .prepare(sql.replace(/\?\d/g, "?")) .run(...(bound as never[])); recorded.folderRowsWritten += 1; } } return statements.map(() => ({ results: [], success: true, meta: { changes: 1 } })); }, async exec() { return { count: 0, duration: 0 }; }, }; const bucket = { async head(key: string) { recorded.keys.push(key); return null; }, async put(key: string, _value: unknown) { recorded.keys.push(key); recorded.putKeys.push(key); return { key }; }, async get() { return null; }, async delete() {}, async list() { return { objects: [], truncated: false }; }, }; return { env: { DB: db, BUCKET: bucket }, recorded }; } /** A harness whose session store holds one live row for `SESSION_TOKEN`. */ function authedHarness(): Harness { return createHarness({ sessionRow: { token: SESSION_TOKEN, created_at: new Date().toISOString(), expires_at: new Date(Date.now() + 3_600_000).toISOString(), }, }); } // ── Request driver ────────────────────────────────────────────────── type CreateResult = { status: number; body: Record; text: string; recorded: Recorded; }; /** * POST a body to the REAL handler and report what the bindings saw. * * The cookie is written by name because `requireAuth` reads * `getCookie(c, "mailboxes_session")` verbatim — that is the contract being * honoured, not an implementation detail being sidestepped. */ async function postCreateMailbox( harness: Harness, body: unknown, options: { cookie?: string | null } = {}, ): Promise { const headers: Record = { "Content-Type": "application/json" }; if (options.cookie !== null) { headers.Cookie = options.cookie ?? `${SESSION_COOKIE}=${SESSION_TOKEN}`; } const res = await app.request( "/api/v1/mailboxes", { method: "POST", headers, body: JSON.stringify(body) }, harness.env, ); const text = await res.text(); return { status: res.status, body: JSON.parse(text) as Record, text, recorded: harness.recorded }; } /** `details[].path` values, in order — the field names the dashboard reads. */ function detailsPaths(body: Record): unknown[] { const details = body.details; if (!Array.isArray(details)) { assert.fail(`expected a \`details\` array in ${JSON.stringify(body)}`); } return (details as Array<{ path?: unknown }>).map((detail) => detail.path); } /** The 201 contract every accepted address must satisfy. */ function assertCreated(result: CreateResult): void { assert.equal( result.status, 201, `expected the mailbox to be created, got ${result.status}: ${result.text}`, ); assert.equal(result.body.id, NORMALISED_ID, "the response id must be the normalised address"); assert.equal(result.body.email, NORMALISED_ID, "the response email must be normalised too"); } // ── 1-4. Every accepted form collapses to one identity ────────────── describe("POST /api/v1/mailboxes — whitespace/case normalisation (real handler)", () => { it("accepts a canonical address unchanged, answering 201", { skip: sqliteSkip }, async () => { // The control case: normalisation must be a NO-OP for input that is // already clean, so a "fix" that rewrote clean addresses fails here. const result = await postCreateMailbox(authedHarness(), { email: "privacy@doforu.ai", name: "Info", }); assertCreated(result); assert.equal(result.body.name, "Info"); }); it("accepts a LEADING space and stores the trimmed id", { skip: sqliteSkip }, async () => { // THE regression case. Before the fix this was `400 Validation failed`: // the schema matched the regex against the raw `" privacy@doforu.ai"`, // which no anchored pattern can accept. const result = await postCreateMailbox(authedHarness(), { email: " privacy@doforu.ai", name: "Info", }); assertCreated(result); }); it("accepts a TRAILING space and stores the trimmed id", { skip: sqliteSkip }, async () => { const result = await postCreateMailbox(authedHarness(), { email: "privacy@doforu.ai ", name: "Info", }); assertCreated(result); }); it("accepts surrounding space PLUS mixed case and normalises both", { skip: sqliteSkip }, async () => { // Both normalisations at once — the shape a paste out of a mail client // or a spreadsheet actually produces. const result = await postCreateMailbox(authedHarness(), { email: " Privacy@Doforu.AI ", name: "Info", }); assertCreated(result); }); }); // ── 5. The `+` limitation is documented, not silently accepted ────── describe("POST /api/v1/mailboxes — addresses the schema still rejects", () => { it("still rejects a plus-tagged address, with details[0].path === ['email']", { skip: sqliteSkip }, async () => { // KNOWN LIMITATION — pre-existing, and NOT the target of the // normalisation fix. The character class is `[a-z0-9.*_-]`, which has no // room for `+`, so `privacy+tag@…` is refused even though sub-addressing // is legal in the wild. // // What this case pins is the DIAGNOSTIC, not the rejection: the body // must keep naming the offending field, because that is what the // dashboard reads to surface `errorInvalidEmail` instead of the bare // "Validation failed" that started the incident. Widening the character // class is a separate decision with its own UX and data consequences, // and doing it here would be a behaviour change dressed up as a test fix. const result = await postCreateMailbox(authedHarness(), { email: "privacy+tag@doforu.ai", name: "Info", }); assert.equal(result.status, 400); assert.equal(result.body.error, VALIDATION_FAILED); assert.deepEqual(detailsPaths(result.body), [["email"]]); }); }); // ── 6. Storage and D1 receive the normalised identity ─────────────── describe("POST /api/v1/mailboxes — the normalised identity is what gets stored", () => { it("writes exactly mailboxes/privacy@doforu.ai.json, and no key contains whitespace", { skip: sqliteSkip }, async () => { // The other half of the incident: a normalised RESPONSE with a // whitespace-bearing KEY would leave the mailbox unreachable, since the // inbound path looks it up by the address rather than by what was typed. const result = await postCreateMailbox(authedHarness(), { email: " privacy@doforu.ai ", name: "Info", }); assertCreated(result); assert.deepEqual(result.recorded.putKeys, [NORMALISED_KEY]); // Belt and braces: EVERY key the handler touched (`head` included) must // be whitespace-free, so a "head with the raw value, put with the // trimmed one" split personality cannot slip through. for (const key of result.recorded.keys) { assert.doesNotMatch( key, /\s/, `a whitespace-bearing R2 key reached the bucket: ${JSON.stringify(key)}`, ); } }); it("keys the six default folders by the NORMALISED mailbox id", { skip: sqliteSkip }, async () => { // The folder rows are the durable copy of the mailbox id. They go // through drizzle's `db.batch()` into a real SQLite engine here, so this // is an actual SQL effect rather than a call count. const sqlite = createSqlite(); const harness = createHarness({ sqlite, token: SESSION_TOKEN }); const result = await postCreateMailbox(harness, { email: " Privacy@Doforu.AI ", name: "Info", }); assertCreated(result); assert.equal( result.recorded.folderMailboxId, NORMALISED_ID, "the folder batch must be keyed by the trimmed, lower-cased address", ); assert.equal( result.recorded.folderRowsWritten, 6, "all six default folders must have been executed against the engine", ); const folders = sqlite .prepare("SELECT id FROM folders WHERE mailbox_id = ? ORDER BY id") .all(NORMALISED_ID) as Array<{ id: string }>; assert.deepEqual( folders.map((row) => row.id), EXPECTED_FOLDERS, "the rows must be found under the normalised address and nowhere else", ); // …and the raw, whitespace-bearing address seeded nothing. const rawRows = sqlite .prepare("SELECT id FROM folders WHERE mailbox_id = ?") .all(" Privacy@Doforu.AI ") as Array<{ id: string }>; assert.deepEqual(rawRows, []); }); }); // ── 7. Normalising `email` did not loosen anything else ───────────── describe("POST /api/v1/mailboxes — the rest of the body is still validated", () => { it("still requires a name, with details[0].path === ['name']", { skip: sqliteSkip }, async () => { // `name` has no default and no fallback in the schema. Trimming must not // have turned into "make everything optional": `email` is the only field // that gained a normaliser. const result = await postCreateMailbox(authedHarness(), { email: "privacy@doforu.ai" }); assert.equal(result.status, 400); assert.equal(result.body.error, VALIDATION_FAILED); assert.deepEqual(detailsPaths(result.body), [["name"]]); }); it("still rejects an empty name, with details[0].path === ['name']", { skip: sqliteSkip }, async () => { const result = await postCreateMailbox(authedHarness(), { email: "privacy@doforu.ai", name: "", }); assert.equal(result.status, 400); assert.deepEqual(detailsPaths(result.body), [["name"]]); }); it("still rejects an address with an INTERNAL space, with details[0].path === ['email']", { skip: sqliteSkip }, async () => { // `trim()` removes surrounding whitespace only. A space inside the // address stays invalid — normalisation must not be mistaken for a // sanitiser that silently repairs genuinely malformed input. const result = await postCreateMailbox(authedHarness(), { email: "pri vacy@doforu.ai", name: "Info", }); assert.equal(result.status, 400); assert.deepEqual(detailsPaths(result.body), [["email"]]); }); }); // ── The session shim is a guard, not a bypass ─────────────────────── describe("POST /api/v1/mailboxes — the session shim behaves like the real guard", () => { it("rejects a request with NO cookie, without consulting the session store", { skip: sqliteSkip }, async () => { const result = await postCreateMailbox( authedHarness(), { email: NORMALISED_ID, name: "Info" }, { cookie: null }, ); assert.equal(result.status, 401); assert.equal( result.recorded.sessionLookups, 0, "an anonymous request must not be looked up in the session store", ); assert.deepEqual(result.recorded.putKeys, [], "a rejected request must write nothing to R2"); }); it("rejects a token that is not in the store", { skip: sqliteSkip }, async () => { // The shim answers "no such session" here, which is what proves the // green cases above are not green because the guard was bypassed. const harness = createHarness({ sessionRow: null }); const result = await postCreateMailbox(harness, { email: NORMALISED_ID, name: "Info" }); assert.equal(result.status, 401); assert.equal(result.recorded.sessionLookups, 1, "the presented token must be looked up"); assert.deepEqual(result.recorded.putKeys, []); }); it("rejects an EXPIRED session row (written and read back through SQLite)", { skip: sqliteSkip }, async () => { const sqlite = createSqlite(); const row = insertSession(sqlite, SESSION_TOKEN, new Date(Date.now() - 60_000)); assert.ok(row, "the expired row must exist for the case to be meaningful"); const harness = createHarness({ sessionRow: row }); const result = await postCreateMailbox(harness, { email: NORMALISED_ID, name: "Info" }); assert.equal(result.status, 401); assert.deepEqual(result.recorded.putKeys, [], "an expired session must not create a mailbox"); assert.equal(result.recorded.folderMailboxId, null, "and must not seed folders"); assert.equal(result.recorded.folderRowsWritten, 0); }); }); // ── Preconditions ─────────────────────────────────────────────────── // // A false green here would mean the cases above silently stopped covering // something, so the engine, the mount and the recording are asserted directly // instead of being inferred from the suite passing. describe("create-mailbox normalisation preconditions", () => { it("has a usable SQLite engine (no silent skip)", async () => { assert.equal(hasSqliteEngine(), true, "these guards need node:sqlite (Node >= 22)"); assert.equal(sqliteSkip, false); }); it("mounted the real index app, not a copy", () => { assert.equal(typeof app.request, "function"); assert.equal(typeof app.fetch, "function"); }); it("is offline: the create path makes no outbound request", { skip: sqliteSkip }, async () => { // `mockFetchWithTimeout` throws on ANY use, so reaching 201 proves the // route stayed offline end to end. const before = mockFetchWithTimeout.mock.callCount(); const result = await postCreateMailbox(authedHarness(), { email: NORMALISED_ID, name: "Info", }); assertCreated(result); assert.equal(mockFetchWithTimeout.mock.callCount(), before, "no outbound request may be made"); }); it("records R2 keys, so the storage assertions cannot be vacuous", { skip: sqliteSkip }, async () => { // If `put` ever stopped recording, "no key contains whitespace" would // iterate an empty list and pass while proving nothing. const result = await postCreateMailbox(authedHarness(), { email: NORMALISED_ID, name: "Info", }); assertCreated(result); assert.deepEqual(result.recorded.keys, [NORMALISED_KEY, NORMALISED_KEY], "head then put"); }); it("sees the folder batch, so the id assertions cannot be vacuous", { skip: sqliteSkip }, async () => { // Mirrors the case above for the SQL side: an empty/renamed batch would // make `folderMailboxId` null rather than silently right. const sqlite = createSqlite(); const result = await postCreateMailbox(createHarness({ sqlite, token: SESSION_TOKEN }), { email: NORMALISED_ID, name: "Info", }); assertCreated(result); assert.equal(result.recorded.folderMailboxId, NORMALISED_ID); assert.equal(result.recorded.folderRowsWritten, 6); }); });