Ctrl K
ComunidadGuías…

tests/health.test.ts

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

Archivo original ↗Preguntar a KiBot
import { createExecutionContext, waitOnExecutionContext } from "cloudflare:test";
import { env } from "cloudflare:workers";
import { describe, expect, it } from "vitest";
import migrationInitial from "../migrations/d1/0001_initial.sql?raw";
import migrationMessageIndex from "../migrations/d1/0002_message_index.sql?raw";
import migrationTransactionalRequests from "../migrations/d1/0007_transactional_requests.sql?raw";
import migrationEmailEvents from "../migrations/d1/0008_email_events_suppressions.sql?raw";
import worker from "../src/server";
import { applyMigrations } from "./helpers/migrations";

type TestEnv = Env & { INDEX_DB: D1Database };

type HealthBody = {
	readiness: { ok: boolean; status: string };
	dependencies: {
		sendingFeedback: {
			ok: boolean;
			configured: boolean;
			mode: string;
			reason: string | null;
			darkDomains: string[];
			maturityHours: number | null;
		};
	};
};

const testEnv = env as unknown as TestEnv;

async function fetchHealth(url: string): Promise<Response> {
	const request = new Request(url);
	const ctx = createExecutionContext();
	const response = await worker.fetch(request, env, ctx);
	await waitOnExecutionContext(ctx);
	return response;
}

