@classytic/arc-testkit 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 +44 -12
- package/dist/conformance.d.mts +380 -0
- package/dist/conformance.mjs +831 -0
- package/dist/index.d.mts +0 -2
- package/dist/index.mjs +1 -2
- package/dist/mongo-commands.d.mts +27 -0
- package/dist/mongo-commands.mjs +53 -0
- package/dist/mongo-explain.d.mts +32 -0
- package/dist/mongo-explain.mjs +66 -0
- package/dist/mongo.d.mts +0 -2
- package/dist/mongo.mjs +1 -2
- package/dist/pool.d.mts +0 -2
- package/dist/pool.mjs +3 -4
- package/dist/story.d.mts +202 -0
- package/dist/story.mjs +330 -0
- package/llms.txt +33 -0
- package/package.json +31 -3
- package/skills/arc-testkit/SKILL.md +113 -0
- package/skills/arc-testkit/references/conformance.md +104 -0
- package/skills/arc-testkit/references/fixtures.md +92 -0
- package/skills/arc-testkit/references/list-filters.md +3 -0
- package/skills/arc-testkit/references/story.md +80 -0
|
@@ -0,0 +1,831 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { expect, it } from "vitest";
|
|
4
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
|
+
// src/conformance/shared.ts
|
|
6
|
+
/**
|
|
7
|
+
* Shared seams of the conformance suites: a structural view of a booted app (a Fastify instance
|
|
8
|
+
* satisfies it), the `Caller` a suite sends requests through, the route table, and the exception
|
|
9
|
+
* lists every guard takes (an exception that no longer matches anything fails the guard).
|
|
10
|
+
*/
|
|
11
|
+
async function resolve(source) {
|
|
12
|
+
return typeof source === "function" ? await source() : source;
|
|
13
|
+
}
|
|
14
|
+
function registryEntries(app) {
|
|
15
|
+
const arc = app.arc;
|
|
16
|
+
const getAll = arc?.registry?.getAll;
|
|
17
|
+
if (typeof getAll !== "function") throw new Error("the app has no `app.arc.registry.getAll()`: pass an app booted by @classytic/arc");
|
|
18
|
+
return getAll.call(arc?.registry);
|
|
19
|
+
}
|
|
20
|
+
/** A `Caller` over `app.inject`, JSON in and out, sending `headers` (an auth token) with every request. */
|
|
21
|
+
function injectCaller(app, headers = {}) {
|
|
22
|
+
const request = async (method, path, body) => {
|
|
23
|
+
const res = await app.inject({
|
|
24
|
+
method,
|
|
25
|
+
url: path,
|
|
26
|
+
headers: body === void 0 ? headers : {
|
|
27
|
+
"content-type": "application/json",
|
|
28
|
+
...headers
|
|
29
|
+
},
|
|
30
|
+
...body === void 0 ? {} : { payload: JSON.stringify(body) }
|
|
31
|
+
});
|
|
32
|
+
let parsed = res.body;
|
|
33
|
+
try {
|
|
34
|
+
parsed = res.body === "" ? void 0 : JSON.parse(res.body);
|
|
35
|
+
} catch {}
|
|
36
|
+
return {
|
|
37
|
+
status: res.statusCode,
|
|
38
|
+
body: parsed,
|
|
39
|
+
headers: res.headers
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
return {
|
|
43
|
+
request,
|
|
44
|
+
get: (path) => request("GET", path),
|
|
45
|
+
post: (path, body = {}) => request("POST", path, body),
|
|
46
|
+
put: (path, body = {}) => request("PUT", path, body),
|
|
47
|
+
patch: (path, body = {}) => request("PATCH", path, body),
|
|
48
|
+
delete: (path) => request("DELETE", path)
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** The app's route table as `{ path, methods }` rows, read from Fastify's radix-tree dump. */
|
|
52
|
+
function routeTable(app) {
|
|
53
|
+
const dump = app.printRoutes({
|
|
54
|
+
commonPrefix: false,
|
|
55
|
+
includeHooks: false
|
|
56
|
+
});
|
|
57
|
+
const rows = [];
|
|
58
|
+
const stack = [];
|
|
59
|
+
for (const line of dump.split("\n")) {
|
|
60
|
+
const match = /^([│ ]*)[├└]── (.*?)(?: \(([A-Z, ]+)\))?$/.exec(line);
|
|
61
|
+
if (!match) continue;
|
|
62
|
+
const depth = (match[1] ?? "").length / 4;
|
|
63
|
+
stack.length = depth;
|
|
64
|
+
stack[depth] = match[2] ?? "";
|
|
65
|
+
const methods = (match[3] ?? "").split(",").map((m) => m.trim()).filter(Boolean);
|
|
66
|
+
if (methods.length === 0) continue;
|
|
67
|
+
const path = stack.join("").replace(/\/{2,}/g, "/").replace(/(.)\/$/, "$1");
|
|
68
|
+
rows.push({
|
|
69
|
+
path,
|
|
70
|
+
methods
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return rows;
|
|
74
|
+
}
|
|
75
|
+
/** Methods a route table reports beside the real ones; never part of a contract. */
|
|
76
|
+
const IMPLICIT_METHODS = /* @__PURE__ */ new Set(["HEAD", "OPTIONS"]);
|
|
77
|
+
/** `/a/{id}/b`, `/a/:id|:other/b` and `/a/:id/b/` are one shape: `/a/:x/b`. */
|
|
78
|
+
function shapeOf(path) {
|
|
79
|
+
return path.replace(/\{[^}]+\}/g, ":x").replace(/:[A-Za-z0-9_]+(?:\|:[A-Za-z0-9_]+)*/g, ":x").replace(/(.)\/+$/, "$1");
|
|
80
|
+
}
|
|
81
|
+
function exceptionKeys(list) {
|
|
82
|
+
if (list === void 0) return [];
|
|
83
|
+
if (typeof list[Symbol.iterator] === "function") return [...list];
|
|
84
|
+
return Object.keys(list);
|
|
85
|
+
}
|
|
86
|
+
/** Which of `declared` never matched anything: a stale exception hides nothing and must go. */
|
|
87
|
+
function staleOf(declared, used) {
|
|
88
|
+
return declared.filter((key) => !used.has(key));
|
|
89
|
+
}
|
|
90
|
+
/** The failure text of a guard: problems, one per line. */
|
|
91
|
+
function report(what, problems) {
|
|
92
|
+
return problems.length === 0 ? "" : `${what}:\n ${problems.join("\n ")}`;
|
|
93
|
+
}
|
|
94
|
+
/** Fails with the problems listed, one per line; passes when there are none. Use it with the `check*` functions inside a host's own tests. */
|
|
95
|
+
function expectClean(what, problems) {
|
|
96
|
+
expect(problems, report(what, problems)).toEqual([]);
|
|
97
|
+
}
|
|
98
|
+
// src/conformance/client-drift.ts
|
|
99
|
+
/**
|
|
100
|
+
* Client-route drift: every route a client can emit exists in the OpenAPI document the real server
|
|
101
|
+
* generates, and the body fields a client relies on appear in that document's schemas. The host
|
|
102
|
+
* supplies what the client can emit (`{ method, path }` rows; a path may be a concrete URL or a
|
|
103
|
+
* `{param}` / `:param` template). `dumpOpenApi` / `loadOpenApiDump` carry the document from the
|
|
104
|
+
* server's test run to the client's, and refuse a dump older than the sources it describes.
|
|
105
|
+
*/
|
|
106
|
+
/** Spec path templates as matchers: `/api/x/{id}/y` accepts `/api/x/anything/y` and `/api/x/:id/y`. */
|
|
107
|
+
function specMatcher(spec) {
|
|
108
|
+
const byMethod = /* @__PURE__ */ new Map();
|
|
109
|
+
for (const [path, item] of Object.entries(spec.paths ?? {})) {
|
|
110
|
+
const re = new RegExp(`^${path.split(/\{[^}]+\}/).map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[^/]+")}/?$`);
|
|
111
|
+
for (const method of Object.keys(item)) {
|
|
112
|
+
const list = byMethod.get(method.toUpperCase()) ?? [];
|
|
113
|
+
list.push({
|
|
114
|
+
path,
|
|
115
|
+
re
|
|
116
|
+
});
|
|
117
|
+
byMethod.set(method.toUpperCase(), list);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return (method, concrete) => {
|
|
121
|
+
const path = concrete.replace(/\?.*$/, "").replace(/:[A-Za-z0-9_]+/g, "x");
|
|
122
|
+
return byMethod.get(method.toUpperCase())?.find(({ re }) => re.test(path))?.path;
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** Client routes the document does not have (sorted, deduplicated, with their source). */
|
|
126
|
+
function missingClientRoutes(spec, routes) {
|
|
127
|
+
const find = specMatcher(spec);
|
|
128
|
+
const missing = routes.filter((r) => !find(r.method, r.path)).map((r) => `${r.method.toUpperCase()} ${r.path}${r.source ? ` (${r.source})` : ""}`);
|
|
129
|
+
return [...new Set(missing)].sort();
|
|
130
|
+
}
|
|
131
|
+
function resolveRef(spec, schema) {
|
|
132
|
+
if (!schema?.$ref) return schema;
|
|
133
|
+
return spec.components?.schemas?.[schema.$ref.replace("#/components/schemas/", "")];
|
|
134
|
+
}
|
|
135
|
+
/** Every property name a schema offers, through refs and unions. */
|
|
136
|
+
function propertyNames(spec, schema, depth = 0) {
|
|
137
|
+
const out = /* @__PURE__ */ new Set();
|
|
138
|
+
const resolved = resolveRef(spec, schema);
|
|
139
|
+
if (!resolved || depth > 6) return out;
|
|
140
|
+
for (const name of Object.keys(resolved.properties ?? {})) out.add(name);
|
|
141
|
+
for (const branch of [
|
|
142
|
+
...resolved.oneOf ?? [],
|
|
143
|
+
...resolved.allOf ?? [],
|
|
144
|
+
...resolved.anyOf ?? []
|
|
145
|
+
]) for (const name of propertyNames(spec, branch, depth + 1)) out.add(name);
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
function bodyFieldProblems(spec, check) {
|
|
149
|
+
const label = check.name ?? `${check.method.toUpperCase()} ${check.path}`;
|
|
150
|
+
const op = spec.paths?.[check.path]?.[check.method.toLowerCase()];
|
|
151
|
+
if (!op) return [`${label}: not in the document`];
|
|
152
|
+
const names = propertyNames(spec, check.in === "response" ? op.responses?.[check.status ?? "200"]?.content?.["application/json"]?.schema : op.requestBody?.content?.["application/json"]?.schema);
|
|
153
|
+
if (names.size === 0) return [`${label}: the document declares no ${check.in ?? "request"} schema`];
|
|
154
|
+
return check.fields.filter((f) => !names.has(f)).map((f) => `${label}: field "${f}" is not in the schema`);
|
|
155
|
+
}
|
|
156
|
+
async function checkClientDrift(opts) {
|
|
157
|
+
const spec = await resolve(opts.spec);
|
|
158
|
+
const routes = await resolve(opts.routes);
|
|
159
|
+
const problems = [];
|
|
160
|
+
const min = opts.minRoutes ?? 1;
|
|
161
|
+
if (routes.length < min) problems.push(`only ${routes.length} client routes supplied, expected at least ${min}`);
|
|
162
|
+
for (const m of missingClientRoutes(spec, routes)) problems.push(`client route missing from the document: ${m}`);
|
|
163
|
+
for (const check of opts.bodyFields ?? []) problems.push(...bodyFieldProblems(spec, check));
|
|
164
|
+
return problems;
|
|
165
|
+
}
|
|
166
|
+
/** Registers the drift guard as vitest tests. */
|
|
167
|
+
function registerClientDriftGuard(opts) {
|
|
168
|
+
it("every route the client can emit exists in the OpenAPI document", async () => {
|
|
169
|
+
const spec = await resolve(opts.spec);
|
|
170
|
+
const routes = await resolve(opts.routes);
|
|
171
|
+
const problems = missingClientRoutes(spec, routes);
|
|
172
|
+
expect(problems, report("client routes missing from the document", problems)).toEqual([]);
|
|
173
|
+
expect(routes.length, "client routes supplied").toBeGreaterThanOrEqual(opts.minRoutes ?? 1);
|
|
174
|
+
});
|
|
175
|
+
if ((opts.bodyFields ?? []).length > 0) it("the body fields the client relies on are declared in the document", async () => {
|
|
176
|
+
const spec = await resolve(opts.spec);
|
|
177
|
+
const problems = (opts.bodyFields ?? []).flatMap((c) => bodyFieldProblems(spec, c));
|
|
178
|
+
expect(problems, report("body field drift", problems)).toEqual([]);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
/** Write the document to `file` (the server side of the dump: `npm run openapi:dump`). */
|
|
182
|
+
function dumpOpenApi(spec, file) {
|
|
183
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
184
|
+
writeFileSync(file, `${JSON.stringify({
|
|
185
|
+
...spec,
|
|
186
|
+
"x-generated-at": (/* @__PURE__ */ new Date()).toISOString()
|
|
187
|
+
}, null, 1)}\n`);
|
|
188
|
+
}
|
|
189
|
+
function newestSource(dir) {
|
|
190
|
+
let best = {
|
|
191
|
+
file: dir,
|
|
192
|
+
mtime: 0
|
|
193
|
+
};
|
|
194
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
195
|
+
const path = join(dir, entry.name);
|
|
196
|
+
const found = entry.isDirectory() ? newestSource(path) : /\.[cm]?tsx?$/.test(entry.name) ? {
|
|
197
|
+
file: path,
|
|
198
|
+
mtime: statSync(path).mtimeMs
|
|
199
|
+
} : best;
|
|
200
|
+
if (found.mtime > best.mtime) best = found;
|
|
201
|
+
}
|
|
202
|
+
return best;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Read a dumped document. Throws when the dump is missing or older than any `.ts` file under
|
|
206
|
+
* `sources`, so a stale document can never pass for the current API. `regenerate` names the command
|
|
207
|
+
* in the message.
|
|
208
|
+
*/
|
|
209
|
+
function loadOpenApiDump(file, opts = {}) {
|
|
210
|
+
const how = opts.regenerate ?? "regenerate the OpenAPI dump on the server";
|
|
211
|
+
if (!existsSync(file)) throw new Error(`The OpenAPI dump is missing: ${file}\n${how}, then run this test again.`);
|
|
212
|
+
const dumped = statSync(file).mtimeMs;
|
|
213
|
+
for (const dir of opts.sources ?? []) {
|
|
214
|
+
const newest = newestSource(dir);
|
|
215
|
+
if (newest.mtime > dumped) throw new Error(`The OpenAPI dump is older than the sources it describes.\n dump: ${new Date(dumped).toISOString()}\n newer: ${newest.file} (${new Date(newest.mtime).toISOString()})\n${how}.`);
|
|
216
|
+
}
|
|
217
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
218
|
+
}
|
|
219
|
+
// src/conformance/collection-routes.ts
|
|
220
|
+
/**
|
|
221
|
+
* Collection routes and the trailing slash. A client addresses a collection by its bare path
|
|
222
|
+
* (`GET /api/things?limit=5`, never `/api/things/?limit=5`), so the server must register the bare
|
|
223
|
+
* form with the verbs the client sends, and serve it.
|
|
224
|
+
*/
|
|
225
|
+
async function checkCollectionRoutes(opts) {
|
|
226
|
+
const table = routeTable(await resolve(opts.app));
|
|
227
|
+
const problems = [];
|
|
228
|
+
for (const { path, methods } of opts.collections) {
|
|
229
|
+
if (path.length > 1 && path.endsWith("/")) {
|
|
230
|
+
problems.push(`${path}: a collection is addressed by its bare path`);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const row = table.find((r) => r.path === path);
|
|
234
|
+
if (!row) {
|
|
235
|
+
problems.push(`${path} is not registered`);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
for (const method of methods) if (!row.methods.includes(method.toUpperCase())) problems.push(`${method.toUpperCase()} ${path} is not registered`);
|
|
239
|
+
}
|
|
240
|
+
if (opts.served) {
|
|
241
|
+
const caller = await resolve(opts.served.caller);
|
|
242
|
+
for (const path of opts.served.paths) {
|
|
243
|
+
const res = await caller.get(path);
|
|
244
|
+
if (res.status !== 200) problems.push(`GET ${path} answered ${res.status}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return problems;
|
|
248
|
+
}
|
|
249
|
+
/** Registers the collection route guard as a vitest test. */
|
|
250
|
+
function registerCollectionRoutesGuard(opts) {
|
|
251
|
+
it("collection routes are registered bare, with the verbs the client sends", async () => {
|
|
252
|
+
const problems = await checkCollectionRoutes(opts);
|
|
253
|
+
expect(problems, report("collection route violations", problems)).toEqual([]);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
// src/conformance/errors.ts
|
|
257
|
+
/**
|
|
258
|
+
* Every class of error response, provoked through the real app, is a `@classytic/repo-core`
|
|
259
|
+
* `ErrorContract`: `{ code, message, status, details?, correlationId?, meta? }` with nothing else
|
|
260
|
+
* on the body. The host supplies one `provoke` per class (it knows which request fails how); the
|
|
261
|
+
* guard asserts the status, the code and the contract. `unknownRoute` has a built-in probe.
|
|
262
|
+
*/
|
|
263
|
+
const ERROR_CLASSES = [
|
|
264
|
+
"validation",
|
|
265
|
+
"unauthorized",
|
|
266
|
+
"forbidden",
|
|
267
|
+
"notFound",
|
|
268
|
+
"unknownRoute",
|
|
269
|
+
"conflict",
|
|
270
|
+
"duplicateKey"
|
|
271
|
+
];
|
|
272
|
+
const DEFAULT_STATUS = {
|
|
273
|
+
validation: 400,
|
|
274
|
+
unauthorized: 401,
|
|
275
|
+
forbidden: 403,
|
|
276
|
+
notFound: 404,
|
|
277
|
+
unknownRoute: 404,
|
|
278
|
+
conflict: 409,
|
|
279
|
+
duplicateKey: 409
|
|
280
|
+
};
|
|
281
|
+
const CONTRACT_KEYS = [
|
|
282
|
+
"code",
|
|
283
|
+
"message",
|
|
284
|
+
"status",
|
|
285
|
+
"details",
|
|
286
|
+
"correlationId",
|
|
287
|
+
"meta"
|
|
288
|
+
];
|
|
289
|
+
const DETAIL_KEYS = [
|
|
290
|
+
"path",
|
|
291
|
+
"code",
|
|
292
|
+
"message",
|
|
293
|
+
"meta"
|
|
294
|
+
];
|
|
295
|
+
/** The reasons a reply is not the expected `ErrorContract` (empty: it is one). */
|
|
296
|
+
function errorContractProblems(reply, expected, allowedKeys = CONTRACT_KEYS) {
|
|
297
|
+
const problems = [];
|
|
298
|
+
if (reply.status !== expected.status) problems.push(`status ${reply.status}, expected ${expected.status}`);
|
|
299
|
+
const body = reply.body;
|
|
300
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) return [...problems, `body is not an object: ${JSON.stringify(body)}`];
|
|
301
|
+
const rec = body;
|
|
302
|
+
const foreign = Object.keys(rec).filter((k) => !allowedKeys.includes(k));
|
|
303
|
+
if (foreign.length > 0) problems.push(`foreign keys on the body: ${foreign.join(", ")}`);
|
|
304
|
+
if (typeof rec.code !== "string" || rec.code.length === 0) problems.push("`code` is not a non-empty string");
|
|
305
|
+
else if (typeof expected.code === "string" && rec.code !== expected.code) problems.push(`code "${rec.code}", expected "${expected.code}"`);
|
|
306
|
+
else if (expected.code instanceof RegExp && !expected.code.test(rec.code)) problems.push(`code "${rec.code}" does not match ${expected.code}`);
|
|
307
|
+
if (typeof rec.message !== "string" || rec.message.length === 0) problems.push("`message` is not a non-empty string");
|
|
308
|
+
if (rec.status !== expected.status) problems.push(`body.status is ${String(rec.status)}, expected ${expected.status}`);
|
|
309
|
+
if (rec.correlationId !== void 0 && typeof rec.correlationId !== "string") problems.push("`correlationId` is not a string");
|
|
310
|
+
if (rec.meta !== void 0 && (typeof rec.meta !== "object" || rec.meta === null)) problems.push("`meta` is not an object");
|
|
311
|
+
const details = rec.details;
|
|
312
|
+
if (details !== void 0 && !Array.isArray(details)) problems.push("`details` is not an array");
|
|
313
|
+
if (expected.details === "some" && !(Array.isArray(details) && details.length > 0)) problems.push("`details` is empty, expected field details");
|
|
314
|
+
if (expected.details === "none" && details !== void 0) problems.push("`details` present, expected none");
|
|
315
|
+
for (const detail of Array.isArray(details) ? details : []) {
|
|
316
|
+
const d = detail ?? {};
|
|
317
|
+
const odd = Object.keys(d).filter((k) => !DETAIL_KEYS.includes(k));
|
|
318
|
+
if (odd.length > 0) problems.push(`foreign keys on a detail: ${odd.join(", ")}`);
|
|
319
|
+
if (typeof d.code !== "string") problems.push("a detail has no string `code`");
|
|
320
|
+
if (typeof d.message !== "string") problems.push("a detail has no string `message`");
|
|
321
|
+
if (d.path !== void 0 && typeof d.path !== "string") problems.push("a detail `path` is not a string");
|
|
322
|
+
if (expected.detailCode !== void 0 && d.code !== expected.detailCode) problems.push(`a detail has code "${String(d.code)}", expected "${expected.detailCode}"`);
|
|
323
|
+
}
|
|
324
|
+
return problems;
|
|
325
|
+
}
|
|
326
|
+
/** Throws unless the reply is the expected `ErrorContract`; returns the body. */
|
|
327
|
+
function expectErrorContract(reply, expected) {
|
|
328
|
+
const problems = errorContractProblems(reply, expected);
|
|
329
|
+
expect(problems, `${report("not an ErrorContract", problems)}\n body: ${JSON.stringify(reply.body)}`).toEqual([]);
|
|
330
|
+
return reply.body;
|
|
331
|
+
}
|
|
332
|
+
async function checkErrorContracts(opts) {
|
|
333
|
+
const problems = [];
|
|
334
|
+
const probes = { ...opts.probes };
|
|
335
|
+
if (probes.unknownRoute === void 0 && opts.caller !== void 0) {
|
|
336
|
+
const caller = await resolve(opts.caller);
|
|
337
|
+
probes.unknownRoute = { provoke: () => caller.get("/__no_such_route__?token=secret") };
|
|
338
|
+
}
|
|
339
|
+
for (const cls of opts.require ?? ERROR_CLASSES) if (probes[cls] === void 0) problems.push(`${cls}: no probe supplied`);
|
|
340
|
+
for (const cls of ERROR_CLASSES) {
|
|
341
|
+
const entry = probes[cls];
|
|
342
|
+
if (entry === void 0) continue;
|
|
343
|
+
for (const probe of Array.isArray(entry) ? entry : [entry]) {
|
|
344
|
+
const reply = await probe.provoke();
|
|
345
|
+
const details = probe.details ?? (cls === "validation" || cls === "duplicateKey" ? "some" : void 0);
|
|
346
|
+
const detailCode = probe.detailCode ?? (cls === "duplicateKey" ? "duplicate_key" : void 0);
|
|
347
|
+
const found = errorContractProblems(reply, {
|
|
348
|
+
status: probe.status ?? DEFAULT_STATUS[cls],
|
|
349
|
+
...probe.code === void 0 ? {} : { code: probe.code },
|
|
350
|
+
...details === void 0 ? {} : { details },
|
|
351
|
+
...detailCode === void 0 ? {} : { detailCode }
|
|
352
|
+
}, opts.allowedKeys);
|
|
353
|
+
for (const p of found) problems.push(`${cls}: ${p}`);
|
|
354
|
+
if (cls === "unknownRoute" && JSON.stringify(reply.body).includes("secret")) problems.push("unknownRoute: the query string is echoed in the body");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return problems;
|
|
358
|
+
}
|
|
359
|
+
/** Registers the error contract guard as vitest tests. */
|
|
360
|
+
function registerErrorContractGuard(opts) {
|
|
361
|
+
it("every error class is an ErrorContract body", async () => {
|
|
362
|
+
const problems = await checkErrorContracts(opts);
|
|
363
|
+
expect(problems, report("error contract violations", problems)).toEqual([]);
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
// src/conformance/filter-declarations.ts
|
|
367
|
+
/**
|
|
368
|
+
* UI filters, search and sorts against the list route's declared query contract. The host lists what
|
|
369
|
+
* each screen sends (`{ route, filters: [{ queryField, op }], search, sorts }`); each must be allowed by
|
|
370
|
+
* the list operation in the OpenAPI document: a query parameter `field` / `field[op]`, or the arc
|
|
371
|
+
* `x-arc-query` extension (`filterFields`, `operators`, `sortFields`).
|
|
372
|
+
*/
|
|
373
|
+
/** The URL operators a filter really sends: `between` is `gte` + `lte`. */
|
|
374
|
+
function wireOps(op) {
|
|
375
|
+
return op === "between" ? ["gte", "lte"] : [op];
|
|
376
|
+
}
|
|
377
|
+
async function checkFilterDeclarations(opts) {
|
|
378
|
+
const spec = await resolve(opts.spec);
|
|
379
|
+
const find = specMatcher(spec);
|
|
380
|
+
const problems = [];
|
|
381
|
+
for (const decl of await resolve(opts.declarations)) {
|
|
382
|
+
const path = find("GET", decl.route);
|
|
383
|
+
const op = path ? spec.paths?.[path]?.get : void 0;
|
|
384
|
+
if (!op) {
|
|
385
|
+
problems.push(`${decl.route}: no GET operation in the document`);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const params = new Set((op["parameters"] ?? []).map((p) => p.name));
|
|
389
|
+
const contract = op["x-arc-query"];
|
|
390
|
+
for (const { queryField, op: filterOp = "eq" } of decl.filters ?? []) for (const wire of wireOps(filterOp)) {
|
|
391
|
+
const name = wire === "eq" ? queryField : `${queryField}[${wire}]`;
|
|
392
|
+
if (!(params.has(name) || contract?.filterFields?.includes(queryField) === true && (wire === "eq" || !contract.operators || contract.operators.includes(wire)))) problems.push(`${decl.route}: filter ${name} is not declared by the route`);
|
|
393
|
+
}
|
|
394
|
+
if (decl.search && !params.has("search")) problems.push(`${decl.route}: search is not declared by the route`);
|
|
395
|
+
for (const sort of decl.sorts ?? []) {
|
|
396
|
+
const field = sort.replace(/^[+-]/, "");
|
|
397
|
+
if (!params.has("sort") && !contract?.sortFields) problems.push(`${decl.route}: sort ${field} is not declared by the route`);
|
|
398
|
+
else if (contract?.sortFields && !contract.sortFields.includes(field)) problems.push(`${decl.route}: sort ${field} is not an allowed sort field`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return problems;
|
|
402
|
+
}
|
|
403
|
+
/** Registers the declaration check as a vitest test. */
|
|
404
|
+
function registerFilterDeclarationsGuard(opts) {
|
|
405
|
+
it("every UI filter, search and sort is allowed by its list route", async () => {
|
|
406
|
+
const problems = await checkFilterDeclarations(opts);
|
|
407
|
+
expect(problems, report("undeclared list queries", problems)).toEqual([]);
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
// src/conformance/list-envelope.ts
|
|
411
|
+
/**
|
|
412
|
+
* Every list route answers one canonical `@classytic/repo-core` envelope. The route table is read
|
|
413
|
+
* from the running app: each parameterless GET under `prefix` is called as `caller` and, when it
|
|
414
|
+
* answers a list, the answer must be an offset page (`method: 'offset'` with its page numbers), a
|
|
415
|
+
* keyset page, or a bare `{ data: [...] }`. Old shapes (`{ docs, total }`, `{ data, pagination }`,
|
|
416
|
+
* a bare array, `rows`) fail. A GET that is not a list is named in `notLists` with the reason, so a
|
|
417
|
+
* new route (or a deviating list) is a decision somebody has to make.
|
|
418
|
+
*/
|
|
419
|
+
const DEFAULT_SKIP = /\/(stream|export|content|download)(\/|$)/;
|
|
420
|
+
const DEFAULT_FORBIDDEN = [
|
|
421
|
+
"docs",
|
|
422
|
+
"rows",
|
|
423
|
+
"items",
|
|
424
|
+
"pagination",
|
|
425
|
+
"totalPages"
|
|
426
|
+
];
|
|
427
|
+
const OFFSET_NUMBERS = [
|
|
428
|
+
"page",
|
|
429
|
+
"limit",
|
|
430
|
+
"total",
|
|
431
|
+
"pages"
|
|
432
|
+
];
|
|
433
|
+
/** The reasons a body is not a canonical list (empty: it is one). */
|
|
434
|
+
function listEnvelopeProblems(body, methods = ["offset", "keyset"], forbidden = DEFAULT_FORBIDDEN) {
|
|
435
|
+
const keys = Object.keys(body);
|
|
436
|
+
const problems = [];
|
|
437
|
+
const old = forbidden.filter((k) => keys.includes(k));
|
|
438
|
+
if (old.length > 0) problems.push(`carries ${old.join(", ")}`);
|
|
439
|
+
const rec = body;
|
|
440
|
+
if (!("method" in rec)) {
|
|
441
|
+
if (keys.length !== 1) problems.push(`a list with paging numbers but no method (${keys.join(", ")})`);
|
|
442
|
+
return problems;
|
|
443
|
+
}
|
|
444
|
+
const method = rec.method;
|
|
445
|
+
if (typeof method !== "string" || !methods.includes(method)) problems.push(`method "${String(method)}" is not one of ${methods.join(", ")}`);
|
|
446
|
+
else if (method === "offset") {
|
|
447
|
+
for (const k of OFFSET_NUMBERS) if (typeof rec[k] !== "number") problems.push(`offset page: \`${k}\` is not a number`);
|
|
448
|
+
for (const k of ["hasNext", "hasPrev"]) if (typeof rec[k] !== "boolean") problems.push(`offset page: \`${k}\` is not a boolean`);
|
|
449
|
+
} else {
|
|
450
|
+
if (typeof rec.limit !== "number") problems.push("keyset page: `limit` is not a number");
|
|
451
|
+
if (typeof rec.hasMore !== "boolean") problems.push("keyset page: `hasMore` is not a boolean");
|
|
452
|
+
if (rec.next !== null && typeof rec.next !== "string") problems.push("keyset page: `next` is not a string or null");
|
|
453
|
+
}
|
|
454
|
+
return problems;
|
|
455
|
+
}
|
|
456
|
+
async function checkListEnvelopes(opts) {
|
|
457
|
+
const app = await resolve(opts.app);
|
|
458
|
+
const caller = await resolve(opts.caller);
|
|
459
|
+
const skip = opts.skip ?? DEFAULT_SKIP;
|
|
460
|
+
const notLists = exceptionKeys(opts.notLists);
|
|
461
|
+
const custom = exceptionKeys(opts.customShapes);
|
|
462
|
+
const known = /* @__PURE__ */ new Set();
|
|
463
|
+
const getPaths = /* @__PURE__ */ new Set();
|
|
464
|
+
for (const { path, methods } of routeTable(app)) {
|
|
465
|
+
if (!methods.some((m) => m === "GET")) continue;
|
|
466
|
+
known.add(path);
|
|
467
|
+
if (path.includes(":") || path.includes("*") || skip.test(path)) continue;
|
|
468
|
+
if (opts.prefix !== void 0 && !path.startsWith(opts.prefix)) continue;
|
|
469
|
+
getPaths.add(path);
|
|
470
|
+
}
|
|
471
|
+
const probed = [...getPaths].sort();
|
|
472
|
+
const problems = [];
|
|
473
|
+
const lists = [];
|
|
474
|
+
for (const path of probed) {
|
|
475
|
+
if (notLists.includes(path) || custom.includes(path)) continue;
|
|
476
|
+
const res = await caller.get(path);
|
|
477
|
+
if (res.status !== 200) continue;
|
|
478
|
+
const body = res.body;
|
|
479
|
+
if (Array.isArray(body)) {
|
|
480
|
+
problems.push(`${path}: a bare array`);
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
const data = body?.data;
|
|
484
|
+
if (typeof body !== "object" || body === null || !Array.isArray(data)) {
|
|
485
|
+
const shape = typeof body === "object" && body !== null ? Object.keys(body).join(", ") : typeof body;
|
|
486
|
+
problems.push(`${path}: not a list and not in notLists (${shape})`);
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
lists.push(path);
|
|
490
|
+
for (const p of listEnvelopeProblems(body, opts.methods, opts.forbiddenKeys)) problems.push(`${path}: ${p}`);
|
|
491
|
+
}
|
|
492
|
+
for (const key of staleOf([...notLists, ...custom], known)) problems.push(`stale exception ${key}: no such GET route is registered`);
|
|
493
|
+
const min = opts.minRoutes ?? 1;
|
|
494
|
+
if (probed.length < min) problems.push(`only ${probed.length} GET routes probed, expected at least ${min}`);
|
|
495
|
+
for (const path of opts.expectLists ?? []) if (!lists.includes(path)) problems.push(`${path} was expected to answer a list and did not`);
|
|
496
|
+
return {
|
|
497
|
+
problems,
|
|
498
|
+
lists,
|
|
499
|
+
probed
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
/** Registers the list envelope guard as vitest tests. */
|
|
503
|
+
function registerListEnvelopeGuard(opts) {
|
|
504
|
+
it("every GET list route answers the canonical envelope", async () => {
|
|
505
|
+
const { problems } = await checkListEnvelopes(opts);
|
|
506
|
+
expect(problems, report("list envelope violations", problems)).toEqual([]);
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
// src/conformance/naming.ts
|
|
510
|
+
/**
|
|
511
|
+
* Route naming conventions, read from the running app so a new route cannot drift:
|
|
512
|
+
* - the verbs of the action router (`POST /<resource>/:id/action`) match `verbPattern`;
|
|
513
|
+
* - each path rule names a family of routes (e.g. every path containing `sweep`) and the exact
|
|
514
|
+
* `METHOD path` set that family may consist of;
|
|
515
|
+
* - `forbiddenPaths` are spellings that must not be routed at all.
|
|
516
|
+
* Verbs a package fixes belong in `exemptVerbs`; one that no longer exists fails as stale.
|
|
517
|
+
*/
|
|
518
|
+
async function checkNaming(opts) {
|
|
519
|
+
const app = await resolve(opts.app);
|
|
520
|
+
const pattern = opts.verbPattern ?? /^[a-z][a-zA-Z0-9]*$/;
|
|
521
|
+
const exempt = exceptionKeys(opts.exemptVerbs);
|
|
522
|
+
const problems = [];
|
|
523
|
+
const verbs = registryEntries(app).flatMap((entry) => (entry.actions ?? []).map((a) => ({
|
|
524
|
+
resource: entry.name,
|
|
525
|
+
verb: a.name
|
|
526
|
+
})));
|
|
527
|
+
const seen = /* @__PURE__ */ new Set();
|
|
528
|
+
for (const { resource, verb } of verbs) {
|
|
529
|
+
if (exempt.includes(verb)) {
|
|
530
|
+
seen.add(verb);
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (!pattern.test(verb)) problems.push(`action ${resource}.${verb} does not match ${pattern}`);
|
|
534
|
+
}
|
|
535
|
+
for (const verb of staleOf(exempt, seen)) problems.push(`stale exempt verb ${verb}`);
|
|
536
|
+
for (const verb of opts.requiredVerbs ?? []) if (!verbs.some((v) => v.verb === verb)) problems.push(`expected action verb ${verb} is not registered`);
|
|
537
|
+
const min = opts.minVerbs ?? 1;
|
|
538
|
+
if (verbs.length < min) problems.push(`only ${verbs.length} action verbs found, expected at least ${min}`);
|
|
539
|
+
const routes = routeTable(app);
|
|
540
|
+
const label = (r) => `${r.methods.filter((m) => !IMPLICIT_METHODS.has(m)).join("+")} ${r.path}`;
|
|
541
|
+
for (const rule of opts.pathRules ?? []) {
|
|
542
|
+
const found = routes.filter((r) => rule.match.test(r.path)).map(label).sort();
|
|
543
|
+
const want = [...rule.allowed].sort();
|
|
544
|
+
for (const route of found) if (!want.includes(route)) problems.push(`${rule.name}: unexpected route ${route}`);
|
|
545
|
+
for (const route of want) if (!found.includes(route)) problems.push(`${rule.name}: missing route ${route}`);
|
|
546
|
+
}
|
|
547
|
+
for (const re of opts.forbiddenPaths ?? []) for (const r of routes.filter((row) => re.test(row.path))) problems.push(`forbidden path ${label(r)} (${re})`);
|
|
548
|
+
return problems;
|
|
549
|
+
}
|
|
550
|
+
/** Registers the naming guard as vitest tests. */
|
|
551
|
+
function registerNamingGuard(opts) {
|
|
552
|
+
it("action verbs and named route families follow the naming rules", async () => {
|
|
553
|
+
const problems = await checkNaming(opts);
|
|
554
|
+
expect(problems, report("route naming violations", problems)).toEqual([]);
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
// src/conformance/openapi.ts
|
|
558
|
+
/**
|
|
559
|
+
* The generated API reference (an OpenAPI 3 document): it names every route the server answers,
|
|
560
|
+
* says which of them need a sign-in, and carries no configuration value. The document is supplied
|
|
561
|
+
* by the host (`fetchOpenApi(caller)` reads it from the running app).
|
|
562
|
+
*/
|
|
563
|
+
const OPENAPI_METHODS = [
|
|
564
|
+
"get",
|
|
565
|
+
"put",
|
|
566
|
+
"post",
|
|
567
|
+
"delete",
|
|
568
|
+
"patch"
|
|
569
|
+
];
|
|
570
|
+
/** Read the document from the running app (default `/openapi.json`). */
|
|
571
|
+
async function fetchOpenApi(caller, path = "/openapi.json") {
|
|
572
|
+
const res = await caller.get(path);
|
|
573
|
+
if (res.status !== 200) throw new Error(`GET ${path} answered ${res.status}, expected the OpenAPI document`);
|
|
574
|
+
return res.body;
|
|
575
|
+
}
|
|
576
|
+
const DEFAULT_SECRET_PATTERNS = [/mongodb(\+srv)?:\/\//i, /redis:\/\//i];
|
|
577
|
+
function matchesPublic(rule, path) {
|
|
578
|
+
if (typeof rule.path === "string") return rule.path === path;
|
|
579
|
+
if (rule.path instanceof RegExp) return rule.path.test(path);
|
|
580
|
+
return rule.path(path);
|
|
581
|
+
}
|
|
582
|
+
function operationsOf(spec) {
|
|
583
|
+
const out = [];
|
|
584
|
+
for (const [path, item] of Object.entries(spec.paths ?? {})) for (const method of OPENAPI_METHODS) {
|
|
585
|
+
const op = item[method];
|
|
586
|
+
if (op) out.push({
|
|
587
|
+
method,
|
|
588
|
+
path,
|
|
589
|
+
op
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
return out;
|
|
593
|
+
}
|
|
594
|
+
/** The document is OpenAPI 3, prefixed, and has no trailing-slash path. */
|
|
595
|
+
async function checkOpenApiShape(opts) {
|
|
596
|
+
const spec = await resolve(opts.spec);
|
|
597
|
+
const problems = [];
|
|
598
|
+
if (!/^3\./.test(String(spec.openapi))) problems.push(`openapi is "${String(spec.openapi)}", expected 3.x`);
|
|
599
|
+
const ops = operationsOf(spec);
|
|
600
|
+
const min = opts.minOperations ?? 1;
|
|
601
|
+
if (ops.length < min) problems.push(`only ${ops.length} operations documented, expected at least ${min}`);
|
|
602
|
+
const rules = opts.publicOperations ?? [];
|
|
603
|
+
for (const path of Object.keys(spec.paths ?? {})) {
|
|
604
|
+
if (path.length > 1 && path.endsWith("/")) problems.push(`${path} ends with a slash`);
|
|
605
|
+
if (opts.prefix !== void 0 && !path.startsWith(opts.prefix) && !rules.some((r) => matchesPublic(r, path))) problems.push(`${path} is outside the ${opts.prefix} prefix`);
|
|
606
|
+
}
|
|
607
|
+
return problems;
|
|
608
|
+
}
|
|
609
|
+
/** Every route in the table is documented (once, in its bare form). */
|
|
610
|
+
async function checkRoutesDocumented(opts) {
|
|
611
|
+
if (opts.app === void 0) return ["checkRoutesDocumented needs `app`"];
|
|
612
|
+
const spec = await resolve(opts.spec);
|
|
613
|
+
const app = await resolve(opts.app);
|
|
614
|
+
const listed = /* @__PURE__ */ new Set();
|
|
615
|
+
for (const { method, path } of operationsOf(spec)) listed.add(`${method.toUpperCase()} ${shapeOf(path)}`);
|
|
616
|
+
const problems = [];
|
|
617
|
+
for (const row of routeTable(app)) {
|
|
618
|
+
if (opts.undocumented?.test(row.path)) continue;
|
|
619
|
+
for (const method of row.methods) {
|
|
620
|
+
if (IMPLICIT_METHODS.has(method)) continue;
|
|
621
|
+
if (!listed.has(`${method} ${shapeOf(row.path)}`)) problems.push(`${method} ${row.path} is not in the document`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return problems;
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Every operation requires a security scheme (its own `security`, else the document's), except the
|
|
628
|
+
* public list; a public rule that matches no operation fails as stale.
|
|
629
|
+
*/
|
|
630
|
+
async function checkSecurity(opts) {
|
|
631
|
+
const spec = await resolve(opts.spec);
|
|
632
|
+
const rules = opts.publicOperations ?? [];
|
|
633
|
+
const used = /* @__PURE__ */ new Set();
|
|
634
|
+
const problems = [];
|
|
635
|
+
for (const { method, path, op } of operationsOf(spec)) {
|
|
636
|
+
const idx = rules.findIndex((r) => matchesPublic(r, path));
|
|
637
|
+
if (idx >= 0) {
|
|
638
|
+
used.add(idx);
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
const effective = op.security ?? spec.security;
|
|
642
|
+
if (!Array.isArray(effective) || effective.length === 0) problems.push(`${method.toUpperCase()} ${path} has no security requirement`);
|
|
643
|
+
}
|
|
644
|
+
rules.forEach((rule, i) => {
|
|
645
|
+
if (!used.has(i)) problems.push(`stale public operation (${rule.why}): ${String(rule.path)} matches nothing`);
|
|
646
|
+
});
|
|
647
|
+
return problems;
|
|
648
|
+
}
|
|
649
|
+
/** An anonymous GET of every documented, protected, parameterless path is refused with 401. */
|
|
650
|
+
async function checkAnonymousRefused(opts, expected = 401) {
|
|
651
|
+
if (opts.anonymous === void 0) return ["checkAnonymousRefused needs `anonymous`"];
|
|
652
|
+
const spec = await resolve(opts.spec);
|
|
653
|
+
const anon = await resolve(opts.anonymous);
|
|
654
|
+
const skip = opts.skipAnonymous ?? /\{|\/stream$/;
|
|
655
|
+
const rules = opts.publicOperations ?? [];
|
|
656
|
+
const problems = [];
|
|
657
|
+
for (const { method, path, op } of operationsOf(spec)) {
|
|
658
|
+
if (method !== "get" || skip.test(path) || rules.some((r) => matchesPublic(r, path))) continue;
|
|
659
|
+
const effective = op.security ?? spec.security;
|
|
660
|
+
if (!Array.isArray(effective) || effective.length === 0) continue;
|
|
661
|
+
const res = await anon.get(path);
|
|
662
|
+
if (res.status !== expected) problems.push(`GET ${path} -> ${res.status}, expected ${expected}`);
|
|
663
|
+
}
|
|
664
|
+
return problems;
|
|
665
|
+
}
|
|
666
|
+
/** The serialised document carries no configuration value. */
|
|
667
|
+
async function checkNoSecrets(opts) {
|
|
668
|
+
const text = JSON.stringify(await resolve(opts.spec));
|
|
669
|
+
const problems = [];
|
|
670
|
+
const values = opts.secretValues === void 0 ? [] : await resolve(opts.secretValues);
|
|
671
|
+
for (const value of values) if (typeof value === "string" && value.length >= 6 && text.includes(value)) problems.push("a configuration value is in the document");
|
|
672
|
+
for (const re of opts.secretPatterns ?? DEFAULT_SECRET_PATTERNS) if (re.test(text)) problems.push(`the document matches ${re}`);
|
|
673
|
+
return [...new Set(problems)];
|
|
674
|
+
}
|
|
675
|
+
/** Registers the OpenAPI guards as vitest tests (the optional ones only when their input is given). */
|
|
676
|
+
function registerOpenApiGuard(opts) {
|
|
677
|
+
const run = (name, check) => it(name, async () => {
|
|
678
|
+
const problems = await check();
|
|
679
|
+
expect(problems, report(name, problems)).toEqual([]);
|
|
680
|
+
});
|
|
681
|
+
run("the document is OpenAPI 3, prefixed, with bare paths", () => checkOpenApiShape(opts));
|
|
682
|
+
if (opts.app !== void 0) run("the document names every route the server answers", () => checkRoutesDocumented(opts));
|
|
683
|
+
run("every operation requires a sign-in, except the public list", () => checkSecurity(opts));
|
|
684
|
+
if (opts.anonymous !== void 0) run("an anonymous request to a protected route is refused", () => checkAnonymousRefused(opts));
|
|
685
|
+
run("the document carries no configuration value", () => checkNoSecrets(opts));
|
|
686
|
+
}
|
|
687
|
+
// src/conformance/refusal.ts
|
|
688
|
+
/**
|
|
689
|
+
* A refused request does no work. Every mutating route is called as an anonymous and as an
|
|
690
|
+
* unprivileged caller; each call must be refused (401/403) AND leave the host's state untouched.
|
|
691
|
+
*
|
|
692
|
+
* Why the state check and not the status alone: a gate that sends its 403 from an async hook
|
|
693
|
+
* without returning the reply still answers 403 when two or more async `preSerialization` /
|
|
694
|
+
* `onSend` hooks defer the write, yet Fastify goes on to run the handler. The status is right
|
|
695
|
+
* and the side effect already happened, so only a before/after comparison catches it.
|
|
696
|
+
*/
|
|
697
|
+
const MUTATING = /* @__PURE__ */ new Set([
|
|
698
|
+
"POST",
|
|
699
|
+
"PUT",
|
|
700
|
+
"PATCH",
|
|
701
|
+
"DELETE"
|
|
702
|
+
]);
|
|
703
|
+
async function derive(opts) {
|
|
704
|
+
const keys = [];
|
|
705
|
+
if (opts.routes !== void 0) return {
|
|
706
|
+
routes: [...await resolve(opts.routes)],
|
|
707
|
+
keys
|
|
708
|
+
};
|
|
709
|
+
if (opts.app === void 0) throw new Error("checkRefusalDoesNoWork needs `app` or `routes`");
|
|
710
|
+
const table = routeTable(await resolve(opts.app));
|
|
711
|
+
const value = opts.paramValue ?? "000000000000000000000001";
|
|
712
|
+
const routes = [];
|
|
713
|
+
for (const row of table) {
|
|
714
|
+
if (opts.prefix !== void 0 && !row.path.startsWith(opts.prefix)) continue;
|
|
715
|
+
for (const method of row.methods) {
|
|
716
|
+
if (IMPLICIT_METHODS.has(method) || !MUTATING.has(method)) continue;
|
|
717
|
+
const key = `${method} ${row.path}`;
|
|
718
|
+
keys.push(key);
|
|
719
|
+
routes.push({
|
|
720
|
+
method,
|
|
721
|
+
path: row.path.replace(/\{[^}]+\}/g, value).replace(/:[A-Za-z0-9_]+(?:\|:[A-Za-z0-9_]+)*/g, value).replace(/\*/g, "x")
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return {
|
|
726
|
+
routes,
|
|
727
|
+
keys
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
/** Problems found; empty when every refusal answered and left the snapshot unchanged. */
|
|
731
|
+
async function checkRefusalDoesNoWork(opts) {
|
|
732
|
+
const problems = [];
|
|
733
|
+
const callers = [];
|
|
734
|
+
if (opts.anonymous !== void 0) callers.push(["anonymous", await resolve(opts.anonymous)]);
|
|
735
|
+
if (opts.unprivileged !== void 0) callers.push(["unprivileged", await resolve(opts.unprivileged)]);
|
|
736
|
+
if (callers.length === 0) return ["checkRefusalDoesNoWork needs `anonymous` and/or `unprivileged`"];
|
|
737
|
+
const refused = opts.refusedStatuses ?? [401, 403];
|
|
738
|
+
const settle = opts.settleMs ?? 25;
|
|
739
|
+
const excluded = exceptionKeys(opts.exclude);
|
|
740
|
+
const { routes, keys } = await derive(opts);
|
|
741
|
+
const used = /* @__PURE__ */ new Set();
|
|
742
|
+
let checked = 0;
|
|
743
|
+
for (const [index, route] of routes.entries()) {
|
|
744
|
+
const method = route.method.toUpperCase();
|
|
745
|
+
const key = keys[index] ?? `${method} ${route.path}`;
|
|
746
|
+
if (excluded.includes(key) || excluded.includes(`${method} ${route.path}`)) {
|
|
747
|
+
used.add(key);
|
|
748
|
+
used.add(`${method} ${route.path}`);
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
checked++;
|
|
752
|
+
for (const [who, caller] of callers) {
|
|
753
|
+
const before = await opts.snapshot();
|
|
754
|
+
const body = method === "DELETE" ? void 0 : route.body ?? {};
|
|
755
|
+
const reply = await caller.request(method, route.path, body);
|
|
756
|
+
await new Promise((done) => setTimeout(done, settle));
|
|
757
|
+
const after = await opts.snapshot();
|
|
758
|
+
const label = `${who} ${method} ${route.path}`;
|
|
759
|
+
if (!refused.includes(reply.status)) problems.push(`${label}: answered ${reply.status}, expected a refusal (${refused.join("/")})`);
|
|
760
|
+
if (!isDeepStrictEqual(before, after)) problems.push(`${label}: answered ${reply.status} but the snapshot changed: the handler ran after the refusal (a gate must \`return reply\` after \`reply.send\`, or throw)`);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
if (checked < (opts.minRoutes ?? 1)) problems.push(`only ${checked} mutating route(s) checked, expected at least ${opts.minRoutes ?? 1}`);
|
|
764
|
+
for (const key of staleOf(excluded, used)) problems.push(`stale exclusion ${key}: no such mutating route is checked`);
|
|
765
|
+
return problems;
|
|
766
|
+
}
|
|
767
|
+
/** Registers the refusal guard as a vitest test. */
|
|
768
|
+
function registerRefusalDoesNoWorkGuard(opts) {
|
|
769
|
+
it("a refused request does no work (state is unchanged after every 401/403)", async () => {
|
|
770
|
+
const problems = await checkRefusalDoesNoWork(opts);
|
|
771
|
+
expect(problems, report("refusal violations", problems)).toEqual([]);
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
// src/conformance/route-schemas.ts
|
|
775
|
+
/**
|
|
776
|
+
* Request validation lives at the route layer. Read from the app's resource registry so a new route
|
|
777
|
+
* cannot slip past:
|
|
778
|
+
* - every entry of every `actions` map declares its schema (`actionSchemas`);
|
|
779
|
+
* - every POST / PUT / PATCH custom route declares a `body` schema, unless it takes no JSON body or
|
|
780
|
+
* a shared package mounts it, both listed with the reason.
|
|
781
|
+
* The exception lists are the only way out, and an entry that no longer matches anything fails too.
|
|
782
|
+
*/
|
|
783
|
+
async function checkRouteSchemas(opts) {
|
|
784
|
+
const app = await resolve(opts.app);
|
|
785
|
+
const actionOk = exceptionKeys(opts.actionExceptions);
|
|
786
|
+
const routeOk = exceptionKeys(opts.routeExceptions);
|
|
787
|
+
const methods = (opts.bodyMethods ?? [
|
|
788
|
+
"POST",
|
|
789
|
+
"PUT",
|
|
790
|
+
"PATCH"
|
|
791
|
+
]).map((m) => m.toUpperCase());
|
|
792
|
+
const problems = [];
|
|
793
|
+
const seenActions = /* @__PURE__ */ new Set();
|
|
794
|
+
const seenRoutes = /* @__PURE__ */ new Set();
|
|
795
|
+
let actions = 0;
|
|
796
|
+
let routes = 0;
|
|
797
|
+
for (const entry of registryEntries(app)) {
|
|
798
|
+
for (const action of entry.actions ?? []) {
|
|
799
|
+
actions++;
|
|
800
|
+
const key = `${entry.name}.${action.name}`;
|
|
801
|
+
const allowed = actionOk.includes(key);
|
|
802
|
+
if (allowed) seenActions.add(key);
|
|
803
|
+
if (!action.schema && !allowed) problems.push(`action ${key} declares no schema (add it to actionSchemas)`);
|
|
804
|
+
}
|
|
805
|
+
for (const route of entry.customRoutes ?? []) {
|
|
806
|
+
if (!methods.includes(route.method.toUpperCase())) continue;
|
|
807
|
+
routes++;
|
|
808
|
+
const key = `${entry.name} ${route.method.toUpperCase()} ${route.path}`;
|
|
809
|
+
const allowed = routeOk.includes(key);
|
|
810
|
+
if (allowed) seenRoutes.add(key);
|
|
811
|
+
if (!route.schema?.body && !allowed) problems.push(`route ${key} declares no body schema`);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
for (const key of staleOf(actionOk, seenActions)) problems.push(`stale action exception ${key}`);
|
|
815
|
+
for (const key of staleOf(routeOk, seenRoutes)) problems.push(`stale route exception ${key}`);
|
|
816
|
+
const min = opts.minActions ?? 1;
|
|
817
|
+
if (actions < min) problems.push(`only ${actions} actions found, expected at least ${min}`);
|
|
818
|
+
return {
|
|
819
|
+
problems,
|
|
820
|
+
actions,
|
|
821
|
+
routes
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
/** Registers the schema guard as vitest tests. */
|
|
825
|
+
function registerRouteSchemaGuard(opts) {
|
|
826
|
+
it("every action declares a schema and every write route a body schema", async () => {
|
|
827
|
+
const { problems } = await checkRouteSchemas(opts);
|
|
828
|
+
expect(problems, report("route-layer validation gaps", problems)).toEqual([]);
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
export { ERROR_CLASSES, OPENAPI_METHODS, bodyFieldProblems, checkAnonymousRefused, checkClientDrift, checkCollectionRoutes, checkErrorContracts, checkFilterDeclarations, checkListEnvelopes, checkNaming, checkNoSecrets, checkOpenApiShape, checkRefusalDoesNoWork, checkRouteSchemas, checkRoutesDocumented, checkSecurity, dumpOpenApi, errorContractProblems, expectClean, expectErrorContract, fetchOpenApi, injectCaller, listEnvelopeProblems, loadOpenApiDump, missingClientRoutes, propertyNames, registerClientDriftGuard, registerCollectionRoutesGuard, registerErrorContractGuard, registerFilterDeclarationsGuard, registerListEnvelopeGuard, registerNamingGuard, registerOpenApiGuard, registerRefusalDoesNoWorkGuard, registerRouteSchemaGuard, resolveRef, routeTable, shapeOf, specMatcher };
|