@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
package/dist/runtime/run.js
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* everything downstream is reported BLOCKED against that single cause, rather than re-failing
|
|
6
6
|
* per case — which is what made prior attempts produce dozens of failures from one broken fixture.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { isSecretHeaderName, registerSecret, registerSecretHeader } from "./redact.js";
|
|
10
|
+
import { buildModel, operationResolver } from "../spec/graph.js";
|
|
9
11
|
import { dereference, loadSpec } from "../spec/load.js";
|
|
10
12
|
import { createPrincipal } from "./auth.js";
|
|
11
13
|
import { createExchangeJournal } from "./exchanges.js";
|
|
@@ -13,9 +15,8 @@ import { DEFAULT_NETWORK_RETRIES, DEFAULT_NETWORK_WAIT_MS, NetworkError, createN
|
|
|
13
15
|
import { resolveBackoff } from "./poll.js";
|
|
14
16
|
import { persistedToPrincipal, snapshotPrincipal } from "./principals.js";
|
|
15
17
|
import { CHECKS } from "./checks.js";
|
|
16
|
-
import { Client } from "./client.js";
|
|
17
|
-
import { FindingCollector } from "./finding.js";
|
|
18
|
-
import { reportFeatureGateSchemaDrift } from "./feature-gate.js";
|
|
18
|
+
import { AuthRefreshError, Client, } from "./client.js";
|
|
19
|
+
import { FindingCollector, isRootCause } from "./finding.js";
|
|
19
20
|
import { formatUniqueSets } from "../spec/extensions.js";
|
|
20
21
|
import { excludedByProfile, resolveProfile } from "./profile.js";
|
|
21
22
|
import { authStepOperationIds, buildScopeReport, createIsAuthProvisioned, GradeLedger, resolveTargetScope, staticSubjects, } from "./scope.js";
|
|
@@ -25,19 +26,6 @@ import { Ledger } from "./teardown.js";
|
|
|
25
26
|
import { SchemaValidator } from "./validate.js";
|
|
26
27
|
import { isOverflowError, overflowFrom } from "./fixture.js";
|
|
27
28
|
import { SeedError, fillPath, listExisting, probeCreateFixtures, resolvePathScope, seedCohort, } from "./world.js";
|
|
28
|
-
function readPointer(body, pointer) {
|
|
29
|
-
const path = pointer
|
|
30
|
-
.replace(/^\$\.?/, "")
|
|
31
|
-
.split(".")
|
|
32
|
-
.filter(Boolean);
|
|
33
|
-
let node = body;
|
|
34
|
-
for (const segment of path) {
|
|
35
|
-
if (node === null || typeof node !== "object")
|
|
36
|
-
return undefined;
|
|
37
|
-
node = node[segment];
|
|
38
|
-
}
|
|
39
|
-
return node;
|
|
40
|
-
}
|
|
41
29
|
function sameTenant(a, b) {
|
|
42
30
|
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])];
|
|
43
31
|
if (keys.length === 0)
|
|
@@ -81,37 +69,6 @@ async function teardownPrincipals(principals, hooks, findings) {
|
|
|
81
69
|
}
|
|
82
70
|
}
|
|
83
71
|
}
|
|
84
|
-
/**
|
|
85
|
-
* A 429 is a finding only when the request that drew it was demonstrably under a rate the
|
|
86
|
-
* *document* declared — `rateLimitSource === "tag"` and the bucket had a free token, meaning oat
|
|
87
|
-
* did not have to wait for one. A 429 against a config-supplied rate is the operator's own guess
|
|
88
|
-
* about the environment, not a claim the API made, so it is paced around and never reported; a
|
|
89
|
-
* 429 that only arrived after oat's own bucket made the request wait means oat's rate model was
|
|
90
|
-
* too generous, which is oat's fault, not the backend's.
|
|
91
|
-
*
|
|
92
|
-
* Grouped by category rather than reported per request: the point is "the declared rate for X is
|
|
93
|
-
* wrong", once, not a flood of identical findings for every request that category serves.
|
|
94
|
-
*/
|
|
95
|
-
function reportRateLimitViolations(client, findings) {
|
|
96
|
-
const byCategory = new Map();
|
|
97
|
-
for (const exchange of client.transcript) {
|
|
98
|
-
if (exchange.status !== 429)
|
|
99
|
-
continue;
|
|
100
|
-
if (exchange.rateLimitSource !== "tag" || exchange.rateLimitHadRoom !== true)
|
|
101
|
-
continue;
|
|
102
|
-
const category = exchange.rateLimitCategory ?? "unknown";
|
|
103
|
-
const list = byCategory.get(category) ?? [];
|
|
104
|
-
list.push(exchange);
|
|
105
|
-
byCategory.set(category, list);
|
|
106
|
-
}
|
|
107
|
-
for (const [category, exchanges] of byCategory) {
|
|
108
|
-
findings.spec("spec.declared-rate-limit-is-honoured", category, `the declared "${category}" rate limit is not honoured`, `${exchanges.length} request(s) to the "${category}" category returned 429 despite oat ` +
|
|
109
|
-
"pacing them within the rate x-rate-limit declares — each had a free token in its own " +
|
|
110
|
-
"bucket at the moment it was sent, so this is not oat outrunning its own model. Either " +
|
|
111
|
-
`the declared rate is wrong, or the backend enforces a stricter one than "${category}" ` +
|
|
112
|
-
"documents.", exchanges.slice(0, 3));
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
72
|
/**
|
|
116
73
|
* Resolves a principal to something that can produce headers on demand.
|
|
117
74
|
*
|
|
@@ -124,6 +81,13 @@ async function resolvePrincipal(principal, model, client, hooks, outOfBand, orig
|
|
|
124
81
|
/* A principal without a flow authenticates by static header — a long-lived API key needs no
|
|
125
82
|
* acquisition at all, and forcing one would be ceremony. */
|
|
126
83
|
if (principal.auth === undefined) {
|
|
84
|
+
/* A static key is a credential like any other: never written down in clear. */
|
|
85
|
+
for (const [name, value] of Object.entries(principal.headers ?? {})) {
|
|
86
|
+
if (!isSecretHeaderName(name) && !/key|token|secret|auth/i.test(name))
|
|
87
|
+
continue;
|
|
88
|
+
registerSecretHeader(name);
|
|
89
|
+
registerSecret(value);
|
|
90
|
+
}
|
|
127
91
|
return {
|
|
128
92
|
headers: () => principal.headers ?? {},
|
|
129
93
|
id: principal.id,
|
|
@@ -166,7 +130,7 @@ async function resolvePrincipal(principal, model, client, hooks, outOfBand, orig
|
|
|
166
130
|
};
|
|
167
131
|
}
|
|
168
132
|
/** Reads whatever the list endpoint already returns, for degraded read-only coverage. */
|
|
169
|
-
async function readExisting(listOp, client, headers, roots) {
|
|
133
|
+
async function readExisting(listOp, model, client, headers, roots) {
|
|
170
134
|
/* Seed with every known root, not only the list route's own parameters. Sibling routes for
|
|
171
135
|
* the same entity are often scoped differently — a global list beside a tenant-scoped item
|
|
172
136
|
* route — and a scope built from the list alone leaves those unresolvable. */
|
|
@@ -175,7 +139,7 @@ async function readExisting(listOp, client, headers, roots) {
|
|
|
175
139
|
if (scope[param] === undefined)
|
|
176
140
|
return { records: [], scope };
|
|
177
141
|
}
|
|
178
|
-
const records = await listExisting(listOp, client, headers, scope);
|
|
142
|
+
const records = await listExisting(listOp, model, client, headers, scope);
|
|
179
143
|
return { records, scope };
|
|
180
144
|
}
|
|
181
145
|
export async function run(options) {
|
|
@@ -190,7 +154,7 @@ export async function run(options) {
|
|
|
190
154
|
let currentCheck;
|
|
191
155
|
let currentEntityIndex;
|
|
192
156
|
let entityTotal;
|
|
193
|
-
const defectCount = () => findings.findings.filter(
|
|
157
|
+
const defectCount = () => findings.findings.filter(isRootCause).length;
|
|
194
158
|
const oldestInflight = () => {
|
|
195
159
|
let oldest;
|
|
196
160
|
for (const probe of inflight) {
|
|
@@ -294,9 +258,11 @@ export async function run(options) {
|
|
|
294
258
|
remember(exchange);
|
|
295
259
|
if (journal !== null) {
|
|
296
260
|
try {
|
|
261
|
+
/* The exchange names its own check and entity: a shared "current check" was wrong
|
|
262
|
+
* whenever checks ran concurrently, and journaled a whole batch under the last one. */
|
|
297
263
|
await journal.record(exchange, {
|
|
298
|
-
...(
|
|
299
|
-
...(
|
|
264
|
+
...(exchange.check === undefined ? {} : { check: exchange.check }),
|
|
265
|
+
...(exchange.subject === undefined ? {} : { entity: exchange.subject }),
|
|
300
266
|
phase: currentPhase,
|
|
301
267
|
});
|
|
302
268
|
}
|
|
@@ -354,10 +320,15 @@ export async function run(options) {
|
|
|
354
320
|
client = new Client(options.baseUrl, options.globalHeaders ?? {}, options.maxInFlight ?? 4, onExchange, rateLimiter, httpHooks, networkClient);
|
|
355
321
|
if (hooks.resolveHeaders !== undefined)
|
|
356
322
|
client.setResolveHeaders(hooks.resolveHeaders);
|
|
357
|
-
|
|
323
|
+
client.setOperationResolver(operationResolver(model));
|
|
324
|
+
const validator = new SchemaValidator(model.defs);
|
|
358
325
|
const ledger = new Ledger();
|
|
359
326
|
const seed = options.seed ?? 1;
|
|
327
|
+
/* Fresh per run: the seed reproduces a run, the nonce keeps two runs' records apart. */
|
|
328
|
+
const nonce = options.nonce ?? randomBytes(4).toString("hex");
|
|
360
329
|
const outOfBand = resolveBackoff(options.outOfBand);
|
|
330
|
+
/* Write paths that have had the whole payload catalog, shared by every entity in the run. */
|
|
331
|
+
const payloadPaths = new Map();
|
|
361
332
|
if (options.principals[0] === undefined)
|
|
362
333
|
throw new Error("oat: at least one principal is required");
|
|
363
334
|
const originClients = await loadOriginClients(options.origins ?? [], hooks, options.maxInFlight ?? 4, options.globalHeaders ?? {}, onExchange, httpHooks, networkClient);
|
|
@@ -398,18 +369,21 @@ export async function run(options) {
|
|
|
398
369
|
const resolved = [];
|
|
399
370
|
try {
|
|
400
371
|
for (const principal of options.principals) {
|
|
401
|
-
resolved.push(await resolvePrincipal(principal, model, client, hooks, outOfBand, originClients.size === 0 ? undefined : originClients));
|
|
372
|
+
resolved.push(await resolvePrincipal(principal, model, client.view({ purpose: "auth" }), hooks, outOfBand, originClients.size === 0 ? undefined : originClients));
|
|
402
373
|
}
|
|
403
374
|
}
|
|
404
375
|
catch (error) {
|
|
405
376
|
if (!isNetworkError(error))
|
|
406
377
|
throw error;
|
|
407
378
|
noteNetwork(error, "auth");
|
|
379
|
+
await journal?.flush().catch(() => undefined);
|
|
408
380
|
return {
|
|
409
381
|
checksOutOfScope: [],
|
|
410
382
|
checksRun: [],
|
|
411
383
|
checksSkipped: [],
|
|
412
384
|
checksSuppressed: [],
|
|
385
|
+
checkNotes: [],
|
|
386
|
+
checkTimings: [],
|
|
413
387
|
client,
|
|
414
388
|
created: 0,
|
|
415
389
|
entitiesTested: [],
|
|
@@ -430,14 +404,51 @@ export async function run(options) {
|
|
|
430
404
|
phase: "auth",
|
|
431
405
|
requests: client.transcript.length,
|
|
432
406
|
});
|
|
407
|
+
/* Every exchange names the principal whose credential it carried. */
|
|
408
|
+
client.setPrincipalResolver((sent) => resolved.find((principal) => {
|
|
409
|
+
const own = principal.headers();
|
|
410
|
+
return Object.keys(own).length > 0 && Object.entries(own).every(([key, value]) => sent[key] === value);
|
|
411
|
+
})?.id);
|
|
433
412
|
const alpha = resolved[0];
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
413
|
+
const ownerOf = (principal) => ({ headers: principal.headers, id: principal.id });
|
|
414
|
+
const alphaOwner = ownerOf(alpha);
|
|
415
|
+
/* A record a principal created and could not remove itself — a member cannot delete — may be
|
|
416
|
+
* removed by a stronger principal of the same tenant, and by nobody else. */
|
|
417
|
+
const fallbackDeleters = (owner) => {
|
|
418
|
+
const creator = resolved.find((principal) => principal.id === owner.id);
|
|
419
|
+
if (creator === undefined)
|
|
420
|
+
return [];
|
|
421
|
+
return resolved
|
|
422
|
+
.filter((principal) => principal.id !== creator.id && sameTenant(principal.roots, creator.roots))
|
|
423
|
+
.sort((a, b) => b.rank - a.rank)
|
|
424
|
+
.map(ownerOf);
|
|
425
|
+
};
|
|
426
|
+
const recorder = (owner) => (entity, id, values) => {
|
|
427
|
+
ledger.record(entity, id, values, owner);
|
|
428
|
+
};
|
|
429
|
+
/**
|
|
430
|
+
* Proactive refreshes, before each entity and before the credentials are persisted. A failure
|
|
431
|
+
* is reported — the backend would not renew a session — and the run carries on to teardown,
|
|
432
|
+
* where an exception here used to end it with every record still in place.
|
|
433
|
+
*/
|
|
434
|
+
const refreshAll = async (where) => {
|
|
435
|
+
for (const principal of resolved) {
|
|
436
|
+
try {
|
|
437
|
+
await principal.runtime?.refreshIfStale();
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
findings.blocked("auth.refresh", where, `refreshing the credential of "${principal.id}" failed`, error instanceof Error ? error.message : String(error));
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return true;
|
|
445
|
+
};
|
|
437
446
|
const entitiesTested = [];
|
|
438
447
|
const checksRun = new Set();
|
|
439
448
|
const checksSkipped = [];
|
|
440
449
|
const checksSuppressed = [];
|
|
450
|
+
const checkTimings = [];
|
|
451
|
+
const checkNotes = [];
|
|
441
452
|
const checksOutOfScope = [];
|
|
442
453
|
const profileExcludes = (operationId) => {
|
|
443
454
|
const op = model.byOperationId.get(operationId);
|
|
@@ -465,6 +476,17 @@ export async function run(options) {
|
|
|
465
476
|
const createOp = model.byOperationId.get(entity.create ?? "");
|
|
466
477
|
if (listOp === undefined)
|
|
467
478
|
return;
|
|
479
|
+
/* Cohort variants the backend refused. A check that stands down for want of cohort data
|
|
480
|
+
* says which variants never arrived, so the gap points at the seed rather than the API. */
|
|
481
|
+
let lostVariants = [];
|
|
482
|
+
/* This entity's own scratch parents, shared by its records and its principals' scopes but by
|
|
483
|
+
* no other entity: entities whose parent chains are disjoint run side by side, and one that
|
|
484
|
+
* shares a parent record with another could see that entity's writes through it. */
|
|
485
|
+
const ancestors = new Map();
|
|
486
|
+
const withLostVariants = (needs) => lostVariants.length === 0 || !/cohort|record/.test(needs)
|
|
487
|
+
? needs
|
|
488
|
+
: `${needs} (the cohort is missing its ${lostVariants.map((v) => `"${v}"`).join(", ")} ` +
|
|
489
|
+
`variant${lostVariants.length === 1 ? "" : "s"}, which failed to seed)`;
|
|
468
490
|
if (networkGate.exhausted)
|
|
469
491
|
return;
|
|
470
492
|
/* Targets this entity's checks can grade. Seeding findings land on them: under --ops a seed
|
|
@@ -487,6 +509,8 @@ export async function run(options) {
|
|
|
487
509
|
currentEntity = entity.name;
|
|
488
510
|
currentCheck = undefined;
|
|
489
511
|
currentPhase = "seed";
|
|
512
|
+
/* Everything this entity's world-building sends is seeding, attributed to the entity. */
|
|
513
|
+
const seeding = client.view({ purpose: "seed", subject: entity.name });
|
|
490
514
|
tick({
|
|
491
515
|
entity: entity.name,
|
|
492
516
|
entityIndex: currentEntityIndex,
|
|
@@ -495,8 +519,8 @@ export async function run(options) {
|
|
|
495
519
|
phase: "seed",
|
|
496
520
|
requests: client.transcript.length,
|
|
497
521
|
});
|
|
498
|
-
|
|
499
|
-
|
|
522
|
+
if (!(await refreshAll(entity.name)))
|
|
523
|
+
return;
|
|
500
524
|
const rootValues = { ...options.roots, ...alpha.roots };
|
|
501
525
|
let scope;
|
|
502
526
|
let records;
|
|
@@ -506,7 +530,7 @@ export async function run(options) {
|
|
|
506
530
|
/* Same fallback a failed create takes below: read-only coverage against whatever
|
|
507
531
|
* already exists beats no coverage, and it is exactly the state most likely to hide a
|
|
508
532
|
* read-path bug. The gap finding excludeOp already reported names the reason. */
|
|
509
|
-
const existing = await readExisting(listOp,
|
|
533
|
+
const existing = await readExisting(listOp, model, seeding, alpha.headers(), {
|
|
510
534
|
...options.roots,
|
|
511
535
|
...alpha.roots,
|
|
512
536
|
});
|
|
@@ -522,7 +546,7 @@ export async function run(options) {
|
|
|
522
546
|
else if (createOp === undefined || authProvisioned) {
|
|
523
547
|
/* Invite is not a fixture create; register / x-fresh-principal already ran in auth.
|
|
524
548
|
* The invite check is the only thing that POSTs a grant, and it uses inviteAs. */
|
|
525
|
-
const existing = await readExisting(listOp,
|
|
549
|
+
const existing = await readExisting(listOp, model, seeding, alpha.headers(), {
|
|
526
550
|
...options.roots,
|
|
527
551
|
...alpha.roots,
|
|
528
552
|
});
|
|
@@ -532,8 +556,12 @@ export async function run(options) {
|
|
|
532
556
|
}
|
|
533
557
|
else {
|
|
534
558
|
try {
|
|
535
|
-
scope = await resolvePathScope(createOp, model,
|
|
559
|
+
scope = await resolvePathScope(createOp, model, seeding, {
|
|
536
560
|
authHeaders: alpha.headers,
|
|
561
|
+
ancestors,
|
|
562
|
+
onCreate: recorder(alphaOwner),
|
|
563
|
+
principal: alpha.id,
|
|
564
|
+
nonce,
|
|
537
565
|
roots: rootValues,
|
|
538
566
|
seed,
|
|
539
567
|
uploads,
|
|
@@ -543,9 +571,13 @@ export async function run(options) {
|
|
|
543
571
|
* a tenant-scoped item route — and a scope built from create alone leaves those
|
|
544
572
|
* unresolvable, which shows up as a wall of "could not complete" gaps. */
|
|
545
573
|
scope.values = { ...rootValues, ...scope.values };
|
|
546
|
-
const cohort = await seedCohort(createOp, model,
|
|
574
|
+
const cohort = await seedCohort(createOp, model, seeding, {
|
|
547
575
|
authHeaders: alpha.headers,
|
|
548
576
|
...(options.cohortSize === undefined ? {} : { cohortSize: options.cohortSize }),
|
|
577
|
+
ancestors,
|
|
578
|
+
onCreate: recorder(alphaOwner),
|
|
579
|
+
principal: alpha.id,
|
|
580
|
+
nonce,
|
|
549
581
|
roots: rootValues,
|
|
550
582
|
seed,
|
|
551
583
|
uploads,
|
|
@@ -553,6 +585,19 @@ export async function run(options) {
|
|
|
553
585
|
if (cohort.uniqueGap !== undefined) {
|
|
554
586
|
entityFindings.gap("world.seed", entity.name, cohort.uniqueGap, cohort.uniqueGap);
|
|
555
587
|
}
|
|
588
|
+
lostVariants = (cohort.failedVariants ?? []).map((failed) => failed.variant);
|
|
589
|
+
for (const failed of cohort.failedVariants ?? []) {
|
|
590
|
+
const said = JSON.stringify(failed.exchange.responseBody).slice(0, 300);
|
|
591
|
+
if (failed.exchange.status >= 500) {
|
|
592
|
+
entityFindings.backend("create.does-not-error", entity.name, `creating the "${failed.variant}" variant of a "${entity.name}" fails with a server error`, `${createOp.operationId} returned ${failed.exchange.status} for a body generated from the ` +
|
|
593
|
+
`documented schema (${said}). The other variants were created, so this input is what ` +
|
|
594
|
+
"breaks the handler. Checks that need this variant's data run without it.", [failed.exchange]);
|
|
595
|
+
}
|
|
596
|
+
else {
|
|
597
|
+
entityFindings.gap("world.seed", entity.name, `the "${failed.variant}" variant of "${entity.name}" was refused with ${failed.exchange.status}`, `${createOp.operationId} refused a body the document permits (${said}). Checks that ` +
|
|
598
|
+
"rely on this variant's values run without them.");
|
|
599
|
+
}
|
|
600
|
+
}
|
|
556
601
|
if (cohort.adopted === true) {
|
|
557
602
|
/* Plan limit after an effect already created the row: keep the id so children
|
|
558
603
|
* (row after extract→table) can seed, but do not assert write-path oracles
|
|
@@ -560,17 +605,10 @@ export async function run(options) {
|
|
|
560
605
|
entityFindings.gap("world.seed", entity.name, `seeding "${entity.name}" hit a plan limit; using an existing same-tenant record`, `${createOp.operationId} returned a plan-limit refusal and the list already ` +
|
|
561
606
|
"had a record — likely an earlier x-effects create. Write-path checks stand " +
|
|
562
607
|
"down rather than treat payment_required as a backend defect.");
|
|
608
|
+
/* Adopted, not created: what oat did create was recorded as it happened, and
|
|
609
|
+
* this record is somebody else's to keep. */
|
|
563
610
|
records = cohort.records;
|
|
564
611
|
degraded = true;
|
|
565
|
-
for (const ancestor of scope.created) {
|
|
566
|
-
ledger.record(ancestor.entity, ancestor.id, scope.values);
|
|
567
|
-
}
|
|
568
|
-
for (const record of records) {
|
|
569
|
-
const id = record[entity.identity ?? "id"];
|
|
570
|
-
if (typeof id === "string" || typeof id === "number") {
|
|
571
|
-
ledger.record(entity.name, String(id), scope.values);
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
612
|
}
|
|
575
613
|
else if (cohort.uniqueAdopted === true) {
|
|
576
614
|
/* Unique 409 on the first variant with a same-tenant list: adopt like 402,
|
|
@@ -584,24 +622,14 @@ export async function run(options) {
|
|
|
584
622
|
records = cohort.records;
|
|
585
623
|
degraded = true;
|
|
586
624
|
uniqueAdopted = true;
|
|
587
|
-
for (const ancestor of scope.created) {
|
|
588
|
-
ledger.record(ancestor.entity, ancestor.id, scope.values);
|
|
589
|
-
}
|
|
590
|
-
for (const record of records) {
|
|
591
|
-
const id = record[entity.identity ?? "id"];
|
|
592
|
-
if (typeof id === "string" || typeof id === "number") {
|
|
593
|
-
ledger.record(entity.name, String(id), scope.values);
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
625
|
}
|
|
597
626
|
else if (cohort.featureGate !== null) {
|
|
598
627
|
/* Same degradation a profile-excluded create takes: the tag said this
|
|
599
628
|
* principal cannot create the row, so a correct 403 is coverage, not a
|
|
600
|
-
* seed defect.
|
|
601
|
-
reportFeatureGateSchemaDrift(entityFindings, validator, createOp, model.rawOperations.get(createOp.operationId), cohort.featureGate.exchange, entity.name);
|
|
629
|
+
* seed defect. schema.error-response-matches-document still judges its body. */
|
|
602
630
|
entityFindings.gap("world.seed", entity.name, `seeding "${entity.name}" is gated by ${cohort.featureGate.detail}`, `${cohort.featureGate.detail}. Checks that need a row oat created stand down ` +
|
|
603
631
|
"rather than treat the documented 403 as a defect.");
|
|
604
|
-
const existing = await readExisting(listOp,
|
|
632
|
+
const existing = await readExisting(listOp, model, seeding, alpha.headers(), {
|
|
605
633
|
...options.roots,
|
|
606
634
|
...alpha.roots,
|
|
607
635
|
});
|
|
@@ -614,15 +642,9 @@ export async function run(options) {
|
|
|
614
642
|
degraded = true;
|
|
615
643
|
}
|
|
616
644
|
else {
|
|
645
|
+
/* Every ancestor and cohort record was ledgered as it was created, in creation
|
|
646
|
+
* order — the unwind reverses it, so children go before their parents. */
|
|
617
647
|
records = cohort.records;
|
|
618
|
-
/* Ancestors first, then the cohort — the unwind reverses this, so children are
|
|
619
|
-
* always removed before the parents they hang from. */
|
|
620
|
-
for (const ancestor of scope.created) {
|
|
621
|
-
ledger.record(ancestor.entity, ancestor.id, scope.values);
|
|
622
|
-
}
|
|
623
|
-
for (const record of records) {
|
|
624
|
-
ledger.record(entity.name, String(record[entity.identity ?? "id"]), scope.values);
|
|
625
|
-
}
|
|
626
648
|
}
|
|
627
649
|
}
|
|
628
650
|
catch (error) {
|
|
@@ -633,7 +655,7 @@ export async function run(options) {
|
|
|
633
655
|
if (isOverflowError(error)) {
|
|
634
656
|
const overflow = overflowFrom(error, createOp.operationId);
|
|
635
657
|
entityFindings.gap("world.seed", entity.name, overflow.message, overflow.message);
|
|
636
|
-
const existing = await readExisting(listOp,
|
|
658
|
+
const existing = await readExisting(listOp, model, seeding, alpha.headers(), {
|
|
637
659
|
...options.roots,
|
|
638
660
|
...alpha.roots,
|
|
639
661
|
});
|
|
@@ -652,7 +674,10 @@ export async function run(options) {
|
|
|
652
674
|
const failedOp = (error instanceof SeedError ? error.operationId : undefined) ?? createOp.operationId;
|
|
653
675
|
const evidence = error instanceof SeedError && error.exchange !== undefined
|
|
654
676
|
? [error.exchange]
|
|
655
|
-
: client.transcript
|
|
677
|
+
: await Promise.all(client.transcript
|
|
678
|
+
.filter((e) => e.status >= 500)
|
|
679
|
+
.slice(-1)
|
|
680
|
+
.map((e) => client.hydrate(e)));
|
|
656
681
|
/* Under --ops a create nobody targeted is support. Its failure leaves the targets
|
|
657
682
|
* ungraded — blocked, with the evidence — but it is not what this run was asked to
|
|
658
683
|
* judge, so it is not reported as a defect of its own. */
|
|
@@ -670,7 +695,7 @@ export async function run(options) {
|
|
|
670
695
|
/* Fall back to whatever already exists. A backend whose create is broken can still
|
|
671
696
|
* have a working list, and read-only coverage beats no coverage — this is exactly
|
|
672
697
|
* the state in which a read-path bug is most likely to be sitting undiscovered. */
|
|
673
|
-
const existing = await readExisting(listOp,
|
|
698
|
+
const existing = await readExisting(listOp, model, seeding, alpha.headers(), {
|
|
674
699
|
...options.roots,
|
|
675
700
|
...alpha.roots,
|
|
676
701
|
});
|
|
@@ -688,8 +713,13 @@ export async function run(options) {
|
|
|
688
713
|
const actorOf = async (principal, seedOffset) => {
|
|
689
714
|
const roots = { ...options.roots, ...principal.roots };
|
|
690
715
|
try {
|
|
691
|
-
const next = await resolvePathScope(listOp, model,
|
|
716
|
+
const next = await resolvePathScope(listOp, model, seeding, {
|
|
692
717
|
authHeaders: principal.headers,
|
|
718
|
+
/* Created as this principal, so removed as this principal. */
|
|
719
|
+
ancestors,
|
|
720
|
+
onCreate: recorder(ownerOf(principal)),
|
|
721
|
+
principal: principal.id,
|
|
722
|
+
nonce,
|
|
693
723
|
roots,
|
|
694
724
|
seed: seed + seedOffset,
|
|
695
725
|
uploads: worldUploads(seedOffset),
|
|
@@ -745,9 +775,22 @@ export async function run(options) {
|
|
|
745
775
|
* reporting that it had cleaned up everything it made. Recording centrally rather than at
|
|
746
776
|
* each call site means a new check cannot forget: the wrapper sees every request.
|
|
747
777
|
*/
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
:
|
|
778
|
+
let createPath;
|
|
779
|
+
try {
|
|
780
|
+
createPath = createOp === undefined ? undefined : fillPath(createOp.path, scope.values);
|
|
781
|
+
}
|
|
782
|
+
catch {
|
|
783
|
+
createPath = undefined;
|
|
784
|
+
}
|
|
785
|
+
/* Whoever sent the request owns what it created: matched on the credential it carried. */
|
|
786
|
+
const ownerByHeaders = (sent) => {
|
|
787
|
+
const rendered = JSON.stringify(typeof sent === "function" ? sent() : (sent ?? {}));
|
|
788
|
+
const match = resolved.find((principal) => JSON.stringify(principal.headers()) === rendered);
|
|
789
|
+
return match === undefined ? alphaOwner : ownerOf(match);
|
|
790
|
+
};
|
|
791
|
+
const tracked = (base) => createOp === undefined || degraded
|
|
792
|
+
? base
|
|
793
|
+
: new Proxy(base, {
|
|
751
794
|
get(target, property, receiver) {
|
|
752
795
|
if (property !== "request")
|
|
753
796
|
return Reflect.get(target, property, receiver);
|
|
@@ -755,15 +798,18 @@ export async function run(options) {
|
|
|
755
798
|
const exchange = await target.request(method, path, options ?? {});
|
|
756
799
|
if (method.toUpperCase() !== "POST" || exchange.status >= 300)
|
|
757
800
|
return exchange;
|
|
801
|
+
/* Only this entity's own create makes one of its records. Any other 2xx
|
|
802
|
+
* POST carrying an `id` — an invite, an action — names something else, and
|
|
803
|
+
* on an integer-id API deleting by that number removes an unrelated row. */
|
|
804
|
+
if (path !== createPath)
|
|
805
|
+
return exchange;
|
|
758
806
|
const body = exchange.responseBody;
|
|
759
807
|
if (body === null || typeof body !== "object")
|
|
760
808
|
return exchange;
|
|
761
809
|
const id = body[entity.identity ?? "id"];
|
|
762
810
|
if (typeof id !== "string" && typeof id !== "number")
|
|
763
811
|
return exchange;
|
|
764
|
-
|
|
765
|
-
* on an already-removed record — and missing one is not. */
|
|
766
|
-
ledger.record(entity.name, String(id), scope.values);
|
|
812
|
+
ledger.record(entity.name, String(id), scope.values, ownerByHeaders(options?.headers));
|
|
767
813
|
return exchange;
|
|
768
814
|
};
|
|
769
815
|
},
|
|
@@ -814,9 +860,11 @@ export async function run(options) {
|
|
|
814
860
|
inScope: (op) => runScope.inScope(op.operationId),
|
|
815
861
|
hooks,
|
|
816
862
|
outOfBand,
|
|
863
|
+
payloads: { policy: options.payloads ?? "per-write-path", ran: payloadPaths },
|
|
817
864
|
auth: alpha.headers,
|
|
865
|
+
recordCreated: recorder(alphaOwner),
|
|
818
866
|
...(alpha.runtime === undefined ? {} : { refreshIfStale: alpha.runtime.refreshIfStale }),
|
|
819
|
-
client:
|
|
867
|
+
client: tracked(client.view({ purpose: "assertion", subject: entity.name })),
|
|
820
868
|
collectionKey: listOp.collection?.key ?? null,
|
|
821
869
|
/* In degraded mode oat did not write these records, so it has no oracle for them —
|
|
822
870
|
* every write-path check must sit out rather than assert against data it did not
|
|
@@ -840,6 +888,7 @@ export async function run(options) {
|
|
|
840
888
|
softDelete: model.operations.find((op) => op.entity === entity.name && op.softDelete !== null)?.softDelete ??
|
|
841
889
|
listOp.softDelete,
|
|
842
890
|
seed,
|
|
891
|
+
nonce,
|
|
843
892
|
updateOp: degraded || updateExcluded ? undefined : updateOpModel,
|
|
844
893
|
uploads,
|
|
845
894
|
validator,
|
|
@@ -858,12 +907,20 @@ export async function run(options) {
|
|
|
858
907
|
entitiesTested.push(entity.name);
|
|
859
908
|
/* Subjects each check grades on this entity: in scope, and not excluded by the profile. */
|
|
860
909
|
const gradedBy = new Map();
|
|
910
|
+
/* What each check's plan gathered, and how each check ended, on this entity. */
|
|
911
|
+
const plans = new Map();
|
|
912
|
+
const outcomes = new Map();
|
|
861
913
|
const runOne = async (check) => {
|
|
862
914
|
const graded = gradedBy.get(check.id) ?? [];
|
|
863
915
|
let judged;
|
|
916
|
+
/* Its own client: every exchange names this check, even while a batch runs concurrently.
|
|
917
|
+
* A check that does not mutate gets one that refuses to write. */
|
|
918
|
+
const stamp = { check: check.id, purpose: "assertion", subject: entity.name };
|
|
919
|
+
const attributed = graded.length === 0 ? findings : findings.attributed(graded);
|
|
864
920
|
const view = {
|
|
865
921
|
...ctx,
|
|
866
|
-
|
|
922
|
+
client: tracked(check.mutates === true ? client.view(stamp) : client.readOnlyView(stamp)),
|
|
923
|
+
findings: attributed.ownedBy(check.id),
|
|
867
924
|
judged: (operationIds) => {
|
|
868
925
|
judged ??= new Set();
|
|
869
926
|
for (const id of operationIds)
|
|
@@ -881,15 +938,45 @@ export async function run(options) {
|
|
|
881
938
|
phase: "test",
|
|
882
939
|
requests: client.transcript.length,
|
|
883
940
|
});
|
|
941
|
+
const start = findings.findings.length;
|
|
942
|
+
const began = performance.now();
|
|
943
|
+
let outcome;
|
|
884
944
|
try {
|
|
885
|
-
await check.run(view);
|
|
945
|
+
outcome = await check.run(view, plans.get(check.id));
|
|
886
946
|
}
|
|
887
947
|
catch (error) {
|
|
888
948
|
if (isNetworkError(error)) {
|
|
889
949
|
noteNetwork(error, entity.name);
|
|
950
|
+
outcomes.set(check.id, { kind: "unresolved", reason: "the network failed" });
|
|
890
951
|
return;
|
|
891
952
|
}
|
|
892
|
-
|
|
953
|
+
if (error instanceof AuthRefreshError) {
|
|
954
|
+
outcomes.set(check.id, { kind: "unresolved", reason: "a credential could not be refreshed" });
|
|
955
|
+
attributed.blocked("auth.refresh", entity.name, `refreshing the credential${error.principal === undefined ? "" : ` of "${error.principal}"`} failed`, `${error.message}. ${check.id} could not complete.`);
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
outcome = view.findings.gap(check.id, entity.name, `check "${check.id}" could not complete`, error instanceof Error ? error.message : String(error));
|
|
959
|
+
}
|
|
960
|
+
checkTimings.push({ check: check.id, entity: entity.name, ms: Math.round(performance.now() - began) });
|
|
961
|
+
/* A check that reported along the way and then fell through to "asserted" did not
|
|
962
|
+
* assert anything: what it reported is the outcome. */
|
|
963
|
+
if (outcome.kind === "asserted") {
|
|
964
|
+
const reported = findings.findings
|
|
965
|
+
.slice(start)
|
|
966
|
+
.find((f) => f.check === check.id && f.entity === entity.name && isRootCause(f));
|
|
967
|
+
if (reported !== undefined)
|
|
968
|
+
outcome = { kind: "finding", verdict: reported.verdict };
|
|
969
|
+
}
|
|
970
|
+
outcomes.set(check.id, outcome);
|
|
971
|
+
if (outcome.kind === "asserted" && outcome.note !== undefined) {
|
|
972
|
+
checkNotes.push({ check: check.id, entity: entity.name, note: outcome.note });
|
|
973
|
+
}
|
|
974
|
+
if (outcome.kind === "stood-down") {
|
|
975
|
+
/* Not a pass: the property was never tested here, and the report says why. */
|
|
976
|
+
const needs = withLostVariants(outcome.reason);
|
|
977
|
+
checksSkipped.push({ check: check.id, entity: entity.name, needs });
|
|
978
|
+
grades.skipped(graded, needs);
|
|
979
|
+
return;
|
|
893
980
|
}
|
|
894
981
|
grades.graded(judged === undefined ? graded : graded.filter((id) => judged?.has(id) === true), check.id);
|
|
895
982
|
};
|
|
@@ -911,7 +998,13 @@ export async function run(options) {
|
|
|
911
998
|
const suppressedBy = new Map();
|
|
912
999
|
const suppressed = (check) => {
|
|
913
1000
|
for (const dependency of check.dependsOn ?? []) {
|
|
914
|
-
|
|
1001
|
+
/* Only a failed premise suppresses. A dependency that stood down, could not reach a
|
|
1002
|
+
* verdict, or was blocked says nothing about whether its premise holds. */
|
|
1003
|
+
const outcome = outcomes.get(dependency);
|
|
1004
|
+
const failed = outcome === undefined
|
|
1005
|
+
? /* Not run as a check here, but reported against — during seeding, say. */
|
|
1006
|
+
findings.findings.some((f) => f.check === dependency && f.entity === entity.name && isRootCause(f))
|
|
1007
|
+
: outcome.kind === "finding";
|
|
915
1008
|
const inherited = suppressedBy.get(dependency);
|
|
916
1009
|
if (!failed && inherited === undefined)
|
|
917
1010
|
continue;
|
|
@@ -940,6 +1033,8 @@ export async function run(options) {
|
|
|
940
1033
|
await Promise.all(pending.map(runOne));
|
|
941
1034
|
};
|
|
942
1035
|
for (const check of CHECKS) {
|
|
1036
|
+
if (options.signal?.aborted === true)
|
|
1037
|
+
break;
|
|
943
1038
|
const graded = check.subjects(entity, model).filter((id) => runScope.inScope(id) && !profileExcludes(id));
|
|
944
1039
|
/* Out of scope is not a skip: the check could have run, this run was not asked to. */
|
|
945
1040
|
if (runScope.mode === "targeted" && graded.length === 0) {
|
|
@@ -947,15 +1042,19 @@ export async function run(options) {
|
|
|
947
1042
|
continue;
|
|
948
1043
|
}
|
|
949
1044
|
gradedBy.set(check.id, graded);
|
|
950
|
-
|
|
1045
|
+
const planned = check.plan(ctx);
|
|
1046
|
+
if (!planned.ok) {
|
|
951
1047
|
/* Recorded, not dropped: on an API shaped unlike the fixture this is most of the
|
|
952
1048
|
* suite, and a silent skip reads exactly like a clean result. */
|
|
953
|
-
const needs = check.needs ?? "an unstated precondition";
|
|
1049
|
+
const needs = withLostVariants(planned.needs ?? check.needs ?? "an unstated precondition");
|
|
954
1050
|
checksSkipped.push({ check: check.id, entity: entity.name, needs });
|
|
955
1051
|
grades.skipped(graded, needs);
|
|
956
1052
|
continue;
|
|
957
1053
|
}
|
|
958
|
-
|
|
1054
|
+
plans.set(check.id, planned.value);
|
|
1055
|
+
/* A writer runs alone; so does a check that judges the transcript, once everything
|
|
1056
|
+
* queued before it has finished sending. */
|
|
1057
|
+
if (check.mutates === true || check.judgesTranscript === true) {
|
|
959
1058
|
await flush();
|
|
960
1059
|
if (suppressed(check))
|
|
961
1060
|
continue;
|
|
@@ -972,22 +1071,55 @@ export async function run(options) {
|
|
|
972
1071
|
await flush();
|
|
973
1072
|
};
|
|
974
1073
|
/*
|
|
975
|
-
*
|
|
976
|
-
*
|
|
977
|
-
*
|
|
978
|
-
*
|
|
979
|
-
*
|
|
1074
|
+
* Checks inside an entity stay ordered because cascade suppression consults findings already
|
|
1075
|
+
* reported for it — concurrent checks would let a root cause and its consequences race and
|
|
1076
|
+
* both be reported. Nested graphs couple entities too: a child create in flight while a
|
|
1077
|
+
* parent page-walk runs invents pagination findings. So entities that can observe each
|
|
1078
|
+
* other share a lane and run in series, and only lanes run side by side.
|
|
980
1079
|
*/
|
|
981
1080
|
const queue = runScope.entities;
|
|
982
1081
|
entityTotal = queue.length;
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1082
|
+
let interrupted = false;
|
|
1083
|
+
let started = 0;
|
|
1084
|
+
let stopNoted = false;
|
|
1085
|
+
/* Entities that share nothing run side by side; see `entityLanes`. */
|
|
1086
|
+
const runLane = async (lane) => {
|
|
1087
|
+
for (const entity of lane) {
|
|
1088
|
+
/* Stopped from outside — Ctrl-C, a deadline. Nothing more is tested, and everything
|
|
1089
|
+
* made so far is still removed below. */
|
|
1090
|
+
if (options.signal?.aborted === true || networkGate.exhausted) {
|
|
1091
|
+
if (stopNoted)
|
|
1092
|
+
return;
|
|
1093
|
+
stopNoted = true;
|
|
1094
|
+
const left = queue.length - started;
|
|
1095
|
+
if (options.signal?.aborted === true) {
|
|
1096
|
+
interrupted = true;
|
|
1097
|
+
findings.gap("run.interrupted", "run", `stopped before ${left} remaining entit${left === 1 ? "y" : "ies"}`, "the run was interrupted; what was tested is reported, and what oat created is removed");
|
|
1098
|
+
}
|
|
1099
|
+
else {
|
|
1100
|
+
findings.blocked("net.unreachable", "run", `skipped ${left} remaining entit${left === 1 ? "y" : "ies"}`, "network down");
|
|
1101
|
+
}
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
started += 1;
|
|
1105
|
+
currentEntityIndex = started;
|
|
1106
|
+
try {
|
|
1107
|
+
await testEntity(entity);
|
|
1108
|
+
}
|
|
1109
|
+
catch (error) {
|
|
1110
|
+
/* An error oat did not expect must not skip teardown: it is reported, and the run
|
|
1111
|
+
* carries on to remove what it made and write its report. */
|
|
1112
|
+
findings.blocked("run.error", entity.name, `testing "${entity.name}" stopped on an unexpected error`, errorText(error));
|
|
1113
|
+
}
|
|
988
1114
|
}
|
|
989
|
-
|
|
990
|
-
|
|
1115
|
+
};
|
|
1116
|
+
try {
|
|
1117
|
+
await Promise.all(entityLanes(model, queue).map(runLane));
|
|
1118
|
+
}
|
|
1119
|
+
catch (error) {
|
|
1120
|
+
/* Each entity's errors are caught where it runs; this is the scheduling around them. What
|
|
1121
|
+
* was created is still removed below, and the report still written. */
|
|
1122
|
+
findings.blocked("run.error", "run", "testing stopped on an unexpected error", errorText(error));
|
|
991
1123
|
}
|
|
992
1124
|
/* Unwind after every check has run, never per case: a check may legitimately depend on records
|
|
993
1125
|
* another one created, and tearing down early turns that into a phantom defect. */
|
|
@@ -998,25 +1130,32 @@ export async function run(options) {
|
|
|
998
1130
|
phase: "teardown",
|
|
999
1131
|
requests: client.transcript.length,
|
|
1000
1132
|
});
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1133
|
+
let teardown = null;
|
|
1134
|
+
if (options.keepFixtures !== true && ledger.size > 0) {
|
|
1135
|
+
try {
|
|
1136
|
+
teardown = await ledger.unwind(model, client.view({ purpose: "teardown" }), fallbackDeleters, (done, total, item) => {
|
|
1137
|
+
if (done % 25 !== 0 && done !== total)
|
|
1138
|
+
return;
|
|
1139
|
+
tick({
|
|
1140
|
+
entity: item.entity,
|
|
1141
|
+
message: `${done}/${total} ${item.entity} ${item.id}`,
|
|
1142
|
+
phase: "teardown",
|
|
1143
|
+
requests: client.transcript.length,
|
|
1144
|
+
});
|
|
1011
1145
|
});
|
|
1012
|
-
}
|
|
1146
|
+
}
|
|
1147
|
+
catch (error) {
|
|
1148
|
+
findings.blocked("world.teardown", "run", `teardown stopped on an unexpected error with ${ledger.size} record(s) ledgered`, errorText(error));
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1013
1151
|
if (teardown !== null && teardown.unsupported.length > 0) {
|
|
1014
1152
|
findings.gap("world.teardown", teardown.unsupported.join(", "), "records created during the run could not be removed", `no delete operation is reachable for ${teardown.unsupported.join(", ")}, so this run left ` +
|
|
1015
1153
|
"fixtures behind. Declare x-cleanup to name the route that removes them.");
|
|
1016
1154
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1155
|
+
await refreshAll("run");
|
|
1156
|
+
/* Every check has judged the transcript; bodies moved out of memory are no longer needed. */
|
|
1157
|
+
await client.dispose();
|
|
1158
|
+
await journal?.flush().catch(() => undefined);
|
|
1020
1159
|
const persisted = resolved.map((principal) => snapshotPrincipal({
|
|
1021
1160
|
headers: principal.headers,
|
|
1022
1161
|
id: principal.id,
|
|
@@ -1027,7 +1166,12 @@ export async function run(options) {
|
|
|
1027
1166
|
}));
|
|
1028
1167
|
const coverage = scopeReport();
|
|
1029
1168
|
if ((options.origins ?? []).length > 0 && options.skipPrincipalTeardown !== true) {
|
|
1030
|
-
|
|
1169
|
+
try {
|
|
1170
|
+
await runSecondaryOrigins(options, persisted, findings, checksRun, checksSkipped, checksSuppressed, checksOutOfScope, entitiesTested, runScope, coverage);
|
|
1171
|
+
}
|
|
1172
|
+
catch (error) {
|
|
1173
|
+
findings.blocked("run.error", "origins", "the secondary origins stopped on an unexpected error", errorText(error));
|
|
1174
|
+
}
|
|
1031
1175
|
}
|
|
1032
1176
|
if (options.skipPrincipalTeardown !== true) {
|
|
1033
1177
|
await teardownPrincipals(resolved, hooks, findings);
|
|
@@ -1038,6 +1182,8 @@ export async function run(options) {
|
|
|
1038
1182
|
requests: client.transcript.length,
|
|
1039
1183
|
});
|
|
1040
1184
|
return {
|
|
1185
|
+
checkNotes,
|
|
1186
|
+
checkTimings,
|
|
1041
1187
|
checksOutOfScope,
|
|
1042
1188
|
checksRun: [...checksRun].sort(),
|
|
1043
1189
|
checksSkipped,
|
|
@@ -1053,6 +1199,7 @@ export async function run(options) {
|
|
|
1053
1199
|
profileExclusions,
|
|
1054
1200
|
scope: coverage,
|
|
1055
1201
|
teardown,
|
|
1202
|
+
...(interrupted ? { interrupted } : {}),
|
|
1056
1203
|
...(journal === null ? {} : { exchanges: { count: journal.count } }),
|
|
1057
1204
|
...(networkOutcome === undefined ? {} : { network: networkOutcome }),
|
|
1058
1205
|
};
|
|
@@ -1074,6 +1221,9 @@ async function loadOriginClients(origins, hooks, maxInFlight, globalHeaders, onE
|
|
|
1074
1221
|
}
|
|
1075
1222
|
return map;
|
|
1076
1223
|
}
|
|
1224
|
+
function errorText(error) {
|
|
1225
|
+
return error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
1226
|
+
}
|
|
1077
1227
|
async function runSecondaryOrigins(options, persisted, findings, checksRun, checksSkipped, checksSuppressed, checksOutOfScope, entitiesTested, runScope, coverage) {
|
|
1078
1228
|
for (const origin of options.origins ?? []) {
|
|
1079
1229
|
/* Targets were resolved against every origin up front; an origin none of them name has
|
|
@@ -1138,4 +1288,62 @@ async function runSecondaryOrigins(options, persisted, findings, checksRun, chec
|
|
|
1138
1288
|
}
|
|
1139
1289
|
}
|
|
1140
1290
|
}
|
|
1291
|
+
/**
|
|
1292
|
+
* Entities grouped into lanes that can be tested side by side.
|
|
1293
|
+
*
|
|
1294
|
+
* Two entities share a lane when one can observe the other's writes: one's collection lives
|
|
1295
|
+
* under the other's (a child, created inside a parent oat also makes), an operation of one
|
|
1296
|
+
* invalidates a route of the other, or one's effects, waits or async polls land on the other.
|
|
1297
|
+
* Inside a lane entities keep their order and run one after another; lanes share nothing, so
|
|
1298
|
+
* nothing one does can turn up as a finding in another.
|
|
1299
|
+
*/
|
|
1300
|
+
export function entityLanes(model, entities) {
|
|
1301
|
+
const names = entities.map((entity) => entity.name);
|
|
1302
|
+
const parent = new Map(names.map((name) => [name, name]));
|
|
1303
|
+
const find = (name) => {
|
|
1304
|
+
let root = name;
|
|
1305
|
+
while (parent.get(root) !== root)
|
|
1306
|
+
root = parent.get(root) ?? root;
|
|
1307
|
+
return root;
|
|
1308
|
+
};
|
|
1309
|
+
const join = (a, b) => {
|
|
1310
|
+
if (a === null || a === undefined || b === null || b === undefined)
|
|
1311
|
+
return;
|
|
1312
|
+
if (!parent.has(a) || !parent.has(b))
|
|
1313
|
+
return;
|
|
1314
|
+
parent.set(find(a), find(b));
|
|
1315
|
+
};
|
|
1316
|
+
const collection = (entity) => {
|
|
1317
|
+
const op = model.byOperationId.get(entity.list ?? entity.create ?? "");
|
|
1318
|
+
return op?.path.replace(/\/+$/, "");
|
|
1319
|
+
};
|
|
1320
|
+
for (const a of entities) {
|
|
1321
|
+
const pathA = collection(a);
|
|
1322
|
+
for (const b of entities) {
|
|
1323
|
+
const pathB = collection(b);
|
|
1324
|
+
if (a === b || pathA === undefined || pathB === undefined)
|
|
1325
|
+
continue;
|
|
1326
|
+
if (pathB.startsWith(`${pathA}/`))
|
|
1327
|
+
join(a.name, b.name);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
for (const op of model.operations) {
|
|
1331
|
+
for (const route of op.invalidates)
|
|
1332
|
+
join(op.entity, model.byRoute.get(route)?.entity);
|
|
1333
|
+
for (const effect of op.effects)
|
|
1334
|
+
join(op.entity, effect.entity);
|
|
1335
|
+
if (op.wait !== null)
|
|
1336
|
+
join(op.entity, model.byOperationId.get(op.wait.operationId)?.entity);
|
|
1337
|
+
if (op.async !== null) {
|
|
1338
|
+
const poll = model.byOperationId.get(op.async.poll) ?? model.byRoute.get(op.async.poll);
|
|
1339
|
+
join(op.entity, poll?.entity);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
const lanes = new Map();
|
|
1343
|
+
for (const entity of entities) {
|
|
1344
|
+
const root = find(entity.name);
|
|
1345
|
+
lanes.set(root, [...(lanes.get(root) ?? []), entity]);
|
|
1346
|
+
}
|
|
1347
|
+
return [...lanes.values()];
|
|
1348
|
+
}
|
|
1141
1349
|
//# sourceMappingURL=run.js.map
|