@specific.dev/spectest 0.99.0 → 0.101.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.
@@ -14,7 +14,7 @@
14
14
  // - REPLAY (hermetic): the request is matched against the cassette the
15
15
  // test EXPLICITLY loaded (`ctx.fakes.<name>.replay(file)`) and the
16
16
  // recorded response is returned. No network. Nothing is auto-loaded:
17
- // a test that loads no cassette gets a fail-loud 599 on every request.
17
+ // a test that loads no cassette gets a fail-loud 599 on every delegated request.
18
18
  // This is what runs under `spectest test`.
19
19
  // - RECORD (MITM): the request is forwarded to the REAL host and the
20
20
  // request/response pair is captured (decoded, redacted), and the real
@@ -35,6 +35,8 @@
35
35
  // recorder (a `spectest test` case), false in eval/manual. So `auto`
36
36
  // (the default) replays under test and records during manual use — no new
37
37
  // control-plane mode flag. `mode: "record" | "replay"` overrides it.
38
+ // An optional custom handler can return scripted responses or explicitly
39
+ // delegate with ctx.replay(request). Only delegated traffic uses this engine.
38
40
  //
39
41
  // Credential brokering (per-fake): `inject` rules set
40
42
  // headers on the egress forward — overwriting whatever the app sent — so
@@ -65,6 +67,8 @@ import * as dnsPacket from "dns-packet";
65
67
  import type { FakeContext, FakeDefinition } from "../index.js";
66
68
  import { isRecording } from "../recorder.js";
67
69
  import { getRecordSecret } from "../record-secrets.js";
