@specific.dev/spectest 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,515 @@
1
+ // Record/replay fake — a VCR-style cassette fake.
2
+ //
3
+ // `replayFake({...})` returns a plain `FakeDefinition`, so it drops
4
+ // straight into `fakes: { stripe: replayFake({...}) }` and inherits all
5
+ // the existing fakes machinery: ingress lowering mints the TLS leaf cert
6
+ // and registers the hostname, dispatch routes by `Host` header to the
7
+ // generated `handler`, and `state` forks per test like any fake.
8
+ //
9
+ // The only new behaviour lives inside the generated `handler`/`state`:
10
+ //
11
+ // - REPLAY (hermetic): the request is matched against a committed
12
+ // cassette and the recorded response is returned. No network. This is
13
+ // what runs under `spectest test`.
14
+ // - RECORD: the request is forwarded to the real upstream, the
15
+ // request/response pair is captured (decoded, redacted), and the real
16
+ // response is returned to the app so a manual session behaves like
17
+ // production. This runs under `spectest_eval` / a manual env.
18
+ //
19
+ // Mode is chosen by `isRecording()`: it is true only inside an active
20
+ // recorder (a `spectest test` case), false in eval/manual. So `auto`
21
+ // (the default) replays under test and records under eval — no new
22
+ // control-plane mode flag. `mode: "record" | "replay"` overrides it.
23
+ //
24
+ // Secrets are injected at the egress boundary (the forwarder): the app
25
+ // only ever holds a placeholder; the real value — pushed eval-scoped from
26
+ // the control plane via {@link getRecordSecret} — is swapped in on the
27
+ // forward to upstream and never persists into a cassette (redacted,
28
+ // fail-closed) or any snapshot a hermetic run could fork.
29
+
30
+ import { createHash } from "node:crypto";
31
+ import { existsSync, readFileSync } from "node:fs";
32
+ import path from "node:path";
33
+
34
+ import type { FakeContext, FakeDefinition } from "../index.js";
35
+ import { isRecording } from "../recorder.js";
36
+ import { getRecordSecret } from "../record-secrets.js";
37
+
38
+ // Pristine `fetch`, captured at module load (project import time, before
39
+ // any per-test / per-eval fetch wrapper monkey-patches `globalThis.fetch`).
40
+ // The forwarder uses this so its outbound call isn't intercepted by the
41
+ // recorder — same reason the daemon's reverse-proxy keeps its own
42
+ // `NATIVE_FETCH`.
43
+ const NATIVE_FETCH: typeof fetch = globalThis.fetch.bind(globalThis);
44
+
45
+ // ────────────────────────────────────────────────────────────────────────
46
+ // Options + cassette format
47
+ // ────────────────────────────────────────────────────────────────────────
48
+
49
+ /** Which parts of a request define a match against the cassette. */
50
+ export interface ReplayMatch {
51
+ /** Match on HTTP method. Default `true`. */
52
+ method?: boolean;
53
+ /** Match on path (no query). Default `true`. */
54
+ path?: boolean;
55
+ /** Match on normalized query string. Default `true`. */
56
+ query?: boolean;
57
+ /** Match on body: `true` = exact decoded body, `"hash"` = sha256 only.
58
+ * Default off. */
59
+ body?: boolean | "hash";
60
+ /** Header names (lowercase) to include in the match. Default none. */
61
+ headers?: string[];
62
+ }
63
+
64
+ export interface ReplaySecretInjection {
65
+ /** Platform secret reference, e.g. `"STRIPE_API_KEY"`. The control plane
66
+ * resolves it (default: `SPECTEST_SECRET_STRIPE_API_KEY` in the server
67
+ * env) and pushes the value on the eval path only. */
68
+ ref: string;
69
+ /** Build the headers to set on the forward to upstream from the real
70
+ * secret value, *replacing* whatever placeholder the app sent. Default:
71
+ * `{ authorization: "Bearer <value>" }`. */
72
+ inject?: (req: Request, value: string) => Record<string, string>;
73
+ /** Extra substrings/patterns to scrub from stored request/response
74
+ * bodies and queries (the secret value itself is always scrubbed). */
75
+ redactPatterns?: (string | RegExp)[];
76
+ }
77
+
78
+ export interface ReplayFakeOptions {
79
+ /** Stable name — the `ctx.fakes` key. */
80
+ name: string;
81
+ /** Hostnames the fake answers to (e.g. `["api.stripe.test"]`). Keep
82
+ * these distinct from the real `upstream` host so the record-mode
83
+ * forward doesn't resolve back into the daemon (see the loopback
84
+ * guard). */
85
+ hostnames: string[];
86
+ /** Real upstream base URL to forward to in record mode (e.g.
87
+ * `"https://api.stripe.com"`). */
88
+ upstream: string;
89
+ /** TCP port the fake listens on. Default `80`. */
90
+ port?: number;
91
+ /** Cassette filename under `spectest/recordings/`. Default `<name>.json`. */
92
+ cassette?: string;
93
+ /** What defines a request match. Default `{ method, path, query }`. */
94
+ match?: ReplayMatch;
95
+ /** Record-time secret injection at the egress boundary. */
96
+ secret?: ReplaySecretInjection;
97
+ /** `"auto"` (default) records under eval and replays under test;
98
+ * `"record"` / `"replay"` force a mode. */
99
+ mode?: "auto" | "record" | "replay";
100
+ }
101
+
102
+ export interface CassetteRequest {
103
+ method: string;
104
+ path: string;
105
+ /** Normalized: keys + repeated values sorted, so matching is order-free. */
106
+ query: Record<string, string[]>;
107
+ /** Matched/readable header subset only — never `authorization`/`cookie`. */
108
+ headers: Record<string, string>;
109
+ bodyHash?: string;
110
+ /** Decoded + redacted request body (omitted when empty). */
111
+ body?: string;
112
+ }
113
+
114
+ export interface CassetteResponse {
115
+ status: number;
116
+ headers: Record<string, string>;
117
+ /** Decoded + redacted response body. */
118
+ body: string;
119
+ bodyEncoding: "utf8" | "base64";
120
+ }
121
+
122
+ export interface CassetteInteraction {
123
+ request: CassetteRequest;
124
+ response: CassetteResponse;
125
+ }
126
+
127
+ export interface Cassette {
128
+ version: 1;
129
+ fake: string;
130
+ upstream: string;
131
+ interactions: CassetteInteraction[];
132
+ }
133
+
134
+ /** Forked-per-test state for one replay fake. */
135
+ export interface CassetteState {
136
+ cassette: Cassette;
137
+ /** Per-match-signature replay cursor (call-order sequencing). */
138
+ cursor: Map<string, number>;
139
+ /** Interactions captured this record session. */
140
+ recorded: CassetteInteraction[];
141
+ dirty: boolean;
142
+ }
143
+
144
+ export interface ReplayHelpers extends Record<string, unknown> {
145
+ /** Full cassette JSON (committed interactions + newly recorded, redacted)
146
+ * — the MVP delivery channel: `export default ctx.fakes.<name>.dump()`,
147
+ * then write the result to `spectest/recordings/<name>.json`. */
148
+ dump(): Cassette;
149
+ /** Interactions captured this record session. */
150
+ recorded(): CassetteInteraction[];
151
+ /** Count of interactions captured this record session. */
152
+ count(): number;
153
+ }
154
+
155
+ // ────────────────────────────────────────────────────────────────────────
156
+ // Helpers
157
+ // ────────────────────────────────────────────────────────────────────────
158
+
159
+ const SENSITIVE_HEADERS = new Set([
160
+ "authorization",
161
+ "cookie",
162
+ "set-cookie",
163
+ "proxy-authorization",
164
+ "x-api-key",
165
+ "x-amz-security-token",
166
+ ]);
167
+
168
+ /** Hop-by-hop + body-framing headers never forwarded / never replayed
169
+ * (Bun's fetch already decompresses, so the encoding/length no longer
170
+ * describe the bytes). */
171
+ const STRIP_FORWARD_HEADERS = new Set([
172
+ "host",
173
+ "connection",
174
+ "keep-alive",
175
+ "proxy-authenticate",
176
+ "proxy-authorization",
177
+ "te",
178
+ "trailers",
179
+ "transfer-encoding",
180
+ "upgrade",
181
+ "content-length",
182
+ "content-encoding",
183
+ ]);
184
+
185
+ function defaultMatch(m: ReplayMatch | undefined): Required<Omit<ReplayMatch, "body" | "headers">> &
186
+ Pick<ReplayMatch, "body" | "headers"> {
187
+ return {
188
+ method: m?.method ?? true,
189
+ path: m?.path ?? true,
190
+ query: m?.query ?? true,
191
+ body: m?.body,
192
+ headers: m?.headers,
193
+ };
194
+ }
195
+
196
+ function normalizeQuery(search: URLSearchParams): Record<string, string[]> {
197
+ const out: Record<string, string[]> = {};
198
+ for (const key of [...search.keys()].sort()) {
199
+ out[key] = search.getAll(key).slice().sort();
200
+ }
201
+ return out;
202
+ }
203
+
204
+ function sha256(s: string): string {
205
+ return "sha256:" + createHash("sha256").update(s).digest("hex");
206
+ }
207
+
208
+ function recordable(headers: Headers, want: string[]): Record<string, string> {
209
+ const out: Record<string, string> = {};
210
+ for (const name of want) {
211
+ const lower = name.toLowerCase();
212
+ if (SENSITIVE_HEADERS.has(lower)) continue;
213
+ const v = headers.get(lower);
214
+ if (v !== null) out[lower] = v;
215
+ }
216
+ return out;
217
+ }
218
+
219
+ /** Canonical match signature for an interaction's request, used as both
220
+ * the replay-cursor key and the equality basis (two requests match iff
221
+ * their signatures are equal). Computed identically for an incoming
222
+ * request and a stored `CassetteRequest`. */
223
+ function signature(req: CassetteRequest, match: ReturnType<typeof defaultMatch>): string {
224
+ const sig: Record<string, unknown> = {};
225
+ if (match.method) sig.method = req.method.toUpperCase();
226
+ if (match.path) sig.path = req.path;
227
+ if (match.query) sig.query = req.query;
228
+ if (match.body === true) sig.body = req.body ?? "";
229
+ else if (match.body === "hash") sig.bodyHash = req.bodyHash ?? "";
230
+ if (match.headers && match.headers.length > 0) {
231
+ const h: Record<string, string> = {};
232
+ for (const name of match.headers.map((n) => n.toLowerCase()).sort()) {
233
+ h[name] = req.headers[name] ?? "";
234
+ }
235
+ sig.headers = h;
236
+ }
237
+ return JSON.stringify(sig);
238
+ }
239
+
240
+ function recordingsDir(): string {
241
+ const dir =
242
+ process.env.SPECTEST_PROJECT_DIR ??
243
+ path.join(process.env.SPECTEST_APP_DIR ?? "/opt/spectest/app", "spectest");
244
+ return path.join(dir, "recordings");
245
+ }
246
+
247
+ function emptyCassette(name: string, upstream: string): Cassette {
248
+ return { version: 1, fake: name, upstream, interactions: [] };
249
+ }
250
+
251
+ function loadCassette(name: string, file: string, upstream: string): Cassette {
252
+ const p = path.join(recordingsDir(), file);
253
+ if (!existsSync(p)) return emptyCassette(name, upstream);
254
+ try {
255
+ const parsed = JSON.parse(readFileSync(p, "utf8")) as Cassette;
256
+ if (!Array.isArray(parsed.interactions)) {
257
+ throw new Error("cassette has no `interactions` array");
258
+ }
259
+ return {
260
+ version: 1,
261
+ fake: parsed.fake ?? name,
262
+ upstream: parsed.upstream ?? upstream,
263
+ interactions: parsed.interactions,
264
+ };
265
+ } catch (err) {
266
+ throw new Error(
267
+ `replayFake(${name}): failed to read cassette ${p}: ${(err as Error).message}`,
268
+ );
269
+ }
270
+ }
271
+
272
+ /** Decode response bytes to a string, falling back to base64 for binary. */
273
+ function decodeBody(buf: ArrayBuffer): { body: string; bodyEncoding: "utf8" | "base64" } {
274
+ const bytes = new Uint8Array(buf);
275
+ try {
276
+ const text = new TextDecoder("utf8", { fatal: true }).decode(bytes);
277
+ return { body: text, bodyEncoding: "utf8" };
278
+ } catch {
279
+ return { body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" };
280
+ }
281
+ }
282
+
283
+ function makeRedactor(
284
+ secretValue: string | undefined,
285
+ patterns: (string | RegExp)[] | undefined,
286
+ ref: string | undefined,
287
+ ): (s: string) => string {
288
+ return (s: string): string => {
289
+ let out = s;
290
+ if (secretValue && secretValue.length > 0) {
291
+ out = out.split(secretValue).join(`<REDACTED:${ref ?? "SECRET"}>`);
292
+ }
293
+ for (const p of patterns ?? []) {
294
+ if (typeof p === "string") {
295
+ if (p.length > 0) out = out.split(p).join("<REDACTED>");
296
+ } else {
297
+ const g = p.flags.includes("g") ? p : new RegExp(p.source, p.flags + "g");
298
+ out = out.replace(g, "<REDACTED>");
299
+ }
300
+ }
301
+ return out;
302
+ };
303
+ }
304
+
305
+ // ────────────────────────────────────────────────────────────────────────
306
+ // replayFake
307
+ // ────────────────────────────────────────────────────────────────────────
308
+
309
+ export function replayFake(
310
+ opts: ReplayFakeOptions,
311
+ ): FakeDefinition<CassetteState, ReplayHelpers> {
312
+ if (!opts.name) throw new Error("replayFake: `name` is required");
313
+ if (!Array.isArray(opts.hostnames) || opts.hostnames.length === 0) {
314
+ throw new Error(`replayFake(${opts.name}): at least one hostname is required`);
315
+ }
316
+ if (!opts.upstream) {
317
+ throw new Error(`replayFake(${opts.name}): "upstream" is required`);
318
+ }
319
+ const cassetteFile = opts.cassette ?? `${opts.name}.json`;
320
+ const upstream = opts.upstream.replace(/\/+$/, "");
321
+ const match = defaultMatch(opts.match);
322
+ const fakeHosts = new Set(opts.hostnames.map((h) => h.toLowerCase()));
323
+ const inject =
324
+ opts.secret?.inject ??
325
+ ((_req: Request, value: string): Record<string, string> => ({
326
+ authorization: `Bearer ${value}`,
327
+ }));
328
+
329
+ const resolveMode = (): "record" | "replay" => {
330
+ if (opts.mode === "record") return "record";
331
+ if (opts.mode === "replay") return "replay";
332
+ return isRecording() ? "replay" : "record";
333
+ };
334
+
335
+ // ── REPLAY ──────────────────────────────────────────────────────────
336
+ function replay(reqLike: CassetteRequest, state: CassetteState): Response {
337
+ const sig = signature(reqLike, match);
338
+ const matches: CassetteInteraction[] = [];
339
+ for (const it of state.cassette.interactions) {
340
+ if (signature(it.request, match) === sig) matches.push(it);
341
+ }
342
+ const idx = state.cursor.get(sig) ?? 0;
343
+ const hit = matches[idx];
344
+ if (!hit) {
345
+ const body =
346
+ `replayFake(${opts.name}): no cassette interaction for ` +
347
+ `${reqLike.method} ${reqLike.path} (matched ${matches.length}, wanted #${idx + 1}). ` +
348
+ `Re-record the cassette, or relax \`match\`.\n`;
349
+ return new Response(body, {
350
+ status: 599,
351
+ headers: { "content-type": "text/plain" },
352
+ });
353
+ }
354
+ state.cursor.set(sig, idx + 1);
355
+ const headers = new Headers(hit.response.headers);
356
+ const payload =
357
+ hit.response.bodyEncoding === "base64"
358
+ ? Buffer.from(hit.response.body, "base64")
359
+ : hit.response.body;
360
+ return new Response(payload, { status: hit.response.status, headers });
361
+ }
362
+
363
+ // ── RECORD ──────────────────────────────────────────────────────────
364
+ async function record(
365
+ req: Request,
366
+ reqLike: CassetteRequest,
367
+ rawBody: string,
368
+ state: CassetteState,
369
+ ): Promise<Response> {
370
+ const secretValue = opts.secret ? getRecordSecret(opts.secret.ref) : undefined;
371
+ if (opts.secret && secretValue === undefined) {
372
+ return new Response(
373
+ `replayFake(${opts.name}): secret ${JSON.stringify(opts.secret.ref)} was not supplied ` +
374
+ `(set SPECTEST_SECRET_${opts.secret.ref} in the server env and record via spectest_eval).\n`,
375
+ { status: 599, headers: { "content-type": "text/plain" } },
376
+ );
377
+ }
378
+
379
+ // Build the upstream URL and guard against forwarding back into the
380
+ // daemon (which would happen if the upstream host is itself one of
381
+ // this fake's hostnames — the in-VM resolver points those at us).
382
+ const target = new URL(reqLike.path, upstream + "/");
383
+ const incomingUrl = new URL(req.url);
384
+ incomingUrl.searchParams.forEach((v, k) => target.searchParams.append(k, v));
385
+ if (fakeHosts.has(target.hostname.toLowerCase())) {
386
+ throw new Error(
387
+ `replayFake(${opts.name}): upstream host ${target.hostname} is also a fake hostname — ` +
388
+ `forwarding would loop back into the daemon. Use a distinct fake hostname ` +
389
+ `(e.g. "api.${opts.name}.test") pointed at upstream ${upstream}.`,
390
+ );
391
+ }
392
+
393
+ // Forward headers: drop hop-by-hop, then apply secret injection
394
+ // (replacing whatever placeholder the app sent — the real key only
395
+ // ever appears on this outbound wire).
396
+ const fwdHeaders = new Headers();
397
+ req.headers.forEach((v, k) => {
398
+ if (!STRIP_FORWARD_HEADERS.has(k.toLowerCase())) fwdHeaders.set(k, v);
399
+ });
400
+ if (opts.secret && secretValue !== undefined) {
401
+ for (const [k, v] of Object.entries(inject(req, secretValue))) {
402
+ fwdHeaders.set(k, v);
403
+ }
404
+ }
405
+
406
+ const upstreamRes = await NATIVE_FETCH(target.toString(), {
407
+ method: reqLike.method,
408
+ headers: fwdHeaders,
409
+ body: rawBody.length > 0 ? rawBody : undefined,
410
+ redirect: "manual",
411
+ });
412
+ const resBuf = await upstreamRes.arrayBuffer();
413
+
414
+ // Decode + redact for storage; return the real (un-redacted) bytes to
415
+ // the app so the manual session behaves like production.
416
+ const redact = makeRedactor(secretValue, opts.secret?.redactPatterns, opts.secret?.ref);
417
+ const decoded = decodeBody(resBuf);
418
+ const respHeaders: Record<string, string> = {};
419
+ upstreamRes.headers.forEach((v, k) => {
420
+ const lower = k.toLowerCase();
421
+ if (SENSITIVE_HEADERS.has(lower) || STRIP_FORWARD_HEADERS.has(lower)) return;
422
+ respHeaders[lower] = v;
423
+ });
424
+
425
+ const redactedQuery: Record<string, string[]> = {};
426
+ for (const [k, vs] of Object.entries(reqLike.query)) {
427
+ redactedQuery[k] = vs.map(redact);
428
+ }
429
+ const interaction: CassetteInteraction = {
430
+ request: {
431
+ method: reqLike.method,
432
+ path: reqLike.path,
433
+ query: redactedQuery,
434
+ headers: reqLike.headers,
435
+ ...(rawBody.length > 0
436
+ ? { bodyHash: reqLike.bodyHash, body: redact(rawBody) }
437
+ : {}),
438
+ },
439
+ response: {
440
+ status: upstreamRes.status,
441
+ headers: respHeaders,
442
+ body: decoded.bodyEncoding === "utf8" ? redact(decoded.body) : decoded.body,
443
+ bodyEncoding: decoded.bodyEncoding,
444
+ },
445
+ };
446
+
447
+ // Fail-closed: never persist a cassette that still carries the raw
448
+ // secret (a missed redaction is a leak, not a warning).
449
+ if (secretValue && secretValue.length > 0) {
450
+ if (JSON.stringify(interaction).includes(secretValue)) {
451
+ throw new Error(
452
+ `replayFake(${opts.name}): refusing to record — the secret value survived redaction ` +
453
+ `into the interaction for ${reqLike.method} ${reqLike.path}. Add a redactPatterns entry.`,
454
+ );
455
+ }
456
+ }
457
+
458
+ state.recorded.push(interaction);
459
+ state.dirty = true;
460
+
461
+ const outHeaders = new Headers(respHeaders);
462
+ return new Response(resBuf, { status: upstreamRes.status, headers: outHeaders });
463
+ }
464
+
465
+ // ── handler ─────────────────────────────────────────────────────────
466
+ const handler = async (
467
+ req: Request,
468
+ state: CassetteState,
469
+ _ctx: FakeContext,
470
+ ): Promise<Response> => {
471
+ const url = new URL(req.url);
472
+ const rawBody = req.method === "GET" || req.method === "HEAD" ? "" : await req.text();
473
+ const reqLike: CassetteRequest = {
474
+ method: req.method.toUpperCase(),
475
+ path: url.pathname,
476
+ query: normalizeQuery(url.searchParams),
477
+ headers: recordable(req.headers, ["content-type", ...(match.headers ?? [])]),
478
+ ...(rawBody.length > 0 ? { bodyHash: sha256(rawBody) } : {}),
479
+ };
480
+
481
+ if (resolveMode() === "replay") return replay(reqLike, state);
482
+ return record(req, reqLike, rawBody, state);
483
+ };
484
+
485
+ const def: FakeDefinition<CassetteState, ReplayHelpers> = {
486
+ name: opts.name,
487
+ hostnames: opts.hostnames,
488
+ port: opts.port,
489
+ secretRefs: opts.secret ? [opts.secret.ref] : [],
490
+ state: (): CassetteState => ({
491
+ cassette: loadCassette(opts.name, cassetteFile, upstream),
492
+ cursor: new Map(),
493
+ recorded: [],
494
+ dirty: false,
495
+ }),
496
+ handler,
497
+ helpers: ({ state }): ReplayHelpers => ({
498
+ dump(): Cassette {
499
+ return {
500
+ version: 1,
501
+ fake: opts.name,
502
+ upstream,
503
+ interactions: [...state.cassette.interactions, ...state.recorded],
504
+ };
505
+ },
506
+ recorded(): CassetteInteraction[] {
507
+ return state.recorded.slice();
508
+ },
509
+ count(): number {
510
+ return state.recorded.length;
511
+ },
512
+ }),
513
+ };
514
+ return def;
515
+ }