slicetest 0.4.0 → 0.6.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 +293 -7
- package/dist/app.js +2 -2
- package/dist/auth.d.ts +55 -0
- package/dist/auth.js +140 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +18 -6
- package/dist/config.d.ts +72 -5
- package/dist/config.js +74 -6
- package/dist/containers.d.ts +2 -0
- package/dist/containers.js +20 -1
- package/dist/db.d.ts +28 -0
- package/dist/db.js +68 -0
- package/dist/doctor.js +17 -5
- package/dist/drivers/driver.d.ts +27 -0
- package/dist/drivers/mysql.d.ts +2 -1
- package/dist/drivers/mysql.js +38 -0
- package/dist/drivers/postgres.d.ts +2 -1
- package/dist/drivers/postgres.js +28 -0
- package/dist/drivers/sqlite.d.ts +2 -1
- package/dist/drivers/sqlite.js +52 -0
- package/dist/factory.d.ts +21 -0
- package/dist/factory.js +129 -0
- package/dist/form.d.ts +45 -0
- package/dist/form.js +226 -0
- package/dist/gen.d.ts +1 -0
- package/dist/gen.js +9 -4
- package/dist/global-setup.d.ts +1 -0
- package/dist/global-setup.js +90 -34
- package/dist/http.d.ts +24 -1
- package/dist/http.js +86 -9
- package/dist/index.d.ts +6 -1
- package/dist/index.js +3 -0
- package/dist/init.js +245 -24
- package/dist/intercept.d.ts +37 -0
- package/dist/intercept.js +199 -0
- package/dist/neon.d.ts +14 -0
- package/dist/neon.js +113 -0
- package/dist/openapi.d.ts +10 -0
- package/dist/openapi.js +22 -0
- package/dist/query-log.d.ts +49 -0
- package/dist/query-log.js +261 -0
- package/dist/runtime.d.ts +18 -0
- package/dist/runtime.js +163 -23
- package/dist/setup-file.js +19 -2
- package/dist/stub.d.ts +46 -0
- package/dist/stub.js +109 -0
- package/dist/vitest.d.ts +11 -1
- package/dist/vitest.js +27 -2
- package/dist/webhook.d.ts +40 -0
- package/dist/webhook.js +52 -0
- package/dist/x509.d.ts +37 -0
- package/dist/x509.js +150 -0
- package/dist/yaml-runtime.js +76 -16
- package/dist/yaml.d.ts +67 -1
- package/dist/yaml.js +87 -3
- package/package.json +12 -4
- package/preload/node-proxy.cjs +19 -0
- package/schema/scenario.schema.json +320 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** A custom HMAC scheme: `header: prefix + hmac(secret, body)`. */
|
|
2
|
+
export interface HmacScheme {
|
|
3
|
+
header: string;
|
|
4
|
+
/** Default `sha256`. */
|
|
5
|
+
algorithm?: string;
|
|
6
|
+
/** Default `hex`. */
|
|
7
|
+
encoding?: "hex" | "base64";
|
|
8
|
+
/** Prepended to the digest, e.g. `sha256=`. */
|
|
9
|
+
prefix?: string;
|
|
10
|
+
}
|
|
11
|
+
export type WebhookProvider = "stripe" | "github" | "slack" | "shopify" | "standard" | HmacScheme;
|
|
12
|
+
export interface WebhookOptions {
|
|
13
|
+
provider: WebhookProvider;
|
|
14
|
+
/** The signing secret the app is configured with. For `standard` (Svix), `whsec_<base64>` or the raw key. */
|
|
15
|
+
secret: string;
|
|
16
|
+
/** Event type, sent where the provider puts it: `X-GitHub-Event`, `X-Shopify-Topic`. */
|
|
17
|
+
event?: string;
|
|
18
|
+
/** Unix seconds the signature is made for. Default: now. */
|
|
19
|
+
timestamp?: number;
|
|
20
|
+
/** Signed ten minutes ago: an app that rejects replays must refuse it. */
|
|
21
|
+
stale?: boolean;
|
|
22
|
+
/** Signed with a different secret: the app must refuse it. */
|
|
23
|
+
invalidSignature?: boolean;
|
|
24
|
+
/** Message id for `standard` (`webhook-id`). Default: a random `msg_…`. */
|
|
25
|
+
id?: string;
|
|
26
|
+
/** Extra request headers. */
|
|
27
|
+
headers?: Record<string, string>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The headers a provider sends with `body`, signed with `secret` the way its
|
|
31
|
+
* SDK verifies them: Stripe's `Stripe-Signature`, GitHub's
|
|
32
|
+
* `X-Hub-Signature-256`, Slack's `v0` signature, Shopify's base64 HMAC and
|
|
33
|
+
* Standard Webhooks (Svix, Resend, Clerk, …).
|
|
34
|
+
*/
|
|
35
|
+
export declare function signWebhook(body: string, opts: WebhookOptions): Record<string, string>;
|
|
36
|
+
/** The bytes to send and their content type: objects as JSON, URLSearchParams as a form (Slack commands), strings as-is. */
|
|
37
|
+
export declare function webhookBody(payload: unknown): {
|
|
38
|
+
body: string;
|
|
39
|
+
type: string;
|
|
40
|
+
};
|
package/dist/webhook.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createHmac, randomUUID } from "node:crypto";
|
|
2
|
+
const hmac = (algorithm, key, data) => createHmac(algorithm, key).update(data);
|
|
3
|
+
/**
|
|
4
|
+
* The headers a provider sends with `body`, signed with `secret` the way its
|
|
5
|
+
* SDK verifies them: Stripe's `Stripe-Signature`, GitHub's
|
|
6
|
+
* `X-Hub-Signature-256`, Slack's `v0` signature, Shopify's base64 HMAC and
|
|
7
|
+
* Standard Webhooks (Svix, Resend, Clerk, …).
|
|
8
|
+
*/
|
|
9
|
+
export function signWebhook(body, opts) {
|
|
10
|
+
const now = Math.floor(Date.now() / 1000);
|
|
11
|
+
const ts = opts.timestamp ?? (opts.stale ? now - 600 : now);
|
|
12
|
+
const secret = opts.invalidSignature ? `${opts.secret}-not-the-real-secret` : opts.secret;
|
|
13
|
+
const p = opts.provider;
|
|
14
|
+
if (typeof p === "object") {
|
|
15
|
+
if (!p.header)
|
|
16
|
+
throw new Error("slicetest: a custom webhook provider needs `header`, e.g. { header: \"X-Signature\", prefix: \"sha256=\" }");
|
|
17
|
+
return { [p.header]: `${p.prefix ?? ""}${hmac(p.algorithm ?? "sha256", secret, body).digest(p.encoding ?? "hex")}` };
|
|
18
|
+
}
|
|
19
|
+
switch (p) {
|
|
20
|
+
case "stripe":
|
|
21
|
+
return { "stripe-signature": `t=${ts},v1=${hmac("sha256", secret, `${ts}.${body}`).digest("hex")}` };
|
|
22
|
+
case "github":
|
|
23
|
+
return {
|
|
24
|
+
"x-hub-signature-256": `sha256=${hmac("sha256", secret, body).digest("hex")}`,
|
|
25
|
+
"x-github-event": opts.event ?? "ping",
|
|
26
|
+
"x-github-delivery": randomUUID(),
|
|
27
|
+
};
|
|
28
|
+
case "slack":
|
|
29
|
+
return { "x-slack-request-timestamp": String(ts), "x-slack-signature": `v0=${hmac("sha256", secret, `v0:${ts}:${body}`).digest("hex")}` };
|
|
30
|
+
case "shopify":
|
|
31
|
+
return {
|
|
32
|
+
"x-shopify-hmac-sha256": hmac("sha256", secret, body).digest("base64"),
|
|
33
|
+
...(opts.event ? { "x-shopify-topic": opts.event } : {}),
|
|
34
|
+
"x-shopify-webhook-id": randomUUID(),
|
|
35
|
+
};
|
|
36
|
+
case "standard": {
|
|
37
|
+
const id = opts.id ?? `msg_${randomUUID().replace(/-/g, "")}`;
|
|
38
|
+
const key = secret.startsWith("whsec_") ? Buffer.from(secret.slice(6), "base64") : Buffer.from(secret);
|
|
39
|
+
return { "webhook-id": id, "webhook-timestamp": String(ts), "webhook-signature": `v1,${hmac("sha256", key, `${id}.${ts}.${body}`).digest("base64")}` };
|
|
40
|
+
}
|
|
41
|
+
default:
|
|
42
|
+
throw new Error(`slicetest: unknown webhook provider ${JSON.stringify(p)} (expected stripe, github, slack, shopify, standard or { header, prefix, encoding })`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** The bytes to send and their content type: objects as JSON, URLSearchParams as a form (Slack commands), strings as-is. */
|
|
46
|
+
export function webhookBody(payload) {
|
|
47
|
+
if (typeof payload === "string")
|
|
48
|
+
return { body: payload, type: "application/json" };
|
|
49
|
+
if (payload instanceof URLSearchParams)
|
|
50
|
+
return { body: payload.toString(), type: "application/x-www-form-urlencoded" };
|
|
51
|
+
return { body: JSON.stringify(payload ?? {}), type: "application/json" };
|
|
52
|
+
}
|
package/dist/x509.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Just enough X.509 to impersonate hosts in tests: a throwaway certificate
|
|
3
|
+
* authority, leaf certificates for the intercepted hosts, and the CA in the
|
|
4
|
+
* formats apps read it from (PEM bundle, and a PKCS#12 trust store for Java).
|
|
5
|
+
* DER is written by hand so the package needs no crypto dependency.
|
|
6
|
+
*/
|
|
7
|
+
import { KeyObject } from "node:crypto";
|
|
8
|
+
export declare const seq: (...items: Buffer[]) => Buffer<ArrayBuffer>;
|
|
9
|
+
interface Issued {
|
|
10
|
+
key: KeyObject;
|
|
11
|
+
der: Buffer;
|
|
12
|
+
pem: string;
|
|
13
|
+
}
|
|
14
|
+
/** A certificate authority that exists for one test run. */
|
|
15
|
+
export declare class Authority {
|
|
16
|
+
#private;
|
|
17
|
+
readonly key: KeyObject;
|
|
18
|
+
readonly der: Buffer;
|
|
19
|
+
readonly pem: string;
|
|
20
|
+
constructor();
|
|
21
|
+
/** A certificate for `host`, signed by this authority (cached). */
|
|
22
|
+
leaf(host: string): Issued;
|
|
23
|
+
}
|
|
24
|
+
/** PEM bundle of `extra` plus the usual public roots, for SSL_CERT_FILE and friends (which replace the default store). */
|
|
25
|
+
export declare function pemBundle(roots: readonly string[], extra: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* A PKCS#12 trust store of certificates, readable by Java without a password
|
|
28
|
+
* (`-Djavax.net.ssl.trustStoreType=PKCS12`, no `trustStorePassword`): no MAC and
|
|
29
|
+
* unencrypted certificate bags, each marked as a trusted certificate entry.
|
|
30
|
+
*/
|
|
31
|
+
export declare function pkcs12TrustStore(certs: {
|
|
32
|
+
alias: string;
|
|
33
|
+
der: Buffer;
|
|
34
|
+
}[]): Buffer<ArrayBuffer>;
|
|
35
|
+
/** DER of a PEM certificate, or undefined when it doesn't parse. */
|
|
36
|
+
export declare function pemToDer(pem: string): Buffer<ArrayBuffer> | undefined;
|
|
37
|
+
export {};
|
package/dist/x509.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Just enough X.509 to impersonate hosts in tests: a throwaway certificate
|
|
3
|
+
* authority, leaf certificates for the intercepted hosts, and the CA in the
|
|
4
|
+
* formats apps read it from (PEM bundle, and a PKCS#12 trust store for Java).
|
|
5
|
+
* DER is written by hand so the package needs no crypto dependency.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash, createPublicKey, generateKeyPairSync, randomBytes, sign, X509Certificate } from "node:crypto";
|
|
8
|
+
// --- DER ---
|
|
9
|
+
function len(n) {
|
|
10
|
+
if (n < 0x80)
|
|
11
|
+
return Buffer.from([n]);
|
|
12
|
+
const bytes = [];
|
|
13
|
+
for (let v = n; v > 0; v >>= 8)
|
|
14
|
+
bytes.unshift(v & 0xff);
|
|
15
|
+
return Buffer.from([0x80 | bytes.length, ...bytes]);
|
|
16
|
+
}
|
|
17
|
+
const tlv = (tag, ...content) => {
|
|
18
|
+
const body = Buffer.concat(content);
|
|
19
|
+
return Buffer.concat([Buffer.from([tag]), len(body.length), body]);
|
|
20
|
+
};
|
|
21
|
+
export const seq = (...items) => tlv(0x30, ...items);
|
|
22
|
+
const set = (...items) => tlv(0x31, ...items);
|
|
23
|
+
const octets = (b) => tlv(0x04, b);
|
|
24
|
+
const bits = (b) => tlv(0x03, Buffer.from([0]), b);
|
|
25
|
+
const utf8 = (s) => tlv(0x0c, Buffer.from(s, "utf8"));
|
|
26
|
+
const bmp = (s) => tlv(0x1e, Buffer.from(s, "utf16le").swap16());
|
|
27
|
+
const bool = (v) => tlv(0x01, Buffer.from([v ? 0xff : 0]));
|
|
28
|
+
const explicit = (n, ...content) => tlv(0xa0 + n, ...content);
|
|
29
|
+
const int = (b) => {
|
|
30
|
+
let i = 0;
|
|
31
|
+
while (i < b.length - 1 && b[i] === 0)
|
|
32
|
+
i++;
|
|
33
|
+
const v = b.subarray(i);
|
|
34
|
+
return tlv(0x02, v[0] & 0x80 ? Buffer.concat([Buffer.from([0]), v]) : v);
|
|
35
|
+
};
|
|
36
|
+
const oid = (dotted) => {
|
|
37
|
+
const [a, b, ...rest] = dotted.split(".").map(Number);
|
|
38
|
+
const out = [a * 40 + b];
|
|
39
|
+
for (const n of rest) {
|
|
40
|
+
const chunk = [];
|
|
41
|
+
let v = n;
|
|
42
|
+
do {
|
|
43
|
+
chunk.unshift(v & 0x7f);
|
|
44
|
+
v = Math.floor(v / 128);
|
|
45
|
+
} while (v > 0);
|
|
46
|
+
for (let i = 0; i < chunk.length - 1; i++)
|
|
47
|
+
chunk[i] |= 0x80;
|
|
48
|
+
out.push(...chunk);
|
|
49
|
+
}
|
|
50
|
+
return tlv(0x06, Buffer.from(out));
|
|
51
|
+
};
|
|
52
|
+
const time = (d) => {
|
|
53
|
+
// UTCTime up to 2049, GeneralizedTime after, as RFC 5280 requires.
|
|
54
|
+
const iso = d.toISOString().replace(/[-:T]/g, "").slice(0, 14);
|
|
55
|
+
return d.getUTCFullYear() < 2050 ? tlv(0x17, Buffer.from(`${iso.slice(2)}Z`)) : tlv(0x18, Buffer.from(`${iso}Z`));
|
|
56
|
+
};
|
|
57
|
+
const OID = {
|
|
58
|
+
ecdsaSha256: "1.2.840.10045.4.3.2",
|
|
59
|
+
commonName: "2.5.4.3",
|
|
60
|
+
organization: "2.5.4.10",
|
|
61
|
+
basicConstraints: "2.5.29.19",
|
|
62
|
+
keyUsage: "2.5.29.15",
|
|
63
|
+
extKeyUsage: "2.5.29.37",
|
|
64
|
+
serverAuth: "1.3.6.1.5.5.7.3.1",
|
|
65
|
+
subjectAltName: "2.5.29.17",
|
|
66
|
+
subjectKeyId: "2.5.29.14",
|
|
67
|
+
authorityKeyId: "2.5.29.35",
|
|
68
|
+
data: "1.2.840.113549.1.7.1",
|
|
69
|
+
certBag: "1.2.840.113549.1.12.10.1.3",
|
|
70
|
+
x509Certificate: "1.2.840.113549.1.9.22.1",
|
|
71
|
+
friendlyName: "1.2.840.113549.1.9.20",
|
|
72
|
+
// Java only treats a PKCS#12 certificate as trusted when it carries this attribute.
|
|
73
|
+
oracleTrustedKeyUsage: "2.16.840.1.113894.746875.1.1",
|
|
74
|
+
anyExtendedKeyUsage: "2.5.29.37.0",
|
|
75
|
+
};
|
|
76
|
+
const name = (cn) => seq(set(seq(oid(OID.organization), utf8("slicetest"))), set(seq(oid(OID.commonName), utf8(cn))));
|
|
77
|
+
const extension = (id, critical, value) => seq(oid(id), ...(critical ? [bool(true)] : []), octets(value));
|
|
78
|
+
const keyId = (key) => createHash("sha1").update(createPublicKey(key).export({ type: "spki", format: "der" })).digest();
|
|
79
|
+
function certificate(opts) {
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
const ski = keyId(opts.publicKey);
|
|
82
|
+
const extensions = [
|
|
83
|
+
extension(OID.basicConstraints, true, seq(...(opts.ca ? [bool(true)] : []))),
|
|
84
|
+
// CA: keyCertSign + cRLSign. Leaf: digitalSignature.
|
|
85
|
+
extension(OID.keyUsage, true, opts.ca ? tlv(0x03, Buffer.from([1, 0x06])) : tlv(0x03, Buffer.from([7, 0x80]))),
|
|
86
|
+
extension(OID.subjectKeyId, false, octets(ski)),
|
|
87
|
+
...(opts.ca
|
|
88
|
+
? []
|
|
89
|
+
: [
|
|
90
|
+
extension(OID.authorityKeyId, false, seq(tlv(0x80, opts.issuerKeyId))),
|
|
91
|
+
extension(OID.extKeyUsage, false, seq(oid(OID.serverAuth))),
|
|
92
|
+
extension(OID.subjectAltName, false, seq(...opts.hosts.map((h) => tlv(0x82, Buffer.from(h, "ascii"))))),
|
|
93
|
+
]),
|
|
94
|
+
];
|
|
95
|
+
const algorithm = seq(oid(OID.ecdsaSha256));
|
|
96
|
+
const tbs = seq(explicit(0, int(Buffer.from([2]))), // v3
|
|
97
|
+
int(randomBytes(16)), algorithm, name(opts.issuer), seq(time(new Date(now - 60 * 60_000)), time(new Date(now + (opts.ca ? 7 : 2) * 24 * 60 * 60_000))), name(opts.subject), createPublicKey(opts.publicKey).export({ type: "spki", format: "der" }), explicit(3, seq(...extensions)));
|
|
98
|
+
return seq(tbs, algorithm, bits(sign("sha256", tbs, opts.signer)));
|
|
99
|
+
}
|
|
100
|
+
const toPem = (der) => `-----BEGIN CERTIFICATE-----\n${der.toString("base64").replace(/.{64}/g, "$&\n").replace(/\n?$/, "\n")}-----END CERTIFICATE-----\n`;
|
|
101
|
+
/** A certificate authority that exists for one test run. */
|
|
102
|
+
export class Authority {
|
|
103
|
+
key;
|
|
104
|
+
der;
|
|
105
|
+
pem;
|
|
106
|
+
#keyId;
|
|
107
|
+
#leaves = new Map();
|
|
108
|
+
constructor() {
|
|
109
|
+
const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
110
|
+
this.key = privateKey;
|
|
111
|
+
this.#keyId = keyId(privateKey);
|
|
112
|
+
this.der = certificate({ subject: "slicetest test CA", publicKey: privateKey, issuer: "slicetest test CA", signer: privateKey, issuerKeyId: this.#keyId, ca: true });
|
|
113
|
+
this.pem = toPem(this.der);
|
|
114
|
+
}
|
|
115
|
+
/** A certificate for `host`, signed by this authority (cached). */
|
|
116
|
+
leaf(host) {
|
|
117
|
+
let issued = this.#leaves.get(host);
|
|
118
|
+
if (!issued) {
|
|
119
|
+
const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
120
|
+
const der = certificate({ subject: host, publicKey: privateKey, issuer: "slicetest test CA", signer: this.key, issuerKeyId: this.#keyId, ca: false, hosts: [host] });
|
|
121
|
+
issued = { key: privateKey, der, pem: toPem(der) };
|
|
122
|
+
this.#leaves.set(host, issued);
|
|
123
|
+
}
|
|
124
|
+
return issued;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** PEM bundle of `extra` plus the usual public roots, for SSL_CERT_FILE and friends (which replace the default store). */
|
|
128
|
+
export function pemBundle(roots, extra) {
|
|
129
|
+
return `${extra}${roots.map((r) => (r.endsWith("\n") ? r : `${r}\n`)).join("")}`;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* A PKCS#12 trust store of certificates, readable by Java without a password
|
|
133
|
+
* (`-Djavax.net.ssl.trustStoreType=PKCS12`, no `trustStorePassword`): no MAC and
|
|
134
|
+
* unencrypted certificate bags, each marked as a trusted certificate entry.
|
|
135
|
+
*/
|
|
136
|
+
export function pkcs12TrustStore(certs) {
|
|
137
|
+
const bags = certs.map(({ alias, der }) => seq(oid(OID.certBag), explicit(0, seq(oid(OID.x509Certificate), explicit(0, octets(der)))), set(seq(oid(OID.friendlyName), set(bmp(alias))), seq(oid(OID.oracleTrustedKeyUsage), set(oid(OID.anyExtendedKeyUsage))))));
|
|
138
|
+
const safeContents = seq(...bags);
|
|
139
|
+
const authSafe = seq(seq(oid(OID.data), explicit(0, octets(safeContents))));
|
|
140
|
+
return seq(int(Buffer.from([3])), seq(oid(OID.data), explicit(0, octets(authSafe))));
|
|
141
|
+
}
|
|
142
|
+
/** DER of a PEM certificate, or undefined when it doesn't parse. */
|
|
143
|
+
export function pemToDer(pem) {
|
|
144
|
+
try {
|
|
145
|
+
return Buffer.from(new X509Certificate(pem).raw);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
}
|
package/dist/yaml-runtime.js
CHANGED
|
@@ -4,6 +4,8 @@ import "./provided.js";
|
|
|
4
4
|
import { formatChanges } from "./db.js";
|
|
5
5
|
import "./matchers.js";
|
|
6
6
|
import { scenario } from "./scenario.js";
|
|
7
|
+
import { signWebhook, webhookBody } from "./webhook.js";
|
|
8
|
+
import { sse } from "./stub.js";
|
|
7
9
|
/** Registers the scenarios of one parsed YAML file. Called from the module the plugin generates for it. */
|
|
8
10
|
export function defineYamlScenarios(doc) {
|
|
9
11
|
for (const sc of doc.scenarios) {
|
|
@@ -39,6 +41,8 @@ async function runSteps(doc, sc, steps, ctx, vars) {
|
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
43
|
function describeStep(step) {
|
|
44
|
+
if ("submit" in step)
|
|
45
|
+
return step.submit === true ? "submit" : `submit ${JSON.stringify(step.submit)}`;
|
|
42
46
|
if ("request" in step)
|
|
43
47
|
return step.concurrency ? `${step.request} ×${step.concurrency}` : step.request;
|
|
44
48
|
if ("stub" in step)
|
|
@@ -47,6 +51,10 @@ function describeStep(step) {
|
|
|
47
51
|
return `received ${step.received}${step.call ? ` ${step.call}` : ""}`;
|
|
48
52
|
if ("insert" in step)
|
|
49
53
|
return `insert ${step.insert}`;
|
|
54
|
+
if ("make" in step)
|
|
55
|
+
return `make ${step.make}`;
|
|
56
|
+
if ("chaos" in step)
|
|
57
|
+
return `chaos ${step.chaos}`;
|
|
50
58
|
if ("db" in step)
|
|
51
59
|
return `db ${step.db}`;
|
|
52
60
|
if ("changes" in step)
|
|
@@ -144,7 +152,10 @@ async function runStep(step, ctx, vars) {
|
|
|
144
152
|
if (step.delay !== undefined)
|
|
145
153
|
route = route.delay(step.delay);
|
|
146
154
|
// Replies are interpolated when a call arrives, so they can echo it: {{call.params.id}}, {{call.json.name}}.
|
|
147
|
-
const answer = (template) => (call) =>
|
|
155
|
+
const answer = (template) => (call) => {
|
|
156
|
+
const { sse: events, ...response } = interpolate(template, { ...vars, call: callVars(call) });
|
|
157
|
+
return events ? sse(events, response) : response;
|
|
158
|
+
};
|
|
148
159
|
if (step.networkError)
|
|
149
160
|
route.networkError();
|
|
150
161
|
else if (step.sequence) {
|
|
@@ -156,32 +167,42 @@ async function runStep(step, ctx, vars) {
|
|
|
156
167
|
route.reply(answer(step.reply ?? {}));
|
|
157
168
|
return;
|
|
158
169
|
}
|
|
170
|
+
if ("submit" in step) {
|
|
171
|
+
const page = ctx.http.history.at(-1);
|
|
172
|
+
if (!page)
|
|
173
|
+
throw new Error("submit needs a page with a form: request it first, e.g. `- request: GET /signup`");
|
|
174
|
+
const res = await ctx.http.submit(page, {
|
|
175
|
+
button: step.submit === true ? undefined : interpolate(step.submit, vars),
|
|
176
|
+
form: interpolate(step.form, vars),
|
|
177
|
+
fields: interpolate(step.fields, vars),
|
|
178
|
+
headers: interpolate(step.headers, vars),
|
|
179
|
+
follow: step.follow,
|
|
180
|
+
});
|
|
181
|
+
verifyResponse(res, step.expect, vars);
|
|
182
|
+
capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers) }, vars);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
159
185
|
if ("request" in step) {
|
|
160
186
|
const [method, rawPath] = splitCall(step.request);
|
|
161
187
|
const path = interpolate(rawPath, vars);
|
|
188
|
+
const headers = interpolate(step.headers, vars);
|
|
162
189
|
const opts = {
|
|
163
|
-
headers: interpolate(step.
|
|
190
|
+
headers: step.auth ? { ...ctx.auth.header(step.auth === true ? {} : interpolate(step.auth, vars)), ...headers } : headers,
|
|
164
191
|
query: interpolate(step.query, vars),
|
|
165
192
|
follow: step.follow,
|
|
166
193
|
};
|
|
167
|
-
|
|
194
|
+
let body = step.json !== undefined
|
|
168
195
|
? interpolate(step.json, vars)
|
|
169
196
|
: step.form !== undefined
|
|
170
197
|
? ctx.http.form(interpolate(step.form, vars))
|
|
171
198
|
: interpolate(step.body, vars);
|
|
199
|
+
if (step.webhook) {
|
|
200
|
+
const signed = webhookBody(body);
|
|
201
|
+
body = signed.body;
|
|
202
|
+
opts.headers = { "content-type": signed.type, ...signWebhook(signed.body, interpolate(step.webhook, vars)), ...opts.headers };
|
|
203
|
+
}
|
|
172
204
|
const e = step.expect;
|
|
173
|
-
const verify = (res) =>
|
|
174
|
-
if (e?.status !== undefined)
|
|
175
|
-
expect(res).toHaveStatus(interpolate(e.status, vars));
|
|
176
|
-
if (e?.headers !== undefined) {
|
|
177
|
-
const expected = Object.fromEntries(Object.entries(e.headers).map(([k, v]) => [k.toLowerCase(), v]));
|
|
178
|
-
check(Object.fromEntries(res.headers), expected, vars, "response headers");
|
|
179
|
-
}
|
|
180
|
-
if (e?.json !== undefined)
|
|
181
|
-
check(res.json, e.json, vars, "response JSON");
|
|
182
|
-
if (e?.text !== undefined)
|
|
183
|
-
check(res.text, e.text, vars, "response text");
|
|
184
|
-
};
|
|
205
|
+
const verify = (res) => verifyResponse(res, e, vars);
|
|
185
206
|
if (step.concurrency !== undefined) {
|
|
186
207
|
// Each request gets its own body: a URLSearchParams body can only be read once.
|
|
187
208
|
const all = await ctx.http.concurrently(step.concurrency, () => ctx.http.request(method, path, body instanceof URLSearchParams ? new URLSearchParams(body) : body, opts));
|
|
@@ -190,7 +211,18 @@ async function runStep(step, ctx, vars) {
|
|
|
190
211
|
all.forEach(verify);
|
|
191
212
|
return;
|
|
192
213
|
}
|
|
193
|
-
const
|
|
214
|
+
const max = e?.queries === undefined ? undefined : interpolate(e.queries, vars);
|
|
215
|
+
let res;
|
|
216
|
+
const queries = await (max === undefined ? undefined : ctx.db.queries(async () => (res = await ctx.http.request(method, path, body, opts))));
|
|
217
|
+
if (!queries)
|
|
218
|
+
res = await ctx.http.request(method, path, body, opts);
|
|
219
|
+
if (queries && max !== undefined) {
|
|
220
|
+
const ran = queries.withoutTransactions();
|
|
221
|
+
if (ran.length > max) {
|
|
222
|
+
const shapes = ran.shapes().map((s) => ` ${s.count > 1 ? `×${s.count} ` : ""}${s.sql}`);
|
|
223
|
+
throw new Error(`expected at most ${max} SQL statements for ${method} ${path}, the app ran ${ran.length}:\n${shapes.join("\n")}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
194
226
|
verify(res);
|
|
195
227
|
capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers) }, vars);
|
|
196
228
|
return;
|
|
@@ -200,6 +232,22 @@ async function runStep(step, ctx, vars) {
|
|
|
200
232
|
capture(step.capture, { rows, row: rows[0] }, vars);
|
|
201
233
|
return;
|
|
202
234
|
}
|
|
235
|
+
if ("chaos" in step) {
|
|
236
|
+
const { chaos, name: _, ...opts } = step;
|
|
237
|
+
ctx.stub(chaos).chaos(opts);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if ("make" in step) {
|
|
241
|
+
const given = interpolate(step.rows, vars) ?? {};
|
|
242
|
+
const rows = [];
|
|
243
|
+
if (Array.isArray(given))
|
|
244
|
+
for (const r of given)
|
|
245
|
+
rows.push(await ctx.db.make(step.make, r));
|
|
246
|
+
else
|
|
247
|
+
rows.push(...(await ctx.db.makeMany(step.make, step.count ?? 1, given)));
|
|
248
|
+
capture(step.capture, { rows, row: rows[0] }, vars);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
203
251
|
if ("sql" in step) {
|
|
204
252
|
const rows = await ctx.db.query(step.sql, interpolate(step.params, vars) ?? []);
|
|
205
253
|
expectRows(step.expect, rows, vars);
|
|
@@ -358,3 +406,15 @@ function interpolateTitle(name, row, index) {
|
|
|
358
406
|
return v === undefined ? m : typeof v === "object" ? JSON.stringify(v) : String(v);
|
|
359
407
|
});
|
|
360
408
|
}
|
|
409
|
+
function verifyResponse(res, e, vars) {
|
|
410
|
+
if (e?.status !== undefined)
|
|
411
|
+
expect(res).toHaveStatus(interpolate(e.status, vars));
|
|
412
|
+
if (e?.headers !== undefined) {
|
|
413
|
+
const expected = Object.fromEntries(Object.entries(e.headers).map(([k, v]) => [k.toLowerCase(), v]));
|
|
414
|
+
check(Object.fromEntries(res.headers), expected, vars, "response headers");
|
|
415
|
+
}
|
|
416
|
+
if (e?.json !== undefined)
|
|
417
|
+
check(res.json, e.json, vars, "response JSON");
|
|
418
|
+
if (e?.text !== undefined)
|
|
419
|
+
check(res.text, e.text, vars, "response text");
|
|
420
|
+
}
|
package/dist/yaml.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ export interface YamlScenario {
|
|
|
21
21
|
timeout?: number;
|
|
22
22
|
steps: Step[];
|
|
23
23
|
}
|
|
24
|
-
export type Step = (StubStep | RequestStep | InsertStep | SqlStep | DbStep | ReceivedStep | ChangesStep | CheckpointStep | LogStep | SnapshotStep | MailStep) & {
|
|
24
|
+
export type Step = (StubStep | RequestStep | SubmitStep | InsertStep | MakeStep | ChaosStep | SqlStep | DbStep | ReceivedStep | ChangesStep | CheckpointStep | LogStep | SnapshotStep | MailStep) & {
|
|
25
25
|
line: number;
|
|
26
26
|
name?: string;
|
|
27
27
|
};
|
|
@@ -29,15 +29,26 @@ export interface StubStep {
|
|
|
29
29
|
stub: string;
|
|
30
30
|
on: string;
|
|
31
31
|
when?: Conditions;
|
|
32
|
+
/** `sse`: a streamed reply, a list of { event, data, id } (instead of `body`). */
|
|
32
33
|
reply?: {
|
|
33
34
|
status?: number;
|
|
34
35
|
headers?: Record<string, string>;
|
|
35
36
|
body?: unknown;
|
|
37
|
+
sse?: {
|
|
38
|
+
event?: string;
|
|
39
|
+
data: unknown;
|
|
40
|
+
id?: string;
|
|
41
|
+
}[];
|
|
36
42
|
};
|
|
37
43
|
sequence?: {
|
|
38
44
|
status?: number;
|
|
39
45
|
headers?: Record<string, string>;
|
|
40
46
|
body?: unknown;
|
|
47
|
+
sse?: {
|
|
48
|
+
event?: string;
|
|
49
|
+
data: unknown;
|
|
50
|
+
id?: string;
|
|
51
|
+
}[];
|
|
41
52
|
}[];
|
|
42
53
|
networkError?: boolean;
|
|
43
54
|
times?: number;
|
|
@@ -57,14 +68,51 @@ export interface RequestStep {
|
|
|
57
68
|
form?: Record<string, unknown>;
|
|
58
69
|
body?: string;
|
|
59
70
|
follow?: boolean;
|
|
71
|
+
/** Send `Authorization: Bearer` with a token from the `auth` issuer: `true`, or the claims, e.g. { sub: u1, roles: [admin] }. */
|
|
72
|
+
auth?: true | Record<string, unknown>;
|
|
73
|
+
/** Sign the body as this provider would deliver a webhook: { provider: stripe, secret: whsec_test, event, stale, invalidSignature }. */
|
|
74
|
+
webhook?: {
|
|
75
|
+
provider: string | {
|
|
76
|
+
header: string;
|
|
77
|
+
algorithm?: string;
|
|
78
|
+
encoding?: "hex" | "base64";
|
|
79
|
+
prefix?: string;
|
|
80
|
+
};
|
|
81
|
+
secret: string;
|
|
82
|
+
event?: string;
|
|
83
|
+
stale?: boolean;
|
|
84
|
+
invalidSignature?: boolean;
|
|
85
|
+
};
|
|
60
86
|
/** Send the request this many times at once. `expect` applies to every response; `statuses` counts them. */
|
|
61
87
|
concurrency?: number;
|
|
88
|
+
/** `queries`: at most this many SQL statements (not counting BEGIN/COMMIT), with `db.queries` on. */
|
|
62
89
|
expect?: {
|
|
63
90
|
status?: number;
|
|
64
91
|
statuses?: Record<string, number>;
|
|
65
92
|
headers?: Record<string, unknown>;
|
|
66
93
|
json?: unknown;
|
|
67
94
|
text?: unknown;
|
|
95
|
+
queries?: number;
|
|
96
|
+
};
|
|
97
|
+
capture?: Record<string, string>;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Submit a form of the page the last request returned, as a browser with JavaScript off would.
|
|
101
|
+
* `submit:` names the button to press (its text, value, name or id), or is `true` for the form's only button.
|
|
102
|
+
*/
|
|
103
|
+
export interface SubmitStep {
|
|
104
|
+
submit: string | true;
|
|
105
|
+
/** The form by id, name or 0-based position, when the page has several. */
|
|
106
|
+
form?: string | number;
|
|
107
|
+
/** Values typed into the form by field name; true / false / a list check checkboxes and radios. */
|
|
108
|
+
fields?: Record<string, string | number | boolean | (string | number)[]>;
|
|
109
|
+
headers?: Record<string, string>;
|
|
110
|
+
follow?: boolean;
|
|
111
|
+
expect?: {
|
|
112
|
+
status?: number;
|
|
113
|
+
headers?: Record<string, unknown>;
|
|
114
|
+
json?: unknown;
|
|
115
|
+
text?: unknown;
|
|
68
116
|
};
|
|
69
117
|
capture?: Record<string, string>;
|
|
70
118
|
}
|
|
@@ -73,6 +121,24 @@ export interface InsertStep {
|
|
|
73
121
|
rows: Record<string, unknown> | Record<string, unknown>[];
|
|
74
122
|
capture?: Record<string, string>;
|
|
75
123
|
}
|
|
124
|
+
/** Rows built by `db.make()`: only the columns given are fixed, the rest is filled from the schema. */
|
|
125
|
+
export interface MakeStep {
|
|
126
|
+
make: string;
|
|
127
|
+
rows?: Record<string, unknown> | Record<string, unknown>[];
|
|
128
|
+
/** Make this many rows, each with `rows` (a mapping). */
|
|
129
|
+
count?: number;
|
|
130
|
+
capture?: Record<string, string>;
|
|
131
|
+
}
|
|
132
|
+
/** Faults injected into a stub's answers for the rest of the scenario, like `stub(name).chaos()`. */
|
|
133
|
+
export interface ChaosStep {
|
|
134
|
+
chaos: string;
|
|
135
|
+
failFirst?: number;
|
|
136
|
+
errorRate?: number;
|
|
137
|
+
statuses?: number[];
|
|
138
|
+
networkErrorRate?: number;
|
|
139
|
+
latency?: number | [number, number];
|
|
140
|
+
seed?: number;
|
|
141
|
+
}
|
|
76
142
|
export interface SqlStep {
|
|
77
143
|
sql: string;
|
|
78
144
|
params?: unknown[];
|