@lovrozagar/oat 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -21
- package/dist/cli.js +7 -0
- package/dist/config/define-config.d.ts +95 -1
- package/dist/config/define-config.d.ts.map +1 -1
- package/dist/config/define-config.js +6 -0
- package/dist/config/define-config.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js +3 -1
- package/dist/report/console.d.ts.map +1 -1
- package/dist/report/console.js +2 -0
- package/dist/report/console.js.map +1 -1
- package/dist/report/render.d.ts.map +1 -1
- package/dist/report/render.js +4 -2
- package/dist/report/render.js.map +1 -1
- package/dist/runtime/auth.d.ts +22 -2
- package/dist/runtime/auth.d.ts.map +1 -1
- package/dist/runtime/auth.js +129 -16
- package/dist/runtime/auth.js.map +1 -1
- package/dist/runtime/body.d.ts.map +1 -1
- package/dist/runtime/body.js +6 -3
- package/dist/runtime/body.js.map +1 -1
- package/dist/runtime/checks.d.ts +6 -0
- package/dist/runtime/checks.d.ts.map +1 -1
- package/dist/runtime/checks.js +103 -4
- package/dist/runtime/checks.js.map +1 -1
- package/dist/runtime/client.d.ts +8 -2
- package/dist/runtime/client.d.ts.map +1 -1
- package/dist/runtime/client.js +42 -10
- package/dist/runtime/client.js.map +1 -1
- package/dist/runtime/finding.d.ts +2 -0
- package/dist/runtime/finding.d.ts.map +1 -1
- package/dist/runtime/finding.js.map +1 -1
- package/dist/runtime/input.d.ts +7 -0
- package/dist/runtime/input.d.ts.map +1 -0
- package/dist/runtime/input.js +46 -0
- package/dist/runtime/input.js.map +1 -0
- package/dist/runtime/poll.d.ts +29 -0
- package/dist/runtime/poll.d.ts.map +1 -0
- package/dist/runtime/poll.js +59 -0
- package/dist/runtime/poll.js.map +1 -0
- package/dist/runtime/principals.d.ts +29 -0
- package/dist/runtime/principals.d.ts.map +1 -0
- package/dist/runtime/principals.js +97 -0
- package/dist/runtime/principals.js.map +1 -0
- package/dist/runtime/rate-limit.d.ts +40 -2
- package/dist/runtime/rate-limit.d.ts.map +1 -1
- package/dist/runtime/rate-limit.js +115 -4
- package/dist/runtime/rate-limit.js.map +1 -1
- package/dist/runtime/run.d.ts +10 -1
- package/dist/runtime/run.d.ts.map +1 -1
- package/dist/runtime/run.js +119 -23
- package/dist/runtime/run.js.map +1 -1
- package/dist/runtime/upload.d.ts +1 -0
- package/dist/runtime/upload.d.ts.map +1 -1
- package/dist/runtime/upload.js.map +1 -1
- package/dist/runtime/wait.d.ts +32 -0
- package/dist/runtime/wait.d.ts.map +1 -0
- package/dist/runtime/wait.js +125 -0
- package/dist/runtime/wait.js.map +1 -0
- package/dist/runtime/world.d.ts +16 -0
- package/dist/runtime/world.d.ts.map +1 -1
- package/dist/runtime/world.js +91 -0
- package/dist/runtime/world.js.map +1 -1
- package/dist/spec/extensions.d.ts +16 -0
- package/dist/spec/extensions.d.ts.map +1 -1
- package/dist/spec/extensions.js +24 -1
- package/dist/spec/extensions.js.map +1 -1
- package/dist/spec/graph.d.ts +2 -1
- package/dist/spec/graph.d.ts.map +1 -1
- package/dist/spec/graph.js +2 -1
- package/dist/spec/graph.js.map +1 -1
- package/package.json +4 -2
package/dist/runtime/checks.js
CHANGED
|
@@ -12,7 +12,9 @@ import { encodeForOperation } from "./body.js";
|
|
|
12
12
|
import { STRING_PAYLOADS, payloadFits } from "./payloads.js";
|
|
13
13
|
import { describeFeatureGate, isDocumentedFeatureGateDenial, reportFeatureGateSchemaDrift } from "./feature-gate.js";
|
|
14
14
|
import { driveAsync, inspectStreamAsync, matchesPredicate, resolveAsyncId } from "./async.js";
|
|
15
|
+
import { resolveOutOfBandValue } from "./auth.js";
|
|
15
16
|
import { buildCohort } from "./fixture.js";
|
|
17
|
+
import { driveWait } from "./wait.js";
|
|
16
18
|
import { fillPath } from "./world.js";
|
|
17
19
|
/* ------------------------------------------------------------------- helpers */
|
|
18
20
|
/**
|
|
@@ -33,6 +35,16 @@ function standDownForFeatureGate(ctx, op, exchange, check) {
|
|
|
33
35
|
"down rather than treat the documented 403 as a defect.");
|
|
34
36
|
return true;
|
|
35
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* A 429 that survived Client retries is the bucket, not the backend. The check that needed a
|
|
40
|
+
* 2xx stands down; it is not a defect and it is not a concurrency conclusion.
|
|
41
|
+
*/
|
|
42
|
+
function standDownForRateLimit(ctx, exchange, check) {
|
|
43
|
+
if (exchange.status !== 429)
|
|
44
|
+
return false;
|
|
45
|
+
ctx.findings.unresolved(check, ctx.entityName, "the request was rate-limited (429) after retries, so this check could not complete");
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
36
48
|
/** The list endpoint's derived conventions — parameter roles and envelope spellings. */
|
|
37
49
|
function conv(ctx) {
|
|
38
50
|
return ctx.listOp.conventions;
|
|
@@ -151,6 +163,7 @@ function linkRelations(exchange) {
|
|
|
151
163
|
async function list(ctx, query = {}, auth = ctx.auth, scope = ctx.scope) {
|
|
152
164
|
const exchange = await ctx.client.get(fillPath(ctx.listOp.path, scope), {
|
|
153
165
|
headers: auth(),
|
|
166
|
+
operationId: ctx.listOp.operationId,
|
|
154
167
|
query,
|
|
155
168
|
});
|
|
156
169
|
const body = exchange.responseBody;
|
|
@@ -2591,6 +2604,7 @@ const inviteGrantsThenRevokes = {
|
|
|
2591
2604
|
const invited = await ctx.client.request(inviteOp.method, fillPath(inviteOp.path, resource), {
|
|
2592
2605
|
body: { [spec.granteeField]: delegate.inviteAs },
|
|
2593
2606
|
headers: { ...owner.headers(), "content-type": "application/json" },
|
|
2607
|
+
operationId: inviteOp.operationId,
|
|
2594
2608
|
});
|
|
2595
2609
|
if (standDownForFeatureGate(ctx, inviteOp, invited, this.id))
|
|
2596
2610
|
return;
|
|
@@ -2602,10 +2616,25 @@ const inviteGrantsThenRevokes = {
|
|
|
2602
2616
|
"step is then theatre — anyone who can be named in an invite body is already in.", [invited]);
|
|
2603
2617
|
return;
|
|
2604
2618
|
}
|
|
2605
|
-
const token = pointerValue(invited.responseBody, spec.tokenPointer);
|
|
2606
2619
|
const grantId = pointerValue(invited.responseBody, spec.grantPointer);
|
|
2607
|
-
|
|
2608
|
-
|
|
2620
|
+
let token;
|
|
2621
|
+
if (spec.tokenFrom === "outOfBand") {
|
|
2622
|
+
const kind = spec.tokenKind ?? `${ctx.entityName}-invite`;
|
|
2623
|
+
try {
|
|
2624
|
+
token = await resolveOutOfBandValue(ctx.hooks.resolveOutOfBand, delegate.inviteAs, kind, {
|
|
2625
|
+
label: `invite to ${ctx.entityName}`,
|
|
2626
|
+
outOfBand: ctx.outOfBand,
|
|
2627
|
+
});
|
|
2628
|
+
}
|
|
2629
|
+
catch (error) {
|
|
2630
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, error instanceof Error ? error.message : String(error));
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
else {
|
|
2634
|
+
token = pointerValue(invited.responseBody, spec.tokenPointer);
|
|
2635
|
+
if (token === undefined) {
|
|
2636
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `invite response has no token at ${spec.tokenPointer}`);
|
|
2637
|
+
}
|
|
2609
2638
|
}
|
|
2610
2639
|
const acceptScope = { ...resource, token };
|
|
2611
2640
|
const acceptBody = documentedJsonBody(ctx, acceptOp, acceptScope);
|
|
@@ -2615,6 +2644,7 @@ const inviteGrantsThenRevokes = {
|
|
|
2615
2644
|
...delegate.headers(),
|
|
2616
2645
|
...(acceptBody === undefined ? {} : { "content-type": "application/json" }),
|
|
2617
2646
|
},
|
|
2647
|
+
operationId: acceptOp.operationId,
|
|
2618
2648
|
});
|
|
2619
2649
|
if (standDownForFeatureGate(ctx, acceptOp, accepted, this.id))
|
|
2620
2650
|
return;
|
|
@@ -2637,6 +2667,7 @@ const inviteGrantsThenRevokes = {
|
|
|
2637
2667
|
...owner.headers(),
|
|
2638
2668
|
...(revokeBody === undefined ? {} : { "content-type": "application/json" }),
|
|
2639
2669
|
},
|
|
2670
|
+
operationId: revokeOp.operationId,
|
|
2640
2671
|
});
|
|
2641
2672
|
if (standDownForFeatureGate(ctx, revokeOp, revoked, this.id))
|
|
2642
2673
|
return;
|
|
@@ -3246,6 +3277,10 @@ const noLostUpdate = {
|
|
|
3246
3277
|
return;
|
|
3247
3278
|
if (standDownForFeatureGate(ctx, ctx.updateOp, secondWrite, this.id))
|
|
3248
3279
|
return;
|
|
3280
|
+
if (standDownForRateLimit(ctx, firstWrite, this.id))
|
|
3281
|
+
return;
|
|
3282
|
+
if (standDownForRateLimit(ctx, secondWrite, this.id))
|
|
3283
|
+
return;
|
|
3249
3284
|
if (firstWrite.status >= 300 || secondWrite.status >= 300) {
|
|
3250
3285
|
return ctx.findings.unresolved(this.id, ctx.entityName, `a concurrent PATCH was rejected (${firstWrite.status}, ${secondWrite.status}), so no ` +
|
|
3251
3286
|
"race actually took place");
|
|
@@ -3343,6 +3378,8 @@ const declaredEffectsOccur = {
|
|
|
3343
3378
|
});
|
|
3344
3379
|
if (standDownForFeatureGate(ctx, op, invoked, this.id))
|
|
3345
3380
|
continue;
|
|
3381
|
+
if (standDownForRateLimit(ctx, invoked, this.id))
|
|
3382
|
+
continue;
|
|
3346
3383
|
if (invoked.status >= 400) {
|
|
3347
3384
|
ctx.findings.gap(this.id, ctx.entityName, `${op.operationId} could not be invoked`, `returned ${invoked.status}; its declared effects are unverified`);
|
|
3348
3385
|
continue;
|
|
@@ -3380,6 +3417,61 @@ const declaredEffectsOccur = {
|
|
|
3380
3417
|
}
|
|
3381
3418
|
},
|
|
3382
3419
|
};
|
|
3420
|
+
/**
|
|
3421
|
+
* After a write that declares `x-wait`, poll the named GET until a JSON path is occupied
|
|
3422
|
+
* (or `awaitSideEffect` returns true). Timeout is a backend finding, not a coverage gap.
|
|
3423
|
+
*/
|
|
3424
|
+
const sideEffectArrives = {
|
|
3425
|
+
applicable: (ctx) => ctx.waitOps.length > 0,
|
|
3426
|
+
dependsOn: ["list.read-after-write"],
|
|
3427
|
+
mutates: true,
|
|
3428
|
+
id: "effects.side-effect-arrives",
|
|
3429
|
+
needs: "an operation declaring x-wait",
|
|
3430
|
+
async run(ctx) {
|
|
3431
|
+
for (const op of ctx.waitOps) {
|
|
3432
|
+
const spec = op.wait;
|
|
3433
|
+
if (spec === null)
|
|
3434
|
+
continue;
|
|
3435
|
+
const pollOp = ctx.model.byOperationId.get(spec.operationId);
|
|
3436
|
+
if (pollOp === undefined) {
|
|
3437
|
+
ctx.findings.gap(this.id, ctx.entityName, `${op.operationId} x-wait names "${spec.operationId}", which is not in the document`, "the side effect cannot be observed, so it is not verified");
|
|
3438
|
+
continue;
|
|
3439
|
+
}
|
|
3440
|
+
const body = op.hasRequestBody ? bodyForOp(ctx, op) : undefined;
|
|
3441
|
+
const invoked = await ctx.client.request(op.method, fillPath(op.path, ctx.scope), {
|
|
3442
|
+
headers: ctx.auth(),
|
|
3443
|
+
operationId: op.operationId,
|
|
3444
|
+
...(body === undefined ? {} : await encodeOpBody(ctx, op, body)),
|
|
3445
|
+
});
|
|
3446
|
+
if (standDownForFeatureGate(ctx, op, invoked, this.id))
|
|
3447
|
+
continue;
|
|
3448
|
+
if (standDownForRateLimit(ctx, invoked, this.id))
|
|
3449
|
+
continue;
|
|
3450
|
+
if (invoked.status >= 400) {
|
|
3451
|
+
ctx.findings.gap(this.id, ctx.entityName, `${op.operationId} could not be invoked`, `returned ${invoked.status}; its declared x-wait is unverified`);
|
|
3452
|
+
continue;
|
|
3453
|
+
}
|
|
3454
|
+
const outcome = await driveWait({
|
|
3455
|
+
awaitSideEffect: ctx.hooks.awaitSideEffect,
|
|
3456
|
+
client: ctx.client,
|
|
3457
|
+
headers: ctx.auth,
|
|
3458
|
+
pollOp,
|
|
3459
|
+
record: invoked.responseBody,
|
|
3460
|
+
scope: ctx.scope,
|
|
3461
|
+
spec,
|
|
3462
|
+
writeOpId: op.operationId,
|
|
3463
|
+
...(ctx.refreshIfStale === undefined ? {} : { refreshIfStale: ctx.refreshIfStale }),
|
|
3464
|
+
});
|
|
3465
|
+
if (!outcome.timedOut)
|
|
3466
|
+
continue;
|
|
3467
|
+
ctx.findings.backend(this.id, ctx.entityName, `${op.operationId} side effect did not appear within ${spec.timeoutMs}ms`, `x-wait polls ${spec.operationId}` +
|
|
3468
|
+
(spec.until === undefined ? "" : ` until ${spec.until} is non-empty`) +
|
|
3469
|
+
` and the path was still empty after ${outcome.polls} poll(s) / ${Math.round(outcome.elapsedMs)}ms. ` +
|
|
3470
|
+
"Queue consumers and webhook inboxes are not the same request; a timeout here is a " +
|
|
3471
|
+
"missed delivery, not a coverage gap.", [invoked, ...outcome.exchanges.slice(-2)]);
|
|
3472
|
+
}
|
|
3473
|
+
},
|
|
3474
|
+
};
|
|
3383
3475
|
async function observe(ctx, listOp) {
|
|
3384
3476
|
let path;
|
|
3385
3477
|
try {
|
|
@@ -3590,6 +3682,8 @@ const stringPayloadSurvives = {
|
|
|
3590
3682
|
const control = await writeField(ASCII_PAYLOAD_PROBE, id);
|
|
3591
3683
|
if (standDownForFeatureGate(ctx, writeOp, control, this.id))
|
|
3592
3684
|
return;
|
|
3685
|
+
if (standDownForRateLimit(ctx, control, this.id))
|
|
3686
|
+
return;
|
|
3593
3687
|
if (control.status === 401 || control.status === 403) {
|
|
3594
3688
|
return ctx.findings.unresolved(this.id, ctx.entityName, `the ASCII control write returned ${control.status}, so later refusals cannot be attributed to the payload`);
|
|
3595
3689
|
}
|
|
@@ -3632,7 +3726,9 @@ const stringPayloadSurvives = {
|
|
|
3632
3726
|
evidence.push(written);
|
|
3633
3727
|
continue;
|
|
3634
3728
|
}
|
|
3635
|
-
if (
|
|
3729
|
+
if (standDownForRateLimit(ctx, written, this.id))
|
|
3730
|
+
return;
|
|
3731
|
+
if (written.status === 404 || written.status === 409 || written.status === 415) {
|
|
3636
3732
|
continue;
|
|
3637
3733
|
}
|
|
3638
3734
|
if (written.status >= 400 && written.status < 500) {
|
|
@@ -3731,6 +3827,8 @@ const documentedStatusHonoured = {
|
|
|
3731
3827
|
continue;
|
|
3732
3828
|
if (isDocumentedFeatureGateDenial(op, exchange.status, exchange.responseBody))
|
|
3733
3829
|
continue;
|
|
3830
|
+
if (exchange.status === 429)
|
|
3831
|
+
continue;
|
|
3734
3832
|
const raw = ctx.model.rawOperations.get(op.operationId);
|
|
3735
3833
|
if (!declaresConcreteStatuses(raw))
|
|
3736
3834
|
continue;
|
|
@@ -3827,6 +3925,7 @@ export const CHECKS = [
|
|
|
3827
3925
|
/* declared side effects and async lifecycles, last: both invoke operations that change the
|
|
3828
3926
|
* world, and both are meaningless if the read surface above is already known broken */
|
|
3829
3927
|
declaredEffectsOccur,
|
|
3928
|
+
sideEffectArrives,
|
|
3830
3929
|
asyncReachesTerminalState,
|
|
3831
3930
|
asyncReceiptIsResolvable,
|
|
3832
3931
|
/* After every other check has written to the transcript — create is owned by
|