Ctrl K
ComunidadGuías…

workers/setup-domain-checks.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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

/**
 * Domain-configuration regression guards for `workers/setup.ts`.
 *
 * Two checks live here, both about what a "correctly configured domain" looks
 * like in THIS fork:
 *
 *   1. `POST /api/v1/setup/verify-mx` — the handler used to compare a domain's
 *      MX against a SINGLE hard-coded literal, `mailboxes.pages.dev` (the
 *      upstream agentic-inbox deployment host). This fork does not deploy
 *      there, and real inbound mail arrives through Cloudflare Email Routing,
 *      whose MX records are always `route1/2/3.mx.cloudflare.net`. The old
 *      literal therefore reported EVERY correctly configured domain — e.g.
 *      `doforu.app` and `doforu.ai` — as `verified: false`. The handler now
 *      accepts a SET of inbound targets (`ACCEPTED_INBOUND_MX_TARGETS`),
 *      compares case-insensitively and ignores the trailing root dot that the
 *      DoH JSON answer carries. The cases below pin: a route*.mx.cloudflare.net
 *      answer passes (with the trailing dot and mixed case), all three route
 *      hosts pass (not just one), the legacy `mailboxes.pages.dev` still passes
 *      (no pass→fail regression), a foreign host fails with `matched: null`,
 *      and the pre-existing failure shape is unchanged.
 *
 *      Reverse verification: re-setting the comparison to the old
 *      `=== "mailboxes.pages.dev"` literal makes cases a/b/e FAIL.
 *
 *      A second `verify-mx` group pins the MATCH SEMANTICS: the check is a
 *      normalised EQUALITY test, so hosts that merely contain / prefix /
 *      suffix an accepted target (`evil-route1.mx.cloudflare.net`,
 *      `xroute1.mx.cloudflare.net`, `route1.mx.cloudflare.net.attacker.com`,
 *      `route1.mx.cloudflare.netX`, `mailboxes.pages.dev.attacker.com`) and
 *      the unlisted `route4.mx.cloudflare.net` must all answer
 *      `verified: false`, while the real targets pass in any letter case and
 *      with or without the trailing root dot.
 *
 *   2. `POST /api/v1/setup/detect-dns-provider` — the production
 *      `DNS_PROVIDERS` table plus the production
 *      `detectProviderFromNameservers` loop, driven through the REAL route.
 *      A suspected defect was that `/ns\d*\.cloudflare\.com/i` would NOT match
 *      the letter-prefixed Cloudflare nameservers actually handed out
 *      (`walt.ns.cloudflare.com`, `ziggy.ns.cloudflare.com`; confirmed by
 *      `dig`/DoH for `doforu.app`), so Cloudflare would be misreported as
 *      "Other".
 *
 *      That defect was REFUTED by running the exact regex against the samples:
 *      `\d*` is zero-or-more and `RegExp.test` matches UNANCHORED, so the
 *      pattern matches the `ns.cloudflare.com` substring of
 *      `walt.ns.cloudflare.com`. The regex is therefore deliberately UNCHANGED —
 *      these cases only LOCK the existing behavior so a future "tightening" to
 *      `ns\d+\.` or an `^` anchor cannot silently regress it, and they assert
 *      the non-Cloudflare providers still land in their own buckets.
 *
 *      HISTORY / why this group was rewritten: it used to RE-DECLARE
 *      `DNS_PROVIDERS` and re-implement the loop in the test, because both
 *      symbols are module-private in `setup.ts`. That made the assertions test
 *      a COPY: renaming the production Cloudflare row to "BrokenProvider" left
 *      the suite fully green, i.e. the cases protected the production table
 *      ZERO. The copy is gone; the cases now drive the real route with
 *      `mock.module("node:dns")` serving `dns.promises.resolveNs`, so a change
 *      to the production table or to the detection loop turns them RED.
 *
 * No test in this file performs a real DNS/HTTP request: the DoH call goes
 * through a `mock.module` stub of `./lib/fetch-with-timeout`, and the
 * nameserver cases are fed by a `mock.module` stub of `node:dns`.
 *
 * To run: npm test
 */

import assert from "node:assert/strict";
import { describe, it, mock } from "node:test";
import { Hono } from "hono";

// ── DoH stub ────────────────────────────────────────────────────────
//
// `setup.ts` performs the DoH query with `fetchWithTimeout` imported from
// `./lib/fetch-with-timeout`. That is the only network call on the verify-mx
// path, so replacing the module with a scripted stub makes the whole route
// deterministic. The stub hands back a real `Response`, mirroring the
// `application/dns-json` payload Cloudflare's DoH endpoint returns.
type DohAnswer = { type: number; data: string; TTL: number };

