@specific.dev/spectest 0.26.0 → 0.27.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/dist/aws-sigv4.d.ts +42 -0
- package/dist/aws-sigv4.js +166 -0
- package/dist/browser.d.ts +314 -0
- package/dist/browser.js +1320 -0
- package/dist/components/email.d.ts +135 -0
- package/dist/components/email.js +271 -0
- package/dist/components/expo.d.ts +69 -0
- package/dist/components/expo.js +125 -0
- package/dist/components/index.d.ts +8 -0
- package/dist/components/index.js +18 -0
- package/dist/components/k3s.d.ts +143 -0
- package/dist/components/k3s.js +1067 -0
- package/dist/components/postgres.d.ts +93 -0
- package/dist/components/postgres.js +58 -0
- package/dist/components/replayFake.d.ts +169 -0
- package/dist/components/replayFake.js +738 -0
- package/dist/components/s3.d.ts +99 -0
- package/dist/components/s3.js +81 -0
- package/dist/components/supabase.d.ts +197 -0
- package/dist/components/supabase.js +1003 -0
- package/dist/daemon.d.ts +1 -0
- package/dist/daemon.js +4223 -0
- package/dist/ids.d.ts +2 -0
- package/{src/ids.ts → dist/ids.js} +46 -50
- package/dist/index.d.ts +1183 -0
- package/dist/index.js +769 -0
- package/dist/ingress.d.ts +114 -0
- package/dist/ingress.js +210 -0
- package/dist/inspect.d.ts +228 -0
- package/dist/inspect.js +429 -0
- package/dist/locator.d.ts +260 -0
- package/dist/locator.js +293 -0
- package/dist/mobile.d.ts +71 -0
- package/dist/mobile.js +65 -0
- package/dist/record-secrets.d.ts +9 -0
- package/{src/record-secrets.ts → dist/record-secrets.js} +13 -15
- package/dist/recorder.d.ts +516 -0
- package/dist/recorder.js +219 -0
- package/dist/redis.d.ts +54 -0
- package/dist/redis.js +126 -0
- package/dist/replay-bundle.d.ts +38 -0
- package/{src/replay-bundle.ts → dist/replay-bundle.js} +29 -47
- package/dist/resolver.d.ts +1 -0
- package/dist/resolver.js +309 -0
- package/dist/s3.d.ts +89 -0
- package/dist/s3.js +198 -0
- package/dist/sql.d.ts +74 -0
- package/dist/sql.js +151 -0
- package/dist/terminal.d.ts +161 -0
- package/dist/terminal.js +538 -0
- package/package.json +24 -9
- package/src/browser.ts +0 -1819
- package/src/components/email.ts +0 -398
- package/src/components/expo.ts +0 -167
- package/src/components/index.ts +0 -63
- package/src/components/k3s.ts +0 -1312
- package/src/components/postgres.ts +0 -105
- package/src/components/replayFake.ts +0 -848
- package/src/components/s3.ts +0 -132
- package/src/components/supabase.ts +0 -1299
- package/src/daemon.ts +0 -4969
- package/src/index.ts +0 -2350
- package/src/ingress.ts +0 -288
- package/src/inspect.ts +0 -673
- package/src/locator.ts +0 -594
- package/src/mobile.ts +0 -133
- package/src/recorder.ts +0 -817
- package/src/redis.ts +0 -202
- package/src/resolver.ts +0 -351
- package/src/s3.ts +0 -333
- package/src/sql.ts +0 -243
- package/src/terminal.ts +0 -740
- package/src/vendor/rrweb-plugin-console-record.umd.js +0 -521
- package/src/vendor/rrweb-record.min.js +0 -5061
|
@@ -1,848 +0,0 @@
|
|
|
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
|
-
// You fake the REAL host directly — `host: "api.stripe.com"`, not a
|
|
10
|
-
// stand-in. The fake answers for that name on both `http://` (:80) and
|
|
11
|
-
// `https://` (:443, in-VM-CA leaf cert), and the SAME name is what gets
|
|
12
|
-
// forwarded to the real upstream in record mode. The two modes:
|
|
13
|
-
//
|
|
14
|
-
// - REPLAY (hermetic): the request is matched against the cassette the
|
|
15
|
-
// test EXPLICITLY loaded (`ctx.fakes.<name>.replay(file)`) and the
|
|
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.
|
|
18
|
-
// This is what runs under `spectest test`.
|
|
19
|
-
// - RECORD (MITM): the request is forwarded to the REAL host and the
|
|
20
|
-
// request/response pair is captured (decoded, redacted), and the real
|
|
21
|
-
// response is returned to the app so a manual session behaves like
|
|
22
|
-
// production. This runs under `spectest_eval` / a manual env.
|
|
23
|
-
//
|
|
24
|
-
// Because the fake's hostname IS the real host, the in-VM resolver points
|
|
25
|
-
// that name at the daemon — so a naive `fetch("https://api.stripe.com")`
|
|
26
|
-
// from the record-mode forwarder would loop straight back into us. The
|
|
27
|
-
// forwarder therefore resolves the real IP itself against an EXTERNAL DNS
|
|
28
|
-
// server (default 1.1.1.1, `SPECTEST_UPSTREAM_DNS`), then opens a real TLS
|
|
29
|
-
// connection to that IP with the correct SNI/`Host` (validating the real
|
|
30
|
-
// public cert). This is a transparent MITM: the app's leg is terminated
|
|
31
|
-
// with our in-VM-CA leaf; the upstream leg is a genuine public-cert TLS
|
|
32
|
-
// handshake to the real host.
|
|
33
|
-
//
|
|
34
|
-
// Mode is chosen by `isRecording()`: it is true only inside an active
|
|
35
|
-
// recorder (a `spectest test` case), false in eval/manual. So `auto`
|
|
36
|
-
// (the default) replays under test and records under eval — no new
|
|
37
|
-
// control-plane mode flag. `mode: "record" | "replay"` overrides it.
|
|
38
|
-
//
|
|
39
|
-
// Credential brokering (per-fake): `inject` rules set
|
|
40
|
-
// headers on the egress forward — overwriting whatever the app sent — so
|
|
41
|
-
// app code never holds the credential. Header values embed `{{secret:REF}}`
|
|
42
|
-
// tokens; each REF is resolved server-side from the platform Secrets store
|
|
43
|
-
// and pushed eval-scoped from the control plane (via {@link getRecordSecret}).
|
|
44
|
-
// The real value lives only on the outbound wire to the real upstream and
|
|
45
|
-
// is redacted from the cassette (fail-closed) — it never enters project
|
|
46
|
-
// code, the tarball, the warm-cache hash, or any snapshot a hermetic run
|
|
47
|
-
// could fork.
|
|
48
|
-
|
|
49
|
-
import { createSocket } from "node:dgram";
|
|
50
|
-
import { createHash } from "node:crypto";
|
|
51
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
52
|
-
import { request as httpsRequest } from "node:https";
|
|
53
|
-
import path from "node:path";
|
|
54
|
-
import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
|
|
55
|
-
import * as dnsPacket from "dns-packet";
|
|
56
|
-
|
|
57
|
-
import type { FakeContext, FakeDefinition } from "../index.js";
|
|
58
|
-
import { isRecording } from "../recorder.js";
|
|
59
|
-
import { getRecordSecret } from "../record-secrets.js";
|
|
60
|
-
|
|
61
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
62
|
-
// Options + cassette format
|
|
63
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
64
|
-
|
|
65
|
-
/** Which parts of a request define a match against the cassette. */
|
|
66
|
-
export interface ReplayMatch {
|
|
67
|
-
/** Match on HTTP method. Default `true`. */
|
|
68
|
-
method?: boolean;
|
|
69
|
-
/** Match on path (no query). Default `true`. */
|
|
70
|
-
path?: boolean;
|
|
71
|
-
/** Match on normalized query string. Default `true`. */
|
|
72
|
-
query?: boolean;
|
|
73
|
-
/** Match on body: `true` = exact decoded body, `"hash"` = sha256 only.
|
|
74
|
-
* Default off. */
|
|
75
|
-
body?: boolean | "hash";
|
|
76
|
-
/** Header names (lowercase) to include in the match. Default none. */
|
|
77
|
-
headers?: string[];
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** Match one request dimension. A bare string is an exact match; the
|
|
81
|
-
* object forms cover prefix and regex matching. */
|
|
82
|
-
export type StringMatch =
|
|
83
|
-
| string
|
|
84
|
-
| { exact: string }
|
|
85
|
-
| { startsWith: string }
|
|
86
|
-
| { regex: string };
|
|
87
|
-
|
|
88
|
-
/** Request matchers for a credential-injection rule. A rule applies only
|
|
89
|
-
* when EVERY specified dimension matches; omit `match` to always apply. */
|
|
90
|
-
export interface InjectMatch {
|
|
91
|
-
/** Match the request path (no query). */
|
|
92
|
-
path?: StringMatch;
|
|
93
|
-
/** HTTP method(s) — any one matching satisfies it. */
|
|
94
|
-
method?: string | string[];
|
|
95
|
-
/** Query-param matchers (ANDed); each key's value(s) must match. */
|
|
96
|
-
query?: Record<string, StringMatch>;
|
|
97
|
-
/** Header matchers (ANDed; header names case-insensitive). */
|
|
98
|
-
headers?: Record<string, StringMatch>;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** One credential-brokering rule. When it matches a request, these headers
|
|
102
|
-
* are SET on the egress forward — overwriting whatever the app sent (so app
|
|
103
|
-
* code can't smuggle a different value past the broker). Header VALUES may
|
|
104
|
-
* embed `{{secret:REF}}` tokens; each `REF` is resolved server-side from the
|
|
105
|
-
* project's Secrets store and pushed eval-scoped — the real value never
|
|
106
|
-
* enters project code, the
|
|
107
|
-
* tarball, the warm-cache hash, or a cassette (it's redacted, fail-closed). */
|
|
108
|
-
export interface InjectRule {
|
|
109
|
-
match?: InjectMatch;
|
|
110
|
-
headers: Record<string, string>;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export interface ReplayFakeOptions {
|
|
114
|
-
/** Stable name — the `ctx.fakes` key. */
|
|
115
|
-
name: string;
|
|
116
|
-
/** The REAL host to fake, e.g. `"api.stripe.com"`. The fake answers for
|
|
117
|
-
* this name on both `http://` (:80) and `https://` (:443), and the same
|
|
118
|
-
* name is forwarded to the real upstream in record mode (over HTTPS,
|
|
119
|
-
* resolved via an external DNS server — see the module header). Your
|
|
120
|
-
* app points at this real host directly; no separate stand-in. */
|
|
121
|
-
host: string;
|
|
122
|
-
/** What defines a request match. Default `{ method, path, query }`. */
|
|
123
|
-
match?: ReplayMatch;
|
|
124
|
-
/** Per-fake credential brokering. Rules are evaluated in
|
|
125
|
-
* order; the first whose `match` matches wins (a rule without `match`
|
|
126
|
-
* matches everything and shadows later rules). Applied at the
|
|
127
|
-
* record-mode egress forward; resolved secret values are redacted from
|
|
128
|
-
* the cassette (fail-closed). */
|
|
129
|
-
inject?: InjectRule[];
|
|
130
|
-
/** Extra substrings/patterns to scrub from stored request/response
|
|
131
|
-
* bodies and queries (every injected secret value is always scrubbed). */
|
|
132
|
-
redactPatterns?: (string | RegExp)[];
|
|
133
|
-
/** `"auto"` (default) records under eval and replays under test;
|
|
134
|
-
* `"record"` / `"replay"` force a mode. */
|
|
135
|
-
mode?: "auto" | "record" | "replay";
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
export interface CassetteRequest {
|
|
139
|
-
method: string;
|
|
140
|
-
path: string;
|
|
141
|
-
/** Normalized: keys + repeated values sorted, so matching is order-free. */
|
|
142
|
-
query: Record<string, string[]>;
|
|
143
|
-
/** Matched/readable header subset only — never `authorization`/`cookie`. */
|
|
144
|
-
headers: Record<string, string>;
|
|
145
|
-
bodyHash?: string;
|
|
146
|
-
/** Decoded + redacted request body (omitted when empty). */
|
|
147
|
-
body?: string;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export interface CassetteResponse {
|
|
151
|
-
status: number;
|
|
152
|
-
headers: Record<string, string>;
|
|
153
|
-
/** Decoded + redacted response body. */
|
|
154
|
-
body: string;
|
|
155
|
-
bodyEncoding: "utf8" | "base64";
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
export interface CassetteInteraction {
|
|
159
|
-
request: CassetteRequest;
|
|
160
|
-
response: CassetteResponse;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
export interface Cassette {
|
|
164
|
-
version: 1;
|
|
165
|
-
fake: string;
|
|
166
|
-
/** The real host this cassette mirrors (e.g. `api.stripe.com`). */
|
|
167
|
-
host: string;
|
|
168
|
-
interactions: CassetteInteraction[];
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** Forked-per-test state for one replay fake. */
|
|
172
|
-
export interface CassetteState {
|
|
173
|
-
cassette: Cassette;
|
|
174
|
-
/** Per-match-signature replay cursor (call-order sequencing). */
|
|
175
|
-
cursor: Map<string, number>;
|
|
176
|
-
/** Interactions captured this record session. */
|
|
177
|
-
recorded: CassetteInteraction[];
|
|
178
|
-
dirty: boolean;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/** What `replay(...)` returns — a small summary that surfaces in the test
|
|
182
|
-
* timeline (recorded as a `fake` event) so the run shows exactly which
|
|
183
|
-
* cassette a test loaded and how many interactions it carried. */
|
|
184
|
-
export interface ReplaySummary {
|
|
185
|
-
/** Filename loaded (under `spectest/recordings/`) or `"(inline)"`. */
|
|
186
|
-
cassette: string;
|
|
187
|
-
/** The real host the cassette mirrors. */
|
|
188
|
-
host: string;
|
|
189
|
-
/** Number of interactions now available for replay. */
|
|
190
|
-
interactions: number;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
export interface ReplayHelpers extends Record<string, unknown> {
|
|
194
|
-
/** Load a cassette into THIS fork's replay set and replay from it,
|
|
195
|
-
* replacing whatever was loaded before and resetting the replay cursor.
|
|
196
|
-
* Cassettes are NOT auto-loaded — call this at the top of a test. Pass a
|
|
197
|
-
* path relative to `spectest/tests/` (e.g. `"recordings/stripe.json"`; it
|
|
198
|
-
* must stay under `spectest/tests/`) or a cassette object; a missing file
|
|
199
|
-
* throws. The returned summary is recorded as a step, so the UI timeline
|
|
200
|
-
* shows what was loaded. */
|
|
201
|
-
replay(file: string | Cassette): ReplaySummary;
|
|
202
|
-
/** Full cassette JSON (loaded interactions + newly recorded, redacted)
|
|
203
|
-
* — the MVP delivery channel: `export default ctx.fakes.<name>.dump()`,
|
|
204
|
-
* then write the result to `spectest/recordings/<name>.json`. */
|
|
205
|
-
dump(): Cassette;
|
|
206
|
-
/** Interactions captured this record session. */
|
|
207
|
-
recorded(): CassetteInteraction[];
|
|
208
|
-
/** Count of interactions captured this record session. */
|
|
209
|
-
count(): number;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
213
|
-
// Helpers
|
|
214
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
215
|
-
|
|
216
|
-
const SENSITIVE_HEADERS = new Set([
|
|
217
|
-
"authorization",
|
|
218
|
-
"cookie",
|
|
219
|
-
"set-cookie",
|
|
220
|
-
"proxy-authorization",
|
|
221
|
-
"x-api-key",
|
|
222
|
-
"x-amz-security-token",
|
|
223
|
-
]);
|
|
224
|
-
|
|
225
|
-
/** Hop-by-hop + body-framing headers never forwarded / never replayed
|
|
226
|
-
* (we decompress on the way through, so the encoding/length no longer
|
|
227
|
-
* describe the bytes the app receives). */
|
|
228
|
-
const STRIP_FORWARD_HEADERS = new Set([
|
|
229
|
-
"host",
|
|
230
|
-
"connection",
|
|
231
|
-
"keep-alive",
|
|
232
|
-
"proxy-authenticate",
|
|
233
|
-
"proxy-authorization",
|
|
234
|
-
"te",
|
|
235
|
-
"trailers",
|
|
236
|
-
"transfer-encoding",
|
|
237
|
-
"upgrade",
|
|
238
|
-
"content-length",
|
|
239
|
-
"content-encoding",
|
|
240
|
-
// We force `accept-encoding: identity` on the forward and decompress any
|
|
241
|
-
// residual encoding, so the app never sees a stale accept-encoding.
|
|
242
|
-
"accept-encoding",
|
|
243
|
-
]);
|
|
244
|
-
|
|
245
|
-
/** External resolver used to find the REAL upstream IP, bypassing the in-VM
|
|
246
|
-
* resolver (which points the faked host back at the daemon). */
|
|
247
|
-
const EXTERNAL_DNS = process.env.SPECTEST_UPSTREAM_DNS ?? "1.1.1.1";
|
|
248
|
-
const EXTERNAL_DNS_PORT = Number(process.env.SPECTEST_UPSTREAM_PORT ?? "53");
|
|
249
|
-
|
|
250
|
-
function defaultMatch(m: ReplayMatch | undefined): Required<Omit<ReplayMatch, "body" | "headers">> &
|
|
251
|
-
Pick<ReplayMatch, "body" | "headers"> {
|
|
252
|
-
return {
|
|
253
|
-
method: m?.method ?? true,
|
|
254
|
-
path: m?.path ?? true,
|
|
255
|
-
query: m?.query ?? true,
|
|
256
|
-
body: m?.body,
|
|
257
|
-
headers: m?.headers,
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function normalizeQuery(search: URLSearchParams): Record<string, string[]> {
|
|
262
|
-
const out: Record<string, string[]> = {};
|
|
263
|
-
for (const key of [...search.keys()].sort()) {
|
|
264
|
-
out[key] = search.getAll(key).slice().sort();
|
|
265
|
-
}
|
|
266
|
-
return out;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
function sha256(s: string): string {
|
|
270
|
-
return "sha256:" + createHash("sha256").update(s).digest("hex");
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function recordable(headers: Headers, want: string[]): Record<string, string> {
|
|
274
|
-
const out: Record<string, string> = {};
|
|
275
|
-
for (const name of want) {
|
|
276
|
-
const lower = name.toLowerCase();
|
|
277
|
-
if (SENSITIVE_HEADERS.has(lower)) continue;
|
|
278
|
-
const v = headers.get(lower);
|
|
279
|
-
if (v !== null) out[lower] = v;
|
|
280
|
-
}
|
|
281
|
-
return out;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/** Canonical match signature for an interaction's request, used as both
|
|
285
|
-
* the replay-cursor key and the equality basis (two requests match iff
|
|
286
|
-
* their signatures are equal). Computed identically for an incoming
|
|
287
|
-
* request and a stored `CassetteRequest`. */
|
|
288
|
-
function signature(req: CassetteRequest, match: ReturnType<typeof defaultMatch>): string {
|
|
289
|
-
const sig: Record<string, unknown> = {};
|
|
290
|
-
if (match.method) sig.method = req.method.toUpperCase();
|
|
291
|
-
if (match.path) sig.path = req.path;
|
|
292
|
-
if (match.query) sig.query = req.query;
|
|
293
|
-
if (match.body === true) sig.body = req.body ?? "";
|
|
294
|
-
else if (match.body === "hash") sig.bodyHash = req.bodyHash ?? "";
|
|
295
|
-
if (match.headers && match.headers.length > 0) {
|
|
296
|
-
const h: Record<string, string> = {};
|
|
297
|
-
for (const name of match.headers.map((n) => n.toLowerCase()).sort()) {
|
|
298
|
-
h[name] = req.headers[name] ?? "";
|
|
299
|
-
}
|
|
300
|
-
sig.headers = h;
|
|
301
|
-
}
|
|
302
|
-
return JSON.stringify(sig);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/** The project's `spectest/tests/` dir — cassettes live alongside the
|
|
306
|
-
* tests, and a `replay(path)` argument is a path relative to here. Keeping
|
|
307
|
-
* them under `tests/` means re-recording is picked up on the next run with
|
|
308
|
-
* no environment rebuild (that subtree is re-imported with the tests). */
|
|
309
|
-
function cassetteRoot(): string {
|
|
310
|
-
const spectestDir =
|
|
311
|
-
process.env.SPECTEST_PROJECT_DIR ??
|
|
312
|
-
path.join(process.env.SPECTEST_APP_DIR ?? "/opt/spectest/app", "spectest");
|
|
313
|
-
return path.join(spectestDir, "tests");
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function emptyCassette(name: string, host: string): Cassette {
|
|
317
|
-
return { version: 1, fake: name, host, interactions: [] };
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/** Validate + normalize a parsed cassette (from a file or passed inline). */
|
|
321
|
-
function coerceCassette(parsed: Cassette, name: string, host: string): Cassette {
|
|
322
|
-
if (!parsed || !Array.isArray(parsed.interactions)) {
|
|
323
|
-
throw new Error(`replayFake(${name}): cassette has no \`interactions\` array`);
|
|
324
|
-
}
|
|
325
|
-
return {
|
|
326
|
-
version: 1,
|
|
327
|
-
fake: parsed.fake ?? name,
|
|
328
|
-
host: parsed.host ?? host,
|
|
329
|
-
interactions: parsed.interactions,
|
|
330
|
-
};
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/** Read a cassette by its path relative to `spectest/tests/` (e.g.
|
|
334
|
-
* `"recordings/stripe.json"`). The path must stay under `spectest/tests/`,
|
|
335
|
-
* and explicit loads must point at a real file — a missing one throws (fail
|
|
336
|
-
* loud) rather than silently replaying nothing. */
|
|
337
|
-
function readCassetteFile(name: string, file: string, host: string): Cassette {
|
|
338
|
-
const base = cassetteRoot();
|
|
339
|
-
const p = path.resolve(base, file);
|
|
340
|
-
if (p !== base && !p.startsWith(base + path.sep)) {
|
|
341
|
-
throw new Error(
|
|
342
|
-
`replayFake(${name}): cassette path ${JSON.stringify(file)} must be under spectest/tests/`,
|
|
343
|
-
);
|
|
344
|
-
}
|
|
345
|
-
if (!existsSync(p)) {
|
|
346
|
-
throw new Error(
|
|
347
|
-
`replayFake(${name}): cassette not found at ${p} — record one first, or pass a cassette object.`,
|
|
348
|
-
);
|
|
349
|
-
}
|
|
350
|
-
let parsed: Cassette;
|
|
351
|
-
try {
|
|
352
|
-
parsed = JSON.parse(readFileSync(p, "utf8")) as Cassette;
|
|
353
|
-
} catch (err) {
|
|
354
|
-
throw new Error(
|
|
355
|
-
`replayFake(${name}): failed to read cassette ${p}: ${(err as Error).message}`,
|
|
356
|
-
);
|
|
357
|
-
}
|
|
358
|
-
return coerceCassette(parsed, name, host);
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
/** Decode response bytes to a string, falling back to base64 for binary. */
|
|
362
|
-
function decodeBody(bytes: Uint8Array): { body: string; bodyEncoding: "utf8" | "base64" } {
|
|
363
|
-
try {
|
|
364
|
-
const text = new TextDecoder("utf8", { fatal: true }).decode(bytes);
|
|
365
|
-
return { body: text, bodyEncoding: "utf8" };
|
|
366
|
-
} catch {
|
|
367
|
-
return { body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" };
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/** One resolved secret value and the ref it came from (for the redaction
|
|
372
|
-
* placeholder). */
|
|
373
|
-
interface ResolvedSecret {
|
|
374
|
-
ref: string;
|
|
375
|
-
value: string;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
function makeRedactor(
|
|
379
|
-
secrets: ResolvedSecret[],
|
|
380
|
-
patterns: (string | RegExp)[] | undefined,
|
|
381
|
-
): (s: string) => string {
|
|
382
|
-
return (s: string): string => {
|
|
383
|
-
let out = s;
|
|
384
|
-
for (const { ref, value } of secrets) {
|
|
385
|
-
if (value.length > 0) out = out.split(value).join(`<REDACTED:${ref}>`);
|
|
386
|
-
}
|
|
387
|
-
for (const p of patterns ?? []) {
|
|
388
|
-
if (typeof p === "string") {
|
|
389
|
-
if (p.length > 0) out = out.split(p).join("<REDACTED>");
|
|
390
|
-
} else {
|
|
391
|
-
const g = p.flags.includes("g") ? p : new RegExp(p.source, p.flags + "g");
|
|
392
|
-
out = out.replace(g, "<REDACTED>");
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
return out;
|
|
396
|
-
};
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
400
|
-
// Credential brokering: matchers + secret-token resolution
|
|
401
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
402
|
-
|
|
403
|
-
/** `{{secret:REF}}` template token embedded in an injected header value. */
|
|
404
|
-
const SECRET_TOKEN = /\{\{secret:([A-Za-z0-9_]+)\}\}/g;
|
|
405
|
-
|
|
406
|
-
/** Every secret ref referenced by any inject rule's header templates. */
|
|
407
|
-
function collectSecretRefs(rules: InjectRule[]): string[] {
|
|
408
|
-
const refs = new Set<string>();
|
|
409
|
-
for (const rule of rules) {
|
|
410
|
-
for (const value of Object.values(rule.headers)) {
|
|
411
|
-
for (const m of value.matchAll(SECRET_TOKEN)) refs.add(m[1]);
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
return [...refs];
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
function matchString(m: StringMatch, value: string): boolean {
|
|
418
|
-
if (typeof m === "string") return value === m;
|
|
419
|
-
if ("exact" in m) return value === m.exact;
|
|
420
|
-
if ("startsWith" in m) return value.startsWith(m.startsWith);
|
|
421
|
-
if ("regex" in m) return new RegExp(m.regex).test(value);
|
|
422
|
-
return false;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
interface IncomingRequest {
|
|
426
|
-
method: string;
|
|
427
|
-
path: string;
|
|
428
|
-
query: URLSearchParams;
|
|
429
|
-
headers: Headers;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
/** True if every dimension the matcher specifies is satisfied (AND across
|
|
433
|
-
* dimensions). No `match` → always true. */
|
|
434
|
-
function ruleMatches(match: InjectMatch | undefined, req: IncomingRequest): boolean {
|
|
435
|
-
if (!match) return true;
|
|
436
|
-
if (match.path !== undefined && !matchString(match.path, req.path)) return false;
|
|
437
|
-
if (match.method !== undefined) {
|
|
438
|
-
const methods = (Array.isArray(match.method) ? match.method : [match.method]).map((s) =>
|
|
439
|
-
s.toUpperCase(),
|
|
440
|
-
);
|
|
441
|
-
if (!methods.includes(req.method.toUpperCase())) return false;
|
|
442
|
-
}
|
|
443
|
-
if (match.query) {
|
|
444
|
-
for (const [k, m] of Object.entries(match.query)) {
|
|
445
|
-
if (!req.query.getAll(k).some((v) => matchString(m, v))) return false;
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
if (match.headers) {
|
|
449
|
-
for (const [k, m] of Object.entries(match.headers)) {
|
|
450
|
-
const v = req.headers.get(k.toLowerCase());
|
|
451
|
-
if (v === null || !matchString(m, v)) return false;
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
return true;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
interface BrokeredHeaders {
|
|
458
|
-
/** Lowercased header → resolved value, to SET on the forward. */
|
|
459
|
-
headers: Record<string, string>;
|
|
460
|
-
/** Resolved secrets (for redaction). */
|
|
461
|
-
secrets: ResolvedSecret[];
|
|
462
|
-
/** Refs referenced but not supplied by the control plane. */
|
|
463
|
-
missing: string[];
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
/** Resolve a rule's header templates, substituting every `{{secret:REF}}`
|
|
467
|
-
* with the eval-scoped value. Collects resolved secrets (for redaction)
|
|
468
|
-
* and any refs the control plane didn't supply (fail loud). */
|
|
469
|
-
function brokerHeaders(rule: InjectRule): BrokeredHeaders {
|
|
470
|
-
const headers: Record<string, string> = {};
|
|
471
|
-
const secrets: ResolvedSecret[] = [];
|
|
472
|
-
const missing: string[] = [];
|
|
473
|
-
const seen = new Set<string>();
|
|
474
|
-
for (const [name, template] of Object.entries(rule.headers)) {
|
|
475
|
-
headers[name.toLowerCase()] = template.replace(SECRET_TOKEN, (_full, ref: string) => {
|
|
476
|
-
const value = getRecordSecret(ref);
|
|
477
|
-
if (value === undefined) {
|
|
478
|
-
missing.push(ref);
|
|
479
|
-
return "";
|
|
480
|
-
}
|
|
481
|
-
if (!seen.has(ref)) {
|
|
482
|
-
seen.add(ref);
|
|
483
|
-
secrets.push({ ref, value });
|
|
484
|
-
}
|
|
485
|
-
return value;
|
|
486
|
-
});
|
|
487
|
-
}
|
|
488
|
-
return { headers, secrets, missing };
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
492
|
-
// Record-mode egress: resolve the real host externally, then MITM-forward
|
|
493
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
494
|
-
|
|
495
|
-
/** Resolve a host's A records against an EXTERNAL DNS server, bypassing the
|
|
496
|
-
* in-VM resolver (which would answer with the daemon's own gateway for a
|
|
497
|
-
* faked host). Mirrors spectest-resolver's own upstream-forward transport
|
|
498
|
-
* (dns-packet over UDP) so it works identically in-VM. */
|
|
499
|
-
function resolveExternalA(host: string): Promise<string[]> {
|
|
500
|
-
const query = dnsPacket.encode({
|
|
501
|
-
type: "query",
|
|
502
|
-
id: 1,
|
|
503
|
-
flags: dnsPacket.RECURSION_DESIRED,
|
|
504
|
-
questions: [{ type: "A", name: host }],
|
|
505
|
-
});
|
|
506
|
-
return new Promise((resolve) => {
|
|
507
|
-
const sock = createSocket("udp4");
|
|
508
|
-
let done = false;
|
|
509
|
-
const finish = (ips: string[]): void => {
|
|
510
|
-
if (done) return;
|
|
511
|
-
done = true;
|
|
512
|
-
try {
|
|
513
|
-
sock.close();
|
|
514
|
-
} catch {
|
|
515
|
-
// ignore
|
|
516
|
-
}
|
|
517
|
-
resolve(ips);
|
|
518
|
-
};
|
|
519
|
-
sock.on("message", (msg) => {
|
|
520
|
-
try {
|
|
521
|
-
const res = dnsPacket.decode(msg);
|
|
522
|
-
const ips = (res.answers ?? []).flatMap((a) =>
|
|
523
|
-
a.type === "A" && typeof a.data === "string" && a.data.length > 0 ? [a.data] : [],
|
|
524
|
-
);
|
|
525
|
-
finish(ips);
|
|
526
|
-
} catch {
|
|
527
|
-
finish([]);
|
|
528
|
-
}
|
|
529
|
-
});
|
|
530
|
-
sock.on("error", () => finish([]));
|
|
531
|
-
sock.send(query, EXTERNAL_DNS_PORT, EXTERNAL_DNS, (err) => {
|
|
532
|
-
if (err) finish([]);
|
|
533
|
-
});
|
|
534
|
-
setTimeout(() => finish([]), 3_000);
|
|
535
|
-
});
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
interface UpstreamResponse {
|
|
539
|
-
status: number;
|
|
540
|
-
headers: Record<string, string>;
|
|
541
|
-
/** Decompressed body bytes. */
|
|
542
|
-
bytes: Uint8Array;
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
/** Open a real HTTPS connection to `ip` but with SNI + `Host` = `host`, so
|
|
546
|
-
* the public certificate validates against the real hostname (the in-VM CA
|
|
547
|
-
* is not involved on this leg). Decompresses the response so the stored and
|
|
548
|
-
* returned bytes are the plain payload. */
|
|
549
|
-
function forwardToRealUpstream(args: {
|
|
550
|
-
ip: string;
|
|
551
|
-
host: string;
|
|
552
|
-
method: string;
|
|
553
|
-
pathWithQuery: string;
|
|
554
|
-
headers: Record<string, string>;
|
|
555
|
-
body: string;
|
|
556
|
-
}): Promise<UpstreamResponse> {
|
|
557
|
-
return new Promise((resolve, reject) => {
|
|
558
|
-
const req = httpsRequest(
|
|
559
|
-
{
|
|
560
|
-
host: args.ip,
|
|
561
|
-
port: 443,
|
|
562
|
-
method: args.method,
|
|
563
|
-
path: args.pathWithQuery,
|
|
564
|
-
servername: args.host,
|
|
565
|
-
headers: { ...args.headers, host: args.host, "accept-encoding": "identity" },
|
|
566
|
-
},
|
|
567
|
-
(res) => {
|
|
568
|
-
const chunks: Buffer[] = [];
|
|
569
|
-
res.on("data", (c: Buffer) => chunks.push(c));
|
|
570
|
-
res.on("end", () => {
|
|
571
|
-
let bytes: Uint8Array = Buffer.concat(chunks);
|
|
572
|
-
const enc = String(res.headers["content-encoding"] ?? "").toLowerCase();
|
|
573
|
-
try {
|
|
574
|
-
if (enc.includes("br")) bytes = brotliDecompressSync(bytes);
|
|
575
|
-
else if (enc.includes("gzip")) bytes = gunzipSync(bytes);
|
|
576
|
-
else if (enc.includes("deflate")) bytes = inflateSync(bytes);
|
|
577
|
-
} catch {
|
|
578
|
-
// Leave the bytes as-is if decompression fails.
|
|
579
|
-
}
|
|
580
|
-
const headers: Record<string, string> = {};
|
|
581
|
-
for (const [k, v] of Object.entries(res.headers)) {
|
|
582
|
-
if (typeof v === "string") headers[k.toLowerCase()] = v;
|
|
583
|
-
else if (Array.isArray(v)) headers[k.toLowerCase()] = v.join(", ");
|
|
584
|
-
}
|
|
585
|
-
resolve({ status: res.statusCode ?? 0, headers, bytes });
|
|
586
|
-
});
|
|
587
|
-
res.on("error", reject);
|
|
588
|
-
},
|
|
589
|
-
);
|
|
590
|
-
req.on("error", reject);
|
|
591
|
-
if (args.body.length > 0) req.write(args.body);
|
|
592
|
-
req.end();
|
|
593
|
-
});
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
597
|
-
// replayFake
|
|
598
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
599
|
-
|
|
600
|
-
export function replayFake(
|
|
601
|
-
opts: ReplayFakeOptions,
|
|
602
|
-
): FakeDefinition<CassetteState, ReplayHelpers> {
|
|
603
|
-
if (!opts.name) throw new Error("replayFake: `name` is required");
|
|
604
|
-
if (!opts.host || typeof opts.host !== "string") {
|
|
605
|
-
throw new Error(`replayFake(${opts.name}): "host" (the real hostname) is required`);
|
|
606
|
-
}
|
|
607
|
-
const host = opts.host.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
|
608
|
-
const match = defaultMatch(opts.match);
|
|
609
|
-
const injectRules = opts.inject ?? [];
|
|
610
|
-
// Refs every header template references — the control plane resolves these
|
|
611
|
-
// server-side and pushes them eval-scoped (see the daemon's
|
|
612
|
-
// /record-secret-refs endpoint, which reads `def.secretRefs`).
|
|
613
|
-
const secretRefs = collectSecretRefs(injectRules);
|
|
614
|
-
|
|
615
|
-
const resolveMode = (): "record" | "replay" => {
|
|
616
|
-
if (opts.mode === "record") return "record";
|
|
617
|
-
if (opts.mode === "replay") return "replay";
|
|
618
|
-
return isRecording() ? "replay" : "record";
|
|
619
|
-
};
|
|
620
|
-
|
|
621
|
-
// ── REPLAY ──────────────────────────────────────────────────────────
|
|
622
|
-
function replay(reqLike: CassetteRequest, state: CassetteState): Response {
|
|
623
|
-
const sig = signature(reqLike, match);
|
|
624
|
-
const matches: CassetteInteraction[] = [];
|
|
625
|
-
for (const it of state.cassette.interactions) {
|
|
626
|
-
if (signature(it.request, match) === sig) matches.push(it);
|
|
627
|
-
}
|
|
628
|
-
const idx = state.cursor.get(sig) ?? 0;
|
|
629
|
-
const hit = matches[idx];
|
|
630
|
-
if (!hit) {
|
|
631
|
-
const body =
|
|
632
|
-
`replayFake(${opts.name}): no cassette interaction for ` +
|
|
633
|
-
`${reqLike.method} ${reqLike.path} (matched ${matches.length}, wanted #${idx + 1}). ` +
|
|
634
|
-
`Re-record the cassette, or relax \`match\`.\n`;
|
|
635
|
-
return new Response(body, {
|
|
636
|
-
status: 599,
|
|
637
|
-
headers: { "content-type": "text/plain" },
|
|
638
|
-
});
|
|
639
|
-
}
|
|
640
|
-
state.cursor.set(sig, idx + 1);
|
|
641
|
-
const headers = new Headers(hit.response.headers);
|
|
642
|
-
const payload =
|
|
643
|
-
hit.response.bodyEncoding === "base64"
|
|
644
|
-
? Buffer.from(hit.response.body, "base64")
|
|
645
|
-
: hit.response.body;
|
|
646
|
-
return new Response(payload, { status: hit.response.status, headers });
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
// ── RECORD (MITM) ───────────────────────────────────────────────────
|
|
650
|
-
async function record(
|
|
651
|
-
req: Request,
|
|
652
|
-
url: URL,
|
|
653
|
-
reqLike: CassetteRequest,
|
|
654
|
-
rawBody: string,
|
|
655
|
-
state: CassetteState,
|
|
656
|
-
): Promise<Response> {
|
|
657
|
-
// Credential brokering: first matching inject rule wins. Resolve its
|
|
658
|
-
// `{{secret:REF}}` tokens from the eval-scoped store; a
|
|
659
|
-
// referenced-but-unsupplied ref fails loud.
|
|
660
|
-
const rule = injectRules.find((r) =>
|
|
661
|
-
ruleMatches(r.match, {
|
|
662
|
-
method: reqLike.method,
|
|
663
|
-
path: reqLike.path,
|
|
664
|
-
query: url.searchParams,
|
|
665
|
-
headers: req.headers,
|
|
666
|
-
}),
|
|
667
|
-
);
|
|
668
|
-
const brokered = rule ? brokerHeaders(rule) : { headers: {}, secrets: [], missing: [] };
|
|
669
|
-
if (brokered.missing.length > 0) {
|
|
670
|
-
const refs = [...new Set(brokered.missing)];
|
|
671
|
-
return new Response(
|
|
672
|
-
`replayFake(${opts.name}): secret(s) ${JSON.stringify(refs)} were not supplied ` +
|
|
673
|
-
`(configure them on the project's Secrets page, and record via spectest_eval).\n`,
|
|
674
|
-
{ status: 599, headers: { "content-type": "text/plain" } },
|
|
675
|
-
);
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
// Resolve the REAL host's IP via an external resolver so we don't loop
|
|
679
|
-
// back into the daemon (the in-VM resolver answers our own gateway for
|
|
680
|
-
// this faked name). See the module header.
|
|
681
|
-
const ips = await resolveExternalA(host);
|
|
682
|
-
if (ips.length === 0) {
|
|
683
|
-
return new Response(
|
|
684
|
-
`replayFake(${opts.name}): could not resolve real upstream ${JSON.stringify(host)} ` +
|
|
685
|
-
`via external DNS ${EXTERNAL_DNS} (is the VM online?).\n`,
|
|
686
|
-
{ status: 599, headers: { "content-type": "text/plain" } },
|
|
687
|
-
);
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
// Forward headers: drop hop-by-hop/encoding, then SET the brokered
|
|
691
|
-
// headers — overwriting whatever the app sent (so the credential can't
|
|
692
|
-
// be smuggled or spoofed by app code). The real values only ever appear
|
|
693
|
-
// on this outbound wire.
|
|
694
|
-
const fwdHeaders: Record<string, string> = {};
|
|
695
|
-
req.headers.forEach((v, k) => {
|
|
696
|
-
if (!STRIP_FORWARD_HEADERS.has(k.toLowerCase())) fwdHeaders[k.toLowerCase()] = v;
|
|
697
|
-
});
|
|
698
|
-
for (const [k, v] of Object.entries(brokered.headers)) fwdHeaders[k] = v;
|
|
699
|
-
|
|
700
|
-
const search = url.searchParams.toString();
|
|
701
|
-
const pathWithQuery = reqLike.path + (search ? `?${search}` : "");
|
|
702
|
-
|
|
703
|
-
let upstream: UpstreamResponse;
|
|
704
|
-
try {
|
|
705
|
-
upstream = await forwardToRealUpstream({
|
|
706
|
-
ip: ips[0],
|
|
707
|
-
host,
|
|
708
|
-
method: reqLike.method,
|
|
709
|
-
pathWithQuery,
|
|
710
|
-
headers: fwdHeaders,
|
|
711
|
-
body: rawBody,
|
|
712
|
-
});
|
|
713
|
-
} catch (err) {
|
|
714
|
-
return new Response(
|
|
715
|
-
`replayFake(${opts.name}): forward to real ${host} (${ips[0]}) failed: ${(err as Error).message}\n`,
|
|
716
|
-
{ status: 599, headers: { "content-type": "text/plain" } },
|
|
717
|
-
);
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
// Decode + redact for storage; return the real (un-redacted) bytes to
|
|
721
|
-
// the app so the manual session behaves like production.
|
|
722
|
-
const redact = makeRedactor(brokered.secrets, opts.redactPatterns);
|
|
723
|
-
const decoded = decodeBody(upstream.bytes);
|
|
724
|
-
const respHeaders: Record<string, string> = {};
|
|
725
|
-
for (const [k, v] of Object.entries(upstream.headers)) {
|
|
726
|
-
if (SENSITIVE_HEADERS.has(k) || STRIP_FORWARD_HEADERS.has(k)) continue;
|
|
727
|
-
respHeaders[k] = v;
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
const redactedQuery: Record<string, string[]> = {};
|
|
731
|
-
for (const [k, vs] of Object.entries(reqLike.query)) {
|
|
732
|
-
redactedQuery[k] = vs.map(redact);
|
|
733
|
-
}
|
|
734
|
-
const interaction: CassetteInteraction = {
|
|
735
|
-
request: {
|
|
736
|
-
method: reqLike.method,
|
|
737
|
-
path: reqLike.path,
|
|
738
|
-
query: redactedQuery,
|
|
739
|
-
headers: reqLike.headers,
|
|
740
|
-
...(rawBody.length > 0
|
|
741
|
-
? { bodyHash: reqLike.bodyHash, body: redact(rawBody) }
|
|
742
|
-
: {}),
|
|
743
|
-
},
|
|
744
|
-
response: {
|
|
745
|
-
status: upstream.status,
|
|
746
|
-
headers: respHeaders,
|
|
747
|
-
body: decoded.bodyEncoding === "utf8" ? redact(decoded.body) : decoded.body,
|
|
748
|
-
bodyEncoding: decoded.bodyEncoding,
|
|
749
|
-
},
|
|
750
|
-
};
|
|
751
|
-
|
|
752
|
-
// Fail-closed: never persist a cassette that still carries any raw
|
|
753
|
-
// secret value (a missed redaction is a leak, not a warning).
|
|
754
|
-
const serialized = JSON.stringify(interaction);
|
|
755
|
-
for (const { ref, value } of brokered.secrets) {
|
|
756
|
-
if (value.length > 0 && serialized.includes(value)) {
|
|
757
|
-
throw new Error(
|
|
758
|
-
`replayFake(${opts.name}): refusing to record — the value for secret ${JSON.stringify(ref)} ` +
|
|
759
|
-
`survived redaction into the interaction for ${reqLike.method} ${reqLike.path}. ` +
|
|
760
|
-
`Add a redactPatterns entry.`,
|
|
761
|
-
);
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
state.recorded.push(interaction);
|
|
766
|
-
state.dirty = true;
|
|
767
|
-
|
|
768
|
-
return new Response(upstream.bytes as unknown as BodyInit, {
|
|
769
|
-
status: upstream.status,
|
|
770
|
-
headers: new Headers(respHeaders),
|
|
771
|
-
});
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
// ── handler ─────────────────────────────────────────────────────────
|
|
775
|
-
const handler = async (
|
|
776
|
-
req: Request,
|
|
777
|
-
state: CassetteState,
|
|
778
|
-
_ctx: FakeContext,
|
|
779
|
-
): Promise<Response> => {
|
|
780
|
-
const url = new URL(req.url);
|
|
781
|
-
const rawBody = req.method === "GET" || req.method === "HEAD" ? "" : await req.text();
|
|
782
|
-
const reqLike: CassetteRequest = {
|
|
783
|
-
method: req.method.toUpperCase(),
|
|
784
|
-
path: url.pathname,
|
|
785
|
-
query: normalizeQuery(url.searchParams),
|
|
786
|
-
headers: recordable(req.headers, ["content-type", ...(match.headers ?? [])]),
|
|
787
|
-
...(rawBody.length > 0 ? { bodyHash: sha256(rawBody) } : {}),
|
|
788
|
-
};
|
|
789
|
-
|
|
790
|
-
if (resolveMode() === "replay") return replay(reqLike, state);
|
|
791
|
-
return record(req, url, reqLike, rawBody, state);
|
|
792
|
-
};
|
|
793
|
-
|
|
794
|
-
const def: FakeDefinition<CassetteState, ReplayHelpers> = {
|
|
795
|
-
name: opts.name,
|
|
796
|
-
// Fake = real: we answer for the real host on :80 and :443.
|
|
797
|
-
hostnames: [host],
|
|
798
|
-
secretRefs,
|
|
799
|
-
// Cassettes are NOT auto-loaded — a fork starts with an empty replay
|
|
800
|
-
// set, so a test that loads nothing fails loud (599). Tests call
|
|
801
|
-
// `replay(...)` to populate it.
|
|
802
|
-
state: (): CassetteState => ({
|
|
803
|
-
cassette: emptyCassette(opts.name, host),
|
|
804
|
-
cursor: new Map(),
|
|
805
|
-
recorded: [],
|
|
806
|
-
dirty: false,
|
|
807
|
-
}),
|
|
808
|
-
handler,
|
|
809
|
-
helpers: ({ state }): ReplayHelpers => ({
|
|
810
|
-
replay(file: string | Cassette): ReplaySummary {
|
|
811
|
-
let loaded: Cassette;
|
|
812
|
-
let label: string;
|
|
813
|
-
if (file && typeof file === "object") {
|
|
814
|
-
loaded = coerceCassette(file, opts.name, host);
|
|
815
|
-
label = file.fake || "(inline)";
|
|
816
|
-
} else if (typeof file === "string" && file.length > 0) {
|
|
817
|
-
label = file;
|
|
818
|
-
loaded = readCassetteFile(opts.name, file, host);
|
|
819
|
-
} else {
|
|
820
|
-
throw new Error(
|
|
821
|
-
`replayFake(${opts.name}): replay(...) needs a cassette path under spectest/tests/ ` +
|
|
822
|
-
`(e.g. "recordings/${opts.name}.json").`,
|
|
823
|
-
);
|
|
824
|
-
}
|
|
825
|
-
// Mutate the live state object (the handler reads the same
|
|
826
|
-
// reference); reset the cursor so replay starts from the top.
|
|
827
|
-
state.cassette = loaded;
|
|
828
|
-
state.cursor = new Map();
|
|
829
|
-
return { cassette: label, host: loaded.host, interactions: loaded.interactions.length };
|
|
830
|
-
},
|
|
831
|
-
dump(): Cassette {
|
|
832
|
-
return {
|
|
833
|
-
version: 1,
|
|
834
|
-
fake: opts.name,
|
|
835
|
-
host,
|
|
836
|
-
interactions: [...state.cassette.interactions, ...state.recorded],
|
|
837
|
-
};
|
|
838
|
-
},
|
|
839
|
-
recorded(): CassetteInteraction[] {
|
|
840
|
-
return state.recorded.slice();
|
|
841
|
-
},
|
|
842
|
-
count(): number {
|
|
843
|
-
return state.recorded.length;
|
|
844
|
-
},
|
|
845
|
-
}),
|
|
846
|
-
};
|
|
847
|
-
return def;
|
|
848
|
-
}
|