slicetest 0.4.0 → 0.5.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.
@@ -134,6 +134,44 @@ export class MysqlDriver {
134
134
  const conds = Object.keys(where).map((k) => `${this.column(k)} = ?`);
135
135
  return this.query(`SELECT * FROM ${this.ident(table)} WHERE ${conds.join(" AND ")}`, Object.values(where));
136
136
  }
137
+ async describe(table) {
138
+ const [schema, name] = table.includes(".") ? table.split(".", 2) : [null, table];
139
+ const columns = await this.query(`SELECT COLUMN_NAME AS name, COLUMN_TYPE AS type, IS_NULLABLE AS nullable, COLUMN_DEFAULT AS dflt, EXTRA AS extra,
140
+ CHARACTER_MAXIMUM_LENGTH AS max
141
+ FROM information_schema.COLUMNS
142
+ WHERE TABLE_SCHEMA = COALESCE(?, DATABASE()) AND TABLE_NAME = ?
143
+ ORDER BY ORDINAL_POSITION`, [schema, name]);
144
+ if (columns.length === 0)
145
+ throw new Error(`slicetest: there is no table "${table}"`);
146
+ const keys = await this.query(`SELECT CONSTRAINT_NAME AS id, COLUMN_NAME AS \`column\`, REFERENCED_TABLE_SCHEMA AS \`schema\`,
147
+ REFERENCED_TABLE_NAME AS \`table\`, REFERENCED_COLUMN_NAME AS ref, DATABASE() AS current
148
+ FROM information_schema.KEY_COLUMN_USAGE
149
+ WHERE TABLE_SCHEMA = COALESCE(?, DATABASE()) AND TABLE_NAME = ? AND REFERENCED_TABLE_NAME IS NOT NULL
150
+ ORDER BY CONSTRAINT_NAME, ORDINAL_POSITION`, [schema, name]);
151
+ const foreignKeys = new Map();
152
+ for (const k of keys) {
153
+ const fk = foreignKeys.get(k.id) ?? { columns: [], table: k.schema === k.current ? k.table : `${k.schema}.${k.table}`, references: [] };
154
+ fk.columns.push(k.column);
155
+ fk.references.push(k.ref);
156
+ foreignKeys.set(k.id, fk);
157
+ }
158
+ const checks = await this.query(`SELECT c.CHECK_CLAUSE AS def
159
+ FROM information_schema.CHECK_CONSTRAINTS c
160
+ JOIN information_schema.TABLE_CONSTRAINTS t ON t.CONSTRAINT_SCHEMA = c.CONSTRAINT_SCHEMA AND t.CONSTRAINT_NAME = c.CONSTRAINT_NAME
161
+ WHERE t.TABLE_SCHEMA = COALESCE(?, DATABASE()) AND t.TABLE_NAME = ?`, [schema, name]);
162
+ return {
163
+ columns: columns.map((c) => ({
164
+ name: c.name,
165
+ type: c.type,
166
+ nullable: c.nullable === "YES",
167
+ hasDefault: c.dflt !== null || /auto_increment|generated/i.test(c.extra),
168
+ maxLength: c.max === null ? undefined : Number(c.max),
169
+ values: /^enum\(/i.test(c.type) ? [...c.type.matchAll(/'((?:[^']|'')*)'/g)].map((m) => m[1].replace(/''/g, "'")) : undefined,
170
+ })),
171
+ foreignKeys: [...foreignKeys.values()],
172
+ checks: checks.map((c) => c.def),
173
+ };
174
+ }
137
175
  async #primaryKey(table) {
138
176
  let key = this.#keys.get(table);