let nextDohResponse: () => Response = () => {
	throw new Error("nextDohResponse was not configured");
};

const mockFetchWithTimeout = mock.fn(
	async (_url: string | URL, _options: RequestInit = {}): Promise<Response> =>
		nextDohResponse(),
);

mock.module("./lib/fetch-with-timeout", {
	namedExports: { fetchWithTimeout: mockFetchWithTimeout },
});

// ── NS answer for the /detect-dns-provider stub ─────────────────────
//
// A `mock.fn` — NOT a captured closure over a mutable variable — is what makes
// the answer re-scriptable per case. Under `tsx` this file is transpiled to
// CJS, and `mock.module` SNAPSHOTS its options bag at registration time: a
// closure reading a later-reassigned module-level binding (or a mutated
// object property) keeps serving the value captured at import, which silently
// pinned every case to the FIRST scripted answer (observed as a route that
// returned the previous case's NS set). `mockImplementation` on a `mock.fn`
// mutates the object the route actually holds, so each case really re-scripts.
//
// The final implementation intentionally THROWS: a case that forgets to script
// an answer must fail loudly instead of resolving something the real world
// never said. Returns either an NS list, or rejects like a resolver failure
// (NXDOMAIN / timeout) — see the `resolveNs` cases further down.
//
// Registered HERE, before `import("./setup")`: module mocks only bind to
// modules loaded AFTER the registration, so a later `mock.module("node:dns")`
// would leave `setup.ts` holding the REAL resolver (observed as the route
// answering from a live lookup while the stub's call count stayed 0).
const resolveNsStub = mock.fn(
	async (_domain: string): Promise<string[]> => {
		throw new Error("resolveNsStub was not scripted for this case");
	},
);

// A deliberately MINIMAL `node:dns`: only `promises.resolveNs` — the single
// entry point these routes use — is provided, and it is deny-by-default (a
// case must script an answer via `scriptNameservers`). Every other method is
// left ABSENT rather than stubbed, so a future change that reaches for a
// different lookup fails loudly instead of quietly hitting the real resolver.
//
// NOTE: this must be a `defaultExport`-only mock. Node's module mocker cannot
// carry `namedExports` alongside a default export here: adding a
// `namedExports.promises` SHADOWS the default export's own `promises` and
// breaks `dns.promises.resolveNs` (confirmed by hand against Node 24).
mock.module("node:dns", {
	defaultExport: { promises: { resolveNs: resolveNsStub } },
});

/** Script the answer the next `resolveNs` call receives (an NS list). */
function scriptNameservers(nameservers: string[]): void {
	resolveNsStub.mock.mockImplementation(async () => nameservers);
}

/** Script a resolver REJECTION (NXDOMAIN / timeout) for the next call. */
function scriptNameserverFailure(): void {
	resolveNsStub.mock.mockImplementation(async () => {
		throw new Error("ENOTFOUND");
	});
}

// Imported AFTER the module mock so the route binds the stub.
const { default: setup } = await import("./setup");

// A standalone Hono mount: verify-mx needs no D1/R2 binding, so no `env` shim
// is required and nothing else in setup.ts is exercised by the import.
const app = new Hono();
app.route("/", setup);

/** Build a DoH JSON answer of MX records (`type: 15`), as Cloudflare emits it. */
function mxResponse(exchanges: string[]): Response {
	const Answer: DohAnswer[] = exchanges.map((data, i) => ({
		type: 15,
		data: `${(i + 1) * 2} ${data}`, // "priority exchange"
		TTL: 300,
	}));
	return Response.json({ Status: 0, Answer });
}

async function postVerifyMx(domain: string): Promise<{
	status: number;
	body: Record<string, unknown>;
}> {
	const res = await app.request("/api/v1/setup/verify-mx", {
		method: "POST",
		headers: { "Content-Type": "application/json" },
		body: JSON.stringify({ domain }),
	});
	return { status: res.status, body: (await res.json()) as Record<string, unknown> };
}

// ── verify-mx ───────────────────────────────────────────────────────

