@lovrozagar/oat 0.9.0 → 0.10.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 +104 -33
- package/dist/cli.js +56 -14
- package/dist/config/define-config.d.ts +7 -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 +2 -0
- package/dist/index.js +1 -0
- package/dist/report/console.d.ts +2 -1
- package/dist/report/console.d.ts.map +1 -1
- package/dist/report/console.js +33 -1
- package/dist/report/console.js.map +1 -1
- package/dist/report/matrix.d.ts +8 -13
- package/dist/report/matrix.d.ts.map +1 -1
- package/dist/report/matrix.js +21 -10
- package/dist/report/matrix.js.map +1 -1
- package/dist/report/render.d.ts +8 -0
- package/dist/report/render.d.ts.map +1 -1
- package/dist/report/render.js +94 -1
- package/dist/report/render.js.map +1 -1
- package/dist/runtime/auth.d.ts.map +1 -1
- package/dist/runtime/auth.js +2 -1
- package/dist/runtime/auth.js.map +1 -1
- package/dist/runtime/checks.d.ts +42 -1
- package/dist/runtime/checks.d.ts.map +1 -1
- package/dist/runtime/checks.js +190 -24
- package/dist/runtime/checks.js.map +1 -1
- package/dist/runtime/finding.d.ts +14 -1
- package/dist/runtime/finding.d.ts.map +1 -1
- package/dist/runtime/finding.js +25 -6
- package/dist/runtime/finding.js.map +1 -1
- package/dist/runtime/run.d.ts +17 -0
- package/dist/runtime/run.d.ts.map +1 -1
- package/dist/runtime/run.js +126 -66
- package/dist/runtime/run.js.map +1 -1
- package/dist/runtime/scope.d.ts +163 -0
- package/dist/runtime/scope.d.ts.map +1 -0
- package/dist/runtime/scope.js +423 -0
- package/dist/runtime/scope.js.map +1 -0
- package/dist/runtime/world.d.ts +7 -2
- package/dist/runtime/world.d.ts.map +1 -1
- package/dist/runtime/world.js +12 -6
- package/dist/runtime/world.js.map +1 -1
- package/package.json +1 -1
package/dist/runtime/checks.js
CHANGED
|
@@ -21,6 +21,44 @@ import { driveWait } from "./wait.js";
|
|
|
21
21
|
import { fillPath, isPlanLimitResponse } from "./world.js";
|
|
22
22
|
import { bodyPropertyNames, collisionCreateBody, collisionUpdatePatch, idempotencyHeaderRequired, probeableUniqueSets, reportUniqueSchemaDrift, uniquifyProbeBody, uniqueProbeHeaders, } from "./unique.js";
|
|
23
23
|
import { bindAfterCreateEffects, bindCreatedScope, bindMissingPathParams, canFillPath, describeEffectHold, effectHolds, identityPathParam, } from "./effects.js";
|
|
24
|
+
const graded = (ctx, op) => ctx.inScope?.(op) ?? true;
|
|
25
|
+
/** The context a multi-operation check uses for one subject: its findings land on that operation. */
|
|
26
|
+
function forOperation(ctx, op) {
|
|
27
|
+
return { ...ctx, findings: ctx.findings.attributed([op.operationId]) };
|
|
28
|
+
}
|
|
29
|
+
const declared = (...operationIds) => operationIds.filter((id) => id !== undefined && id !== "");
|
|
30
|
+
const ownOps = (entity, model, keep) => model.operations.filter((op) => op.entity === entity.name && keep(op)).map((op) => op.operationId);
|
|
31
|
+
/** Subject sets shared by the registry. Each reads the same fields the check's context is built from. */
|
|
32
|
+
export const subjectsOf = {
|
|
33
|
+
async: (entity, model) => ownOps(entity, model, (op) => op.async !== null),
|
|
34
|
+
asyncReceipt: (entity, model) => ownOps(entity, model, (op) => op.async?.idFrom !== undefined),
|
|
35
|
+
create: (entity) => declared(entity.create),
|
|
36
|
+
createAndForeignReads: (entity, model) => {
|
|
37
|
+
const create = model.byOperationId.get(entity.create ?? "");
|
|
38
|
+
if (create === undefined)
|
|
39
|
+
return [];
|
|
40
|
+
const foreign = create.invalidates
|
|
41
|
+
.map((route) => model.byRoute.get(route))
|
|
42
|
+
.filter((op) => op !== undefined && op.entity !== entity.name && op.method.toUpperCase() === "GET")
|
|
43
|
+
.map((op) => op.operationId);
|
|
44
|
+
return [create.operationId, ...foreign];
|
|
45
|
+
},
|
|
46
|
+
createAndList: (entity) => declared(entity.create, entity.list),
|
|
47
|
+
delete: (entity) => declared(entity.delete),
|
|
48
|
+
deleteAndList: (entity) => declared(entity.delete, entity.list),
|
|
49
|
+
effects: (entity, model) => ownOps(entity, model, (op) => op.effects.length > 0),
|
|
50
|
+
invite: (entity) => entity.invite === null ? [] : declared(entity.invite.invite, entity.invite.accept, entity.invite.revoke),
|
|
51
|
+
list: (entity) => declared(entity.list),
|
|
52
|
+
nonCreate: (entity, model) => ownOps(entity, model, (op) => op.action !== "create"),
|
|
53
|
+
read: (entity) => declared(entity.read),
|
|
54
|
+
readAndGated: (entity, model) => [
|
|
55
|
+
...new Set([...declared(entity.read), ...ownOps(entity, model, (op) => op.featureGate !== null)]),
|
|
56
|
+
],
|
|
57
|
+
readAndList: (entity) => declared(entity.read, entity.list),
|
|
58
|
+
update: (entity) => declared(entity.update),
|
|
59
|
+
waits: (entity, model) => ownOps(entity, model, (op) => op.wait !== null),
|
|
60
|
+
writeAndRead: (entity) => declared(entity.update ?? entity.create, entity.read),
|
|
61
|
+
};
|
|
24
62
|
/* ------------------------------------------------------------------- helpers */
|
|
25
63
|
/**
|
|
26
64
|
* A documented feature-gate 403 is not a 2xx-check failure and not SECURITY.
|
|
@@ -473,6 +511,7 @@ const readAfterWrite = {
|
|
|
473
511
|
mutates: true,
|
|
474
512
|
id: "list.read-after-write",
|
|
475
513
|
needs: "a create operation and at least one seeded record",
|
|
514
|
+
subjects: subjectsOf.createAndList,
|
|
476
515
|
async run(ctx) {
|
|
477
516
|
const target = ctx.records[0];
|
|
478
517
|
if (target === undefined)
|
|
@@ -554,6 +593,7 @@ const unknownFilterRejected = {
|
|
|
554
593
|
applicable: (ctx) => conv(ctx).filter !== undefined,
|
|
555
594
|
id: "filter.unknown-field-rejected",
|
|
556
595
|
needs: "a way to express a filter — a filter expression parameter, or filterable fields",
|
|
596
|
+
subjects: subjectsOf.list,
|
|
557
597
|
async run(ctx) {
|
|
558
598
|
const baseline = await list(ctx, q(ctx, { limit: 100 }));
|
|
559
599
|
const unknownField = filterTerm(conv(ctx), "oat_no_such_field_xyz", "eq", 1);
|
|
@@ -579,6 +619,7 @@ const equalityFilterSelectsOne = {
|
|
|
579
619
|
dependsOn: ["list.read-after-write"],
|
|
580
620
|
id: "filter.equality-selects-exactly-one",
|
|
581
621
|
needs: "a `filter` parameter that accepts the identity field",
|
|
622
|
+
subjects: subjectsOf.list,
|
|
582
623
|
async run(ctx) {
|
|
583
624
|
const target = ctx.records[0];
|
|
584
625
|
if (target === undefined)
|
|
@@ -605,6 +646,7 @@ const zeroMatchFilter = {
|
|
|
605
646
|
dependsOn: ["list.read-after-write"],
|
|
606
647
|
id: "filter.zero-match-returns-none",
|
|
607
648
|
needs: "a `filter` parameter that accepts the identity field",
|
|
649
|
+
subjects: subjectsOf.list,
|
|
608
650
|
async run(ctx) {
|
|
609
651
|
const term = filterTerm(conv(ctx), filterIdentity(ctx), "eq", "oat-nonexistent-value-000");
|
|
610
652
|
if (term === null)
|
|
@@ -631,6 +673,7 @@ const negationPartitions = {
|
|
|
631
673
|
],
|
|
632
674
|
id: "filter.negation-partitions-the-set",
|
|
633
675
|
needs: "a `filter` parameter supporting eq and neq",
|
|
676
|
+
subjects: subjectsOf.list,
|
|
634
677
|
async run(ctx) {
|
|
635
678
|
const target = ctx.records[0];
|
|
636
679
|
if (target === undefined)
|
|
@@ -704,6 +747,7 @@ const filterAndComposesAsIntersection = {
|
|
|
704
747
|
],
|
|
705
748
|
id: "filter.and-composes-as-intersection",
|
|
706
749
|
needs: "two filterable fields and a filter parameter or grammar supporting eq",
|
|
750
|
+
subjects: subjectsOf.list,
|
|
707
751
|
async run(ctx) {
|
|
708
752
|
const conventions = conv(ctx);
|
|
709
753
|
const picked = twoFilterableFields(ctx);
|
|
@@ -766,6 +810,7 @@ const filterOrComposesAsUnion = {
|
|
|
766
810
|
],
|
|
767
811
|
id: "filter.or-composes-as-union",
|
|
768
812
|
needs: "two filterable fields and an or() combinator (postgrest-shaped filter grammar)",
|
|
813
|
+
subjects: subjectsOf.list,
|
|
769
814
|
async run(ctx) {
|
|
770
815
|
const conventions = conv(ctx);
|
|
771
816
|
const picked = twoFilterableFields(ctx);
|
|
@@ -823,6 +868,7 @@ const sortReverseSymmetry = {
|
|
|
823
868
|
],
|
|
824
869
|
id: "sort.reverse-symmetry",
|
|
825
870
|
needs: "an `order` parameter supporting asc and desc",
|
|
871
|
+
subjects: subjectsOf.list,
|
|
826
872
|
async run(ctx) {
|
|
827
873
|
/* A nullable sort key exercises null ordering, where the interesting bugs live. */
|
|
828
874
|
const field = nullableField(ctx, ctx.query?.sortable ?? []) ??
|
|
@@ -881,6 +927,7 @@ const pageWalkCoversSet = {
|
|
|
881
927
|
],
|
|
882
928
|
id: "pagination.page-walk-covers-set",
|
|
883
929
|
needs: "a way to page forward — a page number or a row offset — and at least three records",
|
|
930
|
+
subjects: subjectsOf.list,
|
|
884
931
|
async run(ctx) {
|
|
885
932
|
const limit = ctx.query?.maxLimit ?? 100;
|
|
886
933
|
/* Walk under a low-cardinality sort. Distinct keys admit exactly one valid order, so an
|
|
@@ -934,6 +981,7 @@ const cursorAgreesWithPage = {
|
|
|
934
981
|
],
|
|
935
982
|
id: "pagination.cursor-agrees-with-page",
|
|
936
983
|
needs: "both `cursor` and `page` parameters",
|
|
984
|
+
subjects: subjectsOf.list,
|
|
937
985
|
async run(ctx) {
|
|
938
986
|
/* Walk under an explicit text sort. Cursor pagination is almost always used with one, and
|
|
939
987
|
* a boundary resolved under a different collation than the listing can only diverge when
|
|
@@ -985,6 +1033,7 @@ const countIsConsistentWithPage = {
|
|
|
985
1033
|
dependsOn: ["list.read-after-write"],
|
|
986
1034
|
id: "count.consistent-with-returned-page",
|
|
987
1035
|
needs: "a total-count field in the list envelope",
|
|
1036
|
+
subjects: subjectsOf.list,
|
|
988
1037
|
async run(ctx) {
|
|
989
1038
|
const result = await list(ctx, q(ctx, { limit: ctx.query?.maxLimit ?? 100 }));
|
|
990
1039
|
if (result.exchange.status >= 400)
|
|
@@ -1011,6 +1060,7 @@ const countMatchesWalk = {
|
|
|
1011
1060
|
dependsOn: ["list.read-after-write", "filter.equality-selects-exactly-one"],
|
|
1012
1061
|
id: "count.matches-filtered-set",
|
|
1013
1062
|
needs: "a total-count field and a `filter` parameter",
|
|
1063
|
+
subjects: subjectsOf.list,
|
|
1014
1064
|
async run(ctx) {
|
|
1015
1065
|
if (!identityIsFilterable(ctx))
|
|
1016
1066
|
return;
|
|
@@ -1037,6 +1087,7 @@ const selectProjection = {
|
|
|
1037
1087
|
applicable: (ctx) => conv(ctx).select !== undefined && ctx.records.length > 0,
|
|
1038
1088
|
id: "select.projection-honoured",
|
|
1039
1089
|
needs: "a `select` sparse-fieldset parameter",
|
|
1090
|
+
subjects: subjectsOf.list,
|
|
1040
1091
|
async run(ctx) {
|
|
1041
1092
|
const requested = [ctx.identity];
|
|
1042
1093
|
const extra = ctx.query?.selectable.find((f) => f !== ctx.identity);
|
|
@@ -1067,6 +1118,7 @@ const patchMinimality = {
|
|
|
1067
1118
|
mutates: true,
|
|
1068
1119
|
id: "patch.minimality",
|
|
1069
1120
|
needs: "an update operation and an item route",
|
|
1121
|
+
subjects: subjectsOf.update,
|
|
1070
1122
|
async run(ctx) {
|
|
1071
1123
|
const target = ctx.records.find((r) => Object.values(r).some((v) => typeof v === "string"));
|
|
1072
1124
|
if (target === undefined || ctx.updateOp === undefined || ctx.readOp === undefined)
|
|
@@ -1183,6 +1235,7 @@ const immutableRejected = {
|
|
|
1183
1235
|
mutates: true,
|
|
1184
1236
|
id: "patch.immutable-field-rejected",
|
|
1185
1237
|
needs: "fields declared immutable via x-immutable",
|
|
1238
|
+
subjects: subjectsOf.update,
|
|
1186
1239
|
async run(ctx) {
|
|
1187
1240
|
/*
|
|
1188
1241
|
* Deliberately not `records[0]`.
|
|
@@ -1230,6 +1283,7 @@ const likeEscaping = {
|
|
|
1230
1283
|
dependsOn: ["list.read-after-write"],
|
|
1231
1284
|
id: "filter.like-metacharacters-escaped",
|
|
1232
1285
|
needs: "a `filter` parameter supporting a like operator",
|
|
1286
|
+
subjects: subjectsOf.list,
|
|
1233
1287
|
async run(ctx) {
|
|
1234
1288
|
const field = firstFilterable(ctx, (name) => (ctx.query?.searchable ?? []).includes(name));
|
|
1235
1289
|
if (field === null) {
|
|
@@ -1259,6 +1313,7 @@ const createStatusMatchesSpec = {
|
|
|
1259
1313
|
applicable: (ctx) => ctx.createOp !== undefined,
|
|
1260
1314
|
id: "create.status-matches-document",
|
|
1261
1315
|
needs: "a create operation",
|
|
1316
|
+
subjects: subjectsOf.create,
|
|
1262
1317
|
async run(ctx) {
|
|
1263
1318
|
const createOp = ctx.createOp;
|
|
1264
1319
|
if (createOp === undefined)
|
|
@@ -1301,6 +1356,7 @@ const deleteMissingIs404 = {
|
|
|
1301
1356
|
mutates: true,
|
|
1302
1357
|
id: "delete.absent-record-returns-404",
|
|
1303
1358
|
needs: "a delete operation",
|
|
1359
|
+
subjects: subjectsOf.delete,
|
|
1304
1360
|
async run(ctx) {
|
|
1305
1361
|
if (ctx.deleteOp === undefined)
|
|
1306
1362
|
return;
|
|
@@ -1326,6 +1382,7 @@ const softDeleteHidden = {
|
|
|
1326
1382
|
dependsOn: ["list.read-after-write"],
|
|
1327
1383
|
id: "softdelete.absent-from-default-list",
|
|
1328
1384
|
needs: "a delete operation and x-soft-delete",
|
|
1385
|
+
subjects: subjectsOf.deleteAndList,
|
|
1329
1386
|
async run(ctx) {
|
|
1330
1387
|
const target = ctx.records.at(-1);
|
|
1331
1388
|
if (target === undefined || ctx.deleteOp === undefined)
|
|
@@ -1367,6 +1424,7 @@ const denialDoesNotRevealExistence = {
|
|
|
1367
1424
|
],
|
|
1368
1425
|
id: "tenant.denial-does-not-reveal-existence",
|
|
1369
1426
|
needs: "a second principal in a different tenant, and a tenant tagged or inferred from the path",
|
|
1427
|
+
subjects: subjectsOf.read,
|
|
1370
1428
|
async run(ctx) {
|
|
1371
1429
|
const target = ctx.records[0];
|
|
1372
1430
|
if (target === undefined || ctx.readOp === undefined || ctx.altAuth === undefined)
|
|
@@ -1411,6 +1469,7 @@ const idempotentReplay = {
|
|
|
1411
1469
|
id: "idempotency.replay-does-not-duplicate",
|
|
1412
1470
|
mutates: true,
|
|
1413
1471
|
needs: "a create operation declaring an idempotency-key header",
|
|
1472
|
+
subjects: subjectsOf.create,
|
|
1414
1473
|
async run(ctx) {
|
|
1415
1474
|
const createOp = ctx.createOp;
|
|
1416
1475
|
const header = createOp?.idempotencyHeader;
|
|
@@ -1466,13 +1525,20 @@ const declaredInvalidationHappens = {
|
|
|
1466
1525
|
id: "invalidation.declared-route-changes",
|
|
1467
1526
|
mutates: true,
|
|
1468
1527
|
needs: "a create operation whose x-invalidate names another entity's read route",
|
|
1528
|
+
subjects: subjectsOf.createAndForeignReads,
|
|
1469
1529
|
async run(ctx) {
|
|
1470
1530
|
const createOp = ctx.createOp;
|
|
1471
1531
|
if (createOp === undefined)
|
|
1472
1532
|
return;
|
|
1533
|
+
/* A graded create is judged through every route it declares; a graded route alone is
|
|
1534
|
+
* judged only itself, with the create as support. */
|
|
1535
|
+
const createGraded = graded(ctx, createOp);
|
|
1473
1536
|
const foreign = createOp.invalidates
|
|
1474
1537
|
.map((route) => ctx.model.byRoute.get(route))
|
|
1475
|
-
.filter((op) => op !== undefined &&
|
|
1538
|
+
.filter((op) => op !== undefined &&
|
|
1539
|
+
op.entity !== ctx.entityName &&
|
|
1540
|
+
op.method.toUpperCase() === "GET" &&
|
|
1541
|
+
(createGraded || graded(ctx, op)));
|
|
1476
1542
|
if (foreign.length === 0)
|
|
1477
1543
|
return;
|
|
1478
1544
|
const schema = requestSchemaOf(ctx, createOp);
|
|
@@ -1519,7 +1585,12 @@ const declaredInvalidationHappens = {
|
|
|
1519
1585
|
continue;
|
|
1520
1586
|
if (JSON.stringify(after.responseBody) !== snapshot)
|
|
1521
1587
|
continue;
|
|
1522
|
-
ctx.findings
|
|
1588
|
+
ctx.findings
|
|
1589
|
+
.attributed([
|
|
1590
|
+
...(createGraded ? [createOp.operationId] : []),
|
|
1591
|
+
...(graded(ctx, probe.op) ? [probe.op.operationId] : []),
|
|
1592
|
+
])
|
|
1593
|
+
.backend(this.id, ctx.entityName, "a route the document says is invalidated by this write did not change", `creating a ${ctx.entityName} declares x-invalidate on "${probe.op.route}", but that ` +
|
|
1523
1594
|
"route returned a byte-identical body before and after the write. Either it serves " +
|
|
1524
1595
|
"a value derived from this entity and that value is stale — a denormalised counter " +
|
|
1525
1596
|
"or a cached projection nobody refreshed — or the declaration is wrong and every " +
|
|
@@ -1560,6 +1631,7 @@ const projectionsAgree = {
|
|
|
1560
1631
|
],
|
|
1561
1632
|
id: "consistency.projections-agree",
|
|
1562
1633
|
needs: "an item route and a record with a comparable field",
|
|
1634
|
+
subjects: subjectsOf.readAndList,
|
|
1563
1635
|
async run(ctx) {
|
|
1564
1636
|
const target = ctx.records[0];
|
|
1565
1637
|
if (target === undefined || ctx.readOp === undefined)
|
|
@@ -1672,6 +1744,7 @@ const queryAxesCompose = {
|
|
|
1672
1744
|
],
|
|
1673
1745
|
id: "query.axes-compose",
|
|
1674
1746
|
needs: "a filterable field, a sortable field, and more than two records",
|
|
1747
|
+
subjects: subjectsOf.list,
|
|
1675
1748
|
async run(ctx) {
|
|
1676
1749
|
const conventions = conv(ctx);
|
|
1677
1750
|
const limit = ctx.query?.maxLimit ?? 100;
|
|
@@ -1764,6 +1837,7 @@ const filterAndPagingCompose = {
|
|
|
1764
1837
|
],
|
|
1765
1838
|
id: "query.filter-selects-from-whole-set",
|
|
1766
1839
|
needs: "a filterable field and more than two records",
|
|
1840
|
+
subjects: subjectsOf.list,
|
|
1767
1841
|
async run(ctx) {
|
|
1768
1842
|
const conventions = conv(ctx);
|
|
1769
1843
|
/* A small page deliberately: the bug only shows once the window excludes matching rows,
|
|
@@ -1856,6 +1930,7 @@ const filterAndSelectCompose = {
|
|
|
1856
1930
|
],
|
|
1857
1931
|
id: "query.filter-and-select-compose",
|
|
1858
1932
|
needs: "a filterable field, a select parameter, and more than two records",
|
|
1933
|
+
subjects: subjectsOf.list,
|
|
1859
1934
|
async run(ctx) {
|
|
1860
1935
|
const conventions = conv(ctx);
|
|
1861
1936
|
const limit = ctx.query?.maxLimit ?? 100;
|
|
@@ -1944,6 +2019,7 @@ const searchAndFilterCompose = {
|
|
|
1944
2019
|
],
|
|
1945
2020
|
id: "query.search-and-filter-compose",
|
|
1946
2021
|
needs: "a filterable field, a free-text search parameter, and more than two records",
|
|
2022
|
+
subjects: subjectsOf.list,
|
|
1947
2023
|
async run(ctx) {
|
|
1948
2024
|
const conventions = conv(ctx);
|
|
1949
2025
|
const limit = ctx.query?.maxLimit ?? 100;
|
|
@@ -2058,6 +2134,7 @@ const filterSortSelectCompose = {
|
|
|
2058
2134
|
],
|
|
2059
2135
|
id: "query.filter-sort-select-compose",
|
|
2060
2136
|
needs: "a filterable field, a sortable field, a select parameter, and more than two records",
|
|
2137
|
+
subjects: subjectsOf.list,
|
|
2061
2138
|
async run(ctx) {
|
|
2062
2139
|
const conventions = conv(ctx);
|
|
2063
2140
|
const limit = ctx.query?.maxLimit ?? 100;
|
|
@@ -2126,6 +2203,7 @@ const filterSearchSortCompose = {
|
|
|
2126
2203
|
],
|
|
2127
2204
|
id: "query.filter-search-sort-compose",
|
|
2128
2205
|
needs: "a filterable field, a search parameter, a sortable field, and more than two records",
|
|
2206
|
+
subjects: subjectsOf.list,
|
|
2129
2207
|
async run(ctx) {
|
|
2130
2208
|
const conventions = conv(ctx);
|
|
2131
2209
|
const limit = ctx.query?.maxLimit ?? 100;
|
|
@@ -2185,6 +2263,7 @@ const filterSearchSelectCompose = {
|
|
|
2185
2263
|
],
|
|
2186
2264
|
id: "query.filter-search-select-compose",
|
|
2187
2265
|
needs: "a filterable field, a search parameter, a select parameter, and more than two records",
|
|
2266
|
+
subjects: subjectsOf.list,
|
|
2188
2267
|
async run(ctx) {
|
|
2189
2268
|
const conventions = conv(ctx);
|
|
2190
2269
|
const limit = ctx.query?.maxLimit ?? 100;
|
|
@@ -2357,6 +2436,7 @@ const declaredFilterableWorks = {
|
|
|
2357
2436
|
],
|
|
2358
2437
|
id: "spec.declared-filterable-is-filterable",
|
|
2359
2438
|
needs: "x-query naming filterable fields",
|
|
2439
|
+
subjects: subjectsOf.list,
|
|
2360
2440
|
async run(ctx) {
|
|
2361
2441
|
const conventions = conv(ctx);
|
|
2362
2442
|
const rejected = [];
|
|
@@ -2415,6 +2495,7 @@ const declaredSortableWorks = {
|
|
|
2415
2495
|
],
|
|
2416
2496
|
id: "spec.declared-sortable-is-sortable",
|
|
2417
2497
|
needs: "x-query naming sortable fields",
|
|
2498
|
+
subjects: subjectsOf.list,
|
|
2418
2499
|
async run(ctx) {
|
|
2419
2500
|
const conventions = conv(ctx);
|
|
2420
2501
|
const rejected = [];
|
|
@@ -2464,6 +2545,7 @@ const declaredSelectableWorks = {
|
|
|
2464
2545
|
],
|
|
2465
2546
|
id: "spec.declared-selectable-is-selectable",
|
|
2466
2547
|
needs: "x-query naming selectable fields",
|
|
2548
|
+
subjects: subjectsOf.list,
|
|
2467
2549
|
async run(ctx) {
|
|
2468
2550
|
const conventions = conv(ctx);
|
|
2469
2551
|
const rejected = [];
|
|
@@ -2497,6 +2579,7 @@ const crossTenantItemRead = {
|
|
|
2497
2579
|
applicable: (ctx) => ctx.altAuth !== undefined && ctx.readOp !== undefined && ctx.records.length > 0 && tenantBoundary(ctx.readOp),
|
|
2498
2580
|
id: "tenant.item-not-readable-cross-tenant",
|
|
2499
2581
|
needs: "a second principal in a different tenant, and a tenant tagged or inferred from the path",
|
|
2582
|
+
subjects: subjectsOf.read,
|
|
2500
2583
|
async run(ctx) {
|
|
2501
2584
|
const target = ctx.records[0];
|
|
2502
2585
|
if (target === undefined || ctx.readOp === undefined || ctx.altAuth === undefined)
|
|
@@ -2546,6 +2629,7 @@ const crossTenantFilterBypass = {
|
|
|
2546
2629
|
dependsOn: ["query.filter-selects-from-whole-set"],
|
|
2547
2630
|
id: "tenant.filter-does-not-bypass-scope",
|
|
2548
2631
|
needs: "a second principal, a filter, and a tenant tagged or inferred from the path",
|
|
2632
|
+
subjects: subjectsOf.list,
|
|
2549
2633
|
async run(ctx) {
|
|
2550
2634
|
const target = ctx.records[0];
|
|
2551
2635
|
if (target === undefined || ctx.altAuth === undefined || ctx.altScope === undefined)
|
|
@@ -2613,6 +2697,7 @@ const rankIsMonotonic = {
|
|
|
2613
2697
|
dependsOn: ["list.read-after-write"],
|
|
2614
2698
|
id: "auth.rank-is-monotonic",
|
|
2615
2699
|
needs: "two same-tenant principals at different ranks, and an item route",
|
|
2700
|
+
subjects: subjectsOf.read,
|
|
2616
2701
|
async run(ctx) {
|
|
2617
2702
|
const target = ctx.records[0];
|
|
2618
2703
|
const primary = ctx.actors[0];
|
|
@@ -2678,6 +2763,7 @@ const inviteGrantsThenRevokes = {
|
|
|
2678
2763
|
dependsOn: ["list.read-after-write", "tenant.item-not-readable-cross-tenant"],
|
|
2679
2764
|
id: "auth.invite-grants-then-revokes",
|
|
2680
2765
|
needs: "x-invite naming invite/accept/revoke, a peer principal with inviteAs, and an item or list route",
|
|
2766
|
+
subjects: subjectsOf.invite,
|
|
2681
2767
|
async run(ctx) {
|
|
2682
2768
|
const spec = ctx.invite;
|
|
2683
2769
|
const owner = ctx.actors[0];
|
|
@@ -2812,6 +2898,7 @@ const malformedFilterNot5xx = {
|
|
|
2812
2898
|
applicable: (ctx) => filterable(ctx),
|
|
2813
2899
|
id: "error.malformed-filter-not-5xx",
|
|
2814
2900
|
needs: "a way to express a filter — a filter expression parameter, or filterable fields",
|
|
2901
|
+
subjects: subjectsOf.list,
|
|
2815
2902
|
async run(ctx) {
|
|
2816
2903
|
const result = await list(ctx, q(ctx, { filter: "((((", limit: 10 }));
|
|
2817
2904
|
if (result.exchange.status < 500)
|
|
@@ -2825,6 +2912,7 @@ const limitBoundsPageSize = {
|
|
|
2825
2912
|
dependsOn: ["list.read-after-write"],
|
|
2826
2913
|
id: "pagination.limit-bounds-page-size",
|
|
2827
2914
|
needs: "a page-size query parameter named `limit`",
|
|
2915
|
+
subjects: subjectsOf.list,
|
|
2828
2916
|
async run(ctx) {
|
|
2829
2917
|
const result = await list(ctx, q(ctx, { limit: 2 }));
|
|
2830
2918
|
if (result.exchange.status >= 400)
|
|
@@ -2844,6 +2932,7 @@ const limitRespectsMax = {
|
|
|
2844
2932
|
dependsOn: ["pagination.limit-bounds-page-size"],
|
|
2845
2933
|
id: "pagination.limit-respects-documented-max",
|
|
2846
2934
|
needs: "a declared maxLimit, and more records than it",
|
|
2935
|
+
subjects: subjectsOf.list,
|
|
2847
2936
|
async run(ctx) {
|
|
2848
2937
|
const max = ctx.query?.maxLimit;
|
|
2849
2938
|
if (max === undefined)
|
|
@@ -2862,6 +2951,7 @@ const hasMoreIsAccurate = {
|
|
|
2862
2951
|
dependsOn: ["pagination.limit-bounds-page-size"],
|
|
2863
2952
|
id: "pagination.has-more-is-accurate",
|
|
2864
2953
|
needs: "a way to page forward, and a more-pages signal in the body or a Link header",
|
|
2954
|
+
subjects: subjectsOf.list,
|
|
2865
2955
|
async run(ctx) {
|
|
2866
2956
|
const first = await list(ctx, q(ctx, { limit: 1, page: 1 }));
|
|
2867
2957
|
if (first.exchange.status >= 400 || first.items.length === 0)
|
|
@@ -2885,6 +2975,7 @@ const orderChangesResult = {
|
|
|
2885
2975
|
dependsOn: ["pagination.limit-bounds-page-size"],
|
|
2886
2976
|
id: "sort.order-is-applied",
|
|
2887
2977
|
needs: "an `order` parameter and a sortable field",
|
|
2978
|
+
subjects: subjectsOf.list,
|
|
2888
2979
|
async run(ctx) {
|
|
2889
2980
|
const field = ctx.query?.sortable.find((f) => f !== ctx.identity) ?? ctx.query?.sortable[0];
|
|
2890
2981
|
if (field === undefined)
|
|
@@ -2932,6 +3023,7 @@ const searchNarrowsResult = {
|
|
|
2932
3023
|
dependsOn: ["list.read-after-write"],
|
|
2933
3024
|
id: "search.q-narrows-result",
|
|
2934
3025
|
needs: "a free-text `q` parameter and declared searchable fields",
|
|
3026
|
+
subjects: subjectsOf.list,
|
|
2935
3027
|
async run(ctx) {
|
|
2936
3028
|
const field = ctx.query?.searchable[0];
|
|
2937
3029
|
if (field === undefined)
|
|
@@ -2955,6 +3047,7 @@ const createPersistsFields = {
|
|
|
2955
3047
|
dependsOn: ["list.read-after-write"],
|
|
2956
3048
|
id: "create.persists-submitted-fields",
|
|
2957
3049
|
needs: "a create operation that echoes the record back",
|
|
3050
|
+
subjects: subjectsOf.create,
|
|
2958
3051
|
async run(ctx) {
|
|
2959
3052
|
const createOp = ctx.createOp;
|
|
2960
3053
|
if (createOp === undefined)
|
|
@@ -2988,6 +3081,7 @@ const enumValidated = {
|
|
|
2988
3081
|
mutates: true,
|
|
2989
3082
|
id: "validation.enum-enforced",
|
|
2990
3083
|
needs: "a request schema with an enum field",
|
|
3084
|
+
subjects: subjectsOf.create,
|
|
2991
3085
|
async run(ctx) {
|
|
2992
3086
|
const createOp = ctx.createOp;
|
|
2993
3087
|
if (createOp === undefined)
|
|
@@ -3018,6 +3112,7 @@ const maxLengthValidated = {
|
|
|
3018
3112
|
mutates: true,
|
|
3019
3113
|
id: "validation.max-length-enforced",
|
|
3020
3114
|
needs: "a request schema with a maxLength constraint",
|
|
3115
|
+
subjects: subjectsOf.create,
|
|
3021
3116
|
async run(ctx) {
|
|
3022
3117
|
const createOp = ctx.createOp;
|
|
3023
3118
|
if (createOp === undefined)
|
|
@@ -3047,6 +3142,7 @@ const requiredValidated = {
|
|
|
3047
3142
|
mutates: true,
|
|
3048
3143
|
id: "validation.required-enforced",
|
|
3049
3144
|
needs: "a request schema with a required field",
|
|
3145
|
+
subjects: subjectsOf.create,
|
|
3050
3146
|
async run(ctx) {
|
|
3051
3147
|
const createOp = ctx.createOp;
|
|
3052
3148
|
if (createOp === undefined)
|
|
@@ -3077,6 +3173,7 @@ const contentTypeEnforced = {
|
|
|
3077
3173
|
mutates: true,
|
|
3078
3174
|
id: "validation.content-type-enforced",
|
|
3079
3175
|
needs: "a documented 415 response",
|
|
3176
|
+
subjects: subjectsOf.create,
|
|
3080
3177
|
async run(ctx) {
|
|
3081
3178
|
const createOp = ctx.createOp;
|
|
3082
3179
|
if (createOp === undefined)
|
|
@@ -3102,6 +3199,7 @@ const errorSchemaHonoured = {
|
|
|
3102
3199
|
applicable: (ctx) => ctx.validator !== undefined && ctx.readOp !== undefined,
|
|
3103
3200
|
id: "schema.error-response-matches-document",
|
|
3104
3201
|
needs: "a documented error schema on the item route",
|
|
3202
|
+
subjects: subjectsOf.readAndGated,
|
|
3105
3203
|
async run(ctx) {
|
|
3106
3204
|
const readOp = ctx.readOp;
|
|
3107
3205
|
const validator = ctx.validator;
|
|
@@ -3110,21 +3208,16 @@ const errorSchemaHonoured = {
|
|
|
3110
3208
|
const raw = ctx.model.rawOperations.get(readOp.operationId);
|
|
3111
3209
|
if (raw === undefined)
|
|
3112
3210
|
return;
|
|
3113
|
-
const
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
if (!result.ok) {
|
|
3118
|
-
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 ` +
|
|
3119
|
-
`document declares for it: ${result.errors.join("; ")}. Clients that parse errors ` +
|
|
3120
|
-
"from the spec will not understand this response.", [exchange]);
|
|
3121
|
-
}
|
|
3211
|
+
const judged = [];
|
|
3212
|
+
if (graded(ctx, readOp)) {
|
|
3213
|
+
await probeMissingItemError(ctx, readOp, raw, validator, this.id);
|
|
3214
|
+
judged.push(readOp.operationId);
|
|
3122
3215
|
}
|
|
3123
3216
|
/* A documented feature-gate 403 is coverage for the 2xx check, not for the error schema.
|
|
3124
3217
|
* Walk this entity's already-observed gate denials so an undeclared body is still drift. */
|
|
3125
3218
|
const seen = new Set();
|
|
3126
3219
|
for (const op of ctx.model.operations) {
|
|
3127
|
-
if (op.entity !== ctx.entityName || op.featureGate === null)
|
|
3220
|
+
if (op.entity !== ctx.entityName || op.featureGate === null || !graded(ctx, op))
|
|
3128
3221
|
continue;
|
|
3129
3222
|
const opRaw = ctx.model.rawOperations.get(op.operationId);
|
|
3130
3223
|
if (opRaw === undefined)
|
|
@@ -3136,11 +3229,26 @@ const errorSchemaHonoured = {
|
|
|
3136
3229
|
if (seen.has(key))
|
|
3137
3230
|
continue;
|
|
3138
3231
|
seen.add(key);
|
|
3139
|
-
|
|
3232
|
+
judged.push(op.operationId);
|
|
3233
|
+
reportFeatureGateSchemaDrift(ctx.findings.attributed([op.operationId]), validator, op, opRaw, prior, ctx.entityName);
|
|
3140
3234
|
}
|
|
3141
3235
|
}
|
|
3142
3236
|
},
|
|
3143
3237
|
};
|
|
3238
|
+
async function probeMissingItemError(ctx, readOp, raw, validator, check) {
|
|
3239
|
+
const params = { ...ctx.scope, ...itemParamFor(ctx, "oat-definitely-missing-id") };
|
|
3240
|
+
const exchange = await ctx.client.get(fillPath(readOp.path, params), { headers: ctx.auth() });
|
|
3241
|
+
if (exchange.status < 400 || !validator.documents(raw, exchange.status))
|
|
3242
|
+
return;
|
|
3243
|
+
const result = validator.validate(readOp.operationId, raw, exchange.status, exchange.responseBody);
|
|
3244
|
+
if (result.ok)
|
|
3245
|
+
return;
|
|
3246
|
+
ctx.findings
|
|
3247
|
+
.attributed([readOp.operationId])
|
|
3248
|
+
.spec(check, 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 ` +
|
|
3249
|
+
`document declares for it: ${result.errors.join("; ")}. Clients that parse errors ` +
|
|
3250
|
+
"from the spec will not understand this response.", [exchange]);
|
|
3251
|
+
}
|
|
3144
3252
|
const successSchemaHonoured = {
|
|
3145
3253
|
applicable: (ctx) => ctx.validator !== undefined && ctx.createOp !== undefined,
|
|
3146
3254
|
/* The body is validated against the schema declared *for the status that came back*. When the
|
|
@@ -3149,6 +3257,7 @@ const successSchemaHonoured = {
|
|
|
3149
3257
|
dependsOn: ["create.status-matches-document"],
|
|
3150
3258
|
id: "schema.success-response-matches-document",
|
|
3151
3259
|
needs: "a documented success schema on create",
|
|
3260
|
+
subjects: subjectsOf.create,
|
|
3152
3261
|
async run(ctx) {
|
|
3153
3262
|
const createOp = ctx.createOp;
|
|
3154
3263
|
const validator = ctx.validator;
|
|
@@ -3325,6 +3434,7 @@ const numericComparisonIsNumeric = {
|
|
|
3325
3434
|
dependsOn: ["list.read-after-write", "filter.unknown-field-rejected"],
|
|
3326
3435
|
id: "filter.numeric-comparison-is-numeric",
|
|
3327
3436
|
needs: "a `filter` parameter and a numeric field",
|
|
3437
|
+
subjects: subjectsOf.list,
|
|
3328
3438
|
async run(ctx) {
|
|
3329
3439
|
const field = numericFilterField(ctx);
|
|
3330
3440
|
if (field === null)
|
|
@@ -3409,6 +3519,7 @@ const noLostUpdate = {
|
|
|
3409
3519
|
id: "concurrency.no-lost-update",
|
|
3410
3520
|
needs: "an update operation and two writable string fields",
|
|
3411
3521
|
mutates: true,
|
|
3522
|
+
subjects: subjectsOf.update,
|
|
3412
3523
|
async run(ctx) {
|
|
3413
3524
|
const target = ctx.records[0];
|
|
3414
3525
|
if (target === undefined || ctx.updateOp === undefined || ctx.readOp === undefined)
|
|
@@ -3522,8 +3633,10 @@ const declaredEffectsOccur = {
|
|
|
3522
3633
|
mutates: true,
|
|
3523
3634
|
id: "effects.declared-effect-occurs",
|
|
3524
3635
|
needs: "an operation declaring x-effects",
|
|
3525
|
-
|
|
3526
|
-
|
|
3636
|
+
subjects: subjectsOf.effects,
|
|
3637
|
+
async run(all) {
|
|
3638
|
+
for (const op of all.effectOps) {
|
|
3639
|
+
const ctx = forOperation(all, op);
|
|
3527
3640
|
await forEachInvocation(op.operationId, ctx.uploads, async (uploads, slot) => {
|
|
3528
3641
|
const fixture = slot?.filename;
|
|
3529
3642
|
const effects = op.effects;
|
|
@@ -3639,8 +3752,10 @@ const sideEffectArrives = {
|
|
|
3639
3752
|
mutates: true,
|
|
3640
3753
|
id: "effects.side-effect-arrives",
|
|
3641
3754
|
needs: "an operation declaring x-wait",
|
|
3642
|
-
|
|
3643
|
-
|
|
3755
|
+
subjects: subjectsOf.waits,
|
|
3756
|
+
async run(all) {
|
|
3757
|
+
for (const op of all.waitOps) {
|
|
3758
|
+
const ctx = forOperation(all, op);
|
|
3644
3759
|
const spec = op.wait;
|
|
3645
3760
|
if (spec === null)
|
|
3646
3761
|
continue;
|
|
@@ -3723,8 +3838,10 @@ const asyncReachesTerminalState = {
|
|
|
3723
3838
|
mutates: true,
|
|
3724
3839
|
id: "async.reaches-terminal-state",
|
|
3725
3840
|
needs: "an operation declaring x-async",
|
|
3726
|
-
async
|
|
3727
|
-
|
|
3841
|
+
subjects: subjectsOf.async,
|
|
3842
|
+
async run(all) {
|
|
3843
|
+
for (const op of all.asyncOps) {
|
|
3844
|
+
const ctx = forOperation(all, op);
|
|
3728
3845
|
const spec = op.async;
|
|
3729
3846
|
if (spec === null)
|
|
3730
3847
|
continue;
|
|
@@ -3779,8 +3896,10 @@ const asyncReceiptIsResolvable = {
|
|
|
3779
3896
|
applicable: (ctx) => ctx.asyncOps.some((op) => op.async?.idFrom !== undefined),
|
|
3780
3897
|
id: "async.receipt-identifies-the-job",
|
|
3781
3898
|
needs: "x-async with an idFrom pointer",
|
|
3782
|
-
|
|
3783
|
-
|
|
3899
|
+
subjects: subjectsOf.asyncReceipt,
|
|
3900
|
+
async run(all) {
|
|
3901
|
+
for (const op of all.asyncOps) {
|
|
3902
|
+
const ctx = forOperation(all, op);
|
|
3784
3903
|
const spec = op.async;
|
|
3785
3904
|
if (spec?.idFrom === undefined)
|
|
3786
3905
|
continue;
|
|
@@ -3859,6 +3978,7 @@ const stringPayloadSurvives = {
|
|
|
3859
3978
|
id: "payload.string-survives",
|
|
3860
3979
|
mutates: true,
|
|
3861
3980
|
needs: "an update or create+delete, an item route, and a writable unconstrained string",
|
|
3981
|
+
subjects: subjectsOf.writeAndRead,
|
|
3862
3982
|
async run(ctx) {
|
|
3863
3983
|
const writeOp = ctx.updateOp ?? ctx.createOp;
|
|
3864
3984
|
const readOp = ctx.readOp;
|
|
@@ -4040,9 +4160,11 @@ const documentedStatusHonoured = {
|
|
|
4040
4160
|
applicable: (ctx) => ctx.model.operations.some((op) => op.entity === ctx.entityName && op.action !== "create"),
|
|
4041
4161
|
id: "response.status-is-documented",
|
|
4042
4162
|
needs: "a modeled non-create operation on this entity",
|
|
4163
|
+
subjects: subjectsOf.nonCreate,
|
|
4043
4164
|
async run(ctx) {
|
|
4044
4165
|
const createId = ctx.createOp?.operationId;
|
|
4045
4166
|
const byOp = new Map();
|
|
4167
|
+
const observed = new Set();
|
|
4046
4168
|
for (const exchange of ctx.client.transcript) {
|
|
4047
4169
|
let pathname;
|
|
4048
4170
|
try {
|
|
@@ -4054,21 +4176,25 @@ const documentedStatusHonoured = {
|
|
|
4054
4176
|
const op = resolveEntityOperation(ctx, exchange.method, pathname);
|
|
4055
4177
|
if (op === null)
|
|
4056
4178
|
continue;
|
|
4057
|
-
if (
|
|
4179
|
+
if (!graded(ctx, op))
|
|
4058
4180
|
continue;
|
|
4059
|
-
if (
|
|
4181
|
+
if (op.operationId === createId || op.action === "create")
|
|
4060
4182
|
continue;
|
|
4061
4183
|
if (exchange.status === 429)
|
|
4062
4184
|
continue;
|
|
4063
4185
|
const raw = ctx.model.rawOperations.get(op.operationId);
|
|
4064
4186
|
if (!declaresConcreteStatuses(raw))
|
|
4065
4187
|
continue;
|
|
4188
|
+
observed.add(op.operationId);
|
|
4189
|
+
if (isDocumentedFeatureGateDenial(op, exchange.status, exchange.responseBody))
|
|
4190
|
+
continue;
|
|
4066
4191
|
if (statusIsDeclared(raw, exchange.status))
|
|
4067
4192
|
continue;
|
|
4068
4193
|
const seen = byOp.get(op.operationId) ?? [];
|
|
4069
4194
|
seen.push(exchange);
|
|
4070
4195
|
byOp.set(op.operationId, seen);
|
|
4071
4196
|
}
|
|
4197
|
+
ctx.judged?.([...observed]);
|
|
4072
4198
|
if (byOp.size === 0)
|
|
4073
4199
|
return;
|
|
4074
4200
|
const lines = [];
|
|
@@ -4085,7 +4211,9 @@ const documentedStatusHonoured = {
|
|
|
4085
4211
|
if (first !== undefined && evidence.length < 6)
|
|
4086
4212
|
evidence.push(first);
|
|
4087
4213
|
}
|
|
4088
|
-
ctx.findings
|
|
4214
|
+
ctx.findings
|
|
4215
|
+
.attributed([...byOp.keys()])
|
|
4216
|
+
.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);
|
|
4089
4217
|
},
|
|
4090
4218
|
};
|
|
4091
4219
|
function pickFieldForOp(ctx, op, minDistinct = 2) {
|
|
@@ -4120,6 +4248,7 @@ const filterInIsUnionOfEq = {
|
|
|
4120
4248
|
dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one"],
|
|
4121
4249
|
id: "filter.in-is-union-of-eq",
|
|
4122
4250
|
needs: "a field that allows `in` and at least two distinct values",
|
|
4251
|
+
subjects: subjectsOf.list,
|
|
4123
4252
|
async run(ctx) {
|
|
4124
4253
|
const picked = pickFieldForOp(ctx, "in", 2);
|
|
4125
4254
|
if (picked === null)
|
|
@@ -4156,6 +4285,7 @@ const filterNinComplementsIn = {
|
|
|
4156
4285
|
dependsOn: [...FOUNDATIONS, "filter.in-is-union-of-eq"],
|
|
4157
4286
|
id: "filter.nin-complements-in",
|
|
4158
4287
|
needs: "a field that allows both `in` and `nin`",
|
|
4288
|
+
subjects: subjectsOf.list,
|
|
4159
4289
|
async run(ctx) {
|
|
4160
4290
|
const picked = pickFieldForOp(ctx, "in", 1);
|
|
4161
4291
|
if (picked === null || !canUseOp(ctx, picked.field, "nin"))
|
|
@@ -4201,6 +4331,7 @@ const filterGteIsGtOrEq = {
|
|
|
4201
4331
|
dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one", "filter.numeric-comparison-is-numeric"],
|
|
4202
4332
|
id: "filter.gte-is-gt-or-eq",
|
|
4203
4333
|
needs: "an ordered field that allows `gte` and `gt`",
|
|
4334
|
+
subjects: subjectsOf.list,
|
|
4204
4335
|
async run(ctx) {
|
|
4205
4336
|
const picked = pickOrderedField(ctx);
|
|
4206
4337
|
if (picked === null || !canUseOp(ctx, picked.field, "gte"))
|
|
@@ -4216,6 +4347,7 @@ const filterLteIsLtOrEq = {
|
|
|
4216
4347
|
dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one", "filter.numeric-comparison-is-numeric"],
|
|
4217
4348
|
id: "filter.lte-is-lt-or-eq",
|
|
4218
4349
|
needs: "an ordered field that allows `lte` and `lt`",
|
|
4350
|
+
subjects: subjectsOf.list,
|
|
4219
4351
|
async run(ctx) {
|
|
4220
4352
|
const picked = pickOrderedField(ctx);
|
|
4221
4353
|
if (picked === null || !canUseOp(ctx, picked.field, "lte"))
|
|
@@ -4254,6 +4386,7 @@ const filterOrderedTriplePartitions = {
|
|
|
4254
4386
|
dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one", "filter.numeric-comparison-is-numeric"],
|
|
4255
4387
|
id: "filter.ordered-triple-partitions",
|
|
4256
4388
|
needs: "an ordered field that allows `lt`, `eq`, and `gt`",
|
|
4389
|
+
subjects: subjectsOf.list,
|
|
4257
4390
|
async run(ctx) {
|
|
4258
4391
|
const picked = pickOrderedField(ctx);
|
|
4259
4392
|
if (picked === null)
|
|
@@ -4308,6 +4441,7 @@ const filterIlikeIsCaseInsensitive = {
|
|
|
4308
4441
|
dependsOn: [...FOUNDATIONS, "filter.like-metacharacters-escaped"],
|
|
4309
4442
|
id: "filter.ilike-is-case-insensitive",
|
|
4310
4443
|
needs: "a field that allows both `ilike` and `like`, and a string with a letter",
|
|
4444
|
+
subjects: subjectsOf.list,
|
|
4311
4445
|
async run(ctx) {
|
|
4312
4446
|
const field = fieldsAllowing(ctx, "ilike").find((item) => canUseOp(ctx, item, "like"));
|
|
4313
4447
|
if (field === undefined)
|
|
@@ -4356,6 +4490,7 @@ const filterIsNullSelectsNulls = {
|
|
|
4356
4490
|
dependsOn: [...FOUNDATIONS],
|
|
4357
4491
|
id: "filter.is-null-selects-nulls",
|
|
4358
4492
|
needs: "a field that allows `is` and a cohort that contains a null",
|
|
4493
|
+
subjects: subjectsOf.list,
|
|
4359
4494
|
async run(ctx) {
|
|
4360
4495
|
const field = fieldsAllowing(ctx, "is").find((item) => mixedNullField(ctx, item.field));
|
|
4361
4496
|
if (field === undefined)
|
|
@@ -4393,6 +4528,7 @@ const filterContainsMembership = {
|
|
|
4393
4528
|
dependsOn: [...FOUNDATIONS],
|
|
4394
4529
|
id: "filter.contains-membership",
|
|
4395
4530
|
needs: "an array field that allows `contains` and a known element",
|
|
4531
|
+
subjects: subjectsOf.list,
|
|
4396
4532
|
async run(ctx) {
|
|
4397
4533
|
const field = resolvedCaps(ctx).filterable.find((item) => canUseOp(ctx, item, "contains") &&
|
|
4398
4534
|
ctx.records.some((row) => Array.isArray(row[item.field]) && row[item.field].length > 0));
|
|
@@ -4427,6 +4563,7 @@ const filterNestedAndOrDistributes = {
|
|
|
4427
4563
|
dependsOn: [...FOUNDATIONS, "filter.and-composes-as-intersection", "filter.or-composes-as-union"],
|
|
4428
4564
|
id: "filter.nested-and-or-distributes",
|
|
4429
4565
|
needs: "a postgrest-shaped grammar and two filterable fields",
|
|
4566
|
+
subjects: subjectsOf.list,
|
|
4430
4567
|
async run(ctx) {
|
|
4431
4568
|
const picked = twoFilterableFields(ctx);
|
|
4432
4569
|
if (picked === null) {
|
|
@@ -4474,6 +4611,7 @@ const filterAliasMatchesCanonical = {
|
|
|
4474
4611
|
dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one"],
|
|
4475
4612
|
id: "filter.alias-matches-canonical",
|
|
4476
4613
|
needs: "a declared filter operator alias",
|
|
4614
|
+
subjects: subjectsOf.list,
|
|
4477
4615
|
async run(ctx) {
|
|
4478
4616
|
const caps = resolvedCaps(ctx);
|
|
4479
4617
|
const conventions = conv(ctx);
|
|
@@ -4516,6 +4654,7 @@ const filterIllegalOpRejected = {
|
|
|
4516
4654
|
dependsOn: ["list.read-after-write", "filter.unknown-field-rejected", "error.malformed-filter-not-5xx"],
|
|
4517
4655
|
id: "filter.illegal-op-rejected",
|
|
4518
4656
|
needs: "a field with a closed operator list",
|
|
4657
|
+
subjects: subjectsOf.list,
|
|
4519
4658
|
async run(ctx) {
|
|
4520
4659
|
const field = resolvedCaps(ctx).filterable.find((item) => firstIllegalOp(ctx, item) !== undefined);
|
|
4521
4660
|
const op = field === undefined ? undefined : firstIllegalOp(ctx, field);
|
|
@@ -4544,6 +4683,7 @@ const filterEmptyIn = {
|
|
|
4544
4683
|
dependsOn: [...FOUNDATIONS, "filter.in-is-union-of-eq"],
|
|
4545
4684
|
id: "filter.empty-in",
|
|
4546
4685
|
needs: "`emptyIn` declared and a field that allows `in`",
|
|
4686
|
+
subjects: subjectsOf.list,
|
|
4547
4687
|
async run(ctx) {
|
|
4548
4688
|
const picked = pickFieldForOp(ctx, "in", 1);
|
|
4549
4689
|
const policy = resolvedCaps(ctx).emptyIn;
|
|
@@ -4576,6 +4716,7 @@ const filterInOverLimitRejected = {
|
|
|
4576
4716
|
dependsOn: [...FOUNDATIONS, "error.malformed-filter-not-5xx"],
|
|
4577
4717
|
id: "filter.in-over-limit-rejected",
|
|
4578
4718
|
needs: "`maxInValues` declared and a field that allows `in`",
|
|
4719
|
+
subjects: subjectsOf.list,
|
|
4579
4720
|
async run(ctx) {
|
|
4580
4721
|
const max = resolvedCaps(ctx).maxInValues;
|
|
4581
4722
|
const picked = pickFieldForOp(ctx, "in", 1);
|
|
@@ -4598,6 +4739,7 @@ const filterConditionCapRejected = {
|
|
|
4598
4739
|
dependsOn: [...FOUNDATIONS, "error.malformed-filter-not-5xx"],
|
|
4599
4740
|
id: "filter.condition-cap-rejected",
|
|
4600
4741
|
needs: "`maxFilterConditions` declared and a grammar that can group eq terms",
|
|
4742
|
+
subjects: subjectsOf.list,
|
|
4601
4743
|
async run(ctx) {
|
|
4602
4744
|
const max = resolvedCaps(ctx).maxFilterConditions;
|
|
4603
4745
|
const parameter = conv(ctx).filter;
|
|
@@ -4632,6 +4774,7 @@ const declaredFilterableOpsAccepted = {
|
|
|
4632
4774
|
dependsOn: ["list.read-after-write", "spec.declared-filterable-is-filterable", "error.malformed-filter-not-5xx"],
|
|
4633
4775
|
id: "spec.declared-filterable-ops-accepted",
|
|
4634
4776
|
needs: "a closed operator list on at least one declared field",
|
|
4777
|
+
subjects: subjectsOf.list,
|
|
4635
4778
|
async run(ctx) {
|
|
4636
4779
|
const caps = resolvedCaps(ctx);
|
|
4637
4780
|
const rejected = [];
|
|
@@ -4661,6 +4804,7 @@ const declaredFilterableIllegalOpRejected = {
|
|
|
4661
4804
|
dependsOn: ["filter.illegal-op-rejected", "spec.declared-filterable-is-filterable"],
|
|
4662
4805
|
id: "spec.declared-filterable-illegal-op-rejected",
|
|
4663
4806
|
needs: "a closed operator list on a declared field",
|
|
4807
|
+
subjects: subjectsOf.list,
|
|
4664
4808
|
async run(ctx) {
|
|
4665
4809
|
await filterIllegalOpRejected.run(ctx);
|
|
4666
4810
|
},
|
|
@@ -4670,6 +4814,7 @@ const sortUnknownFieldRejected = {
|
|
|
4670
4814
|
dependsOn: ["error.malformed-filter-not-5xx"],
|
|
4671
4815
|
id: "sort.unknown-field-rejected",
|
|
4672
4816
|
needs: "an order parameter",
|
|
4817
|
+
subjects: subjectsOf.list,
|
|
4673
4818
|
async run(ctx) {
|
|
4674
4819
|
const result = await list(ctx, q(ctx, { limit: 5, order: sortTerm(conv(ctx), "oat_no_such_sort_xyz", "asc") }));
|
|
4675
4820
|
if (result.exchange.status >= 500) {
|
|
@@ -4691,6 +4836,7 @@ const sortNumericOrderIsNumeric = {
|
|
|
4691
4836
|
dependsOn: ["sort.order-is-applied"],
|
|
4692
4837
|
id: "sort.numeric-order-is-numeric",
|
|
4693
4838
|
needs: "a numeric sortable field whose lexical order disagrees with numeric order",
|
|
4839
|
+
subjects: subjectsOf.list,
|
|
4694
4840
|
async run(ctx) {
|
|
4695
4841
|
const field = resolvedCaps(ctx).sortable.find((item) => numericLexicalDisagrees(ctx, item.field));
|
|
4696
4842
|
if (field === undefined)
|
|
@@ -4713,6 +4859,7 @@ const sortNullsFirstLast = {
|
|
|
4713
4859
|
dependsOn: ["sort.order-is-applied", "sort.reverse-symmetry"],
|
|
4714
4860
|
id: "sort.nulls-first-last",
|
|
4715
4861
|
needs: "a declared nulls token, a dotted sort grammar, and a null in the cohort",
|
|
4862
|
+
subjects: subjectsOf.list,
|
|
4716
4863
|
async run(ctx) {
|
|
4717
4864
|
const field = resolvedCaps(ctx).sortable.find((item) => (fieldAllowsNulls(item, resolvedCaps(ctx), "first") || fieldAllowsNulls(item, resolvedCaps(ctx), "last")) &&
|
|
4718
4865
|
ctx.records.some((row) => row[item.field] === null || row[item.field] === undefined));
|
|
@@ -4774,6 +4921,7 @@ const sortMultiKeyTiebreak = {
|
|
|
4774
4921
|
dependsOn: ["sort.order-is-applied", "sort.reverse-symmetry"],
|
|
4775
4922
|
id: "sort.multi-key-tiebreak",
|
|
4776
4923
|
needs: "two sortable fields and rows that tie on the first and differ on the second",
|
|
4924
|
+
subjects: subjectsOf.list,
|
|
4777
4925
|
async run(ctx) {
|
|
4778
4926
|
const pair = pickMultiKeyPair(ctx);
|
|
4779
4927
|
if (pair === null) {
|
|
@@ -4798,6 +4946,7 @@ const sortDefaultOrderApplied = {
|
|
|
4798
4946
|
dependsOn: ["sort.order-is-applied", "pagination.page-walk-covers-set"],
|
|
4799
4947
|
id: "sort.default-order-applied",
|
|
4800
4948
|
needs: "a declared defaultOrder and a complete walk",
|
|
4949
|
+
subjects: subjectsOf.list,
|
|
4801
4950
|
async run(ctx) {
|
|
4802
4951
|
const declared = resolvedCaps(ctx).sort?.defaultOrder ?? ctx.query?.defaultOrder;
|
|
4803
4952
|
if (declared === undefined)
|
|
@@ -4821,6 +4970,7 @@ const sortStableTiebreak = {
|
|
|
4821
4970
|
dependsOn: ["sort.order-is-applied"],
|
|
4822
4971
|
id: "sort.stable-tiebreak",
|
|
4823
4972
|
needs: "a declared stableTiebreak and a tie on the primary key",
|
|
4973
|
+
subjects: subjectsOf.list,
|
|
4824
4974
|
async run(ctx) {
|
|
4825
4975
|
const tiebreak = resolvedCaps(ctx).sort?.stableTiebreak ?? ctx.query?.stableTiebreak;
|
|
4826
4976
|
const primary = ctx.query?.sortable.find((name) => name !== tiebreak) ?? ctx.query?.sortable[0];
|
|
@@ -4843,6 +4993,7 @@ const declaredSortableNullsAccepted = {
|
|
|
4843
4993
|
dependsOn: ["sort.order-is-applied"],
|
|
4844
4994
|
id: "spec.declared-sortable-nulls-accepted",
|
|
4845
4995
|
needs: "a declared nulls token on a sortable field",
|
|
4996
|
+
subjects: subjectsOf.list,
|
|
4846
4997
|
async run(ctx) {
|
|
4847
4998
|
const rejected = [];
|
|
4848
4999
|
for (const field of resolvedCaps(ctx).sortable) {
|
|
@@ -4868,6 +5019,7 @@ const searchTokensAnd = {
|
|
|
4868
5019
|
dependsOn: ["search.q-narrows-result"],
|
|
4869
5020
|
id: "search.tokens-and",
|
|
4870
5021
|
needs: "searchable fields and two tokens that split the cohort",
|
|
5022
|
+
subjects: subjectsOf.list,
|
|
4871
5023
|
async run(ctx) {
|
|
4872
5024
|
const field = ctx.query?.searchable[0];
|
|
4873
5025
|
if (field === undefined)
|
|
@@ -4911,6 +5063,7 @@ const searchCaseInsensitive = {
|
|
|
4911
5063
|
dependsOn: ["search.q-narrows-result"],
|
|
4912
5064
|
id: "search.case-insensitive",
|
|
4913
5065
|
needs: "a searchable string with a letter",
|
|
5066
|
+
subjects: subjectsOf.list,
|
|
4914
5067
|
async run(ctx) {
|
|
4915
5068
|
let sample;
|
|
4916
5069
|
for (const field of ctx.query?.searchable ?? []) {
|
|
@@ -4947,6 +5100,7 @@ const searchEmptyQ = {
|
|
|
4947
5100
|
dependsOn: ["search.q-narrows-result"],
|
|
4948
5101
|
id: "search.empty-q",
|
|
4949
5102
|
needs: "`searchEmpty` declared",
|
|
5103
|
+
subjects: subjectsOf.list,
|
|
4950
5104
|
async run(ctx) {
|
|
4951
5105
|
const policy = resolvedCaps(ctx).searchEmpty;
|
|
4952
5106
|
if (policy === undefined)
|
|
@@ -4978,6 +5132,7 @@ const searchUndeclaredFieldNotRequired = {
|
|
|
4978
5132
|
dependsOn: ["search.q-narrows-result"],
|
|
4979
5133
|
id: "search.undeclared-field-not-required",
|
|
4980
5134
|
needs: "a searchable field and a non-searchable string",
|
|
5135
|
+
subjects: subjectsOf.list,
|
|
4981
5136
|
async run() {
|
|
4982
5137
|
/* Extra recall is not a defect. This check exists so undeclared-field hits are not
|
|
4983
5138
|
* reported as SEARCH_IGNORED by other checks. */
|
|
@@ -4995,6 +5150,7 @@ const searchModeAccepted = {
|
|
|
4995
5150
|
conv(ctx).search !== undefined,
|
|
4996
5151
|
id: "search.mode-accepted",
|
|
4997
5152
|
needs: "declared searchModes and a search-mode parameter",
|
|
5153
|
+
subjects: subjectsOf.list,
|
|
4998
5154
|
async run(ctx) {
|
|
4999
5155
|
const rejected = [];
|
|
5000
5156
|
for (const mode of resolvedCaps(ctx).searchModes ?? []) {
|
|
@@ -5014,6 +5170,7 @@ const searchModesDiffer = {
|
|
|
5014
5170
|
dependsOn: ["search.mode-accepted"],
|
|
5015
5171
|
id: "search.modes-differ",
|
|
5016
5172
|
needs: "at least two declared searchModes and a mode parameter",
|
|
5173
|
+
subjects: subjectsOf.list,
|
|
5017
5174
|
async run(ctx) {
|
|
5018
5175
|
const modes = resolvedCaps(ctx).searchModes ?? [];
|
|
5019
5176
|
const a = modes[0];
|
|
@@ -5042,6 +5199,7 @@ const selectRequestedFieldsPresent = {
|
|
|
5042
5199
|
dependsOn: ["select.projection-honoured"],
|
|
5043
5200
|
id: "select.requested-fields-present",
|
|
5044
5201
|
needs: "a select parameter and at least one selectable field",
|
|
5202
|
+
subjects: subjectsOf.list,
|
|
5045
5203
|
async run(ctx) {
|
|
5046
5204
|
const requested = [
|
|
5047
5205
|
ctx.identity,
|
|
@@ -5070,6 +5228,7 @@ const selectUnknownFieldRejected = {
|
|
|
5070
5228
|
dependsOn: ["select.projection-honoured", "error.malformed-filter-not-5xx"],
|
|
5071
5229
|
id: "select.unknown-field-rejected",
|
|
5072
5230
|
needs: "`select.unknown` declared",
|
|
5231
|
+
subjects: subjectsOf.list,
|
|
5073
5232
|
async run(ctx) {
|
|
5074
5233
|
const policy = resolvedCaps(ctx).select?.unknown;
|
|
5075
5234
|
if (policy === undefined)
|
|
@@ -5101,6 +5260,7 @@ const selectNestedHonoured = {
|
|
|
5101
5260
|
dependsOn: ["select.projection-honoured"],
|
|
5102
5261
|
id: "select.nested-honoured",
|
|
5103
5262
|
needs: "select.nested and a named relation",
|
|
5263
|
+
subjects: subjectsOf.list,
|
|
5104
5264
|
async run(ctx) {
|
|
5105
5265
|
const relation = resolvedCaps(ctx).select?.relations?.[0];
|
|
5106
5266
|
if (relation === undefined || relation.fields[0] === undefined)
|
|
@@ -5133,6 +5293,7 @@ const querySortAndSelectCompose = {
|
|
|
5133
5293
|
dependsOn: ["sort.order-is-applied", "select.projection-honoured", "select.requested-fields-present"],
|
|
5134
5294
|
id: "query.sort-and-select-compose",
|
|
5135
5295
|
needs: "sortable and selectable fields",
|
|
5296
|
+
subjects: subjectsOf.list,
|
|
5136
5297
|
async run(ctx) {
|
|
5137
5298
|
const field = ctx.query?.sortable.find((name) => name !== ctx.identity) ?? ctx.query?.sortable[0];
|
|
5138
5299
|
const extra = ctx.query?.selectable.find((name) => name !== ctx.identity);
|
|
@@ -5167,6 +5328,7 @@ const querySearchAndSelectCompose = {
|
|
|
5167
5328
|
dependsOn: ["search.q-narrows-result", "select.projection-honoured"],
|
|
5168
5329
|
id: "query.search-and-select-compose",
|
|
5169
5330
|
needs: "searchable and selectable fields",
|
|
5331
|
+
subjects: subjectsOf.list,
|
|
5170
5332
|
async run(ctx) {
|
|
5171
5333
|
const field = ctx.query?.searchable[0];
|
|
5172
5334
|
if (field === undefined)
|
|
@@ -5201,6 +5363,7 @@ const querySearchAndSortCompose = {
|
|
|
5201
5363
|
dependsOn: ["search.q-narrows-result", "sort.order-is-applied"],
|
|
5202
5364
|
id: "query.search-and-sort-compose",
|
|
5203
5365
|
needs: "searchable and sortable fields",
|
|
5366
|
+
subjects: subjectsOf.list,
|
|
5204
5367
|
async run(ctx) {
|
|
5205
5368
|
const searchField = ctx.query?.searchable[0];
|
|
5206
5369
|
const sortField = ctx.query?.sortable.find((name) => name !== ctx.identity) ?? ctx.query?.sortable[0];
|
|
@@ -5248,6 +5411,7 @@ const queryFilterSearchSortSelectCompose = {
|
|
|
5248
5411
|
],
|
|
5249
5412
|
id: "query.filter-search-sort-select-compose",
|
|
5250
5413
|
needs: "all four list axes declared and at least three records",
|
|
5414
|
+
subjects: subjectsOf.list,
|
|
5251
5415
|
async run(ctx) {
|
|
5252
5416
|
const picked = overlappingFilterAndSearch(ctx);
|
|
5253
5417
|
if (picked === null) {
|
|
@@ -5319,6 +5483,7 @@ const uniqueConflictCreate = {
|
|
|
5319
5483
|
id: "create.unique-conflict-rejected",
|
|
5320
5484
|
mutates: true,
|
|
5321
5485
|
needs: "x-unique on create with at least one probeable column set, and a known row",
|
|
5486
|
+
subjects: subjectsOf.create,
|
|
5322
5487
|
async run(ctx) {
|
|
5323
5488
|
const createOp = ctx.uniqueCreateOp;
|
|
5324
5489
|
if (createOp === undefined)
|
|
@@ -5387,6 +5552,7 @@ const uniqueConflictUpdate = {
|
|
|
5387
5552
|
id: "update.unique-conflict-rejected",
|
|
5388
5553
|
mutates: true,
|
|
5389
5554
|
needs: "x-unique, an update operation with a probeable set, and two known rows",
|
|
5555
|
+
subjects: subjectsOf.update,
|
|
5390
5556
|
async run(ctx) {
|
|
5391
5557
|
const updateOp = ctx.uniqueUpdateOp;
|
|
5392
5558
|
if (updateOp === undefined)
|