workers/create-mailbox-email-normalisation.test.ts
Rama main · réplica de lectura del repositorio original.
// 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<Response> => {
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 <file>` 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<Response>;
fetch(request: Request, env?: unknown): Promise<Response>;
};
};
// ── 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<typeof createSqlite>,
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<typeof createSqlite>; 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<string, unknown>;
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<CreateResult> {
const headers: Record<string, string> = { "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<string, unknown>, text, recorded: harness.recorded };
}
/** `details[].path` values, in order — the field names the dashboard reads. */
function detailsPaths(body: Record<string, unknown>): 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);
});
});