describe("verify-mx accepts the real inbound MX targets", () => {
	it("passes on route1.mx.cloudflare.net with a trailing dot and mixed case", async () => {
		// The DoH answer string is what lands in `matched.exchange`; the handler
		// strips the trailing dot when parsing but the target set must also
		// tolerate it. Feed the mixed-case + trailing-dot form on purpose.
		nextDohResponse = () => mxResponse(["Route1.MX.Cloudflare.NET."]);
		const { status, body } = await postVerifyMx("doforu.app");

		assert.equal(status, 200);
		assert.equal(body.verified, true, "a Cloudflare Email Routing MX must verify");
		assert.deepEqual(body.matched, {
			priority: 2,
			exchange: "Route1.MX.Cloudflare.NET", // trailing dot stripped by the parser
		});
	});

	it("passes on every route*.mx.cloudflare.net host, not just one", async () => {
		// Guards the fix at the level that matters: the set is CONSULTED, so the
		// other two Cloudflare Email Routing hosts also verify. Reverting to the
		// single `mailboxes.pages.dev` literal fails this case.
		for (const exchange of [
			"route1.mx.cloudflare.net.",
			"route2.mx.cloudflare.net.",
			"route3.mx.cloudflare.net.",
		]) {
			nextDohResponse = () => mxResponse([exchange]);
			const { body } = await postVerifyMx("doforu.ai");
			assert.equal(
				body.verified,
				true,
				`${exchange} should be accepted as a valid inbound MX target`,
			);
			assert.ok(body.matched, "matched must point at the record that hit");
		}
	});

	it("matches a route host at Cloudflare's real priority (case-insensitive)", async () => {
		// Priorities 2/68/70 are what Cloudflare Email Routing publishes; the
		// matcher must be priority-agnostic (it keys on the exchange only).
		nextDohResponse = () => mxResponse(["route3.mx.cloudflare.net."]);
		const { body } = await postVerifyMx("example.com");
		const records = body.records as Array<{ priority: number; exchange: string }>;
		// mxResponse assigned priority (i+1)*2 = 2 for the single record.
		assert.equal(records[0].priority, 2);
		assert.equal(body.verified, true);
	});

	it("still passes on the legacy mailboxes.pages.dev target (compatibility)", async () => {
		nextDohResponse = () => mxResponse(["mailboxes.pages.dev."]);
		const { status, body } = await postVerifyMx("legacy.example");

		assert.equal(status, 200);
		assert.equal(body.verified, true, "the old upstream target must not regress to fail");
		assert.deepEqual(body.matched, { priority: 2, exchange: "mailboxes.pages.dev" });
	});

	it("fails a foreign MX host and returns matched: null", async () => {
		nextDohResponse = () => mxResponse(["smtp.google.com."]);
		const { status, body } = await postVerifyMx("gmail-ish.example");

		assert.equal(status, 200);
		assert.equal(body.verified, false);
		assert.equal(body.matched, null, "an unmatched MX yields matched: null");
		// The records are still reported so the UI can show what was found.
		assert.deepEqual(body.records, [{ priority: 2, exchange: "smtp.google.com" }]);
	});

	it("returns verified:false + records:[] when the domain has no MX records", async () => {
		// A successful DoH lookup whose Answer carries no type-15 record is the
		// "domain has no MX yet" shape: not an exception, just an empty match.
		nextDohResponse = () => Response.json({ Status: 0, Answer: [] });
		const { status, body } = await postVerifyMx("no-mx.example");

		assert.equal(status, 200);
		assert.equal(body.verified, false);
		assert.equal(body.matched, null);
		assert.deepEqual(body.records, []);
	});

	it("keeps the existing failure shape when the DoH request throws", async () => {
		// The handler's catch returns `{ verified: false, error, records: [] }`
		// (no `matched` key) and a 200. Asserted as-is so the shape is pinned.
		nextDohResponse = () => {
			throw new Error("network down");
		};
		const { status, body } = await postVerifyMx("broken.example");

		assert.equal(status, 200);
		assert.equal(body.verified, false);
		assert.deepEqual(body.records, []);
		assert.equal(typeof body.error, "string");
		assert.ok((body.error as string).length > 0, "a localized error string is returned");
	});

	it("rejects a missing domain with 400", async () => {
		const res = await app.request("/api/v1/setup/verify-mx", {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({}),
		});
		assert.equal(res.status, 400);
	});
});

