@classytic/arc-testkit 0.6.0 → 0.7.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 +26 -5
- package/dist/conformance.d.mts +129 -70
- package/dist/conformance.mjs +207 -61
- package/dist/index.d.mts +5 -6
- package/dist/mongo-commands.d.mts +6 -6
- package/dist/mongo-explain.d.mts +57 -5
- package/dist/mongo-explain.mjs +71 -1
- package/dist/mongo.d.mts +122 -10
- package/dist/mongo.mjs +295 -8
- package/dist/pool.d.mts +13 -8
- package/dist/pool.mjs +24 -21
- package/dist/process-cleanup-1iBIS_o_.mjs +66 -0
- package/dist/story.d.mts +38 -38
- package/llms.txt +2 -2
- package/package.json +6 -3
- package/skills/arc-testkit/SKILL.md +3 -4
- package/skills/arc-testkit/references/conformance.md +15 -14
- package/skills/arc-testkit/references/fixtures.md +12 -0
package/dist/conformance.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { isDeepStrictEqual } from "node:util";
|
|
|
8
8
|
* satisfies it), the `Caller` a suite sends requests through, the route table, and the exception
|
|
9
9
|
* lists every guard takes (an exception that no longer matches anything fails the guard).
|
|
10
10
|
*/
|
|
11
|
-
async function resolve(source) {
|
|
11
|
+
async function resolve$1(source) {
|
|
12
12
|
return typeof source === "function" ? await source() : source;
|
|
13
13
|
}
|
|
14
14
|
function registryEntries(app) {
|
|
@@ -145,17 +145,37 @@ function propertyNames(spec, schema, depth = 0) {
|
|
|
145
145
|
]) for (const name of propertyNames(spec, branch, depth + 1)) out.add(name);
|
|
146
146
|
return out;
|
|
147
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Every literal value a schema accepts: `enum` and `const` at its root, through refs and unions,
|
|
150
|
+
* and on its top-level properties (an action router's `action` discriminator).
|
|
151
|
+
*/
|
|
152
|
+
function schemaValues(spec, schema, depth = 0) {
|
|
153
|
+
const out = /* @__PURE__ */ new Set();
|
|
154
|
+
const resolved = resolveRef(spec, schema);
|
|
155
|
+
if (!resolved || depth > 6) return out;
|
|
156
|
+
for (const value of resolved.enum ?? []) out.add(value);
|
|
157
|
+
if (resolved.const !== void 0) out.add(resolved.const);
|
|
158
|
+
for (const branch of [
|
|
159
|
+
...resolved.oneOf ?? [],
|
|
160
|
+
...resolved.allOf ?? [],
|
|
161
|
+
...resolved.anyOf ?? []
|
|
162
|
+
]) for (const value of schemaValues(spec, branch, depth + 1)) out.add(value);
|
|
163
|
+
if (depth === 0) for (const prop of Object.values(resolved.properties ?? {})) for (const value of schemaValues(spec, prop, depth + 1)) out.add(value);
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
148
166
|
function bodyFieldProblems(spec, check) {
|
|
149
167
|
const label = check.name ?? `${check.method.toUpperCase()} ${check.path}`;
|
|
150
168
|
const op = spec.paths?.[check.path]?.[check.method.toLowerCase()];
|
|
151
169
|
if (!op) return [`${label}: not in the document`];
|
|
152
|
-
const
|
|
170
|
+
const schema = check.in === "response" ? op.responses?.[check.status ?? "200"]?.content?.["application/json"]?.schema : op.requestBody?.content?.["application/json"]?.schema;
|
|
171
|
+
const names = propertyNames(spec, schema);
|
|
153
172
|
if (names.size === 0) return [`${label}: the document declares no ${check.in ?? "request"} schema`];
|
|
154
|
-
|
|
173
|
+
const accepted = check.values?.length ? schemaValues(spec, schema) : /* @__PURE__ */ new Set();
|
|
174
|
+
return [...check.fields.filter((f) => !names.has(f)).map((f) => `${label}: field "${f}" is not in the schema`), ...(check.values ?? []).filter((v) => !accepted.has(v)).map((v) => `${label}: value ${JSON.stringify(v)} is not one the schema accepts`)];
|
|
155
175
|
}
|
|
156
176
|
async function checkClientDrift(opts) {
|
|
157
|
-
const spec = await resolve(opts.spec);
|
|
158
|
-
const routes = await resolve(opts.routes);
|
|
177
|
+
const spec = await resolve$1(opts.spec);
|
|
178
|
+
const routes = await resolve$1(opts.routes);
|
|
159
179
|
const problems = [];
|
|
160
180
|
const min = opts.minRoutes ?? 1;
|
|
161
181
|
if (routes.length < min) problems.push(`only ${routes.length} client routes supplied, expected at least ${min}`);
|
|
@@ -166,14 +186,14 @@ async function checkClientDrift(opts) {
|
|
|
166
186
|
/** Registers the drift guard as vitest tests. */
|
|
167
187
|
function registerClientDriftGuard(opts) {
|
|
168
188
|
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);
|
|
189
|
+
const spec = await resolve$1(opts.spec);
|
|
190
|
+
const routes = await resolve$1(opts.routes);
|
|
171
191
|
const problems = missingClientRoutes(spec, routes);
|
|
172
192
|
expect(problems, report("client routes missing from the document", problems)).toEqual([]);
|
|
173
193
|
expect(routes.length, "client routes supplied").toBeGreaterThanOrEqual(opts.minRoutes ?? 1);
|
|
174
194
|
});
|
|
175
195
|
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);
|
|
196
|
+
const spec = await resolve$1(opts.spec);
|
|
177
197
|
const problems = (opts.bodyFields ?? []).flatMap((c) => bodyFieldProblems(spec, c));
|
|
178
198
|
expect(problems, report("body field drift", problems)).toEqual([]);
|
|
179
199
|
});
|
|
@@ -223,7 +243,7 @@ function loadOpenApiDump(file, opts = {}) {
|
|
|
223
243
|
* form with the verbs the client sends, and serve it.
|
|
224
244
|
*/
|
|
225
245
|
async function checkCollectionRoutes(opts) {
|
|
226
|
-
const table = routeTable(await resolve(opts.app));
|
|
246
|
+
const table = routeTable(await resolve$1(opts.app));
|
|
227
247
|
const problems = [];
|
|
228
248
|
for (const { path, methods } of opts.collections) {
|
|
229
249
|
if (path.length > 1 && path.endsWith("/")) {
|
|
@@ -238,7 +258,7 @@ async function checkCollectionRoutes(opts) {
|
|
|
238
258
|
for (const method of methods) if (!row.methods.includes(method.toUpperCase())) problems.push(`${method.toUpperCase()} ${path} is not registered`);
|
|
239
259
|
}
|
|
240
260
|
if (opts.served) {
|
|
241
|
-
const caller = await resolve(opts.served.caller);
|
|
261
|
+
const caller = await resolve$1(opts.served.caller);
|
|
242
262
|
for (const path of opts.served.paths) {
|
|
243
263
|
const res = await caller.get(path);
|
|
244
264
|
if (res.status !== 200) problems.push(`GET ${path} answered ${res.status}`);
|
|
@@ -333,7 +353,7 @@ async function checkErrorContracts(opts) {
|
|
|
333
353
|
const problems = [];
|
|
334
354
|
const probes = { ...opts.probes };
|
|
335
355
|
if (probes.unknownRoute === void 0 && opts.caller !== void 0) {
|
|
336
|
-
const caller = await resolve(opts.caller);
|
|
356
|
+
const caller = await resolve$1(opts.caller);
|
|
337
357
|
probes.unknownRoute = { provoke: () => caller.get("/__no_such_route__?token=secret") };
|
|
338
358
|
}
|
|
339
359
|
for (const cls of opts.require ?? ERROR_CLASSES) if (probes[cls] === void 0) problems.push(`${cls}: no probe supplied`);
|
|
@@ -375,10 +395,10 @@ function wireOps(op) {
|
|
|
375
395
|
return op === "between" ? ["gte", "lte"] : [op];
|
|
376
396
|
}
|
|
377
397
|
async function checkFilterDeclarations(opts) {
|
|
378
|
-
const spec = await resolve(opts.spec);
|
|
398
|
+
const spec = await resolve$1(opts.spec);
|
|
379
399
|
const find = specMatcher(spec);
|
|
380
400
|
const problems = [];
|
|
381
|
-
for (const decl of await resolve(opts.declarations)) {
|
|
401
|
+
for (const decl of await resolve$1(opts.declarations)) {
|
|
382
402
|
const path = find("GET", decl.route);
|
|
383
403
|
const op = path ? spec.paths?.[path]?.get : void 0;
|
|
384
404
|
if (!op) {
|
|
@@ -454,8 +474,8 @@ function listEnvelopeProblems(body, methods = ["offset", "keyset"], forbidden =
|
|
|
454
474
|
return problems;
|
|
455
475
|
}
|
|
456
476
|
async function checkListEnvelopes(opts) {
|
|
457
|
-
const app = await resolve(opts.app);
|
|
458
|
-
const caller = await resolve(opts.caller);
|
|
477
|
+
const app = await resolve$1(opts.app);
|
|
478
|
+
const caller = await resolve$1(opts.caller);
|
|
459
479
|
const skip = opts.skip ?? DEFAULT_SKIP;
|
|
460
480
|
const notLists = exceptionKeys(opts.notLists);
|
|
461
481
|
const custom = exceptionKeys(opts.customShapes);
|
|
@@ -516,7 +536,7 @@ function registerListEnvelopeGuard(opts) {
|
|
|
516
536
|
* Verbs a package fixes belong in `exemptVerbs`; one that no longer exists fails as stale.
|
|
517
537
|
*/
|
|
518
538
|
async function checkNaming(opts) {
|
|
519
|
-
const app = await resolve(opts.app);
|
|
539
|
+
const app = await resolve$1(opts.app);
|
|
520
540
|
const pattern = opts.verbPattern ?? /^[a-z][a-zA-Z0-9]*$/;
|
|
521
541
|
const exempt = exceptionKeys(opts.exemptVerbs);
|
|
522
542
|
const problems = [];
|
|
@@ -593,7 +613,7 @@ function operationsOf(spec) {
|
|
|
593
613
|
}
|
|
594
614
|
/** The document is OpenAPI 3, prefixed, and has no trailing-slash path. */
|
|
595
615
|
async function checkOpenApiShape(opts) {
|
|
596
|
-
const spec = await resolve(opts.spec);
|
|
616
|
+
const spec = await resolve$1(opts.spec);
|
|
597
617
|
const problems = [];
|
|
598
618
|
if (!/^3\./.test(String(spec.openapi))) problems.push(`openapi is "${String(spec.openapi)}", expected 3.x`);
|
|
599
619
|
const ops = operationsOf(spec);
|
|
@@ -609,8 +629,8 @@ async function checkOpenApiShape(opts) {
|
|
|
609
629
|
/** Every route in the table is documented (once, in its bare form). */
|
|
610
630
|
async function checkRoutesDocumented(opts) {
|
|
611
631
|
if (opts.app === void 0) return ["checkRoutesDocumented needs `app`"];
|
|
612
|
-
const spec = await resolve(opts.spec);
|
|
613
|
-
const app = await resolve(opts.app);
|
|
632
|
+
const spec = await resolve$1(opts.spec);
|
|
633
|
+
const app = await resolve$1(opts.app);
|
|
614
634
|
const listed = /* @__PURE__ */ new Set();
|
|
615
635
|
for (const { method, path } of operationsOf(spec)) listed.add(`${method.toUpperCase()} ${shapeOf(path)}`);
|
|
616
636
|
const problems = [];
|
|
@@ -628,7 +648,7 @@ async function checkRoutesDocumented(opts) {
|
|
|
628
648
|
* public list; a public rule that matches no operation fails as stale.
|
|
629
649
|
*/
|
|
630
650
|
async function checkSecurity(opts) {
|
|
631
|
-
const spec = await resolve(opts.spec);
|
|
651
|
+
const spec = await resolve$1(opts.spec);
|
|
632
652
|
const rules = opts.publicOperations ?? [];
|
|
633
653
|
const used = /* @__PURE__ */ new Set();
|
|
634
654
|
const problems = [];
|
|
@@ -649,8 +669,8 @@ async function checkSecurity(opts) {
|
|
|
649
669
|
/** An anonymous GET of every documented, protected, parameterless path is refused with 401. */
|
|
650
670
|
async function checkAnonymousRefused(opts, expected = 401) {
|
|
651
671
|
if (opts.anonymous === void 0) return ["checkAnonymousRefused needs `anonymous`"];
|
|
652
|
-
const spec = await resolve(opts.spec);
|
|
653
|
-
const anon = await resolve(opts.anonymous);
|
|
672
|
+
const spec = await resolve$1(opts.spec);
|
|
673
|
+
const anon = await resolve$1(opts.anonymous);
|
|
654
674
|
const skip = opts.skipAnonymous ?? /\{|\/stream$/;
|
|
655
675
|
const rules = opts.publicOperations ?? [];
|
|
656
676
|
const problems = [];
|
|
@@ -665,9 +685,9 @@ async function checkAnonymousRefused(opts, expected = 401) {
|
|
|
665
685
|
}
|
|
666
686
|
/** The serialised document carries no configuration value. */
|
|
667
687
|
async function checkNoSecrets(opts) {
|
|
668
|
-
const text = JSON.stringify(await resolve(opts.spec));
|
|
688
|
+
const text = JSON.stringify(await resolve$1(opts.spec));
|
|
669
689
|
const problems = [];
|
|
670
|
-
const values = opts.secretValues === void 0 ? [] : await resolve(opts.secretValues);
|
|
690
|
+
const values = opts.secretValues === void 0 ? [] : await resolve$1(opts.secretValues);
|
|
671
691
|
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
692
|
for (const re of opts.secretPatterns ?? DEFAULT_SECRET_PATTERNS) if (re.test(text)) problems.push(`the document matches ${re}`);
|
|
673
693
|
return [...new Set(problems)];
|
|
@@ -693,6 +713,11 @@ function registerOpenApiGuard(opts) {
|
|
|
693
713
|
* without returning the reply still answers 403 when two or more async `preSerialization` /
|
|
694
714
|
* `onSend` hooks defer the write, yet Fastify goes on to run the handler. The status is right
|
|
695
715
|
* and the side effect already happened, so only a before/after comparison catches it.
|
|
716
|
+
*
|
|
717
|
+
* Action routes: arc validates `POST …/:id/action` against the discriminated body (`{ action, …}`)
|
|
718
|
+
* before the verb's permission runs, so a bare `{}` answers 400 and never reaches the gate. With
|
|
719
|
+
* `actionBodies`, each derived action route becomes one call per registered verb, with that verb's
|
|
720
|
+
* body.
|
|
696
721
|
*/
|
|
697
722
|
const MUTATING = /* @__PURE__ */ new Set([
|
|
698
723
|
"POST",
|
|
@@ -700,73 +725,194 @@ const MUTATING = /* @__PURE__ */ new Set([
|
|
|
700
725
|
"PATCH",
|
|
701
726
|
"DELETE"
|
|
702
727
|
]);
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
728
|
+
const CALLERS = ["anonymous", "unprivileged"];
|
|
729
|
+
const DEFAULT_NAME = "a refused request does no work (state is unchanged after every 401/403)";
|
|
730
|
+
function isCallerExceptions(value) {
|
|
731
|
+
if (typeof value[Symbol.iterator] === "function") return false;
|
|
732
|
+
const entries = Object.entries(value);
|
|
733
|
+
return entries.length > 0 && entries.every(([key, list]) => (key === "anonymous" || key === "unprivileged") && typeof list !== "string");
|
|
734
|
+
}
|
|
735
|
+
function concrete(path, value) {
|
|
736
|
+
return path.replace(/\{[^}]+\}/g, value).replace(/:[A-Za-z0-9_]+(?:\|:[A-Za-z0-9_]+)*/g, value).replace(/\*/g, "x");
|
|
737
|
+
}
|
|
738
|
+
/** The resource whose prefix the path shape carries at a segment boundary; the longest prefix wins. */
|
|
739
|
+
function resourceOf(shape, entries) {
|
|
740
|
+
let best;
|
|
741
|
+
for (const entry of entries) {
|
|
742
|
+
const prefix = entry.prefix === void 0 ? "" : shapeOf(entry.prefix);
|
|
743
|
+
if (prefix === "" || prefix === "/") continue;
|
|
744
|
+
for (let at = shape.indexOf(prefix); at !== -1; at = shape.indexOf(prefix, at + 1)) {
|
|
745
|
+
const rest = shape.slice(at + prefix.length);
|
|
746
|
+
if (rest !== "" && !rest.startsWith("/")) continue;
|
|
747
|
+
if (best === void 0 || prefix.length > best.length) best = {
|
|
748
|
+
entry,
|
|
749
|
+
rest,
|
|
750
|
+
length: prefix.length
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return best && {
|
|
755
|
+
entry: best.entry,
|
|
756
|
+
rest: best.rest
|
|
708
757
|
};
|
|
758
|
+
}
|
|
759
|
+
async function derive(opts) {
|
|
760
|
+
if (opts.routes !== void 0) return (await resolve$1(opts.routes)).map((route) => {
|
|
761
|
+
const method = route.method.toUpperCase();
|
|
762
|
+
return {
|
|
763
|
+
method,
|
|
764
|
+
path: route.path,
|
|
765
|
+
keys: [`${method} ${route.path}`],
|
|
766
|
+
body: route.body
|
|
767
|
+
};
|
|
768
|
+
});
|
|
709
769
|
if (opts.app === void 0) throw new Error("checkRefusalDoesNoWork needs `app` or `routes`");
|
|
710
|
-
const
|
|
711
|
-
const
|
|
712
|
-
const
|
|
770
|
+
const app = await resolve$1(opts.app);
|
|
771
|
+
const table = routeTable(app);
|
|
772
|
+
const withActions = opts.actionBodies !== void 0;
|
|
773
|
+
const needsRegistry = withActions || typeof opts.paramValue === "function";
|
|
774
|
+
let entries = [];
|
|
775
|
+
if (withActions) entries = registryEntries(app);
|
|
776
|
+
else if (needsRegistry) try {
|
|
777
|
+
entries = registryEntries(app);
|
|
778
|
+
} catch {}
|
|
779
|
+
const param = async (method, path, resource) => typeof opts.paramValue === "function" ? await opts.paramValue({
|
|
780
|
+
method,
|
|
781
|
+
path,
|
|
782
|
+
resource
|
|
783
|
+
}) : opts.paramValue ?? "000000000000000000000001";
|
|
784
|
+
const planned = [];
|
|
713
785
|
for (const row of table) {
|
|
714
786
|
if (opts.prefix !== void 0 && !row.path.startsWith(opts.prefix)) continue;
|
|
715
787
|
for (const method of row.methods) {
|
|
716
788
|
if (IMPLICIT_METHODS.has(method) || !MUTATING.has(method)) continue;
|
|
717
789
|
const key = `${method} ${row.path}`;
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
790
|
+
const owner = needsRegistry ? resourceOf(shapeOf(row.path), entries) : void 0;
|
|
791
|
+
const path = concrete(row.path, await param(method, row.path, owner?.entry.name));
|
|
792
|
+
const keys = key === `${method} ${path}` ? [key] : [key, `${method} ${path}`];
|
|
793
|
+
const mount = owner?.rest === "/:x/action" ? "id" : owner?.rest === "/action" ? "root" : void 0;
|
|
794
|
+
if (!withActions || method !== "POST" || owner === void 0 || mount === void 0) {
|
|
795
|
+
planned.push({
|
|
796
|
+
method,
|
|
797
|
+
path,
|
|
798
|
+
keys
|
|
799
|
+
});
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
const verbs = (owner.entry.actions ?? []).filter((a) => a.id !== false === (mount === "id"));
|
|
803
|
+
if (verbs.length === 0) {
|
|
804
|
+
planned.push({
|
|
805
|
+
method,
|
|
806
|
+
path,
|
|
807
|
+
keys
|
|
808
|
+
});
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
for (const action of verbs) {
|
|
812
|
+
const verbKey = `${owner.entry.name}.${action.name}`;
|
|
813
|
+
const source = opts.actionBodies?.[verbKey];
|
|
814
|
+
planned.push({
|
|
815
|
+
method,
|
|
816
|
+
path,
|
|
817
|
+
keys: [...keys, verbKey],
|
|
818
|
+
verb: {
|
|
819
|
+
resource: owner.entry.name,
|
|
820
|
+
action: action.name,
|
|
821
|
+
key: verbKey,
|
|
822
|
+
...source === void 0 ? {} : { source }
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
}
|
|
723
826
|
}
|
|
724
827
|
}
|
|
725
|
-
return
|
|
726
|
-
routes,
|
|
727
|
-
keys
|
|
728
|
-
};
|
|
828
|
+
return planned;
|
|
729
829
|
}
|
|
730
830
|
/** Problems found; empty when every refusal answered and left the snapshot unchanged. */
|
|
731
831
|
async function checkRefusalDoesNoWork(opts) {
|
|
732
832
|
const problems = [];
|
|
733
833
|
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)]);
|
|
834
|
+
if (opts.anonymous !== void 0) callers.push(["anonymous", await resolve$1(opts.anonymous)]);
|
|
835
|
+
if (opts.unprivileged !== void 0) callers.push(["unprivileged", await resolve$1(opts.unprivileged)]);
|
|
736
836
|
if (callers.length === 0) return ["checkRefusalDoesNoWork needs `anonymous` and/or `unprivileged`"];
|
|
737
837
|
const refused = opts.refusedStatuses ?? [401, 403];
|
|
738
838
|
const settle = opts.settleMs ?? 25;
|
|
739
|
-
const
|
|
740
|
-
const
|
|
741
|
-
const
|
|
839
|
+
const perCaller = opts.exclude !== void 0 && isCallerExceptions(opts.exclude);
|
|
840
|
+
const shared = perCaller ? [] : exceptionKeys(opts.exclude);
|
|
841
|
+
const own = {
|
|
842
|
+
anonymous: [],
|
|
843
|
+
unprivileged: []
|
|
844
|
+
};
|
|
845
|
+
if (perCaller) {
|
|
846
|
+
const lists = opts.exclude;
|
|
847
|
+
for (const who of CALLERS) own[who] = exceptionKeys(lists[who]);
|
|
848
|
+
}
|
|
849
|
+
const planned = await derive(opts);
|
|
850
|
+
const usedShared = /* @__PURE__ */ new Set();
|
|
851
|
+
const usedOwn = {
|
|
852
|
+
anonymous: /* @__PURE__ */ new Set(),
|
|
853
|
+
unprivileged: /* @__PURE__ */ new Set()
|
|
854
|
+
};
|
|
855
|
+
const usedBodies = /* @__PURE__ */ new Set();
|
|
742
856
|
let checked = 0;
|
|
743
|
-
for (const
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
if (excluded.includes(key) || excluded.includes(`${method} ${route.path}`)) {
|
|
747
|
-
used.add(key);
|
|
748
|
-
used.add(`${method} ${route.path}`);
|
|
857
|
+
for (const call of planned) {
|
|
858
|
+
if (call.keys.some((k) => shared.includes(k))) {
|
|
859
|
+
for (const k of call.keys) usedShared.add(k);
|
|
749
860
|
continue;
|
|
750
861
|
}
|
|
751
|
-
|
|
862
|
+
let ran = false;
|
|
863
|
+
let reportedMissing = false;
|
|
752
864
|
for (const [who, caller] of callers) {
|
|
865
|
+
if (call.keys.some((k) => own[who].includes(k))) {
|
|
866
|
+
for (const k of call.keys) usedOwn[who].add(k);
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
const label = `${who} ${call.method} ${call.path}${call.verb ? ` (${call.verb.key})` : ""}`;
|
|
870
|
+
let body = call.method === "DELETE" ? void 0 : call.body ?? {};
|
|
871
|
+
if (call.verb !== void 0) {
|
|
872
|
+
const source = call.verb.source;
|
|
873
|
+
if (source === void 0) {
|
|
874
|
+
if (!reportedMissing) problems.push(`no body for action ${call.verb.key} (${call.method} ${call.path}): add it to \`actionBodies\` or exclude it`);
|
|
875
|
+
reportedMissing = true;
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
usedBodies.add(call.verb.key);
|
|
879
|
+
body = {
|
|
880
|
+
...typeof source === "function" ? await source({
|
|
881
|
+
resource: call.verb.resource,
|
|
882
|
+
action: call.verb.action,
|
|
883
|
+
caller: who,
|
|
884
|
+
path: call.path
|
|
885
|
+
}) : source,
|
|
886
|
+
action: call.verb.action
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
ran = true;
|
|
753
890
|
const before = await opts.snapshot();
|
|
754
|
-
const
|
|
755
|
-
const reply = await caller.request(method, route.path, body);
|
|
891
|
+
const reply = await caller.request(call.method, call.path, body);
|
|
756
892
|
await new Promise((done) => setTimeout(done, settle));
|
|
757
893
|
const after = await opts.snapshot();
|
|
758
|
-
const label = `${who} ${method} ${route.path}`;
|
|
759
894
|
if (!refused.includes(reply.status)) problems.push(`${label}: answered ${reply.status}, expected a refusal (${refused.join("/")})`);
|
|
760
895
|
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
896
|
}
|
|
897
|
+
if (ran) checked++;
|
|
762
898
|
}
|
|
763
899
|
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(
|
|
900
|
+
for (const key of staleOf(shared, usedShared)) problems.push(`stale exclusion ${key}: no such mutating route is checked`);
|
|
901
|
+
const given = new Set(callers.map(([who]) => who));
|
|
902
|
+
for (const who of CALLERS) {
|
|
903
|
+
if (own[who].length === 0) continue;
|
|
904
|
+
if (!given.has(who)) {
|
|
905
|
+
problems.push(`exclude.${who} is given but no ${who} caller is`);
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
for (const key of staleOf(own[who], usedOwn[who])) problems.push(`stale exclusion (${who}) ${key}: no such mutating route is checked`);
|
|
909
|
+
}
|
|
910
|
+
for (const key of Object.keys(opts.actionBodies ?? {})) if (!usedBodies.has(key)) problems.push(`stale action body ${key}: no such action verb is checked`);
|
|
765
911
|
return problems;
|
|
766
912
|
}
|
|
767
|
-
/** Registers the refusal guard as a vitest test. */
|
|
913
|
+
/** Registers the refusal guard as a vitest test (title: `name`, or the default). */
|
|
768
914
|
function registerRefusalDoesNoWorkGuard(opts) {
|
|
769
|
-
it(
|
|
915
|
+
it(opts.name ?? DEFAULT_NAME, async () => {
|
|
770
916
|
const problems = await checkRefusalDoesNoWork(opts);
|
|
771
917
|
expect(problems, report("refusal violations", problems)).toEqual([]);
|
|
772
918
|
});
|
|
@@ -781,7 +927,7 @@ function registerRefusalDoesNoWorkGuard(opts) {
|
|
|
781
927
|
* The exception lists are the only way out, and an entry that no longer matches anything fails too.
|
|
782
928
|
*/
|
|
783
929
|
async function checkRouteSchemas(opts) {
|
|
784
|
-
const app = await resolve(opts.app);
|
|
930
|
+
const app = await resolve$1(opts.app);
|
|
785
931
|
const actionOk = exceptionKeys(opts.actionExceptions);
|
|
786
932
|
const routeOk = exceptionKeys(opts.routeExceptions);
|
|
787
933
|
const methods = (opts.bodyMethods ?? [
|
|
@@ -828,4 +974,4 @@ function registerRouteSchemaGuard(opts) {
|
|
|
828
974
|
expect(problems, report("route-layer validation gaps", problems)).toEqual([]);
|
|
829
975
|
});
|
|
830
976
|
}
|
|
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 };
|
|
977
|
+
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, schemaValues, shapeOf, specMatcher };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { BootModuleAppOptions as BootModuleAppOptions$1, BootModulesInput, ModuleTestApp as ModuleTestApp$1, TestkitContext as TestkitContext$1 } from "@classytic/arc/testing";
|
|
2
2
|
import { Connection } from "mongoose";
|
|
3
|
-
type TestkitContext = TestkitContext$1<Connection>;
|
|
4
|
-
type ModulesInput = BootModulesInput<Connection>;
|
|
5
|
-
type BootModuleAppOptions = BootModuleAppOptions$1<Connection>;
|
|
6
|
-
type ModuleTestApp = ModuleTestApp$1<Connection>;
|
|
7
|
-
declare function bootModuleApp(modules: ModulesInput, options?: BootModuleAppOptions): Promise<ModuleTestApp>;
|
|
8
|
-
export { BootModuleAppOptions, ModuleTestApp, ModulesInput, TestkitContext, bootModuleApp };
|
|
3
|
+
export type TestkitContext = TestkitContext$1<Connection>;
|
|
4
|
+
export type ModulesInput = BootModulesInput<Connection>;
|
|
5
|
+
export type BootModuleAppOptions = BootModuleAppOptions$1<Connection>;
|
|
6
|
+
export type ModuleTestApp = ModuleTestApp$1<Connection>;
|
|
7
|
+
export declare function bootModuleApp(modules: ModulesInput, options?: BootModuleAppOptions): Promise<ModuleTestApp>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Connection } from "mongoose";
|
|
2
2
|
type MongoClientLike = ReturnType<Connection["getClient"]>;
|
|
3
|
-
interface CountCommandsOptions {
|
|
3
|
+
export interface CountCommandsOptions {
|
|
4
4
|
/** Count only commands against these collections. */
|
|
5
5
|
readonly collections?: readonly string[];
|
|
6
6
|
/** Count only these command names (`find`, `aggregate`, `insert`, …). */
|
|
@@ -8,12 +8,12 @@ interface CountCommandsOptions {
|
|
|
8
8
|
/** Never count these command names — e.g. `createIndexes` a model may run lazily. */
|
|
9
9
|
readonly ignore?: readonly string[];
|
|
10
10
|
}
|
|
11
|
-
interface WireCommand {
|
|
11
|
+
export interface WireCommand {
|
|
12
12
|
readonly command: string;
|
|
13
13
|
/** The collection the command targets; `undefined` for session, transaction and admin commands. */
|
|
14
14
|
readonly collection: string | undefined;
|
|
15
15
|
}
|
|
16
|
-
interface CommandCount<T> {
|
|
16
|
+
export interface CommandCount<T> {
|
|
17
17
|
readonly result: T;
|
|
18
18
|
readonly total: number;
|
|
19
19
|
/** Counted commands, in the order the driver sent them. */
|
|
@@ -22,6 +22,6 @@ interface CommandCount<T> {
|
|
|
22
22
|
readonly byCollection: Readonly<Record<string, number>>;
|
|
23
23
|
readonly byCommand: Readonly<Record<string, number>>;
|
|
24
24
|
}
|
|
25
|
-
declare function countCommands<T>(source: Connection | MongoClientLike, fn: () => Promise<T>, options?: CountCommandsOptions): Promise<CommandCount<T>>;
|
|
26
|
-
declare function assertConstantCommands<T>(source: Connection | MongoClientLike, run: (size: number) => Promise<T>, sizes: readonly number[], options?: CountCommandsOptions): Promise<readonly CommandCount<T>[]>;
|
|
27
|
-
export {
|
|
25
|
+
export declare function countCommands<T>(source: Connection | MongoClientLike, fn: () => Promise<T>, options?: CountCommandsOptions): Promise<CommandCount<T>>;
|
|
26
|
+
export declare function assertConstantCommands<T>(source: Connection | MongoClientLike, run: (size: number) => Promise<T>, sizes: readonly number[], options?: CountCommandsOptions): Promise<readonly CommandCount<T>[]>;
|
|
27
|
+
export {};
|
package/dist/mongo-explain.d.mts
CHANGED
|
@@ -8,8 +8,17 @@
|
|
|
8
8
|
* const explain = await Model.find({ organizationId, state: "posted" }).explain("executionStats");
|
|
9
9
|
* assertIndexServed(explain, { index: "org_state_date", maxDocsExamined: 40 });
|
|
10
10
|
* ```
|
|
11
|
+
*
|
|
12
|
+
* `explainCaptured` explains the query a piece of code ACTUALLY sends (a repository method, a
|
|
13
|
+
* plugin-built filter) instead of a copy of it: it runs the code with command monitoring, captures
|
|
14
|
+
* the matching command and explains exactly that.
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* const { explain } = await explainCaptured(conn, () => timers.claim(now), { collection: "timers", commands: ["findAndModify"] });
|
|
18
|
+
* assertIndexServed(explain, { index: "state_due", maxDocsExamined: 6 });
|
|
19
|
+
* ```
|
|
11
20
|
*/
|
|
12
|
-
interface ExplainSummary {
|
|
21
|
+
export interface ExplainSummary {
|
|
13
22
|
/** Stage names of the winning plan(s), outermost first. */
|
|
14
23
|
readonly stages: readonly string[];
|
|
15
24
|
/** Index names the winning plan scans (`_id_` for an id lookup). */
|
|
@@ -19,7 +28,7 @@ interface ExplainSummary {
|
|
|
19
28
|
readonly keysExamined: number | undefined;
|
|
20
29
|
readonly returned: number | undefined;
|
|
21
30
|
}
|
|
22
|
-
interface IndexServedOptions {
|
|
31
|
+
export interface IndexServedOptions {
|
|
23
32
|
/** Every one of these must be scanned. */
|
|
24
33
|
readonly index?: string | readonly string[];
|
|
25
34
|
/** Every one of these stages must appear — e.g. `OR` for a `$or` served arm by arm. */
|
|
@@ -27,6 +36,49 @@ interface IndexServedOptions {
|
|
|
27
36
|
/** Ceiling on `totalDocsExamined`; requires `executionStats` verbosity. */
|
|
28
37
|
readonly maxDocsExamined?: number;
|
|
29
38
|
}
|
|
30
|
-
declare function summarizeExplain(explain: unknown): ExplainSummary;
|
|
31
|
-
declare function assertIndexServed(explain: unknown, options?: IndexServedOptions): ExplainSummary;
|
|
32
|
-
|
|
39
|
+
export declare function summarizeExplain(explain: unknown): ExplainSummary;
|
|
40
|
+
export declare function assertIndexServed(explain: unknown, options?: IndexServedOptions): ExplainSummary;
|
|
41
|
+
/** A `commandStarted` event, as far as `explainCaptured` reads it. */
|
|
42
|
+
export interface CommandStartedLike {
|
|
43
|
+
readonly commandName: string;
|
|
44
|
+
readonly databaseName: string;
|
|
45
|
+
readonly command: Readonly<Record<string, unknown>>;
|
|
46
|
+
}
|
|
47
|
+
/** A MongoDB driver client (`connection.getClient()` of mongoose, or a `MongoClient`). */
|
|
48
|
+
export interface MonitoredClientLike {
|
|
49
|
+
readonly options: {
|
|
50
|
+
readonly monitorCommands?: boolean;
|
|
51
|
+
};
|
|
52
|
+
on(event: "commandStarted", listener: (event: CommandStartedLike) => void): unknown;
|
|
53
|
+
off(event: "commandStarted", listener: (event: CommandStartedLike) => void): unknown;
|
|
54
|
+
db(name: string): {
|
|
55
|
+
command(command: Record<string, unknown>): Promise<unknown>;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export interface ExplainCapturedOptions {
|
|
59
|
+
/** The collection the command targets. */
|
|
60
|
+
readonly collection: string;
|
|
61
|
+
/** Command names to capture. Default: `find`, `aggregate`, `findAndModify`, `count`, `distinct`, `update`, `delete`. */
|
|
62
|
+
readonly commands?: readonly string[];
|
|
63
|
+
/** Which captured command to explain (0-based). Required when more than one matches. */
|
|
64
|
+
readonly index?: number;
|
|
65
|
+
/** Default: `executionStats` (what `maxDocsExamined` needs). */
|
|
66
|
+
readonly verbosity?: "queryPlanner" | "executionStats" | "allPlansExecution";
|
|
67
|
+
}
|
|
68
|
+
export interface ExplainCaptured<T> {
|
|
69
|
+
readonly result: T;
|
|
70
|
+
/** The explain of the chosen command, ready for `assertIndexServed`. */
|
|
71
|
+
readonly explain: unknown;
|
|
72
|
+
/** The chosen command as the driver sent it, minus session and transport fields. */
|
|
73
|
+
readonly command: Readonly<Record<string, unknown>>;
|
|
74
|
+
/** Every matching command, in the order sent (same fields removed). */
|
|
75
|
+
readonly captured: readonly Readonly<Record<string, unknown>>[];
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Runs `run` with command monitoring, captures the commands it sends to `collection` and explains
|
|
79
|
+
* the chosen one (the explain never applies a write). Refuses a client without
|
|
80
|
+
* `monitorCommands: true`, a run that sent no matching command, and an ambiguous capture.
|
|
81
|
+
*/
|
|
82
|
+
export declare function explainCaptured<T>(source: {
|
|
83
|
+
getClient(): MonitoredClientLike;
|
|
84
|
+
} | MonitoredClientLike, run: () => Promise<T>, options: ExplainCapturedOptions): Promise<ExplainCaptured<T>>;
|
package/dist/mongo-explain.mjs
CHANGED
|
@@ -57,10 +57,80 @@ function assertIndexServed(explain, options = {}) {
|
|
|
57
57
|
}
|
|
58
58
|
return plan;
|
|
59
59
|
}
|
|
60
|
+
const EXPLAINABLE = [
|
|
61
|
+
"find",
|
|
62
|
+
"aggregate",
|
|
63
|
+
"findAndModify",
|
|
64
|
+
"count",
|
|
65
|
+
"distinct",
|
|
66
|
+
"update",
|
|
67
|
+
"delete"
|
|
68
|
+
];
|
|
69
|
+
/** Fields the driver adds to a command that an `explain` wrapper must not carry. */
|
|
70
|
+
const TRANSPORT = /* @__PURE__ */ new Set([
|
|
71
|
+
"lsid",
|
|
72
|
+
"$db",
|
|
73
|
+
"$clusterTime",
|
|
74
|
+
"$readPreference",
|
|
75
|
+
"txnNumber",
|
|
76
|
+
"autocommit",
|
|
77
|
+
"startTransaction",
|
|
78
|
+
"readConcern",
|
|
79
|
+
"writeConcern",
|
|
80
|
+
"apiVersion",
|
|
81
|
+
"apiStrict",
|
|
82
|
+
"apiDeprecationErrors"
|
|
83
|
+
]);
|
|
84
|
+
/**
|
|
85
|
+
* Runs `run` with command monitoring, captures the commands it sends to `collection` and explains
|
|
86
|
+
* the chosen one (the explain never applies a write). Refuses a client without
|
|
87
|
+
* `monitorCommands: true`, a run that sent no matching command, and an ambiguous capture.
|
|
88
|
+
*/
|
|
89
|
+
async function explainCaptured(source, run, options) {
|
|
90
|
+
const client = "getClient" in source ? source.getClient() : source;
|
|
91
|
+
const fail = (msg) => {
|
|
92
|
+
throw new Error(`[arc-testkit] explainCaptured: ${msg}`);
|
|
93
|
+
};
|
|
94
|
+
if (client.options.monitorCommands !== true) fail("the client was created without `monitorCommands: true` — the driver emits no command events, so nothing can be captured");
|
|
95
|
+
const names = options.commands ?? EXPLAINABLE;
|
|
96
|
+
const seen = [];
|
|
97
|
+
const onStarted = (e) => {
|
|
98
|
+
if (!names.includes(e.commandName) || e.command[e.commandName] !== options.collection) return;
|
|
99
|
+
const command = {};
|
|
100
|
+
for (const [key, value] of Object.entries(e.command)) if (!TRANSPORT.has(key)) command[key] = value;
|
|
101
|
+
seen.push({
|
|
102
|
+
db: e.databaseName,
|
|
103
|
+
command
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
client.on("commandStarted", onStarted);
|
|
107
|
+
let result;
|
|
108
|
+
try {
|
|
109
|
+
result = await run();
|
|
110
|
+
} finally {
|
|
111
|
+
client.off("commandStarted", onStarted);
|
|
112
|
+
}
|
|
113
|
+
const what = `${names.join("/")} on "${options.collection}"`;
|
|
114
|
+
if (seen.length === 0) fail(`run sent no ${what}`);
|
|
115
|
+
if (options.index === void 0 && seen.length > 1) fail(`run sent ${seen.length} ${what} (${seen.map((s) => Object.keys(s.command)[0]).join(", ")}): pass \`index\` to pick one`);
|
|
116
|
+
const chosen = seen[options.index ?? 0];
|
|
117
|
+
if (chosen === void 0) fail(`no captured command at index ${options.index} (${seen.length} captured)`);
|
|
118
|
+
const { db, command } = chosen;
|
|
119
|
+
const explain = await client.db(db).command({
|
|
120
|
+
explain: command,
|
|
121
|
+
verbosity: options.verbosity ?? "executionStats"
|
|
122
|
+
});
|
|
123
|
+
return {
|
|
124
|
+
result,
|
|
125
|
+
explain,
|
|
126
|
+
command,
|
|
127
|
+
captured: seen.map((s) => s.command)
|
|
128
|
+
};
|
|
129
|
+
}
|
|
60
130
|
function isRecord(value) {
|
|
61
131
|
return typeof value === "object" && value !== null;
|
|
62
132
|
}
|
|
63
133
|
function numberOr(value) {
|
|
64
134
|
return typeof value === "number" ? value : void 0;
|
|
65
135
|
}
|
|
66
|
-
export { assertIndexServed, summarizeExplain };
|
|
136
|
+
export { assertIndexServed, explainCaptured, summarizeExplain };
|