// D1 storage in this pool is NOT rolled back between individual `it()`s within a
// file (only across files) -- tables created by one test remain visible to later
// tests in this same describe block. So the "schema missing" case must run BEFORE
// the migrations are applied, and the "schema present" case (which applies the
// migrations) must run after it, permanently establishing the schema for the rest
// of this file's tests (the last test below only asserts on `auth`, which is
// unaffected by indexDb's state either way).
describe("health route", () => {
	// This is the regression test for the false-confidence bug: previously
	// `indexDb: { ok: true, configured: true }` was hardcoded and never queried D1,
	// so a missing migration (e.g. no "aliases" table) was invisible to /api/health
	// even while email ingest was 500ing with "D1_ERROR: no such table: aliases".
	it("reports indexDb.ok:false with a reason and degrades readiness when the D1 schema is missing", async () => {
		const response = await fetchHealth("http://localhost/api/health");

		expect(response.status).toBe(200);
		const body = (await response.json()) as {
			readiness: { ok: boolean; status: string };
			dependencies: { indexDb: { ok: boolean; configured: boolean; reason: string | null } };
		};
		expect(body.dependencies.indexDb.ok).toBe(false);
		expect(body.dependencies.indexDb.configured).toBe(true);
		expect(body.dependencies.indexDb.reason).toBeTruthy();
		expect(body.dependencies.indexDb.reason).toContain("aliases");
		expect(body.readiness).toEqual({ ok: false, status: "degraded" });
	});

	it("reports indexDb.ok:true and overall readiness ready when the D1 schema is present", async () => {
		await applyMigrations(
			testEnv.INDEX_DB,
			migrationInitial as string,
			migrationMessageIndex as string,
		);

		const response = await fetchHealth("http://localhost/api/health");

		expect(response.status).toBe(200);
		expect(await response.json()).toEqual({
			ok: true,
			readiness: {
				ok: true,
				status: "ready",
			},
			dependencies: {
				auth: {
					ok: true,
					configured: false,
					mode: "local-dev-bypass",
					reason: "Better Auth is disabled until BETTER_AUTH_SECRET is configured.",
					missing: ["BETTER_AUTH_SECRET"],
				},
				indexDb: {
					ok: true,
					configured: true,
					reason: null,
				},
				cloudflareApi: {
					ok: true,
					configured: false,
					reason: "CLOUDFLARE_API_TOKEN is not set.",
				},
				telegram: {
					ok: true,
					configured: false,
					mode: "off",
					reason: "Telegram bridge is disabled until TELEGRAM_BOT_TOKEN is set.",
					missing: [],
					webhookUrl: null,
					pendingUpdateCount: null,
				},
				// The transactional projections are not part of this migration set, so
				// the send log cannot be read at all. "unknown" is the honest answer to
				// that, and it must not read as "all clear".
				sendingFeedback: {
					ok: true,
					configured: false,
					mode: "unknown",
					reason: null,
					darkDomains: [],
					maturityHours: null,
				},
			},
		});
	});

	it("reports degraded auth readiness on non-localhost when Access is not configured", async () => {
		const response = await fetchHealth("https://example.com/api/health");

		expect(response.status).toBe(200);
		const body = (await response.json()) as {
			readiness: { ok: boolean; status: string };
			dependencies: { auth: { ok: boolean; reason: string } };
		};
		expect(body.readiness).toEqual({ ok: false, status: "degraded" });
		expect(body.dependencies.auth.ok).toBe(false);
		expect(body.dependencies.auth.reason).toBe(
			"Better Auth is not configured, so non-localhost requests cannot authenticate.",
		);
	});

	// The defect this guards: a sending domain with no Email Sending event
	// subscription produces no lifecycle events for anything, so every send from
	// it stays `delivery_status: null` and looks exactly like a send whose event
	// has not arrived yet. Nothing in the system could tell those apart, and three
	// features read the absence as evidence about the message.
	it("names a sending domain whose feedback channel has never answered", async () => {
		await applyMigrations(
			testEnv.INDEX_DB,
			migrationTransactionalRequests as string,
			migrationEmailEvents as string,
		);

		const before = (await (await fetchHealth("http://localhost/api/health")).json()) as HealthBody;
		expect(before.dependencies.sendingFeedback).toMatchObject({
			ok: true,
			configured: false,
			mode: "no_sends",
		});

		const old = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString();
		await insertRequestLog({ requestId: "req-dark", sender: "bot@notify.example.com", at: old });
		await insertRequestLog({
			requestId: "req-live",
			sender: "bot@send.example.com",
			at: old,
			deliveryEventAt: old,
		});

		const after = (await (await fetchHealth("http://localhost/api/health")).json()) as HealthBody;
		const feedback = after.dependencies.sendingFeedback;
		expect(feedback.ok).toBe(false);
		expect(feedback.mode).toBe("dark");
		expect(feedback.darkDomains).toEqual(["notify.example.com"]);
		expect(feedback.reason).toContain("notify.example.com");
		// The domain that is answering must not be dragged down with it: this is a
		// per-domain fact, and a blanket "sending feedback is broken" would send the
		// operator looking in the wrong place.
		expect(feedback.darkDomains).not.toContain("send.example.com");
		// Readiness stays ready: sending still works, and a dark feedback channel is
		// not a reason to fail a liveness probe.
		expect(after.readiness.ok).toBe(true);
	});

	// Liveness is a claim about now. A domain nobody has sent from in a month has
	// no current evidence either way, and keeping it permanently red would both
	// mislead and turn a polled endpoint into an ever-growing table scan.
	it("ignores a domain whose sends are older than the lookback window", async () => {
		await insertRequestLog({
			requestId: "req-ancient",
			sender: "bot@retired.example.com",
			at: new Date(Date.now() - 45 * 24 * 60 * 60 * 1000).toISOString(),
		});

		const body = (await (await fetchHealth("http://localhost/api/health")).json()) as HealthBody;
		expect(body.dependencies.sendingFeedback.darkDomains).not.toContain("retired.example.com");
		expect(body.dependencies.sendingFeedback.darkDomains).toEqual(["notify.example.com"]);
	});
});

async function insertRequestLog(row: {
	requestId: string;
	sender: string;
	at: string;
	deliveryEventAt?: string;
}): Promise<void> {
	await testEnv.INDEX_DB.prepare(
		`INSERT INTO transactional_request_log
       (request_id, key_id, mailbox_id, status, to_addr, template_id, sender,
        provider_message_id, error_code, delivery_status, delivery_event_at,
        created_at, updated_at)
     VALUES (?, 'key-1', 'mbx-1', 'sent', 'someone@example.org', NULL, ?, ?, NULL, ?, ?, ?, ?)`,
	)
		.bind(
			row.requestId,
			row.sender,
			`msg-${row.requestId}`,
			row.deliveryEventAt ? "delivered" : null,
			row.deliveryEventAt ?? null,
			row.at,
			row.at,
		)
		.run();
}