// ── verify-mx: EXACT match, not substring ───────────────────────────
//
// The accepted-MX check must be a normalised EQUALITY test, never a substring
// or suffix containment. These cases pin that semantic from both sides:
//
//   - a host that merely CONTAINS an accepted target (`evil-route1…`),
//     PREFIXES one (`xroute1…`), SUFFIXES an accepted target with extra labels
//     (`route1.mx.cloudflare.net.attacker.com`), or extends its final label
//     (`route1.mx.cloudflare.netX`) must NOT verify;
//   - a DIFFERENT Cloudflare route host that is not in the set (`route4…`)
//     must NOT verify — the set is a whitelist, not a `route\d` family;
//   - an unrelated accepted host with an attacker suffix
//     (`mailboxes.pages.dev.attacker.com`) must NOT verify;
//   - the genuinely accepted hosts still DO verify, in any letter case and
//     with or without the trailing root dot.
//
// Without these, switching the production check to `.includes(...)` on a
// substring (or a `startsWith`/`endsWith` test) would keep the suite green
// while accepting attacker-controlled zones — the exact class of bug this set
// exists to prevent. All answers come from the mocked DoH stub; no DNS is
// contacted.

describe("verify-mx requires EXACT equality with an accepted target", () => {
	it("rejects hosts that merely CONTAIN an accepted target", async () => {
		for (const exchange of [
			"route1.mx.cloudflare.net.attacker.com",
			"evil-route1.mx.cloudflare.net",
			"xroute1.mx.cloudflare.net",
			"route1.mx.cloudflare.netX",
			"mailboxes.pages.dev.attacker.com",
		]) {
			nextDohResponse = () => mxResponse([exchange]);
			const { status, body } = await postVerifyMx("victim.example");

			assert.equal(status, 200);
			assert.equal(
				body.verified,
				false,
				`${exchange} must NOT verify: it is not exactly an accepted target`,
			);
			assert.equal(body.matched, null, `${exchange} must not produce a match`);
			// The offending record is still reported, so the UI can show it.
			assert.deepEqual(body.records, [{ priority: 2, exchange: exchange.replace(/\.$/, "") }]);
		}
	});

	it("rejects an unlisted Cloudflare route host (the set is a whitelist)", async () => {
		// `route4` is NOT one of the three hosts Cloudflare Email Routing
		// publishes, so it must not pass merely by looking similar.
		nextDohResponse = () => mxResponse(["route4.mx.cloudflare.net"]);
		const { body } = await postVerifyMx("victim.example");

		assert.equal(body.verified, false, "route4.mx.cloudflare.net is not an accepted target");
		assert.equal(body.matched, null);
	});

	it("accepts an exact target regardless of letter case or trailing dot", async () => {
		for (const [exchange, expectedExchange] of [
			["ROUTE1.MX.CLOUDFLARE.NET", "ROUTE1.MX.CLOUDFLARE.NET"],
			["Route3.MX.Cloudflare.NET.", "Route3.MX.Cloudflare.NET"],
			["MAILBOXES.PAGES.DEV.", "MAILBOXES.PAGES.DEV"],
		] as const) {
			nextDohResponse = () => mxResponse([exchange]);
			const { body } = await postVerifyMx("doforu.app");

			assert.equal(body.verified, true, `${exchange} must verify (case/root-dot insensitive)`);
			// The parser strips the trailing dot but preserves the original case.
			assert.deepEqual(body.matched, { priority: 2, exchange: expectedExchange });
		}
	});
});

// ── POST /api/v1/setup/detect-dns-provider ──────────────────────────
//
// These cases drive the REAL route and assert its `provider` field, so what is
// under test is the PRODUCTION provider table plus the PRODUCTION detection
// loop in `workers/setup.ts` — not a transcription of them.
//
// ── Why this is a rewrite (the defect this section used to have) ────────────
// `DNS_PROVIDERS` and `detectProviderFromNameservers` are module-private, and
// an earlier revision of this file solved that by RE-DECLARING the whole table
// and re-implementing the loop here. That made the four cases below assert a
// COPY: mutating the production table (e.g. renaming its Cloudflare row to
// "BrokenProvider") left the suite fully green, i.e. the cases protected the
// production table ZERO. The copy is gone; nothing below names a provider or a
// nameserver pattern. The only literals are domain-ish nameservers that must
// resolve to a provider, which is what a route-level test can honestly state.
//
// ── How the route is driven offline ────────────────────────────────────────
// The route resolves the NS set through `dns.promises.resolveNs(domain)` (the
// module-private `dns` import at the top of `setup.ts`). Stubbing that default
// export — Node's `--experimental-test-module-mocks`, already in `npm test` —
// makes the deny-by-default module stub below serve the answer, with NO real
// resolver and no DoH traffic. Verified by hand before being encoded: a
// deliberately wrong stub would otherwise be indistinguishable from a real
// lookup.
//
// MUTATION EVIDENCE (what makes these cases load-bearing):
//   - rename the production Cloudflare row to "BrokenProvider" → the two
//     Cloudflare cases FAIL;
//   - drop "route2…" from a production list these cases pin → that case FAILS.

