diff --git a/package.json b/package.json index 89b2633..ef280d2 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "hookified": "^3.0.4", "html-escaper": "^3.0.3", "keyv": "^6.1.0", + "mongodb": "7.7.0", "pino": "^10.4.0", "pino-pretty": "^13.2.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 546fc08..67f7932 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -199,6 +199,9 @@ importers: keyv: specifier: ^6.1.0 version: 6.1.0 + mongodb: + specifier: 7.7.0 + version: 7.7.0 pino: specifier: ^10.4.0 version: 10.4.0 diff --git a/site/docs/bins.md b/site/docs/bins.md index 0657832..b6a1a0f 100644 --- a/site/docs/bins.md +++ b/site/docs/bins.md @@ -52,6 +52,63 @@ curl http://localhost:3000/bins/abc123def456/requests | DELETE | `/bins/:id/requests` | Clear all captured requests in a bin | | DELETE | `/bins/:id` | Delete a bin | | ANY | `/b/:id` and `/b/:id/*` | Capture requests sent to a bin | +| GET | `/healthz` | Check whether bin storage is reachable | + +## Storage health + +`GET /healthz` is on the Node server and the Worker. This route does not create a bin. + +A memory store sends HTTP 200: + +```json +{ + "status": "ok", + "bins": { + "store": "memory", + "connected": true, + "latencyMs": 1, + "uriPresent": false, + "scheme": null + } +} +``` + +The Worker uses MongoDB when `MONGODB_URI` is set. The check opens a client, sends an admin ping, and then closes the client. The timeout is short. The check does not keep the client after a failure. + +A successful ping sends HTTP 200. `store` is `mongo`. `scheme` is `mongodb` or `mongodb+srv`. + +A failed ping sends HTTP 503: + +```json +{ + "status": "error", + "bins": { + "store": "mongo", + "connected": false, + "uriPresent": true, + "scheme": "mongodb+srv", + "error": { + "name": "MongoParseError", + "message": "Invalid connection string. Percent-encode reserved characters in the username and password (@ : / ? # [ ]).", + "code": null + } + } +} +``` + +The response does not contain the connection string, the username, the password, or the host. The logs do not contain them. `uriPresent` is a boolean. `scheme` is `mongodb`, `mongodb+srv`, or `null`. + +`Invalid connection string` means that the URI has a second `@`. It also means that you did not encode a reserved character in the username or the password. Encode these characters: + +- `@` as `%40` +- `:` as `%3A` +- `/` as `%2F` +- `?` as `%3F` +- `#` as `%23` +- `[` as `%5B` +- `]` as `%5D` + +A correct `mongodb+srv://` URI does not fail at parse time. A DNS failure or a network failure has a different error name. ## End-to-End Webhook Debugging Example diff --git a/site/docs/how-to-deploy/cloudflare.md b/site/docs/how-to-deploy/cloudflare.md index 54e552d..fb3268c 100644 --- a/site/docs/how-to-deploy/cloudflare.md +++ b/site/docs/how-to-deploy/cloudflare.md @@ -23,7 +23,13 @@ The Worker starts the same MockHTTP app as Node.js, with a few options set for t | `logging` | `false` | Request logging is off. | | `autoDetectPort` | `false` | The app always listens on port 3000 inside the isolate. | -The rate limiter key is the `cf-connecting-ip` header, then the first address in `x-forwarded-for`, then `unknown`. A limited request returns `429` with `retry-after: 60`. That limiter is shared across isolates. Bins are not. The default store is in memory inside the isolate that handled the request, so a bin created on one request can be missing when another isolate or location handles the next one. +The rate limiter key is the `cf-connecting-ip` header, then the first address in `x-forwarded-for`, then `unknown`. A limited request returns `429` with `retry-after: 60`. That limiter is shared across isolates. + +Bins stay in memory in the isolate when `MONGODB_URI` is not set. A bin from one request can be absent when a different isolate handles the next request. When `MONGODB_URI` is set, bins are stored in MongoDB. The client connects on the first bin operation. It does not connect while the isolate starts. + +Percent-encode reserved characters in the username and the password. `@` is `%40`. An unencoded `@` makes the driver throw `Invalid connection string` before it contacts Atlas. + +`GET /healthz` reports the bin store. A successful admin ping returns HTTP 200 and `bins.connected: true`. A failed ping returns HTTP 503 with the driver error name, a redacted message, and a code. The body has `uriPresent` (boolean) and `scheme` (`mongodb` or `mongodb+srv`). The body does not have the URI, the username, the password, or the host. On the Node server, this route reports the memory store. Connection failures are also one JSON line in the Worker logs (`mockhttp.bin-store`). Those logs use the same redaction. Taps are a library API (`mock.taps.inject`). They have no HTTP routes, so a deployed Worker has no way to inject one. diff --git a/site/docs/library.md b/site/docs/library.md index 5f23fb3..ab8f283 100644 --- a/site/docs/library.md +++ b/site/docs/library.md @@ -137,7 +137,7 @@ Register image routes (jpeg, png, svg, webp) with content negotiation support. #### `async registerBinRoutes(fastifyInstance?)` -Register the request bin routes — management at `/bins` and capture at `/b/:id`. +Register the request bin routes. Management is at `/bins` and capture is at `/b/:id`. `GET /healthz` reports whether bin storage is reachable. ## Bins (Request Capture) diff --git a/src/bin-store-diagnostics.ts b/src/bin-store-diagnostics.ts new file mode 100644 index 0000000..081a2aa --- /dev/null +++ b/src/bin-store-diagnostics.ts @@ -0,0 +1,281 @@ +/** + * Diagnostics for the bin store. + * Logs and HTTP bodies can show the error name and a safe message. + * They can show if the URI is present. + * They cannot show the URI, the username, the password, or the host. + */ + +export type BinStoreScheme = "mongodb" | "mongodb+srv"; + +export type BinStoreErrorInfo = { + name: string; + message: string; + code: string | number | null; +}; + +export type BinHealth = + | { + status: "ok"; + bins: { + store: "mongo" | "memory"; + connected: true; + latencyMs: number; + uriPresent: boolean; + scheme: BinStoreScheme | null; + }; + } + | { + status: "error"; + bins: { + store: "mongo" | "memory"; + connected: false; + uriPresent: boolean; + scheme: BinStoreScheme | null; + error: BinStoreErrorInfo; + }; + }; + +export type UriShape = { + whitespace: boolean; + wrappedQuotes: boolean; + extraAt: boolean; +}; + +const ENCODING_HINT = + "Percent-encode reserved characters in the username and password (@ : / ? # [ ])."; + +const loggedPhases = new WeakMap>(); + +/** Error whose message is already safe to return to clients. */ +export class BinStoreFailure extends Error { + readonly binStoreFailure = true; + + constructor(message: string, cause?: unknown) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "BinStoreFailure"; + } +} + +export function isBinStoreFailure(error: unknown): error is BinStoreFailure { + return ( + error instanceof BinStoreFailure || + (typeof error === "object" && + error !== null && + "binStoreFailure" in error && + (error as { binStoreFailure?: boolean }).binStoreFailure === true) + ); +} + +export function mongoUriFacts(uri: string | undefined): { + present: boolean; + scheme: BinStoreScheme | null; +} { + if (!uri) { + return { present: false, scheme: null }; + } + if (uri.startsWith("mongodb+srv://")) { + return { present: true, scheme: "mongodb+srv" }; + } + if (uri.startsWith("mongodb://")) { + return { present: true, scheme: "mongodb" }; + } + return { present: true, scheme: null }; +} + +/** + * Shape flags only. A normal URI has one `@` or zero `@`. + * A second `@` makes the driver throw `Invalid connection string` before the network call. + */ +export function uriShape(uri: string): UriShape { + return { + whitespace: /\s/.test(uri), + wrappedQuotes: + uri.startsWith('"') || + uri.startsWith("'") || + uri.endsWith('"') || + uri.endsWith("'"), + extraAt: uri.split("@").length - 1 > 1, + }; +} + +export function isBinStoreConnectionFailure(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + if (/^Mongo(Parse|Network|ServerSelection|API)/.test(error.name)) { + return true; + } + const code = + "code" in error && (error as { code?: unknown }).code != null + ? String((error as { code?: unknown }).code) + : ""; + if ( + code === "ENOTFOUND" || + code === "ECONNREFUSED" || + code === "ETIMEDOUT" || + code === "EAI_AGAIN" || + code === "ENETUNREACH" + ) { + return true; + } + return /invalid connection string|unescaped characters|querySrv|server selection|proxy request failed|ECONNREFUSED|ENOTFOUND/i.test( + error.message, + ); +} + +function replaceSecret(message: string, secret: string): string { + if (secret.length < 3) { + return message; + } + // Short alphabetic tokens are substrings of words like "username". + if (/^[a-z]+$/i.test(secret) && secret.length < 8) { + return message; + } + return message.split(secret).join("[redacted]"); +} + +function sensitiveParts(uri: string): string[] { + const parts: string[] = []; + const body = uri.replace(/^mongodb(?:\+srv)?:\/\//i, ""); + const at = body.lastIndexOf("@"); + if (at >= 0) { + const userinfo = body.slice(0, at); + const colon = userinfo.indexOf(":"); + if (colon >= 0) { + parts.push(userinfo.slice(0, colon), userinfo.slice(colon + 1)); + } else if (userinfo) { + parts.push(userinfo); + } + const host = body.slice(at + 1).split(/[/?]/)[0] ?? ""; + const hostname = host.split(":")[0] ?? ""; + if (hostname) { + parts.push(hostname); + } + } else { + const host = body.split(/[/?]/)[0] ?? ""; + const hostname = host.split(":")[0] ?? ""; + if (hostname) { + parts.push(hostname); + } + } + return parts; +} + +export function redactSecrets(message: string, uri?: string): string { + let out = message; + if (uri) { + out = out.split(uri).join("[redacted-uri]"); + for (const part of sensitiveParts(uri)) { + out = replaceSecret(out, part); + } + } + out = out.replace(/mongodb(?:\+srv)?:\/\/\S+/gi, "[redacted-uri]"); + out = out.replace(/\/\/([^/\s:@]+):([^/\s@]+)@/g, "//[redacted]:[redacted]@"); + return out; +} + +function withEncodingHint( + message: string, + shape: UriShape | undefined, +): string { + const needsHint = + shape?.extraAt === true || + /invalid connection string|unescaped characters/i.test(message); + if (!needsHint || message.includes("Percent-encode")) { + return message; + } + return `${message}. ${ENCODING_HINT}`; +} + +export function redactStoreError( + error: unknown, + uri?: string, +): BinStoreErrorInfo { + const name = error instanceof Error && error.name ? error.name : "Error"; + const raw = + error instanceof Error ? error.message : "Bin store request failed"; + const codeValue = + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: unknown }).code != null + ? (error as { code?: unknown }).code + : null; + const code = + typeof codeValue === "string" || typeof codeValue === "number" + ? codeValue + : null; + const shape = uri ? uriShape(uri) : undefined; + return { + name, + message: withEncodingHint(redactSecrets(raw, uri), shape), + code, + }; +} + +export function toSafeStoreError( + error: unknown, + uri?: string, +): BinStoreFailure { + const info = redactStoreError(error, uri); + return new BinStoreFailure(info.message); +} + +type LogBinStoreInput = { + phase: string; + store: string; + uri?: string; + error?: unknown; + level?: "error" | "info"; + connected?: boolean; + latencyMs?: number; +}; + +function alreadyLogged(error: unknown, phase: string): boolean { + if (typeof error !== "object" || error === null) { + return false; + } + let phases = loggedPhases.get(error); + if (!phases) { + phases = new Set(); + loggedPhases.set(error, phases); + } + if (phases.has(phase)) { + return true; + } + phases.add(phase); + return false; +} + +/** One JSON line. Safe to print in Workers logs. */ +export function logBinStore(input: LogBinStoreInput): void { + if (input.error !== undefined && alreadyLogged(input.error, input.phase)) { + return; + } + const facts = mongoUriFacts(input.uri); + const payload: Record = { + source: "mockhttp.bin-store", + phase: input.phase, + store: input.store, + uriPresent: facts.present, + scheme: facts.scheme, + }; + if (input.uri) { + payload.uriShape = uriShape(input.uri); + } + if (input.connected !== undefined) { + payload.connected = input.connected; + } + if (input.latencyMs !== undefined) { + payload.latencyMs = input.latencyMs; + } + if (input.error !== undefined) { + payload.error = redactStoreError(input.error, input.uri); + } + const line = JSON.stringify(payload); + if (input.level === "info") { + console.info(line); + return; + } + console.error(line); +} diff --git a/src/mock-http-bins.ts b/src/mock-http-bins.ts index e640d22..630981c 100644 --- a/src/mock-http-bins.ts +++ b/src/mock-http-bins.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import Keyv from "keyv"; import type { Bin, CapturedRequest } from "./bin-store.js"; +import { BinStoreFailure, isBinStoreFailure } from "./bin-store-diagnostics.js"; /** * One bin and its captured requests, stored as a single Keyv value. @@ -226,6 +227,9 @@ export class MockHttpBins { this.ttlFor(doc.bin), ); } catch (cause) { + if (isBinStoreFailure(cause)) { + throw new BinStoreFailure(`${message}. ${cause.message}`, cause); + } throw new Error(message, { cause }); } if (!saved) { diff --git a/src/mock-http.ts b/src/mock-http.ts index 9ef4e69..36798f4 100644 --- a/src/mock-http.ts +++ b/src/mock-http.ts @@ -12,6 +12,7 @@ import { fastifySwagger } from "@fastify/swagger"; import { detect } from "detect-port"; import Fastify, { type FastifyInstance, type FastifyRequest } from "fastify"; import { Hookified, type HookifiedOptions } from "hookified"; +import type { BinHealth } from "./bin-store-diagnostics.js"; import { type CertificateOptions, generateCertificate } from "./certificate.js"; import { getFastifyConfig } from "./fastify-config.js"; import { MockHttpBins } from "./mock-http-bins.js"; @@ -40,6 +41,7 @@ import { streamRoute, uuidRoute, } from "./routes/dynamic-data/index.js"; +import { checkMemoryBinStore, healthRoute } from "./routes/health.js"; import { deleteRoute, getRoute, @@ -237,6 +239,7 @@ export class MockHttp extends Hookified { private _server: FastifyInstance | undefined; private _taps: TapManager = new TapManager(); private _bins: MockHttpBins = new MockHttpBins(); + private _binHealthCheck?: () => Promise; constructor(options?: MockHttpOptions) { super(options?.hookOptions); @@ -588,6 +591,22 @@ export class MockHttp extends Hookified { this._bins = bins; } + /** + * Override the bin storage probe used by `GET /healthz`. + * The default probe reads the in-memory Keyv. + */ + public set binHealthCheck(check: () => Promise) { + this._binHealthCheck = check; + } + + /** Whether bin storage can serve a read or a MongoDB ping. */ + public checkBinStore(): Promise { + if (this._binHealthCheck) { + return this._binHealthCheck(); + } + return checkMemoryBinStore(this._bins.keyv); + } + /** * Register plugins and routes without listening. Use `inject()` against * `server`, or call `start()` to bind a port. @@ -695,6 +714,8 @@ export class MockHttp extends Hookified { await this.registerSwagger(); } + await this.server.register(healthRoute(() => this.checkBinStore())); + const { httpMethods, redirects, diff --git a/src/routes/health.ts b/src/routes/health.ts new file mode 100644 index 0000000..ecf69c0 --- /dev/null +++ b/src/routes/health.ts @@ -0,0 +1,119 @@ +import type { FastifyInstance, FastifyReply, FastifySchema } from "fastify"; +import { + type BinHealth, + logBinStore, + redactStoreError, +} from "../bin-store-diagnostics.js"; + +/** Cheap read against the in-memory Keyv used when MongoDB is not configured. */ +export async function checkMemoryBinStore(keyv: { + get(key: string): Promise; +}): Promise { + const started = Date.now(); + try { + await keyv.get("__mockhttp_healthz__"); + return { + status: "ok", + bins: { + store: "memory", + connected: true, + latencyMs: Math.max(0, Date.now() - started), + uriPresent: false, + scheme: null, + }, + }; + } catch (error) { + logBinStore({ phase: "healthz", store: "memory", error }); + return { + status: "error", + bins: { + store: "memory", + connected: false, + uriPresent: false, + scheme: null, + error: redactStoreError(error), + }, + }; + } +} + +const binHealthFields = { + store: { type: "string", enum: ["mongo", "memory"] }, + connected: { type: "boolean" }, + latencyMs: { type: "integer" }, + uriPresent: { type: "boolean" }, + scheme: { type: ["string", "null"], enum: ["mongodb", "mongodb+srv", null] }, +}; + +const healthSchema: FastifySchema = { + description: + "Report whether bin storage is reachable. MongoDB failures include the driver error with credentials removed. The URI, username, password, and host are never returned.", + tags: ["Health"], + response: { + 200: { + type: "object", + properties: { + status: { type: "string", enum: ["ok"] }, + bins: { + type: "object", + properties: binHealthFields, + required: ["store", "connected", "latencyMs", "uriPresent", "scheme"], + }, + }, + required: ["status", "bins"], + }, + 503: { + type: "object", + properties: { + status: { type: "string", enum: ["error"] }, + bins: { + type: "object", + properties: { + ...binHealthFields, + error: { + type: "object", + properties: { + name: { type: "string" }, + message: { type: "string" }, + code: { type: ["string", "number", "null"] }, + }, + required: ["name", "message", "code"], + }, + }, + required: ["store", "connected", "uriPresent", "scheme", "error"], + }, + }, + required: ["status", "bins"], + }, + }, +}; + +export const healthRoute = + (check: () => Promise) => (fastify: FastifyInstance) => { + fastify.get( + "/healthz", + { schema: healthSchema }, + async (_request, reply: FastifyReply) => { + try { + const report = await check(); + if (report.status === "error") { + return reply.status(503).send(report); + } + return report; + } catch (error) { + logBinStore({ phase: "healthz", store: "unknown", error }); + const info = redactStoreError(error); + return reply.status(503).send({ + status: "error", + bins: { + store: "mongo", + connected: false, + uriPresent: false, + scheme: null, + error: info, + }, + }); + } + }, + ); + }; diff --git a/test/bin-store-diagnostics.test.ts b/test/bin-store-diagnostics.test.ts new file mode 100644 index 0000000..e2df04a --- /dev/null +++ b/test/bin-store-diagnostics.test.ts @@ -0,0 +1,180 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + isBinStoreConnectionFailure, + isBinStoreFailure, + logBinStore, + mongoUriFacts, + redactSecrets, + redactStoreError, + toSafeStoreError, + uriShape, +} from "../src/bin-store-diagnostics.js"; + +const uri = "mongodb+srv://user:p@ssw!rd@cluster0.abcde.mongodb.net/mockhttp"; + +describe("bin store diagnostics", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("classifies URI schemes without echoing the value", () => { + expect(mongoUriFacts(undefined)).toEqual({ present: false, scheme: null }); + expect(mongoUriFacts("")).toEqual({ present: false, scheme: null }); + expect(mongoUriFacts("mongodb+srv://user:pass@host/db").scheme).toBe( + "mongodb+srv", + ); + expect(mongoUriFacts("mongodb://user:pass@host/db").scheme).toBe("mongodb"); + expect(mongoUriFacts("http://example").scheme).toBeNull(); + expect(mongoUriFacts("http://example").present).toBe(true); + }); + + it("flags whitespace, quotes, and a second @", () => { + expect(uriShape("mongodb://user:pass@host/db")).toEqual({ + whitespace: false, + wrappedQuotes: false, + extraAt: false, + }); + expect(uriShape("mongodb://user:pass@host/db\n").whitespace).toBe(true); + expect(uriShape('"mongodb://user:pass@host/db"').wrappedQuotes).toBe(true); + expect(uriShape("'mongodb://user:pass@host/db").wrappedQuotes).toBe(true); + expect(uriShape(uri).extraAt).toBe(true); + }); + + it("recognizes connection failures and ignores ordinary errors", () => { + expect(isBinStoreConnectionFailure("nope")).toBe(false); + expect( + isBinStoreConnectionFailure( + Object.assign(new Error("Invalid connection string"), { + name: "MongoParseError", + }), + ), + ).toBe(true); + expect( + isBinStoreConnectionFailure( + Object.assign(new Error("reset"), { name: "MongoNetworkError" }), + ), + ).toBe(true); + const coded = Object.assign(new Error("down"), { code: "ECONNREFUSED" }); + expect(isBinStoreConnectionFailure(coded)).toBe(true); + expect( + isBinStoreConnectionFailure( + Object.assign(new Error("querySrv ENOTFOUND"), { code: "ENOTFOUND" }), + ), + ).toBe(true); + for (const code of ["ETIMEDOUT", "EAI_AGAIN", "ENETUNREACH"]) { + expect( + isBinStoreConnectionFailure(Object.assign(new Error("x"), { code })), + ).toBe(true); + } + expect( + isBinStoreConnectionFailure( + Object.assign(new Error("duplicate key"), { name: "MongoServerError" }), + ), + ).toBe(false); + expect( + isBinStoreConnectionFailure( + Object.assign(new Error("proxy request failed"), { name: "Error" }), + ), + ).toBe(true); + }); + + it("redacts credentials, hosts, and connection strings", () => { + const message = `failed ${uri} Username contains unescaped characters user`; + const redacted = redactSecrets(message, uri); + expect(redacted).not.toContain("p@ssw"); + expect(redacted).not.toContain("cluster0"); + expect(redacted).not.toContain("mongodb+srv://"); + expect(redacted).toContain("Username contains unescaped characters"); + expect(redactSecrets("see mongodb://other:secret@db.example/app")).toBe( + "see [redacted-uri]", + ); + expect(redactSecrets("auth //other:secret@host left alone")).toContain( + "//[redacted]:[redacted]@", + ); + const plain = "mongodb://localhost/mockhttp"; + expect(redactSecrets(`down ${plain}`, plain)).not.toContain("localhost"); + expect( + redactSecrets("ab and x", "mongodb://ab:x@host.example/db"), + ).toContain("ab"); + expect( + redactSecrets("onlyuser at host", "mongodb://onlyuser@host.example/db"), + ).not.toContain("onlyuser"); + }); + + it("adds a percent-encoding hint for parser failures", () => { + const info = redactStoreError( + Object.assign(new Error("Invalid connection string"), { + name: "MongoParseError", + code: "PARSE", + }), + uri, + ); + expect(info).toMatchObject({ + name: "MongoParseError", + code: "PARSE", + }); + expect(info.message).toContain("Percent-encode"); + expect(info.message).not.toContain("p@ssw"); + const again = redactStoreError( + new Error(`${info.message}`), + "mongodb://user:pass@host/db", + ); + expect(again.message.split("Percent-encode")).toHaveLength(2); + + const unnamed = new Error("Password contains unescaped characters"); + unnamed.name = ""; + expect(redactStoreError(unnamed).name).toBe("Error"); + expect(redactStoreError(unnamed).message).toContain("Percent-encode"); + expect(redactStoreError("secret").message).toBe("Bin store request failed"); + const numeric = Object.assign(new Error("timeout"), { code: 50 }); + expect(redactStoreError(numeric).code).toBe(50); + const weird = Object.assign(new Error("timeout"), { code: { n: 1 } }); + expect(redactStoreError(weird).code).toBeNull(); + }); + + it("marks safe store errors and logs each phase once", () => { + const safe = toSafeStoreError(new Error("Invalid connection string"), uri); + expect(isBinStoreFailure(safe)).toBe(true); + expect(isBinStoreFailure({ binStoreFailure: true })).toBe(true); + expect(isBinStoreFailure(new Error("no"))).toBe(false); + expect(safe.message).toContain("Percent-encode"); + expect(safe.message).not.toContain(uri); + + const errorLog = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const infoLog = vi + .spyOn(console, "info") + .mockImplementation(() => undefined); + const cause = new Error(`boom ${uri}`); + logBinStore({ phase: "keyv.get", store: "keyv-mongo", uri, error: cause }); + logBinStore({ phase: "keyv.get", store: "keyv-mongo", uri, error: cause }); + logBinStore({ + phase: "keyv.connect", + store: "keyv-mongo", + uri, + error: cause, + connected: false, + latencyMs: 4, + }); + logBinStore({ + phase: "keyv.connect", + store: "keyv-mongo", + uri, + level: "info", + connected: true, + latencyMs: 3, + }); + logBinStore({ phase: "healthz", store: "memory", error: "x" }); + expect(errorLog).toHaveBeenCalledTimes(3); + expect(infoLog).toHaveBeenCalledTimes(1); + const logged = [...errorLog.mock.calls, ...infoLog.mock.calls] + .map((call) => String(call[0])) + .join("\n"); + expect(logged).toContain("mockhttp.bin-store"); + expect(logged).toContain("extraAt"); + expect(logged).not.toContain(uri); + expect(logged).not.toContain("p@ssw"); + expect(logged).not.toContain("cluster0"); + }); +}); diff --git a/test/bin-store-guard.test.ts b/test/bin-store-guard.test.ts new file mode 100644 index 0000000..0848727 --- /dev/null +++ b/test/bin-store-guard.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { guardMongoStore } from "../worker/bin-store.js"; + +describe("guardMongoStore", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("logs Keyv failures, redacts them, and drops a broken client once", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const uri = "mongodb://user:p@ssw!rd@cluster0.abcde.mongodb.net/mockhttp"; + const resets: object[] = []; + let disconnected = 0; + const store = { + url: "kept", + label() { + return "bound"; + }, + async set() { + return false; + }, + async delete() { + throw Object.assign(new Error(`Invalid connection string ${uri}`), { + name: "MongoParseError", + }); + }, + async *iterator(): AsyncGenerator { + if (disconnected >= 0) { + throw new Error("iterator exploded"); + } + yield undefined; + }, + async disconnect() { + disconnected += 1; + }, + }; + const guarded = guardMongoStore(store, uri, () => { + resets.push(store); + }) as typeof store; + + expect(guarded.url).toBe("kept"); + expect(guarded.label()).toBe("bound"); + await expect(guarded.set()).resolves.toBe(false); + await expect(guarded.delete()).rejects.toThrow(/Percent-encode/); + await expect(guarded.iterator().next()).rejects.toThrow( + "iterator exploded", + ); + await guarded.disconnect(); + expect(disconnected).toBe(1); + store.disconnect = async () => { + throw new Error("disconnect failed"); + }; + await expect(guarded.disconnect()).rejects.toThrow("disconnect failed"); + expect(resets).toHaveLength(1); + expect(console.error).toHaveBeenCalled(); + const logged = vi.mocked(console.error).mock.calls.join("\n"); + expect(logged).not.toContain("p@ssw"); + expect(logged).not.toContain("cluster0"); + }); +}); diff --git a/test/bin-store-ping.test.ts b/test/bin-store-ping.test.ts new file mode 100644 index 0000000..6882827 --- /dev/null +++ b/test/bin-store-ping.test.ts @@ -0,0 +1,123 @@ +import net from "node:net"; +import { MongoClient } from "mongodb"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + binStoreRuntime, + installSocketProxyGuard, +} from "../worker/bin-store.js"; + +describe("mongo bin store ping", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("does not treat a well-formed mongodb+srv URI as invalid", async () => { + const client = new MongoClient( + "mongodb+srv://user:pass@cluster0.example.invalid/mockhttp", + { serverSelectionTimeoutMS: 200 }, + ); + expect(client.options.srvHost).toBeTruthy(); + await client.close(); + }); + + it("reports an unencoded @ as an invalid connection string", async () => { + const errorLog = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const uri = "mongodb+srv://user:p@ssw!rd@cluster0.example.invalid/mockhttp"; + const report = await binStoreRuntime.ping(uri); + expect(report.status).toBe("error"); + if (report.status === "error") { + expect(report.bins.store).toBe("mongo"); + expect(report.bins.connected).toBe(false); + expect(report.bins.uriPresent).toBe(true); + expect(report.bins.scheme).toBe("mongodb+srv"); + expect(report.bins.error.name).toBe("MongoParseError"); + expect(report.bins.error.message).toContain("Invalid connection string"); + expect(report.bins.error.message).toContain("Percent-encode"); + } + const body = JSON.stringify(report); + expect(body).not.toContain("p@ssw"); + expect(body).not.toContain("cluster0"); + expect(body).not.toContain(uri); + expect(errorLog.mock.calls.join("\n")).not.toContain("p@ssw"); + expect(errorLog.mock.calls.join("\n")).not.toContain("cluster0"); + }); + + it("returns a successful ping and logs a close failure", async () => { + const errorLog = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + vi.spyOn(MongoClient.prototype, "db").mockReturnValue({ + command: vi.fn().mockResolvedValue({ ok: 1 }), + } as never); + vi.spyOn(MongoClient.prototype, "close").mockRejectedValue( + new Error("close failed mongodb://user:pass@cluster0.example.invalid/db"), + ); + const report = await binStoreRuntime.ping( + "mongodb://user:pass@cluster0.example.invalid:27017/mockhttp", + ); + expect(report).toMatchObject({ + status: "ok", + bins: { + store: "mongo", + connected: true, + uriPresent: true, + scheme: "mongodb", + }, + }); + if (report.status === "ok") { + expect(report.bins.latencyMs).toEqual(expect.any(Number)); + } + expect(errorLog.mock.calls.join("\n")).toContain("healthz.close"); + expect(errorLog.mock.calls.join("\n")).not.toContain("cluster0"); + expect(errorLog.mock.calls.join("\n")).not.toContain("user:pass"); + }); + + it("returns a redacted connect failure", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(MongoClient.prototype, "db").mockReturnValue({ + command: vi + .fn() + .mockRejectedValue( + Object.assign( + new Error( + "querySrv ENOTFOUND _mongodb._tcp.cluster0.example.invalid", + ), + { code: "ENOTFOUND" }, + ), + ), + } as never); + vi.spyOn(MongoClient.prototype, "close").mockResolvedValue(); + const report = await binStoreRuntime.ping( + "mongodb+srv://user:pass@cluster0.example.invalid/mockhttp", + ); + expect(report.status).toBe("error"); + if (report.status === "error") { + expect(report.bins.error.code).toBe("ENOTFOUND"); + expect(report.bins.error.message).toContain("querySrv"); + expect(report.bins.error.message).not.toContain("cluster0"); + } + }); + + it("keeps a second socket proxy error inside the process", () => { + installSocketProxyGuard(); + const socket = new net.Socket(); + const proxy = new Error( + "proxy request failed, cannot connect to the specified address", + ); + expect(() => { + socket.emit("error", proxy); + }).not.toThrow(); + expect(() => { + socket.emit("error", new Error("other failure")); + }).toThrow(/other failure/); + const seen: unknown[] = []; + socket.on("error", (error: unknown) => { + seen.push(error); + }); + socket.emit("error", proxy); + expect(seen).toEqual([proxy]); + socket.destroy(); + }); +}); diff --git a/test/mock-http-bins.test.ts b/test/mock-http-bins.test.ts index 8dc4274..5a52eb5 100644 --- a/test/mock-http-bins.test.ts +++ b/test/mock-http-bins.test.ts @@ -1,6 +1,7 @@ import Keyv from "keyv"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { CapturedRequest } from "../src/bin-store.js"; +import { BinStoreFailure } from "../src/bin-store-diagnostics.js"; import { MockHttpBins } from "../src/mock-http-bins.js"; const RAW: Omit = { @@ -292,6 +293,21 @@ describe("MockHttpBins", () => { ); }); + it("includes a safe store message when the Keyv write fails", async () => { + const keyv = new Keyv({ store: new Map() }); + vi.spyOn(keyv, "set").mockRejectedValue( + new BinStoreFailure( + "Invalid connection string. Percent-encode reserved characters in the username and password (@ : / ? # [ ]).", + ), + ); + const failing = new MockHttpBins({ keyv }); + const error = await failing.createBin().catch((cause) => cause); + expect(error).toBeInstanceOf(BinStoreFailure); + expect(error.message).toMatch(/^MockHttpBins: could not save bin /); + expect(error.message).toContain("Percent-encode"); + expect(error.cause).toBeInstanceOf(BinStoreFailure); + }); + it("wraps the store error when no error listener is attached", async () => { const keyv = new Keyv({ store: new Map() }); vi.spyOn(keyv.store, "set").mockRejectedValue(new Error("store down")); diff --git a/test/routes/health.test.ts b/test/routes/health.test.ts new file mode 100644 index 0000000..52ffecb --- /dev/null +++ b/test/routes/health.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { MockHttp } from "../../src/mock-http.js"; + +describe("GET /healthz", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("reports the in-memory bin store on the Node server", async () => { + const mock = new MockHttp({ logging: false, rateLimit: false }); + await mock.initialize(); + const health = await mock.server.inject({ method: "GET", url: "/healthz" }); + expect(health.statusCode).toBe(200); + expect(health.json()).toMatchObject({ + status: "ok", + bins: { + store: "memory", + connected: true, + uriPresent: false, + scheme: null, + }, + }); + expect(health.json().bins.latencyMs).toEqual(expect.any(Number)); + const spec = mock.server.swagger() as { paths: Record }; + expect(spec.paths["/healthz"]).toBeDefined(); + await mock.close(); + }); + + it("returns 503 when the memory store read fails", async () => { + const errorLog = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const mock = new MockHttp({ logging: false, rateLimit: false }); + await mock.initialize(); + vi.spyOn(mock.bins.keyv, "get").mockRejectedValue( + new Error("memory down mongodb://user:pass@secret-host.example/db"), + ); + const health = await mock.server.inject({ method: "GET", url: "/healthz" }); + expect(health.statusCode).toBe(503); + expect(health.json()).toMatchObject({ + status: "error", + bins: { + store: "memory", + connected: false, + uriPresent: false, + scheme: null, + error: { name: "Error" }, + }, + }); + expect(health.body).not.toContain("user:pass"); + expect(health.body).not.toContain("secret-host"); + expect(errorLog.mock.calls.join("\n")).toContain("healthz"); + await mock.close(); + }); + + it("redacts an unexpected probe failure", async () => { + const mock = new MockHttp({ logging: false, rateLimit: false }); + mock.binHealthCheck = () => { + throw new Error("boom mongodb://user:pass@secret-host.example/db"); + }; + await mock.initialize(); + const health = await mock.server.inject({ method: "GET", url: "/healthz" }); + expect(health.statusCode).toBe(503); + expect(health.json().bins.error.message).toBe("boom [redacted-uri]"); + expect(health.body).not.toContain("user:pass"); + expect(health.body).not.toContain("secret-host"); + await mock.close(); + }); +}); diff --git a/test/worker-runtime.test.ts b/test/worker-runtime.test.ts index feb0e81..27574b9 100644 --- a/test/worker-runtime.test.ts +++ b/test/worker-runtime.test.ts @@ -116,12 +116,75 @@ describe("worker mongo connect under workerd", () => { const get = await worker.fetch("https://mockhttp.org/get"); expect(get.status, await textOf(get)).toBe(200); + const health = await worker.fetch("https://mockhttp.org/healthz"); + const healthBody = await health.text(); + expect(health.status, healthBody).toBe(503); + expect(healthBody).toContain('"store":"mongo"'); + expect(healthBody).toContain('"connected":false'); + expect(healthBody).toContain('"scheme":"mongodb"'); + expect(healthBody).toContain('"uriPresent":true'); + expect(healthBody).not.toContain("Invalid connection string"); + expect(healthBody).not.toContain("127.0.0.1"); + expect(healthBody).not.toContain("mongodb://"); + const created = await worker.fetch("https://mockhttp.org/bins", { method: "POST", }); const body = await created.text(); expect(created.status, body).toBe(500); expect(body).not.toContain("Code generation from strings"); + expect(body).not.toContain("127.0.0.1"); + expect(body).not.toContain("mongodb://"); + }, 60_000); +}); + +describe("worker srv and unencoded password under workerd", () => { + it("reports a DNS error for a well-formed mongodb+srv URI", async () => { + const worker = await unstable_dev("worker/index.ts", { + ...devOptions, + port: 18790, + inspectorPort: 19232, + vars: { + MONGODB_URI: + "mongodb+srv://user:pass@cluster0.example.invalid/mockhttp", + }, + }); + try { + const health = await worker.fetch("https://mockhttp.org/healthz"); + const body = await health.text(); + expect(health.status, body).toBe(503); + expect(body).toContain('"scheme":"mongodb+srv"'); + expect(body).not.toContain("Invalid connection string"); + expect(body).not.toContain("user:pass"); + expect(body).not.toContain("cluster0"); + expect(body).not.toContain("mongodb+srv://"); + } finally { + await worker.stop(); + } + }, 60_000); + + it("explains an unencoded @ without returning the password", async () => { + const worker = await unstable_dev("worker/index.ts", { + ...devOptions, + port: 18792, + inspectorPort: 19234, + vars: { + MONGODB_URI: + "mongodb+srv://user:p@ssw!rd@cluster0.example.invalid/mockhttp", + }, + }); + try { + const health = await worker.fetch("https://mockhttp.org/healthz"); + const body = await health.text(); + expect(health.status, body).toBe(503); + expect(body).toContain("Invalid connection string"); + expect(body).toContain("Percent-encode"); + expect(body).not.toContain("p@ssw"); + expect(body).not.toContain("cluster0"); + expect(body).not.toContain("mongodb+srv://"); + } finally { + await worker.stop(); + } }, 60_000); }); diff --git a/test/worker.test.ts b/test/worker.test.ts index 3597012..573ea8d 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -12,6 +12,9 @@ const { handleAsNodeRequestMock, KeyvMongoMock } = vi.hoisted(() => { url?: string | Record, options?: Record, ) { + if (KeyvMongoMock.failConstruct) { + throw KeyvMongoMock.failConstruct; + } if (typeof url === "string") { this.url = url; this.options = options; @@ -20,13 +23,49 @@ const { handleAsNodeRequestMock, KeyvMongoMock } = vi.hoisted(() => { this.options = url; } KeyvMongoMock.instances.push(this); + if (KeyvMongoMock.failConnect) { + this.connect = Promise.reject(KeyvMongoMock.failConnect); + void this.connect.catch(() => undefined); + } else if (KeyvMongoMock.resolveConnect) { + this.connect = Promise.resolve("ok"); + } + if (KeyvMongoMock.useDisconnect) { + this.disconnect = async () => { + KeyvMongoMock.disconnects += 1; + if (KeyvMongoMock.failDisconnect) { + throw new Error("disconnect failed"); + } + }; + } + if (KeyvMongoMock.useOn) { + const listeners: Array<(error: unknown) => void> = []; + this.on = (event, handler) => { + if (event === "error") { + listeners.push(handler); + } + }; + this.emitError = (error) => { + for (const listener of listeners) { + listener(error); + } + }; + } } async get(key: string): Promise { + if (KeyvMongoMock.failGet) { + throw KeyvMongoMock.failGet; + } return this.data.get(key); } async set(key: string, value: unknown): Promise { + if (KeyvMongoMock.failSet) { + throw KeyvMongoMock.failSet; + } + if (KeyvMongoMock.failSetFalse) { + return false; + } this.data.set(key, value); return true; } @@ -40,10 +79,30 @@ const { handleAsNodeRequestMock, KeyvMongoMock } = vi.hoisted(() => { } async *iterator(): AsyncGenerator<[string, unknown]> { + if (KeyvMongoMock.failIterator) { + throw KeyvMongoMock.failIterator; + } for (const entry of this.data) { yield entry; } } + + static failConstruct: Error | undefined; + static failGet: Error | undefined; + static failSet: Error | undefined; + static failSetFalse = false; + static failIterator: Error | undefined; + static failConnect: Error | undefined; + static resolveConnect = false; + static useOn = false; + static useDisconnect = true; + static failDisconnect = false; + static disconnects = 0; + + connect?: Promise; + on?: (event: string, handler: (error: unknown) => void) => void; + emitError?: (error: unknown) => void; + disconnect?: () => Promise; } return { @@ -65,14 +124,17 @@ const { applyRateLimit, bindPublicAssets, clientIp, + applyWorkerMongoEnv, createWorkerApp, handleWorkerFetch, listenWorkerApp, requestWithPublicProtocol, workerMockHttpOptions, + workerMongoState, workerMongoUri, workerRuntime, } = await import("../worker/app.js"); +const { binStoreRuntime } = await import("../worker/bin-store.js"); const originalDispatch = workerRuntime.dispatch; @@ -80,6 +142,18 @@ describe("cloudflare worker", () => { const dispatch = vi.fn(); afterEach(() => { + KeyvMongoMock.failConstruct = undefined; + KeyvMongoMock.failGet = undefined; + KeyvMongoMock.failSet = undefined; + KeyvMongoMock.failSetFalse = false; + KeyvMongoMock.failIterator = undefined; + KeyvMongoMock.failConnect = undefined; + KeyvMongoMock.resolveConnect = false; + KeyvMongoMock.useOn = false; + KeyvMongoMock.useDisconnect = true; + KeyvMongoMock.failDisconnect = false; + KeyvMongoMock.disconnects = 0; + workerMongoState.uri = undefined; dispatch.mockReset(); dispatch.mockResolvedValue(new Response("ok", { status: 200 })); handleAsNodeRequestMock.mockReset(); @@ -271,6 +345,22 @@ describe("cloudflare worker", () => { }); describe("worker Fastify app", () => { + afterEach(() => { + KeyvMongoMock.failConstruct = undefined; + KeyvMongoMock.failGet = undefined; + KeyvMongoMock.failSet = undefined; + KeyvMongoMock.failSetFalse = false; + KeyvMongoMock.failIterator = undefined; + KeyvMongoMock.failConnect = undefined; + KeyvMongoMock.resolveConnect = false; + KeyvMongoMock.useOn = false; + KeyvMongoMock.useDisconnect = true; + KeyvMongoMock.failDisconnect = false; + KeyvMongoMock.disconnects = 0; + workerMongoState.uri = undefined; + delete process.env.MONGODB_URI; + }); + it("serves mock endpoints without listening", async () => { const mockHttp = await createWorkerApp(); const response = await mockHttp.server.inject({ @@ -416,4 +506,245 @@ describe("worker Fastify app", () => { await mockHttp.close(); delete process.env.MONGODB_URI; }); + + it("reports in-memory bin storage from /healthz", async () => { + delete process.env.MONGODB_URI; + const mockHttp = await createWorkerApp(); + const health = await mockHttp.server.inject({ + method: "GET", + url: "/healthz", + }); + expect(health.statusCode).toBe(200); + expect(health.json()).toMatchObject({ + status: "ok", + bins: { + store: "memory", + connected: true, + uriPresent: false, + scheme: null, + }, + }); + expect(health.json().bins.latencyMs).toEqual(expect.any(Number)); + const spec = await mockHttp.server.inject({ + method: "GET", + url: "/openapi.json", + }); + expect(spec.json().paths["/healthz"]).toBeDefined(); + await mockHttp.close(); + }); + + it("returns a redacted mongo ping failure from /healthz", async () => { + const uri = "mongodb+srv://user:pass@cluster.mongodb.net/mockhttp"; + const ping = vi.spyOn(binStoreRuntime, "ping").mockResolvedValue({ + status: "error", + bins: { + store: "mongo", + connected: false, + uriPresent: true, + scheme: "mongodb+srv", + error: { + name: "MongoServerSelectionError", + message: "server selection failed", + code: "ENOTFOUND", + }, + }, + }); + const mockHttp = await createWorkerApp({ MONGODB_URI: uri }); + const health = await mockHttp.server.inject({ + method: "GET", + url: "/healthz", + }); + expect(ping).toHaveBeenCalledWith(uri); + expect(health.statusCode).toBe(503); + expect(health.json()).toMatchObject({ + status: "error", + bins: { + store: "mongo", + connected: false, + scheme: "mongodb+srv", + uriPresent: true, + }, + }); + expect(JSON.stringify(health.json())).not.toContain(uri); + ping.mockRestore(); + await mockHttp.close(); + }); + + it("uses the request MONGODB_URI when it differs from startup", async () => { + const startup = "mongodb://user:startup-pass@cluster.mongodb.net/mockhttp"; + const requestUri = + "mongodb+srv://user:request-pass@cluster.mongodb.net/mockhttp"; + const errorLog = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + KeyvMongoMock.instances.length = 0; + const mockHttp = await createWorkerApp({ MONGODB_URI: startup }); + await handleWorkerFetch( + new Request("https://mockhttp.org/bins"), + { MONGODB_URI: requestUri }, + vi.fn().mockResolvedValue(new Response("ok")), + ); + const created = await mockHttp.server.inject({ + method: "POST", + url: "/bins", + }); + expect(created.statusCode).toBe(200); + expect(KeyvMongoMock.instances[0]?.url).toBe(requestUri); + const logged = errorLog.mock.calls + .map((call) => String(call[0])) + .join("\n"); + expect(logged).toContain("keyv.uri"); + expect(logged).toContain("mongodb+srv"); + expect(logged).not.toContain(startup); + expect(logged).not.toContain(requestUri); + expect(logged).not.toContain("startup-pass"); + expect(logged).not.toContain("request-pass"); + errorLog.mockRestore(); + await mockHttp.close(); + }); + + it("logs and returns a safe message when Keyv cannot construct or write", async () => { + const uri = + "mongodb+srv://user:p@ssw!rd@cluster0.abcde.mongodb.net/mockhttp"; + const errorLog = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + KeyvMongoMock.failConstruct = new Error(`Invalid connection string ${uri}`); + let mockHttp = await createWorkerApp({ MONGODB_URI: uri }); + const construct = await mockHttp.server.inject({ + method: "POST", + url: "/bins", + }); + expect(construct.statusCode).toBe(500); + expect(construct.json().message).toContain("Invalid connection string"); + expect(construct.json().message).toContain("Percent-encode"); + expect(construct.body).not.toContain("p@ssw"); + expect(construct.body).not.toContain("cluster0"); + await mockHttp.close(); + + KeyvMongoMock.failConstruct = undefined; + KeyvMongoMock.failSet = Object.assign( + new Error(`Invalid connection string ${uri}`), + { name: "MongoParseError", code: "PARSE" }, + ); + KeyvMongoMock.failConnect = KeyvMongoMock.failSet; + KeyvMongoMock.failDisconnect = true; + mockHttp = await createWorkerApp({ MONGODB_URI: uri }); + const saved = await mockHttp.server.inject({ + method: "POST", + url: "/bins", + }); + expect(saved.statusCode).toBe(500); + expect(saved.json().message).toContain("could not save bin"); + expect(saved.json().message).toContain("Percent-encode"); + expect(saved.body).not.toContain(uri); + expect(saved.body).not.toContain("p@ssw"); + const logged = errorLog.mock.calls + .map((call) => String(call[0])) + .join("\n"); + expect(logged).toContain("keyv.construct"); + expect(logged).toContain("keyv.connect"); + expect(logged).toContain("keyv.set"); + expect(logged).toContain("extraAt"); + expect(logged).not.toContain(uri); + expect(logged).not.toContain("p@ssw"); + expect(KeyvMongoMock.disconnects).toBe(1); + await mockHttp.close(); + + KeyvMongoMock.failConnect = undefined; + KeyvMongoMock.failSet = Object.assign( + new Error("Invalid connection string"), + { + name: "MongoParseError", + }, + ); + KeyvMongoMock.useDisconnect = false; + mockHttp = await createWorkerApp({ + MONGODB_URI: "mongodb://user:pass@cluster.mongodb.net/mockhttp", + }); + const noDisconnect = await mockHttp.server.inject({ + method: "POST", + url: "/bins", + }); + expect(noDisconnect.statusCode).toBe(500); + expect(noDisconnect.body).toContain("Invalid connection string"); + expect(noDisconnect.body).not.toContain("user:pass"); + errorLog.mockRestore(); + await mockHttp.close(); + }); + + it("logs a connected Keyv client and a false set", async () => { + const uri = "mongodb://user:pass@cluster.mongodb.net:27017/mockhttp"; + const infoLog = vi + .spyOn(console, "info") + .mockImplementation(() => undefined); + const errorLog = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + KeyvMongoMock.resolveConnect = true; + KeyvMongoMock.failSetFalse = true; + const mockHttp = await createWorkerApp({ MONGODB_URI: uri }); + const saved = await mockHttp.server.inject({ + method: "POST", + url: "/bins", + }); + expect(saved.statusCode).toBe(500); + await vi.waitFor(() => { + expect(infoLog.mock.calls.join("\n")).toContain("keyv.connect"); + }); + expect(errorLog.mock.calls.join("\n")).toContain( + "Keyv store set returned false", + ); + expect(infoLog.mock.calls.join("\n")).not.toContain("user:pass"); + infoLog.mockRestore(); + errorLog.mockRestore(); + await mockHttp.close(); + }); + + it("redacts iterator failures and forwards store error events", async () => { + const uri = "mongodb+srv://user:pass@cluster.mongodb.net/mockhttp"; + const errorLog = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + KeyvMongoMock.failIterator = new Error( + "iterator down mongodb://user:pass@cluster.mongodb.net/mockhttp", + ); + const mockHttp = await createWorkerApp({ MONGODB_URI: uri }); + await mockHttp.server.inject({ method: "POST", url: "/bins" }); + const listed = await mockHttp.server.inject({ + method: "GET", + url: "/bins", + }); + expect(listed.statusCode).toBe(500); + expect(listed.body).toContain("iterator down"); + expect(listed.body).not.toContain("user:pass"); + expect(listed.body).not.toContain("cluster.mongodb.net"); + await mockHttp.close(); + + KeyvMongoMock.failIterator = undefined; + KeyvMongoMock.useOn = true; + KeyvMongoMock.instances.length = 0; + const again = await createWorkerApp({ MONGODB_URI: uri }); + await again.server.inject({ method: "POST", url: "/bins" }); + const store = KeyvMongoMock.instances[0]; + const failure = Object.assign(new Error("querySrv ENOTFOUND"), { + name: "MongoNetworkError", + code: "ENOTFOUND", + }); + expect(() => store?.emitError?.(failure)).toThrow(failure); + expect(errorLog.mock.calls.join("\n")).toContain("keyv.error"); + expect(errorLog.mock.calls.join("\n")).not.toContain("user:pass"); + errorLog.mockRestore(); + await again.close(); + }); + + it("leaves the mongo URI in place when the request omits it", () => { + workerMongoState.uri = "mongodb://user:pass@cluster.mongodb.net/mockhttp"; + applyWorkerMongoEnv({}); + expect(workerMongoState.uri).toBe( + "mongodb://user:pass@cluster.mongodb.net/mockhttp", + ); + applyWorkerMongoEnv({ MONGODB_URI: workerMongoState.uri }); + expect(workerMongoState.uri).toContain("mongodb://"); + }); }); diff --git a/worker/app.ts b/worker/app.ts index bf0e913..19b5fd9 100644 --- a/worker/app.ts +++ b/worker/app.ts @@ -1,13 +1,11 @@ import { handleAsNodeRequest } from "cloudflare:node"; -import KeyvMongo from "@keyv/mongo"; -import Keyv from "keyv"; +import { logBinStore } from "../src/bin-store-diagnostics.js"; import { MockHttp, type MockHttpOptions } from "../src/mock-http.js"; -import { - MockHttpBins, - type MockHttpBinsOptions, -} from "../src/mock-http-bins.js"; +import { MockHttpBins } from "../src/mock-http-bins.js"; import { setPublicFileReader } from "../src/public-files.js"; import { PUBLIC_PROTOCOL_HEADER } from "../src/routes/bins/management.js"; +import { checkMemoryBinStore } from "../src/routes/health.js"; +import { binStoreRuntime, createAdaptiveBinKeyv } from "./bin-store.js"; export const WORKER_PORT = 3000; @@ -37,18 +35,6 @@ export type WorkerEnv = { MONGODB_URI?: string; }; -/** - * Pool stays small because each Worker isolate opens its own client. - * `serverSelectionTimeoutMS` keeps a down Atlas from holding the request - * for the driver's 30 second default. - */ -const workerBinMongoOptions = { - collection: "bins", - maxPoolSize: 5, - minPoolSize: 0, - serverSelectionTimeoutMS: 5_000, -}; - /** * Atlas URI for this request. The Worker binding wins. An empty string is * treated as unset so a blank secret does not open a client. @@ -61,38 +47,33 @@ export function workerMongoUri(env: WorkerEnv = {}): string | undefined { return uri; } -type WorkerBinKeyv = NonNullable; - /** - * Keyv backed by MongoDB. `@keyv/mongo` calls `MongoClient.connect()` from - * its constructor, and workerd rejects that network I/O during isolate - * startup, so the client is created on the first bin operation. + * URI the open bin client should use. Module startup records + * `cloudflare:workers` env. Each request then replaces it with the fetch + * handler env when that value is non-empty, so a startup snapshot cannot + * stick a different string. */ -function createLazyBinKeyv(uri: string): WorkerBinKeyv { - let keyv: WorkerBinKeyv | undefined; - const open = (): WorkerBinKeyv => { - keyv ??= new Keyv({ - store: new KeyvMongo(uri, workerBinMongoOptions), - }) as WorkerBinKeyv; - return keyv; - }; +export const workerMongoState: { uri: string | undefined } = { uri: undefined }; - return new Proxy({} as WorkerBinKeyv, { - get(_target, property) { - const target = open(); - const value = Reflect.get(target, property, target); - if (typeof value === "function") { - return value.bind(target); - } - return value; - }, +/** Apply a request's MONGODB_URI when it is set. Does not log the value. */ +export function applyWorkerMongoEnv(env: WorkerEnv): void { + const next = env.MONGODB_URI; + if (!next || next === workerMongoState.uri) { + return; + } + logBinStore({ + phase: "keyv.uri", + store: "keyv-mongo", + uri: next, + error: new Error("MONGODB_URI changed for this isolate"), }); + workerMongoState.uri = next; } -/** Request bins stored in MongoDB through Keyv. */ -export function createWorkerBins(uri: string): MockHttpBins { +/** Request bins. MongoDB is used when a URI is configured, otherwise memory. */ +export function createWorkerBins(): MockHttpBins { return new MockHttpBins({ - keyv: createLazyBinKeyv(uri), + keyv: createAdaptiveBinKeyv(() => workerMongoState.uri), }); } @@ -175,10 +156,15 @@ export function bindPublicAssets(assets: WorkerAssets | undefined): void { export async function createWorkerApp(env: WorkerEnv = {}): Promise { const mockHttp = new MockHttp(workerMockHttpOptions); - const uri = workerMongoUri(env); - if (uri) { - mockHttp.bins = createWorkerBins(uri); - } + workerMongoState.uri = workerMongoUri(env); + mockHttp.bins = createWorkerBins(); + mockHttp.binHealthCheck = () => { + const uri = workerMongoState.uri; + if (!uri) { + return checkMemoryBinStore(mockHttp.bins.keyv); + } + return binStoreRuntime.ping(uri); + }; await mockHttp.initialize(); return mockHttp; } @@ -195,6 +181,7 @@ export async function handleWorkerFetch( env: WorkerEnv, dispatch: WorkerDispatch = workerRuntime.dispatch, ): Promise { + applyWorkerMongoEnv(env); const limited = await applyRateLimit(request, env); if (limited) { return limited; diff --git a/worker/bin-store.ts b/worker/bin-store.ts new file mode 100644 index 0000000..92b8d4b --- /dev/null +++ b/worker/bin-store.ts @@ -0,0 +1,314 @@ +import net from "node:net"; +import KeyvMongo from "@keyv/mongo"; +import Keyv from "keyv"; +import { MongoClient, type MongoClientOptions } from "mongodb"; +import { + type BinHealth, + isBinStoreConnectionFailure, + logBinStore, + mongoUriFacts, + redactStoreError, + toSafeStoreError, + uriShape, +} from "../src/bin-store-diagnostics.js"; +import type { MockHttpBinsOptions } from "../src/mock-http-bins.js"; + +export const BIN_STORE_PING_TIMEOUT_MS = 2_000; + +const SOCKET_PROXY_FAILURE = "proxy request failed"; +let socketProxyGuardInstalled = false; + +/** + * workerd reports a refused socket twice. The driver handles the first emit. + * The second emit has no listener, so the isolate aborts. + * Keep that second emit inside the process. + */ +export function installSocketProxyGuard(): void { + if (socketProxyGuardInstalled) { + return; + } + socketProxyGuardInstalled = true; + const proto = net.Socket.prototype as net.Socket & { + emit(event: string | symbol, ...args: unknown[]): boolean; + }; + const emit = proto.emit; + proto.emit = function ( + this: net.Socket, + event: string | symbol, + ...args: unknown[] + ) { + if ( + event === "error" && + this.listenerCount("error") === 0 && + isSocketProxyFailure(args[0]) + ) { + return false; + } + return emit.call(this, event, ...args); + }; +} + +function isSocketProxyFailure(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error ?? ""); + return message.includes(SOCKET_PROXY_FAILURE); +} + +installSocketProxyGuard(); + +const workerBinMongoOptions = { + collection: "bins", + maxPoolSize: 5, + minPoolSize: 0, + serverSelectionTimeoutMS: 5_000, +}; + +type WorkerBinKeyv = NonNullable; + +const MONGO_METHODS = new Set([ + "get", + "set", + "delete", + "clear", + "has", + "getMany", + "setMany", + "deleteMany", + "hasMany", + "disconnect", +]); + +type StoreMethod = (...args: unknown[]) => unknown; + +function noteFailure( + phase: string, + uri: string, + error: unknown, + reset: () => void, +): void { + logBinStore({ phase, store: "keyv-mongo", uri, error }); + if (isBinStoreConnectionFailure(error)) { + reset(); + } +} + +/** + * Wrap a KeyvMongo store. Log each failed Keyv operation without the URI or credentials. + * Drop a failed client so the next operation opens a new client. + */ +export function guardMongoStore( + store: object, + uri: string, + reset: () => void, +): object { + return new Proxy(store, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver); + if (property === "iterator" && typeof value === "function") { + const iterate = value as StoreMethod; + return async function* (...args: unknown[]) { + try { + yield* iterate.apply(target, args) as AsyncIterable; + } catch (error) { + noteFailure("keyv.iterator", uri, error, reset); + throw toSafeStoreError(error, uri); + } + }; + } + if ( + typeof property === "string" && + MONGO_METHODS.has(property) && + typeof value === "function" + ) { + const call = value as StoreMethod; + return async (...args: unknown[]) => { + try { + const result = await call.apply(target, args); + if (property === "set" && result === false) { + logBinStore({ + phase: "keyv.set", + store: "keyv-mongo", + uri, + error: new Error("Keyv store set returned false"), + }); + } + return result; + } catch (error) { + noteFailure(`keyv.${property}`, uri, error, reset); + throw toSafeStoreError(error, uri); + } + }; + } + if (typeof value === "function") { + return value.bind(target); + } + return value; + }, + }); +} + +function watchMongoStore(store: object, uri: string, reset: () => void): void { + const source = store as { + on?: (event: "error", handler: (error: unknown) => void) => void; + connect?: Promise; + }; + if (typeof source.on === "function") { + source.on("error", (error) => { + noteFailure("keyv.error", uri, error, reset); + }); + } + if (source.connect && typeof source.connect.then === "function") { + const started = Date.now(); + void source.connect.then( + () => { + logBinStore({ + phase: "keyv.connect", + store: "keyv-mongo", + uri, + level: "info", + connected: true, + latencyMs: Date.now() - started, + }); + }, + (error: unknown) => { + noteFailure("keyv.connect", uri, error, reset); + }, + ); + } +} + +function logOpen(uri: string): void { + const shape = uriShape(uri); + const dirty = shape.extraAt || shape.whitespace || shape.wrappedQuotes; + logBinStore({ + phase: "keyv.open", + store: "keyv-mongo", + uri, + level: dirty ? "error" : "info", + connected: !dirty, + }); +} + +/** + * Keyv uses MongoDB when a URI is set, and memory when it is not. + * `@keyv/mongo` connects in its constructor. workerd rejects that network call + * during isolate startup, so the client opens on the first bin operation. + */ +export function createAdaptiveBinKeyv( + getUri: () => string | undefined, +): WorkerBinKeyv { + let keyv: WorkerBinKeyv | undefined; + let openedFor: string | undefined; + const closing = new WeakSet(); + + const reset = (store: object) => { + keyv = undefined; + openedFor = undefined; + if (closing.has(store)) { + return; + } + closing.add(store); + const disconnect = (store as { disconnect?: () => Promise }) + .disconnect; + if (typeof disconnect === "function") { + void disconnect.call(store).catch(() => undefined); + } + }; + + const open = (): WorkerBinKeyv => { + const uri = getUri(); + if (!uri) { + if (keyv && openedFor === undefined) { + return keyv; + } + keyv = new Keyv() as WorkerBinKeyv; + openedFor = undefined; + return keyv; + } + if (keyv && openedFor === uri) { + return keyv; + } + logOpen(uri); + let store: KeyvMongo; + try { + store = new KeyvMongo(uri, workerBinMongoOptions); + } catch (error) { + logBinStore({ phase: "keyv.construct", store: "keyv-mongo", uri, error }); + throw toSafeStoreError(error, uri); + } + watchMongoStore(store, uri, () => reset(store)); + keyv = new Keyv({ + store: guardMongoStore(store, uri, () => reset(store)), + }) as WorkerBinKeyv; + openedFor = uri; + return keyv; + }; + + return new Proxy({} as WorkerBinKeyv, { + get(_target, property) { + const target = open(); + const value = Reflect.get(target, property, target); + if (typeof value === "function") { + return value.bind(target); + } + return value; + }, + }); +} + +const pingOptions: MongoClientOptions = { + serverSelectionTimeoutMS: BIN_STORE_PING_TIMEOUT_MS, + connectTimeoutMS: BIN_STORE_PING_TIMEOUT_MS, + socketTimeoutMS: BIN_STORE_PING_TIMEOUT_MS, + maxPoolSize: 1, + minPoolSize: 0, +}; + +/** Admin ping. The client always closes. A failed client is not kept. */ +export async function pingMongoBinStore(uri: string): Promise { + const facts = mongoUriFacts(uri); + const started = Date.now(); + let client: MongoClient | undefined; + try { + client = new MongoClient(uri, pingOptions); + await client.db("admin").command({ ping: 1 }); + return { + status: "ok", + bins: { + store: "mongo", + connected: true, + latencyMs: Date.now() - started, + uriPresent: facts.present, + scheme: facts.scheme, + }, + }; + } catch (error) { + logBinStore({ phase: "healthz", store: "keyv-mongo", uri, error }); + return { + status: "error", + bins: { + store: "mongo", + connected: false, + uriPresent: facts.present, + scheme: facts.scheme, + error: redactStoreError(error, uri), + }, + }; + } finally { + if (client) { + await client.close().catch((error: unknown) => { + logBinStore({ + phase: "healthz.close", + store: "keyv-mongo", + uri, + error, + }); + }); + } + } +} + +export const binStoreRuntime = { + ping(uri: string): Promise { + return pingMongoBinStore(uri); + }, +};