139
177
  if (!key) {
@@ -1,4 +1,4 @@
1
- import type { Driver, Engine, Row, Table } from "./driver.js";
1
+ import type { Driver, Engine, Row, Table, TableShape } from "./driver.js";
2
2
  export declare class PostgresDriver implements Driver {
3
3
  private readonly client;
4
4
  private constructor();
@@ -14,6 +14,7 @@ export declare class PostgresDriver implements Driver {
14
14
  listTables(schemas: string[], keep: string[]): Promise<Table[]>;
15
15
  truncate(tables: Table[]): Promise<void>;
16
16
  insert(table: string, row: Row): Promise<Row[]>;
17
+ describe(table: string): Promise<TableShape>;
17
18
  close(): Promise<void>;
18
19
  }
19
20
  export declare const postgres: Engine;
@@ -88,6 +88,34 @@ export class PostgresDriver {
88
88
  : `INSERT INTO ${this.ident(table)} (${cols.map((c) => this.ident(c)).join(", ")}) VALUES (${cols.map((_, i) => `$${i + 1}`).join(", ")}) RETURNING *`;
89
89
  return this.query(sql, Object.values(row));
90
90
  }
91
+ async describe(table) {
92
+ const [{ oid } = { oid: null }] = await this.query("SELECT to_regclass($1)::oid AS oid", [this.ident(table)]);
93
+ if (oid === null)
94
+ throw new Error(`slicetest: there is no table "${table}"`);
95
+ const columns = await this.query(`SELECT a.attname AS name, format_type(a.atttypid, a.atttypmod) AS type, NOT a.attnotnull AS nullable,
96
+ (a.atthasdef OR a.attidentity <> '' OR a.attgenerated <> '') AS has_default,
97
+ information_schema._pg_char_max_length(a.atttypid, a.atttypmod) AS max,
98
+ (SELECT array_agg(e.enumlabel::text ORDER BY e.enumsortorder) FROM pg_enum e WHERE e.enumtypid = a.atttypid) AS values
99
+ FROM pg_attribute a
100
+ WHERE a.attrelid = $1 AND a.attnum > 0 AND NOT a.attisdropped
101
+ ORDER BY a.attnum`, [oid]);
102
+ const keys = await this.query(`SELECT (SELECT array_agg(a.attname::text ORDER BY k.ord) FROM unnest(c.conkey) WITH ORDINALITY k(n, ord)
103
+ JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.n) AS columns,
104
+ n.nspname AS schema, r.relname AS table,
105
+ (SELECT array_agg(a.attname::text ORDER BY k.ord) FROM unnest(c.confkey) WITH ORDINALITY k(n, ord)
106
+ JOIN pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = k.n) AS references
107
+ FROM pg_constraint c
108
+ JOIN pg_class r ON r.oid = c.confrelid
109
+ JOIN pg_namespace n ON n.oid = r.relnamespace
110
+ WHERE c.contype = 'f' AND c.conrelid = $1
111
+ ORDER BY c.conname`, [oid]);
112
+ const checks = await this.query("SELECT pg_get_constraintdef(oid) AS def FROM pg_constraint WHERE contype = 'c' AND conrelid = $1", [oid]);
113
+ return {
114
+ columns: columns.map((c) => ({ name: c.name, type: c.type, nullable: c.nullable, hasDefault: c.has_default, maxLength: c.max ?? undefined, values: c.values ?? undefined })),
115
+ foreignKeys: keys.map((k) => ({ columns: k.columns, table: k.schema === "public" ? k.table : `${k.schema}.${k.table}`, references: k.references })),
116
+ checks: checks.map((c) => c.def),
117
+ };
118
+ }
91
119
  async close() {
92
120
  await this.client.end();
93
121
  }
@@ -1,4 +1,4 @@
1
- import type { Driver, Engine, Row, Table } from "./driver.js";
1
+ import type { Driver, Engine, Row, Table, TableShape } from "./driver.js";
2
2
  /** `sqlite:///tmp/a.db` → `/tmp/a.db`; `sqlite:///C:/x/a.db` → `C:/x/a.db`. Plain paths pass through. */
3
3
  export declare function sqlitePath(url: string): string;
4
4
  export declare function sqliteUrl(file: string): string;
@@ -18,6 +18,7 @@ export declare class SqliteDriver implements Driver {
18
18
  /** DELETE, then forget AUTOINCREMENT counters. Foreign keys are off for this connection only. */
19
19
  truncate(tables: Table[]): Promise<void>;
20
20
  insert(table: string, row: Row): Promise<Row[]>;
21
+ describe(table: string): Promise<TableShape>;
21
22
  close(): Promise<void>;
22
23
  }
23
24
  export declare const sqliteEngine: Engine;
@@ -35,6 +35,27 @@ export function sqliteUrl(file) {
35
35
  return `sqlite://${p.startsWith("/") ? "" : "/"}${p}`;
36
36
  }
37
37
  /** Values node:sqlite can't bind are stored the way an app would store them. */
38
+ /** The parenthesised bodies of every CHECK in a CREATE TABLE statement. */
39
+ function checkBodies(sql) {
40
+ const bodies = [];
41
+ for (const m of sql.matchAll(/\bcheck\s*\(/gi)) {
42
+ let depth = 1;
43
+ let i = m.index + m[0].length;
44
+ const start = i;
45
+ for (; i < sql.length && depth > 0; i++) {
46
+ if (sql[i] === "'")
47
+ i = sql.indexOf("'", i + 1);
48
+ else if (sql[i] === "(")
49
+ depth++;
50
+ else if (sql[i] === ")")
51
+ depth--;
52
+ if (i < 0)
53
+ return bodies;
54
+ }
55
+ bodies.push(sql.slice(start, i - 1));
56
+ }
57
+ return bodies;
58
+ }
38
59
  function bind(v) {
39
60
  if (typeof v === "boolean")
40
61
  return v ? 1 : 0;
@@ -117,6 +138,37 @@ export class SqliteDriver {
117
138
  : `INSERT INTO ${this.ident(table)} (${cols.map((c) => this.column(c)).join(", ")}) VALUES (${cols.map((_, i) => `?${i + 1}`).join(", ")}) RETURNING *`;
118
139
  return this.query(sql, Object.values(row));
119
140
  }
141
+ async describe(table) {
142
+ const [master] = await this.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?1", [table]);
143
+ if (!master)
144
+ throw new Error(`slicetest: there is no table "${table}"`);
145
+ const columns = await this.query("SELECT name, type, \"notnull\", dflt_value, pk, hidden FROM pragma_table_xinfo(?1) ORDER BY cid", [table]);
146
+ const pk = columns.filter((c) => c.pk > 0);
147
+ // An INTEGER PRIMARY KEY is the rowid: SQLite assigns it.
148
+ const rowid = pk.length === 1 && pk[0].type.toUpperCase() === "INTEGER" ? pk[0].name : undefined;
149
+ const keys = await this.query('SELECT id, "table", "from", "to" FROM pragma_foreign_key_list(?1) ORDER BY id, seq', [table]);
150
+ const foreignKeys = new Map();
151
+ for (const k of keys) {
152
+ const fk = foreignKeys.get(k.id) ?? { columns: [], table: k.table, references: [] };
153
+ fk.columns.push(k.from);
154
+ if (k.to !== null)
155
+ fk.references.push(k.to);
156
+ foreignKeys.set(k.id, fk);
157
+ }
158
+ return {
159
+ columns: columns
160
+ .filter((c) => c.hidden !== 1)
161
+ .map((c) => ({
162
+ name: c.name,
163
+ type: c.type,
164
+ nullable: c.notnull === 0 && c.pk === 0,
165
+ hasDefault: c.dflt_value !== null || c.hidden > 1 || c.name === rowid,
166
+ maxLength: Number(/\((\d+)\)/.exec(c.type)?.[1]) || undefined,
167
+ })),
168
+ foreignKeys: [...foreignKeys.values()],
169
+ checks: checkBodies(master.sql),
170
+ };
171
+ }
120
172
  async close() {
121
173
  this.db.close();
122
174
  }
@@ -0,0 +1,21 @@
1
+ import type { Column, Row, TableShape } from "./drivers/driver.js";
2
+ /**
3
+ * `db.make()`: rows that satisfy the schema without spelling it out. Required
4
+ * columns the caller doesn't give get a value of their type, enum and
5
+ * `CHECK (col IN (...))` columns their first allowed value, and required
6
+ * foreign keys a parent row made the same way. Values are numbered per
7
+ * scenario, so they are unique and the same on every run.
8
+ */
9
+ export declare class Factory {
10
+ #private;
11
+ private readonly describe;
12
+ private readonly insert;
13
+ constructor(describe: (table: string) => Promise<TableShape>, insert: (table: string, row: Row) => Promise<Row[]>);
14
+ /** Restart numbering; called when the database is reset. */
15
+ reset(): void;
16
+ make(table: string, overrides?: Row, path?: string[]): Promise<Row>;
17
+ }
18
+ /** String literals allowed by a CHECK on `column` such as `status IN ('open', 'closed')` or `status = ANY (ARRAY[...])`. */
19
+ export declare function allowedValues(column: string, checks: string[]): string[] | undefined;
20
+ /** A value of `column`'s type, distinct for each `n`. */
21
+ export declare function valueFor(table: string, column: Column, n: number, allowed?: string[]): unknown;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * `db.make()`: rows that satisfy the schema without spelling it out. Required
3
+ * columns the caller doesn't give get a value of their type, enum and
4
+ * `CHECK (col IN (...))` columns their first allowed value, and required
5
+ * foreign keys a parent row made the same way. Values are numbered per
6
+ * scenario, so they are unique and the same on every run.
7
+ */
8
+ export class Factory {
9
+ describe;
10
+ insert;
11
+ #n = 0;
12
+ #shapes = new Map();
13
+ constructor(describe, insert) {
14
+ this.describe = describe;
15
+ this.insert = insert;
16
+ }
17
+ /** Restart numbering; called when the database is reset. */
18
+ reset() {
19
+ this.#n = 0;
20
+ }
21
+ async make(table, overrides = {}, path = []) {
22
+ const shape = await this.#shape(table);
23
+ for (const col of Object.keys(overrides)) {
24
+ if (!shape.columns.some((c) => c.name === col)) {
25
+ throw new Error(`slicetest: db.make("${table}"): there is no column "${col}" (columns: ${shape.columns.map((c) => c.name).join(", ")})`);
26
+ }
27
+ }
28
+ const n = ++this.#n;
29
+ const row = { ...overrides };
30
+ const byName = new Map(shape.columns.map((c) => [c.name, c]));
31
+ const needed = (name) => {
32
+ const c = byName.get(name);
33
+ return !!c && !c.nullable && !c.hasDefault && !(name in row);
34
+ };
35
+ for (const fk of shape.foreignKeys) {
36
+ // Only fill keys that must be set and that the caller left entirely to us.
37
+ if (!fk.columns.some(needed) || fk.columns.some((c) => c in overrides))
38
+ continue;
39
+ if (fk.table === table || path.includes(fk.table)) {
40
+ const chain = [...path, table, fk.table].join(" → ");
41
+ throw new Error(`slicetest: db.make("${table}") can't create a parent for ${fk.columns.join(", ")}: the foreign keys form a cycle (${chain}). Pass ${fk.columns.join(", ")} yourself.`);
42
+ }
43
+ const parent = await this.make(fk.table, {}, [...path, table]);
44
+ const refs = fk.references.length ? fk.references : (await this.#primaryKey(fk.table, parent));
45
+ fk.columns.forEach((c, i) => (row[c] = parent[refs[i]]));
46
+ }
47
+ for (const c of shape.columns) {
48
+ if (needed(c.name))
49
+ row[c.name] = valueFor(table, c, n, allowedValues(c.name, shape.checks));
50
+ }
51
+ try {
52
+ const [stored] = await this.insert(table, row);
53
+ return stored ?? row;
54
+ }
55
+ catch (e) {
56
+ const generated = Object.fromEntries(Object.entries(row).filter(([k]) => !(k in overrides)));
57
+ throw new Error(`slicetest: db.make("${table}") failed: ${e.message}\n` +
58
+ ` generated: ${JSON.stringify(generated)}\n` +
59
+ ` pass the columns that need specific values, e.g. db.make("${table}", { ${Object.keys(generated)[0] ?? "column"}: ... })`, { cause: e });
60
+ }
61
+ }
62
+ #shape(table) {
63
+ let shape = this.#shapes.get(table);
64
+ if (!shape) {
65
+ shape = this.describe(table);
66
+ this.#shapes.set(table, shape);
67
+ shape.catch(() => this.#shapes.delete(table));
68
+ }
69
+ return shape;
70
+ }
71
+ /** SQLite foreign keys may omit the referenced columns: they mean the parent's primary key, or its rowid. */
72
+ async #primaryKey(table, parent) {
73
+ const shape = await this.#shape(table);
74
+ const key = shape.columns.filter((c) => c.hasDefault && /^integer$/i.test(c.type)).map((c) => c.name);
75
+ return key.length ? key : Object.keys(parent).slice(0, 1);
76
+ }
77
+ }
78
+ /** String literals allowed by a CHECK on `column` such as `status IN ('open', 'closed')` or `status = ANY (ARRAY[...])`. */
79
+ export function allowedValues(column, checks) {
80
+ const esc = column.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
81
+ const mention = new RegExp(`(^|[^\\w])[\`"]?${esc}[\`"]?\\)?(::\\w+(\\s\\w+)?)?\\s*(in\\s*\\(|=\\s*any\\s*\\()`, "i");
82
+ for (const check of checks) {
83
+ if (!mention.test(check))
84
+ continue;
85
+ const literals = [...check.matchAll(/(?:_\w+)?'((?:[^']|'')*)'/g)].map((m) => m[1].replace(/''/g, "'"));
86
+ if (literals.length)
87
+ return literals;
88
+ }
89
+ return undefined;
90
+ }
91
+ const EPOCH = Date.UTC(2026, 0, 1);
92
+ /** A value of `column`'s type, distinct for each `n`. */
93
+ export function valueFor(table, column, n, allowed) {
94
+ const values = column.values?.length ? column.values : allowed;
95
+ if (values?.length)
96
+ return values[0];
97
+ const t = column.type.toLowerCase();
98
+ if (/\[\]$/.test(t))
99
+ return "{}";
100
+ if (/^bool/.test(t) || t === "tinyint(1)")
101
+ return false;
102
+ if (/uuid/.test(t))
103
+ return `00000000-0000-4000-8000-${n.toString(16).padStart(12, "0")}`;
104
+ if (/json/.test(t))
105
+ return "{}";
106
+ if (/^(timestamp|datetime)/.test(t))
107
+ return new Date(EPOCH + n * 1000).toISOString().slice(0, 19).replace("T", " ");
108
+ if (/^date/.test(t))
109
+ return new Date(EPOCH + (n - 1) * 86_400_000).toISOString().slice(0, 10);
110
+ if (/^time/.test(t))
111
+ return "00:00:00";
112
+ if (/int|serial|numeric|decimal|real|double|float|money|number/.test(t))
113
+ return n;
114
+ if (/bytea|blob|binary/.test(t))
115
+ return Buffer.from(String(n));
116
+ if (/^inet/.test(t))
117
+ return `10.0.${Math.floor(n / 256) % 256}.${n % 256}`;
118
+ if (t === "" || /char|text|clob|string|citext|name/.test(t))
119
+ return text(table, column, n);
120
+ throw new Error(`slicetest: db.make("${table}") doesn't know how to make a value for ${column.name} (${column.type}); pass it yourself: db.make("${table}", { ${column.name}: ... })`);
121
+ }
122
+ function text(table, column, n) {
123
+ const name = column.name.toLowerCase();
124
+ let s = /e_?mail/.test(name) ? `${table}-${n}@example.test` : /url|website|link/.test(name) ? `https://example.test/${table}/${n}` : `${column.name}-${n}`;
125
+ const max = column.maxLength;
126
+ if (max !== undefined && s.length > max)
127
+ s = String(n).padStart(max, "0").slice(-max);
128
+ return s;
129
+ }
package/dist/gen.d.ts CHANGED
@@ -21,4 +21,5 @@ export declare function gen(root: string, opts: GenOptions): Promise<{
21
21
  written: string[];
22
22
  skipped: string[];
23
23
  count: number;
24
+ needsAuth: boolean;
24
25
  }>;
package/dist/gen.js CHANGED
@@ -58,7 +58,7 @@ export async function gen(root, opts) {
58
58
  await writeFile(file, header + stringify({ scenarios }, { lineWidth: 0, aliasDuplicateObjects: false }));
59
59
  written.push(rel);
60
60
  }
61
- return { written, skipped, count };
61
+ return { written, skipped, count, needsAuth: ops.some((o) => o.bearer) };
62
62
  }
63
63
  function scenarioFor(op, key, all) {
64
64
  const steps = [];
@@ -80,9 +80,11 @@ function scenarioFor(op, key, all) {
80
80
  // Two error responses need no arranging: a made-up id is not found, and an empty body is invalid.
81
81
  const notFound = status === 404 && Object.keys(op.pathParams).length > 0;
82
82
  const invalid = (status === 400 || status === 422) && isFilledObject(op.json);
83
- steps.push(requestStep(invalid ? { ...op, json: {} } : op, pathText, { status }));
83
+ // A protected operation answers 401 to a request without a token, whatever its state.
84
+ const unauthenticated = status === 401 && !!op.bearer;
85
+ steps.push(requestStep(invalid ? { ...op, json: {} } : op, pathText, { status }, undefined, unauthenticated));
84
86
  const name = `${op.method} ${op.template} → ${key}${op.summary ? ` (${op.summary})` : ""}`;
85
- const runnable = success ? !unresolved : notFound || invalid;
87
+ const runnable = success ? !unresolved : notFound || invalid || unauthenticated;
86
88
  return runnable ? { name, steps } : { name: `${name} — TODO: arrange the state that produces this`, skip: true, steps };
87
89
  }
88
90
  /**
@@ -99,8 +101,11 @@ function creatorFor(op, param, all) {
99
101
  const status = post.responses.filter((k) => /^2\d\d$/.test(k)).sort()[0];
100
102
  return field && status ? { op: post, field, status } : undefined;
101
103
  }
102
- function requestStep(op, pathText, expect, capture) {
104
+ function requestStep(op, pathText, expect, capture, anonymous = false) {
103
105
  const step = { request: `${op.method} ${pathText}` };
106
+ // Tokens come from the `auth` issuer; scopes the spec requires go into the `scope` claim.
107
+ if (op.bearer && !anonymous)
108
+ step.auth = op.bearer.scopes.length ? { scope: op.bearer.scopes.join(" ") } : true;
104
109
  if (Object.keys(op.query).length)
105
110
  step.query = Object.fromEntries(Object.entries(op.query).map(([k, v]) => [k, String(v)]));
106
111
  if (op.json !== undefined)
package/dist/http.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type WebhookOptions } from "./webhook.js";
1
2
  export interface HttpResponse {
2
3
  method: string;
3
4
  url: string;
@@ -37,6 +38,12 @@ export declare class HttpClient {
37
38
  post(path: string, body?: unknown, opts?: RequestOptions): Promise<HttpResponse>;
38
39
  put(path: string, body?: unknown, opts?: RequestOptions): Promise<HttpResponse>;
39
40
  patch(path: string, body?: unknown, opts?: RequestOptions): Promise<HttpResponse>;
41
+ /**
42
+ * POST `payload` to the app as `provider` would deliver it, signed with `secret`:
43
+ * `http.webhook("/webhooks/stripe", event, { provider: "stripe", secret: "whsec_test" })`.
44
+ * `{ invalidSignature: true }` and `{ stale: true }` make deliveries the app must refuse.
45
+ */
46
+ webhook(path: string, payload: unknown, opts: WebhookOptions): Promise<HttpResponse>;
40
47
  /**
41
48
  * Run `send` `n` times at once and wait for every response, to provoke races
42
49
  * (double bookings, lost updates). Pair with `toHaveStatuses({ 201: 1, 409: n - 1 })`
package/dist/http.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { signWebhook, webhookBody } from "./webhook.js";
1
2
  /** Per-scenario state shared by a client and every client derived from it with `with()`. */
2
3
  class Session {
3
4
  cookies = new Map();
@@ -41,6 +42,15 @@ export class HttpClient {
41
42
  patch(path, body, opts) {
42
43
  return this.request("PATCH", path, body, opts);
43
44
  }
45
+ /**
46
+ * POST `payload` to the app as `provider` would deliver it, signed with `secret`:
47
+ * `http.webhook("/webhooks/stripe", event, { provider: "stripe", secret: "whsec_test" })`.
48
+ * `{ invalidSignature: true }` and `{ stale: true }` make deliveries the app must refuse.
49
+ */
50
+ webhook(path, payload, opts) {
51
+ const { body, type } = webhookBody(payload);
52
+ return this.request("POST", path, body, { headers: { "content-type": type, ...signWebhook(body, opts), ...opts.headers } });
53
+ }
44
54
  /**
45
55
  * Run `send` `n` times at once and wait for every response, to provoke races
46
56
  * (double bookings, lost updates). Pair with `toHaveStatuses({ 201: 1, 409: n - 1 })`
package/dist/index.d.ts CHANGED
@@ -3,10 +3,13 @@ export { mask } from "./trace.js";
3
3
  export type { Trace, MaskOptions } from "./trace.js";
4
4
  export type { ScenarioContext } from "./runtime.js";
5
5
  export type { Db, Row, Where, RowsOptions, Changes, TableChanges } from "./db.js";
6
- export type { Stub, RecordedCall, StubResponse, Responder, MatchOptions, RouteBuilder } from "./stub.js";
6
+ export type { Stub, RecordedCall, StubResponse, Responder, MatchOptions, RouteBuilder, ChaosOptions } from "./stub.js";
7
7
  export type { HttpClient, HttpResponse, RequestOptions } from "./http.js";
8
8
  export type { App } from "./app.js";
9
9
  export type { Dependency } from "./containers.js";
10
10
  export type { Mailbox, Mail, MailFilter } from "./mail.js";
11
+ export type { Issuer, TokenOptions, AuthOptions } from "./auth.js";
12
+ export { normalizeSql, type Query, type QueryList } from "./query-log.js";
13
+ export { signWebhook, type WebhookOptions, type WebhookProvider, type HmacScheme } from "./webhook.js";
11
14
  export type { SlicetestOptions, ContainerOptions } from "./config.js";
12
15
  export type {} from "./matchers.js";
package/dist/index.js CHANGED
@@ -1,2 +1,4 @@
1
1
  export { scenario } from "./scenario.js";
2
2
  export { mask } from "./trace.js";
3
+ export { normalizeSql } from "./query-log.js";
4
+ export { signWebhook } from "./webhook.js";
package/dist/init.js CHANGED
@@ -107,6 +107,12 @@ export async function detect(root) {
107
107
  env.DATABASE_URL = sqlite.url;
108
108
  notes.push(`db: SQLite (${sqlite.why}), no container needed. The app gets DATABASE_URL=${sqlite.url}; {{db.path}} is the plain file path`);
109
109
  }
110
+ const auth = await authFromEnvExample(root, read, env, notes);
111
+ const jwtDeps = ["jose", "jsonwebtoken", "passport-jwt", "express-oauth2-jwt-bearer", "express-jwt", "jwks-rsa", "@fastify/jwt", "next-auth"].filter((d) => deps[d]);
112
+ const jwtPython = /\b(pyjwt|python-jose|authlib|fastapi-azure-auth|djangorestframework-simplejwt)\b/i.exec(python)?.[1];
113
+ if (!auth && (jwtDeps.length || jwtPython)) {
114
+ notes.push(`auth: ${jwtDeps[0] ?? jwtPython} is a dependency, so the app may verify JWTs. To test with real tokens, add \`auth: true\` and pass {{auth.issuer}} / {{auth.jwks}} / {{auth.audience}} to the variables the app reads`);
115
+ }
110
116
  const stubs = await stubsFromEnvExample(root, read, env, notes);
111
117
  const mysqlDeps = !!deps.mysql2 || !!deps.mysql || /\b(pymysql|mysqlclient|aiomysql)\b/.test(python) || /\bgem ['"]mysql2['"]/.test(gemfile);
112
118
  if (!composeDb.engine && mysqlDeps) {
@@ -122,6 +128,7 @@ export async function detect(root) {
122
128
  ...(migrate || Object.keys(composeDb).length ? { db: { ...composeDb, ...(migrate ? { migrate } : {}) } } : {}),
123
129
  ...(Object.keys(containers).length ? { containers } : {}),
124
130
  ...(mail ? { mail: true } : {}),
131
+ ...(auth ? { auth: true } : {}),
125
132
  stubs,
126
133
  ...(openapi ? { openapi } : {}),
127
134
  };
@@ -150,6 +157,42 @@ async function detectSqlite(root, read, deps, python, gemfile) {
150
157
  return { why: "a SQLite driver is a dependency", url: "{{db.url}}" };
151
158
  return undefined;
152
159
  }
160
+ /** Variables that tell the app which token issuer to trust, and what slicetest passes instead. */
161
+ const AUTH_VARS = [
162
+ [/JWKS(_URL|_URI|_ENDPOINT)?$/, "{{auth.jwks}}"],
163
+ [/(ISSUER|ISSUER_URL|ISSUER_BASE_URL|AUTHORITY)$/, "{{auth.issuer}}"],
164
+ [/^(AUTH0|OKTA|KEYCLOAK|COGNITO|OIDC|OAUTH2?|AUTH|JWT)_?(DOMAIN|URL|BASE_URL)$/, "{{auth.issuer}}"],
165
+ [/(AUDIENCE|_AUD)$/, "{{auth.audience}}"],
166
+ ];
167
+ /**
168
+ * `.env.example` variables naming a token issuer (`OIDC_ISSUER`, `AUTH0_DOMAIN`,
169
+ * `JWKS_URL`, `JWT_AUDIENCE`) turn on `auth`, with the variables pointed at
170
+ * slicetest's issuer. Returns whether any was found.
171
+ */
172
+ async function authFromEnvExample(root, read, env, notes) {
173
+ const file = [".env.example", ".env.sample", ".env.template", ".env.dist"].find((f) => existsSync(path.join(root, f)));
174
+ if (!file)
175
+ return false;
176
+ const found = [];
177
+ for (const line of (await read(file)).split(/\r?\n/)) {
178
+ const name = /^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/.exec(line)?.[1];
179
+ if (!name || name in env)
180
+ continue;
181
+ const match = AUTH_VARS.find(([re]) => re.test(name));
182
+ // AUDIENCE alone doesn't say the app verifies tokens; it comes along with an issuer or JWKS.
183
+ if (!match)
184
+ continue;
185
+ env[name] = match[1];
186
+ found.push(name);
187
+ }
188
+ if (!found.some((n) => env[n] !== "{{auth.audience}}")) {
189
+ for (const n of found)
190
+ delete env[n];
191
+ return false;
192
+ }
193
+ notes.push(`auth: ${found.join(", ")} in ${file} point at slicetest's OpenID issuer; scenarios mint tokens with auth.token({ sub, ... }) or \`auth:\` on a YAML request. An issuer the app expects as a bare domain (Auth0) may need the variable reshaped`);
194
+ return true;
195
+ }
153
196
  /** Hosts that are never third-party APIs to stub. */
154
197
  const LOCAL_HOST = /^(localhost|127\.|0\.0\.0\.0|\[::1\]|host\.docker\.internal|[\w-]+$)/;
155
198
  /**
@@ -164,7 +207,7 @@ async function stubsFromEnvExample(root, read, env, notes) {
164
207
  const stubs = [];
165
208
  for (const line of (await read(file)).split(/\r?\n/)) {
166
209
  const m = /^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=\s*["']?(https?:\/\/[^\s"'#]+)/.exec(line);
167
- if (!m || !/(URL|URI|ENDPOINT|HOST|BASE)$/.test(m[1]) || /DATABASE|REDIS|MONGO|AMQP|SMTP|MAIL|CALLBACK|REDIRECT|FRONTEND|PUBLIC|APP_URL|SITE_URL|WEBHOOK_URL$/.test(m[1]))
210
+ if (!m || m[1] in env || !/(URL|URI|ENDPOINT|HOST|BASE)$/.test(m[1]) || /DATABASE|REDIS|MONGO|AMQP|SMTP|MAIL|CALLBACK|REDIRECT|FRONTEND|PUBLIC|APP_URL|SITE_URL|WEBHOOK_URL$/.test(m[1]))
168
211
  continue;
169
212
  const url = new URL(m[2]);
170
213
  if (LOCAL_HOST.test(url.hostname))
package/dist/openapi.d.ts CHANGED
@@ -15,6 +15,14 @@ export interface OperationSketch {
15
15
  responses: string[];
16
16
  /** Top-level properties of the first 2xx JSON response. */
17
17
  createdFields: string[];
18
+ /**
19
+ * The operation requires a bearer token (an `http: bearer`, `oauth2` or
20
+ * `openIdConnect` security scheme), with the scopes it lists. Undefined when
21
+ * it needs no token or accepts other credentials only.
22
+ */
23
+ bearer?: {
24
+ scopes: string[];
25
+ };
18
26
  }
19
27
  export interface Message {
20
28
  status?: number;
package/dist/openapi.js CHANGED
@@ -157,9 +157,27 @@ export class OpenApiSpec {
157
157
  json: json ?? undefined,
158
158
  responses,
159
159
  createdFields: okSchema?.properties ? Object.keys(okSchema.properties) : [],
160
+ bearer: this.#bearer(op.security ?? this.doc.security),
160
161
  };
161
162
  });
162
163
  }
164
+ #bearer(security) {
165
+ if (!Array.isArray(security) || security.length === 0)
166
+ return undefined;
167
+ // An empty requirement makes credentials optional.
168
+ if (security.some((req) => Object.keys(req ?? {}).length === 0))
169
+ return undefined;
170
+ const schemes = this.doc.components?.securitySchemes ?? {};
171
+ for (const req of security) {
172
+ for (const [name, scopes] of Object.entries(req)) {
173
+ const s = this.#deref(schemes[name]);
174
+ if (s && ((s.type === "http" && String(s.scheme).toLowerCase() === "bearer") || s.type === "oauth2" || s.type === "openIdConnect")) {
175
+ return { scopes: Array.isArray(scopes) ? scopes : [] };
176
+ }
177
+ }
178
+ }
179
+ return undefined;
180
+ }
163
181
  /** A value that satisfies `schema` (as far as a simple walk can): examples, defaults, enums, then types. */
164
182
  #sample(schema, depth = 0) {
165
183
  const s = this.#deref(schema);
@@ -0,0 +1,49 @@
1
+ export interface Query {
2
+ sql: string;
3
+ /** Milliseconds since the log started. */
4
+ at: number;
5
+ }
6
+ /** A list of queries, with helpers for spotting N+1 patterns. */
7
+ export declare class QueryList extends Array<Query> {
8
+ /** Statements grouped by shape (literals and parameters replaced by `?`), most frequent first. */
9
+ shapes(): {
10
+ sql: string;
11
+ count: number;
12
+ }[];
13
+ /** Without transaction control and session statements (`BEGIN`, `COMMIT`, `SAVEPOINT`, `SET`, …), which some drivers add around every query. */
14
+ withoutTransactions(): QueryList;
15
+ /** Shapes run at least `min` times (default 3): the usual signature of an N+1. */
16
+ repeated(min?: number): {
17
+ sql: string;
18
+ count: number;
19
+ }[];
20
+ }
21
+ export declare function queryList(queries: Query[]): QueryList;
22
+ /** `SELECT * FROM t WHERE id = 7 AND name = 'x'` → `SELECT * FROM t WHERE id = ? AND name = ?`. */
23
+ export declare function normalizeSql(sql: string): string;
24
+ type Engine = "postgres" | "mysql";
25
+ /**
26
+ * A TCP proxy between the app and its database that reads the wire protocol
27
+ * and records every statement the app runs, whatever its language or driver.
28
+ * Postgres: simple queries and extended-protocol executions. MySQL: COM_QUERY
29
+ * and prepared-statement executions. Connections that switch to TLS are
30
+ * forwarded but not read.
31
+ */
32
+ export declare class QueryLog {
33
+ #private;
34
+ private readonly server;
35
+ readonly port: number;
36
+ private constructor();
37
+ static start(engine: Engine, upstream: {
38
+ host: string;
39
+ port: number;
40
+ }): Promise<QueryLog>;
41
+ /** `url` with its host and port pointing at the proxy. */
42
+ proxyUrl(url: string): string;
43
+ /** Queries since the last `reset()`, or since `mark` (an index from `mark()`). */
44
+ queries(since?: number): QueryList;
45
+ mark(): number;
46
+ reset(): void;
47
+ close(): Promise<void>;
48
+ }
49
+ export {};