// The stub itself, and the two scripting helpers, are registered near the top
// of this file (before `import("./setup")`) — see the `node:dns` section there.

async function postDetectProvider(domain: string): Promise<{
	status: number;
	body: Record<string, unknown>;
}> {
	const res = await app.request("/api/v1/setup/detect-dns-provider", {
		method: "POST",
		headers: { "Content-Type": "application/json" },
		body: JSON.stringify({ domain }),
	});
	return { status: res.status, body: (await res.json()) as Record<string, unknown> };
}

describe("POST /api/v1/setup/detect-dns-provider — production table, real route", () => {
	it("matches the letter-prefixed Cloudflare nameservers actually in use", async () => {
		// `doforu.app` serves exactly these two (confirmed via dig/DoH). The
		// suspected defect — that the Cloudflare pattern misses them — is false:
		// the pattern's digit run is zero-or-more and `RegExp.test` is unanchored.
		// These cases lock that in, so a future "tightening" of the pattern (or
		// anchoring it with `^`) cannot silently regress the common case.
		for (const nameservers of [
			["walt.ns.cloudflare.com"],
			["ziggy.ns.cloudflare.com"],
			["walt.ns.cloudflare.com", "ziggy.ns.cloudflare.com"],
		]) {
			scriptNameservers(nameservers);
			const { status, body } = await postDetectProvider("doforu.app");
			assert.equal(status, 200);
			// The route echoes the RESOLVED set, so this also proves the stub was
			// consulted (a mis-wired mock would report an empty set).
			assert.deepEqual(body.nameservers, nameservers);
			assert.equal(
				body.provider,
				"Cloudflare",
				`the production table must classify ${nameservers.join(", ")} as Cloudflare`,
			);
		}
	});

	it("still matches the numeric ns1 form", async () => {
		for (const nameserver of ["ns1.cloudflare.com", "ns3.cloudflare.com"]) {
			scriptNameservers([nameserver]);
			const { body } = await postDetectProvider("doforu.app");
			assert.equal(body.provider, "Cloudflare", `${nameserver} must classify as Cloudflare`);
		}
	});

	it("keeps non-Cloudflare providers in their own buckets", async () => {
		// `Other` for a provider the table does not (yet) know, and the two
		// buckets that are most likely to be confused with Cloudflare.
		for (const [nameserver, provider] of [
			["dns1.p01.nsone.net", "Other"],
			["ns1.google.com", "Google Cloud DNS"],
			["ns-123.awsdns-45.org", "AWS Route 53"],
			["ns-99.awsdns-01.com", "AWS Route 53"],
		] as const) {
			scriptNameservers([nameserver]);
			const { body } = await postDetectProvider("example.com");
			assert.equal(body.provider, provider, `${nameserver} must classify as ${provider}`);
		}
	});

	it("cloudflare.com is matched case-insensitively", async () => {
		scriptNameservers(["WALT.NS.CLOUDFLARE.COM"]);
		const { body } = await postDetectProvider("doforu.app");
		assert.equal(body.provider, "Cloudflare");
	});

	it("reports Other when the nameserver lookup throws (NXDOMAIN/timeout)", async () => {
		// The route swallows a resolver failure and returns an EMPTY set with
		// `Other` — 200, never a 5xx. Pinned as-is; also the proof that the
		// stub above is not merely echoing a hard-coded success.
		scriptNameserverFailure();
		const { status, body } = await postDetectProvider("nx.example");
		assert.equal(status, 200);
		assert.equal(body.provider, "Other");
		assert.deepEqual(body.nameservers, []);
	});

	it("rejects a missing domain with 400 without touching the resolver", async () => {
		// An UNSCRIPTED stub (back to its throwing default) is left in place: if
		// a 400 ever reached the resolver, the request would surface the
		// rejection instead of the client error asserted below.
		resolveNsStub.mock.mockImplementation(async () => {
			throw new Error("the resolver must not run for a 400");
		});
		const res = await app.request("/api/v1/setup/detect-dns-provider", {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({}),
		});
		assert.equal(res.status, 400);
	});
});