feat: add extensive test suite
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
type DebugRuntime = {
|
||||
bun: string;
|
||||
node: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
uptime: number;
|
||||
};
|
||||
|
||||
type DebugHandlerDependencies = {
|
||||
adminCheck: () => Promise<unknown>;
|
||||
runtime: () => DebugRuntime;
|
||||
memoryUsage: () => NodeJS.MemoryUsage;
|
||||
probes: ReadonlyArray<{
|
||||
name: string;
|
||||
run: () => Promise<unknown>;
|
||||
}>;
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
export function createDebugHandler({
|
||||
adminCheck,
|
||||
runtime,
|
||||
memoryUsage,
|
||||
probes,
|
||||
now = () => performance.now(),
|
||||
}: DebugHandlerDependencies) {
|
||||
return async function getDebugStatus() {
|
||||
try {
|
||||
if (!(await adminCheck())) return unauthorized();
|
||||
} catch {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
probes.map(async ({ name, run }) => {
|
||||
const started = now();
|
||||
|
||||
try {
|
||||
await run();
|
||||
return { name, ok: true, latencyMs: now() - started };
|
||||
} catch {
|
||||
return { name, ok: false, latencyMs: now() - started };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return Response.json({
|
||||
timestamp: new Date().toISOString(),
|
||||
runtime: runtime(),
|
||||
memory: memoryUsage(),
|
||||
probes: results,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function unauthorized() {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { createDebugHandler } from "./handler";
|
||||
|
||||
const runtime = () => ({
|
||||
bun: "1.0.0",
|
||||
node: "v20.0.0",
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
uptime: 10,
|
||||
});
|
||||
|
||||
const memoryUsage = () => ({
|
||||
rss: 1,
|
||||
heapTotal: 2,
|
||||
heapUsed: 3,
|
||||
external: 4,
|
||||
arrayBuffers: 5,
|
||||
});
|
||||
|
||||
describe("debug route handler", () => {
|
||||
test("rejects non-admin requests without running probes", async () => {
|
||||
const run = mock(async () => "PONG");
|
||||
const handler = createDebugHandler({
|
||||
adminCheck: mock(async () => null),
|
||||
runtime,
|
||||
memoryUsage,
|
||||
probes: [{ name: "redis", run }],
|
||||
});
|
||||
|
||||
const response = await handler();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.text()).toBe("Unauthorized");
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("fails closed when admin authentication throws", async () => {
|
||||
const run = mock(async () => "PONG");
|
||||
const handler = createDebugHandler({
|
||||
adminCheck: mock(async () => {
|
||||
throw new Error("session database unavailable");
|
||||
}),
|
||||
runtime,
|
||||
memoryUsage,
|
||||
probes: [{ name: "redis", run }],
|
||||
});
|
||||
|
||||
const response = await handler();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns bounded diagnostics to an admin", async () => {
|
||||
let time = 10;
|
||||
const handler = createDebugHandler({
|
||||
adminCheck: mock(async () => ({ role: "admin" })),
|
||||
runtime,
|
||||
memoryUsage,
|
||||
probes: [
|
||||
{ name: "redis", run: mock(async () => "PONG") },
|
||||
{
|
||||
name: "postgres",
|
||||
run: mock(async () => {
|
||||
throw new Error("password=secret\nstack trace");
|
||||
}),
|
||||
},
|
||||
],
|
||||
now: () => time++,
|
||||
});
|
||||
|
||||
const response = await handler();
|
||||
const body = await response.json();
|
||||
const serialized = JSON.stringify(body);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toMatchObject({
|
||||
runtime: runtime(),
|
||||
memory: memoryUsage(),
|
||||
probes: [
|
||||
{ name: "redis", ok: true },
|
||||
{ name: "postgres", ok: false },
|
||||
],
|
||||
});
|
||||
expect(
|
||||
body.probes.every(
|
||||
({ latencyMs }: { latencyMs: number }) => latencyMs >= 0,
|
||||
),
|
||||
).toBeTrue();
|
||||
expect(serialized).not.toContain("secret");
|
||||
expect(serialized).not.toContain("stack");
|
||||
expect(serialized).not.toContain("cookie");
|
||||
expect(serialized).not.toContain("authorization");
|
||||
expect(body).not.toHaveProperty("env");
|
||||
expect(body).not.toHaveProperty("request");
|
||||
});
|
||||
|
||||
test("uses the runtime performance clock by default", async () => {
|
||||
const handler = createDebugHandler({
|
||||
adminCheck: mock(async () => ({ role: "admin" })),
|
||||
runtime,
|
||||
memoryUsage,
|
||||
probes: [{ name: "redis", run: mock(async () => "PONG") }],
|
||||
});
|
||||
|
||||
const body = await (await handler()).json();
|
||||
expect(body.probes[0].latencyMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
+35
-112
@@ -1,126 +1,49 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { youtubeCache } from "@/lib/adaptive-cache";
|
||||
import { adminCheck } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { redis } from "@/lib/db/redis";
|
||||
import { env } from "bun";
|
||||
import { sql } from "drizzle-orm";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { createDebugHandler } from "./handler";
|
||||
|
||||
async function probe(name: string, fn: () => Promise<unknown>) {
|
||||
const started = performance.now();
|
||||
const timeoutMs = 5_000;
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
|
||||
return {
|
||||
name,
|
||||
ok: true,
|
||||
latencyMs: performance.now() - started,
|
||||
result,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
name,
|
||||
ok: false,
|
||||
latencyMs: performance.now() - started,
|
||||
error:
|
||||
error instanceof Error
|
||||
? {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
cause: error.cause,
|
||||
}
|
||||
: error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
if (!(await adminCheck())) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
} catch {
|
||||
// idiomatic fallback
|
||||
// even though this is extremely risky, we need it
|
||||
console.error("/api/debug AUTH CHECK ERROR");
|
||||
// use REDIS_URL which only I can memorize as the password,
|
||||
// or IF THAT fails too, use a static string, which shouldn't ever happen
|
||||
if (
|
||||
request.nextUrl.searchParams.get("pass") !==
|
||||
(env.REDIS_URL || "absolute_solver")
|
||||
)
|
||||
return new Response("Unauthorized (Fallback)", { status: 401 });
|
||||
}
|
||||
|
||||
const probes = await Promise.all([
|
||||
probe("redis", async () => {
|
||||
const started = performance.now();
|
||||
const result = await withTimeout(redis!.ping(), 5000);
|
||||
|
||||
return {
|
||||
result,
|
||||
latencyMs: performance.now() - started,
|
||||
};
|
||||
}),
|
||||
|
||||
probe("postgres", async () => {
|
||||
const started = performance.now();
|
||||
const result = await withTimeout(db.execute(sql`SELECT 1`), 5000);
|
||||
|
||||
return {
|
||||
result,
|
||||
latencyMs: performance.now() - started,
|
||||
};
|
||||
}),
|
||||
]);
|
||||
|
||||
return Response.json({
|
||||
timestamp: new Date().toISOString(),
|
||||
|
||||
runtime: safe({
|
||||
bun: Bun.version,
|
||||
node: process.version,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
pid: process.pid,
|
||||
uptime: process.uptime(),
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
|
||||
memory: process.memoryUsage(),
|
||||
|
||||
eventLoop: {
|
||||
now: performance.now(),
|
||||
export const GET = createDebugHandler({
|
||||
adminCheck,
|
||||
runtime: () => ({
|
||||
bun: Bun.version,
|
||||
node: process.version,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
uptime: process.uptime(),
|
||||
}),
|
||||
memoryUsage: () => process.memoryUsage(),
|
||||
probes: [
|
||||
{
|
||||
name: "redis",
|
||||
run: () => withTimeout(redis!.ping(), timeoutMs),
|
||||
},
|
||||
|
||||
request: {
|
||||
url: request.url,
|
||||
headers: safe(Object.fromEntries(request.headers)),
|
||||
{
|
||||
name: "postgres",
|
||||
run: () => withTimeout(db.execute(sql`SELECT 1`), timeoutMs),
|
||||
},
|
||||
|
||||
ytCache: await probe("youtubeCache", async () => youtubeCache.debugStats()),
|
||||
|
||||
env,
|
||||
|
||||
probes,
|
||||
});
|
||||
}
|
||||
{
|
||||
name: "youtubeCache",
|
||||
run: () => withTimeout(youtubeCache.debugStats(), timeoutMs),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms),
|
||||
),
|
||||
]);
|
||||
}
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
function safe(value: unknown) {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
} catch {
|
||||
return String(value);
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error("Probe timed out")), ms);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timeout!);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user