@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.
- package/dist/components/index.d.ts +1 -1
- package/dist/components/replay-key.d.ts +27 -0
- package/dist/components/replay-key.js +96 -0
- package/dist/components/replayFake.d.ts +40 -21
- package/dist/components/replayFake.js +119 -95
- package/package.json +1 -1
- package/src/components/index.ts +6 -1
- package/src/components/replay-key.ts +127 -0
- package/src/components/replayFake-handler.test.ts +217 -0
- package/src/components/replayFake-record.test.ts +156 -0
- package/src/components/replayFake.test.ts +254 -0
- package/src/components/replayFake.ts +161 -116
|
@@ -13,6 +13,6 @@ export { github, type GithubOptions } from "./github.js";
|
|
|
13
13
|
export { apple, type AppleOptions } from "./apple.js";
|
|
14
14
|
export { microsoft, type MicrosoftOptions } from "./microsoft.js";
|
|
15
15
|
export { okta, type OktaOptions } from "./okta.js";
|
|
16
|
-
export { replayFake, type ReplayFakeOptions, type
|
|
16
|
+
export { replayFake, type ReplayFakeOptions, type ReplayFakeContext, type ReplayHandler, type ReplayKey, type ReplayRequest, type MatchKey, type JsonValue, type InjectRule, type InjectMatch, type StringMatch, type SignConfig, type AwsSigV4Sign, type ReplayHelpers, type ReplaySummary, type Cassette, type CassetteInteraction, type CassetteRequest, type CassetteResponse, type CassetteState, } from "./replayFake.js";
|
|
17
17
|
export { electron, type ElectronOptions } from "./electron.js";
|
|
18
18
|
export { android, type AndroidOptions } from "./android.js";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CassetteRequest } from "./replayFake.js";
|
|
2
|
+
export type JsonValue = null | boolean | number | string | readonly JsonValue[] | {
|
|
3
|
+
readonly [key: string]: JsonValue;
|
|
4
|
+
};
|
|
5
|
+
/** Identity content. Objects compare independently of property order; arrays
|
|
6
|
+
* preserve order. Bytes are distinct from strings, arrays, and objects. */
|
|
7
|
+
export type MatchKey = null | boolean | number | string | Uint8Array | readonly MatchKey[] | {
|
|
8
|
+
readonly [key: string]: MatchKey;
|
|
9
|
+
};
|
|
10
|
+
/** A buffered request view shared by incoming and recorded requests.
|
|
11
|
+
* Headers are lowercase and exclude credentials. Recorded values may be
|
|
12
|
+
* redacted; choose identity fields that survive recording. */
|
|
13
|
+
export interface ReplayRequest {
|
|
14
|
+
readonly method: string;
|
|
15
|
+
readonly path: string;
|
|
16
|
+
readonly query: Readonly<Record<string, readonly string[]>>;
|
|
17
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
18
|
+
/** Decode UTF-8, throwing on invalid text. */
|
|
19
|
+
text(): string;
|
|
20
|
+
/** Parse JSON. The type parameter is not runtime validation. */
|
|
21
|
+
json<T = JsonValue>(): T;
|
|
22
|
+
/** Return an independent copy of the body bytes. */
|
|
23
|
+
bytes(): Uint8Array;
|
|
24
|
+
}
|
|
25
|
+
export type ReplayKey = (request: ReplayRequest) => MatchKey;
|
|
26
|
+
/** Internal only; the SDK exports the request/key types, not this function. */
|
|
27
|
+
export declare function replaySignature(request: CassetteRequest, key?: ReplayKey): string;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
/** Stream a typed, length-delimited canonical representation into the hash.
|
|
3
|
+
* Never call toJSON: unsupported values must fail rather than silently lose
|
|
4
|
+
* identity fields. In particular, an async callback must not become `{}`. */
|
|
5
|
+
function hashKey(key) {
|
|
6
|
+
const hash = createHash("sha256");
|
|
7
|
+
const ancestors = new Set();
|
|
8
|
+
const visit = (value) => {
|
|
9
|
+
if (value === null || typeof value === "boolean" || typeof value === "string") {
|
|
10
|
+
hash.update(JSON.stringify(value));
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
14
|
+
hash.update(JSON.stringify(value));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (value instanceof Uint8Array) {
|
|
18
|
+
hash.update(`bytes:${value.byteLength}:`);
|
|
19
|
+
hash.update(value);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (value instanceof Promise) {
|
|
23
|
+
// Report the invalid callback synchronously without leaving a rejected
|
|
24
|
+
// async callback to become an unrelated unhandled-rejection failure.
|
|
25
|
+
void value.catch(() => { });
|
|
26
|
+
throw new Error("key callbacks must be synchronous, not return promises");
|
|
27
|
+
}
|
|
28
|
+
if (typeof value !== "object" || value === null) {
|
|
29
|
+
throw new Error("key must contain only JSON values or Uint8Array bytes (no undefined, functions, or non-finite numbers)");
|
|
30
|
+
}
|
|
31
|
+
if (ancestors.has(value))
|
|
32
|
+
throw new Error("key must not contain cycles");
|
|
33
|
+
if (Object.getOwnPropertySymbols(value).length > 0) {
|
|
34
|
+
throw new Error("key must not contain symbol properties");
|
|
35
|
+
}
|
|
36
|
+
ancestors.add(value);
|
|
37
|
+
if (Array.isArray(value)) {
|
|
38
|
+
if (Object.keys(value).length !== value.length) {
|
|
39
|
+
throw new Error("key arrays must be dense and have no extra properties");
|
|
40
|
+
}
|
|
41
|
+
hash.update("[");
|
|
42
|
+
for (let i = 0; i < value.length; i++) {
|
|
43
|
+
if (!Object.hasOwn(value, i))
|
|
44
|
+
throw new Error("key arrays must be dense");
|
|
45
|
+
visit(value[i]);
|
|
46
|
+
hash.update(",");
|
|
47
|
+
}
|
|
48
|
+
hash.update("]");
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
const proto = Object.getPrototypeOf(value);
|
|
52
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
53
|
+
throw new Error("key must use plain objects, not promises, dates, maps, or class instances; callbacks must be synchronous");
|
|
54
|
+
}
|
|
55
|
+
hash.update("{");
|
|
56
|
+
for (const name of Object.keys(value).sort()) {
|
|
57
|
+
hash.update(JSON.stringify(name));
|
|
58
|
+
hash.update(":");
|
|
59
|
+
visit(value[name]);
|
|
60
|
+
hash.update(",");
|
|
61
|
+
}
|
|
62
|
+
hash.update("}");
|
|
63
|
+
}
|
|
64
|
+
ancestors.delete(value);
|
|
65
|
+
};
|
|
66
|
+
visit(key);
|
|
67
|
+
return hash.digest("hex");
|
|
68
|
+
}
|
|
69
|
+
function bodyBytes(request) {
|
|
70
|
+
return Buffer.from(request.body ?? "", request.bodyEncoding === "base64" ? "base64" : "utf8");
|
|
71
|
+
}
|
|
72
|
+
function requestView(request) {
|
|
73
|
+
let bytes;
|
|
74
|
+
let text;
|
|
75
|
+
const readBytes = () => bytes ??= bodyBytes(request);
|
|
76
|
+
const readText = () => text ??= new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(readBytes());
|
|
77
|
+
const query = Object.fromEntries(Object.entries(request.query).map(([k, v]) => [k, Object.freeze([...v])]));
|
|
78
|
+
return Object.freeze({
|
|
79
|
+
method: request.method.toUpperCase(),
|
|
80
|
+
path: request.path,
|
|
81
|
+
query: Object.freeze(query),
|
|
82
|
+
headers: Object.freeze({ ...request.headers }),
|
|
83
|
+
text: readText,
|
|
84
|
+
json: () => JSON.parse(readText()),
|
|
85
|
+
bytes: () => Uint8Array.from(readBytes()),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/** Internal only; the SDK exports the request/key types, not this function. */
|
|
89
|
+
export function replaySignature(request, key) {
|
|
90
|
+
if (key)
|
|
91
|
+
return hashKey(key(requestView(request)));
|
|
92
|
+
// Existing cassettes retain the original byte hash even when their stored
|
|
93
|
+
// text was redacted. Older/hand-written cassettes may only have the body.
|
|
94
|
+
const body = request.bodyHash ?? `sha256:${createHash("sha256").update(bodyBytes(request)).digest("hex")}`;
|
|
95
|
+
return hashKey({ method: request.method.toUpperCase(), path: request.path, query: request.query, body });
|
|
96
|
+
}
|
|
@@ -1,18 +1,6 @@
|
|
|
1
|
-
import type { FakeDefinition } from "../index.js";
|
|
2
|
-
|
|
3
|
-
export
|
|
4
|
-
/** Match on HTTP method. Default `true`. */
|
|
5
|
-
method?: boolean;
|
|
6
|
-
/** Match on path (no query). Default `true`. */
|
|
7
|
-
path?: boolean;
|
|
8
|
-
/** Match on normalized query string. Default `true`. */
|
|
9
|
-
query?: boolean;
|
|
10
|
-
/** Match on body: `true` = exact decoded body, `"hash"` = sha256 only.
|
|
11
|
-
* Default off. */
|
|
12
|
-
body?: boolean | "hash";
|
|
13
|
-
/** Header names (lowercase) to include in the match. Default none. */
|
|
14
|
-
headers?: string[];
|
|
15
|
-
}
|
|
1
|
+
import type { FakeContext, FakeDefinition } from "../index.js";
|
|
2
|
+
import { type ReplayKey } from "./replay-key.js";
|
|
3
|
+
export type { JsonValue, MatchKey, ReplayRequest, ReplayKey } from "./replay-key.js";
|
|
16
4
|
/** Match one request dimension. A bare string is an exact match; the
|
|
17
5
|
* object forms cover prefix and regex matching. */
|
|
18
6
|
export type StringMatch = string | {
|
|
@@ -66,7 +54,15 @@ export interface AwsSigV4Sign {
|
|
|
66
54
|
sessionToken?: string;
|
|
67
55
|
}
|
|
68
56
|
export type SignConfig = AwsSigV4Sign;
|
|
69
|
-
|
|
57
|
+
/** A normal fake context with explicit delegation to recording/replay. */
|
|
58
|
+
export interface ReplayFakeContext extends FakeContext {
|
|
59
|
+
/** Record against the real upstream in record mode, or consume a matching
|
|
60
|
+
* cassette interaction in replay mode. A replay miss returns 599; there
|
|
61
|
+
* is no fallback to scripted logic. Inspect a body's clone before calling. */
|
|
62
|
+
replay(request: Request): Promise<Response>;
|
|
63
|
+
}
|
|
64
|
+
export type ReplayHandler<S> = (request: Request, state: S, ctx: ReplayFakeContext) => Response | Promise<Response>;
|
|
65
|
+
export interface ReplayFakeOptions<S = undefined, H extends Record<string, unknown> = {}> {
|
|
70
66
|
/** Stable name — the `ctx.fakes` key. */
|
|
71
67
|
name: string;
|
|
72
68
|
/** The REAL host to fake, e.g. `"api.stripe.com"`. The fake answers for
|
|
@@ -75,8 +71,12 @@ export interface ReplayFakeOptions {
|
|
|
75
71
|
* resolved via an external DNS server — see the module header). Your
|
|
76
72
|
* app points at this real host directly; no separate stand-in. */
|
|
77
73
|
host: string;
|
|
78
|
-
/**
|
|
79
|
-
|
|
74
|
+
/** Complete request identity. Default: method, path, normalized query,
|
|
75
|
+
* and exact body bytes. The same synchronous, deterministic callback runs
|
|
76
|
+
* on recorded and incoming requests. Return JSON values and/or Uint8Array
|
|
77
|
+
* bytes; objects compare independently of property order. No implicit
|
|
78
|
+
* matching dimensions or fallback apply when a key is supplied. */
|
|
79
|
+
key?: ReplayKey;
|
|
80
80
|
/** Per-fake credential brokering. Rules are evaluated in
|
|
81
81
|
* order; the first whose `match` matches wins (a rule without `match`
|
|
82
82
|
* matches everything and shadows later rules). Applied at the
|
|
@@ -95,13 +95,27 @@ export interface ReplayFakeOptions {
|
|
|
95
95
|
/** `"auto"` (default) records under eval and replays under test;
|
|
96
96
|
* `"record"` / `"replay"` force a mode. */
|
|
97
97
|
mode?: "auto" | "record" | "replay";
|
|
98
|
+
/** Custom state, with the same lifecycle as a normal fake's state. Kept
|
|
99
|
+
* separate from cassette internals; helpers and handler see only S. */
|
|
100
|
+
state?: () => S | Promise<S>;
|
|
101
|
+
/** Custom helpers exposed alongside replay/dump/recorded/count. Those
|
|
102
|
+
* names are reserved. Loading a cassette does not change custom state. */
|
|
103
|
+
helpers?: (args: {
|
|
104
|
+
name: string;
|
|
105
|
+
state: S;
|
|
106
|
+
ctx: FakeContext;
|
|
107
|
+
}) => H | Promise<H>;
|
|
108
|
+
/** Decide whether to answer in code or call ctx.replay(request). Without
|
|
109
|
+
* a handler, every request delegates. Only delegated requests record. */
|
|
110
|
+
handler?: ReplayHandler<S>;
|
|
98
111
|
}
|
|
99
112
|
export interface CassetteRequest {
|
|
100
113
|
method: string;
|
|
101
114
|
path: string;
|
|
102
115
|
/** Normalized: keys + repeated values sorted, so matching is order-free. */
|
|
103
116
|
query: Record<string, string[]>;
|
|
104
|
-
/**
|
|
117
|
+
/** Captured non-sensitive headers — never `authorization`/`cookie`.
|
|
118
|
+
* Older cassettes may contain only content-type and selected headers. */
|
|
105
119
|
headers: Record<string, string>;
|
|
106
120
|
bodyHash?: string;
|
|
107
121
|
/** Request body (omitted when empty): decoded + redacted UTF-8 text, or
|
|
@@ -131,6 +145,8 @@ export interface Cassette {
|
|
|
131
145
|
/** Forked-per-test state for one replay fake. */
|
|
132
146
|
export interface CassetteState {
|
|
133
147
|
cassette: Cassette;
|
|
148
|
+
/** Complete signature → cassette interaction indexes, in cassette order. */
|
|
149
|
+
index: Map<string, number[]>;
|
|
134
150
|
/** Per-match-signature replay cursor (call-order sequencing). */
|
|
135
151
|
cursor: Map<string, number>;
|
|
136
152
|
/** Interactions captured this record session. */
|
|
@@ -149,8 +165,9 @@ export interface ReplaySummary {
|
|
|
149
165
|
interactions: number;
|
|
150
166
|
}
|
|
151
167
|
export interface ReplayHelpers extends Record<string, unknown> {
|
|
152
|
-
/** Load a cassette into THIS fork's replay set
|
|
168
|
+
/** Load a cassette into THIS fork's replay set for delegated requests,
|
|
153
169
|
* replacing whatever was loaded before and resetting the replay cursor.
|
|
170
|
+
* Does not change custom state or bypass a custom handler.
|
|
154
171
|
* Cassettes are NOT auto-loaded — call this at the top of a test. Pass a
|
|
155
172
|
* path relative to `spectest/tests/` (e.g. `"recordings/stripe.json"`; it
|
|
156
173
|
* must stay under `spectest/tests/`) or a cassette object; a missing file
|
|
@@ -166,4 +183,6 @@ export interface ReplayHelpers extends Record<string, unknown> {
|
|
|
166
183
|
/** Count of interactions captured this record session. */
|
|
167
184
|
count(): number;
|
|
168
185
|
}
|
|
169
|
-
export declare function replayFake(opts: ReplayFakeOptions): FakeDefinition<CassetteState
|
|
186
|
+
export declare function replayFake<S = undefined, H extends Record<string, unknown> = {}>(opts: ReplayFakeOptions<S, H>): FakeDefinition<CassetteState & {
|
|
187
|
+
custom: S;
|
|
188
|
+
}, ReplayHelpers & H>;
|
|
@@ -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
|
|
@@ -62,6 +64,7 @@ import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
|
|
|
62
64
|
import * as dnsPacket from "dns-packet";
|
|
63
65
|
import { isRecording } from "../recorder.js";
|
|
64
66
|
import { getRecordSecret } from "../record-secrets.js";
|
|
67
|
+
import { replaySignature } from "./replay-key.js";
|
|
65
68
|
import { inferSigV4Scope, signAwsV4Request, } from "../aws-sigv4.js";
|
|
66
69
|
// ────────────────────────────────────────────────────────────────────────
|
|
67
70
|
// Helpers
|
|
@@ -71,7 +74,9 @@ const SENSITIVE_HEADERS = new Set([
|
|
|
71
74
|
"cookie",
|
|
72
75
|
"set-cookie",
|
|
73
76
|
"proxy-authorization",
|
|
77
|
+
"api-key",
|
|
74
78
|
"x-api-key",
|
|
79
|
+
"x-goog-api-key",
|
|
75
80
|
"x-amz-security-token",
|
|
76
81
|
]);
|
|
77
82
|
/** Hop-by-hop + body-framing headers never forwarded / never replayed
|
|
@@ -97,15 +102,6 @@ const STRIP_FORWARD_HEADERS = new Set([
|
|
|
97
102
|
* resolver (which points the faked host back at the daemon). */
|
|
98
103
|
const EXTERNAL_DNS = process.env.SPECTEST_UPSTREAM_DNS ?? "1.1.1.1";
|
|
99
104
|
const EXTERNAL_DNS_PORT = Number(process.env.SPECTEST_UPSTREAM_PORT ?? "53");
|
|
100
|
-
function defaultMatch(m) {
|
|
101
|
-
return {
|
|
102
|
-
method: m?.method ?? true,
|
|
103
|
-
path: m?.path ?? true,
|
|
104
|
-
query: m?.query ?? true,
|
|
105
|
-
body: m?.body,
|
|
106
|
-
headers: m?.headers,
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
105
|
/** SigV4 puts per-request signing params (`X-Amz-Signature`, `X-Amz-Date`, …)
|
|
110
106
|
* in the query for presigned URLs — they change every call and would poison
|
|
111
107
|
* query matching, so they're dropped from both the match key and the forward
|
|
@@ -144,31 +140,6 @@ function recordable(headers, want) {
|
|
|
144
140
|
}
|
|
145
141
|
return out;
|
|
146
142
|
}
|
|
147
|
-
/** Canonical match signature for an interaction's request, used as both
|
|
148
|
-
* the replay-cursor key and the equality basis (two requests match iff
|
|
149
|
-
* their signatures are equal). Computed identically for an incoming
|
|
150
|
-
* request and a stored `CassetteRequest`. */
|
|
151
|
-
function signature(req, match) {
|
|
152
|
-
const sig = {};
|
|
153
|
-
if (match.method)
|
|
154
|
-
sig.method = req.method.toUpperCase();
|
|
155
|
-
if (match.path)
|
|
156
|
-
sig.path = req.path;
|
|
157
|
-
if (match.query)
|
|
158
|
-
sig.query = req.query;
|
|
159
|
-
if (match.body === true)
|
|
160
|
-
sig.body = req.body ?? "";
|
|
161
|
-
else if (match.body === "hash")
|
|
162
|
-
sig.bodyHash = req.bodyHash ?? "";
|
|
163
|
-
if (match.headers && match.headers.length > 0) {
|
|
164
|
-
const h = {};
|
|
165
|
-
for (const name of match.headers.map((n) => n.toLowerCase()).sort()) {
|
|
166
|
-
h[name] = req.headers[name] ?? "";
|
|
167
|
-
}
|
|
168
|
-
sig.headers = h;
|
|
169
|
-
}
|
|
170
|
-
return JSON.stringify(sig);
|
|
171
|
-
}
|
|
172
143
|
/** The project's `spectest/tests/` dir — cassettes live alongside the
|
|
173
144
|
* tests, and a `replay(path)` argument is a path relative to here. Keeping
|
|
174
145
|
* them under `tests/` means re-recording is picked up on the next run with
|
|
@@ -190,7 +161,7 @@ function coerceCassette(parsed, name, host) {
|
|
|
190
161
|
version: 1,
|
|
191
162
|
fake: parsed.fake ?? name,
|
|
192
163
|
host: parsed.host ?? host,
|
|
193
|
-
interactions: parsed.interactions,
|
|
164
|
+
interactions: structuredClone(parsed.interactions),
|
|
194
165
|
};
|
|
195
166
|
}
|
|
196
167
|
/** Read a cassette by its path relative to `spectest/tests/` (e.g.
|
|
@@ -218,7 +189,7 @@ function readCassetteFile(name, file, host) {
|
|
|
218
189
|
/** Decode response bytes to a string, falling back to base64 for binary. */
|
|
219
190
|
function decodeBody(bytes) {
|
|
220
191
|
try {
|
|
221
|
-
const text = new TextDecoder("utf8", { fatal: true }).decode(bytes);
|
|
192
|
+
const text = new TextDecoder("utf8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
222
193
|
return { body: text, bodyEncoding: "utf8" };
|
|
223
194
|
}
|
|
224
195
|
catch {
|
|
@@ -434,7 +405,12 @@ export function replayFake(opts) {
|
|
|
434
405
|
throw new Error(`replayFake(${opts.name}): "host" (the real hostname) is required`);
|
|
435
406
|
}
|
|
436
407
|
const host = opts.host.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
|
437
|
-
|
|
408
|
+
if ("match" in opts) {
|
|
409
|
+
throw new Error(`replayFake(${opts.name}): \`match\` has been replaced by \`key(request)\`, which defines the complete request identity`);
|
|
410
|
+
}
|
|
411
|
+
if (opts.key !== undefined && typeof opts.key !== "function") {
|
|
412
|
+
throw new Error(`replayFake(${opts.name}): \`key\` must be a synchronous function`);
|
|
413
|
+
}
|
|
438
414
|
const injectRules = opts.inject ?? [];
|
|
439
415
|
// Refs every header template references — the control plane resolves these
|
|
440
416
|
// server-side and pushes them for the environment lifetime (see the daemon's
|
|
@@ -448,19 +424,33 @@ export function replayFake(opts) {
|
|
|
448
424
|
return isRecording() ? "replay" : "record";
|
|
449
425
|
};
|
|
450
426
|
// ── REPLAY ──────────────────────────────────────────────────────────
|
|
427
|
+
function signature(request, location) {
|
|
428
|
+
try {
|
|
429
|
+
return replaySignature(request, opts.key);
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
433
|
+
throw new Error(`replayFake(${opts.name}): invalid replay key for ${location}: ${message}`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
451
436
|
function replay(reqLike, state) {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
if (signature(it.request, match) === sig)
|
|
456
|
-
matches.push(it);
|
|
437
|
+
let sig;
|
|
438
|
+
try {
|
|
439
|
+
sig = signature(reqLike, "incoming request");
|
|
457
440
|
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
return new Response(error.message + "\n", {
|
|
443
|
+
status: 599, headers: { "content-type": "text/plain" },
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
const matches = state.index.get(sig) ?? [];
|
|
458
447
|
const idx = state.cursor.get(sig) ?? 0;
|
|
459
|
-
const
|
|
448
|
+
const interactionIndex = matches[idx];
|
|
449
|
+
const hit = interactionIndex === undefined ? undefined : state.cassette.interactions[interactionIndex];
|
|
460
450
|
if (!hit) {
|
|
461
451
|
const body = `replayFake(${opts.name}): no cassette interaction for ` +
|
|
462
|
-
`${reqLike.method} ${reqLike.path} (matched ${matches.length}, wanted #${idx + 1}). ` +
|
|
463
|
-
`
|
|
452
|
+
`${reqLike.method} ${reqLike.path} (${matches.length === 0 ? "unknown key" : "exhausted key"}; matched ${matches.length}, wanted #${idx + 1}). ` +
|
|
453
|
+
`Check \`key\` or re-record the cassette.\n`;
|
|
464
454
|
return new Response(body, {
|
|
465
455
|
status: 599,
|
|
466
456
|
headers: { "content-type": "text/plain" },
|
|
@@ -618,7 +608,7 @@ export function replayFake(opts) {
|
|
|
618
608
|
method: reqLike.method,
|
|
619
609
|
path: reqLike.path,
|
|
620
610
|
query: redactedQuery,
|
|
621
|
-
headers: reqLike.headers,
|
|
611
|
+
headers: Object.fromEntries(Object.entries(reqLike.headers).map(([k, v]) => [k, redact(v)])),
|
|
622
612
|
...(reqLike.body !== undefined
|
|
623
613
|
? {
|
|
624
614
|
bodyHash: reqLike.bodyHash,
|
|
@@ -656,6 +646,9 @@ export function replayFake(opts) {
|
|
|
656
646
|
}
|
|
657
647
|
// ── handler ─────────────────────────────────────────────────────────
|
|
658
648
|
const handler = async (req, state, _ctx) => {
|
|
649
|
+
if (req.bodyUsed) {
|
|
650
|
+
throw new Error(`replayFake(${opts.name}): cannot delegate a consumed request body; inspect request.clone() before calling ctx.replay(request)`);
|
|
651
|
+
}
|
|
659
652
|
const url = new URL(req.url);
|
|
660
653
|
const bodyBytes = req.method === "GET" || req.method === "HEAD"
|
|
661
654
|
? new Uint8Array(0)
|
|
@@ -667,7 +660,7 @@ export function replayFake(opts) {
|
|
|
667
660
|
// Strip SigV4 `x-amz-*` query params from the match key when signing —
|
|
668
661
|
// presigned URLs carry a per-request signature there.
|
|
669
662
|
query: normalizeQuery(url.searchParams, opts.sign !== undefined),
|
|
670
|
-
headers: recordable(req.headers, [
|
|
663
|
+
headers: recordable(req.headers, [...req.headers.keys()]),
|
|
671
664
|
...(decodedBody
|
|
672
665
|
? {
|
|
673
666
|
bodyHash: sha256Bytes(bodyBytes),
|
|
@@ -680,58 +673,89 @@ export function replayFake(opts) {
|
|
|
680
673
|
return replay(reqLike, state);
|
|
681
674
|
return record(req, url, reqLike, bodyBytes, state);
|
|
682
675
|
};
|
|
676
|
+
const replayHelpers = (state) => ({
|
|
677
|
+
replay(file) {
|
|
678
|
+
let loaded;
|
|
679
|
+
let label;
|
|
680
|
+
if (file && typeof file === "object") {
|
|
681
|
+
loaded = coerceCassette(file, opts.name, host);
|
|
682
|
+
label = file.fake || "(inline)";
|
|
683
|
+
}
|
|
684
|
+
else if (typeof file === "string" && file.length > 0) {
|
|
685
|
+
label = file;
|
|
686
|
+
loaded = readCassetteFile(opts.name, file, host);
|
|
687
|
+
}
|
|
688
|
+
else {
|
|
689
|
+
throw new Error(`replayFake(${opts.name}): replay(...) needs a cassette path under spectest/tests/ ` +
|
|
690
|
+
`(e.g. "recordings/${opts.name}.json").`);
|
|
691
|
+
}
|
|
692
|
+
// Build/validate before replacing live state: a bad key/cassette
|
|
693
|
+
// must leave the previous cassette and consumption cursors intact.
|
|
694
|
+
const index = new Map();
|
|
695
|
+
for (const [i, interaction] of loaded.interactions.entries()) {
|
|
696
|
+
const sig = signature(interaction.request, `${JSON.stringify(label)} interaction #${i + 1}`);
|
|
697
|
+
const queue = index.get(sig);
|
|
698
|
+
if (queue)
|
|
699
|
+
queue.push(i);
|
|
700
|
+
else
|
|
701
|
+
index.set(sig, [i]);
|
|
702
|
+
}
|
|
703
|
+
// Mutate the live state object (the handler reads the same
|
|
704
|
+
// reference); reset the cursor so replay starts from the top.
|
|
705
|
+
state.cassette = loaded;
|
|
706
|
+
state.index = index;
|
|
707
|
+
state.cursor = new Map();
|
|
708
|
+
return { cassette: label, host: loaded.host, interactions: loaded.interactions.length };
|
|
709
|
+
},
|
|
710
|
+
dump() {
|
|
711
|
+
return structuredClone({
|
|
712
|
+
version: 1,
|
|
713
|
+
fake: opts.name,
|
|
714
|
+
host,
|
|
715
|
+
interactions: [...state.cassette.interactions, ...state.recorded],
|
|
716
|
+
});
|
|
717
|
+
},
|
|
718
|
+
recorded() {
|
|
719
|
+
return structuredClone(state.recorded);
|
|
720
|
+
},
|
|
721
|
+
count() {
|
|
722
|
+
return state.recorded.length;
|
|
723
|
+
},
|
|
724
|
+
});
|
|
725
|
+
const initialState = (custom) => ({
|
|
726
|
+
cassette: emptyCassette(opts.name, host),
|
|
727
|
+
index: new Map(),
|
|
728
|
+
cursor: new Map(),
|
|
729
|
+
recorded: [],
|
|
730
|
+
dirty: false,
|
|
731
|
+
custom,
|
|
732
|
+
});
|
|
683
733
|
const def = {
|
|
684
734
|
name: opts.name,
|
|
685
|
-
// Fake = real: we answer for the real host on :80 and :443.
|
|
686
735
|
hostnames: [host],
|
|
687
736
|
secretRefs,
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
else if (typeof file === "string" && file.length > 0) {
|
|
707
|
-
label = file;
|
|
708
|
-
loaded = readCassetteFile(opts.name, file, host);
|
|
709
|
-
}
|
|
710
|
-
else {
|
|
711
|
-
throw new Error(`replayFake(${opts.name}): replay(...) needs a cassette path under spectest/tests/ ` +
|
|
712
|
-
`(e.g. "recordings/${opts.name}.json").`);
|
|
737
|
+
state: () => opts.state
|
|
738
|
+
? Promise.resolve(opts.state()).then(initialState)
|
|
739
|
+
: initialState(undefined),
|
|
740
|
+
handler: (request, state, ctx) => opts.handler
|
|
741
|
+
? opts.handler(request, state.custom, {
|
|
742
|
+
...ctx,
|
|
743
|
+
replay: (delegated) => handler(delegated, state, ctx),
|
|
744
|
+
})
|
|
745
|
+
: handler(request, state, ctx),
|
|
746
|
+
helpers: ({ name, state, ctx }) => {
|
|
747
|
+
const builtins = replayHelpers(state);
|
|
748
|
+
if (!opts.helpers)
|
|
749
|
+
return builtins;
|
|
750
|
+
return Promise.resolve(opts.helpers({ name, state: state.custom, ctx })).then((custom) => {
|
|
751
|
+
for (const key of Object.keys(builtins)) {
|
|
752
|
+
if (key in custom) {
|
|
753
|
+
throw new Error(`replayFake(${opts.name}): custom helper ${JSON.stringify(key)} is reserved for recording/replay; choose another name`);
|
|
754
|
+
}
|
|
713
755
|
}
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
state.cursor = new Map();
|
|
718
|
-
return { cassette: label, host: loaded.host, interactions: loaded.interactions.length };
|
|
719
|
-
},
|
|
720
|
-
dump() {
|
|
721
|
-
return {
|
|
722
|
-
version: 1,
|
|
723
|
-
fake: opts.name,
|
|
724
|
-
host,
|
|
725
|
-
interactions: [...state.cassette.interactions, ...state.recorded],
|
|
726
|
-
};
|
|
727
|
-
},
|
|
728
|
-
recorded() {
|
|
729
|
-
return state.recorded.slice();
|
|
730
|
-
},
|
|
731
|
-
count() {
|
|
732
|
-
return state.recorded.length;
|
|
733
|
-
},
|
|
734
|
-
}),
|
|
756
|
+
return { ...custom, ...builtins };
|
|
757
|
+
});
|
|
758
|
+
},
|
|
735
759
|
};
|
|
736
760
|
return def;
|
|
737
761
|
}
|
package/package.json
CHANGED
package/src/components/index.ts
CHANGED
|
@@ -85,7 +85,12 @@ export { okta, type OktaOptions } from "./okta.js";
|
|
|
85
85
|
export {
|
|
86
86
|
replayFake,
|
|
87
87
|
type ReplayFakeOptions,
|
|
88
|
-
type
|
|
88
|
+
type ReplayFakeContext,
|
|
89
|
+
type ReplayHandler,
|
|
90
|
+
type ReplayKey,
|
|
91
|
+
type ReplayRequest,
|
|
92
|
+
type MatchKey,
|
|
93
|
+
type JsonValue,
|
|
89
94
|
type InjectRule,
|
|
90
95
|
type InjectMatch,
|
|
91
96
|
type StringMatch,
|