@lovrozagar/oat 0.10.0 → 0.12.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 +8 -9
- package/dist/runtime/auth.d.ts.map +1 -1
- package/dist/runtime/auth.js +60 -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 +88 -20
- package/dist/runtime/checks.d.ts.map +1 -1
- package/dist/runtime/checks.js +3288 -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 +374 -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 +11 -1
- package/dist/spec/extensions.d.ts.map +1 -1
- package/dist/spec/extensions.js +10 -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 +144 -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 +403 -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,18 +26,27 @@ 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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Static principals have no auth runtime. Copy the configured headers so adopting a credential
|
|
31
|
+
* does not mutate the config object, and keep a `Bearer ` prefix when that is how the header
|
|
32
|
+
* was already written.
|
|
33
|
+
*/
|
|
34
|
+
function holdHeaders(initial) {
|
|
35
|
+
let current = initial === undefined ? {} : { ...initial };
|
|
36
|
+
return {
|
|
37
|
+
adoptCredential: (token) => {
|
|
38
|
+
if (token === "")
|
|
39
|
+
return;
|
|
40
|
+
const key = Object.keys(current).find((name) => name.toLowerCase() === "authorization") ?? "authorization";
|
|
41
|
+
const previous = current[key];
|
|
42
|
+
const value = previous === undefined || /^bearer\s+/i.test(previous) ? `Bearer ${token}` : token;
|
|
43
|
+
current = { ...current, [key]: value };
|
|
44
|
+
registerSecretHeader(key);
|
|
45
|
+
registerSecret(token);
|
|
46
|
+
registerSecret(value);
|
|
47
|
+
},
|
|
48
|
+
headers: () => current,
|
|
49
|
+
};
|
|
40
50
|
}
|
|
41
51
|
function sameTenant(a, b) {
|
|
42
52
|
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])];
|
|
@@ -81,37 +91,6 @@ async function teardownPrincipals(principals, hooks, findings) {
|
|
|
81
91
|
}
|
|
82
92
|
}
|
|
83
93
|
}
|
|
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
94
|
/**
|
|
116
95
|
* Resolves a principal to something that can produce headers on demand.
|
|
117
96
|
*
|
|
@@ -124,8 +103,17 @@ async function resolvePrincipal(principal, model, client, hooks, outOfBand, orig
|
|
|
124
103
|
/* A principal without a flow authenticates by static header — a long-lived API key needs no
|
|
125
104
|
* acquisition at all, and forcing one would be ceremony. */
|
|
126
105
|
if (principal.auth === undefined) {
|
|
106
|
+
/* A static key is a credential like any other: never written down in clear. */
|
|
107
|
+
for (const [name, value] of Object.entries(principal.headers ?? {})) {
|
|
108
|
+
if (!isSecretHeaderName(name) && !/key|token|secret|auth/i.test(name))
|
|
109
|
+
continue;
|
|
110
|
+
registerSecretHeader(name);
|
|
111
|
+
registerSecret(value);
|
|
112
|
+
}
|
|
113
|
+
const held = holdHeaders(principal.headers);
|
|
127
114
|
return {
|
|
128
|
-
|
|
115
|
+
adoptCredential: held.adoptCredential,
|
|
116
|
+
headers: held.headers,
|
|
129
117
|
id: principal.id,
|
|
130
118
|
inviteAs: principal.inviteAs,
|
|
131
119
|
rank: principal.rank ?? 0,
|
|
@@ -156,6 +144,7 @@ async function resolvePrincipal(principal, model, client, hooks, outOfBand, orig
|
|
|
156
144
|
refreshIfStale: runtime.refreshIfStale,
|
|
157
145
|
});
|
|
158
146
|
return {
|
|
147
|
+
adoptCredential: (token) => runtime.adoptCredential(token),
|
|
159
148
|
headers,
|
|
160
149
|
id: principal.id,
|
|
161
150
|
inviteAs: principal.inviteAs,
|
|
@@ -166,7 +155,7 @@ async function resolvePrincipal(principal, model, client, hooks, outOfBand, orig
|
|
|
166
155
|
};
|
|
167
156
|
}
|
|
168
157
|
/** Reads whatever the list endpoint already returns, for degraded read-only coverage. */
|
|
169
|
-
async function readExisting(listOp, client, headers, roots) {
|
|
158
|
+
async function readExisting(listOp, model, client, headers, roots) {
|
|
170
159
|
/* Seed with every known root, not only the list route's own parameters. Sibling routes for
|
|
171
160
|
* the same entity are often scoped differently — a global list beside a tenant-scoped item
|
|
172
161
|
* route — and a scope built from the list alone leaves those unresolvable. */
|
|
@@ -175,7 +164,7 @@ async function readExisting(listOp, client, headers, roots) {
|
|
|
175
164
|
if (scope[param] === undefined)
|
|
176
165
|
return { records: [], scope };
|
|
177
166
|
}
|
|
178
|
-
const records = await listExisting(listOp, client, headers, scope);
|
|
167
|
+
const records = await listExisting(listOp, model, client, headers, scope);
|
|
179
168
|
return { records, scope };
|
|
180
169
|
}
|
|
181
170
|
export async function run(options) {
|
|
@@ -190,7 +179,7 @@ export async function run(options) {
|
|
|
190
179
|
let currentCheck;
|
|
191
180
|
let currentEntityIndex;
|
|
192
181
|
let entityTotal;
|
|
193
|
-
const defectCount = () => findings.findings.filter(
|
|
182
|
+
const defectCount = () => findings.findings.filter(isRootCause).length;
|
|
194
183
|
const oldestInflight = () => {
|
|
195
184
|
let oldest;
|
|
196
185
|
for (const probe of inflight) {
|
|
@@ -294,9 +283,11 @@ export async function run(options) {
|
|
|
294
283
|
remember(exchange);
|
|
295
284
|
if (journal !== null) {
|
|
296
285
|
try {
|
|
286
|
+
/* The exchange names its own check and entity: a shared "current check" was wrong
|
|
287
|
+
* whenever checks ran concurrently, and journaled a whole batch under the last one. */
|
|
297
288
|
await journal.record(exchange, {
|
|
298
|
-
...(
|
|
299
|
-
...(
|
|
289
|
+
...(exchange.check === undefined ? {} : { check: exchange.check }),
|
|
290
|
+
...(exchange.subject === undefined ? {} : { entity: exchange.subject }),
|
|
300
291
|
phase: currentPhase,
|
|
301
292
|
});
|
|
302
293
|
}
|
|
@@ -354,10 +345,15 @@ export async function run(options) {
|
|
|
354
345
|
client = new Client(options.baseUrl, options.globalHeaders ?? {}, options.maxInFlight ?? 4, onExchange, rateLimiter, httpHooks, networkClient);
|
|
355
346
|
if (hooks.resolveHeaders !== undefined)
|
|
356
347
|
client.setResolveHeaders(hooks.resolveHeaders);
|
|
357
|
-
|
|
348
|
+
client.setOperationResolver(operationResolver(model));
|
|
349
|
+
const validator = new SchemaValidator(model.defs);
|
|
358
350
|
const ledger = new Ledger();
|
|
359
351
|
const seed = options.seed ?? 1;
|
|
352
|
+
/* Fresh per run: the seed reproduces a run, the nonce keeps two runs' records apart. */
|
|
353
|
+
const nonce = options.nonce ?? randomBytes(4).toString("hex");
|
|
360
354
|
const outOfBand = resolveBackoff(options.outOfBand);
|
|
355
|
+
/* Write paths that have had the whole payload catalog, shared by every entity in the run. */
|
|
356
|
+
const payloadPaths = new Map();
|
|
361
357
|
if (options.principals[0] === undefined)
|
|
362
358
|
throw new Error("oat: at least one principal is required");
|
|
363
359
|
const originClients = await loadOriginClients(options.origins ?? [], hooks, options.maxInFlight ?? 4, options.globalHeaders ?? {}, onExchange, httpHooks, networkClient);
|
|
@@ -398,18 +394,21 @@ export async function run(options) {
|
|
|
398
394
|
const resolved = [];
|
|
399
395
|
try {
|
|
400
396
|
for (const principal of options.principals) {
|
|
401
|
-
resolved.push(await resolvePrincipal(principal, model, client, hooks, outOfBand, originClients.size === 0 ? undefined : originClients));
|
|
397
|
+
resolved.push(await resolvePrincipal(principal, model, client.view({ purpose: "auth" }), hooks, outOfBand, originClients.size === 0 ? undefined : originClients));
|
|
402
398
|
}
|
|
403
399
|
}
|
|
404
400
|
catch (error) {
|
|
405
401
|
if (!isNetworkError(error))
|
|
406
402
|
throw error;
|
|
407
403
|
noteNetwork(error, "auth");
|
|
404
|
+
await journal?.flush().catch(() => undefined);
|
|
408
405
|
return {
|
|
409
406
|
checksOutOfScope: [],
|
|
410
407
|
checksRun: [],
|
|
411
408
|
checksSkipped: [],
|
|
412
409
|
checksSuppressed: [],
|
|
410
|
+
checkNotes: [],
|
|
411
|
+
checkTimings: [],
|
|
413
412
|
client,
|
|
414
413
|
created: 0,
|
|
415
414
|
entitiesTested: [],
|
|
@@ -430,14 +429,51 @@ export async function run(options) {
|
|
|
430
429
|
phase: "auth",
|
|
431
430
|
requests: client.transcript.length,
|
|
432
431
|
});
|
|
432
|
+
/* Every exchange names the principal whose credential it carried. */
|
|
433
|
+
client.setPrincipalResolver((sent) => resolved.find((principal) => {
|
|
434
|
+
const own = principal.headers();
|
|
435
|
+
return Object.keys(own).length > 0 && Object.entries(own).every(([key, value]) => sent[key] === value);
|
|
436
|
+
})?.id);
|
|
433
437
|
const alpha = resolved[0];
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
438
|
+
const ownerOf = (principal) => ({ headers: principal.headers, id: principal.id });
|
|
439
|
+
const alphaOwner = ownerOf(alpha);
|
|
440
|
+
/* A record a principal created and could not remove itself — a member cannot delete — may be
|
|
441
|
+
* removed by a stronger principal of the same tenant, and by nobody else. */
|
|
442
|
+
const fallbackDeleters = (owner) => {
|
|
443
|
+
const creator = resolved.find((principal) => principal.id === owner.id);
|
|
444
|
+
if (creator === undefined)
|
|
445
|
+
return [];
|
|
446
|
+
return resolved
|
|
447
|
+
.filter((principal) => principal.id !== creator.id && sameTenant(principal.roots, creator.roots))
|
|
448
|
+
.sort((a, b) => b.rank - a.rank)
|
|
449
|
+
.map(ownerOf);
|
|
450
|
+
};
|
|
451
|
+
const recorder = (owner) => (entity, id, values) => {
|
|
452
|
+
ledger.record(entity, id, values, owner);
|
|
453
|
+
};
|
|
454
|
+
/**
|
|
455
|
+
* Proactive refreshes, before each entity and before the credentials are persisted. A failure
|
|
456
|
+
* is reported — the backend would not renew a session — and the run carries on to teardown,
|
|
457
|
+
* where an exception here used to end it with every record still in place.
|
|
458
|
+
*/
|
|
459
|
+
const refreshAll = async (where) => {
|
|
460
|
+
for (const principal of resolved) {
|
|
461
|
+
try {
|
|
462
|
+
await principal.runtime?.refreshIfStale();
|
|
463
|
+
}
|
|
464
|
+
catch (error) {
|
|
465
|
+
findings.blocked("auth.refresh", where, `refreshing the credential of "${principal.id}" failed`, error instanceof Error ? error.message : String(error));
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return true;
|
|
470
|
+
};
|
|
437
471
|
const entitiesTested = [];
|
|
438
472
|
const checksRun = new Set();
|
|
439
473
|
const checksSkipped = [];
|
|
440
474
|
const checksSuppressed = [];
|
|
475
|
+
const checkTimings = [];
|
|
476
|
+
const checkNotes = [];
|
|
441
477
|
const checksOutOfScope = [];
|
|
442
478
|
const profileExcludes = (operationId) => {
|
|
443
479
|
const op = model.byOperationId.get(operationId);
|
|
@@ -465,6 +501,17 @@ export async function run(options) {
|
|
|
465
501
|
const createOp = model.byOperationId.get(entity.create ?? "");
|
|
466
502
|
if (listOp === undefined)
|
|
467
503
|
return;
|
|
504
|
+
/* Cohort variants the backend refused. A check that stands down for want of cohort data
|
|
505
|
+
* says which variants never arrived, so the gap points at the seed rather than the API. */
|
|
506
|
+
let lostVariants = [];
|
|
507
|
+
/* This entity's own scratch parents, shared by its records and its principals' scopes but by
|
|
508
|
+
* no other entity: entities whose parent chains are disjoint run side by side, and one that
|
|
509
|
+
* shares a parent record with another could see that entity's writes through it. */
|
|
510
|
+
const ancestors = new Map();
|
|
511
|
+
const withLostVariants = (needs) => lostVariants.length === 0 || !/cohort|record/.test(needs)
|
|
512
|
+
? needs
|
|
513
|
+
: `${needs} (the cohort is missing its ${lostVariants.map((v) => `"${v}"`).join(", ")} ` +
|
|
514
|
+
`variant${lostVariants.length === 1 ? "" : "s"}, which failed to seed)`;
|
|
468
515
|
if (networkGate.exhausted)
|
|
469
516
|
return;
|
|
470
517
|
/* Targets this entity's checks can grade. Seeding findings land on them: under --ops a seed
|
|
@@ -487,6 +534,8 @@ export async function run(options) {
|
|
|
487
534
|
currentEntity = entity.name;
|
|
488
535
|
currentCheck = undefined;
|
|
489
536
|
currentPhase = "seed";
|
|
537
|
+
/* Everything this entity's world-building sends is seeding, attributed to the entity. */
|
|
538
|
+
const seeding = client.view({ purpose: "seed", subject: entity.name });
|
|
490
539
|
tick({
|
|
491
540
|
entity: entity.name,
|
|
492
541
|
entityIndex: currentEntityIndex,
|
|
@@ -495,8 +544,8 @@ export async function run(options) {
|
|
|
495
544
|
phase: "seed",
|
|
496
545
|
requests: client.transcript.length,
|
|
497
546
|
});
|
|
498
|
-
|
|
499
|
-
|
|
547
|
+
if (!(await refreshAll(entity.name)))
|
|
548
|
+
return;
|
|
500
549
|
const rootValues = { ...options.roots, ...alpha.roots };
|
|
501
550
|
let scope;
|
|
502
551
|
let records;
|
|
@@ -506,7 +555,7 @@ export async function run(options) {
|
|
|
506
555
|
/* Same fallback a failed create takes below: read-only coverage against whatever
|
|
507
556
|
* already exists beats no coverage, and it is exactly the state most likely to hide a
|
|
508
557
|
* read-path bug. The gap finding excludeOp already reported names the reason. */
|
|
509
|
-
const existing = await readExisting(listOp,
|
|
558
|
+
const existing = await readExisting(listOp, model, seeding, alpha.headers(), {
|
|
510
559
|
...options.roots,
|
|
511
560
|
...alpha.roots,
|
|
512
561
|
});
|
|
@@ -522,7 +571,7 @@ export async function run(options) {
|
|
|
522
571
|
else if (createOp === undefined || authProvisioned) {
|
|
523
572
|
/* Invite is not a fixture create; register / x-fresh-principal already ran in auth.
|
|
524
573
|
* The invite check is the only thing that POSTs a grant, and it uses inviteAs. */
|
|
525
|
-
const existing = await readExisting(listOp,
|
|
574
|
+
const existing = await readExisting(listOp, model, seeding, alpha.headers(), {
|
|
526
575
|
...options.roots,
|
|
527
576
|
...alpha.roots,
|
|
528
577
|
});
|
|
@@ -532,8 +581,12 @@ export async function run(options) {
|
|
|
532
581
|
}
|
|
533
582
|
else {
|
|
534
583
|
try {
|
|
535
|
-
scope = await resolvePathScope(createOp, model,
|
|
584
|
+
scope = await resolvePathScope(createOp, model, seeding, {
|
|
536
585
|
authHeaders: alpha.headers,
|
|
586
|
+
ancestors,
|
|
587
|
+
onCreate: recorder(alphaOwner),
|
|
588
|
+
principal: alpha.id,
|
|
589
|
+
nonce,
|
|
537
590
|
roots: rootValues,
|
|
538
591
|
seed,
|
|
539
592
|
uploads,
|
|
@@ -543,9 +596,13 @@ export async function run(options) {
|
|
|
543
596
|
* a tenant-scoped item route — and a scope built from create alone leaves those
|
|
544
597
|
* unresolvable, which shows up as a wall of "could not complete" gaps. */
|
|
545
598
|
scope.values = { ...rootValues, ...scope.values };
|
|
546
|
-
const cohort = await seedCohort(createOp, model,
|
|
599
|
+
const cohort = await seedCohort(createOp, model, seeding, {
|
|
547
600
|
authHeaders: alpha.headers,
|
|
548
601
|
...(options.cohortSize === undefined ? {} : { cohortSize: options.cohortSize }),
|
|
602
|
+
ancestors,
|
|
603
|
+
onCreate: recorder(alphaOwner),
|
|
604
|
+
principal: alpha.id,
|
|
605
|
+
nonce,
|
|
549
606
|
roots: rootValues,
|
|
550
607
|
seed,
|
|
551
608
|
uploads,
|
|
@@ -553,6 +610,19 @@ export async function run(options) {
|
|
|
553
610
|
if (cohort.uniqueGap !== undefined) {
|
|
554
611
|
entityFindings.gap("world.seed", entity.name, cohort.uniqueGap, cohort.uniqueGap);
|
|
555
612
|
}
|
|
613
|
+
lostVariants = (cohort.failedVariants ?? []).map((failed) => failed.variant);
|
|
614
|
+
for (const failed of cohort.failedVariants ?? []) {
|
|
615
|
+
const said = JSON.stringify(failed.exchange.responseBody).slice(0, 300);
|
|
616
|
+
if (failed.exchange.status >= 500) {
|
|
617
|
+
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 ` +
|
|
618
|
+
`documented schema (${said}). The other variants were created, so this input is what ` +
|
|
619
|
+
"breaks the handler. Checks that need this variant's data run without it.", [failed.exchange]);
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
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 ` +
|
|
623
|
+
"rely on this variant's values run without them.");
|
|
624
|
+
}
|
|
625
|
+
}
|
|
556
626
|
if (cohort.adopted === true) {
|
|
557
627
|
/* Plan limit after an effect already created the row: keep the id so children
|
|
558
628
|
* (row after extract→table) can seed, but do not assert write-path oracles
|
|
@@ -560,17 +630,10 @@ export async function run(options) {
|
|
|
560
630
|
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
631
|
"had a record — likely an earlier x-effects create. Write-path checks stand " +
|
|
562
632
|
"down rather than treat payment_required as a backend defect.");
|
|
633
|
+
/* Adopted, not created: what oat did create was recorded as it happened, and
|
|
634
|
+
* this record is somebody else's to keep. */
|
|
563
635
|
records = cohort.records;
|
|
564
636
|
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
637
|
}
|
|
575
638
|
else if (cohort.uniqueAdopted === true) {
|
|
576
639
|
/* Unique 409 on the first variant with a same-tenant list: adopt like 402,
|
|
@@ -584,24 +647,14 @@ export async function run(options) {
|
|
|
584
647
|
records = cohort.records;
|
|
585
648
|
degraded = true;
|
|
586
649
|
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
650
|
}
|
|
597
651
|
else if (cohort.featureGate !== null) {
|
|
598
652
|
/* Same degradation a profile-excluded create takes: the tag said this
|
|
599
653
|
* 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);
|
|
654
|
+
* seed defect. schema.error-response-matches-document still judges its body. */
|
|
602
655
|
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
656
|
"rather than treat the documented 403 as a defect.");
|
|
604
|
-
const existing = await readExisting(listOp,
|
|
657
|
+
const existing = await readExisting(listOp, model, seeding, alpha.headers(), {
|
|
605
658
|
...options.roots,
|
|
606
659
|
...alpha.roots,
|
|
607
660
|
});
|
|
@@ -614,15 +667,9 @@ export async function run(options) {
|
|
|
614
667
|
degraded = true;
|
|
615
668
|
}
|
|
616
669
|
else {
|
|
670
|
+
/* Every ancestor and cohort record was ledgered as it was created, in creation
|
|
671
|
+
* order — the unwind reverses it, so children go before their parents. */
|
|
617
672
|
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
673
|
}
|
|
627
674
|
}
|
|
628
675
|
catch (error) {
|
|
@@ -633,7 +680,7 @@ export async function run(options) {
|
|
|
633
680
|
if (isOverflowError(error)) {
|
|
634
681
|
const overflow = overflowFrom(error, createOp.operationId);
|
|
635
682
|
entityFindings.gap("world.seed", entity.name, overflow.message, overflow.message);
|
|
636
|
-
const existing = await readExisting(listOp,
|
|
683
|
+
const existing = await readExisting(listOp, model, seeding, alpha.headers(), {
|
|
637
684
|
...options.roots,
|
|
638
685
|
...alpha.roots,
|
|
639
686
|
});
|
|
@@ -652,7 +699,10 @@ export async function run(options) {
|
|
|
652
699
|
const failedOp = (error instanceof SeedError ? error.operationId : undefined) ?? createOp.operationId;
|
|
653
700
|
const evidence = error instanceof SeedError && error.exchange !== undefined
|
|
654
701
|
? [error.exchange]
|
|
655
|
-
: client.transcript
|
|
702
|
+
: await Promise.all(client.transcript
|
|
703
|
+
.filter((e) => e.status >= 500)
|
|
704
|
+
.slice(-1)
|
|
705
|
+
.map((e) => client.hydrate(e)));
|
|
656
706
|
/* Under --ops a create nobody targeted is support. Its failure leaves the targets
|
|
657
707
|
* ungraded — blocked, with the evidence — but it is not what this run was asked to
|
|
658
708
|
* judge, so it is not reported as a defect of its own. */
|
|
@@ -670,7 +720,7 @@ export async function run(options) {
|
|
|
670
720
|
/* Fall back to whatever already exists. A backend whose create is broken can still
|
|
671
721
|
* have a working list, and read-only coverage beats no coverage — this is exactly
|
|
672
722
|
* the state in which a read-path bug is most likely to be sitting undiscovered. */
|
|
673
|
-
const existing = await readExisting(listOp,
|
|
723
|
+
const existing = await readExisting(listOp, model, seeding, alpha.headers(), {
|
|
674
724
|
...options.roots,
|
|
675
725
|
...alpha.roots,
|
|
676
726
|
});
|
|
@@ -688,13 +738,19 @@ export async function run(options) {
|
|
|
688
738
|
const actorOf = async (principal, seedOffset) => {
|
|
689
739
|
const roots = { ...options.roots, ...principal.roots };
|
|
690
740
|
try {
|
|
691
|
-
const next = await resolvePathScope(listOp, model,
|
|
741
|
+
const next = await resolvePathScope(listOp, model, seeding, {
|
|
692
742
|
authHeaders: principal.headers,
|
|
743
|
+
/* Created as this principal, so removed as this principal. */
|
|
744
|
+
ancestors,
|
|
745
|
+
onCreate: recorder(ownerOf(principal)),
|
|
746
|
+
principal: principal.id,
|
|
747
|
+
nonce,
|
|
693
748
|
roots,
|
|
694
749
|
seed: seed + seedOffset,
|
|
695
750
|
uploads: worldUploads(seedOffset),
|
|
696
751
|
});
|
|
697
752
|
return {
|
|
753
|
+
adoptCredential: principal.adoptCredential,
|
|
698
754
|
headers: principal.headers,
|
|
699
755
|
id: principal.id,
|
|
700
756
|
inviteAs: principal.inviteAs,
|
|
@@ -706,6 +762,7 @@ export async function run(options) {
|
|
|
706
762
|
}
|
|
707
763
|
catch {
|
|
708
764
|
return {
|
|
765
|
+
adoptCredential: principal.adoptCredential,
|
|
709
766
|
headers: principal.headers,
|
|
710
767
|
id: principal.id,
|
|
711
768
|
inviteAs: principal.inviteAs,
|
|
@@ -718,6 +775,7 @@ export async function run(options) {
|
|
|
718
775
|
};
|
|
719
776
|
const actors = [
|
|
720
777
|
{
|
|
778
|
+
adoptCredential: alpha.adoptCredential,
|
|
721
779
|
headers: alpha.headers,
|
|
722
780
|
id: alpha.id,
|
|
723
781
|
inviteAs: alpha.inviteAs,
|
|
@@ -745,9 +803,22 @@ export async function run(options) {
|
|
|
745
803
|
* reporting that it had cleaned up everything it made. Recording centrally rather than at
|
|
746
804
|
* each call site means a new check cannot forget: the wrapper sees every request.
|
|
747
805
|
*/
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
:
|
|
806
|
+
let createPath;
|
|
807
|
+
try {
|
|
808
|
+
createPath = createOp === undefined ? undefined : fillPath(createOp.path, scope.values);
|
|
809
|
+
}
|
|
810
|
+
catch {
|
|
811
|
+
createPath = undefined;
|
|
812
|
+
}
|
|
813
|
+
/* Whoever sent the request owns what it created: matched on the credential it carried. */
|
|
814
|
+
const ownerByHeaders = (sent) => {
|
|
815
|
+
const rendered = JSON.stringify(typeof sent === "function" ? sent() : (sent ?? {}));
|
|
816
|
+
const match = resolved.find((principal) => JSON.stringify(principal.headers()) === rendered);
|
|
817
|
+
return match === undefined ? alphaOwner : ownerOf(match);
|
|
818
|
+
};
|
|
819
|
+
const tracked = (base) => createOp === undefined || degraded
|
|
820
|
+
? base
|
|
821
|
+
: new Proxy(base, {
|
|
751
822
|
get(target, property, receiver) {
|
|
752
823
|
if (property !== "request")
|
|
753
824
|
return Reflect.get(target, property, receiver);
|
|
@@ -755,15 +826,18 @@ export async function run(options) {
|
|
|
755
826
|
const exchange = await target.request(method, path, options ?? {});
|
|
756
827
|
if (method.toUpperCase() !== "POST" || exchange.status >= 300)
|
|
757
828
|
return exchange;
|
|
829
|
+
/* Only this entity's own create makes one of its records. Any other 2xx
|
|
830
|
+
* POST carrying an `id` — an invite, an action — names something else, and
|
|
831
|
+
* on an integer-id API deleting by that number removes an unrelated row. */
|
|
832
|
+
if (path !== createPath)
|
|
833
|
+
return exchange;
|
|
758
834
|
const body = exchange.responseBody;
|
|
759
835
|
if (body === null || typeof body !== "object")
|
|
760
836
|
return exchange;
|
|
761
837
|
const id = body[entity.identity ?? "id"];
|
|
762
838
|
if (typeof id !== "string" && typeof id !== "number")
|
|
763
839
|
return exchange;
|
|
764
|
-
|
|
765
|
-
* on an already-removed record — and missing one is not. */
|
|
766
|
-
ledger.record(entity.name, String(id), scope.values);
|
|
840
|
+
ledger.record(entity.name, String(id), scope.values, ownerByHeaders(options?.headers));
|
|
767
841
|
return exchange;
|
|
768
842
|
};
|
|
769
843
|
},
|
|
@@ -814,9 +888,11 @@ export async function run(options) {
|
|
|
814
888
|
inScope: (op) => runScope.inScope(op.operationId),
|
|
815
889
|
hooks,
|
|
816
890
|
outOfBand,
|
|
891
|
+
payloads: { policy: options.payloads ?? "per-write-path", ran: payloadPaths },
|
|
817
892
|
auth: alpha.headers,
|
|
893
|
+
recordCreated: recorder(alphaOwner),
|
|
818
894
|
...(alpha.runtime === undefined ? {} : { refreshIfStale: alpha.runtime.refreshIfStale }),
|
|
819
|
-
client:
|
|
895
|
+
client: tracked(client.view({ purpose: "assertion", subject: entity.name })),
|
|
820
896
|
collectionKey: listOp.collection?.key ?? null,
|
|
821
897
|
/* In degraded mode oat did not write these records, so it has no oracle for them —
|
|
822
898
|
* every write-path check must sit out rather than assert against data it did not
|
|
@@ -840,6 +916,7 @@ export async function run(options) {
|
|
|
840
916
|
softDelete: model.operations.find((op) => op.entity === entity.name && op.softDelete !== null)?.softDelete ??
|
|
841
917
|
listOp.softDelete,
|
|
842
918
|
seed,
|
|
919
|
+
nonce,
|
|
843
920
|
updateOp: degraded || updateExcluded ? undefined : updateOpModel,
|
|
844
921
|
uploads,
|
|
845
922
|
validator,
|
|
@@ -858,12 +935,20 @@ export async function run(options) {
|
|
|
858
935
|
entitiesTested.push(entity.name);
|
|
859
936
|
/* Subjects each check grades on this entity: in scope, and not excluded by the profile. */
|
|
860
937
|
const gradedBy = new Map();
|
|
938
|
+
/* What each check's plan gathered, and how each check ended, on this entity. */
|
|
939
|
+
const plans = new Map();
|
|
940
|
+
const outcomes = new Map();
|
|
861
941
|
const runOne = async (check) => {
|
|
862
942
|
const graded = gradedBy.get(check.id) ?? [];
|
|
863
943
|
let judged;
|
|
944
|
+
/* Its own client: every exchange names this check, even while a batch runs concurrently.
|
|
945
|
+
* A check that does not mutate gets one that refuses to write. */
|
|
946
|
+
const stamp = { check: check.id, purpose: "assertion", subject: entity.name };
|
|
947
|
+
const attributed = graded.length === 0 ? findings : findings.attributed(graded);
|
|
864
948
|
const view = {
|
|
865
949
|
...ctx,
|
|
866
|
-
|
|
950
|
+
client: tracked(check.mutates === true ? client.view(stamp) : client.readOnlyView(stamp)),
|
|
951
|
+
findings: attributed.ownedBy(check.id),
|
|
867
952
|
judged: (operationIds) => {
|
|
868
953
|
judged ??= new Set();
|
|
869
954
|
for (const id of operationIds)
|
|
@@ -881,15 +966,45 @@ export async function run(options) {
|
|
|
881
966
|
phase: "test",
|
|
882
967
|
requests: client.transcript.length,
|
|
883
968
|
});
|
|
969
|
+
const start = findings.findings.length;
|
|
970
|
+
const began = performance.now();
|
|
971
|
+
let outcome;
|
|
884
972
|
try {
|
|
885
|
-
await check.run(view);
|
|
973
|
+
outcome = await check.run(view, plans.get(check.id));
|
|
886
974
|
}
|
|
887
975
|
catch (error) {
|
|
888
976
|
if (isNetworkError(error)) {
|
|
889
977
|
noteNetwork(error, entity.name);
|
|
978
|
+
outcomes.set(check.id, { kind: "unresolved", reason: "the network failed" });
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
if (error instanceof AuthRefreshError) {
|
|
982
|
+
outcomes.set(check.id, { kind: "unresolved", reason: "a credential could not be refreshed" });
|
|
983
|
+
attributed.blocked("auth.refresh", entity.name, `refreshing the credential${error.principal === undefined ? "" : ` of "${error.principal}"`} failed`, `${error.message}. ${check.id} could not complete.`);
|
|
890
984
|
return;
|
|
891
985
|
}
|
|
892
|
-
view.findings.gap(check.id, entity.name, `check "${check.id}" could not complete`, error instanceof Error ? error.message : String(error));
|
|
986
|
+
outcome = view.findings.gap(check.id, entity.name, `check "${check.id}" could not complete`, error instanceof Error ? error.message : String(error));
|
|
987
|
+
}
|
|
988
|
+
checkTimings.push({ check: check.id, entity: entity.name, ms: Math.round(performance.now() - began) });
|
|
989
|
+
/* A check that reported along the way and then fell through to "asserted" did not
|
|
990
|
+
* assert anything: what it reported is the outcome. */
|
|
991
|
+
if (outcome.kind === "asserted") {
|
|
992
|
+
const reported = findings.findings
|
|
993
|
+
.slice(start)
|
|
994
|
+
.find((f) => f.check === check.id && f.entity === entity.name && isRootCause(f));
|
|
995
|
+
if (reported !== undefined)
|
|
996
|
+
outcome = { kind: "finding", verdict: reported.verdict };
|
|
997
|
+
}
|
|
998
|
+
outcomes.set(check.id, outcome);
|
|
999
|
+
if (outcome.kind === "asserted" && outcome.note !== undefined) {
|
|
1000
|
+
checkNotes.push({ check: check.id, entity: entity.name, note: outcome.note });
|
|
1001
|
+
}
|
|
1002
|
+
if (outcome.kind === "stood-down") {
|
|
1003
|
+
/* Not a pass: the property was never tested here, and the report says why. */
|
|
1004
|
+
const needs = withLostVariants(outcome.reason);
|
|
1005
|
+
checksSkipped.push({ check: check.id, entity: entity.name, needs });
|
|
1006
|
+
grades.skipped(graded, needs);
|
|
1007
|
+
return;
|
|
893
1008
|
}
|
|
894
1009
|
grades.graded(judged === undefined ? graded : graded.filter((id) => judged?.has(id) === true), check.id);
|
|
895
1010
|
};
|
|
@@ -911,7 +1026,13 @@ export async function run(options) {
|
|
|
911
1026
|
const suppressedBy = new Map();
|
|
912
1027
|
const suppressed = (check) => {
|
|
913
1028
|
for (const dependency of check.dependsOn ?? []) {
|
|
914
|
-
|
|
1029
|
+
/* Only a failed premise suppresses. A dependency that stood down, could not reach a
|
|
1030
|
+
* verdict, or was blocked says nothing about whether its premise holds. */
|
|
1031
|
+
const outcome = outcomes.get(dependency);
|
|
1032
|
+
const failed = outcome === undefined
|
|
1033
|
+
? /* Not run as a check here, but reported against — during seeding, say. */
|
|
1034
|
+
findings.findings.some((f) => f.check === dependency && f.entity === entity.name && isRootCause(f))
|
|
1035
|
+
: outcome.kind === "finding";
|
|
915
1036
|
const inherited = suppressedBy.get(dependency);
|
|
916
1037
|
if (!failed && inherited === undefined)
|
|
917
1038
|
continue;
|
|
@@ -940,6 +1061,8 @@ export async function run(options) {
|
|
|
940
1061
|
await Promise.all(pending.map(runOne));
|
|
941
1062
|
};
|
|
942
1063
|
for (const check of CHECKS) {
|
|
1064
|
+
if (options.signal?.aborted === true)
|
|
1065
|
+
break;
|
|
943
1066
|
const graded = check.subjects(entity, model).filter((id) => runScope.inScope(id) && !profileExcludes(id));
|
|
944
1067
|
/* Out of scope is not a skip: the check could have run, this run was not asked to. */
|
|
945
1068
|
if (runScope.mode === "targeted" && graded.length === 0) {
|
|
@@ -947,15 +1070,19 @@ export async function run(options) {
|
|
|
947
1070
|
continue;
|
|
948
1071
|
}
|
|
949
1072
|
gradedBy.set(check.id, graded);
|
|
950
|
-
|
|
1073
|
+
const planned = check.plan(ctx);
|
|
1074
|
+
if (!planned.ok) {
|
|
951
1075
|
/* Recorded, not dropped: on an API shaped unlike the fixture this is most of the
|
|
952
1076
|
* suite, and a silent skip reads exactly like a clean result. */
|
|
953
|
-
const needs = check.needs ?? "an unstated precondition";
|
|
1077
|
+
const needs = withLostVariants(planned.needs ?? check.needs ?? "an unstated precondition");
|
|
954
1078
|
checksSkipped.push({ check: check.id, entity: entity.name, needs });
|
|
955
1079
|
grades.skipped(graded, needs);
|
|
956
1080
|
continue;
|
|
957
1081
|
}
|
|
958
|
-
|
|
1082
|
+
plans.set(check.id, planned.value);
|
|
1083
|
+
/* A writer runs alone; so does a check that judges the transcript, once everything
|
|
1084
|
+
* queued before it has finished sending. */
|
|
1085
|
+
if (check.mutates === true || check.judgesTranscript === true) {
|
|
959
1086
|
await flush();
|
|
960
1087
|
if (suppressed(check))
|
|
961
1088
|
continue;
|
|
@@ -972,22 +1099,55 @@ export async function run(options) {
|
|
|
972
1099
|
await flush();
|
|
973
1100
|
};
|
|
974
1101
|
/*
|
|
975
|
-
*
|
|
976
|
-
*
|
|
977
|
-
*
|
|
978
|
-
*
|
|
979
|
-
*
|
|
1102
|
+
* Checks inside an entity stay ordered because cascade suppression consults findings already
|
|
1103
|
+
* reported for it — concurrent checks would let a root cause and its consequences race and
|
|
1104
|
+
* both be reported. Nested graphs couple entities too: a child create in flight while a
|
|
1105
|
+
* parent page-walk runs invents pagination findings. So entities that can observe each
|
|
1106
|
+
* other share a lane and run in series, and only lanes run side by side.
|
|
980
1107
|
*/
|
|
981
1108
|
const queue = runScope.entities;
|
|
982
1109
|
entityTotal = queue.length;
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1110
|
+
let interrupted = false;
|
|
1111
|
+
let started = 0;
|
|
1112
|
+
let stopNoted = false;
|
|
1113
|
+
/* Entities that share nothing run side by side; see `entityLanes`. */
|
|
1114
|
+
const runLane = async (lane) => {
|
|
1115
|
+
for (const entity of lane) {
|
|
1116
|
+
/* Stopped from outside — Ctrl-C, a deadline. Nothing more is tested, and everything
|
|
1117
|
+
* made so far is still removed below. */
|
|
1118
|
+
if (options.signal?.aborted === true || networkGate.exhausted) {
|
|
1119
|
+
if (stopNoted)
|
|
1120
|
+
return;
|
|
1121
|
+
stopNoted = true;
|
|
1122
|
+
const left = queue.length - started;
|
|
1123
|
+
if (options.signal?.aborted === true) {
|
|
1124
|
+
interrupted = true;
|
|
1125
|
+
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");
|
|
1126
|
+
}
|
|
1127
|
+
else {
|
|
1128
|
+
findings.blocked("net.unreachable", "run", `skipped ${left} remaining entit${left === 1 ? "y" : "ies"}`, "network down");
|
|
1129
|
+
}
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
started += 1;
|
|
1133
|
+
currentEntityIndex = started;
|
|
1134
|
+
try {
|
|
1135
|
+
await testEntity(entity);
|
|
1136
|
+
}
|
|
1137
|
+
catch (error) {
|
|
1138
|
+
/* An error oat did not expect must not skip teardown: it is reported, and the run
|
|
1139
|
+
* carries on to remove what it made and write its report. */
|
|
1140
|
+
findings.blocked("run.error", entity.name, `testing "${entity.name}" stopped on an unexpected error`, errorText(error));
|
|
1141
|
+
}
|
|
988
1142
|
}
|
|
989
|
-
|
|
990
|
-
|
|
1143
|
+
};
|
|
1144
|
+
try {
|
|
1145
|
+
await Promise.all(entityLanes(model, queue).map(runLane));
|
|
1146
|
+
}
|
|
1147
|
+
catch (error) {
|
|
1148
|
+
/* Each entity's errors are caught where it runs; this is the scheduling around them. What
|
|
1149
|
+
* was created is still removed below, and the report still written. */
|
|
1150
|
+
findings.blocked("run.error", "run", "testing stopped on an unexpected error", errorText(error));
|
|
991
1151
|
}
|
|
992
1152
|
/* Unwind after every check has run, never per case: a check may legitimately depend on records
|
|
993
1153
|
* another one created, and tearing down early turns that into a phantom defect. */
|
|
@@ -998,25 +1158,32 @@ export async function run(options) {
|
|
|
998
1158
|
phase: "teardown",
|
|
999
1159
|
requests: client.transcript.length,
|
|
1000
1160
|
});
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1161
|
+
let teardown = null;
|
|
1162
|
+
if (options.keepFixtures !== true && ledger.size > 0) {
|
|
1163
|
+
try {
|
|
1164
|
+
teardown = await ledger.unwind(model, client.view({ purpose: "teardown" }), fallbackDeleters, (done, total, item) => {
|
|
1165
|
+
if (done % 25 !== 0 && done !== total)
|
|
1166
|
+
return;
|
|
1167
|
+
tick({
|
|
1168
|
+
entity: item.entity,
|
|
1169
|
+
message: `${done}/${total} ${item.entity} ${item.id}`,
|
|
1170
|
+
phase: "teardown",
|
|
1171
|
+
requests: client.transcript.length,
|
|
1172
|
+
});
|
|
1011
1173
|
});
|
|
1012
|
-
}
|
|
1174
|
+
}
|
|
1175
|
+
catch (error) {
|
|
1176
|
+
findings.blocked("world.teardown", "run", `teardown stopped on an unexpected error with ${ledger.size} record(s) ledgered`, errorText(error));
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1013
1179
|
if (teardown !== null && teardown.unsupported.length > 0) {
|
|
1014
1180
|
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
1181
|
"fixtures behind. Declare x-cleanup to name the route that removes them.");
|
|
1016
1182
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1183
|
+
await refreshAll("run");
|
|
1184
|
+
/* Every check has judged the transcript; bodies moved out of memory are no longer needed. */
|
|
1185
|
+
await client.dispose();
|
|
1186
|
+
await journal?.flush().catch(() => undefined);
|
|
1020
1187
|
const persisted = resolved.map((principal) => snapshotPrincipal({
|
|
1021
1188
|
headers: principal.headers,
|
|
1022
1189
|
id: principal.id,
|
|
@@ -1027,7 +1194,12 @@ export async function run(options) {
|
|
|
1027
1194
|
}));
|
|
1028
1195
|
const coverage = scopeReport();
|
|
1029
1196
|
if ((options.origins ?? []).length > 0 && options.skipPrincipalTeardown !== true) {
|
|
1030
|
-
|
|
1197
|
+
try {
|
|
1198
|
+
await runSecondaryOrigins(options, persisted, findings, checksRun, checksSkipped, checksSuppressed, checksOutOfScope, entitiesTested, runScope, coverage);
|
|
1199
|
+
}
|
|
1200
|
+
catch (error) {
|
|
1201
|
+
findings.blocked("run.error", "origins", "the secondary origins stopped on an unexpected error", errorText(error));
|
|
1202
|
+
}
|
|
1031
1203
|
}
|
|
1032
1204
|
if (options.skipPrincipalTeardown !== true) {
|
|
1033
1205
|
await teardownPrincipals(resolved, hooks, findings);
|
|
@@ -1038,6 +1210,8 @@ export async function run(options) {
|
|
|
1038
1210
|
requests: client.transcript.length,
|
|
1039
1211
|
});
|
|
1040
1212
|
return {
|
|
1213
|
+
checkNotes,
|
|
1214
|
+
checkTimings,
|
|
1041
1215
|
checksOutOfScope,
|
|
1042
1216
|
checksRun: [...checksRun].sort(),
|
|
1043
1217
|
checksSkipped,
|
|
@@ -1053,6 +1227,7 @@ export async function run(options) {
|
|
|
1053
1227
|
profileExclusions,
|
|
1054
1228
|
scope: coverage,
|
|
1055
1229
|
teardown,
|
|
1230
|
+
...(interrupted ? { interrupted } : {}),
|
|
1056
1231
|
...(journal === null ? {} : { exchanges: { count: journal.count } }),
|
|
1057
1232
|
...(networkOutcome === undefined ? {} : { network: networkOutcome }),
|
|
1058
1233
|
};
|
|
@@ -1074,6 +1249,9 @@ async function loadOriginClients(origins, hooks, maxInFlight, globalHeaders, onE
|
|
|
1074
1249
|
}
|
|
1075
1250
|
return map;
|
|
1076
1251
|
}
|
|
1252
|
+
function errorText(error) {
|
|
1253
|
+
return error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
1254
|
+
}
|
|
1077
1255
|
async function runSecondaryOrigins(options, persisted, findings, checksRun, checksSkipped, checksSuppressed, checksOutOfScope, entitiesTested, runScope, coverage) {
|
|
1078
1256
|
for (const origin of options.origins ?? []) {
|
|
1079
1257
|
/* Targets were resolved against every origin up front; an origin none of them name has
|
|
@@ -1138,4 +1316,62 @@ async function runSecondaryOrigins(options, persisted, findings, checksRun, chec
|
|
|
1138
1316
|
}
|
|
1139
1317
|
}
|
|
1140
1318
|
}
|
|
1319
|
+
/**
|
|
1320
|
+
* Entities grouped into lanes that can be tested side by side.
|
|
1321
|
+
*
|
|
1322
|
+
* Two entities share a lane when one can observe the other's writes: one's collection lives
|
|
1323
|
+
* under the other's (a child, created inside a parent oat also makes), an operation of one
|
|
1324
|
+
* invalidates a route of the other, or one's effects, waits or async polls land on the other.
|
|
1325
|
+
* Inside a lane entities keep their order and run one after another; lanes share nothing, so
|
|
1326
|
+
* nothing one does can turn up as a finding in another.
|
|
1327
|
+
*/
|
|
1328
|
+
export function entityLanes(model, entities) {
|
|
1329
|
+
const names = entities.map((entity) => entity.name);
|
|
1330
|
+
const parent = new Map(names.map((name) => [name, name]));
|
|
1331
|
+
const find = (name) => {
|
|
1332
|
+
let root = name;
|
|
1333
|
+
while (parent.get(root) !== root)
|
|
1334
|
+
root = parent.get(root) ?? root;
|
|
1335
|
+
return root;
|
|
1336
|
+
};
|
|
1337
|
+
const join = (a, b) => {
|
|
1338
|
+
if (a === null || a === undefined || b === null || b === undefined)
|
|
1339
|
+
return;
|
|
1340
|
+
if (!parent.has(a) || !parent.has(b))
|
|
1341
|
+
return;
|
|
1342
|
+
parent.set(find(a), find(b));
|
|
1343
|
+
};
|
|
1344
|
+
const collection = (entity) => {
|
|
1345
|
+
const op = model.byOperationId.get(entity.list ?? entity.create ?? "");
|
|
1346
|
+
return op?.path.replace(/\/+$/, "");
|
|
1347
|
+
};
|
|
1348
|
+
for (const a of entities) {
|
|
1349
|
+
const pathA = collection(a);
|
|
1350
|
+
for (const b of entities) {
|
|
1351
|
+
const pathB = collection(b);
|
|
1352
|
+
if (a === b || pathA === undefined || pathB === undefined)
|
|
1353
|
+
continue;
|
|
1354
|
+
if (pathB.startsWith(`${pathA}/`))
|
|
1355
|
+
join(a.name, b.name);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
for (const op of model.operations) {
|
|
1359
|
+
for (const route of op.invalidates)
|
|
1360
|
+
join(op.entity, model.byRoute.get(route)?.entity);
|
|
1361
|
+
for (const effect of op.effects)
|
|
1362
|
+
join(op.entity, effect.entity);
|
|
1363
|
+
if (op.wait !== null)
|
|
1364
|
+
join(op.entity, model.byOperationId.get(op.wait.operationId)?.entity);
|
|
1365
|
+
if (op.async !== null) {
|
|
1366
|
+
const poll = model.byOperationId.get(op.async.poll) ?? model.byRoute.get(op.async.poll);
|
|
1367
|
+
join(op.entity, poll?.entity);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
const lanes = new Map();
|
|
1371
|
+
for (const entity of entities) {
|
|
1372
|
+
const root = find(entity.name);
|
|
1373
|
+
lanes.set(root, [...(lanes.get(root) ?? []), entity]);
|
|
1374
|
+
}
|
|
1375
|
+
return [...lanes.values()];
|
|
1376
|
+
}
|
|
1141
1377
|
//# sourceMappingURL=run.js.map
|