@lovrozagar/oat 0.3.0 → 0.5.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 +71 -22
- package/dist/cli.js +7 -2
- package/dist/config/define-config.d.ts +41 -1
- package/dist/config/define-config.d.ts.map +1 -1
- package/dist/config/define-config.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/reference/defects.d.ts +14 -0
- package/dist/reference/defects.d.ts.map +1 -1
- package/dist/reference/defects.js +14 -0
- package/dist/reference/defects.js.map +1 -1
- package/dist/reference/http.d.ts.map +1 -1
- package/dist/reference/http.js +4 -1
- package/dist/reference/http.js.map +1 -1
- package/dist/report/matrix.d.ts.map +1 -1
- package/dist/report/matrix.js +2 -0
- package/dist/report/matrix.js.map +1 -1
- package/dist/report/render.d.ts.map +1 -1
- package/dist/report/render.js +28 -5
- package/dist/report/render.js.map +1 -1
- package/dist/runtime/auth.d.ts.map +1 -1
- package/dist/runtime/auth.js +30 -3
- package/dist/runtime/auth.js.map +1 -1
- package/dist/runtime/body.d.ts +33 -0
- package/dist/runtime/body.d.ts.map +1 -0
- package/dist/runtime/body.js +218 -0
- package/dist/runtime/body.js.map +1 -0
- package/dist/runtime/checks.d.ts +3 -0
- package/dist/runtime/checks.d.ts.map +1 -1
- package/dist/runtime/checks.js +329 -18
- package/dist/runtime/checks.js.map +1 -1
- package/dist/runtime/client.d.ts +20 -1
- package/dist/runtime/client.d.ts.map +1 -1
- package/dist/runtime/client.js +207 -8
- package/dist/runtime/client.js.map +1 -1
- package/dist/runtime/dummy.d.ts +23 -0
- package/dist/runtime/dummy.d.ts.map +1 -0
- package/dist/runtime/dummy.js +214 -0
- package/dist/runtime/dummy.js.map +1 -0
- package/dist/runtime/fixture.d.ts.map +1 -1
- package/dist/runtime/fixture.js +4 -3
- package/dist/runtime/fixture.js.map +1 -1
- package/dist/runtime/payloads.d.ts +20 -0
- package/dist/runtime/payloads.d.ts.map +1 -0
- package/dist/runtime/payloads.js +250 -0
- package/dist/runtime/payloads.js.map +1 -0
- package/dist/runtime/progress.d.ts +4 -0
- package/dist/runtime/progress.d.ts.map +1 -1
- package/dist/runtime/progress.js +23 -0
- package/dist/runtime/progress.js.map +1 -1
- package/dist/runtime/run.d.ts +4 -1
- package/dist/runtime/run.d.ts.map +1 -1
- package/dist/runtime/run.js +15 -1
- package/dist/runtime/run.js.map +1 -1
- package/dist/runtime/upload.d.ts +21 -0
- package/dist/runtime/upload.d.ts.map +1 -0
- package/dist/runtime/upload.js +247 -0
- package/dist/runtime/upload.js.map +1 -0
- package/dist/runtime/world.d.ts +2 -0
- package/dist/runtime/world.d.ts.map +1 -1
- package/dist/runtime/world.js +16 -11
- package/dist/runtime/world.js.map +1 -1
- package/dist/spec/collection.d.ts +13 -0
- package/dist/spec/collection.d.ts.map +1 -1
- package/dist/spec/collection.js +36 -6
- package/dist/spec/collection.js.map +1 -1
- package/dist/spec/graph.js +2 -2
- package/dist/spec/graph.js.map +1 -1
- package/dist/spec/load.d.ts +2 -0
- package/dist/spec/load.d.ts.map +1 -1
- package/dist/spec/load.js +12 -0
- package/dist/spec/load.js.map +1 -1
- package/dist/spec/types.d.ts +3 -0
- package/dist/spec/types.d.ts.map +1 -1
- package/dist/spec/types.js.map +1 -1
- package/package.json +1 -1
package/dist/runtime/checks.js
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
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 { requestContent } from "../spec/collection.js";
|
|
10
|
+
import { pathTemplateMatches } from "../spec/load.js";
|
|
11
|
+
import { encodeForOperation } from "./body.js";
|
|
12
|
+
import { STRING_PAYLOADS, payloadFits } from "./payloads.js";
|
|
9
13
|
import { describeFeatureGate, isDocumentedFeatureGateDenial, reportFeatureGateSchemaDrift } from "./feature-gate.js";
|
|
10
14
|
import { driveAsync } from "./async.js";
|
|
11
15
|
import { buildCohort } from "./fixture.js";
|
|
@@ -1312,19 +1316,20 @@ const idempotentReplay = {
|
|
|
1312
1316
|
return;
|
|
1313
1317
|
const schema = requestSchemaOf(ctx, createOp);
|
|
1314
1318
|
if (schema === null) {
|
|
1315
|
-
return ctx.findings.unresolved(this.id, ctx.entityName, "the create operation declares no
|
|
1319
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the create operation declares no request body to replay");
|
|
1316
1320
|
}
|
|
1317
1321
|
const body = validBody(ctx, schema);
|
|
1318
1322
|
const key = `oat-idem-${ctx.seed}-${ctx.entityName}`;
|
|
1319
1323
|
const path = fillPath(createOp.path, ctx.scope);
|
|
1320
1324
|
const headers = { ...ctx.auth(), [header]: key };
|
|
1321
|
-
const
|
|
1325
|
+
const encoded = await encodeOpBody(ctx, createOp, body);
|
|
1326
|
+
const first = await ctx.client.request("POST", path, { ...encoded, headers });
|
|
1322
1327
|
if (standDownForFeatureGate(ctx, createOp, first, this.id))
|
|
1323
1328
|
return;
|
|
1324
1329
|
if (first.status >= 300) {
|
|
1325
1330
|
return ctx.findings.unresolved(this.id, ctx.entityName, `the first create returned ${first.status}, so there was nothing to replay`);
|
|
1326
1331
|
}
|
|
1327
|
-
const second = await ctx.client.request("POST", path, {
|
|
1332
|
+
const second = await ctx.client.request("POST", path, { ...encoded, headers });
|
|
1328
1333
|
if (standDownForFeatureGate(ctx, createOp, second, this.id))
|
|
1329
1334
|
return;
|
|
1330
1335
|
if (second.status >= 300) {
|
|
@@ -1370,7 +1375,7 @@ const declaredInvalidationHappens = {
|
|
|
1370
1375
|
return;
|
|
1371
1376
|
const schema = requestSchemaOf(ctx, createOp);
|
|
1372
1377
|
if (schema === null) {
|
|
1373
|
-
return ctx.findings.unresolved(this.id, ctx.entityName, "the create operation declares no
|
|
1378
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the create operation declares no request body, so no write can be made");
|
|
1374
1379
|
}
|
|
1375
1380
|
/* Only routes whose path can be filled from this entity's scope are probed: a route
|
|
1376
1381
|
* needing an identifier oat does not hold would 404 for a reason unrelated to staleness. */
|
|
@@ -1395,7 +1400,7 @@ const declaredInvalidationHappens = {
|
|
|
1395
1400
|
return ctx.findings.unresolved(this.id, ctx.entityName, "none of the declared routes could be read before the write");
|
|
1396
1401
|
}
|
|
1397
1402
|
const created = await ctx.client.request("POST", fillPath(createOp.path, ctx.scope), {
|
|
1398
|
-
|
|
1403
|
+
...(await encodeOpBody(ctx, createOp, validBody(ctx, schema))),
|
|
1399
1404
|
headers: ctx.auth(),
|
|
1400
1405
|
});
|
|
1401
1406
|
if (standDownForFeatureGate(ctx, createOp, created, this.id))
|
|
@@ -2775,7 +2780,9 @@ const createPersistsFields = {
|
|
|
2775
2780
|
const sent = createExchange(ctx);
|
|
2776
2781
|
if (sent === undefined || sent.requestBody === undefined)
|
|
2777
2782
|
return;
|
|
2778
|
-
const request = sent.requestBody;
|
|
2783
|
+
const request = submittedFields(sent.requestBody);
|
|
2784
|
+
if (Object.keys(request).length === 0)
|
|
2785
|
+
return;
|
|
2779
2786
|
const response = (sent.responseBody ?? {});
|
|
2780
2787
|
const dropped = Object.entries(request).filter(([key, value]) => {
|
|
2781
2788
|
if (value === null || value === undefined)
|
|
@@ -2811,7 +2818,7 @@ const enumValidated = {
|
|
|
2811
2818
|
return;
|
|
2812
2819
|
const body = { ...validBody(ctx, schema), [target.name]: "oat-not-a-member" };
|
|
2813
2820
|
const exchange = await ctx.client.request("POST", fillPath(createOp.path, ctx.scope), {
|
|
2814
|
-
body,
|
|
2821
|
+
...(await encodeOpBody(ctx, createOp, body)),
|
|
2815
2822
|
headers: ctx.auth(),
|
|
2816
2823
|
});
|
|
2817
2824
|
if (standDownForFeatureGate(ctx, createOp, exchange, this.id))
|
|
@@ -2842,7 +2849,7 @@ const maxLengthValidated = {
|
|
|
2842
2849
|
const max = target.schema.maxLength;
|
|
2843
2850
|
const body = { ...validBody(ctx, schema), [target.name]: "x".repeat(max + 25) };
|
|
2844
2851
|
const exchange = await ctx.client.request("POST", fillPath(createOp.path, ctx.scope), {
|
|
2845
|
-
body,
|
|
2852
|
+
...(await encodeOpBody(ctx, createOp, body)),
|
|
2846
2853
|
headers: ctx.auth(),
|
|
2847
2854
|
});
|
|
2848
2855
|
if (standDownForFeatureGate(ctx, createOp, exchange, this.id))
|
|
@@ -2871,7 +2878,7 @@ const requiredValidated = {
|
|
|
2871
2878
|
return;
|
|
2872
2879
|
const { [field]: _omitted, ...body } = validBody(ctx, schema);
|
|
2873
2880
|
const exchange = await ctx.client.request("POST", fillPath(createOp.path, ctx.scope), {
|
|
2874
|
-
body,
|
|
2881
|
+
...(await encodeOpBody(ctx, createOp, body)),
|
|
2875
2882
|
headers: ctx.auth(),
|
|
2876
2883
|
});
|
|
2877
2884
|
if (standDownForFeatureGate(ctx, createOp, exchange, this.id))
|
|
@@ -2971,6 +2978,8 @@ const successSchemaHonoured = {
|
|
|
2971
2978
|
const exchange = createExchange(ctx);
|
|
2972
2979
|
if (exchange === undefined)
|
|
2973
2980
|
return;
|
|
2981
|
+
if (isEventStream(exchange))
|
|
2982
|
+
return;
|
|
2974
2983
|
if (!validator.documents(raw, exchange.status)) {
|
|
2975
2984
|
return ctx.findings.unresolved(this.id, ctx.entityName, `create returned ${exchange.status}, which the document declares no schema for — ` +
|
|
2976
2985
|
"there is nothing to validate the body against");
|
|
@@ -3005,14 +3014,34 @@ function bodyForOp(ctx, op) {
|
|
|
3005
3014
|
}
|
|
3006
3015
|
function requestSchemaOf(ctx, op) {
|
|
3007
3016
|
const raw = ctx.model.rawOperations.get(op.operationId);
|
|
3008
|
-
const
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3017
|
+
const picked = raw === undefined ? null : requestContent(raw);
|
|
3018
|
+
return picked === null ? null : picked.schema;
|
|
3019
|
+
}
|
|
3020
|
+
async function encodeOpBody(ctx, op, fields, variant = "baseline", index = 0) {
|
|
3021
|
+
const encoded = await encodeForOperation(op, ctx.model, fields, ctx.uploads, variant, index);
|
|
3022
|
+
return encoded.contentType === undefined
|
|
3023
|
+
? { body: encoded.body }
|
|
3024
|
+
: { body: encoded.body, contentType: encoded.contentType };
|
|
3025
|
+
}
|
|
3026
|
+
function submittedFields(body) {
|
|
3027
|
+
if (typeof FormData !== "undefined" && body instanceof FormData) {
|
|
3028
|
+
const out = {};
|
|
3029
|
+
for (const [name, value] of body.entries()) {
|
|
3030
|
+
if (typeof value === "string")
|
|
3031
|
+
out[name] = value;
|
|
3032
|
+
}
|
|
3033
|
+
return out;
|
|
3014
3034
|
}
|
|
3015
|
-
|
|
3035
|
+
if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) {
|
|
3036
|
+
return Object.fromEntries(body.entries());
|
|
3037
|
+
}
|
|
3038
|
+
if (body !== null && typeof body === "object" && !Array.isArray(body))
|
|
3039
|
+
return body;
|
|
3040
|
+
return {};
|
|
3041
|
+
}
|
|
3042
|
+
function isEventStream(exchange) {
|
|
3043
|
+
const type = exchange.responseHeaders["content-type"] ?? "";
|
|
3044
|
+
return type.includes("text/event-stream");
|
|
3016
3045
|
}
|
|
3017
3046
|
function findConstrained(schema, predicate) {
|
|
3018
3047
|
const props = schema.properties;
|
|
@@ -3265,7 +3294,7 @@ const declaredEffectsOccur = {
|
|
|
3265
3294
|
const body = op.hasRequestBody ? bodyForOp(ctx, op) : undefined;
|
|
3266
3295
|
const invoked = await ctx.client.request(op.method, fillPath(op.path, ctx.scope), {
|
|
3267
3296
|
headers: ctx.auth(),
|
|
3268
|
-
...(body === undefined ? {} :
|
|
3297
|
+
...(body === undefined ? {} : await encodeOpBody(ctx, op, body)),
|
|
3269
3298
|
});
|
|
3270
3299
|
if (standDownForFeatureGate(ctx, op, invoked, this.id))
|
|
3271
3300
|
continue;
|
|
@@ -3337,7 +3366,7 @@ const asyncReachesTerminalState = {
|
|
|
3337
3366
|
const body = op.hasRequestBody ? bodyForOp(ctx, op) : undefined;
|
|
3338
3367
|
const start = await ctx.client.request(op.method, fillPath(op.path, ctx.scope), {
|
|
3339
3368
|
headers: ctx.auth(),
|
|
3340
|
-
...(body === undefined ? {} :
|
|
3369
|
+
...(body === undefined ? {} : await encodeOpBody(ctx, op, body)),
|
|
3341
3370
|
});
|
|
3342
3371
|
if (standDownForFeatureGate(ctx, op, start, this.id))
|
|
3343
3372
|
continue;
|
|
@@ -3405,6 +3434,284 @@ const asyncReceiptIsResolvable = {
|
|
|
3405
3434
|
}
|
|
3406
3435
|
},
|
|
3407
3436
|
};
|
|
3437
|
+
const ASCII_PAYLOAD_PROBE = "oat-payload-probe";
|
|
3438
|
+
function pickPayloadField(ctx, op) {
|
|
3439
|
+
const schema = requestSchemaOf(ctx, op);
|
|
3440
|
+
const properties = (schema?.properties ?? {});
|
|
3441
|
+
const immutable = new Set([
|
|
3442
|
+
...(ctx.updateOp?.immutable ?? []),
|
|
3443
|
+
...(ctx.updateOp?.generated ?? []),
|
|
3444
|
+
...(ctx.createOp?.generated ?? []),
|
|
3445
|
+
ctx.identity,
|
|
3446
|
+
]);
|
|
3447
|
+
let best = null;
|
|
3448
|
+
for (const [name, declared] of Object.entries(properties)) {
|
|
3449
|
+
if (immutable.has(name) || /_at$|_id$/.test(name))
|
|
3450
|
+
continue;
|
|
3451
|
+
if (declared === null || typeof declared !== "object")
|
|
3452
|
+
continue;
|
|
3453
|
+
if (declared.readOnly === true)
|
|
3454
|
+
continue;
|
|
3455
|
+
const union = declared.oneOf ?? declared.anyOf;
|
|
3456
|
+
const branch = Array.isArray(union)
|
|
3457
|
+
? union.find((candidate) => candidate !== null && typeof candidate === "object" && candidate.type === "string")
|
|
3458
|
+
: undefined;
|
|
3459
|
+
const stringSchema = branch ?? declared;
|
|
3460
|
+
const type = stringSchema.type;
|
|
3461
|
+
const isString = type === "string" || (Array.isArray(type) && type.includes("string"));
|
|
3462
|
+
if (!isString && type !== undefined)
|
|
3463
|
+
continue;
|
|
3464
|
+
if (Array.isArray(stringSchema.enum))
|
|
3465
|
+
continue;
|
|
3466
|
+
if (typeof stringSchema.pattern === "string")
|
|
3467
|
+
continue;
|
|
3468
|
+
if (typeof stringSchema.format === "string")
|
|
3469
|
+
continue;
|
|
3470
|
+
const maxLength = typeof stringSchema.maxLength === "number" ? stringSchema.maxLength : undefined;
|
|
3471
|
+
const minLength = typeof stringSchema.minLength === "number" ? stringSchema.minLength : undefined;
|
|
3472
|
+
if (maxLength !== undefined && maxLength < 8)
|
|
3473
|
+
continue;
|
|
3474
|
+
const nullable = declared.nullable === true ||
|
|
3475
|
+
(Array.isArray(declared.type) && declared.type.includes("null")) ||
|
|
3476
|
+
(Array.isArray(union) &&
|
|
3477
|
+
union.some((candidate) => candidate !== null && typeof candidate === "object" && candidate.type === "null"));
|
|
3478
|
+
const score = (nullable ? 1000 : 0) + (maxLength ?? 1024);
|
|
3479
|
+
if (best === null || score > best.score)
|
|
3480
|
+
best = { maxLength, minLength, name, score };
|
|
3481
|
+
}
|
|
3482
|
+
return best;
|
|
3483
|
+
}
|
|
3484
|
+
const stringPayloadSurvives = {
|
|
3485
|
+
applicable: (ctx) => ctx.readOp !== undefined &&
|
|
3486
|
+
ctx.records.length > 0 &&
|
|
3487
|
+
(ctx.updateOp !== undefined || (ctx.createOp !== undefined && ctx.deleteOp !== undefined)),
|
|
3488
|
+
dependsOn: ["list.read-after-write"],
|
|
3489
|
+
id: "payload.string-survives",
|
|
3490
|
+
mutates: true,
|
|
3491
|
+
needs: "an update or create+delete, an item route, and a writable unconstrained string",
|
|
3492
|
+
async run(ctx) {
|
|
3493
|
+
const writeOp = ctx.updateOp ?? ctx.createOp;
|
|
3494
|
+
const readOp = ctx.readOp;
|
|
3495
|
+
if (writeOp === undefined || readOp === undefined)
|
|
3496
|
+
return;
|
|
3497
|
+
const field = pickPayloadField(ctx, writeOp);
|
|
3498
|
+
if (field === null) {
|
|
3499
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "no writable unconstrained string field is available to probe payloads against");
|
|
3500
|
+
}
|
|
3501
|
+
const viaPatch = ctx.updateOp !== undefined;
|
|
3502
|
+
const target = ctx.records.at(-1) ?? ctx.records[0];
|
|
3503
|
+
if (target === undefined)
|
|
3504
|
+
return;
|
|
3505
|
+
const id = String(target[ctx.identity]);
|
|
3506
|
+
const params = { ...ctx.scope, ...itemParamFor(ctx, id) };
|
|
3507
|
+
const original = target[field.name];
|
|
3508
|
+
const writeField = async (value, recordId) => {
|
|
3509
|
+
if (viaPatch && ctx.updateOp !== undefined) {
|
|
3510
|
+
return ctx.client.request("PATCH", fillPath(ctx.updateOp.path, { ...ctx.scope, ...itemParamFor(ctx, recordId) }), {
|
|
3511
|
+
body: { [field.name]: value },
|
|
3512
|
+
headers: ctx.auth(),
|
|
3513
|
+
});
|
|
3514
|
+
}
|
|
3515
|
+
const createOp = ctx.createOp;
|
|
3516
|
+
if (createOp === undefined)
|
|
3517
|
+
throw new Error("payload probe has no write operation");
|
|
3518
|
+
const body = { ...validBody(ctx, requestSchemaOf(ctx, createOp) ?? {}), [field.name]: value };
|
|
3519
|
+
return ctx.client.request("POST", fillPath(createOp.path, ctx.scope), { body, headers: ctx.auth() });
|
|
3520
|
+
};
|
|
3521
|
+
const readField = async (recordId) => {
|
|
3522
|
+
const exchange = await ctx.client.get(fillPath(readOp.path, { ...ctx.scope, ...itemParamFor(ctx, recordId) }), {
|
|
3523
|
+
headers: ctx.auth(),
|
|
3524
|
+
});
|
|
3525
|
+
const body = (exchange.responseBody ?? {});
|
|
3526
|
+
return { exchange, value: body[field.name] };
|
|
3527
|
+
};
|
|
3528
|
+
const remove = async (recordId) => {
|
|
3529
|
+
if (viaPatch || ctx.deleteOp === undefined)
|
|
3530
|
+
return;
|
|
3531
|
+
await ctx.client.request("DELETE", fillPath(ctx.deleteOp.path, { ...ctx.scope, ...itemParamFor(ctx, recordId) }), {
|
|
3532
|
+
headers: ctx.auth(),
|
|
3533
|
+
});
|
|
3534
|
+
};
|
|
3535
|
+
const identityOf = (exchange) => {
|
|
3536
|
+
const body = exchange.responseBody;
|
|
3537
|
+
if (body === null || typeof body !== "object")
|
|
3538
|
+
return undefined;
|
|
3539
|
+
const value = body[ctx.identity];
|
|
3540
|
+
return value === undefined || value === null ? undefined : String(value);
|
|
3541
|
+
};
|
|
3542
|
+
try {
|
|
3543
|
+
const control = await writeField(ASCII_PAYLOAD_PROBE, id);
|
|
3544
|
+
if (standDownForFeatureGate(ctx, writeOp, control, this.id))
|
|
3545
|
+
return;
|
|
3546
|
+
if (control.status === 401 || control.status === 403) {
|
|
3547
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `the ASCII control write returned ${control.status}, so later refusals cannot be attributed to the payload`);
|
|
3548
|
+
}
|
|
3549
|
+
if (control.status >= 400 && control.status < 500) {
|
|
3550
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `the ASCII control write was rejected with ${control.status}, so the field refuses ordinary strings`);
|
|
3551
|
+
}
|
|
3552
|
+
if (control.status >= 500) {
|
|
3553
|
+
ctx.findings.backend(this.id, ctx.entityName, "an ordinary ASCII write returned 5xx", `PATCH/POST of "${ASCII_PAYLOAD_PROBE}" on "${field.name}" returned ${control.status}.`, [control]);
|
|
3554
|
+
return;
|
|
3555
|
+
}
|
|
3556
|
+
const controlId = viaPatch ? id : identityOf(control);
|
|
3557
|
+
if (controlId === undefined) {
|
|
3558
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the ASCII control write did not return an identity, so the value could not be read back");
|
|
3559
|
+
}
|
|
3560
|
+
const controlRead = await readField(controlId);
|
|
3561
|
+
if (!viaPatch)
|
|
3562
|
+
await remove(controlId);
|
|
3563
|
+
if (controlRead.value !== ASCII_PAYLOAD_PROBE) {
|
|
3564
|
+
ctx.findings.backend(this.id, ctx.entityName, "an ordinary ASCII write did not persist exactly", `"${field.name}" was sent ${JSON.stringify(ASCII_PAYLOAD_PROBE)} and read back ` +
|
|
3565
|
+
`${JSON.stringify(controlRead.value)}.`, [control, controlRead.exchange]);
|
|
3566
|
+
return;
|
|
3567
|
+
}
|
|
3568
|
+
const failed = [];
|
|
3569
|
+
const evidence = [];
|
|
3570
|
+
let lostAuth = false;
|
|
3571
|
+
for (const payload of STRING_PAYLOADS) {
|
|
3572
|
+
if (!payloadFits(payload.value, field.maxLength, field.minLength))
|
|
3573
|
+
continue;
|
|
3574
|
+
const written = await writeField(payload.value, id);
|
|
3575
|
+
if (standDownForFeatureGate(ctx, writeOp, written, this.id))
|
|
3576
|
+
return;
|
|
3577
|
+
if (written.status === 401) {
|
|
3578
|
+
const retry = await writeField(ASCII_PAYLOAD_PROBE, id);
|
|
3579
|
+
if (retry.status >= 400) {
|
|
3580
|
+
lostAuth = true;
|
|
3581
|
+
break;
|
|
3582
|
+
}
|
|
3583
|
+
failed.push(`${payload.id} (${payload.why}): sent ${JSON.stringify(payload.value)}, got HTTP 401`);
|
|
3584
|
+
if (evidence.length < 4)
|
|
3585
|
+
evidence.push(written);
|
|
3586
|
+
continue;
|
|
3587
|
+
}
|
|
3588
|
+
if (written.status === 404 || written.status === 409 || written.status === 415 || written.status === 429) {
|
|
3589
|
+
continue;
|
|
3590
|
+
}
|
|
3591
|
+
if (written.status >= 400 && written.status < 500) {
|
|
3592
|
+
failed.push(`${payload.id} (${payload.why}): sent ${JSON.stringify(payload.value)}, got HTTP ${written.status}`);
|
|
3593
|
+
if (evidence.length < 4)
|
|
3594
|
+
evidence.push(written);
|
|
3595
|
+
if (!viaPatch) {
|
|
3596
|
+
const extra = identityOf(written);
|
|
3597
|
+
if (extra !== undefined)
|
|
3598
|
+
await remove(extra);
|
|
3599
|
+
}
|
|
3600
|
+
continue;
|
|
3601
|
+
}
|
|
3602
|
+
if (written.status >= 500) {
|
|
3603
|
+
failed.push(`${payload.id} (${payload.why}): sent ${JSON.stringify(payload.value)}, got HTTP ${written.status}`);
|
|
3604
|
+
if (evidence.length < 4)
|
|
3605
|
+
evidence.push(written);
|
|
3606
|
+
continue;
|
|
3607
|
+
}
|
|
3608
|
+
const writtenId = viaPatch ? id : identityOf(written);
|
|
3609
|
+
if (writtenId === undefined) {
|
|
3610
|
+
failed.push(`${payload.id} (${payload.why}): write succeeded but returned no identity`);
|
|
3611
|
+
continue;
|
|
3612
|
+
}
|
|
3613
|
+
const got = await readField(writtenId);
|
|
3614
|
+
if (!viaPatch)
|
|
3615
|
+
await remove(writtenId);
|
|
3616
|
+
if (got.value !== payload.value) {
|
|
3617
|
+
failed.push(`${payload.id} (${payload.why}): sent ${JSON.stringify(payload.value)}, got ${JSON.stringify(got.value)}`);
|
|
3618
|
+
if (evidence.length < 4)
|
|
3619
|
+
evidence.push(written, got.exchange);
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3622
|
+
if (lostAuth && failed.length === 0) {
|
|
3623
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "authentication failed in the middle of the payload catalog, so remaining cases were not run");
|
|
3624
|
+
}
|
|
3625
|
+
if (failed.length === 0)
|
|
3626
|
+
return;
|
|
3627
|
+
ctx.findings.backend(this.id, ctx.entityName, "a documented-valid string did not survive a write", `"${field.name}" failed ${failed.length} payload case(s): ${failed.slice(0, 6).join("; ")}` +
|
|
3628
|
+
(failed.length > 6 ? `; and ${failed.length - 6} more` : "") +
|
|
3629
|
+
". The document accepts these as strings; refusing or mutating them is silent corruption " +
|
|
3630
|
+
"or an undocumented constraint.", evidence);
|
|
3631
|
+
}
|
|
3632
|
+
finally {
|
|
3633
|
+
if (viaPatch && ctx.updateOp !== undefined) {
|
|
3634
|
+
await ctx.client.request("PATCH", fillPath(ctx.updateOp.path, params), {
|
|
3635
|
+
body: { [field.name]: original ?? null },
|
|
3636
|
+
headers: ctx.auth(),
|
|
3637
|
+
});
|
|
3638
|
+
}
|
|
3639
|
+
}
|
|
3640
|
+
},
|
|
3641
|
+
};
|
|
3642
|
+
function resolveEntityOperation(ctx, method, pathname) {
|
|
3643
|
+
let best = null;
|
|
3644
|
+
for (const op of ctx.model.operations) {
|
|
3645
|
+
if (op.method !== method)
|
|
3646
|
+
continue;
|
|
3647
|
+
if (op.entity !== ctx.entityName)
|
|
3648
|
+
continue;
|
|
3649
|
+
if (!pathTemplateMatches(op.path, pathname))
|
|
3650
|
+
continue;
|
|
3651
|
+
if (best === null || op.path.length > best.path.length)
|
|
3652
|
+
best = op;
|
|
3653
|
+
}
|
|
3654
|
+
return best;
|
|
3655
|
+
}
|
|
3656
|
+
function statusIsDeclared(raw, status) {
|
|
3657
|
+
const keys = Object.keys(raw?.responses ?? {});
|
|
3658
|
+
if (keys.includes(String(status)))
|
|
3659
|
+
return true;
|
|
3660
|
+
return keys.includes(`${Math.floor(status / 100)}XX`);
|
|
3661
|
+
}
|
|
3662
|
+
function declaresConcreteStatuses(raw) {
|
|
3663
|
+
return Object.keys(raw?.responses ?? {}).some((key) => /^\d{3}$/.test(key) || /^[1-5]XX$/i.test(key));
|
|
3664
|
+
}
|
|
3665
|
+
const documentedStatusHonoured = {
|
|
3666
|
+
applicable: (ctx) => ctx.model.operations.some((op) => op.entity === ctx.entityName && op.action !== "create"),
|
|
3667
|
+
id: "response.status-is-documented",
|
|
3668
|
+
needs: "a modeled non-create operation on this entity",
|
|
3669
|
+
async run(ctx) {
|
|
3670
|
+
const createId = ctx.createOp?.operationId;
|
|
3671
|
+
const byOp = new Map();
|
|
3672
|
+
for (const exchange of ctx.client.transcript) {
|
|
3673
|
+
let pathname;
|
|
3674
|
+
try {
|
|
3675
|
+
pathname = new URL(exchange.url).pathname;
|
|
3676
|
+
}
|
|
3677
|
+
catch {
|
|
3678
|
+
continue;
|
|
3679
|
+
}
|
|
3680
|
+
const op = resolveEntityOperation(ctx, exchange.method, pathname);
|
|
3681
|
+
if (op === null)
|
|
3682
|
+
continue;
|
|
3683
|
+
if (op.operationId === createId || op.action === "create")
|
|
3684
|
+
continue;
|
|
3685
|
+
if (isDocumentedFeatureGateDenial(op, exchange.status, exchange.responseBody))
|
|
3686
|
+
continue;
|
|
3687
|
+
const raw = ctx.model.rawOperations.get(op.operationId);
|
|
3688
|
+
if (!declaresConcreteStatuses(raw))
|
|
3689
|
+
continue;
|
|
3690
|
+
if (statusIsDeclared(raw, exchange.status))
|
|
3691
|
+
continue;
|
|
3692
|
+
const seen = byOp.get(op.operationId) ?? [];
|
|
3693
|
+
seen.push(exchange);
|
|
3694
|
+
byOp.set(op.operationId, seen);
|
|
3695
|
+
}
|
|
3696
|
+
if (byOp.size === 0)
|
|
3697
|
+
return;
|
|
3698
|
+
const lines = [];
|
|
3699
|
+
const evidence = [];
|
|
3700
|
+
for (const [operationId, exchanges] of byOp) {
|
|
3701
|
+
const raw = ctx.model.rawOperations.get(operationId);
|
|
3702
|
+
const declared = Object.keys(raw?.responses ?? {})
|
|
3703
|
+
.filter((key) => key !== "default")
|
|
3704
|
+
.sort()
|
|
3705
|
+
.join(", ");
|
|
3706
|
+
const seen = [...new Set(exchanges.map((item) => item.status))].sort((a, b) => a - b);
|
|
3707
|
+
lines.push(`${operationId} returned ${seen.join(", ")}; the document declares ${declared || "no concrete status"}`);
|
|
3708
|
+
const first = exchanges[0];
|
|
3709
|
+
if (first !== undefined && evidence.length < 6)
|
|
3710
|
+
evidence.push(first);
|
|
3711
|
+
}
|
|
3712
|
+
ctx.findings.spec(this.id, ctx.entityName, "an operation returned a status the document does not declare", `${lines.join(". ")}. Clients generated from this document will not recognise the response.`, evidence);
|
|
3713
|
+
},
|
|
3714
|
+
};
|
|
3408
3715
|
export const CHECKS = [
|
|
3409
3716
|
/* foundations — did the write land, is it visible, and do the paging primitives work at all.
|
|
3410
3717
|
* Everything below assumes these hold, so they must be evaluated first for cascade
|
|
@@ -3442,6 +3749,7 @@ export const CHECKS = [
|
|
|
3442
3749
|
/* write semantics */
|
|
3443
3750
|
patchMinimality,
|
|
3444
3751
|
immutableRejected,
|
|
3752
|
+
stringPayloadSurvives,
|
|
3445
3753
|
idempotentReplay,
|
|
3446
3754
|
declaredInvalidationHappens,
|
|
3447
3755
|
projectionsAgree,
|
|
@@ -3474,5 +3782,8 @@ export const CHECKS = [
|
|
|
3474
3782
|
declaredEffectsOccur,
|
|
3475
3783
|
asyncReachesTerminalState,
|
|
3476
3784
|
asyncReceiptIsResolvable,
|
|
3785
|
+
/* After every other check has written to the transcript — create is owned by
|
|
3786
|
+
* create.status-matches-document. */
|
|
3787
|
+
documentedStatusHonoured,
|
|
3477
3788
|
];
|
|
3478
3789
|
//# sourceMappingURL=checks.js.map
|