@specific.dev/spectest 0.98.0 → 0.100.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/README.md +32 -0
- 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 +12 -17
- package/dist/components/replayFake.js +51 -50
- package/dist/components/supabase.d.ts +5 -1
- package/dist/components/supabase.js +3 -1
- package/dist/daemon.js +16 -22
- package/dist/harness/http-proxy.d.ts +9 -9
- package/dist/harness/http-proxy.js +14 -9
- package/dist/harness/ingress-table.d.ts +2 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.js +3 -0
- package/dist/ingress.d.ts +8 -0
- package/dist/ingress.js +9 -2
- package/package.json +1 -1
- package/src/components/index.ts +4 -1
- package/src/components/replay-key.ts +127 -0
- package/src/components/replayFake-record.test.ts +114 -0
- package/src/components/replayFake.test.ts +254 -0
- package/src/components/replayFake.ts +59 -65
- package/src/components/supabase.ts +8 -1
- package/src/daemon.ts +17 -25
- package/src/harness/http-proxy.test.ts +2 -1
- package/src/harness/http-proxy.ts +20 -9
- package/src/harness/ingress-cors.test.ts +184 -0
- package/src/harness/ingress-table.ts +2 -1
- package/src/index.ts +10 -1
- package/src/ingress.ts +18 -6
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
* thing {@link planBind} still has to say is whether the listener exists
|
|
42
42
|
* yet.
|
|
43
43
|
*/
|
|
44
|
+
import type { CorsMode } from "../ingress.js";
|
|
44
45
|
/**
|
|
45
46
|
* One hostname's upstream: a fake handled in-process, or a container.
|
|
46
47
|
*
|
|
@@ -56,6 +57,7 @@ export type Route<F = unknown> = {
|
|
|
56
57
|
service: string;
|
|
57
58
|
port: number;
|
|
58
59
|
http2?: boolean;
|
|
60
|
+
cors?: CorsMode;
|
|
59
61
|
};
|
|
60
62
|
/** A PEM leaf + key, as handed to `Bun.serve`'s TLS config. */
|
|
61
63
|
export interface Leaf {
|
package/dist/index.d.ts
CHANGED
|
@@ -29,8 +29,8 @@ export { type CoverageAdapter, type CoverageCaptureContext, type CoverageConfigu
|
|
|
29
29
|
import { type ServiceCoverage } from "./coverage.js";
|
|
30
30
|
export { ca } from "./environment-ca.js";
|
|
31
31
|
export { certificate, dnsName, proxy, provides, lowerIngress, isWildcard, SELF_SERVICE_TOKEN, } from "./ingress.js";
|
|
32
|
-
export type { CertificateDecl, DnsDecl, ProxyDecl, IngressDecl, DnsTarget, LoweredIngress, } from "./ingress.js";
|
|
33
|
-
import type
|
|
32
|
+
export type { CorsMode, CertificateDecl, DnsDecl, ProxyDecl, IngressDecl, DnsTarget, LoweredIngress, } from "./ingress.js";
|
|
33
|
+
import { type CorsMode, type DnsTarget } from "./ingress.js";
|
|
34
34
|
import type { InterceptHandler, InterceptNext, InterceptedRequest } from "./harness/intercept.js";
|
|
35
35
|
export type { InterceptHandler, InterceptNext, InterceptedRequest };
|
|
36
36
|
/**
|
|
@@ -744,6 +744,9 @@ export interface ServiceTls {
|
|
|
744
744
|
/** Accept HTTP/2 over TLS and forward it as plaintext HTTP/2 (h2c).
|
|
745
745
|
* Required for gRPC. HTTP/1.1 continues to use the existing HTTP proxy. */
|
|
746
746
|
http2?: boolean;
|
|
747
|
+
/** Preserve upstream CORS policy by default. "permissive" opts HTTP/1
|
|
748
|
+
* ingress into answering preflights and adding credentialed CORS headers. */
|
|
749
|
+
cors?: CorsMode;
|
|
747
750
|
}
|
|
748
751
|
/** @internal Normalized wire representation; legacy definitions remain accepted. */
|
|
749
752
|
export type NormalizedServiceImage = {
|
|
@@ -1458,6 +1461,9 @@ export interface FakeDefinition<S = any, H extends Record<string, unknown> = Rec
|
|
|
1458
1461
|
hostnames: readonly string[];
|
|
1459
1462
|
/** TCP port the fake listens on. Default `80`. */
|
|
1460
1463
|
port?: number;
|
|
1464
|
+
/** Preserve handler/interceptor CORS policy by default. "permissive"
|
|
1465
|
+
* answers preflights and adds credentialed CORS headers at ingress. */
|
|
1466
|
+
cors?: CorsMode;
|
|
1461
1467
|
/**
|
|
1462
1468
|
* Build the fake's initial state. Called once when the project loads.
|
|
1463
1469
|
* The returned value lives in daemon memory for the rest of the
|
package/dist/index.js
CHANGED
|
@@ -49,6 +49,7 @@ export { ca } from "./environment-ca.js";
|
|
|
49
49
|
// `tls` / `hostnames` fields and `defineFake(...)` are built on. See
|
|
50
50
|
// `ingress.ts`.
|
|
51
51
|
export { certificate, dnsName, proxy, provides, lowerIngress, isWildcard, SELF_SERVICE_TOKEN, } from "./ingress.js";
|
|
52
|
+
import { validateCorsMode } from "./ingress.js";
|
|
52
53
|
import { isWildcard as isWildcardHost } from "./ingress.js";
|
|
53
54
|
// ──────────────────────────────────────────────────────────────────────────
|
|
54
55
|
// Service groups — one services-map entry that expands to several services.
|
|
@@ -363,6 +364,7 @@ function validateEnvironmentConfig(config) {
|
|
|
363
364
|
}
|
|
364
365
|
if (entry.http2 !== undefined && typeof entry.http2 !== "boolean")
|
|
365
366
|
throw new Error(`service "${name}" tls http2 must be a boolean`);
|
|
367
|
+
validateCorsMode(entry.cors);
|
|
366
368
|
const h = entry.hostname.toLowerCase();
|
|
367
369
|
if (!HOSTNAME_RE.test(hostPatternBody(h))) {
|
|
368
370
|
throw new Error(`service "${name}" tls hostname ${JSON.stringify(entry.hostname)} is not a multi-label DNS name or wildcard (e.g. "app.test", "*.us-east-1.amazonaws.com")`);
|
|
@@ -408,6 +410,7 @@ export function defineFake(opts) {
|
|
|
408
410
|
throw new Error(`defineFake(${opts.name}): hostname ${JSON.stringify(h)} ends in reserved ".internal" TLD`);
|
|
409
411
|
}
|
|
410
412
|
}
|
|
413
|
+
validateCorsMode(opts.cors);
|
|
411
414
|
if (typeof opts.handler !== "function") {
|
|
412
415
|
throw new Error(`defineFake(${opts.name}): \`handler\` is required`);
|
|
413
416
|
}
|
package/dist/ingress.d.ts
CHANGED
|
@@ -7,6 +7,11 @@ export type DnsTarget = {
|
|
|
7
7
|
} | {
|
|
8
8
|
service: string;
|
|
9
9
|
};
|
|
10
|
+
/** Passthrough preserves application CORS policy (the default). Permissive
|
|
11
|
+
* answers preflights and adds credentialed CORS headers at HTTP/1 ingress. */
|
|
12
|
+
export type CorsMode = "passthrough" | "permissive";
|
|
13
|
+
/** @internal Validate JavaScript config as well as TypeScript callers. */
|
|
14
|
+
export declare function validateCorsMode(cors: unknown): void;
|
|
10
15
|
export interface CertificateDecl {
|
|
11
16
|
kind: "certificate";
|
|
12
17
|
/** Hostnames the leaf cert's SANs cover. One cert is minted per decl. */
|
|
@@ -24,6 +29,7 @@ export interface ProxyDecl {
|
|
|
24
29
|
service: string;
|
|
25
30
|
port: number;
|
|
26
31
|
http2?: boolean;
|
|
32
|
+
cors?: CorsMode;
|
|
27
33
|
};
|
|
28
34
|
}
|
|
29
35
|
export type IngressDecl = CertificateDecl | DnsDecl | ProxyDecl;
|
|
@@ -71,6 +77,7 @@ export declare function proxy(hostname: string, upstream: {
|
|
|
71
77
|
service: string;
|
|
72
78
|
port: number;
|
|
73
79
|
http2?: boolean;
|
|
80
|
+
cors?: CorsMode;
|
|
74
81
|
}): ProxyDecl;
|
|
75
82
|
/**
|
|
76
83
|
* Attach low-level ingress decls to a service definition a component
|
|
@@ -100,6 +107,7 @@ export interface LoweredIngress {
|
|
|
100
107
|
service: string;
|
|
101
108
|
port: number;
|
|
102
109
|
http2?: boolean;
|
|
110
|
+
cors?: CorsMode;
|
|
103
111
|
}[];
|
|
104
112
|
/** Hostnames that resolve to the daemon ingress gateway (DNS registry
|
|
105
113
|
* + `--add-host` on every container). Covers fakes, TLS proxies, and
|
package/dist/ingress.js
CHANGED
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
// deserialises: the decls live on the `Project` (daemon-side only) and,
|
|
18
18
|
// when attached to a service, ride on a Symbol key that `JSON.stringify`
|
|
19
19
|
// drops — so Rust's `deny_unknown_fields` never sees them.
|
|
20
|
+
/** @internal Validate JavaScript config as well as TypeScript callers. */
|
|
21
|
+
export function validateCorsMode(cors) {
|
|
22
|
+
if (cors !== undefined && cors !== "passthrough" && cors !== "permissive") {
|
|
23
|
+
throw new Error('cors must be "passthrough" or "permissive"');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
20
26
|
/** Token a component can use in a decl where it can't know its own
|
|
21
27
|
* services-map key yet — resolved to that key during `lowerIngress`.
|
|
22
28
|
* Mirrors the `{{SPECTEST_SERVICE}}` token honoured in `files`. */
|
|
@@ -101,6 +107,7 @@ export function proxy(hostname, upstream) {
|
|
|
101
107
|
}
|
|
102
108
|
if (upstream.http2 !== undefined && typeof upstream.http2 !== "boolean")
|
|
103
109
|
throw new Error("proxy(): http2 must be a boolean");
|
|
110
|
+
validateCorsMode(upstream.cors);
|
|
104
111
|
return { kind: "proxy", hostname: hostname.toLowerCase(), upstream };
|
|
105
112
|
}
|
|
106
113
|
/** Symbol key under which `provides(...)` stashes a component's ingress
|
|
@@ -165,7 +172,7 @@ export function lowerIngress(project) {
|
|
|
165
172
|
break;
|
|
166
173
|
case "proxy": {
|
|
167
174
|
const service = resolveSelf(decl.upstream.service, selfKey);
|
|
168
|
-
proxies.push({ hostname: decl.hostname, service, port: decl.upstream.port, ...(decl.upstream.http2 ? { http2: true } : {}) });
|
|
175
|
+
proxies.push({ hostname: decl.hostname, service, port: decl.upstream.port, ...(decl.upstream.http2 ? { http2: true } : {}), ...(decl.upstream.cors ? { cors: decl.upstream.cors } : {}) });
|
|
169
176
|
// A proxied hostname must route to the daemon. A wildcard one can
|
|
170
177
|
// only be answered by the resolver (there is no `--add-host` for a
|
|
171
178
|
// pattern), so it goes in the wildcard table pointed at the ingress.
|
|
@@ -204,7 +211,7 @@ export function lowerIngress(project) {
|
|
|
204
211
|
// `tls: [{ hostname, port }]` → cert + TLS-terminated reverse proxy.
|
|
205
212
|
for (const entry of svc.tls ?? []) {
|
|
206
213
|
applyDecl(certificate([entry.hostname]), name);
|
|
207
|
-
applyDecl(proxy(entry.hostname, { service: name, port: entry.port, ...(entry.http2 ? { http2: true } : {}) }), name);
|
|
214
|
+
applyDecl(proxy(entry.hostname, { service: name, port: entry.port, ...(entry.http2 ? { http2: true } : {}), ...(entry.cors ? { cors: entry.cors } : {}) }), name);
|
|
208
215
|
}
|
|
209
216
|
// `hostnames: [h]` → plain peer alias (no daemon hop).
|
|
210
217
|
for (const h of svc.hostnames ?? []) {
|
package/package.json
CHANGED
package/src/components/index.ts
CHANGED
|
@@ -85,7 +85,10 @@ export { okta, type OktaOptions } from "./okta.js";
|
|
|
85
85
|
export {
|
|
86
86
|
replayFake,
|
|
87
87
|
type ReplayFakeOptions,
|
|
88
|
-
type
|
|
88
|
+
type ReplayKey,
|
|
89
|
+
type ReplayRequest,
|
|
90
|
+
type MatchKey,
|
|
91
|
+
type JsonValue,
|
|
89
92
|
type InjectRule,
|
|
90
93
|
type InjectMatch,
|
|
91
94
|
type StringMatch,
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { CassetteRequest } from "./replayFake.js";
|
|
3
|
+
|
|
4
|
+
export type JsonValue =
|
|
5
|
+
| null | boolean | number | string
|
|
6
|
+
| readonly JsonValue[]
|
|
7
|
+
| { readonly [key: string]: JsonValue };
|
|
8
|
+
|
|
9
|
+
/** Identity content. Objects compare independently of property order; arrays
|
|
10
|
+
* preserve order. Bytes are distinct from strings, arrays, and objects. */
|
|
11
|
+
export type MatchKey =
|
|
12
|
+
| null | boolean | number | string | Uint8Array
|
|
13
|
+
| readonly MatchKey[]
|
|
14
|
+
| { readonly [key: string]: MatchKey };
|
|
15
|
+
|
|
16
|
+
/** A buffered request view shared by incoming and recorded requests.
|
|
17
|
+
* Headers are lowercase and exclude credentials. Recorded values may be
|
|
18
|
+
* redacted; choose identity fields that survive recording. */
|
|
19
|
+
export interface ReplayRequest {
|
|
20
|
+
readonly method: string;
|
|
21
|
+
readonly path: string;
|
|
22
|
+
readonly query: Readonly<Record<string, readonly string[]>>;
|
|
23
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
24
|
+
/** Decode UTF-8, throwing on invalid text. */
|
|
25
|
+
text(): string;
|
|
26
|
+
/** Parse JSON. The type parameter is not runtime validation. */
|
|
27
|
+
json<T = JsonValue>(): T;
|
|
28
|
+
/** Return an independent copy of the body bytes. */
|
|
29
|
+
bytes(): Uint8Array;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ReplayKey = (request: ReplayRequest) => MatchKey;
|
|
33
|
+
|
|
34
|
+
/** Stream a typed, length-delimited canonical representation into the hash.
|
|
35
|
+
* Never call toJSON: unsupported values must fail rather than silently lose
|
|
36
|
+
* identity fields. In particular, an async callback must not become `{}`. */
|
|
37
|
+
function hashKey(key: MatchKey): string {
|
|
38
|
+
const hash = createHash("sha256");
|
|
39
|
+
const ancestors = new Set<object>();
|
|
40
|
+
const visit = (value: unknown): void => {
|
|
41
|
+
if (value === null || typeof value === "boolean" || typeof value === "string") {
|
|
42
|
+
hash.update(JSON.stringify(value));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
46
|
+
hash.update(JSON.stringify(value));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (value instanceof Uint8Array) {
|
|
50
|
+
hash.update(`bytes:${value.byteLength}:`);
|
|
51
|
+
hash.update(value);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (value instanceof Promise) {
|
|
55
|
+
// Report the invalid callback synchronously without leaving a rejected
|
|
56
|
+
// async callback to become an unrelated unhandled-rejection failure.
|
|
57
|
+
void value.catch(() => {});
|
|
58
|
+
throw new Error("key callbacks must be synchronous, not return promises");
|
|
59
|
+
}
|
|
60
|
+
if (typeof value !== "object" || value === null) {
|
|
61
|
+
throw new Error("key must contain only JSON values or Uint8Array bytes (no undefined, functions, or non-finite numbers)");
|
|
62
|
+
}
|
|
63
|
+
if (ancestors.has(value)) throw new Error("key must not contain cycles");
|
|
64
|
+
if (Object.getOwnPropertySymbols(value).length > 0) {
|
|
65
|
+
throw new Error("key must not contain symbol properties");
|
|
66
|
+
}
|
|
67
|
+
ancestors.add(value);
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
if (Object.keys(value).length !== value.length) {
|
|
70
|
+
throw new Error("key arrays must be dense and have no extra properties");
|
|
71
|
+
}
|
|
72
|
+
hash.update("[");
|
|
73
|
+
for (let i = 0; i < value.length; i++) {
|
|
74
|
+
if (!Object.hasOwn(value, i)) throw new Error("key arrays must be dense");
|
|
75
|
+
visit(value[i]);
|
|
76
|
+
hash.update(",");
|
|
77
|
+
}
|
|
78
|
+
hash.update("]");
|
|
79
|
+
} else {
|
|
80
|
+
const proto = Object.getPrototypeOf(value);
|
|
81
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
82
|
+
throw new Error("key must use plain objects, not promises, dates, maps, or class instances; callbacks must be synchronous");
|
|
83
|
+
}
|
|
84
|
+
hash.update("{");
|
|
85
|
+
for (const name of Object.keys(value).sort()) {
|
|
86
|
+
hash.update(JSON.stringify(name));
|
|
87
|
+
hash.update(":");
|
|
88
|
+
visit((value as Record<string, unknown>)[name]);
|
|
89
|
+
hash.update(",");
|
|
90
|
+
}
|
|
91
|
+
hash.update("}");
|
|
92
|
+
}
|
|
93
|
+
ancestors.delete(value);
|
|
94
|
+
};
|
|
95
|
+
visit(key);
|
|
96
|
+
return hash.digest("hex");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function bodyBytes(request: CassetteRequest): Uint8Array {
|
|
100
|
+
return Buffer.from(request.body ?? "", request.bodyEncoding === "base64" ? "base64" : "utf8");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function requestView(request: CassetteRequest): ReplayRequest {
|
|
104
|
+
let bytes: Uint8Array | undefined;
|
|
105
|
+
let text: string | undefined;
|
|
106
|
+
const readBytes = () => bytes ??= bodyBytes(request);
|
|
107
|
+
const readText = () => text ??= new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(readBytes());
|
|
108
|
+
const query = Object.fromEntries(Object.entries(request.query).map(([k, v]) => [k, Object.freeze([...v])]));
|
|
109
|
+
return Object.freeze({
|
|
110
|
+
method: request.method.toUpperCase(),
|
|
111
|
+
path: request.path,
|
|
112
|
+
query: Object.freeze(query),
|
|
113
|
+
headers: Object.freeze({ ...request.headers }),
|
|
114
|
+
text: readText,
|
|
115
|
+
json: <T = JsonValue>(): T => JSON.parse(readText()) as T,
|
|
116
|
+
bytes: () => Uint8Array.from(readBytes()),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Internal only; the SDK exports the request/key types, not this function. */
|
|
121
|
+
export function replaySignature(request: CassetteRequest, key?: ReplayKey): string {
|
|
122
|
+
if (key) return hashKey(key(requestView(request)));
|
|
123
|
+
// Existing cassettes retain the original byte hash even when their stored
|
|
124
|
+
// text was redacted. Older/hand-written cassettes may only have the body.
|
|
125
|
+
const body = request.bodyHash ?? `sha256:${createHash("sha256").update(bodyBytes(request)).digest("hex")}`;
|
|
126
|
+
return hashKey({ method: request.method.toUpperCase(), path: request.path, query: request.query, body });
|
|
127
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
// Isolate module mocks from other tests. Drive the actual record handler,
|
|
7
|
+
// replacing only DNS/HTTPS transport; no credentials or external egress.
|
|
8
|
+
test("recorded headers, redaction, and exact body bytes survive record → replay", async () => {
|
|
9
|
+
const moduleUrl = (file: string) => JSON.stringify(new URL(file, import.meta.url).href);
|
|
10
|
+
const script = `
|
|
11
|
+
import { strict as assert } from "node:assert";
|
|
12
|
+
import { EventEmitter } from "node:events";
|
|
13
|
+
import { mock, test } from "bun:test";
|
|
14
|
+
import * as dnsPacket from ${JSON.stringify(import.meta.resolve("dns-packet"))};
|
|
15
|
+
|
|
16
|
+
test("record round trip", async () => {
|
|
17
|
+
mock.module("node:dgram", () => ({ createSocket() {
|
|
18
|
+
const socket = new EventEmitter();
|
|
19
|
+
socket.close = () => {};
|
|
20
|
+
socket.send = (_query, _port, _host, done) => {
|
|
21
|
+
done();
|
|
22
|
+
queueMicrotask(() => socket.emit("message", dnsPacket.encode({
|
|
23
|
+
type: "response", id: 1,
|
|
24
|
+
answers: [{ type: "A", name: "api.example.test", ttl: 60, data: "127.0.0.1" }],
|
|
25
|
+
})));
|
|
26
|
+
};
|
|
27
|
+
return socket;
|
|
28
|
+
} }));
|
|
29
|
+
|
|
30
|
+
const upstreamHeaders = [];
|
|
31
|
+
mock.module("node:https", () => ({ request(options, callback) {
|
|
32
|
+
upstreamHeaders.push(options.headers);
|
|
33
|
+
const req = new EventEmitter();
|
|
34
|
+
req.write = () => {};
|
|
35
|
+
req.end = () => queueMicrotask(() => {
|
|
36
|
+
const res = new EventEmitter();
|
|
37
|
+
res.statusCode = 200;
|
|
38
|
+
res.headers = { "content-type": "text/plain" };
|
|
39
|
+
callback(res);
|
|
40
|
+
res.emit("data", Buffer.from("response"));
|
|
41
|
+
res.emit("end");
|
|
42
|
+
});
|
|
43
|
+
return req;
|
|
44
|
+
} }));
|
|
45
|
+
|
|
46
|
+
const { replayFake } = await import(${moduleUrl("./replayFake.ts")});
|
|
47
|
+
const { setRecordSecrets } = await import(${moduleUrl("../record-secrets.ts")});
|
|
48
|
+
setRecordSecrets({ API_KEY: "recording-secret" });
|
|
49
|
+
const options = {
|
|
50
|
+
name: "test", host: "api.example.test",
|
|
51
|
+
inject: [{ headers: { authorization: "Bearer {{secret:API_KEY}}" } }],
|
|
52
|
+
redactPatterns: ["private-field"],
|
|
53
|
+
};
|
|
54
|
+
const record = replayFake({ ...options, mode: "record" });
|
|
55
|
+
const state = record.state();
|
|
56
|
+
const helpers = record.helpers({ name: "test", state, ctx: {} });
|
|
57
|
+
const bodies = ["\\ufeffhello", new Uint8Array([0, 128, 255]), "recording-secret private-field"];
|
|
58
|
+
for (const [i, body] of bodies.entries()) {
|
|
59
|
+
const res = await record.handler(new Request("https://api.example.test/messages", {
|
|
60
|
+
method: "POST", body,
|
|
61
|
+
headers: {
|
|
62
|
+
"X-Task": "task-" + i,
|
|
63
|
+
authorization: "Bearer dummy", cookie: "dummy-cookie",
|
|
64
|
+
"api-key": "dummy-api-key", "x-goog-api-key": "dummy-google-key",
|
|
65
|
+
"x-extra": "recording-secret private-field",
|
|
66
|
+
},
|
|
67
|
+
}), state, {});
|
|
68
|
+
assert.equal(res.status, 200, await res.text());
|
|
69
|
+
}
|
|
70
|
+
assert.equal(helpers.count(), 3);
|
|
71
|
+
assert.equal(upstreamHeaders[0].authorization, "Bearer recording-secret");
|
|
72
|
+
const cassette = JSON.parse(JSON.stringify(helpers.dump()));
|
|
73
|
+
assert.equal(cassette.interactions[0].request.body, "\\ufeffhello");
|
|
74
|
+
assert.equal(cassette.interactions[1].request.bodyEncoding, "base64");
|
|
75
|
+
assert.equal(cassette.interactions[2].request.body, "<REDACTED:API_KEY> <REDACTED>");
|
|
76
|
+
for (const [i, it] of cassette.interactions.entries()) {
|
|
77
|
+
assert.equal(it.request.headers["x-task"], "task-" + i);
|
|
78
|
+
assert.equal(it.request.headers["x-extra"], "<REDACTED:API_KEY> <REDACTED>");
|
|
79
|
+
for (const name of ["authorization", "cookie", "api-key", "x-goog-api-key"]) {
|
|
80
|
+
assert.equal(it.request.headers[name], undefined);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
assert.equal(JSON.stringify(cassette).includes("recording-secret"), false);
|
|
84
|
+
|
|
85
|
+
for (const key of [undefined, r => r.headers["x-task"]]) {
|
|
86
|
+
const replay = replayFake({ ...options, mode: "replay", key });
|
|
87
|
+
const replayState = replay.state();
|
|
88
|
+
replay.helpers({ name: "test", state: replayState, ctx: {} }).replay(cassette);
|
|
89
|
+
for (const i of [2, 0, 1]) {
|
|
90
|
+
const res = await replay.handler(new Request("https://api.example.test/messages", {
|
|
91
|
+
method: "POST", body: bodies[i], headers: { "X-Task": "task-" + i },
|
|
92
|
+
}), replayState, {});
|
|
93
|
+
assert.equal(res.status, 200);
|
|
94
|
+
assert.equal(await res.text(), "response");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
assert.equal(upstreamHeaders.length, 3); // Replay never forwards.
|
|
98
|
+
});
|
|
99
|
+
`;
|
|
100
|
+
const dir = await mkdtemp(join(tmpdir(), "spectest-replay-record-"));
|
|
101
|
+
try {
|
|
102
|
+
const file = join(dir, "record.test.ts");
|
|
103
|
+
await writeFile(file, script);
|
|
104
|
+
const child = Bun.spawn([process.execPath, "test", file], {
|
|
105
|
+
stdout: "pipe", stderr: "pipe",
|
|
106
|
+
});
|
|
107
|
+
const [exit, stdout, stderr] = await Promise.all([
|
|
108
|
+
child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(),
|
|
109
|
+
]);
|
|
110
|
+
expect(exit, stdout + stderr).toBe(0);
|
|
111
|
+
} finally {
|
|
112
|
+
await rm(dir, { recursive: true, force: true });
|
|
113
|
+
}
|
|
114
|
+
}, 10_000);
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import type { FakeContext } from "../index.js";
|
|
4
|
+
import { replayFake, type Cassette, type CassetteInteraction, type ReplayFakeOptions, type ReplayRequest, type ReplayKey, type MatchKey } from "./index.js";
|
|
5
|
+
|
|
6
|
+
const context = {} as FakeContext;
|
|
7
|
+
const host = "api.example.test";
|
|
8
|
+
const sha = (body: string | Uint8Array) => `sha256:${createHash("sha256").update(body).digest("hex")}`;
|
|
9
|
+
|
|
10
|
+
function interaction(body: string | Uint8Array, answer: string, path = "/messages"): CassetteInteraction {
|
|
11
|
+
return {
|
|
12
|
+
request: {
|
|
13
|
+
method: "POST", path, query: {}, headers: { "content-type": "application/json" },
|
|
14
|
+
body: typeof body === "string" ? body : Buffer.from(body).toString("base64"),
|
|
15
|
+
bodyEncoding: typeof body === "string" ? "utf8" : "base64",
|
|
16
|
+
bodyHash: sha(body),
|
|
17
|
+
},
|
|
18
|
+
response: { status: 200, headers: {}, body: answer, bodyEncoding: "utf8" },
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function cassette(interactions: CassetteInteraction[]): Cassette {
|
|
23
|
+
return { version: 1, fake: "test", host, interactions };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function harness(options: Partial<ReplayFakeOptions> = {}) {
|
|
27
|
+
const fake = replayFake({ name: "test", host, mode: "replay", ...options });
|
|
28
|
+
const state = await fake.state!();
|
|
29
|
+
const helpers = await fake.helpers!({ name: "test", state, ctx: context });
|
|
30
|
+
return {
|
|
31
|
+
state, helpers,
|
|
32
|
+
async send(body: string | Uint8Array = "", path = "/messages", init: RequestInit = {}) {
|
|
33
|
+
return await fake.handler(new Request(`https://${host}${path}`, {
|
|
34
|
+
method: "POST", body: body as BodyInit, ...init,
|
|
35
|
+
}), state, context);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("replay request identity", () => {
|
|
41
|
+
test("default matches 654 concurrent requests by body, independently of arrival order", async () => {
|
|
42
|
+
const h = await harness();
|
|
43
|
+
const calls = Array.from({ length: 654 }, (_, id) => interaction(JSON.stringify({ id }), `answer-${id}`));
|
|
44
|
+
h.helpers.replay(cassette(calls));
|
|
45
|
+
// A permutation visits every identity once in a different order.
|
|
46
|
+
const results = await Promise.all(calls.map(async (_, i) => {
|
|
47
|
+
const id = (i * 37) % calls.length;
|
|
48
|
+
const response = await h.send(JSON.stringify({ id }));
|
|
49
|
+
expect(response.status).toBe(200);
|
|
50
|
+
expect(await response.text()).toBe(`answer-${id}`);
|
|
51
|
+
}));
|
|
52
|
+
expect(results).toHaveLength(654);
|
|
53
|
+
expect(h.state.cursor.size).toBe(654);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("default uses method/path/normalized query/exact bytes, including old cassettes without hashes", async () => {
|
|
57
|
+
const h = await harness();
|
|
58
|
+
const it = interaction('{"a":1}', "ok");
|
|
59
|
+
delete it.request.bodyHash;
|
|
60
|
+
delete it.request.bodyEncoding;
|
|
61
|
+
it.request.query = { b: ["1", "2"], a: ["x"] };
|
|
62
|
+
h.helpers.replay(cassette([it]));
|
|
63
|
+
for (const [body, path, init] of [
|
|
64
|
+
['{ "a":1}', "/messages?a=x&b=2&b=1", {}],
|
|
65
|
+
['{"a":1}', "/other?a=x&b=2&b=1", {}],
|
|
66
|
+
['{"a":1}', "/messages?a=x&b=2&b=1", { method: "PUT" }],
|
|
67
|
+
['{"a":1}', "/messages?a=y&b=2&b=1", {}],
|
|
68
|
+
] as const) expect((await h.send(body, path, init)).status).toBe(599);
|
|
69
|
+
expect(await (await h.send('{"a":1}', "/messages?b=2&a=x&b=1")).text()).toBe("ok");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("the existing committed HTTP cassette still replays with the new default", async () => {
|
|
73
|
+
const h = await harness();
|
|
74
|
+
const existing = await Bun.file(new URL("../../../examples/replay-fake/spectest/tests/recordings/httpbin.json", import.meta.url)).json() as Cassette;
|
|
75
|
+
h.helpers.replay(existing);
|
|
76
|
+
const response = await h.send("", "/get?hello=world", { method: "GET", body: null });
|
|
77
|
+
expect(response.status).toBe(200);
|
|
78
|
+
expect((await response.json()).args).toEqual({ hello: ["world"] });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("SigV4 query credentials stay outside default and custom identity", async () => {
|
|
82
|
+
for (const key of [undefined, (r: ReplayRequest) => ({ path: r.path, query: r.query })]) {
|
|
83
|
+
const h = await harness({ key, sign: { type: "awsSigv4", accessKeyId: "ACCESS", secretAccessKey: "SECRET" } });
|
|
84
|
+
h.helpers.replay(cassette([interaction("body", "ok")]));
|
|
85
|
+
expect(await (await h.send("body", "/messages?X-Amz-Signature=changing&X-Amz-Date=20261001")).text()).toBe("ok");
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("Dentio keys separate model/tasks while ignoring a changing date", async () => {
|
|
90
|
+
let evaluations = 0;
|
|
91
|
+
const h = await harness({ key(request) {
|
|
92
|
+
evaluations++;
|
|
93
|
+
const body = request.json<{ system: { text: string }[] }>();
|
|
94
|
+
return { model: request.path, task: body.system[0].text.slice(0, 200) };
|
|
95
|
+
} });
|
|
96
|
+
const calls = Array.from({ length: 8 }, (_, i) => ({
|
|
97
|
+
task: `task-${i}`.padEnd(200, "."),
|
|
98
|
+
path: `/model/${i < 4 ? "haiku" : "sonnet"}/converse`,
|
|
99
|
+
}));
|
|
100
|
+
const body = (task: string, date: string) => JSON.stringify({ system: [{ text: `${task} Today: ${date}` }] });
|
|
101
|
+
h.helpers.replay(cassette(calls.map((call, i) => interaction(body(call.task, "2026-10-01"), i === 4 ? "Karies 16o" : `task-${i}`, call.path))));
|
|
102
|
+
expect(evaluations).toBe(8);
|
|
103
|
+
for (const i of [7, 2, 4, 0, 6, 1, 5, 3]) {
|
|
104
|
+
expect(await (await h.send(body(calls[i].task, "2026-10-02"), calls[i].path)).text()).toBe(i === 4 ? "Karies 16o" : `task-${i}`);
|
|
105
|
+
}
|
|
106
|
+
expect(evaluations).toBe(16); // No rescanning/recomputing cassette keys.
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("custom key is the entire identity, with no implicit HTTP dimensions", async () => {
|
|
110
|
+
const h = await harness({ key: request => request.json<{ task: string }>().task });
|
|
111
|
+
h.helpers.replay(cassette([interaction('{"task":"same","date":"yesterday"}', "ok", "/old")]));
|
|
112
|
+
expect(await (await h.send('{"task":"same","date":"today"}', "/new?nonce=123", { method: "PUT" })).text()).toBe("ok");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("JSON objects compare structurally but arrays preserve order", async () => {
|
|
116
|
+
const h = await harness({ key: request => request.json() });
|
|
117
|
+
h.helpers.replay(cassette([interaction('{"b":2,"a":{"x":[1,2]}}', "ok")]));
|
|
118
|
+
expect((await h.send('{"a":{"x":[2,1]},"b":2}')).status).toBe(599);
|
|
119
|
+
expect(await (await h.send('{ "a": {"x":[1,2]}, "b": 2 }')).text()).toBe("ok");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("binary keys are lossless and distinct from JSON lookalikes", async () => {
|
|
123
|
+
const h = await harness({ key: request => ({ body: request.bytes() }) });
|
|
124
|
+
const bytes = new Uint8Array([0, 255, 128]);
|
|
125
|
+
h.helpers.replay(cassette([interaction(bytes, "binary")]));
|
|
126
|
+
expect((await h.send("[0,255,128]")).status).toBe(599);
|
|
127
|
+
expect(await (await h.send(bytes)).text()).toBe("binary");
|
|
128
|
+
|
|
129
|
+
const mixed = await harness({ key: r => r.path === "/binary" ? new Uint8Array([1, 2]) : r.json() });
|
|
130
|
+
mixed.helpers.replay(cassette([interaction("", "bytes", "/binary"), interaction("[1,2]", "array"), interaction('{"0":1,"1":2}', "object")]));
|
|
131
|
+
expect(await (await mixed.send("[1,2]")).text()).toBe("array");
|
|
132
|
+
expect(await (await mixed.send('{"0":1,"1":2}')).text()).toBe("object");
|
|
133
|
+
expect(await (await mixed.send("", "/binary")).text()).toBe("bytes");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("empty and BOM-prefixed bodies preserve their bytes", async () => {
|
|
137
|
+
for (const key of [undefined, (r: ReplayRequest) => r.bytes()]) {
|
|
138
|
+
const h = await harness({ key });
|
|
139
|
+
h.helpers.replay(cassette([interaction("", "empty"), interaction("\ufeffhello", "bom")]));
|
|
140
|
+
expect(await (await h.send("")).text()).toBe("empty");
|
|
141
|
+
expect((await h.send("hello")).status).toBe(599);
|
|
142
|
+
expect(await (await h.send("\ufeffhello")).text()).toBe("bom");
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("same-key queues consume once, fail on exhaustion, and reset on reload", async () => {
|
|
147
|
+
const h = await harness();
|
|
148
|
+
const c = cassette([interaction("same", "first"), interaction("other", "other"), interaction("same", "second")]);
|
|
149
|
+
h.helpers.replay(c);
|
|
150
|
+
expect(await Promise.all([h.send("same"), h.send("same")].map(async p => (await p).text()))).toEqual(["first", "second"]);
|
|
151
|
+
const exhausted = await h.send("same");
|
|
152
|
+
expect(exhausted.status).toBe(599);
|
|
153
|
+
expect(await exhausted.text()).toContain("exhausted key");
|
|
154
|
+
expect(await (await h.send("missing")).text()).toContain("unknown key");
|
|
155
|
+
expect(await (await h.send("other")).text()).toBe("other");
|
|
156
|
+
h.helpers.replay(c);
|
|
157
|
+
expect(await (await h.send("same")).text()).toBe("first");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("cassette keys and cursors are isolated between fake states", async () => {
|
|
161
|
+
const a = await harness(), b = await harness();
|
|
162
|
+
const c = cassette([interaction("same", "ok")]);
|
|
163
|
+
a.helpers.replay(c); b.helpers.replay(c);
|
|
164
|
+
expect(await (await a.send("same")).text()).toBe("ok");
|
|
165
|
+
expect((await a.send("same")).status).toBe(599);
|
|
166
|
+
expect(await (await b.send("same")).text()).toBe("ok");
|
|
167
|
+
// Inline input cannot mutate a loaded/indexed cassette.
|
|
168
|
+
c.interactions[0].response.body = "mutated";
|
|
169
|
+
expect(a.helpers.dump().interactions[0].response.body).toBe("ok");
|
|
170
|
+
a.helpers.dump().interactions[0].response.body = "mutated dump";
|
|
171
|
+
expect(a.helpers.dump().interactions[0].response.body).toBe("ok");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("request accessors are synchronous, reusable, and omit internal hashes/credentials", async () => {
|
|
175
|
+
const h = await harness({ key(r) {
|
|
176
|
+
expect("bodyHash" in r).toBe(false);
|
|
177
|
+
expect(r.headers.authorization).toBeUndefined();
|
|
178
|
+
expect(r.headers.cookie).toBeUndefined();
|
|
179
|
+
expect(Object.isFrozen(r)).toBe(true);
|
|
180
|
+
expect(Object.isFrozen(r.query)).toBe(true);
|
|
181
|
+
const bytes = r.bytes(); bytes.fill(0);
|
|
182
|
+
expect(r.text()).toBe('{"a":1}');
|
|
183
|
+
expect(r.json<{ a: number }>()).toEqual({ a: 1 });
|
|
184
|
+
return r.headers["x-task"]!;
|
|
185
|
+
} });
|
|
186
|
+
const it = interaction('{"a":1}', "ok");
|
|
187
|
+
it.request.headers["x-task"] = "journal";
|
|
188
|
+
h.helpers.replay(cassette([it]));
|
|
189
|
+
expect(await (await h.send('{"a":1}', "/messages", { headers: { "X-Task": "journal", authorization: "secret", cookie: "secret" } })).text()).toBe("ok");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("bad incoming JSON fails without consuming a response; bad loads preserve live state", async () => {
|
|
193
|
+
const h = await harness({ key: r => r.json() });
|
|
194
|
+
h.helpers.replay(cassette([interaction("{}", "ok")]));
|
|
195
|
+
expect(() => h.helpers.replay(cassette([interaction("invalid", "bad")]))).toThrow("interaction #1");
|
|
196
|
+
const bad = await h.send("invalid");
|
|
197
|
+
expect(bad.status).toBe(599);
|
|
198
|
+
expect(await bad.text()).toContain("invalid replay key");
|
|
199
|
+
expect(await (await h.send("{}")).text()).toBe("ok");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test.each([
|
|
203
|
+
undefined, NaN, Infinity, BigInt(1), new Date(), new Map(), Promise.resolve("x"),
|
|
204
|
+
{ missing: undefined }, [undefined], Array(1), () => "x",
|
|
205
|
+
])("rejects unsupported keys instead of silently collapsing identities: %p", async value => {
|
|
206
|
+
const h = await harness({ key: () => value as MatchKey });
|
|
207
|
+
expect(() => h.helpers.replay(cassette([interaction("", "bad")]))).toThrow("invalid replay key");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("rejects cyclic keys but permits shared subobjects", async () => {
|
|
211
|
+
const value: Record<string, MatchKey> = {};
|
|
212
|
+
value.self = value;
|
|
213
|
+
const bad = await harness({ key: () => value });
|
|
214
|
+
expect(() => bad.helpers.replay(cassette([interaction("", "bad")]))).toThrow("cycles");
|
|
215
|
+
const shared = { a: 1 };
|
|
216
|
+
const good = await harness({ key: () => [shared, shared] });
|
|
217
|
+
good.helpers.replay(cassette([interaction("", "ok")]));
|
|
218
|
+
expect(await (await good.send()).text()).toBe("ok");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("rejects an async callback that throws without leaking an unhandled rejection", async () => {
|
|
222
|
+
const h = await harness({ key: (async () => { throw new Error("async failure"); }) as unknown as ReplayFakeOptions["key"] });
|
|
223
|
+
expect(() => h.helpers.replay(cassette([interaction("", "bad")]))).toThrow("synchronous");
|
|
224
|
+
await Promise.resolve();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("old match options fail with migration guidance", () => {
|
|
228
|
+
expect(() => replayFake({ name: "test", host, match: { body: "hash" } } as unknown as ReplayFakeOptions)).toThrow("has been replaced by `key(request)`");
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Compile-time API checks, verified by the targeted TypeScript check as well
|
|
233
|
+
// as the SDK build. Never invoked: invalid callbacks must not run.
|
|
234
|
+
function publicTypes(request: ReplayRequest): void {
|
|
235
|
+
const json: ReplayKey = r => r.json();
|
|
236
|
+
const bytes: ReplayKey = r => ({ body: r.bytes(), query: r.query });
|
|
237
|
+
void [json, bytes];
|
|
238
|
+
// @ts-expect-error bodyHash is internal, not part of the callback view.
|
|
239
|
+
request.bodyHash;
|
|
240
|
+
// @ts-expect-error Request metadata is read-only.
|
|
241
|
+
request.method = "POST";
|
|
242
|
+
// @ts-expect-error Query values are read-only.
|
|
243
|
+
request.query.id.push("another");
|
|
244
|
+
// @ts-expect-error Callbacks must be synchronous.
|
|
245
|
+
const asyncKey: ReplayKey = async r => r.text();
|
|
246
|
+
// @ts-expect-error Undefined is not an identity.
|
|
247
|
+
const absentKey: ReplayKey = () => undefined;
|
|
248
|
+
void [asyncKey, absentKey];
|
|
249
|
+
replayFake({ name: "test", host,
|
|
250
|
+
// @ts-expect-error Per-dimension matching switches have been removed.
|
|
251
|
+
match: { body: "hash" },
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
void publicTypes;
|