@lovrozagar/oat 0.10.0 → 0.11.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 +25 -2006
- package/dist/cli.js +283 -59
- package/dist/config/define-config.d.ts +8 -0
- package/dist/config/define-config.d.ts.map +1 -1
- package/dist/config/define-config.js.map +1 -1
- package/dist/config/validate.d.ts +12 -0
- package/dist/config/validate.d.ts.map +1 -0
- package/dist/config/validate.js +86 -0
- package/dist/config/validate.js.map +1 -0
- package/dist/conformance/effects.d.ts +9 -0
- package/dist/conformance/effects.d.ts.map +1 -0
- package/dist/conformance/effects.js +276 -0
- package/dist/conformance/effects.js.map +1 -0
- package/dist/conformance/expected-failures.d.ts +18 -0
- package/dist/conformance/expected-failures.d.ts.map +1 -0
- package/dist/conformance/expected-failures.js +48 -0
- package/dist/conformance/expected-failures.js.map +1 -0
- package/dist/conformance/feature-gate.d.ts +7 -0
- package/dist/conformance/feature-gate.d.ts.map +1 -0
- package/dist/conformance/feature-gate.js +428 -0
- package/dist/conformance/feature-gate.js.map +1 -0
- package/dist/conformance/fuzz.d.ts +108 -0
- package/dist/conformance/fuzz.d.ts.map +1 -0
- package/dist/conformance/fuzz.js +246 -0
- package/dist/conformance/fuzz.js.map +1 -0
- package/dist/conformance/leaks.d.ts +10 -0
- package/dist/conformance/leaks.d.ts.map +1 -0
- package/dist/conformance/leaks.js +23 -0
- package/dist/conformance/leaks.js.map +1 -0
- package/dist/conformance/pool.d.ts +49 -0
- package/dist/conformance/pool.d.ts.map +1 -0
- package/dist/conformance/pool.js +75 -0
- package/dist/conformance/pool.js.map +1 -0
- package/dist/conformance/rate-limit.d.ts +7 -0
- package/dist/conformance/rate-limit.d.ts.map +1 -0
- package/dist/conformance/rate-limit.js +372 -0
- package/dist/conformance/rate-limit.js.map +1 -0
- package/dist/conformance/scope.d.ts +15 -0
- package/dist/conformance/scope.d.ts.map +1 -0
- package/dist/conformance/scope.js +78 -0
- package/dist/conformance/scope.js.map +1 -0
- package/dist/conformance/seed-contract.d.ts +11 -0
- package/dist/conformance/seed-contract.d.ts.map +1 -0
- package/dist/conformance/seed-contract.js +680 -0
- package/dist/conformance/seed-contract.js.map +1 -0
- package/dist/conformance/shapes.d.ts +35 -0
- package/dist/conformance/shapes.d.ts.map +1 -0
- package/dist/conformance/shapes.js +162 -0
- package/dist/conformance/shapes.js.map +1 -0
- package/dist/conformance/specs.d.ts +21 -0
- package/dist/conformance/specs.d.ts.map +1 -0
- package/dist/conformance/specs.js +306 -0
- package/dist/conformance/specs.js.map +1 -0
- package/dist/conformance/suite.d.ts +209 -0
- package/dist/conformance/suite.d.ts.map +1 -0
- package/dist/conformance/suite.js +1538 -0
- package/dist/conformance/suite.js.map +1 -0
- package/dist/conformance/unique.d.ts +7 -0
- package/dist/conformance/unique.d.ts.map +1 -0
- package/dist/conformance/unique.js +586 -0
- package/dist/conformance/unique.js.map +1 -0
- package/dist/conformance/worker.d.ts +3 -0
- package/dist/conformance/worker.d.ts.map +1 -0
- package/dist/conformance/worker.js +27 -0
- package/dist/conformance/worker.js.map +1 -0
- package/dist/index.d.ts +8 -3
- package/dist/index.js +5 -2
- package/dist/reference/defects.d.ts +71 -1
- package/dist/reference/defects.d.ts.map +1 -1
- package/dist/reference/defects.js +74 -8
- package/dist/reference/defects.js.map +1 -1
- package/dist/reference/dialect.d.ts +6 -4
- package/dist/reference/dialect.d.ts.map +1 -1
- package/dist/reference/dialect.js +1 -0
- package/dist/reference/dialect.js.map +1 -1
- package/dist/reference/http.d.ts +17 -25
- package/dist/reference/http.d.ts.map +1 -1
- package/dist/reference/http.js +574 -224
- package/dist/reference/http.js.map +1 -1
- package/dist/reference/listing.d.ts +51 -0
- package/dist/reference/listing.d.ts.map +1 -0
- package/dist/reference/listing.js +256 -0
- package/dist/reference/listing.js.map +1 -0
- package/dist/reference/model.d.ts +35 -1
- package/dist/reference/model.d.ts.map +1 -1
- package/dist/reference/model.js +42 -0
- package/dist/reference/model.js.map +1 -1
- package/dist/reference/query.d.ts +132 -43
- package/dist/reference/query.d.ts.map +1 -1
- package/dist/reference/query.js +397 -315
- package/dist/reference/query.js.map +1 -1
- package/dist/reference/shapes.d.ts +60 -0
- package/dist/reference/shapes.d.ts.map +1 -0
- package/dist/reference/shapes.js +142 -0
- package/dist/reference/shapes.js.map +1 -0
- package/dist/reference/spec.d.ts +9 -2
- package/dist/reference/spec.d.ts.map +1 -1
- package/dist/reference/spec.js +291 -71
- package/dist/reference/spec.js.map +1 -1
- package/dist/reference/store-api.d.ts +14 -43
- package/dist/reference/store-api.d.ts.map +1 -1
- package/dist/reference/store-api.js +8 -58
- package/dist/reference/store-api.js.map +1 -1
- package/dist/reference/stores/memory.d.ts +12 -15
- package/dist/reference/stores/memory.d.ts.map +1 -1
- package/dist/reference/stores/memory.js +22 -70
- package/dist/reference/stores/memory.js.map +1 -1
- package/dist/reference/stores/postgres.d.ts +11 -23
- package/dist/reference/stores/postgres.d.ts.map +1 -1
- package/dist/reference/stores/postgres.js +53 -334
- package/dist/reference/stores/postgres.js.map +1 -1
- package/dist/reference/stores/sql-compile.d.ts +36 -0
- package/dist/reference/stores/sql-compile.d.ts.map +1 -0
- package/dist/reference/stores/sql-compile.js +95 -0
- package/dist/reference/stores/sql-compile.js.map +1 -0
- package/dist/reference/stores/sqlite.d.ts +12 -27
- package/dist/reference/stores/sqlite.d.ts.map +1 -1
- package/dist/reference/stores/sqlite.js +54 -355
- package/dist/reference/stores/sqlite.js.map +1 -1
- package/dist/report/console.d.ts.map +1 -1
- package/dist/report/console.js +23 -9
- package/dist/report/console.js.map +1 -1
- package/dist/report/matrix.d.ts +2 -0
- package/dist/report/matrix.d.ts.map +1 -1
- package/dist/report/matrix.js +63 -10
- package/dist/report/matrix.js.map +1 -1
- package/dist/report/render.d.ts +10 -1
- package/dist/report/render.d.ts.map +1 -1
- package/dist/report/render.js +75 -13
- package/dist/report/render.js.map +1 -1
- package/dist/runtime/async.d.ts +16 -3
- package/dist/runtime/async.d.ts.map +1 -1
- package/dist/runtime/async.js +24 -19
- package/dist/runtime/async.js.map +1 -1
- package/dist/runtime/auth.d.ts +2 -9
- package/dist/runtime/auth.d.ts.map +1 -1
- package/dist/runtime/auth.js +38 -62
- package/dist/runtime/auth.js.map +1 -1
- package/dist/runtime/body.d.ts +15 -3
- package/dist/runtime/body.d.ts.map +1 -1
- package/dist/runtime/body.js +61 -12
- package/dist/runtime/body.js.map +1 -1
- package/dist/runtime/check-costs.d.ts +15 -0
- package/dist/runtime/check-costs.d.ts.map +1 -0
- package/dist/runtime/check-costs.js +114 -0
- package/dist/runtime/check-costs.js.map +1 -0
- package/dist/runtime/check-ids.d.ts +15 -0
- package/dist/runtime/check-ids.d.ts.map +1 -0
- package/dist/runtime/check-ids.js +140 -0
- package/dist/runtime/check-ids.js.map +1 -0
- package/dist/runtime/checks.d.ts +83 -20
- package/dist/runtime/checks.d.ts.map +1 -1
- package/dist/runtime/checks.js +3285 -1947
- package/dist/runtime/checks.js.map +1 -1
- package/dist/runtime/client.d.ts +112 -0
- package/dist/runtime/client.d.ts.map +1 -1
- package/dist/runtime/client.js +271 -39
- package/dist/runtime/client.js.map +1 -1
- package/dist/runtime/cookies.d.ts +16 -2
- package/dist/runtime/cookies.d.ts.map +1 -1
- package/dist/runtime/cookies.js +83 -22
- package/dist/runtime/cookies.js.map +1 -1
- package/dist/runtime/exchanges.d.ts +4 -5
- package/dist/runtime/exchanges.d.ts.map +1 -1
- package/dist/runtime/exchanges.js +33 -55
- package/dist/runtime/exchanges.js.map +1 -1
- package/dist/runtime/exit.d.ts +40 -0
- package/dist/runtime/exit.d.ts.map +1 -0
- package/dist/runtime/exit.js +48 -0
- package/dist/runtime/exit.js.map +1 -0
- package/dist/runtime/feature-gate.d.ts +0 -9
- package/dist/runtime/feature-gate.d.ts.map +1 -1
- package/dist/runtime/feature-gate.js +0 -21
- package/dist/runtime/feature-gate.js.map +1 -1
- package/dist/runtime/finding.d.ts +49 -8
- package/dist/runtime/finding.d.ts.map +1 -1
- package/dist/runtime/finding.js +51 -8
- package/dist/runtime/finding.js.map +1 -1
- package/dist/runtime/fixture.d.ts +35 -28
- package/dist/runtime/fixture.d.ts.map +1 -1
- package/dist/runtime/fixture.js +92 -731
- package/dist/runtime/fixture.js.map +1 -1
- package/dist/runtime/generate.d.ts +63 -0
- package/dist/runtime/generate.d.ts.map +1 -0
- package/dist/runtime/generate.js +635 -0
- package/dist/runtime/generate.js.map +1 -0
- package/dist/runtime/headers.d.ts +6 -0
- package/dist/runtime/headers.d.ts.map +1 -0
- package/dist/runtime/headers.js +16 -0
- package/dist/runtime/headers.js.map +1 -0
- package/dist/runtime/path.d.ts +7 -0
- package/dist/runtime/path.d.ts.map +1 -0
- package/dist/runtime/path.js +44 -0
- package/dist/runtime/path.js.map +1 -0
- package/dist/runtime/payloads.d.ts +2 -0
- package/dist/runtime/payloads.d.ts.map +1 -1
- package/dist/runtime/payloads.js +2 -1
- package/dist/runtime/payloads.js.map +1 -1
- package/dist/runtime/rate-limit.d.ts +6 -4
- package/dist/runtime/rate-limit.d.ts.map +1 -1
- package/dist/runtime/rate-limit.js +22 -33
- package/dist/runtime/rate-limit.js.map +1 -1
- package/dist/runtime/redact.d.ts +24 -0
- package/dist/runtime/redact.d.ts.map +1 -0
- package/dist/runtime/redact.js +98 -0
- package/dist/runtime/redact.js.map +1 -0
- package/dist/runtime/run.d.ts +34 -2
- package/dist/runtime/run.d.ts.map +1 -1
- package/dist/runtime/run.js +346 -138
- package/dist/runtime/run.js.map +1 -1
- package/dist/runtime/runs.d.ts +5 -0
- package/dist/runtime/runs.d.ts.map +1 -1
- package/dist/runtime/runs.js +7 -1
- package/dist/runtime/runs.js.map +1 -1
- package/dist/runtime/teardown.d.ts +17 -4
- package/dist/runtime/teardown.d.ts.map +1 -1
- package/dist/runtime/teardown.js +49 -16
- package/dist/runtime/teardown.js.map +1 -1
- package/dist/runtime/transcript.d.ts +34 -2
- package/dist/runtime/transcript.d.ts.map +1 -1
- package/dist/runtime/transcript.js +119 -24
- package/dist/runtime/transcript.js.map +1 -1
- package/dist/runtime/unique.d.ts +2 -11
- package/dist/runtime/unique.d.ts.map +1 -1
- package/dist/runtime/unique.js +27 -53
- package/dist/runtime/unique.js.map +1 -1
- package/dist/runtime/validate.d.ts +13 -2
- package/dist/runtime/validate.d.ts.map +1 -1
- package/dist/runtime/validate.js +51 -31
- package/dist/runtime/validate.js.map +1 -1
- package/dist/runtime/wait.d.ts +2 -1
- package/dist/runtime/wait.d.ts.map +1 -1
- package/dist/runtime/wait.js +2 -36
- package/dist/runtime/wait.js.map +1 -1
- package/dist/runtime/world.d.ts +24 -1
- package/dist/runtime/world.d.ts.map +1 -1
- package/dist/runtime/world.js +101 -26
- package/dist/runtime/world.js.map +1 -1
- package/dist/spec/collection.d.ts +8 -2
- package/dist/spec/collection.d.ts.map +1 -1
- package/dist/spec/collection.js +47 -7
- package/dist/spec/collection.js.map +1 -1
- package/dist/spec/extensions.d.ts +5 -1
- package/dist/spec/extensions.d.ts.map +1 -1
- package/dist/spec/extensions.js +9 -2
- package/dist/spec/extensions.js.map +1 -1
- package/dist/spec/graph.d.ts +46 -0
- package/dist/spec/graph.d.ts.map +1 -1
- package/dist/spec/graph.js +175 -11
- package/dist/spec/graph.js.map +1 -1
- package/dist/spec/load.d.ts +25 -5
- package/dist/spec/load.d.ts.map +1 -1
- package/dist/spec/load.js +160 -59
- package/dist/spec/load.js.map +1 -1
- package/dist/spec/query-capabilities.d.ts +4 -0
- package/dist/spec/query-capabilities.d.ts.map +1 -1
- package/dist/spec/query-capabilities.js +6 -0
- package/dist/spec/query-capabilities.js.map +1 -1
- package/dist/spec/schema.d.ts +20 -0
- package/dist/spec/schema.d.ts.map +1 -0
- package/dist/spec/schema.js +118 -0
- package/dist/spec/schema.js.map +1 -0
- package/dist/spec/tag-schema.d.ts +12 -0
- package/dist/spec/tag-schema.d.ts.map +1 -0
- package/dist/spec/tag-schema.js +143 -0
- package/dist/spec/tag-schema.js.map +1 -0
- package/dist/spec/types.js +17 -1
- package/dist/spec/types.js.map +1 -1
- package/docs/api.md +59 -0
- package/docs/assumptions.md +99 -0
- package/docs/checks.md +183 -0
- package/docs/commands.md +214 -0
- package/docs/configuration.md +650 -0
- package/docs/model.md +184 -0
- package/docs/reference-defects.md +94 -0
- package/docs/reports.md +211 -0
- package/docs/tags.md +400 -0
- package/package.json +3 -1
|
@@ -0,0 +1,1538 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conformance suite: the measurement loop.
|
|
3
|
+
*
|
|
4
|
+
* For each injected defect, assert oat reports exactly the check that defect should trip — and
|
|
5
|
+
* for the clean baseline, assert it reports nothing at all. The baseline is the harder bar: a
|
|
6
|
+
* tool that cries wolf on a correct backend is worse than no tool.
|
|
7
|
+
*
|
|
8
|
+
* Adding a check to oat means adding a defect here and expecting it. That keeps "smart" honest.
|
|
9
|
+
*/
|
|
10
|
+
import { execFile } from "node:child_process";
|
|
11
|
+
import { createServer } from "node:http";
|
|
12
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { promisify } from "node:util";
|
|
16
|
+
import { DEFECTS } from "../reference/defects.js";
|
|
17
|
+
import { DIALECTS } from "../reference/dialect.js";
|
|
18
|
+
import { STRING_PAYLOADS, catalogProblems } from "../runtime/payloads.js";
|
|
19
|
+
import { coverageByCheck, renderConsole, renderRepros } from "../report/render.js";
|
|
20
|
+
import { CHECKS } from "../runtime/checks.js";
|
|
21
|
+
import { run } from "../runtime/run.js";
|
|
22
|
+
import { buildModel } from "../spec/graph.js";
|
|
23
|
+
import { dereference, loadSpec } from "../spec/load.js";
|
|
24
|
+
import { EXPECTED_INCONCLUSIVE } from "./expected-failures.js";
|
|
25
|
+
import { leftBehind } from "./leaks.js";
|
|
26
|
+
import { SPEC_FIXTURES } from "./specs.js";
|
|
27
|
+
/**
|
|
28
|
+
* Parser robustness. These fixtures are shapes real published documents contain; the bar is that
|
|
29
|
+
* oat never throws and never hangs on them, not that it models them well.
|
|
30
|
+
*/
|
|
31
|
+
export { runSeedContractSuite } from "./seed-contract.js";
|
|
32
|
+
export { runEffectsSuite } from "./effects.js";
|
|
33
|
+
export function runParserSuite() {
|
|
34
|
+
return SPEC_FIXTURES.map((fixture) => {
|
|
35
|
+
try {
|
|
36
|
+
const { doc, externalRefs } = dereference(fixture.doc);
|
|
37
|
+
const model = buildModel(doc);
|
|
38
|
+
const problems = [];
|
|
39
|
+
if (fixture.expectOperations !== undefined && model.operations.length !== fixture.expectOperations) {
|
|
40
|
+
problems.push(`expected ${fixture.expectOperations} operation(s), modelled ${model.operations.length}`);
|
|
41
|
+
}
|
|
42
|
+
if (fixture.expectEntities !== undefined && model.entities.size !== fixture.expectEntities) {
|
|
43
|
+
problems.push(`expected ${fixture.expectEntities} entity/entities, modelled ${model.entities.size}`);
|
|
44
|
+
}
|
|
45
|
+
if (fixture.name === "external-ref" && externalRefs.length === 0) {
|
|
46
|
+
problems.push("external $ref was silently resolved instead of reported");
|
|
47
|
+
}
|
|
48
|
+
if (fixture.name === "irregular-plurals") {
|
|
49
|
+
const names = [...model.entities.keys()];
|
|
50
|
+
for (const bad of ["statu", "analyse", "peopl", "campuse", "inbo"]) {
|
|
51
|
+
if (names.includes(bad))
|
|
52
|
+
problems.push(`mangled plural produced entity "${bad}"`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (fixture.name === "multiple-arrays-in-envelope") {
|
|
56
|
+
const op = model.operations[0];
|
|
57
|
+
if (op?.collection?.key !== "reports") {
|
|
58
|
+
problems.push(`collection key resolved to "${op?.collection?.key ?? "none"}", expected "reports"`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (fixture.name === "query-roles-by-alias") {
|
|
62
|
+
const op = model.operations[0];
|
|
63
|
+
if (op?.query?.source !== "heuristic") {
|
|
64
|
+
problems.push(`query source is ${op?.query?.source ?? "null"}, expected heuristic`);
|
|
65
|
+
}
|
|
66
|
+
if (op?.query?.maxLimit !== 50) {
|
|
67
|
+
problems.push(`maxLimit is ${String(op?.query?.maxLimit)}, expected 50 from per_page.maximum`);
|
|
68
|
+
}
|
|
69
|
+
if (op?.conventions.order !== "sort" || op?.conventions.select !== "fields" || op?.conventions.search !== "q") {
|
|
70
|
+
problems.push(`roles resolved to order=${op?.conventions.order} select=${op?.conventions.select} search=${op?.conventions.search}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (fixture.name === "pagination-only-is-not-query") {
|
|
74
|
+
const op = model.operations[0];
|
|
75
|
+
if (op?.query !== null) {
|
|
76
|
+
problems.push(`query capability inferred from page/limit/status: ${op?.query?.source ?? "unknown"}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
detail: problems.join("; ") || `${model.operations.length} ops, ${model.entities.size} entities`,
|
|
81
|
+
name: fixture.name,
|
|
82
|
+
ok: problems.length === 0,
|
|
83
|
+
why: fixture.why,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
return {
|
|
88
|
+
detail: `threw: ${error instanceof Error ? error.message : String(error)}`,
|
|
89
|
+
name: fixture.name,
|
|
90
|
+
ok: false,
|
|
91
|
+
why: fixture.why,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The annotated example document must resolve to what its comments claim.
|
|
98
|
+
*
|
|
99
|
+
* `labs/annotated-openapi.yaml` is the only place that shows a reader where each tag goes in a
|
|
100
|
+
* real document, and documentation that drifts from the implementation is worse than none — it
|
|
101
|
+
* teaches a shape that no longer works. Asserting the derived model here means the example cannot
|
|
102
|
+
* rot: rename a tag or change how a role resolves, and this fails.
|
|
103
|
+
*
|
|
104
|
+
* It also proved its worth immediately. The first version was invalid YAML (`{workspace_id}` inside
|
|
105
|
+
* a flow sequence) and named an `x-invalidate` route that did not exist in the document.
|
|
106
|
+
*/
|
|
107
|
+
export async function runExampleSpecSuite() {
|
|
108
|
+
const path = new URL("../../labs/annotated-openapi.yaml", import.meta.url).pathname;
|
|
109
|
+
const results = [];
|
|
110
|
+
const fail = (name, why, detail) => {
|
|
111
|
+
results.push({ detail, name, ok: false, why });
|
|
112
|
+
};
|
|
113
|
+
let model;
|
|
114
|
+
try {
|
|
115
|
+
const { doc } = dereference(await loadSpec(path));
|
|
116
|
+
model = buildModel(doc);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
fail("annotated-openapi.yaml", "the documented example must be a parseable OpenAPI document", error instanceof Error ? error.message : String(error));
|
|
120
|
+
return results;
|
|
121
|
+
}
|
|
122
|
+
const list = model.byOperationId.get("widget.list");
|
|
123
|
+
const create = model.byOperationId.get("widget.create");
|
|
124
|
+
const update = model.byOperationId.get("widget.update");
|
|
125
|
+
const start = model.byOperationId.get("export.start");
|
|
126
|
+
const widgetRead = model.byOperationId.get("widget.read");
|
|
127
|
+
const templateRead = model.byOperationId.get("template.read");
|
|
128
|
+
const expectations = [
|
|
129
|
+
["x-query resolves the filter grammar", "the comment claims postgrest", list?.conventions.grammar, "postgrest"],
|
|
130
|
+
["x-query declares filterable fields", "not guessed from the schema", list?.query?.source, "tag"],
|
|
131
|
+
[
|
|
132
|
+
"x-query accepts structured filterable rows",
|
|
133
|
+
"declared-or-skip catalog",
|
|
134
|
+
list?.query?.filterFields?.[0]?.field,
|
|
135
|
+
"id",
|
|
136
|
+
],
|
|
137
|
+
["x-query sets maxLimit", "used to bound page-size probes", list?.query?.maxLimit, 100],
|
|
138
|
+
["limit resolves as page size", "distinguished by maximum + default", list?.conventions.limit, "limit"],
|
|
139
|
+
["cursor role resolves", "declared alongside page", list?.conventions.cursor, "cursor"],
|
|
140
|
+
["x-tenant is declared, not inferred", "decides SECURITY vs AMBIGUITY", list?.tenantSource, "tag"],
|
|
141
|
+
["workspace widget read is tagged", "SECURITY vs AMBIGUITY is unchanged", widgetRead?.tenantSource, "tag"],
|
|
142
|
+
[
|
|
143
|
+
"public catalogue is unscoped",
|
|
144
|
+
"no x-tenant and template_id is not a tenant name",
|
|
145
|
+
templateRead?.tenantSource,
|
|
146
|
+
null,
|
|
147
|
+
],
|
|
148
|
+
["public catalogue has no tenant param", 'omit is not the string "null"', templateRead?.tenantParam, null],
|
|
149
|
+
["x-invalidate names two routes", "one of them cross-entity", create?.invalidates.length, 2],
|
|
150
|
+
["idempotency header is modelled", "a promise oat replays", create?.idempotencyHeader, "Idempotency-Key"],
|
|
151
|
+
["x-immutable is read", "probed for rejection", update?.immutable.includes("workspace_id"), true],
|
|
152
|
+
["x-async is read", "oat follows the job", start?.async?.poll, "export.read"],
|
|
153
|
+
["x-feature-gate is read", "a matching 403 is coverage, not a seed defect", create?.featureGate, "widgets"],
|
|
154
|
+
["x-unique is read", "a 409 duplicate is a pass, 2xx is BACKEND_BUG", create?.unique, [["name"]]],
|
|
155
|
+
];
|
|
156
|
+
for (const [name, why, actual, expected] of expectations) {
|
|
157
|
+
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
|
158
|
+
results.push({
|
|
159
|
+
detail: ok ? `${JSON.stringify(actual)}` : `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
|
160
|
+
name,
|
|
161
|
+
ok,
|
|
162
|
+
why,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return results;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* A skip on one entity must not look like the check never ran.
|
|
169
|
+
*
|
|
170
|
+
* Multi-entity labs made this lie: invalidate ran on every child and the console still said
|
|
171
|
+
* "did not apply" because the parents skipped it.
|
|
172
|
+
*/
|
|
173
|
+
export function runCoverageReportSuite() {
|
|
174
|
+
const input = {
|
|
175
|
+
checksRun: ["invalidation.declared-route-changes", "list.read-after-write"],
|
|
176
|
+
checksSkipped: [
|
|
177
|
+
{ check: "invalidation.declared-route-changes", entity: "parent", needs: "a foreign read route" },
|
|
178
|
+
{ check: "async.reaches-terminal-state", entity: "parent", needs: "x-async" },
|
|
179
|
+
{ check: "async.reaches-terminal-state", entity: "child", needs: "x-async" },
|
|
180
|
+
],
|
|
181
|
+
checksSuppressed: [],
|
|
182
|
+
client: { transcript: [] },
|
|
183
|
+
durationMs: 1,
|
|
184
|
+
entitiesTested: ["parent", "child"],
|
|
185
|
+
findings: [],
|
|
186
|
+
};
|
|
187
|
+
const coverage = coverageByCheck(input);
|
|
188
|
+
const text = renderConsole(input);
|
|
189
|
+
const cases = [
|
|
190
|
+
[
|
|
191
|
+
"invalidate is partial, not never",
|
|
192
|
+
"it ran on the child",
|
|
193
|
+
coverage.partialSkip.some((r) => r.check === "invalidation.declared-route-changes"),
|
|
194
|
+
],
|
|
195
|
+
[
|
|
196
|
+
"async never applied",
|
|
197
|
+
"neither entity had x-async",
|
|
198
|
+
coverage.never.some((r) => r.check === "async.reaches-terminal-state"),
|
|
199
|
+
],
|
|
200
|
+
[
|
|
201
|
+
"console does not put a ran check under DID NOT APPLY",
|
|
202
|
+
"the invalidate line lives in APPLIED ONLY ON SOME ENTITIES",
|
|
203
|
+
text.includes("APPLIED ONLY ON SOME ENTITIES") &&
|
|
204
|
+
(text.split("APPLIED ONLY ON SOME ENTITIES")[0] ?? "").includes("invalidation") === false,
|
|
205
|
+
],
|
|
206
|
+
];
|
|
207
|
+
return cases.map(([name, why, ok]) => ({
|
|
208
|
+
detail: ok ? "true" : "false",
|
|
209
|
+
name,
|
|
210
|
+
ok,
|
|
211
|
+
why,
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* `doctor` claims each tag unlocks specific checks. This proves it.
|
|
216
|
+
*
|
|
217
|
+
* The reference backend is run twice — with its tags and with them stripped — and the checks that
|
|
218
|
+
* disappear must be exactly the set `TAG_UNLOCKS` promises. Without this the claim is a hand-
|
|
219
|
+
* maintained list that drifts the moment a check's applicability changes, and a coverage promise
|
|
220
|
+
* nobody verified is worse than no promise.
|
|
221
|
+
*/
|
|
222
|
+
/**
|
|
223
|
+
* `baselines`, when given, are the checks the defect matrix's two clean baselines ran — the same
|
|
224
|
+
* two runs this would otherwise make again.
|
|
225
|
+
*/
|
|
226
|
+
export async function runTagUnlockSuite(baselines) {
|
|
227
|
+
const { createMemoryServer } = await import("../reference/http.js");
|
|
228
|
+
const { TAG_UNLOCKS } = await import("../report/console.js");
|
|
229
|
+
const ran = async (untagged) => {
|
|
230
|
+
const server = await createMemoryServer({ untagged });
|
|
231
|
+
try {
|
|
232
|
+
const result = await run({
|
|
233
|
+
baseUrl: server.url,
|
|
234
|
+
principals: PRINCIPALS,
|
|
235
|
+
seed: 42,
|
|
236
|
+
spec: `${server.url}/v1/openapi/spec`,
|
|
237
|
+
});
|
|
238
|
+
return new Set(result.checksRun);
|
|
239
|
+
}
|
|
240
|
+
finally {
|
|
241
|
+
await server.close();
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
const tagged = baselines === undefined ? await ran(false) : new Set(baselines.tagged);
|
|
245
|
+
const untagged = baselines === undefined ? await ran(true) : new Set(baselines.untagged);
|
|
246
|
+
const lost = [...tagged].filter((id) => !untagged.has(id)).sort();
|
|
247
|
+
const promised = Object.values(TAG_UNLOCKS).flat().sort();
|
|
248
|
+
const missing = promised.filter((id) => !lost.includes(id));
|
|
249
|
+
const unclaimed = lost.filter((id) => !promised.includes(id));
|
|
250
|
+
const ok = missing.length === 0 && unclaimed.length === 0;
|
|
251
|
+
return [
|
|
252
|
+
{
|
|
253
|
+
detail: ok
|
|
254
|
+
? `${lost.length} check(s) unlocked by tags, exactly as documented`
|
|
255
|
+
: `promised-but-not-lost: [${missing.join(", ")}]; lost-but-unclaimed: [${unclaimed.join(", ")}]`,
|
|
256
|
+
name: "doctor's tag-unlock claims are true",
|
|
257
|
+
ok,
|
|
258
|
+
why: "a coverage promise nobody verified is worse than no promise",
|
|
259
|
+
},
|
|
260
|
+
];
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* A public catalogue is unscoped. Two principals, create as A, GET or filter as B → 200 is
|
|
264
|
+
* the contract, and the isolation checks must not apply. The workspace-scoped widget fixture
|
|
265
|
+
* is unchanged: tag → SECURITY, heuristic → AMBIGUITY. Same split for item GET and for
|
|
266
|
+
* `filter=id.eq.<other tenant>`.
|
|
267
|
+
*/
|
|
268
|
+
export async function runTenantScopeSuite() {
|
|
269
|
+
const results = [];
|
|
270
|
+
try {
|
|
271
|
+
const server = await servePublicCatalogue();
|
|
272
|
+
try {
|
|
273
|
+
const result = await run({
|
|
274
|
+
baseUrl: server.url,
|
|
275
|
+
principals: [
|
|
276
|
+
{ headers: { authorization: "Bearer tok_alpha" }, id: "alpha", roots: { workspace_id: "ws_a" } },
|
|
277
|
+
{ headers: { authorization: "Bearer tok_beta" }, id: "beta", roots: { workspace_id: "ws_b" } },
|
|
278
|
+
],
|
|
279
|
+
seed: 42,
|
|
280
|
+
spec: `${server.url}/v1/openapi/spec`,
|
|
281
|
+
});
|
|
282
|
+
const tenantFindings = result.findings.filter((f) => f.check === "tenant.item-not-readable-cross-tenant" || f.check === "tenant.denial-does-not-reveal-existence");
|
|
283
|
+
const skipped = result.checksSkipped.some((s) => s.check === "tenant.item-not-readable-cross-tenant");
|
|
284
|
+
const ran = result.checksRun.includes("tenant.item-not-readable-cross-tenant");
|
|
285
|
+
const ok = tenantFindings.length === 0 && skipped && !ran;
|
|
286
|
+
results.push({
|
|
287
|
+
detail: ok
|
|
288
|
+
? "check did not apply; no finding"
|
|
289
|
+
: `findings=${tenantFindings.map((f) => `${f.verdict}:${f.check}`).join(",") || "none"}; ` +
|
|
290
|
+
`ran=${String(ran)}; skipped=${String(skipped)}`,
|
|
291
|
+
name: "public catalogue 200 is not a finding",
|
|
292
|
+
ok,
|
|
293
|
+
why: "no x-tenant and template_id is not a tenant name, so isolation does not apply",
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
await server.close();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
results.push({
|
|
302
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
303
|
+
name: "public catalogue 200 is not a finding",
|
|
304
|
+
ok: false,
|
|
305
|
+
why: "no x-tenant and template_id is not a tenant name, so isolation does not apply",
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
const leak = async (untagged) => {
|
|
309
|
+
const { createMemoryServer } = await import("../reference/http.js");
|
|
310
|
+
const backend = await createMemoryServer({ defects: ["CROSS_TENANT_READ"], untagged });
|
|
311
|
+
try {
|
|
312
|
+
const result = await run({
|
|
313
|
+
baseUrl: backend.url,
|
|
314
|
+
only: ["table"],
|
|
315
|
+
principals: PRINCIPALS,
|
|
316
|
+
seed: 42,
|
|
317
|
+
spec: `${backend.url}/v1/openapi/spec`,
|
|
318
|
+
});
|
|
319
|
+
const hit = result.findings.find((f) => f.check === "tenant.item-not-readable-cross-tenant");
|
|
320
|
+
return { check: hit?.check ?? null, verdict: hit?.verdict ?? null };
|
|
321
|
+
}
|
|
322
|
+
finally {
|
|
323
|
+
await backend.close();
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
try {
|
|
327
|
+
const tagged = await leak(false);
|
|
328
|
+
const ok = tagged.verdict === "SECURITY";
|
|
329
|
+
results.push({
|
|
330
|
+
detail: ok ? "SECURITY" : `expected SECURITY, got ${tagged.verdict ?? "no finding"}`,
|
|
331
|
+
name: "tagged tenant leak is SECURITY",
|
|
332
|
+
ok,
|
|
333
|
+
why: "x-tenant + 200 from the other principal is a stated-boundary breach",
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
results.push({
|
|
338
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
339
|
+
name: "tagged tenant leak is SECURITY",
|
|
340
|
+
ok: false,
|
|
341
|
+
why: "x-tenant + 200 from the other principal is a stated-boundary breach",
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
const inferred = await leak(true);
|
|
346
|
+
const ok = inferred.verdict === "AMBIGUITY";
|
|
347
|
+
results.push({
|
|
348
|
+
detail: ok ? "AMBIGUITY" : `expected AMBIGUITY, got ${inferred.verdict ?? "no finding"}`,
|
|
349
|
+
name: "inferred tenant leak is AMBIGUITY",
|
|
350
|
+
ok,
|
|
351
|
+
why: "workspace_id / project_id still infers a tenant when x-tenant is stripped",
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
results.push({
|
|
356
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
357
|
+
name: "inferred tenant leak is AMBIGUITY",
|
|
358
|
+
ok: false,
|
|
359
|
+
why: "workspace_id / project_id still infers a tenant when x-tenant is stripped",
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
const server = await servePublicCatalogue();
|
|
364
|
+
try {
|
|
365
|
+
const result = await run({
|
|
366
|
+
baseUrl: server.url,
|
|
367
|
+
principals: [
|
|
368
|
+
{ headers: { authorization: "Bearer tok_alpha" }, id: "alpha", roots: { workspace_id: "ws_a" } },
|
|
369
|
+
{ headers: { authorization: "Bearer tok_beta" }, id: "beta", roots: { workspace_id: "ws_b" } },
|
|
370
|
+
],
|
|
371
|
+
seed: 42,
|
|
372
|
+
spec: `${server.url}/v1/openapi/spec`,
|
|
373
|
+
});
|
|
374
|
+
const hits = result.findings.filter((f) => f.check === "tenant.filter-does-not-bypass-scope");
|
|
375
|
+
const skipped = result.checksSkipped.some((s) => s.check === "tenant.filter-does-not-bypass-scope");
|
|
376
|
+
const ran = result.checksRun.includes("tenant.filter-does-not-bypass-scope");
|
|
377
|
+
const ok = hits.length === 0 && skipped && !ran;
|
|
378
|
+
results.push({
|
|
379
|
+
detail: ok
|
|
380
|
+
? "check did not apply; no finding"
|
|
381
|
+
: `findings=${hits.map((f) => `${f.verdict}:${f.check}`).join(",") || "none"}; ` +
|
|
382
|
+
`ran=${String(ran)}; skipped=${String(skipped)}`,
|
|
383
|
+
name: "public catalogue filter 200 is not a finding",
|
|
384
|
+
ok,
|
|
385
|
+
why: "no x-tenant and no tenant-named path param, so a 200 on id.eq.<other row> is the contract",
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
await server.close();
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
catch (error) {
|
|
393
|
+
results.push({
|
|
394
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
395
|
+
name: "public catalogue filter 200 is not a finding",
|
|
396
|
+
ok: false,
|
|
397
|
+
why: "no x-tenant and no tenant-named path param, so a 200 on id.eq.<other row> is the contract",
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
const leakFilter = async (untagged) => {
|
|
401
|
+
const { createMemoryServer } = await import("../reference/http.js");
|
|
402
|
+
const backend = await createMemoryServer({ defects: ["TENANT_LEAK_VIA_FILTER"], untagged });
|
|
403
|
+
try {
|
|
404
|
+
const result = await run({
|
|
405
|
+
baseUrl: backend.url,
|
|
406
|
+
only: ["table"],
|
|
407
|
+
principals: PRINCIPALS,
|
|
408
|
+
seed: 42,
|
|
409
|
+
spec: `${backend.url}/v1/openapi/spec`,
|
|
410
|
+
});
|
|
411
|
+
const hit = result.findings.find((f) => f.check === "tenant.filter-does-not-bypass-scope");
|
|
412
|
+
return { check: hit?.check ?? null, verdict: hit?.verdict ?? null };
|
|
413
|
+
}
|
|
414
|
+
finally {
|
|
415
|
+
await backend.close();
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
try {
|
|
419
|
+
const tagged = await leakFilter(false);
|
|
420
|
+
const ok = tagged.verdict === "SECURITY";
|
|
421
|
+
results.push({
|
|
422
|
+
detail: ok ? "SECURITY" : `expected SECURITY, got ${tagged.verdict ?? "no finding"}`,
|
|
423
|
+
name: "tagged tenant filter leak is SECURITY",
|
|
424
|
+
ok,
|
|
425
|
+
why: "x-tenant + filter 200 of the other tenant's row is a stated-boundary breach",
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
catch (error) {
|
|
429
|
+
results.push({
|
|
430
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
431
|
+
name: "tagged tenant filter leak is SECURITY",
|
|
432
|
+
ok: false,
|
|
433
|
+
why: "x-tenant + filter 200 of the other tenant's row is a stated-boundary breach",
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
const inferred = await leakFilter(true);
|
|
438
|
+
const ok = inferred.verdict === "AMBIGUITY";
|
|
439
|
+
results.push({
|
|
440
|
+
detail: ok ? "AMBIGUITY" : `expected AMBIGUITY, got ${inferred.verdict ?? "no finding"}`,
|
|
441
|
+
name: "inferred tenant filter leak is AMBIGUITY",
|
|
442
|
+
ok,
|
|
443
|
+
why: "workspace_id / project_id still infers a tenant when x-tenant is stripped",
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
results.push({
|
|
448
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
449
|
+
name: "inferred tenant filter leak is AMBIGUITY",
|
|
450
|
+
ok: false,
|
|
451
|
+
why: "workspace_id / project_id still infers a tenant when x-tenant is stripped",
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
return results;
|
|
455
|
+
}
|
|
456
|
+
const PUBLIC_CATALOGUE_SPEC = {
|
|
457
|
+
info: { title: "Public catalogue", version: "1.0" },
|
|
458
|
+
openapi: "3.1.0",
|
|
459
|
+
paths: {
|
|
460
|
+
"/v1/templates": {
|
|
461
|
+
get: {
|
|
462
|
+
operationId: "template.list",
|
|
463
|
+
parameters: [
|
|
464
|
+
{
|
|
465
|
+
description: "PostgREST filter expression, e.g. id.eq.value",
|
|
466
|
+
in: "query",
|
|
467
|
+
name: "filter",
|
|
468
|
+
required: false,
|
|
469
|
+
schema: { type: "string" },
|
|
470
|
+
},
|
|
471
|
+
],
|
|
472
|
+
responses: {
|
|
473
|
+
"200": {
|
|
474
|
+
content: {
|
|
475
|
+
"application/json": {
|
|
476
|
+
schema: {
|
|
477
|
+
properties: {
|
|
478
|
+
templates: {
|
|
479
|
+
items: { properties: { id: { type: "string" }, name: { type: "string" } }, type: "object" },
|
|
480
|
+
type: "array",
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
required: ["templates"],
|
|
484
|
+
type: "object",
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
description: "ok",
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
"x-entity": { action: "list", identity: "id", name: "template" },
|
|
492
|
+
},
|
|
493
|
+
post: {
|
|
494
|
+
operationId: "template.create",
|
|
495
|
+
requestBody: {
|
|
496
|
+
content: {
|
|
497
|
+
"application/json": {
|
|
498
|
+
schema: {
|
|
499
|
+
additionalProperties: false,
|
|
500
|
+
properties: { name: { maxLength: 128, type: "string" } },
|
|
501
|
+
required: ["name"],
|
|
502
|
+
type: "object",
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
required: true,
|
|
507
|
+
},
|
|
508
|
+
responses: {
|
|
509
|
+
"201": {
|
|
510
|
+
content: {
|
|
511
|
+
"application/json": {
|
|
512
|
+
schema: { properties: { id: { type: "string" }, name: { type: "string" } }, type: "object" },
|
|
513
|
+
},
|
|
514
|
+
},
|
|
515
|
+
description: "created",
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
"x-entity": { action: "create", identity: "id", name: "template" },
|
|
519
|
+
},
|
|
520
|
+
},
|
|
521
|
+
"/v1/templates/{template_id}": {
|
|
522
|
+
delete: {
|
|
523
|
+
operationId: "template.delete",
|
|
524
|
+
parameters: [{ in: "path", name: "template_id", required: true, schema: { type: "string" } }],
|
|
525
|
+
responses: { "204": { description: "deleted" } },
|
|
526
|
+
"x-entity": { action: "delete", identity: "id", name: "template" },
|
|
527
|
+
},
|
|
528
|
+
get: {
|
|
529
|
+
operationId: "template.read",
|
|
530
|
+
parameters: [{ in: "path", name: "template_id", required: true, schema: { type: "string" } }],
|
|
531
|
+
responses: {
|
|
532
|
+
"200": {
|
|
533
|
+
content: {
|
|
534
|
+
"application/json": {
|
|
535
|
+
schema: { properties: { id: { type: "string" }, name: { type: "string" } }, type: "object" },
|
|
536
|
+
},
|
|
537
|
+
},
|
|
538
|
+
description: "ok",
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
"x-entity": { action: "read", identity: "id", name: "template" },
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
async function servePublicCatalogue() {
|
|
547
|
+
const rows = new Map();
|
|
548
|
+
let seq = 0;
|
|
549
|
+
const tokens = new Set(["tok_alpha", "tok_beta"]);
|
|
550
|
+
const readJson = (req) => new Promise((resolve, reject) => {
|
|
551
|
+
const chunks = [];
|
|
552
|
+
req.on("data", (chunk) => {
|
|
553
|
+
chunks.push(chunk);
|
|
554
|
+
});
|
|
555
|
+
req.on("end", () => {
|
|
556
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
557
|
+
if (text === "") {
|
|
558
|
+
resolve(undefined);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
try {
|
|
562
|
+
resolve(JSON.parse(text));
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
reject(error);
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
req.on("error", reject);
|
|
569
|
+
});
|
|
570
|
+
const send = (res, status, body) => {
|
|
571
|
+
if (body === undefined) {
|
|
572
|
+
res.writeHead(status);
|
|
573
|
+
res.end();
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const text = JSON.stringify(body);
|
|
577
|
+
res.writeHead(status, { "content-type": "application/json", "content-length": String(Buffer.byteLength(text)) });
|
|
578
|
+
res.end(text);
|
|
579
|
+
};
|
|
580
|
+
const server = createServer((req, res) => {
|
|
581
|
+
void (async () => {
|
|
582
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
583
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
584
|
+
if (url.pathname === "/v1/openapi/spec" && method === "GET") {
|
|
585
|
+
return send(res, 200, PUBLIC_CATALOGUE_SPEC);
|
|
586
|
+
}
|
|
587
|
+
const header = req.headers.authorization;
|
|
588
|
+
if (typeof header !== "string" || !header.startsWith("Bearer ") || !tokens.has(header.slice(7).trim())) {
|
|
589
|
+
return send(res, 401, { error: "unauthorized" });
|
|
590
|
+
}
|
|
591
|
+
if (url.pathname === "/v1/templates" && method === "GET") {
|
|
592
|
+
const raw = url.searchParams.get("filter");
|
|
593
|
+
const match = raw === null ? null : /^id\.eq\.(.+)$/.exec(raw);
|
|
594
|
+
if (match !== null) {
|
|
595
|
+
const row = rows.get(match[1] ?? "");
|
|
596
|
+
return send(res, 200, { templates: row === undefined ? [] : [row] });
|
|
597
|
+
}
|
|
598
|
+
return send(res, 200, { templates: [...rows.values()] });
|
|
599
|
+
}
|
|
600
|
+
if (url.pathname === "/v1/templates" && method === "POST") {
|
|
601
|
+
const body = (await readJson(req));
|
|
602
|
+
const name = typeof body?.name === "string" ? body.name : "";
|
|
603
|
+
const id = `tmpl_${String((seq += 1))}`;
|
|
604
|
+
const row = { id, name };
|
|
605
|
+
rows.set(id, row);
|
|
606
|
+
return send(res, 201, row);
|
|
607
|
+
}
|
|
608
|
+
const item = /^\/v1\/templates\/([^/]+)$/.exec(url.pathname);
|
|
609
|
+
if (item !== null && method === "GET") {
|
|
610
|
+
const row = rows.get(decodeURIComponent(item[1] ?? ""));
|
|
611
|
+
return row === undefined ? send(res, 404, { error: "not_found" }) : send(res, 200, row);
|
|
612
|
+
}
|
|
613
|
+
if (item !== null && method === "DELETE") {
|
|
614
|
+
const id = decodeURIComponent(item[1] ?? "");
|
|
615
|
+
if (!rows.has(id))
|
|
616
|
+
return send(res, 404, { error: "not_found" });
|
|
617
|
+
rows.delete(id);
|
|
618
|
+
return send(res, 204);
|
|
619
|
+
}
|
|
620
|
+
return send(res, 404, { error: "not_found" });
|
|
621
|
+
})().catch(() => {
|
|
622
|
+
if (!res.headersSent)
|
|
623
|
+
send(res, 500, { error: "internal" });
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
await new Promise((resolve) => {
|
|
627
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
628
|
+
});
|
|
629
|
+
const addr = server.address();
|
|
630
|
+
return {
|
|
631
|
+
close: () => new Promise((resolve, reject) => {
|
|
632
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
633
|
+
}),
|
|
634
|
+
url: `http://127.0.0.1:${addr.port}`,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
export function runPayloadCatalogSuite() {
|
|
638
|
+
const problems = catalogProblems();
|
|
639
|
+
return [
|
|
640
|
+
{
|
|
641
|
+
detail: problems.join("; ") || `${STRING_PAYLOADS.length} cases, JSON-stable, no NUL`,
|
|
642
|
+
name: "string-payload-catalog",
|
|
643
|
+
ok: problems.length === 0,
|
|
644
|
+
why: "the payload catalog must be unique, JSON-round-trippable, and cover every promised family",
|
|
645
|
+
},
|
|
646
|
+
];
|
|
647
|
+
}
|
|
648
|
+
export function renderParserSuite(results) {
|
|
649
|
+
const lines = [];
|
|
650
|
+
let failures = 0;
|
|
651
|
+
lines.push("");
|
|
652
|
+
lines.push(" spec fixture result");
|
|
653
|
+
lines.push(" ─────────────────────────────────────────────────────────────────────────");
|
|
654
|
+
for (const result of results) {
|
|
655
|
+
if (!result.ok)
|
|
656
|
+
failures++;
|
|
657
|
+
lines.push(` ${result.ok ? "✓" : "✗"} ${result.name.padEnd(28)} ${result.detail}`);
|
|
658
|
+
if (!result.ok)
|
|
659
|
+
lines.push(` ${result.why}`);
|
|
660
|
+
}
|
|
661
|
+
lines.push("");
|
|
662
|
+
lines.push(` ${results.length - failures}/${results.length} hostile specs handled`);
|
|
663
|
+
lines.push("");
|
|
664
|
+
return { failures, text: lines.join("\n") };
|
|
665
|
+
}
|
|
666
|
+
const exec = promisify(execFile);
|
|
667
|
+
/**
|
|
668
|
+
* Generated reproducers are shell scripts, so a quoting mistake turns them into scripts that run
|
|
669
|
+
* against a literal `$BASE` with a literal `$TOKEN` — they look right and do the wrong thing.
|
|
670
|
+
* `bash -n` parses without executing, which catches that class before anyone copies one.
|
|
671
|
+
*/
|
|
672
|
+
export async function checkReproSyntax(findings) {
|
|
673
|
+
const scripts = renderRepros(findings, "https://example.test");
|
|
674
|
+
if (scripts.length === 0)
|
|
675
|
+
return [];
|
|
676
|
+
const dir = await mkdtemp(join(tmpdir(), "oat-repro-"));
|
|
677
|
+
try {
|
|
678
|
+
return await checkScripts(dir, scripts);
|
|
679
|
+
}
|
|
680
|
+
finally {
|
|
681
|
+
/* A self-test that litters the temp directory is the same failure it reports elsewhere. */
|
|
682
|
+
await rm(dir, { force: true, recursive: true });
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
async function checkScripts(dir, scripts) {
|
|
686
|
+
const failures = [];
|
|
687
|
+
for (const script of scripts) {
|
|
688
|
+
const path = join(dir, script.filename);
|
|
689
|
+
await writeFile(path, script.content);
|
|
690
|
+
try {
|
|
691
|
+
await exec("bash", ["-n", path]);
|
|
692
|
+
}
|
|
693
|
+
catch (error) {
|
|
694
|
+
failures.push(`${script.filename}: ${error instanceof Error ? error.message : String(error)}`);
|
|
695
|
+
}
|
|
696
|
+
/* An unexpanded variable inside single quotes is syntactically valid and semantically
|
|
697
|
+
* wrong, so parse success is not enough. Checked per line and skipping comments: prose
|
|
698
|
+
* apostrophes ("schema's") would otherwise let the match span into unrelated code. */
|
|
699
|
+
for (const line of script.content.split("\n")) {
|
|
700
|
+
if (line.trimStart().startsWith("#"))
|
|
701
|
+
continue;
|
|
702
|
+
if (/'[^']*\$(BASE|TOKEN)[^']*'/.test(line)) {
|
|
703
|
+
failures.push(`${script.filename}: $BASE/$TOKEN appears inside single quotes and will not expand — ${line.trim()}`);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return failures;
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Which check each defect must trip.
|
|
711
|
+
*
|
|
712
|
+
* The first id is the primary — the diagnosis oat is required to reach. Any further ids are
|
|
713
|
+
* *acceptable* consequences: one defect can legitimately violate several properties at once, and
|
|
714
|
+
* calling those false positives would push oat toward under-reporting real problems.
|
|
715
|
+
*/
|
|
716
|
+
export const EXPECTED = {
|
|
717
|
+
CREATED_201_AS_200: "create.status-matches-document",
|
|
718
|
+
CROSS_TENANT_READ: "tenant.item-not-readable-cross-tenant",
|
|
719
|
+
EXISTENCE_LEAK_VIA_STATUS: "tenant.denial-does-not-reveal-existence",
|
|
720
|
+
IDEMPOTENCY_IGNORED: "idempotency.replay-does-not-duplicate",
|
|
721
|
+
UNIQUE_NOT_ENFORCED: ["create.unique-conflict-rejected", "update.unique-conflict-rejected"],
|
|
722
|
+
PARENT_PROJECTION_STALE: "invalidation.declared-route-changes",
|
|
723
|
+
SPEC_OVERCLAIMS_FILTERABLE: "spec.declared-filterable-is-filterable",
|
|
724
|
+
SPEC_OVERCLAIMS_SORTABLE: ["spec.declared-sortable-is-sortable", "spec.declared-sortable-nulls-accepted"],
|
|
725
|
+
FILTER_GROUP_COMBINATOR_SWAPPED: [
|
|
726
|
+
"filter.and-composes-as-intersection",
|
|
727
|
+
/* Only fires where an or() combinator exists to swap at all — the postgrest-shaped
|
|
728
|
+
* dialects. On the others the swap still corrupts and(), which is the primary. */
|
|
729
|
+
"filter.or-composes-as-union",
|
|
730
|
+
],
|
|
731
|
+
SPEC_OVERCLAIMS_SELECTABLE: "spec.declared-selectable-is-selectable",
|
|
732
|
+
FILTER_AFTER_PAGINATION: [
|
|
733
|
+
"query.filter-selects-from-whole-set",
|
|
734
|
+
/* The same misordering makes a filtered page shorter than the page size, which the
|
|
735
|
+
* count and negation properties observe from their own angles. */
|
|
736
|
+
"count.matches-filtered-set",
|
|
737
|
+
"filter.negation-partitions-the-set",
|
|
738
|
+
"filter.equality-selects-exactly-one",
|
|
739
|
+
"filter.zero-match-returns-none",
|
|
740
|
+
"query.axes-compose",
|
|
741
|
+
/* A range predicate is evaluated over the same truncated window, so it under-matches too. */
|
|
742
|
+
"filter.numeric-comparison-is-numeric",
|
|
743
|
+
/* And a record that falls outside the window is absent from a filtered projection while
|
|
744
|
+
* present in the item route, which is a genuine disagreement between read paths. */
|
|
745
|
+
"consistency.projections-agree",
|
|
746
|
+
/* The tenant-scoping probe filters too, so its records fall outside the window and the
|
|
747
|
+
* leak it looks for cannot be observed. */
|
|
748
|
+
"tenant.filter-does-not-bypass-scope",
|
|
749
|
+
/* A membership filter is a filter: its matches outside the window are lost the same way. */
|
|
750
|
+
"filter.contains-membership",
|
|
751
|
+
],
|
|
752
|
+
FILTER_DROPPED_WHEN_SORTED: [
|
|
753
|
+
"query.axes-compose",
|
|
754
|
+
/* A sorted page walk gathers its set with the filter present, so the same drop shows up
|
|
755
|
+
* there — and the negation check partitions across pages under a sort too. */
|
|
756
|
+
"filter.negation-partitions-the-set",
|
|
757
|
+
"pagination.page-walk-covers-set",
|
|
758
|
+
"count.matches-filtered-set",
|
|
759
|
+
/* Every larger composition that sorts a filter sees the same drop. */
|
|
760
|
+
"query.filter-search-sort-compose",
|
|
761
|
+
"query.filter-sort-select-compose",
|
|
762
|
+
"query.filter-search-sort-select-compose",
|
|
763
|
+
],
|
|
764
|
+
/* As above: each composition holding the filter and the axis that drops it sees the drop. */
|
|
765
|
+
FILTER_DROPPED_WHEN_SELECTED: [
|
|
766
|
+
"query.filter-and-select-compose",
|
|
767
|
+
"query.filter-search-select-compose",
|
|
768
|
+
"query.filter-sort-select-compose",
|
|
769
|
+
"query.filter-search-sort-select-compose",
|
|
770
|
+
],
|
|
771
|
+
FILTER_DROPPED_WHEN_SEARCHED: [
|
|
772
|
+
"query.search-and-filter-compose",
|
|
773
|
+
"query.filter-search-sort-compose",
|
|
774
|
+
"query.filter-search-select-compose",
|
|
775
|
+
"query.filter-search-sort-select-compose",
|
|
776
|
+
],
|
|
777
|
+
/* The four-axis query holds each of these pairs, so it sees the dropped filter too — and
|
|
778
|
+
* reports it whenever another defect leaves the triple unable to. */
|
|
779
|
+
FILTER_DROPPED_WHEN_SORTED_AND_SELECTED: [
|
|
780
|
+
"query.filter-sort-select-compose",
|
|
781
|
+
"query.filter-search-sort-select-compose",
|
|
782
|
+
],
|
|
783
|
+
FILTER_DROPPED_WHEN_SORTED_AND_SEARCHED: [
|
|
784
|
+
"query.filter-search-sort-compose",
|
|
785
|
+
"query.filter-search-sort-select-compose",
|
|
786
|
+
],
|
|
787
|
+
FILTER_DROPPED_WHEN_SEARCHED_AND_SELECTED: [
|
|
788
|
+
"query.filter-search-select-compose",
|
|
789
|
+
"query.filter-search-sort-select-compose",
|
|
790
|
+
],
|
|
791
|
+
LIST_DETAIL_DISAGREE: [
|
|
792
|
+
"consistency.projections-agree",
|
|
793
|
+
/* A listing serving a different value for a searchable field also breaks every predicate
|
|
794
|
+
* evaluated against the stored value: the record comes back under a name the caller can
|
|
795
|
+
* see but cannot search or filter for. */
|
|
796
|
+
"search.q-narrows-result",
|
|
797
|
+
"filter.equality-selects-exactly-one",
|
|
798
|
+
"select.projection-honoured",
|
|
799
|
+
"sort.order-is-applied",
|
|
800
|
+
],
|
|
801
|
+
COUNT_IGNORES_FILTER: "count.matches-filtered-set",
|
|
802
|
+
CURSOR_DRIFT: "pagination.cursor-agrees-with-page",
|
|
803
|
+
DELETE_MISSING_OK: "delete.absent-record-returns-404",
|
|
804
|
+
ERROR_500_ON_BAD_FILTER: [
|
|
805
|
+
"error.malformed-filter-not-5xx",
|
|
806
|
+
/* A crash on an unknown field is this check's own failure: it is not rejected. */
|
|
807
|
+
"filter.unknown-field-rejected",
|
|
808
|
+
/* 500 is not in the document. The status-vs-document check is a real second symptom,
|
|
809
|
+
* not a misdiagnosis: the handler crashed and invented a code the spec never named. */
|
|
810
|
+
"response.status-is-documented",
|
|
811
|
+
],
|
|
812
|
+
FILTER_IGNORED: "filter.unknown-field-rejected",
|
|
813
|
+
IMMUTABLE_WRITABLE: "patch.immutable-field-rejected",
|
|
814
|
+
LIKE_UNESCAPED: "filter.like-metacharacters-escaped",
|
|
815
|
+
OFF_BY_ONE_PAGE: "pagination.page-walk-covers-set",
|
|
816
|
+
PATCH_REPLACES: ["patch.minimality", "consistency.projections-agree"],
|
|
817
|
+
SELECT_IGNORED: "select.projection-honoured",
|
|
818
|
+
SOFT_DELETE_LEAK: "softdelete.absent-from-default-list",
|
|
819
|
+
STALE_LIST: "list.read-after-write",
|
|
820
|
+
TENANT_LEAK_VIA_FILTER: [
|
|
821
|
+
"tenant.filter-does-not-bypass-scope",
|
|
822
|
+
/* Any filtered query reaches across tenants, so a filter combined with a search returns
|
|
823
|
+
* foreign records the search alone does not: the same leak, seen from the composition. */
|
|
824
|
+
"query.search-and-filter-compose",
|
|
825
|
+
],
|
|
826
|
+
UNSTABLE_SORT: [
|
|
827
|
+
"pagination.page-walk-covers-set",
|
|
828
|
+
/* An unstable default order can hide a just-created record behind a page boundary,
|
|
829
|
+
* which the write-visibility check observes as a lost write. Same root cause. */
|
|
830
|
+
"list.read-after-write",
|
|
831
|
+
"sort.stable-tiebreak",
|
|
832
|
+
],
|
|
833
|
+
LIMIT_IGNORED: "pagination.limit-bounds-page-size",
|
|
834
|
+
LIMIT_EXCEEDS_MAX: "pagination.limit-respects-documented-max",
|
|
835
|
+
HASMORE_ALWAYS_FALSE: "pagination.has-more-is-accurate",
|
|
836
|
+
ORDER_IGNORED: "sort.order-is-applied",
|
|
837
|
+
SEARCH_IGNORED: "search.q-narrows-result",
|
|
838
|
+
CREATE_DROPS_FIELD: "create.persists-submitted-fields",
|
|
839
|
+
STRING_PAYLOAD_MANGLED: "payload.string-survives",
|
|
840
|
+
RESPONSE_STATUS_UNDECLARED: "response.status-is-documented",
|
|
841
|
+
ENUM_NOT_VALIDATED: "validation.enum-enforced",
|
|
842
|
+
MAXLENGTH_NOT_VALIDATED: "validation.max-length-enforced",
|
|
843
|
+
REQUIRED_NOT_VALIDATED: "validation.required-enforced",
|
|
844
|
+
CONTENT_TYPE_NOT_ENFORCED: "validation.content-type-enforced",
|
|
845
|
+
ERROR_SCHEMA_DRIFT: "schema.error-response-matches-document",
|
|
846
|
+
RESPONSE_SCHEMA_DRIFT: "schema.success-response-matches-document",
|
|
847
|
+
FILTER_EQ_NOT_APPLIED: [
|
|
848
|
+
"filter.equality-selects-exactly-one",
|
|
849
|
+
"filter.zero-match-returns-none",
|
|
850
|
+
"filter.negation-partitions-the-set",
|
|
851
|
+
"count.matches-filtered-set",
|
|
852
|
+
"filter.like-metacharacters-escaped",
|
|
853
|
+
],
|
|
854
|
+
EMPTY_RESULT_RETURNS_ALL: [
|
|
855
|
+
"filter.zero-match-returns-none",
|
|
856
|
+
"filter.unknown-field-rejected",
|
|
857
|
+
"filter.like-metacharacters-escaped",
|
|
858
|
+
/* Falling back to the unfiltered set breaks every narrowing predicate, not just the
|
|
859
|
+
* filter that triggered it — search and the tenant-scoped filter included. */
|
|
860
|
+
"search.q-narrows-result",
|
|
861
|
+
"tenant.filter-does-not-bypass-scope",
|
|
862
|
+
"filter.negation-partitions-the-set",
|
|
863
|
+
/* A page walk narrows too: the final page's query legitimately matches nothing, and a
|
|
864
|
+
* backend that answers "nothing" with "everything" makes records reappear mid-walk. */
|
|
865
|
+
"pagination.page-walk-covers-set",
|
|
866
|
+
"count.matches-filtered-set",
|
|
867
|
+
"filter.empty-in",
|
|
868
|
+
],
|
|
869
|
+
COUNT_ALWAYS_ZERO: ["count.consistent-with-returned-page", "count.matches-filtered-set"],
|
|
870
|
+
NEQ_DROPS_NULLS: "filter.negation-partitions-the-set",
|
|
871
|
+
SORT_DESC_DROPS_NULLS: "sort.reverse-symmetry",
|
|
872
|
+
EFFECT_NOT_APPLIED: ["effects.declared-effect-occurs", "async.reaches-terminal-state"],
|
|
873
|
+
COLUMN_NAME_MISMATCH: [
|
|
874
|
+
"create.persists-submitted-fields",
|
|
875
|
+
"list.read-after-write",
|
|
876
|
+
"search.q-narrows-result",
|
|
877
|
+
"filter.equality-selects-exactly-one",
|
|
878
|
+
"sort.order-is-applied",
|
|
879
|
+
"sort.multi-key-tiebreak",
|
|
880
|
+
"response.status-is-documented",
|
|
881
|
+
/*
|
|
882
|
+
* On an engine with double-quoted-string fallback the response *shape* is corrupted, not
|
|
883
|
+
* just its values: D1 returns the unresolved identifier as the key, quotes included, so a
|
|
884
|
+
* row comes back as `{ "\"name\"": "name" }`. The declared property is then missing and an
|
|
885
|
+
* undeclared one is present, which is a genuine schema violation rather than a knock-on
|
|
886
|
+
* mis-diagnosis. Verified against a live D1; `node:sqlite` compiles the fallback off and
|
|
887
|
+
* never reaches this shape.
|
|
888
|
+
*/
|
|
889
|
+
"schema.success-response-matches-document",
|
|
890
|
+
],
|
|
891
|
+
NUMERIC_COMPARED_AS_TEXT: [
|
|
892
|
+
"filter.numeric-comparison-is-numeric",
|
|
893
|
+
"sort.order-is-applied",
|
|
894
|
+
"sort.reverse-symmetry",
|
|
895
|
+
"sort.numeric-order-is-numeric",
|
|
896
|
+
"pagination.page-walk-covers-set",
|
|
897
|
+
"pagination.cursor-agrees-with-page",
|
|
898
|
+
],
|
|
899
|
+
COLLATION_INCONSISTENT: "pagination.cursor-agrees-with-page",
|
|
900
|
+
ROLE_MONOTONICITY_BROKEN: "auth.rank-is-monotonic",
|
|
901
|
+
INVITE_NEVER_GRANTS: "auth.invite-grants-then-revokes",
|
|
902
|
+
REVOKE_IGNORED: "auth.invite-grants-then-revokes",
|
|
903
|
+
CONCURRENT_WRITE_LOST: [
|
|
904
|
+
"concurrency.no-lost-update",
|
|
905
|
+
/* The defect *is* read-modify-write: PATCH reads the row, then writes every column back.
|
|
906
|
+
* That is PUT semantics wearing a PATCH label, so whenever anything else touches the row
|
|
907
|
+
* inside that window the rewrite clobbers a field the caller never named — which is
|
|
908
|
+
* precisely what the minimality check asserts against. It surfaces on a backend with real
|
|
909
|
+
* latency and hides on one without, so it is a legitimate second symptom, not a false
|
|
910
|
+
* positive. */
|
|
911
|
+
"patch.minimality",
|
|
912
|
+
],
|
|
913
|
+
FILTER_IN_FIRST_ONLY: "filter.in-is-union-of-eq",
|
|
914
|
+
FILTER_GTE_IS_GT: "filter.gte-is-gt-or-eq",
|
|
915
|
+
FILTER_ILIKE_IS_LIKE: "filter.ilike-is-case-insensitive",
|
|
916
|
+
FILTER_IS_NULL_MATCHES_ALL: "filter.is-null-selects-nulls",
|
|
917
|
+
FILTER_ILLEGAL_OP_IGNORED: "filter.illegal-op-rejected",
|
|
918
|
+
SORT_NUMERIC_AS_TEXT: "sort.numeric-order-is-numeric",
|
|
919
|
+
SORT_MULTI_KEY_IGNORED: "sort.multi-key-tiebreak",
|
|
920
|
+
SELECT_FIELD_MISSING: "select.requested-fields-present",
|
|
921
|
+
ASYNC_NEVER_COMPLETES: "async.reaches-terminal-state",
|
|
922
|
+
ASYNC_RECEIPT_MISSING_ID: [
|
|
923
|
+
"async.receipt-identifies-the-job",
|
|
924
|
+
"async.reaches-terminal-state",
|
|
925
|
+
"schema.success-response-matches-document",
|
|
926
|
+
],
|
|
927
|
+
FILTER_ALIAS_MISMAPPED: [
|
|
928
|
+
"filter.alias-matches-canonical",
|
|
929
|
+
/* `ne` read as `eq` is legal on a field that allows eq and not neq — so an operator the
|
|
930
|
+
* document closes off is accepted. The same mismapping, seen from the allowlist. */
|
|
931
|
+
"filter.illegal-op-rejected",
|
|
932
|
+
],
|
|
933
|
+
FILTER_CONDITION_CAP_IGNORED: "filter.condition-cap-rejected",
|
|
934
|
+
FILTER_IN_CAP_IGNORED: "filter.in-over-limit-rejected",
|
|
935
|
+
FILTER_LTE_IS_LT: "filter.lte-is-lt-or-eq",
|
|
936
|
+
FILTER_LT_IS_LTE: "filter.ordered-triple-partitions",
|
|
937
|
+
FILTER_NESTED_COMBINATOR_LEAKS: "filter.nested-and-or-distributes",
|
|
938
|
+
FILTER_NIN_FIRST_ONLY: "filter.nin-complements-in",
|
|
939
|
+
FILTER_DROPPED_WHEN_SORTED_SEARCHED_AND_SELECTED: "query.filter-search-sort-select-compose",
|
|
940
|
+
/* The search is dropped wherever a select joins it, the filter+search+select triple included. */
|
|
941
|
+
SEARCH_DROPPED_WHEN_SELECTED: [
|
|
942
|
+
"query.search-and-select-compose",
|
|
943
|
+
"query.filter-search-select-compose",
|
|
944
|
+
"query.filter-search-sort-select-compose",
|
|
945
|
+
],
|
|
946
|
+
SEARCH_DROPPED_WHEN_SORTED: [
|
|
947
|
+
"query.search-and-sort-compose",
|
|
948
|
+
"query.filter-search-sort-compose",
|
|
949
|
+
"query.filter-search-sort-select-compose",
|
|
950
|
+
],
|
|
951
|
+
ORDER_DROPPED_WHEN_SELECTED: [
|
|
952
|
+
"query.sort-and-select-compose",
|
|
953
|
+
"query.filter-sort-select-compose",
|
|
954
|
+
"query.filter-search-sort-select-compose",
|
|
955
|
+
],
|
|
956
|
+
SEARCH_CASE_SENSITIVE: "search.case-insensitive",
|
|
957
|
+
SEARCH_EMPTY_MATCHES_NONE: "search.empty-q",
|
|
958
|
+
SEARCH_ONLY_FIRST_TOKEN: "search.tokens-and",
|
|
959
|
+
SELECT_UNKNOWN_IGNORED: "select.unknown-field-rejected",
|
|
960
|
+
SORT_NULLS_MODIFIER_IGNORED: "sort.nulls-first-last",
|
|
961
|
+
SORT_UNKNOWN_FIELD_IGNORED: "sort.unknown-field-rejected",
|
|
962
|
+
SPEC_OVERCLAIMS_FILTER_OP: "spec.declared-filterable-ops-accepted",
|
|
963
|
+
CONTAINS_MATCHES_SUBSTRING: "filter.contains-membership",
|
|
964
|
+
SEARCH_MODE_REJECTED: "search.mode-accepted",
|
|
965
|
+
NESTED_SELECT_IGNORED: "select.nested-honoured",
|
|
966
|
+
DEFAULT_ORDER_IGNORED: "sort.default-order-applied",
|
|
967
|
+
SIDE_EFFECT_NEVER_ARRIVES: "effects.side-effect-arrives",
|
|
968
|
+
RATE_LIMIT_STRICTER_THAN_DECLARED: "spec.declared-rate-limit-is-honoured",
|
|
969
|
+
IDEMPOTENT_REPLAY_INSERTS: "idempotency.replay-does-not-duplicate",
|
|
970
|
+
TIEBREAK_NOT_APPLIED: "sort.stable-tiebreak",
|
|
971
|
+
CROSS_TENANT_WRITE: "tenant.item-not-writable-cross-tenant",
|
|
972
|
+
ROLE_WRITE_INVERTED: "auth.rank-is-monotonic-on-writes",
|
|
973
|
+
UNKNOWN_PARAM_EMPTIES_LIST: "query.unknown-parameter-consistent",
|
|
974
|
+
/* A walk whose last page is full asks once more, and sees the repeat. */
|
|
975
|
+
PAGE_PAST_END_REPEATS: ["pagination.bounds-handled", "pagination.page-walk-covers-set"],
|
|
976
|
+
FOREIGN_PARENT_ACCEPTED: "tenant.parent-not-reachable-from-another-root",
|
|
977
|
+
CREATE_500_ON_NON_ASCII: [
|
|
978
|
+
"create.does-not-error",
|
|
979
|
+
/* The probes that write non-ASCII strings hit the same crash on their own creates. */
|
|
980
|
+
"payload.string-survives",
|
|
981
|
+
"response.status-is-documented",
|
|
982
|
+
],
|
|
983
|
+
};
|
|
984
|
+
/**
|
|
985
|
+
* Defects only the SQL-backed reference can exhibit.
|
|
986
|
+
*
|
|
987
|
+
* The in-memory store has no physical column names, so a DDL/read naming mismatch has nowhere
|
|
988
|
+
* to happen. Every other defect is defined above the stores and runs on every engine.
|
|
989
|
+
*/
|
|
990
|
+
export const SQL_ONLY = new Set(["COLUMN_NAME_MISMATCH"]);
|
|
991
|
+
/**
|
|
992
|
+
* The only defects a remote D1 can say anything an in-process engine cannot.
|
|
993
|
+
*
|
|
994
|
+
* Every statement against D1 is a network round trip, so a full pass costs minutes. The defects
|
|
995
|
+
* worth paying that for are the ones whose behaviour comes from the *engine build* — collation,
|
|
996
|
+
* type affinity, NULL ordering, LIKE escaping, identifier resolution. Everything else is decided
|
|
997
|
+
* in the request handler, where D1 would re-derive a result `node:sqlite` already gave for free.
|
|
998
|
+
* The lost update is not among them: the handler holds the write open on purpose, so it is real on
|
|
999
|
+
* any engine and runs on the in-memory one only (`ONE_ENGINE`).
|
|
1000
|
+
*/
|
|
1001
|
+
export const ENGINE_SENSITIVE = new Set([
|
|
1002
|
+
"COLLATION_INCONSISTENT",
|
|
1003
|
+
"COLUMN_NAME_MISMATCH",
|
|
1004
|
+
"LIKE_UNESCAPED",
|
|
1005
|
+
"NEQ_DROPS_NULLS",
|
|
1006
|
+
"NUMERIC_COMPARED_AS_TEXT",
|
|
1007
|
+
"SORT_DESC_DROPS_NULLS",
|
|
1008
|
+
"UNSTABLE_SORT",
|
|
1009
|
+
]);
|
|
1010
|
+
/**
|
|
1011
|
+
* Defects that need cursor pagination to exist at all. A dialect that pages only by number
|
|
1012
|
+
* cannot exhibit them, so reporting a miss there would be reporting the absence of a feature.
|
|
1013
|
+
*/
|
|
1014
|
+
/**
|
|
1015
|
+
* Dialects that publish a cursor. Named rather than inferred, because "which defects can this
|
|
1016
|
+
* shape even exhibit" is a property of the fixture, and a new dialect that forgets to declare
|
|
1017
|
+
* itself here shows up as missed detections rather than as silent exclusions.
|
|
1018
|
+
*/
|
|
1019
|
+
export const DIALECTS_WITH_CURSOR = new Set(["postgrest"]);
|
|
1020
|
+
/**
|
|
1021
|
+
* Dialects that report a total. A shape publishing no count cannot publish a wrong one, so the
|
|
1022
|
+
* count defects have nowhere to happen — the same reasoning that excuses cursor defects on a
|
|
1023
|
+
* cursorless API, and not an excuse for a check that merely failed to fire.
|
|
1024
|
+
*/
|
|
1025
|
+
export const DIALECTS_WITH_TOTAL = new Set(["postgrest", "classic", "jsonapi"]);
|
|
1026
|
+
/**
|
|
1027
|
+
* Dialects whose filtering is an expression language rather than one parameter per field.
|
|
1028
|
+
*
|
|
1029
|
+
* Equality-per-field has no expression to malform, no unknown *field* distinct from an unknown
|
|
1030
|
+
* query parameter, and no way to write a pattern — so the defects that live inside a filter
|
|
1031
|
+
* grammar have nowhere to happen.
|
|
1032
|
+
*/
|
|
1033
|
+
export const DIALECTS_WITH_FILTER_EXPRESSION = new Set([
|
|
1034
|
+
"postgrest",
|
|
1035
|
+
"classic",
|
|
1036
|
+
"linked",
|
|
1037
|
+
"jsonapi",
|
|
1038
|
+
]);
|
|
1039
|
+
/**
|
|
1040
|
+
* How many checks must actually run against each shape.
|
|
1041
|
+
*
|
|
1042
|
+
* The recurring bug in this tool has been a check that cannot *express* a request looking exactly
|
|
1043
|
+
* like a backend that ignores one: gating on a `page` parameter against an offset-paged API, or on
|
|
1044
|
+
* a `filter` parameter against one that filters per field. Every instance was found by adding a
|
|
1045
|
+
* new dialect and noticing the number drop — which only works if someone is looking.
|
|
1046
|
+
*
|
|
1047
|
+
* These floors make it automatic. They are deliberately set at the current coverage, so any change
|
|
1048
|
+
* that quietly stops a check from applying fails the suite and names it, rather than waiting for a
|
|
1049
|
+
* sixth dialect to expose the same class again. Raising a floor when coverage genuinely improves
|
|
1050
|
+
* is the intended maintenance; lowering one needs a reason stated in the commit.
|
|
1051
|
+
*/
|
|
1052
|
+
const ALL_CHECK_IDS = CHECKS.map((check) => check.id);
|
|
1053
|
+
export const COVERAGE_FLOOR = {
|
|
1054
|
+
classic: 80,
|
|
1055
|
+
jsonapi: 87,
|
|
1056
|
+
linked: 89,
|
|
1057
|
+
plain: 71,
|
|
1058
|
+
postgrest: 90,
|
|
1059
|
+
};
|
|
1060
|
+
/** Defects that need a filter expression language to exist at all. */
|
|
1061
|
+
export const EXPRESSION_ONLY = new Set([
|
|
1062
|
+
"ERROR_500_ON_BAD_FILTER",
|
|
1063
|
+
/* Per-field equality has no declared filter *expression* to overclaim: an unrecognised
|
|
1064
|
+
* parameter is simply ignored, which is conventional rather than a broken promise. */
|
|
1065
|
+
"SPEC_OVERCLAIMS_FILTERABLE",
|
|
1066
|
+
"FILTER_IGNORED",
|
|
1067
|
+
"LIKE_UNESCAPED",
|
|
1068
|
+
"NEQ_DROPS_NULLS",
|
|
1069
|
+
"NUMERIC_COMPARED_AS_TEXT",
|
|
1070
|
+
"FILTER_ILLEGAL_OP_IGNORED",
|
|
1071
|
+
"FILTER_GTE_IS_GT",
|
|
1072
|
+
"FILTER_LTE_IS_LT",
|
|
1073
|
+
"FILTER_LT_IS_LTE",
|
|
1074
|
+
/* The overclaimed operator is `like`, which per-field equality cannot write. */
|
|
1075
|
+
"SPEC_OVERCLAIMS_FILTER_OP",
|
|
1076
|
+
]);
|
|
1077
|
+
/**
|
|
1078
|
+
* Defects that need a postgrest-shaped filter writer (`in` / `ilike` / `is`). Colon and
|
|
1079
|
+
* equality cannot emit those operators, so injecting the lie there is a miss that names the
|
|
1080
|
+
* grammar rather than oat.
|
|
1081
|
+
*/
|
|
1082
|
+
export const POSTGREST_OP_ONLY = new Set([
|
|
1083
|
+
"FILTER_IN_FIRST_ONLY",
|
|
1084
|
+
"FILTER_ILIKE_IS_LIKE",
|
|
1085
|
+
"FILTER_IS_NULL_MATCHES_ALL",
|
|
1086
|
+
"FILTER_IN_CAP_IGNORED",
|
|
1087
|
+
"FILTER_NIN_FIRST_ONLY",
|
|
1088
|
+
"CONTAINS_MATCHES_SUBSTRING",
|
|
1089
|
+
/* The alias is `ne`, which only the postgrest-shaped writer spells. */
|
|
1090
|
+
"FILTER_ALIAS_MISMAPPED",
|
|
1091
|
+
/* Grouping — nested and()/or(), and enough terms in one expression to pass a cap — exists
|
|
1092
|
+
* only in the postgrest-shaped grammar. */
|
|
1093
|
+
"FILTER_CONDITION_CAP_IGNORED",
|
|
1094
|
+
"FILTER_NESTED_COMBINATOR_LEAKS",
|
|
1095
|
+
]);
|
|
1096
|
+
/** Defects that need a search-mode parameter, which only some dialects publish. */
|
|
1097
|
+
export const SEARCH_MODE_ONLY = new Set(["SEARCH_MODE_REJECTED"]);
|
|
1098
|
+
/** Defects that need a nulls token in the sort grammar — only the dotted grammar writes one. */
|
|
1099
|
+
export const NULLS_TOKEN_ONLY = new Set(["SORT_NULLS_MODIFIER_IGNORED"]);
|
|
1100
|
+
export const DIALECTS_WITH_POSTGREST_FILTER = new Set(["postgrest", "linked", "jsonapi"]);
|
|
1101
|
+
/** Defects that need a reported total to exist at all. */
|
|
1102
|
+
export const COUNT_ONLY = new Set(["COUNT_ALWAYS_ZERO", "COUNT_IGNORES_FILTER"]);
|
|
1103
|
+
export const CURSOR_ONLY = new Set(["CURSOR_DRIFT", "COLLATION_INCONSISTENT"]);
|
|
1104
|
+
/** Whether this runtime can host the SQLite backend (`node:sqlite` needs a flag on Node 22). */
|
|
1105
|
+
export async function sqliteAvailable() {
|
|
1106
|
+
try {
|
|
1107
|
+
/* Constructing is the real test: on some builds the module resolves but the class is
|
|
1108
|
+
* unusable, and a probe that only imports would report a false positive. */
|
|
1109
|
+
const { DatabaseSync } = await import("node:sqlite");
|
|
1110
|
+
new DatabaseSync(":memory:").close();
|
|
1111
|
+
return true;
|
|
1112
|
+
}
|
|
1113
|
+
catch {
|
|
1114
|
+
return false;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* Whether D1 credentials are present. Deliberately not a live probe: a network call on every
|
|
1119
|
+
* `oat conformance` would tax the common path to answer a question about an opt-in backend.
|
|
1120
|
+
*/
|
|
1121
|
+
export function d1Available() {
|
|
1122
|
+
return (process.env.CLOUDFLARE_ACCOUNT_ID !== undefined &&
|
|
1123
|
+
process.env.CLOUDFLARE_D1_DATABASE_ID !== undefined &&
|
|
1124
|
+
process.env.CLOUDFLARE_API_TOKEN !== undefined);
|
|
1125
|
+
}
|
|
1126
|
+
/** Whether a Postgres server is reachable. Requires a running server, not just the driver. */
|
|
1127
|
+
export async function postgresAvailable() {
|
|
1128
|
+
try {
|
|
1129
|
+
const { default: postgres } = await import("postgres");
|
|
1130
|
+
const sql = postgres({ connect_timeout: 3, database: "postgres", max: 1, onnotice: () => { } });
|
|
1131
|
+
try {
|
|
1132
|
+
await sql `select 1`;
|
|
1133
|
+
return true;
|
|
1134
|
+
}
|
|
1135
|
+
finally {
|
|
1136
|
+
await sql.end({ timeout: 3 });
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
catch {
|
|
1140
|
+
return false;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
function primaryOf(expected) {
|
|
1144
|
+
return Array.isArray(expected) ? (expected[0] ?? "") : expected;
|
|
1145
|
+
}
|
|
1146
|
+
function acceptableOf(expected) {
|
|
1147
|
+
return Array.isArray(expected) ? expected : [expected];
|
|
1148
|
+
}
|
|
1149
|
+
export const PRINCIPALS = [
|
|
1150
|
+
{
|
|
1151
|
+
auth: {
|
|
1152
|
+
credentialFrom: "$.access_token",
|
|
1153
|
+
steps: [{ body: { key: "key_alpha" }, operationId: "auth.token" }],
|
|
1154
|
+
},
|
|
1155
|
+
id: "alpha",
|
|
1156
|
+
rank: 2,
|
|
1157
|
+
role: "owner",
|
|
1158
|
+
roots: { project_id: "proj_alpha" },
|
|
1159
|
+
},
|
|
1160
|
+
{
|
|
1161
|
+
auth: {
|
|
1162
|
+
credentialFrom: "$.access_token",
|
|
1163
|
+
steps: [{ body: { key: "key_beta" }, operationId: "auth.token" }],
|
|
1164
|
+
},
|
|
1165
|
+
id: "beta",
|
|
1166
|
+
inviteAs: "key_beta",
|
|
1167
|
+
rank: 2,
|
|
1168
|
+
role: "owner",
|
|
1169
|
+
roots: { project_id: "proj_beta" },
|
|
1170
|
+
},
|
|
1171
|
+
{
|
|
1172
|
+
auth: {
|
|
1173
|
+
credentialFrom: "$.access_token",
|
|
1174
|
+
steps: [{ body: { key: "key_alpha_member" }, operationId: "auth.token" }],
|
|
1175
|
+
},
|
|
1176
|
+
id: "alpha_member",
|
|
1177
|
+
rank: 1,
|
|
1178
|
+
role: "member",
|
|
1179
|
+
roots: { project_id: "proj_alpha" },
|
|
1180
|
+
},
|
|
1181
|
+
{
|
|
1182
|
+
auth: {
|
|
1183
|
+
credentialFrom: "$.access_token",
|
|
1184
|
+
steps: [{ body: { key: "key_alpha_viewer" }, operationId: "auth.token" }],
|
|
1185
|
+
},
|
|
1186
|
+
id: "alpha_viewer",
|
|
1187
|
+
rank: 0,
|
|
1188
|
+
role: "viewer",
|
|
1189
|
+
roots: { project_id: "proj_alpha" },
|
|
1190
|
+
},
|
|
1191
|
+
];
|
|
1192
|
+
async function runAgainst(defects, untagged = false, backend = "memory", dialect = "postgrest", shape) {
|
|
1193
|
+
/* One HTTP implementation, one store per engine — see reference/http.ts. Imported lazily so
|
|
1194
|
+
* an optional runtime such as node:sqlite cannot take down paths that never touch it. */
|
|
1195
|
+
const { createD1Server, createMemoryServer, createPostgresServer, createSqliteServer } = await import("../reference/http.js");
|
|
1196
|
+
const factory = backend === "memory"
|
|
1197
|
+
? createMemoryServer
|
|
1198
|
+
: backend === "sqlite"
|
|
1199
|
+
? createSqliteServer
|
|
1200
|
+
: backend === "d1"
|
|
1201
|
+
? createD1Server
|
|
1202
|
+
: createPostgresServer;
|
|
1203
|
+
const server = await factory({ defects, dialect, untagged, ...(shape === undefined ? {} : { shape }) });
|
|
1204
|
+
try {
|
|
1205
|
+
const before = await server.snapshot();
|
|
1206
|
+
const result = await run({
|
|
1207
|
+
baseUrl: server.url,
|
|
1208
|
+
principals: PRINCIPALS,
|
|
1209
|
+
seed: 42,
|
|
1210
|
+
spec: `${server.url}/v1/openapi/spec`,
|
|
1211
|
+
});
|
|
1212
|
+
/* A tester that litters is one nobody runs twice against anything real, so this is
|
|
1213
|
+
* asserted, not assumed — and read from the backend, not from oat's ledger. */
|
|
1214
|
+
const leakedRecords = leftBehind(before, await server.snapshot());
|
|
1215
|
+
return {
|
|
1216
|
+
checksRun: result.checksRun,
|
|
1217
|
+
entitiesTested: result.entitiesTested,
|
|
1218
|
+
findings: result.findings,
|
|
1219
|
+
inconclusive: result.inconclusive,
|
|
1220
|
+
leaked: leakedRecords.length,
|
|
1221
|
+
leakedRecords,
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
finally {
|
|
1225
|
+
await server.close();
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
export async function runSuite(filter, backend = "memory", dialect = "postgrest") {
|
|
1229
|
+
const results = await runBaselines(backend, dialect);
|
|
1230
|
+
for (const defect of defectsFor(backend, dialect, filter)) {
|
|
1231
|
+
results.push(await judgeDefect(defect, backend, dialect));
|
|
1232
|
+
}
|
|
1233
|
+
return results;
|
|
1234
|
+
}
|
|
1235
|
+
/** A leg's answer, from its results in order: the two baselines first, then its defects. */
|
|
1236
|
+
export function passAnswer(results, backend, dialect) {
|
|
1237
|
+
const rendered = renderSuite(results, dialect, backend);
|
|
1238
|
+
const primaryOps = {};
|
|
1239
|
+
for (const result of results) {
|
|
1240
|
+
const finding = result.findings.find((item) => item.check === result.expected);
|
|
1241
|
+
if (result.defect !== null && finding?.operations !== undefined)
|
|
1242
|
+
primaryOps[result.defect] = finding.operations;
|
|
1243
|
+
}
|
|
1244
|
+
return {
|
|
1245
|
+
baselines: { tagged: results[0]?.checksRun ?? [], untagged: results[1]?.checksRun ?? [] },
|
|
1246
|
+
failures: rendered.failures,
|
|
1247
|
+
primaryOps,
|
|
1248
|
+
proven: [...rendered.proven],
|
|
1249
|
+
text: rendered.text,
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* A case's result as it can cross to another thread: findings keep everything the verdicts read,
|
|
1254
|
+
* and drop the exchanges they cite, which hold live bodies.
|
|
1255
|
+
*/
|
|
1256
|
+
export function portableResult(result) {
|
|
1257
|
+
const strip = (finding) => ({ ...finding, evidence: [] });
|
|
1258
|
+
return { ...result, findings: result.findings.map(strip), spurious: result.spurious.map(strip) };
|
|
1259
|
+
}
|
|
1260
|
+
/** The two clean baselines of a leg: tagged, then with every meta tag stripped. */
|
|
1261
|
+
export async function runBaselines(backend = "memory", dialect = "postgrest") {
|
|
1262
|
+
const results = [];
|
|
1263
|
+
/* Baseline first — everything downstream is meaningless if this is not clean. */
|
|
1264
|
+
try {
|
|
1265
|
+
const { findings, entitiesTested, checksRun, leaked, inconclusive } = await runAgainst([], false, backend, dialect);
|
|
1266
|
+
const real = findings.filter((f) => f.verdict !== "COVERAGE_GAP");
|
|
1267
|
+
results.push({
|
|
1268
|
+
checksRun,
|
|
1269
|
+
inconclusive: inconclusive.map((item) => `${item.check}@${item.entity}: ${item.reason}`),
|
|
1270
|
+
leaked,
|
|
1271
|
+
defect: null,
|
|
1272
|
+
detected: real.length === 0,
|
|
1273
|
+
entitiesTested,
|
|
1274
|
+
expected: null,
|
|
1275
|
+
findings,
|
|
1276
|
+
acceptable: [],
|
|
1277
|
+
label: "baseline (correct backend)",
|
|
1278
|
+
spurious: real,
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
catch (error) {
|
|
1282
|
+
results.push({
|
|
1283
|
+
checksRun: [],
|
|
1284
|
+
defect: null,
|
|
1285
|
+
detected: false,
|
|
1286
|
+
entitiesTested: [],
|
|
1287
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1288
|
+
expected: null,
|
|
1289
|
+
findings: [],
|
|
1290
|
+
label: "baseline (correct backend)",
|
|
1291
|
+
spurious: [],
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
/* The same baseline with every meta tag stripped. oat must still be quiet on a correct
|
|
1295
|
+
* backend when it is running on heuristics alone — a fallback that invents findings would
|
|
1296
|
+
* make the tool unusable on any spec that has not been annotated yet. */
|
|
1297
|
+
try {
|
|
1298
|
+
const { findings, entitiesTested, checksRun, inconclusive } = await runAgainst([], true, backend, dialect);
|
|
1299
|
+
const real = findings.filter((f) => f.verdict !== "COVERAGE_GAP");
|
|
1300
|
+
results.push({
|
|
1301
|
+
checksRun,
|
|
1302
|
+
defect: null,
|
|
1303
|
+
detected: real.length === 0,
|
|
1304
|
+
entitiesTested,
|
|
1305
|
+
expected: null,
|
|
1306
|
+
findings,
|
|
1307
|
+
inconclusive: inconclusive.map((item) => `${item.check}@${item.entity}: ${item.reason}`),
|
|
1308
|
+
acceptable: [],
|
|
1309
|
+
label: "baseline (untagged spec)",
|
|
1310
|
+
spurious: real,
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
catch (error) {
|
|
1314
|
+
results.push({
|
|
1315
|
+
checksRun: [],
|
|
1316
|
+
defect: null,
|
|
1317
|
+
detected: false,
|
|
1318
|
+
entitiesTested: [],
|
|
1319
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1320
|
+
expected: null,
|
|
1321
|
+
findings: [],
|
|
1322
|
+
label: "baseline (untagged spec)",
|
|
1323
|
+
spurious: [],
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
return results;
|
|
1327
|
+
}
|
|
1328
|
+
/** Defects whose symptom does not depend on the API's query or envelope conventions. */
|
|
1329
|
+
const DIALECT_INSENSITIVE = new Set([
|
|
1330
|
+
"PATCH_REPLACES",
|
|
1331
|
+
"EXISTENCE_LEAK_VIA_STATUS",
|
|
1332
|
+
"UNIQUE_NOT_ENFORCED",
|
|
1333
|
+
"IDEMPOTENCY_IGNORED",
|
|
1334
|
+
"IDEMPOTENT_REPLAY_INSERTS",
|
|
1335
|
+
"IMMUTABLE_WRITABLE",
|
|
1336
|
+
"CREATED_201_AS_200",
|
|
1337
|
+
"DELETE_MISSING_OK",
|
|
1338
|
+
"CROSS_TENANT_READ",
|
|
1339
|
+
"CREATE_DROPS_FIELD",
|
|
1340
|
+
"STRING_PAYLOAD_MANGLED",
|
|
1341
|
+
"ENUM_NOT_VALIDATED",
|
|
1342
|
+
"MAXLENGTH_NOT_VALIDATED",
|
|
1343
|
+
"REQUIRED_NOT_VALIDATED",
|
|
1344
|
+
"CONTENT_TYPE_NOT_ENFORCED",
|
|
1345
|
+
"ERROR_SCHEMA_DRIFT",
|
|
1346
|
+
"ASYNC_NEVER_COMPLETES",
|
|
1347
|
+
"ASYNC_RECEIPT_MISSING_ID",
|
|
1348
|
+
"CONCURRENT_WRITE_LOST",
|
|
1349
|
+
"ROLE_MONOTONICITY_BROKEN",
|
|
1350
|
+
"INVITE_NEVER_GRANTS",
|
|
1351
|
+
"REVOKE_IGNORED",
|
|
1352
|
+
"CREATE_500_ON_NON_ASCII",
|
|
1353
|
+
"SIDE_EFFECT_NEVER_ARRIVES",
|
|
1354
|
+
"RATE_LIMIT_STRICTER_THAN_DECLARED",
|
|
1355
|
+
]);
|
|
1356
|
+
/** Defects that need a page-number parameter to be seen. */
|
|
1357
|
+
const PAGE_NUMBER_ONLY = new Set(["PAGE_PAST_END_REPEATS"]);
|
|
1358
|
+
/** Defects that run on the in-memory engine only: the store plays no part in them. */
|
|
1359
|
+
const ONE_ENGINE = new Set(["CONCURRENT_WRITE_LOST"]);
|
|
1360
|
+
/** Every defect this backend and dialect can exhibit, narrowed to `filter` when one is given. */
|
|
1361
|
+
export function defectsFor(backend, dialect, filter) {
|
|
1362
|
+
return (Object.keys(DEFECTS)
|
|
1363
|
+
.filter((name) => filter === undefined || filter.length === 0 || filter.includes(name))
|
|
1364
|
+
/* A defect the backend structurally cannot exhibit is not a miss: the in-memory store has
|
|
1365
|
+
* no physical column names, so a DDL/read mismatch has nowhere to happen. */
|
|
1366
|
+
.filter((name) => backend !== "memory" || !SQL_ONLY.has(name))
|
|
1367
|
+
/* Likewise a dialect that has no cursor cannot drift one. */
|
|
1368
|
+
.filter((name) => DIALECTS_WITH_CURSOR.has(dialect) || !CURSOR_ONLY.has(name))
|
|
1369
|
+
/* Likewise a shape with no total cannot report a wrong one. */
|
|
1370
|
+
.filter((name) => DIALECTS_WITH_TOTAL.has(dialect) || !COUNT_ONLY.has(name))
|
|
1371
|
+
/* And a shape with no filter *expression* cannot malform one. */
|
|
1372
|
+
.filter((name) => DIALECTS_WITH_FILTER_EXPRESSION.has(dialect) || !EXPRESSION_ONLY.has(name))
|
|
1373
|
+
.filter((name) => DIALECTS_WITH_POSTGREST_FILTER.has(dialect) || !POSTGREST_OP_ONLY.has(name))
|
|
1374
|
+
.filter((name) => DIALECTS[dialect]?.params.searchMode !== undefined || !SEARCH_MODE_ONLY.has(name))
|
|
1375
|
+
/* A dialect that pages by offset has no page number to ask past the end. */
|
|
1376
|
+
.filter((name) => DIALECTS[dialect]?.params.page !== undefined || !PAGE_NUMBER_ONLY.has(name))
|
|
1377
|
+
.filter((name) => (DIALECTS[dialect]?.sortGrammar ?? "dotted") === "dotted" || !NULLS_TOKEN_ONLY.has(name))
|
|
1378
|
+
/* A fault in writes, auth or validation reads the same under every dialect; the extra
|
|
1379
|
+
* dialect passes are for how a listing is asked for and answered. */
|
|
1380
|
+
.filter((name) => dialect === "postgrest" || !DIALECT_INSENSITIVE.has(name))
|
|
1381
|
+
/* A store-independent race, timed with a sleep: one engine shows it as well as three. */
|
|
1382
|
+
.filter((name) => backend === "memory" || !ONE_ENGINE.has(name))
|
|
1383
|
+
/* D1 is a network round trip per statement. Restricting it to the engine-sensitive set
|
|
1384
|
+
* keeps a run to minutes rather than an hour, and drops nothing D1 could uniquely show. */
|
|
1385
|
+
.filter((name) => backend !== "d1" || ENGINE_SENSITIVE.has(name)));
|
|
1386
|
+
}
|
|
1387
|
+
/** One defect, injected alone: detected, and nothing reported that it does not explain. */
|
|
1388
|
+
export async function judgeDefect(defect, backend, dialect, shape, label = defect) {
|
|
1389
|
+
const expected = EXPECTED[defect];
|
|
1390
|
+
const acceptable = acceptableOf(expected);
|
|
1391
|
+
try {
|
|
1392
|
+
const { findings, entitiesTested, checksRun } = await runAgainst([defect], false, backend, dialect, shape);
|
|
1393
|
+
const reproFailures = await checkReproSyntax(findings);
|
|
1394
|
+
if (reproFailures.length > 0) {
|
|
1395
|
+
return {
|
|
1396
|
+
acceptable,
|
|
1397
|
+
checksRun,
|
|
1398
|
+
defect,
|
|
1399
|
+
detected: false,
|
|
1400
|
+
entitiesTested,
|
|
1401
|
+
error: `generated reproducer is not runnable — ${reproFailures[0]}`,
|
|
1402
|
+
expected: primaryOf(expected),
|
|
1403
|
+
findings,
|
|
1404
|
+
label,
|
|
1405
|
+
spurious: [],
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
const real = findings.filter((f) => f.verdict !== "COVERAGE_GAP");
|
|
1409
|
+
return {
|
|
1410
|
+
acceptable,
|
|
1411
|
+
checksRun,
|
|
1412
|
+
defect,
|
|
1413
|
+
detected: real.some((f) => f.check === primaryOf(expected)),
|
|
1414
|
+
entitiesTested,
|
|
1415
|
+
expected: primaryOf(expected),
|
|
1416
|
+
findings,
|
|
1417
|
+
label,
|
|
1418
|
+
spurious: real.filter((f) => !acceptable.includes(f.check)),
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
catch (error) {
|
|
1422
|
+
return {
|
|
1423
|
+
acceptable,
|
|
1424
|
+
checksRun: [],
|
|
1425
|
+
defect,
|
|
1426
|
+
detected: false,
|
|
1427
|
+
entitiesTested: [],
|
|
1428
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1429
|
+
expected: primaryOf(expected),
|
|
1430
|
+
findings: [],
|
|
1431
|
+
label,
|
|
1432
|
+
spurious: [],
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
export function renderSuite(results, dialect = "postgrest", backend = "memory") {
|
|
1437
|
+
const lines = [];
|
|
1438
|
+
let failures = 0;
|
|
1439
|
+
lines.push("");
|
|
1440
|
+
lines.push(" defect detected spurious expected check");
|
|
1441
|
+
lines.push(" ─────────────────────────────────────────────────────────────────────────");
|
|
1442
|
+
/*
|
|
1443
|
+
* A baseline that reports nothing because it ran nothing is not evidence of precision — and a
|
|
1444
|
+
* baseline that runs *fewer checks than this shape is known to support* is a coverage
|
|
1445
|
+
* regression wearing a clean result's clothes.
|
|
1446
|
+
*/
|
|
1447
|
+
const floor = COVERAGE_FLOOR[dialect] ?? 10;
|
|
1448
|
+
for (const result of results) {
|
|
1449
|
+
const isBaseline = result.defect === null;
|
|
1450
|
+
/* Only the *tagged* baseline is held to the floor. The untagged case deliberately strips
|
|
1451
|
+
* every x-* tag to prove oat degrades gracefully without them, so running fewer checks
|
|
1452
|
+
* there is the result being measured, not a regression. */
|
|
1453
|
+
const vacuous = result.label === "baseline (correct backend)" && result.checksRun.length < floor;
|
|
1454
|
+
const leaked = result.leaked ?? 0;
|
|
1455
|
+
/* A clean baseline that could not decide a check has not shown that check passes. Only a
|
|
1456
|
+
* verdict declared expected for this backend and shape may stand. */
|
|
1457
|
+
const variant = result.label === "baseline (untagged spec)" ? ":untagged" : "";
|
|
1458
|
+
const allowed = EXPECTED_INCONCLUSIVE[`${backend}:${dialect}${variant}`] ?? [];
|
|
1459
|
+
const undecided = (result.inconclusive ?? []).filter((entry) => !allowed.some((prefix) => entry.startsWith(`${prefix}:`)));
|
|
1460
|
+
const ok = isBaseline
|
|
1461
|
+
? result.spurious.length === 0 &&
|
|
1462
|
+
!vacuous &&
|
|
1463
|
+
leaked === 0 &&
|
|
1464
|
+
undecided.length === 0 &&
|
|
1465
|
+
/* An untagged run that finds nothing *and* runs almost nothing proves nothing. */
|
|
1466
|
+
result.checksRun.length >= 10
|
|
1467
|
+
: result.detected && result.spurious.length === 0;
|
|
1468
|
+
if (!ok)
|
|
1469
|
+
failures++;
|
|
1470
|
+
const mark = ok ? "✓" : "✗";
|
|
1471
|
+
const detected = isBaseline
|
|
1472
|
+
? vacuous
|
|
1473
|
+
? "VACUOUS"
|
|
1474
|
+
: result.spurious.length === 0
|
|
1475
|
+
? "clean"
|
|
1476
|
+
: "noisy"
|
|
1477
|
+
: result.detected
|
|
1478
|
+
? "yes"
|
|
1479
|
+
: "NO";
|
|
1480
|
+
const coverage = isBaseline ? `${result.checksRun.length} checks` : (result.expected ?? "—");
|
|
1481
|
+
lines.push(` ${mark} ${result.label.padEnd(26)} ${detected.padEnd(8)} ${String(result.spurious.length).padEnd(9)} ${coverage}`);
|
|
1482
|
+
if (leaked > 0) {
|
|
1483
|
+
lines.push(` left ${leaked} record(s) behind after teardown — a run must not litter the ` + "system under test");
|
|
1484
|
+
}
|
|
1485
|
+
for (const entry of isBaseline ? undecided : []) {
|
|
1486
|
+
lines.push(` inconclusive on a correct backend: ${entry}`);
|
|
1487
|
+
}
|
|
1488
|
+
if (vacuous) {
|
|
1489
|
+
lines.push(` only ${result.checksRun.length} check(s) ran, below the ${floor} this shape ` +
|
|
1490
|
+
"supports — a check that stopped applying reads exactly like a clean result");
|
|
1491
|
+
const missing = ALL_CHECK_IDS.filter((id) => !result.checksRun.includes(id));
|
|
1492
|
+
if (missing.length > 0)
|
|
1493
|
+
lines.push(` did not run: ${missing.join(", ")}`);
|
|
1494
|
+
}
|
|
1495
|
+
if (result.error !== undefined)
|
|
1496
|
+
lines.push(` error: ${result.error}`);
|
|
1497
|
+
for (const finding of result.spurious.slice(0, 4)) {
|
|
1498
|
+
lines.push(` spurious: [${finding.check}] ${finding.summary}`);
|
|
1499
|
+
}
|
|
1500
|
+
if (!isBaseline && !result.detected && result.error === undefined) {
|
|
1501
|
+
const others = result.findings.filter((f) => f.verdict !== "COVERAGE_GAP");
|
|
1502
|
+
lines.push(` missed — reported instead: ${others.map((f) => f.check).join(", ") || "nothing"}`);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
const cases = results.length;
|
|
1506
|
+
const detectionCases = results.filter((r) => r.defect !== null);
|
|
1507
|
+
const detected = detectionCases.filter((r) => r.detected).length;
|
|
1508
|
+
const baseline = results.find((r) => r.defect === null);
|
|
1509
|
+
lines.push("");
|
|
1510
|
+
lines.push(` recall ${detected}/${detectionCases.length} injected defects detected`);
|
|
1511
|
+
lines.push(` precision ${baseline?.spurious.length === 0 ? "clean baseline — no findings against a correct backend" : `${baseline?.spurious.length ?? 0} false positive(s) on the correct backend`}`);
|
|
1512
|
+
lines.push(` ${cases - failures}/${cases} cases passed`);
|
|
1513
|
+
lines.push("");
|
|
1514
|
+
/* A check is proven by having fired against a defect built for it — not by appearing on a
|
|
1515
|
+
* list of checks a defect may trip. Whether every registered check is proven is decided over
|
|
1516
|
+
* the whole run, since some defects only exist in some shapes. */
|
|
1517
|
+
const proven = new Set(results
|
|
1518
|
+
.filter((r) => r.defect !== null && r.detected)
|
|
1519
|
+
.flatMap((r) => r.findings.filter((f) => f.verdict !== "COVERAGE_GAP").map((f) => f.check)));
|
|
1520
|
+
return { failures, proven, text: lines.join("\n") };
|
|
1521
|
+
}
|
|
1522
|
+
/** Registered checks no injected defect made fire anywhere in the run. Each one fails the suite. */
|
|
1523
|
+
export function renderUnproven(proven) {
|
|
1524
|
+
const unproven = ALL_CHECK_IDS.filter((id) => !proven.has(id));
|
|
1525
|
+
if (unproven.length === 0) {
|
|
1526
|
+
return {
|
|
1527
|
+
failures: 0,
|
|
1528
|
+
text: `\n every one of ${ALL_CHECK_IDS.length} registered checks fired on a defect built for it\n`,
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
const lines = ["", ` ✗ ${unproven.length} registered check(s) never fired on an injected defect:`];
|
|
1532
|
+
for (const id of unproven)
|
|
1533
|
+
lines.push(` ${id}`);
|
|
1534
|
+
lines.push(" a check never shown to detect anything proves nothing by passing — give it a defect or delete it");
|
|
1535
|
+
lines.push("");
|
|
1536
|
+
return { failures: unproven.length, text: lines.join("\n") };
|
|
1537
|
+
}
|
|
1538
|
+
//# sourceMappingURL=suite.js.map
|