Ctrl K
ComunidadGuías…

workers/setup-test-harness.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

/**
 * Shared support module for exercising the REAL `workers/setup.ts` Hono app
 * outside the Workers runtime.
 *
 * NOT a test file: the name deliberately does not end in `.test.ts`, so
 * `npm test` ('workers/**/*.test.ts') never collects it. Two consumers use it:
 *
 *   1. `workers/setup-domain-state.test.ts` — deterministic, fully offline
 *      route tests (every network call stubbed).
 *   2. `scripts/verify-domain-live.mjs` — the LIVE run against real domains,
 *      where the same app is mounted but `fetch` is NOT stubbed, so the DoH
 *      lookups genuinely leave the machine.
 *
 * ── The insight this module encodes ────────────────────────────────────────
 * `setup.ts` mounts its routes on a bare `new Hono<{ Bindings: Env }>()`, i.e.
 * WITHOUT the `/api/*` locale middleware and the `requireAuth` guard that
 * `workers/index.ts` layers on top. So mounting the sub-app on a fresh Hono
 * instance exercises the production handlers while bypassing the cookie
 * session — no login, no network, no `index.ts`.
 *
 * `verify-mx` needs no bindings at all; `POST /api/v1/domains/:id/verify-resend`
 * resolves its translator through `index.ts`'s namespace, so it is mounted
 * through a faithful copy of exactly two lines of that middleware chain
 * (locale + cors — no auth). Every other route here only needs `env.DB` /
 * `env.BUCKET`.
 *
 * ── Why a real SQLite engine ───────────────────────────────────────────────
 * `domains` reads go through drizzle (`db.getDomain`) and `initMailboxFolders`
 * issues a `db.batch()` of INSERTs. A hand-rolled "return an object" fake would
 * silently exercise nothing. `node:sqlite` (Node >= 22) runs the statements for
 * real, so the assertions are about actual SQL effects — e.g. exactly six
 * `folders` rows written by the catch-all path.
 */

import { Hono } from "hono";
import { cors } from "hono/cors";
import { getBackendT } from "../shared/i18n/translate";
import { resolveLocale } from "../shared/i18n/config";
import type { Env } from "./types";

// ── node:sqlite (optional engine) ───────────────────────────────────

type SqliteRow = Record<string, unknown>;

type SqliteStatement = {
	all(...params: unknown[]): SqliteRow[];
	run(...params: unknown[]): { changes?: number | bigint };
};

type SqliteDatabase = {
	exec(sql: string): void;
	prepare(sql: string): SqliteStatement;
	close(): void;
};

type DatabaseSyncLike = new (path: string) => SqliteDatabase;

/**
 * Late-bound specifier: a literal `import("node:sqlite")` fails type-checking
 * under this repo's @types/node (20.x) even though Node >= 22 has the module.
 */
const NODE_SQLITE_MODULE = "node:sqlite";

let DatabaseSyncCtor: DatabaseSyncLike | null = null;
try {
	DatabaseSyncCtor =
		// eslint-disable-next-line @typescript-eslint/no-explicit-any
		((await import(NODE_SQLITE_MODULE)) as any).DatabaseSync ?? null;
} catch {
	DatabaseSyncCtor = null;
}

/**
 * `false` when the SQLite engine is available, otherwise a skip reason. Tests
 * pass it straight to `it(..., { skip })` so a missing engine reports SKIPPED
 * — never a silent pass, which would be a false green.
 */
export const sqliteSkip: string | false = DatabaseSyncCtor
	? false
	: "node:sqlite is unavailable (needs Node >= 22)";

/** Whether the real SQLite engine is usable in this process. */
export function hasSqliteEngine(): boolean {
	return DatabaseSyncCtor !== null;
}

// ── Schema (mirrors migrations 0000/0002/0003/0005/0009) ────────────

/** Every table the routes under test touch. */
export const SCHEMA_SQL = `
	CREATE TABLE IF NOT EXISTS folders (
		mailbox_id   TEXT NOT NULL,
		id           TEXT NOT NULL,
		name         TEXT NOT NULL,
		is_deletable INTEGER NOT NULL DEFAULT 1,
		PRIMARY KEY (mailbox_id, id)
	);
	CREATE TABLE IF NOT EXISTS domains (
		id                TEXT PRIMARY KEY,
		name              TEXT NOT NULL UNIQUE,
		resend_domain_id  TEXT,
		cf_zone_id        TEXT,
		cf_account_id     TEXT,
		status            TEXT NOT NULL DEFAULT 'pending',
		catch_all_mailbox TEXT,
		resend_api_key    TEXT,
		created_at        TEXT NOT NULL
	);
	CREATE TABLE IF NOT EXISTS sessions (
		token      TEXT PRIMARY KEY,
		created_at TEXT NOT NULL,
		expires_at TEXT NOT NULL
	);
`;

/** Fresh in-memory database carrying the tables above. */
export function createSqlite(): SqliteDatabase {
	const Ctor = DatabaseSyncCtor;
	if (!Ctor) throw new Error("node:sqlite is unavailable (needs Node >= 22)");
	const sqlite = new Ctor(":memory:");
	sqlite.exec(SCHEMA_SQL);
	return sqlite;
}

