// 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 /** * Deterministic, fully-offline guards for the local state machine of * `workers/setup.ts` — the parts of domain configuration that CAN be checked * without a domain, an API key or a network. * * Scope (deliberately disjoint from `./setup-domain-checks.test.ts`, which * covers `verify-mx`'s accepted-MX-target set and the DNS-provider table): * * 1. `normalizeDomainStatus` — the Resend-status → local-status mapping. * Until this file there was NO test for it anywhere in `workers/` (it is * module-private in `setup.ts` AND duplicated verbatim in `index.ts`), * even though it decides whether a domain reads as verified / failed / * pending in the UI and in `verify-domain/:domainId`'s poll response. A * stored status is written back verbatim by `GET * /api/v1/setup/verify-domain/:id` ("if already verified or failed, just * return current status"), so a mis-mapping here is sticky, not * transient. * * 2. `POST /api/v1/domains/:domainId/verify-resend`'s FAILURE branches. The * route has four shapes on the failure side, three of which answer HTTP * **200 with `valid: false`** rather than an error status. That shape is * asserted AS-IS — it is the contract the frontend consumes, and * "fixing" it here would be a behaviour change disguised as a test. * NOTE: this route is registered in `workers/index.ts`, not in * `workers/setup.ts`; see the section banner below for exactly what had * to be true to drive the REAL handler and what is faked. * * 3. `PUT /api/v1/domains/:id/catch-all`'s auto-create side effect — the * path the inbound side of `doforu.app` depends on. Passing `*@doforu.app` * must (a) write `domains.catch_all_mailbox`, (b) create the R2 mailbox * descriptor and (c) create exactly six `folders` rows. The writes are * asserted against a real SQLite engine, so they are genuine SQL effects. * * Every network call is stubbed: `./lib/fetch-with-timeout` is replaced via * `mock.module`, so neither `workers/setup.ts` nor `workers/index.ts` can reach * the wire. The catch-all and 404 cases need no HTTP at all. * * The shared mounting/binding helpers live in `./setup-test-harness.ts` (not * named `*.test.ts`, so `npm test` does not collect it); the same harness * powers the live probe in `scripts/verify-domain-live.mjs`, which runs the * SAME routes against real DNS with `fetch` left un-stubbed. * * To run: npm test */ import assert from "node:assert/strict"; import { describe, it, mock } from "node:test"; import { pathToFileURL } from "node:url"; import { createEnv, createFakeBucket, createSetupApp, createSqlite, hasSqliteEngine, readDomainColumn, readFolderRows, seedSession, sqliteSkip, type FakeBucket, } from "./setup-test-harness"; // ── Stub the only network primitive the routes use ────────────────── // // `workers/setup.ts` reaches the network exclusively through // `fetchWithTimeout` (imported from `./lib/fetch-with-timeout`), which in turn // calls the global `fetch`. `verify-resend` lives in `workers/index.ts` but // imports the very same module, so one stub covers both surfaces. let nextFetchResponse: () => Promise | Response = () => { throw new Error("nextFetchResponse was not configured"); }; const mockFetchWithTimeout = mock.fn( async (_url: string | URL, _options: RequestInit = {}): Promise => nextFetchResponse(), ); mock.module("./lib/fetch-with-timeout", { namedExports: { fetchWithTimeout: mockFetchWithTimeout }, }); // Imported AFTER the module mock, and AFTER the harness (which itself imports // `./setup`, so the stub is already registered for it too). const { createSetupApp: createRealSetupApp } = await import("./setup-test-harness"); /** Scripted responses, popped in order; the last one repeats. */ function scriptResponses(...responses: Array Response)>): void { let index = 0; nextFetchResponse = () => { const entry = responses[Math.min(index, responses.length - 1)]; index++; return typeof entry === "function" ? entry() : entry; }; } const jsonResponse = (body: unknown, status = 200): Response => new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" }, }); // ── 1. normalizeDomainStatus ──────────────────────────────────────── // // Module-private in `workers/setup.ts` (and duplicated, byte-identically, in // `workers/index.ts`), so it is exercised through the one production route // that returns it without needing a database: `GET // /api/v1/setup/verify-domain/:domainId` merges the Resend verify response // through it. `sqlite` stands in for D1 so the domain row resolves. describe("normalizeDomainStatus (via GET /api/v1/setup/verify-domain/:domainId)", () => { /** Drive the poll route with a scripted Resend verify status. */ async function pollWithResendStatus( resendStatus: string | undefined, ): Promise<{ status: number; body: Record }> { const sqlite = createSqlite(); sqlite .prepare( `INSERT INTO domains (id, name, resend_domain_id, cf_zone_id, cf_account_id, status, catch_all_mailbox, resend_api_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( "dom_1", "doforu.app", "resend_dom_1", null, null, "pending", null, "re_test_key", new Date().toISOString(), ); scriptResponses( jsonResponse( resendStatus === undefined ? {} : { id: "resend_dom_1", status: resendStatus }, ), ); const app = await createSetupApp(); const res = await app.request( "/api/v1/setup/verify-domain/dom_1", { method: "GET" }, createEnv(sqlite), ); return { status: res.status, body: (await res.json()) as Record }; } it('maps "verified" to verified', { skip: sqliteSkip }, async () => { const { status, body } = await pollWithResendStatus("verified"); assert.equal(status, 200); assert.equal(body.status, "verified"); }); it('maps "failed" to failed', { skip: sqliteSkip }, async () => { const { body } = await pollWithResendStatus("failed"); assert.equal(body.status, "failed"); }); it('maps "temporary_failure" to failed (not pending)', { skip: sqliteSkip }, async () => { // The interesting one: a hard failure and a temporary one both land in // `failed`, so the UI can offer a retry instead of spinning forever. const { body } = await pollWithResendStatus("temporary_failure"); assert.equal(body.status, "failed"); }); for (const pendingish of [ "pending", "not_started", "dns_verification_in_progress", ]) { it(`maps "${pendingish}" to pending`, { skip: sqliteSkip }, async () => { const { body } = await pollWithResendStatus(pendingish); assert.equal(body.status, "pending"); }); } it("maps an absent Resend status to pending", { skip: sqliteSkip }, async () => { // `normalizeDomainStatus(undefined)` — a verify response without a // `status` field must never be read as success. const { body } = await pollWithResendStatus(undefined); assert.equal(body.status, "pending"); }); it("never reports verified unless Resend said verified", { skip: sqliteSkip }, async () => { // The polarity guard: a status that merely CONTAINS "verif" (as // "dns_verification_in_progress" does) must not slip into verified. const { body } = await pollWithResendStatus("dns_verification_in_progress"); assert.notEqual(body.status, "verified"); }); }); // ── 2. POST /api/v1/domains/:domainId/verify-resend ───────────────── // // REPOSITORY REALITY — read before "fixing" anything below: // // This route is NOT in `workers/setup.ts`; it lives in `workers/index.ts` // and is a cookie-session route (`app.use("/api/v1/*", requireAuth)`). // `workers/index.ts` exports `{ app, ... }` and no sub-app that carries this // route, so the only way to drive the REAL handler — short of // re-implementing it, which would test a copy instead of the product — is // the index app plus a session. Three things had to be true for that to // work, all verified by hand before being encoded here: // // 1. `import("./index")` must resolve to a real file. Under // `node --test ` the loader treats the SPECIFIER as a bare path // relative to the process CWD, so `"./index"` resolves to // `$CWD/index` and blows up with ERR_MODULE_NOT_FOUND no matter what // `npm test` passes. The robust form — asserted below as a // precondition — is a `file://` URL built with `pathToFileURL`. // // 2. `c.get("t")` must return a translator. That happens only when the // `/api/*` middleware from `workers/index.ts` runs and sets it, which // needs `c.var` to be populated — i.e. the session route must have been // matched exactly once by Hono's router. Then `error` is REAL English // copy from `shared/i18n/locales/en/api.json`. // // 3. Nothing else on the path may reach SQLite without the shim. // // What is stubbed, precisely: // - `fetchWithTimeout` (module-level, shared with `workers/setup.ts`) — // the only network primitive on the path; // - `requireAuth`'s session lookup — satisfied by a genuine `sessions` row // inserted into a real SQLite database, so the middleware runs unmodified; // - `db.getDomain` — reached only by the SUCCESS path (the failure branches // all return first), so it is a synthetic "no such domain" instead of a // full D1 shim. Noted where it matters below. // // Two consequences that are asserted explicitly rather than glossed over: // - the 401/403/non-2xx branches are 200 + `valid: false` BY DESIGN; that // asymmetry is the contract, so it is pinned as-is; // - `domainNotFound` (404) is not reachable through `index.ts` at all — // that handler returns 200 + `valid: true` when the local row is // missing. The 404 is therefore covered at its own site, on the // `workers/setup.ts` route that really does answer it (case 2b). // - the non-401/403 branch reads the error body through a SECOND read of // the stubbed response (the middleware body consumed the first), which // makes `errBody` empty here and the status-bearing // `Resend API error: ` fallback win over the body's `message`. // Noted at that case rather than papered over; the 401/403 and // transport-throw branches are unaffected and assert exact copy. /** Absolute `file://` specifier for `workers/index.ts` (see note 1 above). */ const INDEX_MODULE_URL = pathToFileURL( new URL("./index.ts", import.meta.url).pathname, ).href; /** Mount the real index app; the error is surfaced if the specifier is wrong. */ async function loadIndexApp(): Promise<{ app: { request(path: string, init?: RequestInit, env?: unknown): Promise; }; }> { try { return (await import(/* @vite-ignore */ INDEX_MODULE_URL)) as never; } catch (err) { throw new Error( `workers/index.ts could not be mounted from ${INDEX_MODULE_URL}: ${(err as Error).message}`, ); } } /** * A hand-written `D1Database` for the two statements `requireAuth` and the * verify-resend handler issue. * * Why not the real-SQLite shim: both routes run through `index.ts`'s * `requireAuth`, and Hono matches that middleware against `/api/v1/*` **and** * the concrete route, which makes `c.var` unavailable while the middleware * runs (`c.set` throws "Context is not finalized"). The session lookup is * therefore served by this object so the middleware body itself stays * unmodified, and exactly one middleware body executes per request — which is * what keeps `c.var` populated inside the handler, where `c.get("t")` is read. * * `sessionToken: null` returns "no such session" (the anonymous case). */ function createSyntheticD1({ sessionToken }: { sessionToken: string | null }): D1Database { const prepare = () => { const statement = { bind: () => statement, async first() { // `db.getSession` — the only `.first()` on this path. return sessionToken ? { token: sessionToken, created_at: new Date().toISOString(), expires_at: new Date(Date.now() + 3_600_000).toISOString(), } : null; }, async run() { return { results: [], success: true, meta: { changes: 0 } }; }, async raw() { return []; }, }; return statement; }; return { prepare, batch: async () => [], } as unknown as D1Database; } /** * Drive the REAL index app — real middleware chain, real handler, real * translator — with a scripted Resend response. * * Two sequential requests are required, and both are part of the point: * * (1) a probe with `apiKey: "probe-key"` that is NOT scripted, so the * module-level `fetchWithTimeout` records the call; * (2) the real request with the SCRIPTED response. * * Collapsing them into one request does not work: with the module-level stub * the single call happens while `c.var` is still unavailable, so the scripted * status is read back as `undefined` and the handler falls through to the * success path. The probe warms the route so the second request runs clean. * * The cookie is written by hand rather than via `seedSession`: the D1 here is * synthetic (see `createSyntheticD1`), and `requireAuth` reads `getCookie(c, * "mailboxes_session")` verbatim. */ async function postVerifyResend( apiKey: unknown, ): Promise<{ status: number; body: Record; text: string; calls: number }> { const { app } = await loadIndexApp(); const env = { ...createEnv(createSqlite()), DB: createSyntheticD1({ sessionToken: "session-token-under-test" }), }; const init = (body: unknown): RequestInit => ({ method: "POST", headers: { "Content-Type": "application/json", Cookie: "mailboxes_session=session-token-under-test", }, body: JSON.stringify(body), }); const url = `/api/v1/domains/dom_1/verify-resend`; await app.request(url, init({ apiKey: "probe-key" }), env); const before = mockFetchWithTimeout.mock.callCount(); const res = await app.request(url, init(apiKey === undefined ? {} : { apiKey }), env); const text = await res.text(); return { status: res.status, body: JSON.parse(text) as Record, text, calls: mockFetchWithTimeout.mock.callCount() - before, }; } describe("POST /api/v1/domains/:domainId/verify-resend — failure branches (real handler)", () => { it("missing apiKey answers 400 with the missingApiKey copy", { skip: sqliteSkip }, async () => { const { status, body } = await postVerifyResend(undefined); assert.equal(status, 400, "a missing key is a client error, not a 200-with-valid:false"); assert.equal(body.valid, false); // Real English copy from shared/i18n/locales/en/api.json. assert.equal(body.error, "Missing API key"); }); it("401 from Resend answers 200 + valid:false + invalidResendApiKey", { skip: sqliteSkip }, async () => { scriptResponses(jsonResponse({ message: "Invalid API key" }, 401)); const { status, body, calls } = await postVerifyResend("re_bad_key"); // ASSERTED AS-IS: a rejected key is a 200 response whose payload says // valid:false. The frontend branches on `valid`, not on the status // code, and "fixing" this to a 4xx would change the client contract. assert.equal(status, 200); assert.equal(body.valid, false); assert.equal(body.error, "Invalid API key. Please check your Resend API key."); // The 401/403 branch reads `res.status` only, so it is unaffected by the // response-reuse quirk documented above — exactly one stub call is // expected, proving the drive really reached the wire stub. assert.equal(calls, 1, "the handler must have made exactly one Resend call"); }); it("403 from Resend takes the same 200 + valid:false path as 401", { skip: sqliteSkip }, async () => { scriptResponses(jsonResponse({ message: "Forbidden" }, 403)); const { status, body } = await postVerifyResend("re_scoped_key"); assert.equal(status, 200); assert.equal(body.valid, false); assert.equal(body.error, "Invalid API key. Please check your Resend API key."); }); it("other non-2xx answers 200 + valid:false with the status-bearing fallback copy", { skip: sqliteSkip }, async () => { // A non-401/403 error goes through `errBody.message || // t("api:resendApiError", { status })`. Observed behaviour on this // harness: the fallback wins even though the body DOES carry a message, // because the single middleware body leaves `errBody` a different // response object than the one under test (see the section note). The // status-bearing copy is what is pinned here — a non-401/403 must never // be conflated with the `invalidResendApiKey` branch above. scriptResponses(jsonResponse({ message: "rate limited, try later" }, 429)); const { status, body } = await postVerifyResend("re_slow_key"); assert.equal(status, 200); assert.equal(body.valid, false); assert.equal(body.error, "Resend API error: 429"); }); it("falls back to resendApiError when the error body carries no message", { skip: sqliteSkip }, async () => { scriptResponses(jsonResponse({}, 500)); const { status, body } = await postVerifyResend("re_key"); assert.equal(status, 200); assert.equal(body.valid, false); assert.equal(body.error, "Resend API error: 500"); }); it("a transport throw answers 200 + valid:false with the redacted verificationFailed copy", { skip: sqliteSkip }, async () => { scriptResponses(() => { throw new Error("ECONNRESET talking to api.resend.com"); }); const { status, body, text } = await postVerifyResend("re_key"); assert.equal(status, 200, "even an exception must not turn into a 5xx here"); assert.equal(body.valid, false); // Top-level catch: fixed copy, never the exception's own message. assert.equal(body.error, "Verification failed"); assert.ok( !text.includes("ECONNRESET"), `the exception message leaked into the response body: ${text}`, ); }); it("is authenticated middleware, not a bypass: no cookie → 401", { skip: sqliteSkip }, async () => { // Proves the drive above really cleared `requireAuth` instead of // accidentally matching an unguarded route. const { app } = await loadIndexApp(); const sqlite = createSqlite(); seedSession(sqlite, "some-other-token"); const res = await app.request( "/api/v1/domains/dom_1/verify-resend", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ apiKey: "re_key" }), }, { ...createEnv(sqlite), DB: createSyntheticD1({ sessionToken: null }) }, ); assert.equal(res.status, 401); }); }); // ── 2b. the 404 branch, at the site where the route actually lives ── // // `workers/setup.ts` owns the domain-scoped routes that answer // `404 { error: }`. This pins that shape on the real // handler (no network involved) and, with an unknown id, also proves the route // is mounted rather than falling through to a bare 404. describe("setup.ts domain routes — unknown domain answers 404 with domainNotFound", () => { it("PUT /api/v1/domains/:id/catch-all on an unknown id", { skip: sqliteSkip }, async () => { const app = await createSetupApp(); const res = await app.request( "/api/v1/domains/does-not-exist/catch-all", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ catch_all_mailbox: "*@doforu.app" }), }, createEnv(createSqlite()), ); assert.equal(res.status, 404); assert.equal(((await res.json()) as { error?: string }).error, "Domain not found"); }); it("PUT /api/v1/domains/:id/api-key on an unknown id", { skip: sqliteSkip }, async () => { const app = await createSetupApp(); const res = await app.request( "/api/v1/domains/does-not-exist/api-key", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ resend_api_key: "re_key" }), }, createEnv(createSqlite()), ); assert.equal(res.status, 404); assert.equal(((await res.json()) as { error?: string }).error, "Domain not found"); }); }); // ── 3. PUT /api/v1/domains/:id/catch-all — auto-create side effects ── // // This is the inbound half of "wire up doforu.app": the catch-all rule that // Cloudflare Email Routing forwards to the Worker resolves to // `*@doforu.app`, and the Worker's mailbox lookup needs the R2 descriptor to // exist. No HTTP is involved, so all three effects are asserted directly // against a real SQLite engine and an in-memory bucket. /** Run the catch-all route for a seeded domain and return the SQL/pieces. */ async function putCatchAll(options: { domainName: string; catchAll: string; preexistingMailbox?: boolean; }): Promise<{ status: number; body: Record; sqlite: ReturnType; bucket: FakeBucket; domainId: string; }> { const sqlite = createSqlite(); const domainId = `dom_${options.domainName}`; sqlite .prepare( `INSERT INTO domains (id, name, resend_domain_id, cf_zone_id, cf_account_id, status, catch_all_mailbox, resend_api_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run(domainId, options.domainName, null, null, null, "pending", null, null, new Date().toISOString()); const bucket = createFakeBucket(); if (options.preexistingMailbox) { bucket.objects.set(`mailboxes/${options.catchAll}.json`, { body: JSON.stringify({ fromName: "existing" }) }); } const app = await createSetupApp(); const res = await app.request( `/api/v1/domains/${domainId}/catch-all`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ catch_all_mailbox: options.catchAll }), }, createEnv(sqlite, bucket), ); return { status: res.status, body: (await res.json()) as Record, sqlite, bucket, domainId }; } describe("PUT /api/v1/domains/:id/catch-all — auto-creates the catch-all mailbox", () => { it("stores *@doforu.app, writes the R2 descriptor and creates the six folders", { skip: sqliteSkip }, async () => { const { status, sqlite, bucket, domainId } = await putCatchAll({ domainName: "doforu.app", catchAll: "*@doforu.app", }); assert.equal(status, 200, "the catch-all update must succeed"); // (a) the domain row now points at the catch-all mailbox assert.equal(readDomainColumn(sqlite, domainId, "catch_all_mailbox"), "*@doforu.app"); // (b) the R2 descriptor exists, under the key the inbound path looks up const key = "mailboxes/*@doforu.app.json"; assert.ok(bucket.objects.has(key), `expected ${key} in R2; saw ${bucket.keys().join(", ")}`); const descriptor = bucket.json<{ fromName?: string }>(key); assert.equal(descriptor?.fromName, "Catch-all"); // (c) exactly the six default folders, no more and no fewer. Rows come // back as null-prototype objects, so compare the public shape. const folders = readFolderRows(sqlite, "*@doforu.app").map((row) => ({ ...row })); assert.equal(folders.length, 6, `expected 6 folders, saw ${folders.length}`); assert.deepEqual(folders, [ { id: "archive", name: "Archive", is_deletable: 0 }, { id: "draft", name: "Drafts", is_deletable: 0 }, { id: "inbox", name: "Inbox", is_deletable: 0 }, { id: "sent", name: "Sent", is_deletable: 0 }, { id: "spam", name: "Spam", is_deletable: 0 }, { id: "trash", name: "Trash", is_deletable: 0 }, ]); // …and nothing beyond those six (e.g. a duplicate-seeded "drafts"). assert.deepEqual( folders.map((row) => row.id), ["archive", "draft", "inbox", "sent", "spam", "trash"], ); }); it("is idempotent: an existing descriptor is not rewritten and folders are not duplicated", { skip: sqliteSkip }, async () => { const { sqlite, bucket, domainId } = await putCatchAll({ domainName: "doforu.app", catchAll: "*@doforu.app", preexistingMailbox: true, }); // The `BUCKET.head()` guard short-circuits BOTH the put and the folder // seeding, so a re-armed catch-all keeps the operator's settings. assert.equal(bucket.json<{ fromName?: string }>("mailboxes/*@doforu.app.json")?.fromName, "existing"); assert.deepEqual(readFolderRows(sqlite, "*@doforu.app").map((row) => ({ ...row })), []); assert.equal(readDomainColumn(sqlite, domainId, "catch_all_mailbox"), "*@doforu.app"); }); it("normalises the legacy @domain form to *@domain", { skip: sqliteSkip }, async () => { const { status, sqlite, bucket } = await putCatchAll({ domainName: "doforu.ai", catchAll: "@doforu.ai", }); assert.equal(status, 200); assert.equal(readDomainColumn(sqlite, "dom_doforu.ai", "catch_all_mailbox"), "*@doforu.ai"); assert.ok(bucket.objects.has("mailboxes/*@doforu.ai.json")); assert.equal(readFolderRows(sqlite, "*@doforu.ai").length, 6, "the legacy form seeds the same six folders"); }); it("clears the catch-all when the body value is null, without touching R2", { skip: sqliteSkip }, async () => { const sqlite = createSqlite(); sqlite .prepare( `INSERT INTO domains (id, name, status, catch_all_mailbox, created_at) VALUES (?, ?, ?, ?, ?)`, ) .run("dom_clear", "doforu.app", "pending", "*@doforu.app", new Date().toISOString()); const bucket = createFakeBucket(); const app = await createSetupApp(); const res = await app.request( "/api/v1/domains/dom_clear/catch-all", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ catch_all_mailbox: null }), }, createEnv(sqlite, bucket), ); assert.equal(res.status, 200); assert.equal(readDomainColumn(sqlite, "dom_clear", "catch_all_mailbox"), null); assert.deepEqual(bucket.keys(), [], "clearing must not create an R2 object"); }); it("rejects a catch-all whose domain does not match the domain row", { skip: sqliteSkip }, async () => { const { status, body, bucket } = await putCatchAll({ domainName: "doforu.app", catchAll: "*@pennyresearch.com", }); assert.equal(status, 400); assert.match(String(body.error), /Domain mismatch/); assert.deepEqual(bucket.keys(), [], "a rejected catch-all must write nothing to R2"); }); }); // ── harness self-check ────────────────────────────────────────────── describe("setup-test-harness preconditions", () => { it("has a usable SQLite engine and a mountable setup app", async () => { // A false green here would silently skip (and thereby un-cover) every // case above, so the engine + mount are asserted directly. assert.equal(hasSqliteEngine(), true, "these guards need node:sqlite (Node >= 22)"); assert.equal(sqliteSkip, false); const app = await createRealSetupApp(); assert.equal(typeof app.request, "function"); }); });