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.
- package/README.md +110 -2
- package/dist/auth.d.ts +45 -0
- package/dist/auth.js +128 -0
- package/dist/cli.js +8 -3
- package/dist/config.d.ts +14 -0
- package/dist/config.js +16 -0
- package/dist/db.d.ts +26 -0
- package/dist/db.js +50 -0
- 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/gen.d.ts +1 -0
- package/dist/gen.js +9 -4
- package/dist/http.d.ts +7 -0
- package/dist/http.js +10 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2 -0
- package/dist/init.js +44 -1
- package/dist/openapi.d.ts +8 -0
- package/dist/openapi.js +18 -0
- package/dist/query-log.d.ts +49 -0
- package/dist/query-log.js +261 -0
- package/dist/runtime.d.ts +6 -0
- package/dist/runtime.js +40 -2
- package/dist/stub.d.ts +32 -0
- package/dist/stub.js +87 -0
- package/dist/webhook.d.ts +40 -0
- package/dist/webhook.js +52 -0
- package/dist/yaml-runtime.js +41 -3
- package/dist/yaml.d.ts +36 -1
- package/dist/yaml.js +56 -2
- package/package.json +8 -2
- package/schema/scenario.schema.json +180 -0
package/dist/drivers/mysql.js
CHANGED
|
@@ -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;
|
package/dist/drivers/postgres.js
CHANGED
|
@@ -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
|
}
|
package/dist/drivers/sqlite.d.ts
CHANGED
|
@@ -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;
|
package/dist/drivers/sqlite.js
CHANGED
|
@@ -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;
|
package/dist/factory.js
ADDED
|
@@ -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
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
|
-
|
|
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
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 {};
|