@lovrozagar/oat 0.4.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 +6 -2
- package/dist/cli.js +4 -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/runtime/checks.d.ts.map +1 -1
- package/dist/runtime/checks.js +284 -0
- package/dist/runtime/checks.js.map +1 -1
- 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/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/package.json +1 -1
package/dist/runtime/checks.js
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { filterTerm, selectTerm, sortTerm } from "../spec/conventions.js";
|
|
9
9
|
import { requestContent } from "../spec/collection.js";
|
|
10
|
+
import { pathTemplateMatches } from "../spec/load.js";
|
|
10
11
|
import { encodeForOperation } from "./body.js";
|
|
12
|
+
import { STRING_PAYLOADS, payloadFits } from "./payloads.js";
|
|
11
13
|
import { describeFeatureGate, isDocumentedFeatureGateDenial, reportFeatureGateSchemaDrift } from "./feature-gate.js";
|
|
12
14
|
import { driveAsync } from "./async.js";
|
|
13
15
|
import { buildCohort } from "./fixture.js";
|
|
@@ -3432,6 +3434,284 @@ const asyncReceiptIsResolvable = {
|
|
|
3432
3434
|
}
|
|
3433
3435
|
},
|
|
3434
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
|
+
};
|
|
3435
3715
|
export const CHECKS = [
|
|
3436
3716
|
/* foundations — did the write land, is it visible, and do the paging primitives work at all.
|
|
3437
3717
|
* Everything below assumes these hold, so they must be evaluated first for cascade
|
|
@@ -3469,6 +3749,7 @@ export const CHECKS = [
|
|
|
3469
3749
|
/* write semantics */
|
|
3470
3750
|
patchMinimality,
|
|
3471
3751
|
immutableRejected,
|
|
3752
|
+
stringPayloadSurvives,
|
|
3472
3753
|
idempotentReplay,
|
|
3473
3754
|
declaredInvalidationHappens,
|
|
3474
3755
|
projectionsAgree,
|
|
@@ -3501,5 +3782,8 @@ export const CHECKS = [
|
|
|
3501
3782
|
declaredEffectsOccur,
|
|
3502
3783
|
asyncReachesTerminalState,
|
|
3503
3784
|
asyncReceiptIsResolvable,
|
|
3785
|
+
/* After every other check has written to the transcript — create is owned by
|
|
3786
|
+
* create.status-matches-document. */
|
|
3787
|
+
documentedStatusHonoured,
|
|
3504
3788
|
];
|
|
3505
3789
|
//# sourceMappingURL=checks.js.map
|