// ── D1Database shim over the real SQLite engine ─────────────────────

/** Drizzle / the D1 driver wrap bound values in `{ value }` parameter objects. */
function unwrapDriverValue(arg: unknown): unknown {
	if (arg !== null && typeof arg === "object" && "value" in (arg as object)) {
		return (arg as { value: unknown }).value;
	}
	return arg;
}

/**
 * Minimal `D1PreparedStatement` that executes for real against `node:sqlite`.
 *
 * `head()`-driven code paths (`R2Bucket`) are unaffected; what matters is that
 * `first()` (plain SELECTs, drizzle `.get()`) and `run()`/`batch()` (drizzle
 * writes, `initMailboxFolders`) round-trip through a genuine engine.
 */
function createShim(sqlite: SqliteDatabase): D1Database {
	const shim = {
		exec: async (sql: string) => {
			sqlite.exec(sql);
			return { count: 0, duration: 0 };
		},
		prepare: (sql: string) => {
			const values: unknown[] = [];
			const statement = {
				bind(...args: unknown[]) {
					values.push(...args.map(unwrapDriverValue));
					return statement;
				},
				async first() {
					return (sqlite.prepare(sql).all(...(values as never[]))[0] ?? null) as never;
				},
				async all() {
					return {
						results: sqlite.prepare(sql).all(...(values as never[])),
						success: true,
						meta: {},
					} as never;
				},
				async run() {
					const info = sqlite.prepare(sql).run(...(values as never[]));
					return {
						results: [],
						success: true,
						meta: { changes: Number(info.changes ?? 0) },
					} as never;
				},
				async raw() {
					return sqlite
						.prepare(sql)
						.all(...(values as never[]))
						.map((row) => Object.values(row)) as never;
				},
			};
			return statement as unknown as D1PreparedStatement;
		},
		// `initMailboxFolders` writes through `db.batch()`; each statement is a
		// prepared `D1PreparedStatement`, so delegate to its `run()`.
		batch: async (statements: Array<{ run(): Promise<unknown> }>) => {
			const results: unknown[] = [];
			for (const statement of statements) results.push(await statement.run());
			return results as never;
		},
	};
	return shim as unknown as D1Database;
}

// ── Fake R2 bucket (in-memory) ──────────────────────────────────────

export type FakeBucket = {
	objects: Map<string, { body: string; contentType?: string }>;
	head: (key: string) => Promise<unknown>;
	get: (key: string) => Promise<unknown>;
	put: (key: string, value: unknown, options?: FakeBucketPutOptions) => Promise<unknown>;
	delete: (keys: string | string[]) => Promise<void>;
	list: () => Promise<unknown>;
	/** Raw object bodies, in insertion order. */
	keys(): string[];
	/** Parsed JSON body of `key`, or `undefined` when absent. */
	json<T = unknown>(key: string): T | undefined;
};

export type FakeBucketPutOptions = { httpMetadata?: { contentType?: string } };

/** An in-memory stand-in for the `BUCKET` R2 binding. */
export function createFakeBucket(): FakeBucket {
	const objects = new Map<string, { body: string; contentType?: string }>();
	const asArrayBuffer = (value: unknown): ArrayBuffer => {
		if (typeof value === "string") {
			const encoded = new TextEncoder().encode(value);
			return encoded.buffer.slice(encoded.byteOffset, encoded.byteOffset + encoded.byteLength) as ArrayBuffer;
		}
		if (value instanceof ArrayBuffer) return value;
		if (value instanceof Uint8Array) {
			return value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength) as ArrayBuffer;
		}
		const encoded = new TextEncoder().encode(String(value));
		return encoded.buffer.slice(encoded.byteOffset, encoded.byteOffset + encoded.byteLength) as ArrayBuffer;
	};

	const bucket = {
		objects,
		async head(key: string) {
			const obj = objects.get(key);
			return obj ? { key, size: obj.body.length } : null;
		},
		async get(key: string) {
			const obj = objects.get(key);
			if (!obj) return null;
			return {
				key,
				size: obj.body.length,
				async text() {
					return obj.body;
				},
				async json() {
					return JSON.parse(obj.body);
				},
				async arrayBuffer() {
					return asArrayBuffer(obj.body);
				},
			};
		},
		async put(
			key: string,
			value: unknown,
			options?: FakeBucketPutOptions,
		) {
			objects.set(key, {
				body: typeof value === "string" ? value : String(value),
				contentType: options?.httpMetadata?.contentType,
			});
			return { key };
		},
		async delete(keys: string | string[]) {
			for (const key of Array.isArray(keys) ? keys : [keys]) objects.delete(key);
		},
		async list() {
			return {
				objects: [...objects.keys()].map((key) => ({ key, size: objects.get(key)!.body.length })),
				truncated: false,
			};
		},
		keys() {
			return [...objects.keys()];
		},
		json<T = unknown>(key: string): T | undefined {
			const obj = objects.get(key);
			return obj ? (JSON.parse(obj.body) as T) : undefined;
		},
	};
	return bucket;
}

