/** * Shared live-app helper for the TASK-0134 security suites. * * The live-app suites (auth-paths, headers, upload-abuse, rate-limits) probe * a RUNNING API + web app. They resolve: * - `SEC_API_URL` — the API base (default http://localhost:4000, matching * the dev stack and the CI e2e services block). * - `SEC_WEB_URL` — the web base (default http://localhost:3000). * - `SEC_REQUIRE_LIVE=1` — a down API FAILS the suite instead of skipping * (the CI security stage sets this, so a missing stack is a red build, * not a silently-green suite). * * Usage (top of a live-app suite file): * beforeAll(async () => { live = await checkLiveApp(); }, 30_000); * const itLive = live ? it : it.skip; // every `it` becomes itLive */ export const SEC_API_URL = (process.env.SEC_API_URL ?? "http://localhost:4000").replace(/\/$/, ""); export const SEC_WEB_URL = (process.env.SEC_WEB_URL ?? "http://localhost:3000").replace(/\/$/, ""); export const SEC_REQUIRE_LIVE = process.env.SEC_REQUIRE_LIVE === "1"; /** * Probe the API health endpoint. Returns true when the API is up (any real * HTTP response < 500, incl. 404 — it answered); a connection-refused/timeout * is false. 10 s timeout so a half-up API doesn't hang the suite. */ export async function apiReachable(base: string = SEC_API_URL): Promise { try { const res = await fetch(`${base}/api/v1/health`, { signal: AbortSignal.timeout(10_000) }); return res.status < 500; } catch { return false; } } let _liveOk: boolean | null = null; /** * Run in `beforeAll`. Resolves true when the API is reachable; false (and the * suites skip) when it is not — UNLESS `SEC_REQUIRE_LIVE=1`, in which case a * down API throws and the build goes red. */ export async function checkLiveApp(): Promise { if (_liveOk === null) _liveOk = await apiReachable(); if (!_liveOk && SEC_REQUIRE_LIVE) { throw new Error( `SEC_REQUIRE_LIVE=1 but the API at ${SEC_API_URL} is unreachable — the stack is not up (CI should have booted it).` ); } return _liveOk; } /** * A fetch bound to the API base, JSON by default, with a per-call timeout. * Paths are relative to the API: the global prefix `api/v1` is added * automatically (callers pass `/auth/login`, not `/api/v1/auth/login`). * `/internal/…` and already-prefixed paths are passed through verbatim. */ export function apiFetch( path: string, init: RequestInit = {}, opts: { timeoutMs?: number } = {} ): Promise { const normalized = path.startsWith("/api/v1/") || path.startsWith("/internal/") ? path : `/api/v1${path.startsWith("/") ? path : `/${path}`}`; const headers = new Headers(init.headers); if (init.body && !headers.has("content-type")) headers.set("content-type", "application/json"); return fetch(`${SEC_API_URL}${normalized}`, { ...init, headers, signal: AbortSignal.timeout(opts.timeoutMs ?? 30_000) }); } /** A fetch bound to the web base. */ export function webFetch( path: string, init: RequestInit = {}, opts: { timeoutMs?: number } = {} ): Promise { return fetch(`${SEC_WEB_URL}${path}`, { ...init, signal: AbortSignal.timeout(opts.timeoutMs ?? 15_000) }); }