@lovrozagar/oat 0.2.1 → 0.3.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 +60 -1
- package/dist/cli.js +17 -1
- package/dist/reference/http.d.ts.map +1 -1
- package/dist/reference/http.js +2 -2
- package/dist/reference/http.js.map +1 -1
- package/dist/report/console.d.ts.map +1 -1
- package/dist/report/console.js +18 -0
- package/dist/report/console.js.map +1 -1
- package/dist/runtime/checks.d.ts.map +1 -1
- package/dist/runtime/checks.js +122 -31
- package/dist/runtime/checks.js.map +1 -1
- package/dist/runtime/feature-gate.d.ts +23 -0
- package/dist/runtime/feature-gate.d.ts.map +1 -0
- package/dist/runtime/feature-gate.js +68 -0
- package/dist/runtime/feature-gate.js.map +1 -0
- package/dist/runtime/fixture.d.ts +28 -1
- package/dist/runtime/fixture.d.ts.map +1 -1
- package/dist/runtime/fixture.js +551 -90
- package/dist/runtime/fixture.js.map +1 -1
- package/dist/runtime/run.d.ts.map +1 -1
- package/dist/runtime/run.js +135 -41
- package/dist/runtime/run.js.map +1 -1
- package/dist/runtime/world.d.ts +17 -1
- package/dist/runtime/world.d.ts.map +1 -1
- package/dist/runtime/world.js +56 -4
- package/dist/runtime/world.js.map +1 -1
- package/dist/spec/extensions.d.ts +7 -0
- package/dist/spec/extensions.d.ts.map +1 -1
- package/dist/spec/extensions.js +10 -0
- package/dist/spec/extensions.js.map +1 -1
- package/dist/spec/graph.d.ts +2 -0
- package/dist/spec/graph.d.ts.map +1 -1
- package/dist/spec/graph.js +7 -2
- package/dist/spec/graph.js.map +1 -1
- package/dist/spec/load.d.ts.map +1 -1
- package/dist/spec/load.js +19 -4
- package/dist/spec/load.js.map +1 -1
- package/labs/annotated-openapi.yaml +6 -0
- package/package.json +3 -2
package/dist/runtime/checks.js
CHANGED
|
@@ -6,10 +6,29 @@
|
|
|
6
6
|
* the oracle of what oat just wrote. Check ids are stable: the conformance suite asserts on them.
|
|
7
7
|
*/
|
|
8
8
|
import { filterTerm, selectTerm, sortTerm } from "../spec/conventions.js";
|
|
9
|
+
import { describeFeatureGate, isDocumentedFeatureGateDenial, reportFeatureGateSchemaDrift } from "./feature-gate.js";
|
|
9
10
|
import { driveAsync } from "./async.js";
|
|
10
11
|
import { buildCohort } from "./fixture.js";
|
|
11
12
|
import { fillPath } from "./world.js";
|
|
12
13
|
/* ------------------------------------------------------------------- helpers */
|
|
14
|
+
/**
|
|
15
|
+
* A documented feature-gate 403 is not a 2xx-check failure and not SECURITY.
|
|
16
|
+
*
|
|
17
|
+
* The check that needed a success stands down with a named gap. The 403 body is still
|
|
18
|
+
* validated against the documented error schema — coverage is not a free pass for drift.
|
|
19
|
+
*/
|
|
20
|
+
function standDownForFeatureGate(ctx, op, exchange, check) {
|
|
21
|
+
if (op === undefined)
|
|
22
|
+
return false;
|
|
23
|
+
if (!isDocumentedFeatureGateDenial(op, exchange.status, exchange.responseBody))
|
|
24
|
+
return false;
|
|
25
|
+
if (ctx.validator !== undefined) {
|
|
26
|
+
reportFeatureGateSchemaDrift(ctx.findings, ctx.validator, op, ctx.model.rawOperations.get(op.operationId), exchange, ctx.entityName);
|
|
27
|
+
}
|
|
28
|
+
ctx.findings.gap(check, ctx.entityName, `${op.operationId} did not apply`, `${describeFeatureGate(op, exchange.responseBody)}. The check that needed a 2xx stands ` +
|
|
29
|
+
"down rather than treat the documented 403 as a defect.");
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
13
32
|
/** The list endpoint's derived conventions — parameter roles and envelope spellings. */
|
|
14
33
|
function conv(ctx) {
|
|
15
34
|
return ctx.listOp.conventions;
|
|
@@ -977,6 +996,8 @@ const patchMinimality = {
|
|
|
977
996
|
const drifting = new Set([
|
|
978
997
|
"updated_at",
|
|
979
998
|
"modified_at",
|
|
999
|
+
/* A job's progress counter advances on GET without this PATCH having written it. */
|
|
1000
|
+
"progress",
|
|
980
1001
|
/*
|
|
981
1002
|
* From both operations, not just the one being probed. A document commonly declares
|
|
982
1003
|
* its server-owned fields on `create` — that is where they are conspicuous, being the
|
|
@@ -1001,6 +1022,8 @@ const patchMinimality = {
|
|
|
1001
1022
|
body: { [field]: "oat patched value" },
|
|
1002
1023
|
headers: ctx.auth(),
|
|
1003
1024
|
});
|
|
1025
|
+
if (standDownForFeatureGate(ctx, ctx.updateOp, patched, this.id))
|
|
1026
|
+
return;
|
|
1004
1027
|
if (patched.status >= 300)
|
|
1005
1028
|
return;
|
|
1006
1029
|
const after = await ctx.client.get(fillPath(ctx.readOp.path, params), { headers: ctx.auth() });
|
|
@@ -1077,6 +1100,8 @@ const immutableRejected = {
|
|
|
1077
1100
|
body: { [field]: probe },
|
|
1078
1101
|
headers: ctx.auth(),
|
|
1079
1102
|
});
|
|
1103
|
+
if (standDownForFeatureGate(ctx, ctx.updateOp, patched, this.id))
|
|
1104
|
+
return;
|
|
1080
1105
|
if (patched.status >= 400)
|
|
1081
1106
|
return;
|
|
1082
1107
|
/* Read the echoed record first. Re-reading can itself fail once the write lands — writing
|
|
@@ -1177,6 +1202,8 @@ const deleteMissingIs404 = {
|
|
|
1177
1202
|
const exchange = await ctx.client.request("DELETE", fillPath(ctx.deleteOp.path, params), {
|
|
1178
1203
|
headers: ctx.auth(),
|
|
1179
1204
|
});
|
|
1205
|
+
if (standDownForFeatureGate(ctx, ctx.deleteOp, exchange, this.id))
|
|
1206
|
+
return;
|
|
1180
1207
|
if (exchange.status === 404 || exchange.status === 410 || exchange.status === 400)
|
|
1181
1208
|
return;
|
|
1182
1209
|
if (exchange.status >= 300)
|
|
@@ -1202,6 +1229,8 @@ const softDeleteHidden = {
|
|
|
1202
1229
|
const deleted = await ctx.client.request("DELETE", fillPath(ctx.deleteOp.path, params), {
|
|
1203
1230
|
headers: ctx.auth(),
|
|
1204
1231
|
});
|
|
1232
|
+
if (standDownForFeatureGate(ctx, ctx.deleteOp, deleted, this.id))
|
|
1233
|
+
return;
|
|
1205
1234
|
if (deleted.status >= 300) {
|
|
1206
1235
|
return ctx.findings.unresolved(this.id, ctx.entityName, `DELETE returned ${deleted.status}, so no tombstone was created to look for`);
|
|
1207
1236
|
}
|
|
@@ -1290,10 +1319,14 @@ const idempotentReplay = {
|
|
|
1290
1319
|
const path = fillPath(createOp.path, ctx.scope);
|
|
1291
1320
|
const headers = { ...ctx.auth(), [header]: key };
|
|
1292
1321
|
const first = await ctx.client.request("POST", path, { body, headers });
|
|
1322
|
+
if (standDownForFeatureGate(ctx, createOp, first, this.id))
|
|
1323
|
+
return;
|
|
1293
1324
|
if (first.status >= 300) {
|
|
1294
1325
|
return ctx.findings.unresolved(this.id, ctx.entityName, `the first create returned ${first.status}, so there was nothing to replay`);
|
|
1295
1326
|
}
|
|
1296
1327
|
const second = await ctx.client.request("POST", path, { body, headers });
|
|
1328
|
+
if (standDownForFeatureGate(ctx, createOp, second, this.id))
|
|
1329
|
+
return;
|
|
1297
1330
|
if (second.status >= 300) {
|
|
1298
1331
|
return ctx.findings.unresolved(this.id, ctx.entityName, `the replay returned ${second.status}; a repeated key must be answered, not refused`);
|
|
1299
1332
|
}
|
|
@@ -1365,6 +1398,8 @@ const declaredInvalidationHappens = {
|
|
|
1365
1398
|
body: validBody(ctx, schema),
|
|
1366
1399
|
headers: ctx.auth(),
|
|
1367
1400
|
});
|
|
1401
|
+
if (standDownForFeatureGate(ctx, createOp, created, this.id))
|
|
1402
|
+
return;
|
|
1368
1403
|
if (created.status >= 300) {
|
|
1369
1404
|
return ctx.findings.unresolved(this.id, ctx.entityName, `the create returned ${created.status}, so nothing was invalidated`);
|
|
1370
1405
|
}
|
|
@@ -2508,25 +2543,20 @@ function pointerValue(body, pointer) {
|
|
|
2508
2543
|
* at the same check — the timeline is the property.
|
|
2509
2544
|
*/
|
|
2510
2545
|
const inviteGrantsThenRevokes = {
|
|
2511
|
-
applicable: (ctx) => ctx.invite !== null &&
|
|
2512
|
-
ctx.readOp !== undefined &&
|
|
2513
|
-
ctx.records.length > 0 &&
|
|
2514
|
-
ctx.actors.some((actor) => actor.inviteAs !== undefined &&
|
|
2515
|
-
ctx.actors[0] !== undefined &&
|
|
2516
|
-
!sameTenantScope(actor.roots, ctx.actors[0].roots)),
|
|
2546
|
+
applicable: (ctx) => ctx.invite !== null && (ctx.readOp !== undefined || ctx.listOp !== undefined),
|
|
2517
2547
|
dependsOn: ["list.read-after-write", "tenant.item-not-readable-cross-tenant"],
|
|
2518
2548
|
id: "auth.invite-grants-then-revokes",
|
|
2519
|
-
needs: "x-invite naming invite/accept/revoke, a peer principal with inviteAs, and an item route",
|
|
2549
|
+
needs: "x-invite naming invite/accept/revoke, a peer principal with inviteAs, and an item or list route",
|
|
2520
2550
|
async run(ctx) {
|
|
2521
2551
|
const spec = ctx.invite;
|
|
2522
2552
|
const owner = ctx.actors[0];
|
|
2523
|
-
|
|
2524
|
-
if (spec === null || owner === undefined || target === undefined || ctx.readOp === undefined) {
|
|
2553
|
+
if (spec === null || owner === undefined) {
|
|
2525
2554
|
return;
|
|
2526
2555
|
}
|
|
2527
|
-
const delegate = ctx.actors.find((actor) => actor.inviteAs !== undefined && !sameTenantScope(actor.roots, owner.roots));
|
|
2556
|
+
const delegate = ctx.actors.find((actor) => actor !== owner && actor.inviteAs !== undefined && !sameTenantScope(actor.roots, owner.roots)) ?? ctx.actors.find((actor) => actor !== owner && actor.inviteAs !== undefined);
|
|
2528
2557
|
if (delegate === undefined || delegate.inviteAs === undefined) {
|
|
2529
|
-
return ctx.findings.
|
|
2558
|
+
return ctx.findings.gap(this.id, ctx.entityName, "x-invite requires a peer principal with inviteAs", "x-invite names the grant flow, but no peer principal declares inviteAs, so oat " +
|
|
2559
|
+
"cannot fill the grantee field without inventing an email");
|
|
2530
2560
|
}
|
|
2531
2561
|
const inviteOp = ctx.model.byOperationId.get(spec.invite);
|
|
2532
2562
|
const acceptOp = ctx.model.byOperationId.get(spec.accept);
|
|
@@ -2534,26 +2564,35 @@ const inviteGrantsThenRevokes = {
|
|
|
2534
2564
|
if (inviteOp === undefined || acceptOp === undefined || revokeOp === undefined) {
|
|
2535
2565
|
return ctx.findings.unresolved(this.id, ctx.entityName, "x-invite names an operation that is not in the document");
|
|
2536
2566
|
}
|
|
2537
|
-
const
|
|
2538
|
-
const
|
|
2539
|
-
const
|
|
2567
|
+
const target = ctx.records[0];
|
|
2568
|
+
const id = target === undefined ? undefined : String(target[ctx.identity]);
|
|
2569
|
+
const resource = {
|
|
2570
|
+
...ctx.scope,
|
|
2571
|
+
...(id === undefined ? {} : itemParamFor(ctx, id)),
|
|
2572
|
+
};
|
|
2573
|
+
const itemPath = ctx.readOp?.path;
|
|
2540
2574
|
const canRead = async () => {
|
|
2575
|
+
if (itemPath === undefined || id === undefined)
|
|
2576
|
+
return null;
|
|
2541
2577
|
const exchange = await ctx.client.get(fillPath(itemPath, { ...delegate.roots, ...resource }), {
|
|
2542
2578
|
headers: delegate.headers(),
|
|
2543
2579
|
});
|
|
2544
2580
|
return exchange.status < 300;
|
|
2545
2581
|
};
|
|
2546
|
-
|
|
2582
|
+
const alreadyReadable = await canRead();
|
|
2583
|
+
if (alreadyReadable === true) {
|
|
2547
2584
|
return ctx.findings.unresolved(this.id, ctx.entityName, "the delegate could already read the record, so invite cannot be distinguished from a leak");
|
|
2548
2585
|
}
|
|
2549
2586
|
const invited = await ctx.client.request(inviteOp.method, fillPath(inviteOp.path, resource), {
|
|
2550
2587
|
body: { [spec.granteeField]: delegate.inviteAs },
|
|
2551
2588
|
headers: { ...owner.headers(), "content-type": "application/json" },
|
|
2552
2589
|
});
|
|
2590
|
+
if (standDownForFeatureGate(ctx, inviteOp, invited, this.id))
|
|
2591
|
+
return;
|
|
2553
2592
|
if (invited.status >= 300) {
|
|
2554
2593
|
return ctx.findings.unresolved(this.id, ctx.entityName, `invite returned ${invited.status}, so accept/revoke were never exercised`);
|
|
2555
2594
|
}
|
|
2556
|
-
if (await canRead()) {
|
|
2595
|
+
if ((await canRead()) === true) {
|
|
2557
2596
|
ctx.findings.backend(this.id, ctx.entityName, "an invite grants access before it is accepted", `inviting ${delegate.id} made ${ctx.entityName} ${id} readable immediately. The accept ` +
|
|
2558
2597
|
"step is then theatre — anyone who can be named in an invite body is already in.", [invited]);
|
|
2559
2598
|
return;
|
|
@@ -2566,10 +2605,12 @@ const inviteGrantsThenRevokes = {
|
|
|
2566
2605
|
const accepted = await ctx.client.request(acceptOp.method, fillPath(acceptOp.path, { token }), {
|
|
2567
2606
|
headers: delegate.headers(),
|
|
2568
2607
|
});
|
|
2608
|
+
if (standDownForFeatureGate(ctx, acceptOp, accepted, this.id))
|
|
2609
|
+
return;
|
|
2569
2610
|
if (accepted.status >= 300) {
|
|
2570
2611
|
return ctx.findings.unresolved(this.id, ctx.entityName, `accept returned ${accepted.status}`);
|
|
2571
2612
|
}
|
|
2572
|
-
if (
|
|
2613
|
+
if ((await canRead()) === false) {
|
|
2573
2614
|
ctx.findings.backend(this.id, ctx.entityName, "accepting an invite does not grant access", `${delegate.id} accepted an invite to ${ctx.entityName} ${id} and still cannot read it. ` +
|
|
2574
2615
|
"The invite flow completed; the grant did not.", [invited, accepted]);
|
|
2575
2616
|
return;
|
|
@@ -2578,10 +2619,12 @@ const inviteGrantsThenRevokes = {
|
|
|
2578
2619
|
return ctx.findings.unresolved(this.id, ctx.entityName, `invite response has no grant id at ${spec.grantPointer}, so revoke cannot be expressed`);
|
|
2579
2620
|
}
|
|
2580
2621
|
const revoked = await ctx.client.request(revokeOp.method, fillPath(revokeOp.path, { ...resource, grant_id: grantId }), { headers: owner.headers() });
|
|
2622
|
+
if (standDownForFeatureGate(ctx, revokeOp, revoked, this.id))
|
|
2623
|
+
return;
|
|
2581
2624
|
if (revoked.status >= 300) {
|
|
2582
2625
|
return ctx.findings.unresolved(this.id, ctx.entityName, `revoke returned ${revoked.status}`);
|
|
2583
2626
|
}
|
|
2584
|
-
if (await canRead()) {
|
|
2627
|
+
if ((await canRead()) === true) {
|
|
2585
2628
|
ctx.findings.backend(this.id, ctx.entityName, "revoking an invite does not remove access", `${delegate.id} can still read ${ctx.entityName} ${id} after the grant was revoked. ` +
|
|
2586
2629
|
"A share that cannot be taken back is a standing leak.", [invited, accepted, revoked]);
|
|
2587
2630
|
}
|
|
@@ -2771,6 +2814,8 @@ const enumValidated = {
|
|
|
2771
2814
|
body,
|
|
2772
2815
|
headers: ctx.auth(),
|
|
2773
2816
|
});
|
|
2817
|
+
if (standDownForFeatureGate(ctx, createOp, exchange, this.id))
|
|
2818
|
+
return;
|
|
2774
2819
|
if (exchange.status >= 400)
|
|
2775
2820
|
return;
|
|
2776
2821
|
const declared = target.schema.enum.map((v) => JSON.stringify(v)).join(", ");
|
|
@@ -2800,6 +2845,8 @@ const maxLengthValidated = {
|
|
|
2800
2845
|
body,
|
|
2801
2846
|
headers: ctx.auth(),
|
|
2802
2847
|
});
|
|
2848
|
+
if (standDownForFeatureGate(ctx, createOp, exchange, this.id))
|
|
2849
|
+
return;
|
|
2803
2850
|
if (exchange.status >= 400)
|
|
2804
2851
|
return;
|
|
2805
2852
|
ctx.findings.backend(this.id, ctx.entityName, "a string longer than the declared maxLength was accepted", `"${target.name}" declares maxLength ${max} but a ${max + 25}-character value was stored ` +
|
|
@@ -2827,6 +2874,8 @@ const requiredValidated = {
|
|
|
2827
2874
|
body,
|
|
2828
2875
|
headers: ctx.auth(),
|
|
2829
2876
|
});
|
|
2877
|
+
if (standDownForFeatureGate(ctx, createOp, exchange, this.id))
|
|
2878
|
+
return;
|
|
2830
2879
|
if (exchange.status >= 400)
|
|
2831
2880
|
return;
|
|
2832
2881
|
ctx.findings.backend(this.id, ctx.entityName, "create succeeded without a required field", `"${field}" is listed in the schema's required array, but omitting it returned ` +
|
|
@@ -2851,6 +2900,8 @@ const contentTypeEnforced = {
|
|
|
2851
2900
|
contentType: "text/plain",
|
|
2852
2901
|
headers: ctx.auth(),
|
|
2853
2902
|
});
|
|
2903
|
+
if (standDownForFeatureGate(ctx, createOp, exchange, this.id))
|
|
2904
|
+
return;
|
|
2854
2905
|
if (exchange.status === 415 || exchange.status >= 400)
|
|
2855
2906
|
return;
|
|
2856
2907
|
ctx.findings.backend(this.id, ctx.entityName, "a non-JSON content type was accepted on a JSON endpoint", `the operation documents 415 and declares only a JSON request body, but a request sent ` +
|
|
@@ -2872,16 +2923,33 @@ const errorSchemaHonoured = {
|
|
|
2872
2923
|
return;
|
|
2873
2924
|
const params = { ...ctx.scope, ...itemParamFor(ctx, "oat-definitely-missing-id") };
|
|
2874
2925
|
const exchange = await ctx.client.get(fillPath(readOp.path, params), { headers: ctx.auth() });
|
|
2875
|
-
if (exchange.status
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2926
|
+
if (exchange.status >= 400 && validator.documents(raw, exchange.status)) {
|
|
2927
|
+
const result = validator.validate(readOp.operationId, raw, exchange.status, exchange.responseBody);
|
|
2928
|
+
if (!result.ok) {
|
|
2929
|
+
ctx.findings.spec(this.id, ctx.entityName, `${exchange.status} error body does not match its documented schema`, `${readOp.operationId} returned ${exchange.status} with a body that fails the schema the ` +
|
|
2930
|
+
`document declares for it: ${result.errors.join("; ")}. Clients that parse errors ` +
|
|
2931
|
+
"from the spec will not understand this response.", [exchange]);
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
/* A documented feature-gate 403 is coverage for the 2xx check, not for the error schema.
|
|
2935
|
+
* Walk this entity's already-observed gate denials so an undeclared body is still drift. */
|
|
2936
|
+
const seen = new Set();
|
|
2937
|
+
for (const op of ctx.model.operations) {
|
|
2938
|
+
if (op.entity !== ctx.entityName || op.featureGate === null)
|
|
2939
|
+
continue;
|
|
2940
|
+
const opRaw = ctx.model.rawOperations.get(op.operationId);
|
|
2941
|
+
if (opRaw === undefined)
|
|
2942
|
+
continue;
|
|
2943
|
+
for (const prior of ctx.client.transcript) {
|
|
2944
|
+
if (!isDocumentedFeatureGateDenial(op, prior.status, prior.responseBody))
|
|
2945
|
+
continue;
|
|
2946
|
+
const key = `${op.operationId}:${prior.seq}`;
|
|
2947
|
+
if (seen.has(key))
|
|
2948
|
+
continue;
|
|
2949
|
+
seen.add(key);
|
|
2950
|
+
reportFeatureGateSchemaDrift(ctx.findings, validator, op, opRaw, prior, ctx.entityName);
|
|
2951
|
+
}
|
|
2952
|
+
}
|
|
2885
2953
|
},
|
|
2886
2954
|
};
|
|
2887
2955
|
const successSchemaHonoured = {
|
|
@@ -2917,9 +2985,24 @@ const successSchemaHonoured = {
|
|
|
2917
2985
|
};
|
|
2918
2986
|
/** Builds a body that should be accepted, for use as the base of a negative probe. */
|
|
2919
2987
|
function validBody(ctx, schema) {
|
|
2920
|
-
const [member] = buildCohort(schema, ctx.seed, ["baseline"]);
|
|
2988
|
+
const [member] = buildCohort(schema, ctx.seed, ["baseline"], ctx.createOp?.operationId ?? ctx.entityName);
|
|
2921
2989
|
return member?.body ?? {};
|
|
2922
2990
|
}
|
|
2991
|
+
/** Invite bodies must carry the peer's `inviteAs`, never a generated email. */
|
|
2992
|
+
function bodyForOp(ctx, op) {
|
|
2993
|
+
const body = validBody(ctx, requestSchemaOf(ctx, op) ?? {});
|
|
2994
|
+
const spec = op.invite ?? ctx.invite;
|
|
2995
|
+
if (spec === null || spec === undefined)
|
|
2996
|
+
return body;
|
|
2997
|
+
const owner = ctx.actors[0];
|
|
2998
|
+
const delegate = ctx.actors.find((actor) => actor !== owner &&
|
|
2999
|
+
actor.inviteAs !== undefined &&
|
|
3000
|
+
owner !== undefined &&
|
|
3001
|
+
!sameTenantScope(actor.roots, owner.roots)) ?? ctx.actors.find((actor) => actor !== owner && actor.inviteAs !== undefined);
|
|
3002
|
+
if (delegate?.inviteAs !== undefined)
|
|
3003
|
+
body[spec.granteeField] = delegate.inviteAs;
|
|
3004
|
+
return body;
|
|
3005
|
+
}
|
|
2923
3006
|
function requestSchemaOf(ctx, op) {
|
|
2924
3007
|
const raw = ctx.model.rawOperations.get(op.operationId);
|
|
2925
3008
|
const content = raw?.requestBody?.content;
|
|
@@ -3085,6 +3168,10 @@ const noLostUpdate = {
|
|
|
3085
3168
|
headers: ctx.auth(),
|
|
3086
3169
|
}),
|
|
3087
3170
|
]);
|
|
3171
|
+
if (standDownForFeatureGate(ctx, ctx.updateOp, firstWrite, this.id))
|
|
3172
|
+
return;
|
|
3173
|
+
if (standDownForFeatureGate(ctx, ctx.updateOp, secondWrite, this.id))
|
|
3174
|
+
return;
|
|
3088
3175
|
if (firstWrite.status >= 300 || secondWrite.status >= 300) {
|
|
3089
3176
|
return ctx.findings.unresolved(this.id, ctx.entityName, `a concurrent PATCH was rejected (${firstWrite.status}, ${secondWrite.status}), so no ` +
|
|
3090
3177
|
"race actually took place");
|
|
@@ -3175,11 +3262,13 @@ const declaredEffectsOccur = {
|
|
|
3175
3262
|
const before = await observe(ctx, listOp);
|
|
3176
3263
|
if (before === null)
|
|
3177
3264
|
continue;
|
|
3178
|
-
const body = op.hasRequestBody ?
|
|
3265
|
+
const body = op.hasRequestBody ? bodyForOp(ctx, op) : undefined;
|
|
3179
3266
|
const invoked = await ctx.client.request(op.method, fillPath(op.path, ctx.scope), {
|
|
3180
3267
|
headers: ctx.auth(),
|
|
3181
3268
|
...(body === undefined ? {} : { body }),
|
|
3182
3269
|
});
|
|
3270
|
+
if (standDownForFeatureGate(ctx, op, invoked, this.id))
|
|
3271
|
+
continue;
|
|
3183
3272
|
if (invoked.status >= 400) {
|
|
3184
3273
|
ctx.findings.gap(this.id, ctx.entityName, `${op.operationId} could not be invoked`, `returned ${invoked.status}; its declared effects are unverified`);
|
|
3185
3274
|
continue;
|
|
@@ -3245,11 +3334,13 @@ const asyncReachesTerminalState = {
|
|
|
3245
3334
|
const spec = op.async;
|
|
3246
3335
|
if (spec === null)
|
|
3247
3336
|
continue;
|
|
3248
|
-
const body = op.hasRequestBody ?
|
|
3337
|
+
const body = op.hasRequestBody ? bodyForOp(ctx, op) : undefined;
|
|
3249
3338
|
const start = await ctx.client.request(op.method, fillPath(op.path, ctx.scope), {
|
|
3250
3339
|
headers: ctx.auth(),
|
|
3251
3340
|
...(body === undefined ? {} : { body }),
|
|
3252
3341
|
});
|
|
3342
|
+
if (standDownForFeatureGate(ctx, op, start, this.id))
|
|
3343
|
+
continue;
|
|
3253
3344
|
if (start.status >= 400) {
|
|
3254
3345
|
ctx.findings.gap(this.id, ctx.entityName, `${op.operationId} could not be started`, `returned ${start.status}; the async lifecycle after it is untested`);
|
|
3255
3346
|
continue;
|