diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cdad7f..af53921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Dev**: Tests for `utils/db.ts`, which sat at 2.85% coverage — the persistence layer behind scale-to-zero, essentially untested. 23 tests, aimed at the boundaries that decide behaviour: `updateProjectAccess` must not clobber a status set elsewhere, or a start in flight would be reset by a request arriving on the waiting page; `setProjectStatus` must leave `last_access` alone, or stopping a project would look like activity and defer the next sweep; `getIdleProjects` treats a project used exactly at the cutoff as active and ignores anything not `running`; and `cleanOldLogs` keeps an entry exactly at the cutoff ([#51]) +## [Unreleased] + +### Added + +- **Dev**: A coverage floor in CI. `test:ci` now fails when statements, branches, functions or lines drop below a threshold set just under the current numbers, so a regression fails while an improvement does not. Verified to actually fail rather than pass silently: raising the bar above the current figure exits 1 with `Coverage for statements (76.24%) does not meet global threshold`. This is the gate for the problem behind several fixes this cycle — `loadProjectList` and `routePath` each had no tests and each hid a bug for nine releases ([#52]) +- **Dev**: Tests for the request handlers, taking agent coverage of `server.ts` from 5.8% to 66.7%. The handlers now receive a `ServerDeps` seam holding the project index, the per-project configs and the side effects, so a test drives them with a known world and records what they did — no socket, no database, no DDEV. The cases worth having: a project's own `auth_policy` overriding the global one while credentials stay server-wide, a second request during a start not queueing another `ddev start`, a failed start recording `stopped` rather than leaving the project wedged on `starting`, and `/__auth__?s=term` reaching the auth handler — the query-string routing bug from 0.1.33, now covered ([#52]) +- **Dev**: Tests for the upgrade sequence, taking `setup/upgrade.ts` from 18.9% to 66.7%. `runUpgrade` takes an `UpgradeIo`, which is what makes the re-exec path testable without replacing the process. Covers the two guards that matter: no re-exec when npm served a stale cache and left the old version in place, and no second re-exec once one has happened — either would loop. Also that an unreachable registry does not stop migrations that are already due ([#52]) + ## [0.1.38] - 2026.09.07 ### Security @@ -498,6 +506,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#49]: https://github.com/studiometa/trafic/pull/49 [#50]: https://github.com/studiometa/trafic/pull/50 [#51]: https://github.com/studiometa/trafic/pull/51 +[#52]: https://github.com/studiometa/trafic/pull/52 [#31]: https://github.com/studiometa/trafic/pull/31 [GHSA-mw96-cpmx-2vgc]: https://github.com/advisories/GHSA-mw96-cpmx-2vgc [ddev/ddev#2696]: https://github.com/ddev/ddev/issues/2696 diff --git a/packages/trafic-agent/src/server.ts b/packages/trafic-agent/src/server.ts index e4aa665..8120d5e 100644 --- a/packages/trafic-agent/src/server.ts +++ b/packages/trafic-agent/src/server.ts @@ -30,6 +30,28 @@ let hostnameIndex: Map; // Cache of per-project configs (project name -> config) const projectConfigs = new Map>(); +/** + * What the request handlers need from the outside world. + * + * Gathered into one seam so a test can drive the handlers with a known + * project list and record what they did, without a listening socket, a real + * database or a DDEV install. `startServer` builds the real one. + */ +export interface ServerDeps { + auth: AuthConfig; + /** hostname -> project name */ + hostnameIndex: Map; + /** project name -> its own config, where it has one */ + projectConfigs: Map>; + loadTemplate: (name: string) => string; + updateProjectAccess: (name: string) => void; + logAccess: (log: Parameters[0]) => void; + getProject: (name: string) => ReturnType; + setProjectStatus: (name: string, status: "running" | "stopped" | "starting") => void; + startProject: (name: string) => Promise; + getProjectInfo: (name: string) => ReturnType; +} + /** * Load HTML template */ @@ -45,15 +67,18 @@ function loadTemplate(name: string): string { /** * Get effective auth config for a project (merges global + per-project) */ -function getEffectiveAuthConfig(projectName: string | undefined): AuthConfig { - if (!projectName) return config.auth; +export function getEffectiveAuthConfig( + projectName: string | undefined, + deps: Pick, +): AuthConfig { + if (!projectName) return deps.auth; - const projectConfig = projectConfigs.get(projectName); - if (!projectConfig?.auth_policy) return config.auth; + const projectConfig = deps.projectConfigs.get(projectName); + if (!projectConfig?.auth_policy) return deps.auth; // Override default policy with project-specific policy return { - ...config.auth, + ...deps.auth, defaultPolicy: projectConfig.auth_policy, }; } @@ -62,7 +87,11 @@ function getEffectiveAuthConfig(projectName: string | undefined): AuthConfig { * Handle forward auth requests from Traefik * Traefik sends the original request headers, we return 200 (allow) or 401 (deny) */ -function handleAuth(req: IncomingMessage, res: ServerResponse): void { +export function handleAuth( + req: IncomingMessage, + res: ServerResponse, + deps: ServerDeps, +): void { const hostname = req.headers["x-forwarded-host"] as string ?? ""; // Keep the two apart: the socket peer cannot be forged, X-Forwarded-For // partly can. checkAuth decides which entry to trust. @@ -72,10 +101,10 @@ function handleAuth(req: IncomingMessage, res: ServerResponse): void { const path = req.headers["x-forwarded-uri"] as string ?? "/"; // Find project from hostname - const projectName = hostnameIndex.get(hostname); + const projectName = deps.hostnameIndex.get(hostname); // Get effective auth config (global + per-project overrides) - const authConfig = getEffectiveAuthConfig(projectName); + const authConfig = getEffectiveAuthConfig(projectName, deps); const clientIp = resolveClientIp( socketIp, @@ -96,8 +125,8 @@ function handleAuth(req: IncomingMessage, res: ServerResponse): void { if (result.allowed) { // Log access and update last access time if (projectName) { - updateProjectAccess(projectName); - logAccess({ + deps.updateProjectAccess(projectName); + deps.logAccess({ project: projectName, timestamp: Date.now(), ip: clientIp, @@ -121,26 +150,27 @@ function handleAuth(req: IncomingMessage, res: ServerResponse): void { * Handle errors middleware requests (502 from Traefik) * When a project is stopped, Traefik returns 502. We show a waiting page and start the project. */ -async function handleErrors( +export async function handleErrors( req: IncomingMessage, res: ServerResponse, + deps: ServerDeps, ): Promise { const hostname = req.headers["x-forwarded-host"] as string ?? req.headers.host ?? ""; - const projectName = hostnameIndex.get(hostname); + const projectName = deps.hostnameIndex.get(hostname); if (!projectName) { // Unknown project - const template = loadTemplate("error"); + const template = deps.loadTemplate("error"); res.writeHead(404, { "Content-Type": "text/html" }); res.end(template.replace("{{message}}", "Project not found")); return; } // Check if project is already starting - const record = getProject(projectName); + const record = deps.getProject(projectName); if (record?.status === "starting") { // Show waiting page - const template = loadTemplate("wait"); + const template = deps.loadTemplate("wait"); res.writeHead(503, { "Content-Type": "text/html", "Retry-After": "5", @@ -154,10 +184,10 @@ async function handleErrors( } // Mark as starting - setProjectStatus(projectName, "starting"); + deps.setProjectStatus(projectName, "starting"); // Show waiting page immediately - const template = loadTemplate("wait"); + const template = deps.loadTemplate("wait"); res.writeHead(503, { "Content-Type": "text/html", "Retry-After": "5", @@ -173,13 +203,13 @@ async function handleErrors( // serving forward auth for every other project while this runs. The status // is recorded when it settles, which is what stops a second request from // starting the same project again. - void startProject(projectName) + void deps.startProject(projectName) .then((success) => { - setProjectStatus(projectName, success ? "running" : "stopped"); + deps.setProjectStatus(projectName, success ? "running" : "stopped"); }) .catch((error: unknown) => { // Leaving it "starting" forever would wedge the waiting page - setProjectStatus(projectName, "stopped"); + deps.setProjectStatus(projectName, "stopped"); console.error(`Could not start ${projectName}:`, error); }); } @@ -187,9 +217,10 @@ async function handleErrors( /** * Handle status polling requests */ -async function handleStatus( +export async function handleStatus( req: IncomingMessage, res: ServerResponse, + deps: ServerDeps, ): Promise { const url = new URL(req.url ?? "/", `http://${req.headers.host}`); const projectName = url.searchParams.get("project"); @@ -200,8 +231,8 @@ async function handleStatus( return; } - const info = await getProjectInfo(projectName); - const record = getProject(projectName); + const info = await deps.getProjectInfo(projectName); + const record = deps.getProject(projectName); res.writeHead(200, { "Content-Type": "application/json" }); res.end( @@ -236,9 +267,10 @@ export function routePath(target: string | undefined): string { /** * Request handler */ -async function handleRequest( +export async function handleRequest( req: IncomingMessage, res: ServerResponse, + deps: ServerDeps, ): Promise { // Route on the path alone. Matching the raw URL meant a query string threw // every internal route off: Traefik's catch-all and errors middleware pass @@ -251,15 +283,15 @@ async function handleRequest( try { // Route requests if (path === "/__auth__" || path.startsWith("/__auth__/")) { - handleAuth(req, res); + handleAuth(req, res, deps); } else if (path === "/__status__" || path.startsWith("/__status__/")) { - await handleStatus(req, res); + await handleStatus(req, res, deps); } else if (path === "/__health__") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "ok", version: "__VERSION__" })); } else { // Default: errors middleware - await handleErrors(req, res); + await handleErrors(req, res, deps); } } catch (error) { console.error("Request error:", error); @@ -310,9 +342,24 @@ export function startServer(agentConfig: AgentConfig): void { reloadProjects(); }); + // The real dependencies. Rebuilt per request for the two maps, which + // reloadProjects replaces wholesale when the project list changes. + const deps = (): ServerDeps => ({ + auth: config.auth, + hostnameIndex, + projectConfigs, + loadTemplate, + updateProjectAccess, + logAccess, + getProject, + setProjectStatus, + startProject, + getProjectInfo, + }); + // Create HTTP server const server = createServer((req, res) => { - handleRequest(req, res).catch((error) => { + handleRequest(req, res, deps()).catch((error) => { console.error("Unhandled error:", error); if (!res.headersSent) { res.writeHead(500); diff --git a/packages/trafic-agent/src/setup/upgrade.ts b/packages/trafic-agent/src/setup/upgrade.ts index 6952aca..22ae340 100644 --- a/packages/trafic-agent/src/setup/upgrade.ts +++ b/packages/trafic-agent/src/setup/upgrade.ts @@ -94,6 +94,42 @@ export function restartAgentService(dryRun: boolean): void { exec("systemctl restart trafic-agent", { silent: !dryRun }); } +/** + * What the upgrade sequence needs from the outside world. + * + * Injected so the orchestration can be tested without reaching npm, writing + * to the filesystem or replacing the running process — the last of which is + * why `reExecNewBinary` is here rather than called directly. + */ +export interface UpgradeIo { + /** The version of the process running the upgrade. */ + currentVersion: string; + /** Whether an earlier run already re-execed, guarding against a loop. */ + alreadyReExeced: boolean; + isRoot: () => boolean; + fetchLatestVersion: () => string | null; + installLatestAgent: (dryRun: boolean) => void; + getInstalledVersion: () => string | null; + reExecNewBinary: (args: string[]) => never; + restartAgentService: (dryRun: boolean) => void; + runPendingMigrations: (dryRun: boolean) => void; +} + +/** The real collaborators, used unless a caller passes their own. */ +export function nodeUpgradeIo(): UpgradeIo { + return { + currentVersion: __VERSION__, + alreadyReExeced: process.env["TRAFIC_UPGRADE_REEXEC"] === "1", + isRoot, + fetchLatestVersion, + installLatestAgent, + getInstalledVersion, + reExecNewBinary, + restartAgentService, + runPendingMigrations, + }; +} + /** * Full upgrade sequence: * 1. Check for a new version on npm @@ -102,23 +138,27 @@ export function restartAgentService(dryRun: boolean): void { * 3. Run pending migrations * 4. Restart the systemd service */ -export function runUpgrade(dryRun = false, reExecArgs?: string[]): void { - if (!isRoot() && !dryRun) { +export function runUpgrade( + dryRun = false, + reExecArgs?: string[], + io: UpgradeIo = nodeUpgradeIo(), +): void { + if (!io.isRoot() && !dryRun) { console.error(" \x1b[31m✗\x1b[0m This command must be run as root"); console.log(" Run: sudo trafic-agent upgrade"); process.exit(1); } // Guard against infinite re-exec loops: only re-exec once per upgrade run. - const alreadyReExeced = process.env["TRAFIC_UPGRADE_REEXEC"] === "1"; + const alreadyReExeced = io.alreadyReExeced; // ── Step 1: Check for updates ───────────────────────────────────────────── step("Check for updates"); - const current = __VERSION__; + const current = io.currentVersion; info(`Current version: ${current}`); - const latest = fetchLatestVersion(); + const latest = io.fetchLatestVersion(); if (!latest) { warn("Could not reach npm registry — skipping version check"); @@ -127,19 +167,19 @@ export function runUpgrade(dryRun = false, reExecArgs?: string[]): void { // ── Step 2: Install ─────────────────────────────────────────────────── step("Install latest version"); - installLatestAgent(dryRun); + io.installLatestAgent(dryRun); if (!dryRun) { // Verify the installed version actually changed before re-execing — // npm can serve stale cache and leave the old binary in place. - const installedVersion = getInstalledVersion(); + const installedVersion = io.getInstalledVersion(); if (!alreadyReExeced && installedVersion && isNewer(current, installedVersion)) { success(`Installed @studiometa/trafic-agent@${installedVersion}`); // Re-exec the newly installed binary so steps 3 and 4 run with the // new migration registry — the current process only knows about // migrations that existed at the time it was compiled. - reExecNewBinary(reExecArgs ?? ["upgrade"]); + io.reExecNewBinary(reExecArgs ?? ["upgrade"]); } else if (installedVersion) { success(`Installed @studiometa/trafic-agent@${installedVersion}`); } @@ -150,11 +190,11 @@ export function runUpgrade(dryRun = false, reExecArgs?: string[]): void { // ── Step 3: Run pending migrations ─────────────────────────────────────── step("Run pending migrations"); - runPendingMigrations(dryRun); + io.runPendingMigrations(dryRun); // ── Step 4: Restart service ─────────────────────────────────────────────── step("Restart trafic-agent service"); - restartAgentService(dryRun); + io.restartAgentService(dryRun); if (!dryRun) { success("Service restarted"); } diff --git a/packages/trafic-agent/test/helpers/fake-server-deps.ts b/packages/trafic-agent/test/helpers/fake-server-deps.ts new file mode 100644 index 0000000..2899cd3 --- /dev/null +++ b/packages/trafic-agent/test/helpers/fake-server-deps.ts @@ -0,0 +1,157 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { ServerDeps } from "../../src/server.js"; +import type { AuthConfig } from "../../src/types.js"; + +export interface Recorded { + accessed: string[]; + logged: { project: string; path: string; ip: string }[]; + statuses: [string, string][]; + started: string[]; +} + +export interface FakeDepsOptions { + auth?: Partial; + /** hostname -> project name */ + hostnames?: Record; + /** project name -> its own auth policy */ + projectPolicies?: Record; + /** project name -> the record the database holds */ + records?: Record; + /** What ddev describe reports */ + info?: Record; + /** Whether a start succeeds */ + startSucceeds?: boolean; +} + +export const defaultAuth: AuthConfig = { + defaultPolicy: "basic", + allowedIps: [], + tokens: [], + basicAuth: ["user:pass"], + rules: [], + trustedProxyHops: 1, +}; + +/** + * A ServerDeps that answers from a fixed world and records what happened. + * + * Injected rather than mocked so the handlers' real decisions run: which + * auth policy applies, whether a project is already starting, and what the + * waiting page is rendered for. + */ +export function createFakeDeps(options: FakeDepsOptions = {}): { + deps: ServerDeps; + recorded: Recorded; +} { + const { + auth = {}, + hostnames = {}, + projectPolicies = {}, + records = {}, + info = {}, + startSucceeds = true, + } = options; + + const recorded: Recorded = { + accessed: [], + logged: [], + statuses: [], + started: [], + }; + + const deps: ServerDeps = { + auth: { ...defaultAuth, ...auth }, + hostnameIndex: new Map(Object.entries(hostnames)), + projectConfigs: new Map( + Object.entries(projectPolicies).map(([name, policy]) => [ + name, + { auth_policy: policy }, + ]), + ), + loadTemplate: (name) => + `${name}:{{message}}{{project}}{{hostname}}`, + updateProjectAccess: (name) => void recorded.accessed.push(name), + logAccess: (log) => + void recorded.logged.push({ + project: log.project, + path: log.path, + ip: log.ip, + }), + getProject: (name) => { + const record = records[name]; + return record + ? { name, lastAccess: 0, status: record.status } + : undefined; + }, + setProjectStatus: (name, status) => + void recorded.statuses.push([name, status]), + startProject: (name) => { + recorded.started.push(name); + return Promise.resolve(startSucceeds); + }, + getProjectInfo: (name) => { + const found = info[name]; + return Promise.resolve( + found + ? { + name, + status: found.status, + appRoot: "/x", + httpURLs: [], + httpsURLs: [], + type: "wordpress", + } + : undefined, + ); + }, + }; + + return { deps, recorded }; +} + +export interface CapturedResponse { + res: ServerResponse; + status: () => number | undefined; + headers: () => Record; + body: () => string; +} + +/** A ServerResponse that records instead of writing to a socket. */ +export function captureResponse(): CapturedResponse { + let status: number | undefined; + let headers: Record = {}; + let body = ""; + + const res = { + writeHead(code: number, given?: Record) { + status = code; + headers = { ...headers, ...given }; + return res; + }, + end(chunk?: string) { + if (chunk) body += chunk; + return res; + }, + headersSent: false, + } as unknown as ServerResponse; + + return { + res, + status: () => status, + headers: () => headers, + body: () => body, + }; +} + +/** A request with the headers ddev-router would forward. */ +export function request( + headers: Record = {}, + url = "/", + socketIp = "172.18.0.5", +): IncomingMessage { + return { + url, + headers, + socket: { remoteAddress: socketIp }, + } as unknown as IncomingMessage; +} diff --git a/packages/trafic-agent/test/server-handlers.test.ts b/packages/trafic-agent/test/server-handlers.test.ts new file mode 100644 index 0000000..6ea3e4c --- /dev/null +++ b/packages/trafic-agent/test/server-handlers.test.ts @@ -0,0 +1,344 @@ +import { describe, it, expect } from "vitest"; +import { + handleAuth, + handleErrors, + handleStatus, + handleRequest, + getEffectiveAuthConfig, +} from "../src/server.js"; +import { + createFakeDeps, + captureResponse, + request, + defaultAuth, +} from "./helpers/fake-server-deps.js"; + +console.log = () => {}; +console.error = () => {}; + +const CREDENTIALS = `Basic ${Buffer.from("user:pass").toString("base64")}`; +const HOSTS = { "app.example.com": "app" }; + +describe("getEffectiveAuthConfig", () => { + it("uses the global policy for an unknown project", () => { + const { deps } = createFakeDeps(); + + expect(getEffectiveAuthConfig(undefined, deps).defaultPolicy).toBe("basic"); + }); + + it("uses the global policy when the project sets none", () => { + const { deps } = createFakeDeps({ hostnames: HOSTS }); + + expect(getEffectiveAuthConfig("app", deps).defaultPolicy).toBe("basic"); + }); + + it("lets a project override the policy", () => { + const { deps } = createFakeDeps({ projectPolicies: { app: "allow" } }); + + expect(getEffectiveAuthConfig("app", deps).defaultPolicy).toBe("allow"); + }); + + it("keeps the rest of the global config when overriding", () => { + const { deps } = createFakeDeps({ projectPolicies: { app: "allow" } }); + + // Only the policy is per-project; credentials stay server-wide + expect(getEffectiveAuthConfig("app", deps).basicAuth).toEqual( + defaultAuth.basicAuth, + ); + }); +}); + +describe("handleAuth", () => { + it("allows a request with valid credentials", () => { + const { deps, recorded } = createFakeDeps({ hostnames: HOSTS }); + const out = captureResponse(); + + handleAuth( + request({ "x-forwarded-host": "app.example.com", authorization: CREDENTIALS }), + out.res, + deps, + ); + + expect(out.status()).toBe(200); + expect(recorded.accessed).toEqual(["app"]); + }); + + it("challenges a request without credentials", () => { + const { deps } = createFakeDeps({ hostnames: HOSTS }); + const out = captureResponse(); + + handleAuth(request({ "x-forwarded-host": "app.example.com" }), out.res, deps); + + expect(out.status()).toBe(401); + // Without this the browser never shows a prompt + expect(out.headers()["WWW-Authenticate"]).toContain("Basic"); + }); + + it("records the path and client address of an allowed request", () => { + const { deps, recorded } = createFakeDeps({ hostnames: HOSTS }); + + handleAuth( + request({ + "x-forwarded-host": "app.example.com", + "x-forwarded-uri": "/wp/wp-admin/", + authorization: CREDENTIALS, + }), + captureResponse().res, + deps, + ); + + expect(recorded.logged).toEqual([ + { project: "app", path: "/wp/wp-admin/", ip: "172.18.0.5" }, + ]); + }); + + it("logs nothing for a hostname that is not a project", () => { + const { deps, recorded } = createFakeDeps({ hostnames: {} }); + + handleAuth( + request({ "x-forwarded-host": "nope.example.com", authorization: CREDENTIALS }), + captureResponse().res, + deps, + ); + + expect(recorded.accessed).toEqual([]); + expect(recorded.logged).toEqual([]); + }); + + it("applies a project's own policy", () => { + const { deps } = createFakeDeps({ + hostnames: HOSTS, + projectPolicies: { app: "allow" }, + }); + const out = captureResponse(); + + handleAuth(request({ "x-forwarded-host": "app.example.com" }), out.res, deps); + + // The project opted out of auth, so no credentials are needed + expect(out.status()).toBe(200); + }); +}); + +describe("handleErrors", () => { + it("shows the error page for an unknown hostname", async () => { + const { deps } = createFakeDeps({ hostnames: {} }); + const out = captureResponse(); + + await handleErrors(request({ "x-forwarded-host": "nope.example.com" }), out.res, deps); + + expect(out.status()).toBe(404); + expect(out.body()).toContain("error:"); + }); + + it("shows the waiting page and starts a stopped project", async () => { + const { deps, recorded } = createFakeDeps({ + hostnames: HOSTS, + records: { app: { status: "stopped" } }, + }); + const out = captureResponse(); + + await handleErrors(request({ "x-forwarded-host": "app.example.com" }), out.res, deps); + + expect(out.status()).toBe(503); + expect(out.headers()["Retry-After"]).toBe("5"); + expect(recorded.started).toEqual(["app"]); + }); + + it("marks the project starting before it responds", async () => { + const { deps, recorded } = createFakeDeps({ + hostnames: HOSTS, + records: { app: { status: "stopped" } }, + }); + + await handleErrors( + request({ "x-forwarded-host": "app.example.com" }), + captureResponse().res, + deps, + ); + + expect(recorded.statuses[0]).toEqual(["app", "starting"]); + }); + + it("does not start a project that is already starting", async () => { + const { deps, recorded } = createFakeDeps({ + hostnames: HOSTS, + records: { app: { status: "starting" } }, + }); + const out = captureResponse(); + + await handleErrors(request({ "x-forwarded-host": "app.example.com" }), out.res, deps); + + // Every request during a start would otherwise pile up another ddev start + expect(out.status()).toBe(503); + expect(recorded.started).toEqual([]); + }); + + it("records the project running once the start succeeds", async () => { + const { deps, recorded } = createFakeDeps({ + hostnames: HOSTS, + records: { app: { status: "stopped" } }, + startSucceeds: true, + }); + + await handleErrors( + request({ "x-forwarded-host": "app.example.com" }), + captureResponse().res, + deps, + ); + await new Promise((resolve) => setImmediate(resolve)); + + expect(recorded.statuses).toContainEqual(["app", "running"]); + }); + + it("records it stopped when the start fails, rather than leaving it starting", async () => { + // Leaving it "starting" forever would wedge the waiting page + const { deps, recorded } = createFakeDeps({ + hostnames: HOSTS, + records: { app: { status: "stopped" } }, + startSucceeds: false, + }); + + await handleErrors( + request({ "x-forwarded-host": "app.example.com" }), + captureResponse().res, + deps, + ); + await new Promise((resolve) => setImmediate(resolve)); + + expect(recorded.statuses).toContainEqual(["app", "stopped"]); + }); + + it("falls back to the Host header when there is no forwarded host", async () => { + const { deps, recorded } = createFakeDeps({ + hostnames: HOSTS, + records: { app: { status: "stopped" } }, + }); + + await handleErrors(request({ host: "app.example.com" }), captureResponse().res, deps); + + expect(recorded.started).toEqual(["app"]); + }); +}); + +describe("handleStatus", () => { + it("rejects a request with no project", async () => { + const { deps } = createFakeDeps(); + const out = captureResponse(); + + await handleStatus(request({}, "/__status__"), out.res, deps); + + expect(out.status()).toBe(400); + }); + + it("reports a running project as ready", async () => { + const { deps } = createFakeDeps({ info: { app: { status: "running" } } }); + const out = captureResponse(); + + await handleStatus(request({}, "/__status__?project=app"), out.res, deps); + + expect(JSON.parse(out.body())).toMatchObject({ status: "running", ready: true }); + }); + + it("is not ready while the project is still starting", async () => { + const { deps } = createFakeDeps({ info: { app: { status: "starting" } } }); + const out = captureResponse(); + + await handleStatus(request({}, "/__status__?project=app"), out.res, deps); + + expect(JSON.parse(out.body()).ready).toBe(false); + }); + + it("falls back to the recorded status when ddev cannot say", async () => { + const { deps } = createFakeDeps({ records: { app: { status: "starting" } } }); + const out = captureResponse(); + + await handleStatus(request({}, "/__status__?project=app"), out.res, deps); + + expect(JSON.parse(out.body()).status).toBe("starting"); + }); + + it("says unknown when neither source knows the project", async () => { + const { deps } = createFakeDeps(); + const out = captureResponse(); + + await handleStatus(request({}, "/__status__?project=ghost"), out.res, deps); + + expect(JSON.parse(out.body()).status).toBe("unknown"); + }); +}); + +describe("handleRequest routing", () => { + it("sends /__auth__ to the auth handler", async () => { + const { deps, recorded } = createFakeDeps({ hostnames: HOSTS }); + + await handleRequest( + request( + { "x-forwarded-host": "app.example.com", authorization: CREDENTIALS }, + "/__auth__", + ), + captureResponse().res, + deps, + ); + + expect(recorded.accessed).toEqual(["app"]); + }); + + it("sends /__auth__ with a query to the auth handler too", async () => { + // Routing on the raw target sent these to the waiting page instead, + // which answered 503 and started an already-running project + const { deps, recorded } = createFakeDeps({ hostnames: HOSTS }); + + await handleRequest( + request( + { "x-forwarded-host": "app.example.com", authorization: CREDENTIALS }, + "/__auth__?s=search+term", + ), + captureResponse().res, + deps, + ); + + expect(recorded.accessed).toEqual(["app"]); + }); + + it("answers /__health__ with the version", async () => { + const { deps } = createFakeDeps(); + const out = captureResponse(); + + await handleRequest(request({}, "/__health__"), out.res, deps); + + expect(out.status()).toBe(200); + expect(JSON.parse(out.body()).status).toBe("ok"); + }); + + it("sends anything else to the waiting page", async () => { + const { deps, recorded } = createFakeDeps({ + hostnames: HOSTS, + records: { app: { status: "stopped" } }, + }); + + await handleRequest( + request({ "x-forwarded-host": "app.example.com" }, "/some/page"), + captureResponse().res, + deps, + ); + + expect(recorded.started).toEqual(["app"]); + }); + + it("answers 500 rather than throwing when a handler fails", async () => { + const { deps } = createFakeDeps({ hostnames: HOSTS }); + const broken = { + ...deps, + hostnameIndex: { + get() { + throw new Error("index exploded"); + }, + } as unknown as Map, + }; + const out = captureResponse(); + + await handleRequest(request({}, "/__auth__"), out.res, broken); + + expect(out.status()).toBe(500); + }); +}); diff --git a/packages/trafic-agent/test/upgrade.test.ts b/packages/trafic-agent/test/upgrade.test.ts index 18423f3..295fbe2 100644 --- a/packages/trafic-agent/test/upgrade.test.ts +++ b/packages/trafic-agent/test/upgrade.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from "vitest"; -import { isNewer } from "../src/setup/upgrade.js"; +import { isNewer, runUpgrade, type UpgradeIo } from "../src/setup/upgrade.js"; + +// runUpgrade prints its progress; keep it out of the test output +console.log = () => {}; +console.error = () => {}; describe("isNewer", () => { it("returns true when major is greater", () => { @@ -35,3 +39,142 @@ describe("isNewer", () => { expect(isNewer("v0.1.13", "v0.1.13")).toBe(false); }); }); + +describe("runUpgrade", () => { + interface Recorded { + installed: boolean[]; + reExeced: string[][]; + migrated: boolean[]; + restarted: boolean[]; + } + + function fakeIo( + overrides: Partial = {}, + ): { io: UpgradeIo; recorded: Recorded } { + const recorded: Recorded = { + installed: [], + reExeced: [], + migrated: [], + restarted: [], + }; + + const io: UpgradeIo = { + currentVersion: "0.1.0", + alreadyReExeced: false, + isRoot: () => true, + fetchLatestVersion: () => "0.1.0", + installLatestAgent: (dryRun) => void recorded.installed.push(dryRun), + getInstalledVersion: () => "0.1.0", + reExecNewBinary: ((args: string[]) => { + recorded.reExeced.push(args); + // The real one replaces the process; stopping here is the closest + // a test can get without exiting the runner + throw new Error("re-exec"); + }) as UpgradeIo["reExecNewBinary"], + restartAgentService: (dryRun) => void recorded.restarted.push(dryRun), + runPendingMigrations: (dryRun) => void recorded.migrated.push(dryRun), + ...overrides, + }; + + return { io, recorded }; + } + + it("skips the install when already up to date", () => { + const { io, recorded } = fakeIo({ fetchLatestVersion: () => "0.1.0" }); + + runUpgrade(false, undefined, io); + + expect(recorded.installed).toEqual([]); + }); + + it("still runs migrations and restarts when up to date", () => { + // A release can add a migration without changing the agent the server + // already runs, so these must not be skipped alongside the install + const { io, recorded } = fakeIo(); + + runUpgrade(false, undefined, io); + + expect(recorded.migrated).toEqual([false]); + expect(recorded.restarted).toEqual([false]); + }); + + it("installs when a newer version is published", () => { + const { io, recorded } = fakeIo({ + fetchLatestVersion: () => "0.2.0", + getInstalledVersion: () => "0.1.0", + }); + + runUpgrade(false, undefined, io); + + expect(recorded.installed).toEqual([false]); + }); + + it("re-execs the new binary so it runs its own migrations", () => { + // The running process only knows the migrations it was compiled with + const { io, recorded } = fakeIo({ + fetchLatestVersion: () => "0.2.0", + getInstalledVersion: () => "0.2.0", + }); + + expect(() => runUpgrade(false, ["upgrade"], io)).toThrow("re-exec"); + expect(recorded.reExeced).toEqual([["upgrade"]]); + }); + + it("does not re-exec when npm left the old version in place", () => { + // npm can serve a stale cache; re-execing the same binary would loop + const { io, recorded } = fakeIo({ + fetchLatestVersion: () => "0.2.0", + getInstalledVersion: () => "0.1.0", + }); + + runUpgrade(false, undefined, io); + + expect(recorded.reExeced).toEqual([]); + expect(recorded.migrated).toEqual([false]); + }); + + it("does not re-exec twice", () => { + const { io, recorded } = fakeIo({ + alreadyReExeced: true, + fetchLatestVersion: () => "0.2.0", + getInstalledVersion: () => "0.2.0", + }); + + runUpgrade(false, undefined, io); + + expect(recorded.reExeced).toEqual([]); + }); + + it("carries on when the registry cannot be reached", () => { + // An unreachable registry must not stop migrations that are already due + const { io, recorded } = fakeIo({ fetchLatestVersion: () => null }); + + runUpgrade(false, undefined, io); + + expect(recorded.installed).toEqual([]); + expect(recorded.migrated).toEqual([false]); + expect(recorded.restarted).toEqual([false]); + }); + + it("never re-execs in dry-run mode", () => { + const { io, recorded } = fakeIo({ + fetchLatestVersion: () => "0.2.0", + getInstalledVersion: () => "0.2.0", + }); + + runUpgrade(true, undefined, io); + + expect(recorded.reExeced).toEqual([]); + expect(recorded.installed).toEqual([true]); + expect(recorded.migrated).toEqual([true]); + expect(recorded.restarted).toEqual([true]); + }); + + it("runs as a non-root dry-run without exiting", () => { + const { io, recorded } = fakeIo({ isRoot: () => false }); + + runUpgrade(true, undefined, io); + + expect(recorded.migrated).toEqual([true]); + }); +}); diff --git a/packages/trafic-agent/vite.config.ts b/packages/trafic-agent/vite.config.ts index 9565093..e5ae96d 100644 --- a/packages/trafic-agent/vite.config.ts +++ b/packages/trafic-agent/vite.config.ts @@ -9,6 +9,16 @@ export default defineConfig({ coverage: { // Test helpers are not production code exclude: ["test/**", "*.config.ts", "dist/**"], + // A floor, not a target. Set just under the current numbers so a drop + // fails CI while an improvement does not. Raise them when coverage + // rises — two untested parsers, loadProjectList and routePath, each + // hid a bug for nine releases, which is what this is here to prevent. + thresholds: { + statements: 75, + branches: 70, + functions: 70, + lines: 75, + }, }, }, define: { diff --git a/packages/trafic-cli/vite.config.ts b/packages/trafic-cli/vite.config.ts index d401d42..0508b6f 100644 --- a/packages/trafic-cli/vite.config.ts +++ b/packages/trafic-cli/vite.config.ts @@ -9,6 +9,14 @@ export default defineConfig({ coverage: { // Test helpers are not production code exclude: ["test/**", "*.config.ts", "dist/**"], + // A floor, not a target. Set just under the current numbers so a drop + // fails CI while an improvement does not. + thresholds: { + statements: 95, + branches: 92, + functions: 100, + lines: 95, + }, }, }, define: {