@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.
- package/package.json +38 -0
- package/src/browser.ts +824 -0
- package/src/components/index.ts +32 -0
- package/src/components/k3s.ts +1324 -0
- package/src/components/postgres.ts +281 -0
- package/src/components/replayFake.ts +515 -0
- package/src/daemon.ts +3910 -0
- package/src/index.ts +1601 -0
- package/src/ingress.ts +288 -0
- package/src/inspect.ts +604 -0
- package/src/record-secrets.ts +41 -0
- package/src/recorder.ts +659 -0
- package/src/resolver.ts +351 -0
- package/src/terminal.ts +740 -0
- package/src/vendor/rrweb-plugin-console-record.umd.js +520 -0
- package/src/vendor/rrweb-record.min.js +5 -0
|
@@ -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
|
+
}
|