70
+ import { replaySignature, type ReplayKey } from "./replay-key.js";
71
+ export type { JsonValue, MatchKey, ReplayRequest, ReplayKey } from "./replay-key.js";
68
72
  import {
69
73
  inferSigV4Scope,
70
74
  signAwsV4Request,
@@ -75,21 +79,6 @@ import {
75
79
  // Options + cassette format
76
80
  // ────────────────────────────────────────────────────────────────────────
77
81
 
78
- /** Which parts of a request define a match against the cassette. */
79
- export interface ReplayMatch {
80
- /** Match on HTTP method. Default `true`. */
81
- method?: boolean;
82
- /** Match on path (no query). Default `true`. */
83
- path?: boolean;
84
- /** Match on normalized query string. Default `true`. */
85
- query?: boolean;
86
- /** Match on body: `true` = exact decoded body, `"hash"` = sha256 only.
87
- * Default off. */
88
- body?: boolean | "hash";
89
- /** Header names (lowercase) to include in the match. Default none. */
90
- headers?: string[];
91
- }
92
-
93
82
  /** Match one request dimension. A bare string is an exact match; the
94
83
  * object forms cover prefix and regex matching. */
95
84
  export type StringMatch =
@@ -146,7 +135,24 @@ export interface AwsSigV4Sign {
146
135
 
147
136
  export type SignConfig = AwsSigV4Sign;
148
137
 
149
- export interface ReplayFakeOptions {
138
+ /** A normal fake context with explicit delegation to recording/replay. */
139
+ export interface ReplayFakeContext extends FakeContext {
140
+ /** Record against the real upstream in record mode, or consume a matching
141
+ * cassette interaction in replay mode. A replay miss returns 599; there
142
+ * is no fallback to scripted logic. Inspect a body's clone before calling. */
143
+ replay(request: Request): Promise<Response>;
144
+ }
145
+
146
+ export type ReplayHandler<S> = (
147
+ request: Request,
148
+ state: S,
149
+ ctx: ReplayFakeContext,
150
+ ) => Response | Promise<Response>;
151
+
152
+ export interface ReplayFakeOptions<
153
+ S = undefined,
154
+ H extends Record<string, unknown> = {},
155
+ > {
150
156
  /** Stable name — the `ctx.fakes` key. */
151
157
  name: string;
152
158
  /** The REAL host to fake, e.g. `"api.stripe.com"`. The fake answers for
@@ -155,8 +161,12 @@ export interface ReplayFakeOptions {
155
161
  * resolved via an external DNS server — see the module header). Your
156
162
  * app points at this real host directly; no separate stand-in. */
157
163
  host: string;
158
- /** What defines a request match. Default `{ method, path, query }`. */
159
- match?: ReplayMatch;
164
+ /** Complete request identity. Default: method, path, normalized query,
165
+ * and exact body bytes. The same synchronous, deterministic callback runs
166
+ * on recorded and incoming requests. Return JSON values and/or Uint8Array
167
+ * bytes; objects compare independently of property order. No implicit
168
+ * matching dimensions or fallback apply when a key is supplied. */
169
+ key?: ReplayKey;
160
170
  /** Per-fake credential brokering. Rules are evaluated in
161
171
  * order; the first whose `match` matches wins (a rule without `match`
162
172
  * matches everything and shadows later rules). Applied at the
@@ -175,6 +185,15 @@ export interface ReplayFakeOptions {
175
185
  /** `"auto"` (default) records under eval and replays under test;
176
186
  * `"record"` / `"replay"` force a mode. */
177
187
  mode?: "auto" | "record" | "replay";
188
+ /** Custom state, with the same lifecycle as a normal fake's state. Kept
189
+ * separate from cassette internals; helpers and handler see only S. */
190
+ state?: () => S | Promise<S>;
191
+ /** Custom helpers exposed alongside replay/dump/recorded/count. Those
192
+ * names are reserved. Loading a cassette does not change custom state. */
193
+ helpers?: (args: { name: string; state: S; ctx: FakeContext }) => H | Promise<H>;
194
+ /** Decide whether to answer in code or call ctx.replay(request). Without
195
+ * a handler, every request delegates. Only delegated requests record. */
196
+ handler?: ReplayHandler<S>;
178
197
  }
179
198
 
180
199
  export interface CassetteRequest {
@@ -182,7 +201,8 @@ export interface CassetteRequest {
182
201
  path: string;
183
202
  /** Normalized: keys + repeated values sorted, so matching is order-free. */
184
203
  query: Record<string, string[]>;
185
- /** Matched/readable header subset only — never `authorization`/`cookie`. */
204
+ /** Captured non-sensitive headers — never `authorization`/`cookie`.
205
+ * Older cassettes may contain only content-type and selected headers. */
186
206
  headers: Record<string, string>;
187
207
  bodyHash?: string;
188
208
  /** Request body (omitted when empty): decoded + redacted UTF-8 text, or
@@ -216,6 +236,8 @@ export interface Cassette {
216
236
  /** Forked-per-test state for one replay fake. */
217
237
  export interface CassetteState {
218
238
  cassette: Cassette;
239
+ /** Complete signature → cassette interaction indexes, in cassette order. */
240
+ index: Map<string, number[]>;
219
241
  /** Per-match-signature replay cursor (call-order sequencing). */
220
242
  cursor: Map<string, number>;
221
243
  /** Interactions captured this record session. */
@@ -236,8 +258,9 @@ export interface ReplaySummary {
236
258
  }
237
259
 
238
260
  export interface ReplayHelpers extends Record<string, unknown> {
239
- /** Load a cassette into THIS fork's replay set and replay from it,
261
+ /** Load a cassette into THIS fork's replay set for delegated requests,
240
262
  * replacing whatever was loaded before and resetting the replay cursor.
263
+ * Does not change custom state or bypass a custom handler.
241
264
  * Cassettes are NOT auto-loaded — call this at the top of a test. Pass a
242
265
  * path relative to `spectest/tests/` (e.g. `"recordings/stripe.json"`; it
243
266
  * must stay under `spectest/tests/`) or a cassette object; a missing file
@@ -263,7 +286,9 @@ const SENSITIVE_HEADERS = new Set([
263
286
  "cookie",
264
287
  "set-cookie",
265
288
  "proxy-authorization",
289
+ "api-key",
266
290
  "x-api-key",
291
+ "x-goog-api-key",
267
292
  "x-amz-security-token",
268
293
  ]);
269
294
 
@@ -292,17 +317,6 @@ const STRIP_FORWARD_HEADERS = new Set([
292
317
  const EXTERNAL_DNS = process.env.SPECTEST_UPSTREAM_DNS ?? "1.1.1.1";
293
318
  const EXTERNAL_DNS_PORT = Number(process.env.SPECTEST_UPSTREAM_PORT ?? "53");
294
319
 
295
- function defaultMatch(m: ReplayMatch | undefined): Required<Omit<ReplayMatch, "body" | "headers">> &
296
- Pick<ReplayMatch, "body" | "headers"> {
297
- return {
298
- method: m?.method ?? true,
299
- path: m?.path ?? true,
300
- query: m?.query ?? true,
301
- body: m?.body,
302
- headers: m?.headers,
303
- };
304
- }
305
-
306
320
  /** SigV4 puts per-request signing params (`X-Amz-Signature`, `X-Amz-Date`, …)
307
321
  * in the query for presigned URLs — they change every call and would poison
308
322
  * query matching, so they're dropped from both the match key and the forward
@@ -341,27 +355,6 @@ function recordable(headers: Headers, want: string[]): Record<string, string> {
341
355
  return out;
342
356
  }
343
357
 
344
- /** Canonical match signature for an interaction's request, used as both
345
- * the replay-cursor key and the equality basis (two requests match iff
346
- * their signatures are equal). Computed identically for an incoming
347
- * request and a stored `CassetteRequest`. */
348
- function signature(req: CassetteRequest, match: ReturnType<typeof defaultMatch>): string {
349
- const sig: Record<string, unknown> = {};
350
- if (match.method) sig.method = req.method.toUpperCase();
351
- if (match.path) sig.path = req.path;
352
- if (match.query) sig.query = req.query;
353
- if (match.body === true) sig.body = req.body ?? "";
354
- else if (match.body === "hash") sig.bodyHash = req.bodyHash ?? "";
355
- if (match.headers && match.headers.length > 0) {
356
- const h: Record<string, string> = {};
357
- for (const name of match.headers.map((n) => n.toLowerCase()).sort()) {
358
- h[name] = req.headers[name] ?? "";
359
- }
360
- sig.headers = h;
361
- }
362
- return JSON.stringify(sig);
363
- }
364
-
365
358
  /** The project's `spectest/tests/` dir — cassettes live alongside the
366
359
  * tests, and a `replay(path)` argument is a path relative to here. Keeping
367
360
  * them under `tests/` means re-recording is picked up on the next run with
@@ -386,7 +379,7 @@ function coerceCassette(parsed: Cassette, name: string, host: string): Cassette
386
379
  version: 1,
387
380
  fake: parsed.fake ?? name,
388
381
  host: parsed.host ?? host,
389
- interactions: parsed.interactions,
382
+ interactions: structuredClone(parsed.interactions),
390
383
  };
391
384
  }
392
385
 
@@ -421,7 +414,7 @@ function readCassetteFile(name: string, file: string, host: string): Cassette {
421
414
  /** Decode response bytes to a string, falling back to base64 for binary. */
422
415
  function decodeBody(bytes: Uint8Array): { body: string; bodyEncoding: "utf8" | "base64" } {
423
416
  try {
424
- const text = new TextDecoder("utf8", { fatal: true }).decode(bytes);
417
+ const text = new TextDecoder("utf8", { fatal: true, ignoreBOM: true }).decode(bytes);
425
418
  return { body: text, bodyEncoding: "utf8" };
426
419
  } catch {
427
420
  return { body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" };
@@ -663,15 +656,20 @@ function forwardToRealUpstream(args: {
663
656
  // replayFake
664
657
  // ────────────────────────────────────────────────────────────────────────
665
658
 
666
- export function replayFake(
667
- opts: ReplayFakeOptions,
668
- ): FakeDefinition<CassetteState, ReplayHelpers> {
659
+ export function replayFake<S = undefined, H extends Record<string, unknown> = {}>(
660
+ opts: ReplayFakeOptions<S, H>,
661
+ ): FakeDefinition<CassetteState & { custom: S }, ReplayHelpers & H> {
669
662
  if (!opts.name) throw new Error("replayFake: `name` is required");
670
663
  if (!opts.host || typeof opts.host !== "string") {
671
664
  throw new Error(`replayFake(${opts.name}): "host" (the real hostname) is required`);
672
665
  }
673
666
  const host = opts.host.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
674
- const match = defaultMatch(opts.match);
667
+ if ("match" in opts) {
668
+ throw new Error(`replayFake(${opts.name}): \`match\` has been replaced by \`key(request)\`, which defines the complete request identity`);
669
+ }
670
+ if (opts.key !== undefined && typeof opts.key !== "function") {
671
+ throw new Error(`replayFake(${opts.name}): \`key\` must be a synchronous function`);
672
+ }
675
673
  const injectRules = opts.inject ?? [];
676
674
  // Refs every header template references — the control plane resolves these
677
675
  // server-side and pushes them for the environment lifetime (see the daemon's
@@ -685,19 +683,33 @@ export function replayFake(
685
683
  };
686
684
 
687
685
  // ── REPLAY ──────────────────────────────────────────────────────────
686
+ function signature(request: CassetteRequest, location: string): string {
687
+ try {
688
+ return replaySignature(request, opts.key);
689
+ } catch (error) {
690
+ const message = error instanceof Error ? error.message : String(error);
691
+ throw new Error(`replayFake(${opts.name}): invalid replay key for ${location}: ${message}`);
692
+ }
693
+ }
694
+
688
695
  function replay(reqLike: CassetteRequest, state: CassetteState): Response {
689
- const sig = signature(reqLike, match);
690
- const matches: CassetteInteraction[] = [];
691
- for (const it of state.cassette.interactions) {
692
- if (signature(it.request, match) === sig) matches.push(it);
696
+ let sig: string;
697
+ try {
698
+ sig = signature(reqLike, "incoming request");
699
+ } catch (error) {
700
+ return new Response((error as Error).message + "\n", {
701
+ status: 599, headers: { "content-type": "text/plain" },
702
+ });
693
703
  }
704
+ const matches = state.index.get(sig) ?? [];
694
705
  const idx = state.cursor.get(sig) ?? 0;
695
- const hit = matches[idx];
706
+ const interactionIndex = matches[idx];
707
+ const hit = interactionIndex === undefined ? undefined : state.cassette.interactions[interactionIndex];
696
708
  if (!hit) {
697
709
  const body =
698
710
  `replayFake(${opts.name}): no cassette interaction for ` +
699
- `${reqLike.method} ${reqLike.path} (matched ${matches.length}, wanted #${idx + 1}). ` +
700
- `Re-record the cassette, or relax \`match\`.\n`;
711
+ `${reqLike.method} ${reqLike.path} (${matches.length === 0 ? "unknown key" : "exhausted key"}; matched ${matches.length}, wanted #${idx + 1}). ` +
712
+ `Check \`key\` or re-record the cassette.\n`;
701
713
  return new Response(body, {
702
714
  status: 599,
703
715
  headers: { "content-type": "text/plain" },
@@ -883,7 +895,7 @@ export function replayFake(
883
895
  method: reqLike.method,
884
896
  path: reqLike.path,
885
897
  query: redactedQuery,
886
- headers: reqLike.headers,
898
+ headers: Object.fromEntries(Object.entries(reqLike.headers).map(([k, v]) => [k, redact(v)])),
887
899
  ...(reqLike.body !== undefined
888
900
  ? {
889
901
  bodyHash: reqLike.bodyHash,
@@ -931,6 +943,9 @@ export function replayFake(
931
943
  state: CassetteState,
932
944
  _ctx: FakeContext,
933
945
  ): Promise<Response> => {
946
+ if (req.bodyUsed) {
947
+ throw new Error(`replayFake(${opts.name}): cannot delegate a consumed request body; inspect request.clone() before calling ctx.replay(request)`);
948
+ }
934
949
  const url = new URL(req.url);
935
950
  const bodyBytes =
936
951
  req.method === "GET" || req.method === "HEAD"
@@ -943,7 +958,7 @@ export function replayFake(
943
958
  // Strip SigV4 `x-amz-*` query params from the match key when signing —
944
959
  // presigned URLs carry a per-request signature there.
945
960
  query: normalizeQuery(url.searchParams, opts.sign !== undefined),
946
- headers: recordable(req.headers, ["content-type", ...(match.headers ?? [])]),
961
+ headers: recordable(req.headers, [...req.headers.keys()]),
947
962
  ...(decodedBody
948
963
  ? {
949
964
  bodyHash: sha256Bytes(bodyBytes),
@@ -957,58 +972,88 @@ export function replayFake(
957
972
  return record(req, url, reqLike, bodyBytes, state);
958
973
  };
959
974
 
960
- const def: FakeDefinition<CassetteState, ReplayHelpers> = {
975
+ const replayHelpers = (state: CassetteState): ReplayHelpers => ({
976
+ replay(file: string | Cassette): ReplaySummary {
977
+ let loaded: Cassette;
978
+ let label: string;
979
+ if (file && typeof file === "object") {
980
+ loaded = coerceCassette(file, opts.name, host);
981
+ label = file.fake || "(inline)";
982
+ } else if (typeof file === "string" && file.length > 0) {
983
+ label = file;
984
+ loaded = readCassetteFile(opts.name, file, host);
985
+ } else {
986
+ throw new Error(
987
+ `replayFake(${opts.name}): replay(...) needs a cassette path under spectest/tests/ ` +
988
+ `(e.g. "recordings/${opts.name}.json").`,
989
+ );
990
+ }
991
+ // Build/validate before replacing live state: a bad key/cassette
992
+ // must leave the previous cassette and consumption cursors intact.
993
+ const index = new Map<string, number[]>();
994
+ for (const [i, interaction] of loaded.interactions.entries()) {
995
+ const sig = signature(interaction.request, `${JSON.stringify(label)} interaction #${i + 1}`);
996
+ const queue = index.get(sig);
997
+ if (queue) queue.push(i);
998
+ else index.set(sig, [i]);
999
+ }
1000
+ // Mutate the live state object (the handler reads the same
1001
+ // reference); reset the cursor so replay starts from the top.
1002
+ state.cassette = loaded;
1003
+ state.index = index;
1004
+ state.cursor = new Map();
1005
+ return { cassette: label, host: loaded.host, interactions: loaded.interactions.length };
1006
+ },
1007
+ dump(): Cassette {
1008
+ return structuredClone({
1009
+ version: 1,
1010
+ fake: opts.name,
1011
+ host,
1012
+ interactions: [...state.cassette.interactions, ...state.recorded],
1013
+ });
1014
+ },
1015
+ recorded(): CassetteInteraction[] {
1016
+ return structuredClone(state.recorded);
1017
+ },
1018
+ count(): number {
1019
+ return state.recorded.length;
1020
+ },
1021
+ });
1022
+
1023
+ const initialState = (custom: S): CassetteState & { custom: S } => ({
1024
+ cassette: emptyCassette(opts.name, host),
1025
+ index: new Map(),
1026
+ cursor: new Map(),
1027
+ recorded: [],
1028
+ dirty: false,
1029
+ custom,
1030
+ });
1031
+
1032
+ const def: FakeDefinition<CassetteState & { custom: S }, ReplayHelpers & H> = {
961
1033
  name: opts.name,
962
- // Fake = real: we answer for the real host on :80 and :443.
963
1034
  hostnames: [host],
964
1035
  secretRefs,
965
- // Cassettes are NOT auto-loaded — a fork starts with an empty replay
966
- // set, so a test that loads nothing fails loud (599). Tests call
967
- // `replay(...)` to populate it.
968
- state: (): CassetteState => ({
969
- cassette: emptyCassette(opts.name, host),
970
- cursor: new Map(),
971
- recorded: [],
972
- dirty: false,
973
- }),
974
- handler,
975
- helpers: ({ state }): ReplayHelpers => ({
976
- replay(file: string | Cassette): ReplaySummary {
977
- let loaded: Cassette;
978
- let label: string;
979
- if (file && typeof file === "object") {
980
- loaded = coerceCassette(file, opts.name, host);
981
- label = file.fake || "(inline)";
982
- } else if (typeof file === "string" && file.length > 0) {
983
- label = file;
984
- loaded = readCassetteFile(opts.name, file, host);
985
- } else {
986
- throw new Error(
987
- `replayFake(${opts.name}): replay(...) needs a cassette path under spectest/tests/ ` +
988
- `(e.g. "recordings/${opts.name}.json").`,
989
- );
1036
+ state: () => opts.state
1037
+ ? Promise.resolve(opts.state()).then(initialState)
1038
+ : initialState(undefined as S),
1039
+ handler: (request, state, ctx) => opts.handler
1040
+ ? opts.handler(request, state.custom, {
1041
+ ...ctx,
1042
+ replay: (delegated) => handler(delegated, state, ctx),
1043
+ })
1044
+ : handler(request, state, ctx),
1045
+ helpers: ({ name, state, ctx }) => {
1046
+ const builtins = replayHelpers(state);
1047
+ if (!opts.helpers) return builtins as ReplayHelpers & H;
1048
+ return Promise.resolve(opts.helpers({ name, state: state.custom, ctx })).then((custom) => {
1049
+ for (const key of Object.keys(builtins)) {
1050
+ if (key in custom) {
1051
+ throw new Error(`replayFake(${opts.name}): custom helper ${JSON.stringify(key)} is reserved for recording/replay; choose another name`);
1052
+ }
990
1053
  }
991
- // Mutate the live state object (the handler reads the same
992
- // reference); reset the cursor so replay starts from the top.
993
- state.cassette = loaded;
994
- state.cursor = new Map();
995
- return { cassette: label, host: loaded.host, interactions: loaded.interactions.length };
996
- },
997
- dump(): Cassette {
998
- return {
999
- version: 1,
1000
- fake: opts.name,
1001
- host,
1002
- interactions: [...state.cassette.interactions, ...state.recorded],
1003
- };
1004
- },
1005
- recorded(): CassetteInteraction[] {
1006
- return state.recorded.slice();
1007
- },
1008
- count(): number {
1009
- return state.recorded.length;
1010
- },
1011
- }),
1054
+ return { ...custom, ...builtins };
1055
+ });
1056
+ },
1012
1057
  };
1013
1058
  return def;
1014
1059
  }