// ── env + app construction ──────────────────────────────────────────

export type TestEnv = Env & { BUCKET: R2Bucket };
export type TestEnvOverrides = Partial<Record<keyof TestEnv, unknown>>;

/** Build an `Env` with a real-SQLite `DB` and an in-memory `BUCKET`. */
export function createEnv(
	sqlite: SqliteDatabase,
	bucket: FakeBucket = createFakeBucket(),
	overrides: TestEnvOverrides = {},
): TestEnv {
	return {
		...overrides,
		DB: ("DB" in overrides ? overrides.DB : createShim(sqlite)) as D1Database,
		BUCKET: ("BUCKET" in overrides ? overrides.BUCKET : bucket) as R2Bucket,
	} as unknown as TestEnv;
}

type SetupApp = {
	/** `request(path, init, env)` — `env` may be the env itself or `{ env }`. */
	request(path: string, init?: RequestInit, env?: unknown): Promise<Response>;
};

/**
 * Extract an env object out of either a duck-typed env or the execute options
 * bag Hono accepts as `app.request(path, init, third)`.
 *
 * Hono's `request` third parameter accepts EITHER an `Env` / bindings object
 * directly OR an options bag `{ env }`. Both call styles are used across this
 * repo's tests, so normalize here instead of forcing callers to remember.
 */
function resolveEnv(third: unknown): unknown {
	if (third && typeof third === "object" && "env" in (third as object)) {
		return (third as { env: unknown }).env;
	}
	return third;
}

/**
 * Mount the real `setup.ts` sub-app the way `index.ts` does, minus the
 * session guard.
 *
 * `bypassAuth` additionally reproduces the two NON-auth middlewares index.ts
 * runs on `/api/*`:
 *
 *   - the locale middleware (`c.set("locale")` / `c.set("t")`) — required by
 *     `POST /api/v1/domains/:id/verify-resend`, which reads `c.get("t")`
 *     (the `api` namespace) and always runs for cookie-session callers;
 *   - `cors()`, which is inert for server-side `app.request()` drives.
 *
 * No `requireAuth` is involved: the sub-app is mounted directly, which is the
 * documented way these handlers are exercised outside the Workers runtime.
 */
export async function createSetupApp(options: { bypassAuth?: boolean } = {}): Promise<SetupApp> {
	const app = new Hono();
	const { default: setup } = await import("./setup");
	if (options.bypassAuth) {
		app.use("/api/*", async (c, next) => {
			const locale = resolveLocale(c.req.raw);
			c.set("locale" as never, locale);
			c.set("t" as never, getBackendT(locale));
			await next();
		});
		app.use("/api/*", cors());
	}
	app.route("/", setup as never);

	// Normalize the third argument to `request()` (see `resolveEnv`). Wrapping
	// the RAW Hono instance keeps its own `request` bound to it.
	const raw = app as unknown as SetupApp;
	return {
		request: (path, init, env) => raw.request(path, init, resolveEnv(env)),
	};
}

// ── Small assertion helpers ─────────────────────────────────────────

/** Seed a `domains` row and return its id. */
export function seedDomain(
	sqlite: SqliteDatabase,
	row: {
		id?: string;
		name: string;
		status?: string;
		catch_all_mailbox?: string | null;
		resend_domain_id?: string | null;
		resend_api_key?: string | null;
		cf_zone_id?: string | null;
		cf_account_id?: string | null;
	},
): string {
	const id = row.id ?? `dom_${row.name}`;
	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(
			id,
			row.name,
			row.resend_domain_id ?? null,
			row.cf_zone_id ?? null,
			row.cf_account_id ?? null,
			row.status ?? "pending",
			row.catch_all_mailbox ?? null,
			row.resend_api_key ?? null,
			new Date().toISOString(),
		);
	return id;
}

/** Read one column of a `domains` row. */
export function readDomainColumn<T = unknown>(
	sqlite: SqliteDatabase,
	id: string,
	column: string,
): T | undefined {
	const row = sqlite.prepare(`SELECT ${column} AS value FROM domains WHERE id = ?`).all(id)[0];
	return row ? (row.value as T) : undefined;
}

/** Every `folders` row for a mailbox, ordered deterministically. */
export function readFolderRows(
	sqlite: SqliteDatabase,
	mailboxId: string,
): Array<{ id: string; name: string; is_deletable: number }> {
	return sqlite
		.prepare("SELECT id, name, is_deletable FROM folders WHERE mailbox_id = ? ORDER BY id")
		.all(mailboxId) as Array<{ id: string; name: string; is_deletable: number }>;
}

/** Seed a live admin session so `requireAuth` accepts a cookie. */
export function seedSession(sqlite: SqliteDatabase, token: string): string {
	const now = new Date();
	sqlite
		.prepare("INSERT INTO sessions (token, created_at, expires_at) VALUES (?, ?, ?)")
		.run(token, now.toISOString(), new Date(now.getTime() + 3_600_000).toISOString());
	return token;
}