@lovrozagar/oat 0.6.2 → 0.7.2
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 +443 -146
- package/dist/cli.js +35 -29
- package/dist/config/define-config.d.ts +125 -3
- package/dist/config/define-config.d.ts.map +1 -1
- package/dist/config/define-config.js +6 -0
- package/dist/config/define-config.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.js +4 -1
- package/dist/reference/defects.d.ts +16 -0
- package/dist/reference/defects.d.ts.map +1 -1
- package/dist/reference/defects.js +16 -0
- package/dist/reference/defects.js.map +1 -1
- package/dist/reference/model.d.ts +9 -0
- package/dist/reference/model.d.ts.map +1 -1
- package/dist/reference/model.js +20 -0
- package/dist/reference/model.js.map +1 -1
- package/dist/reference/query.d.ts +5 -0
- package/dist/reference/query.d.ts.map +1 -1
- package/dist/reference/query.js +87 -21
- package/dist/reference/query.js.map +1 -1
- package/dist/reference/spec.d.ts.map +1 -1
- package/dist/reference/spec.js +47 -2
- package/dist/reference/spec.js.map +1 -1
- package/dist/reference/store-api.d.ts +5 -1
- package/dist/reference/store-api.d.ts.map +1 -1
- package/dist/reference/store-api.js +9 -1
- package/dist/reference/store-api.js.map +1 -1
- package/dist/reference/stores/memory.d.ts.map +1 -1
- package/dist/reference/stores/memory.js +11 -0
- package/dist/reference/stores/memory.js.map +1 -1
- package/dist/reference/stores/postgres.d.ts.map +1 -1
- package/dist/reference/stores/postgres.js +75 -19
- package/dist/reference/stores/postgres.js.map +1 -1
- package/dist/reference/stores/sqlite-driver.d.ts.map +1 -1
- package/dist/reference/stores/sqlite-driver.js +3 -0
- package/dist/reference/stores/sqlite-driver.js.map +1 -1
- package/dist/reference/stores/sqlite.d.ts.map +1 -1
- package/dist/reference/stores/sqlite.js +78 -17
- package/dist/reference/stores/sqlite.js.map +1 -1
- package/dist/report/console.d.ts +7 -1
- package/dist/report/console.d.ts.map +1 -1
- package/dist/report/console.js +210 -1
- package/dist/report/console.js.map +1 -1
- package/dist/report/render.d.ts.map +1 -1
- package/dist/report/render.js +4 -2
- package/dist/report/render.js.map +1 -1
- package/dist/runtime/auth.d.ts +22 -2
- package/dist/runtime/auth.d.ts.map +1 -1
- package/dist/runtime/auth.js +129 -16
- package/dist/runtime/auth.js.map +1 -1
- package/dist/runtime/body.d.ts.map +1 -1
- package/dist/runtime/body.js +6 -3
- package/dist/runtime/body.js.map +1 -1
- package/dist/runtime/checks.d.ts +9 -0
- package/dist/runtime/checks.d.ts.map +1 -1
- package/dist/runtime/checks.js +1414 -18
- package/dist/runtime/checks.js.map +1 -1
- package/dist/runtime/client.d.ts +23 -1
- package/dist/runtime/client.d.ts.map +1 -1
- package/dist/runtime/client.js +35 -5
- package/dist/runtime/client.js.map +1 -1
- package/dist/runtime/finding.d.ts +2 -0
- package/dist/runtime/finding.d.ts.map +1 -1
- package/dist/runtime/finding.js.map +1 -1
- package/dist/runtime/input.d.ts +7 -0
- package/dist/runtime/input.d.ts.map +1 -0
- package/dist/runtime/input.js +46 -0
- package/dist/runtime/input.js.map +1 -0
- package/dist/runtime/poll.d.ts +29 -0
- package/dist/runtime/poll.d.ts.map +1 -0
- package/dist/runtime/poll.js +59 -0
- package/dist/runtime/poll.js.map +1 -0
- package/dist/runtime/principals.d.ts +29 -0
- package/dist/runtime/principals.d.ts.map +1 -0
- package/dist/runtime/principals.js +97 -0
- package/dist/runtime/principals.js.map +1 -0
- package/dist/runtime/progress.d.ts +21 -0
- package/dist/runtime/progress.d.ts.map +1 -1
- package/dist/runtime/progress.js +63 -21
- package/dist/runtime/progress.js.map +1 -1
- package/dist/runtime/query-capabilities.d.ts +24 -0
- package/dist/runtime/query-capabilities.d.ts.map +1 -0
- package/dist/runtime/query-capabilities.js +66 -0
- package/dist/runtime/query-capabilities.js.map +1 -0
- package/dist/runtime/run.d.ts +14 -1
- package/dist/runtime/run.d.ts.map +1 -1
- package/dist/runtime/run.js +198 -39
- package/dist/runtime/run.js.map +1 -1
- package/dist/runtime/runs.d.ts +23 -0
- package/dist/runtime/runs.d.ts.map +1 -0
- package/dist/runtime/runs.js +53 -0
- package/dist/runtime/runs.js.map +1 -0
- package/dist/runtime/upload.d.ts +1 -0
- package/dist/runtime/upload.d.ts.map +1 -1
- package/dist/runtime/upload.js.map +1 -1
- package/dist/runtime/wait.d.ts +32 -0
- package/dist/runtime/wait.d.ts.map +1 -0
- package/dist/runtime/wait.js +125 -0
- package/dist/runtime/wait.js.map +1 -0
- package/dist/runtime/world.d.ts.map +1 -1
- package/dist/runtime/world.js +2 -0
- package/dist/runtime/world.js.map +1 -1
- package/dist/spec/conventions.d.ts +10 -2
- package/dist/spec/conventions.d.ts.map +1 -1
- package/dist/spec/conventions.js +61 -7
- package/dist/spec/conventions.js.map +1 -1
- package/dist/spec/extensions.d.ts +27 -0
- package/dist/spec/extensions.d.ts.map +1 -1
- package/dist/spec/extensions.js +40 -5
- package/dist/spec/extensions.js.map +1 -1
- package/dist/spec/graph.d.ts +2 -1
- package/dist/spec/graph.d.ts.map +1 -1
- package/dist/spec/graph.js +2 -1
- package/dist/spec/graph.js.map +1 -1
- package/dist/spec/query-capabilities.d.ts +154 -0
- package/dist/spec/query-capabilities.d.ts.map +1 -0
- package/dist/spec/query-capabilities.js +734 -0
- package/dist/spec/query-capabilities.js.map +1 -0
- package/labs/annotated-openapi.yaml +21 -2
- package/labs/demo.mjs +4 -2
- package/package.json +4 -2
package/dist/runtime/checks.js
CHANGED
|
@@ -5,14 +5,17 @@
|
|
|
5
5
|
* ground truth — they compare the API against itself, through independent projections, or against
|
|
6
6
|
* the oracle of what oat just wrote. Check ids are stable: the conformance suite asserts on them.
|
|
7
7
|
*/
|
|
8
|
-
import { filterTerm, selectTerm, sortTerm } from "../spec/conventions.js";
|
|
8
|
+
import { canWriteFilterOp, filterTerm, selectTerm, sortTerm, sortTermWithNulls } from "../spec/conventions.js";
|
|
9
|
+
import { FILTER_OPS, ORDERED_TYPES, fieldAllows, fieldAllowsNulls, isFilterOp, mergeQueryCapabilities, opsAreClosed, opsForField, } from "../spec/query-capabilities.js";
|
|
9
10
|
import { requestContent } from "../spec/collection.js";
|
|
10
11
|
import { pathTemplateMatches } from "../spec/load.js";
|
|
11
12
|
import { encodeForOperation } from "./body.js";
|
|
12
13
|
import { STRING_PAYLOADS, payloadFits } from "./payloads.js";
|
|
13
14
|
import { describeFeatureGate, isDocumentedFeatureGateDenial, reportFeatureGateSchemaDrift } from "./feature-gate.js";
|
|
14
15
|
import { driveAsync, inspectStreamAsync, matchesPredicate, resolveAsyncId } from "./async.js";
|
|
16
|
+
import { resolveOutOfBandValue } from "./auth.js";
|
|
15
17
|
import { buildCohort } from "./fixture.js";
|
|
18
|
+
import { driveWait } from "./wait.js";
|
|
16
19
|
import { fillPath } from "./world.js";
|
|
17
20
|
/* ------------------------------------------------------------------- helpers */
|
|
18
21
|
/**
|
|
@@ -84,6 +87,8 @@ function q(ctx, roles) {
|
|
|
84
87
|
out[c.order] = roles.order;
|
|
85
88
|
if (roles.search !== undefined && c.search !== undefined)
|
|
86
89
|
out[c.search] = roles.search;
|
|
90
|
+
if (roles.searchMode !== undefined && c.searchMode !== undefined)
|
|
91
|
+
out[c.searchMode] = roles.searchMode;
|
|
87
92
|
if (roles.filter !== undefined && c.filter !== undefined)
|
|
88
93
|
out[c.filter] = roles.filter;
|
|
89
94
|
return out;
|
|
@@ -108,6 +113,48 @@ function filterable(ctx) {
|
|
|
108
113
|
* which fields are filterable — otherwise oat would be guessing at parameter names. */
|
|
109
114
|
return c.grammar === "equality" && (ctx.query?.filterable.length ?? 0) > 0;
|
|
110
115
|
}
|
|
116
|
+
function resolvedCaps(ctx) {
|
|
117
|
+
if (ctx.capabilities !== undefined)
|
|
118
|
+
return ctx.capabilities;
|
|
119
|
+
return mergeQueryCapabilities({
|
|
120
|
+
itemSchema: ctx.listOp.collection?.itemSchema ?? null,
|
|
121
|
+
tag: ctx.query === null
|
|
122
|
+
? null
|
|
123
|
+
: {
|
|
124
|
+
filterable: ctx.query.filterable,
|
|
125
|
+
searchable: ctx.query.searchable,
|
|
126
|
+
selectable: ctx.query.selectable,
|
|
127
|
+
sortable: ctx.query.sortable,
|
|
128
|
+
source: ctx.query.source,
|
|
129
|
+
...(ctx.query.filterableDeclared === undefined ? {} : { filterableDeclared: ctx.query.filterableDeclared }),
|
|
130
|
+
...(ctx.query.sortableDeclared === undefined ? {} : { sortableDeclared: ctx.query.sortableDeclared }),
|
|
131
|
+
...(ctx.query.searchableDeclared === undefined ? {} : { searchableDeclared: ctx.query.searchableDeclared }),
|
|
132
|
+
...(ctx.query.selectableDeclared === undefined ? {} : { selectableDeclared: ctx.query.selectableDeclared }),
|
|
133
|
+
...(ctx.query.filterFields === undefined ? {} : { filterFields: ctx.query.filterFields }),
|
|
134
|
+
...(ctx.query.sortableFields === undefined ? {} : { sortableFields: ctx.query.sortableFields }),
|
|
135
|
+
...(ctx.query.catalog === undefined ? {} : { catalog: ctx.query.catalog }),
|
|
136
|
+
...(ctx.query.defaultOrder === undefined ? {} : { defaultOrder: ctx.query.defaultOrder }),
|
|
137
|
+
...(ctx.query.stableTiebreak === undefined ? {} : { stableTiebreak: ctx.query.stableTiebreak }),
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
function filterableNames(ctx) {
|
|
142
|
+
return resolvedCaps(ctx).filterable.map((field) => field.field);
|
|
143
|
+
}
|
|
144
|
+
/** Filter field for identity predicates when it differs from the JSON identity. */
|
|
145
|
+
function filterIdentity(ctx) {
|
|
146
|
+
return resolvedCaps(ctx).identityFilter ?? ctx.identity;
|
|
147
|
+
}
|
|
148
|
+
function identityIsFilterable(ctx) {
|
|
149
|
+
const names = filterableNames(ctx);
|
|
150
|
+
return names.includes(filterIdentity(ctx)) || names.includes(ctx.identity);
|
|
151
|
+
}
|
|
152
|
+
function fieldByName(ctx, name) {
|
|
153
|
+
return resolvedCaps(ctx).filterable.find((field) => field.field === name);
|
|
154
|
+
}
|
|
155
|
+
function canUseOp(ctx, field, op) {
|
|
156
|
+
return canWriteFilterOp(conv(ctx), op) && fieldAllows(field, op, resolvedCaps(ctx));
|
|
157
|
+
}
|
|
111
158
|
/**
|
|
112
159
|
* Whether the endpoint can be walked forward at all, however it counts.
|
|
113
160
|
*
|
|
@@ -161,6 +208,7 @@ function linkRelations(exchange) {
|
|
|
161
208
|
async function list(ctx, query = {}, auth = ctx.auth, scope = ctx.scope) {
|
|
162
209
|
const exchange = await ctx.client.get(fillPath(ctx.listOp.path, scope), {
|
|
163
210
|
headers: auth(),
|
|
211
|
+
operationId: ctx.listOp.operationId,
|
|
164
212
|
query,
|
|
165
213
|
});
|
|
166
214
|
const body = exchange.responseBody;
|
|
@@ -312,15 +360,49 @@ function nullableField(ctx, candidates) {
|
|
|
312
360
|
return null;
|
|
313
361
|
}
|
|
314
362
|
function firstFilterable(ctx, predicate) {
|
|
315
|
-
|
|
316
|
-
|
|
363
|
+
return filterableNames(ctx).find(predicate) ?? null;
|
|
364
|
+
}
|
|
365
|
+
function fieldsAllowing(ctx, op) {
|
|
366
|
+
if (!canWriteFilterOp(conv(ctx), op))
|
|
367
|
+
return [];
|
|
368
|
+
return resolvedCaps(ctx).filterable.filter((field) => fieldAllows(field, op, resolvedCaps(ctx)));
|
|
369
|
+
}
|
|
370
|
+
function distinctValues(records, field) {
|
|
371
|
+
const seen = new Set();
|
|
372
|
+
const out = [];
|
|
373
|
+
for (const record of records) {
|
|
374
|
+
const value = record[field];
|
|
375
|
+
if (value === null || value === undefined)
|
|
376
|
+
continue;
|
|
377
|
+
const key = String(value);
|
|
378
|
+
if (seen.has(key))
|
|
379
|
+
continue;
|
|
380
|
+
seen.add(key);
|
|
381
|
+
out.push(value);
|
|
382
|
+
}
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
385
|
+
function asTermValue(value) {
|
|
386
|
+
return typeof value === "number" ? value : String(value);
|
|
387
|
+
}
|
|
388
|
+
function setOf(records, identity) {
|
|
389
|
+
return new Set(ids(records, identity));
|
|
390
|
+
}
|
|
391
|
+
function sameSet(a, b) {
|
|
392
|
+
if (a.size !== b.size)
|
|
393
|
+
return false;
|
|
394
|
+
for (const item of a)
|
|
395
|
+
if (!b.has(item))
|
|
396
|
+
return false;
|
|
397
|
+
return true;
|
|
317
398
|
}
|
|
318
399
|
/**
|
|
319
400
|
* Finds a record holding non-null values on two distinct, non-identity filterable fields — the
|
|
320
401
|
* minimum needed to build a compound predicate that is guaranteed to match at least that record.
|
|
321
402
|
*/
|
|
322
403
|
function twoFilterableFields(ctx) {
|
|
323
|
-
const
|
|
404
|
+
const skip = new Set([ctx.identity, filterIdentity(ctx)]);
|
|
405
|
+
const candidates = filterableNames(ctx).filter((f) => !skip.has(f));
|
|
324
406
|
for (const target of ctx.records) {
|
|
325
407
|
const present = candidates.filter((f) => target[f] !== null && target[f] !== undefined);
|
|
326
408
|
if (present[0] !== undefined && present[1] !== undefined) {
|
|
@@ -484,7 +566,7 @@ const unknownFilterRejected = {
|
|
|
484
566
|
},
|
|
485
567
|
};
|
|
486
568
|
const equalityFilterSelectsOne = {
|
|
487
|
-
applicable: (ctx) => filterable(ctx) && ctx.records.length > 0 && (ctx
|
|
569
|
+
applicable: (ctx) => filterable(ctx) && ctx.records.length > 0 && identityIsFilterable(ctx),
|
|
488
570
|
dependsOn: ["list.read-after-write"],
|
|
489
571
|
id: "filter.equality-selects-exactly-one",
|
|
490
572
|
needs: "a `filter` parameter that accepts the identity field",
|
|
@@ -493,7 +575,8 @@ const equalityFilterSelectsOne = {
|
|
|
493
575
|
if (target === undefined)
|
|
494
576
|
return;
|
|
495
577
|
const id = String(target[ctx.identity]);
|
|
496
|
-
const
|
|
578
|
+
const field = filterIdentity(ctx);
|
|
579
|
+
const term = filterTerm(conv(ctx), field, "eq", id);
|
|
497
580
|
if (term === null)
|
|
498
581
|
return;
|
|
499
582
|
const result = await list(ctx, { ...q(ctx, { limit: 100 }), ...term });
|
|
@@ -505,16 +588,16 @@ const equalityFilterSelectsOne = {
|
|
|
505
588
|
const got = ids(result.items, ctx.identity);
|
|
506
589
|
if (got.length === 1 && got[0] === id)
|
|
507
590
|
return;
|
|
508
|
-
ctx.findings.backend(this.id, ctx.entityName, "equality filter on the identity does not select exactly one record", `filter=${
|
|
591
|
+
ctx.findings.backend(this.id, ctx.entityName, "equality filter on the identity does not select exactly one record", `filter=${field}.eq.${id} returned ${got.length} records (${got.slice(0, 5).join(", ")})`, [result.exchange]);
|
|
509
592
|
},
|
|
510
593
|
};
|
|
511
594
|
const zeroMatchFilter = {
|
|
512
|
-
applicable: (ctx) => filterable(ctx) && (ctx
|
|
595
|
+
applicable: (ctx) => filterable(ctx) && identityIsFilterable(ctx),
|
|
513
596
|
dependsOn: ["list.read-after-write"],
|
|
514
597
|
id: "filter.zero-match-returns-none",
|
|
515
598
|
needs: "a `filter` parameter that accepts the identity field",
|
|
516
599
|
async run(ctx) {
|
|
517
|
-
const term = filterTerm(conv(ctx), ctx
|
|
600
|
+
const term = filterTerm(conv(ctx), filterIdentity(ctx), "eq", "oat-nonexistent-value-000");
|
|
518
601
|
if (term === null)
|
|
519
602
|
return;
|
|
520
603
|
const result = await list(ctx, { ...q(ctx, { limit: 100 }), ...term });
|
|
@@ -527,7 +610,7 @@ const zeroMatchFilter = {
|
|
|
527
610
|
},
|
|
528
611
|
};
|
|
529
612
|
const negationPartitions = {
|
|
530
|
-
applicable: (ctx) => filterable(ctx) && ctx.records.length > 1 && (ctx
|
|
613
|
+
applicable: (ctx) => filterable(ctx) && ctx.records.length > 1 && identityIsFilterable(ctx),
|
|
531
614
|
dependsOn: [
|
|
532
615
|
"list.read-after-write",
|
|
533
616
|
/* Partitioning is asserted over field *values*. A backend that drops submitted fields
|
|
@@ -545,7 +628,7 @@ const negationPartitions = {
|
|
|
545
628
|
return;
|
|
546
629
|
/* Prefer a field with nulls in the cohort: partitioning on the identity can never expose
|
|
547
630
|
* three-valued-logic bugs, because an identity is never null. */
|
|
548
|
-
const field = nullableField(ctx, ctx
|
|
631
|
+
const field = nullableField(ctx, filterableNames(ctx)) ?? filterIdentity(ctx);
|
|
549
632
|
const probe = ctx.records.map((r) => r[field]).find((v) => v !== null && v !== undefined);
|
|
550
633
|
if (probe === undefined) {
|
|
551
634
|
return ctx.findings.unresolved(this.id, ctx.entityName, `every record in the cohort has a null "${field}", leaving no value to negate`);
|
|
@@ -920,13 +1003,13 @@ const countMatchesWalk = {
|
|
|
920
1003
|
id: "count.matches-filtered-set",
|
|
921
1004
|
needs: "a total-count field and a `filter` parameter",
|
|
922
1005
|
async run(ctx) {
|
|
923
|
-
if (!(ctx
|
|
1006
|
+
if (!identityIsFilterable(ctx))
|
|
924
1007
|
return;
|
|
925
1008
|
const target = ctx.records[0];
|
|
926
1009
|
if (target === undefined)
|
|
927
1010
|
return;
|
|
928
1011
|
const id = String(target[ctx.identity]);
|
|
929
|
-
const countTerm = filterTerm(conv(ctx), ctx
|
|
1012
|
+
const countTerm = filterTerm(conv(ctx), filterIdentity(ctx), "eq", id);
|
|
930
1013
|
if (countTerm === null)
|
|
931
1014
|
return;
|
|
932
1015
|
const filtered = await list(ctx, { ...q(ctx, { limit: 100 }), ...countTerm });
|
|
@@ -1463,6 +1546,7 @@ const projectionsAgree = {
|
|
|
1463
1546
|
* disagreement here is that same defect seen a second time. */
|
|
1464
1547
|
"filter.equality-selects-exactly-one",
|
|
1465
1548
|
"select.projection-honoured",
|
|
1549
|
+
"select.requested-fields-present",
|
|
1466
1550
|
"sort.order-is-applied",
|
|
1467
1551
|
],
|
|
1468
1552
|
id: "consistency.projections-agree",
|
|
@@ -2453,10 +2537,10 @@ const crossTenantFilterBypass = {
|
|
|
2453
2537
|
const target = ctx.records[0];
|
|
2454
2538
|
if (target === undefined || ctx.altAuth === undefined || ctx.altScope === undefined)
|
|
2455
2539
|
return;
|
|
2456
|
-
if (!(ctx
|
|
2540
|
+
if (!identityIsFilterable(ctx))
|
|
2457
2541
|
return;
|
|
2458
2542
|
const id = String(target[ctx.identity]);
|
|
2459
|
-
const tenantTerm = filterTerm(conv(ctx), ctx
|
|
2543
|
+
const tenantTerm = filterTerm(conv(ctx), filterIdentity(ctx), "eq", id);
|
|
2460
2544
|
if (tenantTerm === null)
|
|
2461
2545
|
return;
|
|
2462
2546
|
const result = await list(ctx, { ...q(ctx, { limit: 100 }), ...tenantTerm }, ctx.altAuth, ctx.altScope);
|
|
@@ -2601,6 +2685,7 @@ const inviteGrantsThenRevokes = {
|
|
|
2601
2685
|
const invited = await ctx.client.request(inviteOp.method, fillPath(inviteOp.path, resource), {
|
|
2602
2686
|
body: { [spec.granteeField]: delegate.inviteAs },
|
|
2603
2687
|
headers: { ...owner.headers(), "content-type": "application/json" },
|
|
2688
|
+
operationId: inviteOp.operationId,
|
|
2604
2689
|
});
|
|
2605
2690
|
if (standDownForFeatureGate(ctx, inviteOp, invited, this.id))
|
|
2606
2691
|
return;
|
|
@@ -2612,10 +2697,25 @@ const inviteGrantsThenRevokes = {
|
|
|
2612
2697
|
"step is then theatre — anyone who can be named in an invite body is already in.", [invited]);
|
|
2613
2698
|
return;
|
|
2614
2699
|
}
|
|
2615
|
-
const token = pointerValue(invited.responseBody, spec.tokenPointer);
|
|
2616
2700
|
const grantId = pointerValue(invited.responseBody, spec.grantPointer);
|
|
2617
|
-
|
|
2618
|
-
|
|
2701
|
+
let token;
|
|
2702
|
+
if (spec.tokenFrom === "outOfBand") {
|
|
2703
|
+
const kind = spec.tokenKind ?? `${ctx.entityName}-invite`;
|
|
2704
|
+
try {
|
|
2705
|
+
token = await resolveOutOfBandValue(ctx.hooks.resolveOutOfBand, delegate.inviteAs, kind, {
|
|
2706
|
+
label: `invite to ${ctx.entityName}`,
|
|
2707
|
+
outOfBand: ctx.outOfBand,
|
|
2708
|
+
});
|
|
2709
|
+
}
|
|
2710
|
+
catch (error) {
|
|
2711
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, error instanceof Error ? error.message : String(error));
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
else {
|
|
2715
|
+
token = pointerValue(invited.responseBody, spec.tokenPointer);
|
|
2716
|
+
if (token === undefined) {
|
|
2717
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `invite response has no token at ${spec.tokenPointer}`);
|
|
2718
|
+
}
|
|
2619
2719
|
}
|
|
2620
2720
|
const acceptScope = { ...resource, token };
|
|
2621
2721
|
const acceptBody = documentedJsonBody(ctx, acceptOp, acceptScope);
|
|
@@ -2625,6 +2725,7 @@ const inviteGrantsThenRevokes = {
|
|
|
2625
2725
|
...delegate.headers(),
|
|
2626
2726
|
...(acceptBody === undefined ? {} : { "content-type": "application/json" }),
|
|
2627
2727
|
},
|
|
2728
|
+
operationId: acceptOp.operationId,
|
|
2628
2729
|
});
|
|
2629
2730
|
if (standDownForFeatureGate(ctx, acceptOp, accepted, this.id))
|
|
2630
2731
|
return;
|
|
@@ -2647,6 +2748,7 @@ const inviteGrantsThenRevokes = {
|
|
|
2647
2748
|
...owner.headers(),
|
|
2648
2749
|
...(revokeBody === undefined ? {} : { "content-type": "application/json" }),
|
|
2649
2750
|
},
|
|
2751
|
+
operationId: revokeOp.operationId,
|
|
2650
2752
|
});
|
|
2651
2753
|
if (standDownForFeatureGate(ctx, revokeOp, revoked, this.id))
|
|
2652
2754
|
return;
|
|
@@ -2758,6 +2860,15 @@ const orderChangesResult = {
|
|
|
2758
2860
|
"backend ignores silently gives every caller arbitrary ordering.", [ascending.exchange]);
|
|
2759
2861
|
},
|
|
2760
2862
|
};
|
|
2863
|
+
function numericLexicalDisagrees(ctx, field) {
|
|
2864
|
+
const values = ctx.records.map((row) => row[field]).filter((v) => typeof v === "number");
|
|
2865
|
+
if (values.length < 3)
|
|
2866
|
+
return false;
|
|
2867
|
+
const unique = [...new Set(values)];
|
|
2868
|
+
const asNumbers = [...unique].sort((a, b) => a - b);
|
|
2869
|
+
const asText = [...unique].sort((a, b) => String(a).localeCompare(String(b)));
|
|
2870
|
+
return asNumbers.join(",") !== asText.join(",");
|
|
2871
|
+
}
|
|
2761
2872
|
function compareValues(a, b) {
|
|
2762
2873
|
if (a === null || a === undefined)
|
|
2763
2874
|
return -1;
|
|
@@ -3396,6 +3507,61 @@ const declaredEffectsOccur = {
|
|
|
3396
3507
|
}
|
|
3397
3508
|
},
|
|
3398
3509
|
};
|
|
3510
|
+
/**
|
|
3511
|
+
* After a write that declares `x-wait`, poll the named GET until a JSON path is occupied
|
|
3512
|
+
* (or `awaitSideEffect` returns true). Timeout is a backend finding, not a coverage gap.
|
|
3513
|
+
*/
|
|
3514
|
+
const sideEffectArrives = {
|
|
3515
|
+
applicable: (ctx) => ctx.waitOps.length > 0,
|
|
3516
|
+
dependsOn: ["list.read-after-write"],
|
|
3517
|
+
mutates: true,
|
|
3518
|
+
id: "effects.side-effect-arrives",
|
|
3519
|
+
needs: "an operation declaring x-wait",
|
|
3520
|
+
async run(ctx) {
|
|
3521
|
+
for (const op of ctx.waitOps) {
|
|
3522
|
+
const spec = op.wait;
|
|
3523
|
+
if (spec === null)
|
|
3524
|
+
continue;
|
|
3525
|
+
const pollOp = ctx.model.byOperationId.get(spec.operationId);
|
|
3526
|
+
if (pollOp === undefined) {
|
|
3527
|
+
ctx.findings.gap(this.id, ctx.entityName, `${op.operationId} x-wait names "${spec.operationId}", which is not in the document`, "the side effect cannot be observed, so it is not verified");
|
|
3528
|
+
continue;
|
|
3529
|
+
}
|
|
3530
|
+
const body = op.hasRequestBody ? bodyForOp(ctx, op) : undefined;
|
|
3531
|
+
const invoked = await ctx.client.request(op.method, fillPath(op.path, ctx.scope), {
|
|
3532
|
+
headers: ctx.auth(),
|
|
3533
|
+
operationId: op.operationId,
|
|
3534
|
+
...(body === undefined ? {} : await encodeOpBody(ctx, op, body)),
|
|
3535
|
+
});
|
|
3536
|
+
if (standDownForFeatureGate(ctx, op, invoked, this.id))
|
|
3537
|
+
continue;
|
|
3538
|
+
if (standDownForRateLimit(ctx, invoked, this.id))
|
|
3539
|
+
continue;
|
|
3540
|
+
if (invoked.status >= 400) {
|
|
3541
|
+
ctx.findings.gap(this.id, ctx.entityName, `${op.operationId} could not be invoked`, `returned ${invoked.status}; its declared x-wait is unverified`);
|
|
3542
|
+
continue;
|
|
3543
|
+
}
|
|
3544
|
+
const outcome = await driveWait({
|
|
3545
|
+
awaitSideEffect: ctx.hooks.awaitSideEffect,
|
|
3546
|
+
client: ctx.client,
|
|
3547
|
+
headers: ctx.auth,
|
|
3548
|
+
pollOp,
|
|
3549
|
+
record: invoked.responseBody,
|
|
3550
|
+
scope: ctx.scope,
|
|
3551
|
+
spec,
|
|
3552
|
+
writeOpId: op.operationId,
|
|
3553
|
+
...(ctx.refreshIfStale === undefined ? {} : { refreshIfStale: ctx.refreshIfStale }),
|
|
3554
|
+
});
|
|
3555
|
+
if (!outcome.timedOut)
|
|
3556
|
+
continue;
|
|
3557
|
+
ctx.findings.backend(this.id, ctx.entityName, `${op.operationId} side effect did not appear within ${spec.timeoutMs}ms`, `x-wait polls ${spec.operationId}` +
|
|
3558
|
+
(spec.until === undefined ? "" : ` until ${spec.until} is non-empty`) +
|
|
3559
|
+
` and the path was still empty after ${outcome.polls} poll(s) / ${Math.round(outcome.elapsedMs)}ms. ` +
|
|
3560
|
+
"Queue consumers and webhook inboxes are not the same request; a timeout here is a " +
|
|
3561
|
+
"missed delivery, not a coverage gap.", [invoked, ...outcome.exchanges.slice(-2)]);
|
|
3562
|
+
}
|
|
3563
|
+
},
|
|
3564
|
+
};
|
|
3399
3565
|
async function observe(ctx, listOp) {
|
|
3400
3566
|
let path;
|
|
3401
3567
|
try {
|
|
@@ -3781,6 +3947,1199 @@ const documentedStatusHonoured = {
|
|
|
3781
3947
|
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);
|
|
3782
3948
|
},
|
|
3783
3949
|
};
|
|
3950
|
+
function pickFieldForOp(ctx, op, minDistinct = 2) {
|
|
3951
|
+
for (const field of fieldsAllowing(ctx, op)) {
|
|
3952
|
+
const values = distinctValues(ctx.records, field.field);
|
|
3953
|
+
if (values.length >= minDistinct)
|
|
3954
|
+
return { field, values };
|
|
3955
|
+
}
|
|
3956
|
+
return null;
|
|
3957
|
+
}
|
|
3958
|
+
function pickOrderedField(ctx) {
|
|
3959
|
+
const caps = resolvedCaps(ctx);
|
|
3960
|
+
for (const field of caps.filterable) {
|
|
3961
|
+
if (field.type !== undefined && !ORDERED_TYPES.has(field.type))
|
|
3962
|
+
continue;
|
|
3963
|
+
if (!canUseOp(ctx, field, "gt") || !canUseOp(ctx, field, "lt") || !canUseOp(ctx, field, "eq"))
|
|
3964
|
+
continue;
|
|
3965
|
+
const values = ctx.records.map((r) => r[field.field]).filter((v) => typeof v === "number");
|
|
3966
|
+
if (values.length < 3)
|
|
3967
|
+
continue;
|
|
3968
|
+
return { field, values };
|
|
3969
|
+
}
|
|
3970
|
+
return null;
|
|
3971
|
+
}
|
|
3972
|
+
const FOUNDATIONS = [
|
|
3973
|
+
"list.read-after-write",
|
|
3974
|
+
"create.persists-submitted-fields",
|
|
3975
|
+
"pagination.page-walk-covers-set",
|
|
3976
|
+
];
|
|
3977
|
+
const filterInIsUnionOfEq = {
|
|
3978
|
+
applicable: (ctx) => filterable(ctx) && pickFieldForOp(ctx, "in", 2) !== null,
|
|
3979
|
+
dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one"],
|
|
3980
|
+
id: "filter.in-is-union-of-eq",
|
|
3981
|
+
needs: "a field that allows `in` and at least two distinct values",
|
|
3982
|
+
async run(ctx) {
|
|
3983
|
+
const picked = pickFieldForOp(ctx, "in", 2);
|
|
3984
|
+
if (picked === null)
|
|
3985
|
+
return;
|
|
3986
|
+
const [a, b] = picked.values;
|
|
3987
|
+
if (a === undefined || b === undefined)
|
|
3988
|
+
return;
|
|
3989
|
+
const conventions = conv(ctx);
|
|
3990
|
+
const inTerm = filterTerm(conventions, picked.field.field, "in", [asTermValue(a), asTermValue(b)]);
|
|
3991
|
+
const eqA = filterTerm(conventions, picked.field.field, "eq", asTermValue(a));
|
|
3992
|
+
const eqB = filterTerm(conventions, picked.field.field, "eq", asTermValue(b));
|
|
3993
|
+
if (inTerm === null || eqA === null || eqB === null)
|
|
3994
|
+
return;
|
|
3995
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
3996
|
+
const together = await collectSet(ctx, limit, inTerm);
|
|
3997
|
+
const onlyA = await collectSet(ctx, limit, eqA);
|
|
3998
|
+
const onlyB = await collectSet(ctx, limit, eqB);
|
|
3999
|
+
if (together === null || onlyA === null || onlyB === null) {
|
|
4000
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "one of the listings needed for in() was rejected");
|
|
4001
|
+
}
|
|
4002
|
+
if (!together.complete || !onlyA.complete || !onlyB.complete) {
|
|
4003
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers");
|
|
4004
|
+
}
|
|
4005
|
+
const expected = new Set([...ids(onlyA.items, ctx.identity), ...ids(onlyB.items, ctx.identity)]);
|
|
4006
|
+
const got = setOf(together.items, ctx.identity);
|
|
4007
|
+
if (sameSet(expected, got))
|
|
4008
|
+
return;
|
|
4009
|
+
ctx.findings.backend(this.id, ctx.entityName, "in() is not the union of the equalities it lists", `${picked.field.field}.in.(${String(a)},${String(b)}) returned ${got.size} record(s); ` +
|
|
4010
|
+
`eq on each value together match ${expected.size}.`, [together.last.exchange, onlyA.last.exchange, onlyB.last.exchange]);
|
|
4011
|
+
},
|
|
4012
|
+
};
|
|
4013
|
+
const filterNinComplementsIn = {
|
|
4014
|
+
applicable: (ctx) => filterable(ctx) && pickFieldForOp(ctx, "in", 1) !== null && pickFieldForOp(ctx, "nin", 1) !== null,
|
|
4015
|
+
dependsOn: [...FOUNDATIONS, "filter.in-is-union-of-eq"],
|
|
4016
|
+
id: "filter.nin-complements-in",
|
|
4017
|
+
needs: "a field that allows both `in` and `nin`",
|
|
4018
|
+
async run(ctx) {
|
|
4019
|
+
const picked = pickFieldForOp(ctx, "in", 1);
|
|
4020
|
+
if (picked === null || !canUseOp(ctx, picked.field, "nin"))
|
|
4021
|
+
return;
|
|
4022
|
+
const value = picked.values[0];
|
|
4023
|
+
if (value === undefined)
|
|
4024
|
+
return;
|
|
4025
|
+
const conventions = conv(ctx);
|
|
4026
|
+
const inTerm = filterTerm(conventions, picked.field.field, "in", [asTermValue(value)]);
|
|
4027
|
+
const ninTerm = filterTerm(conventions, picked.field.field, "nin", [asTermValue(value)]);
|
|
4028
|
+
if (inTerm === null || ninTerm === null)
|
|
4029
|
+
return;
|
|
4030
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
4031
|
+
const all = await collectSet(ctx, limit);
|
|
4032
|
+
const inside = await collectSet(ctx, limit, inTerm);
|
|
4033
|
+
const outside = await collectSet(ctx, limit, ninTerm);
|
|
4034
|
+
if (all === null || inside === null || outside === null) {
|
|
4035
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "one of the listings needed for nin() was rejected");
|
|
4036
|
+
}
|
|
4037
|
+
if (!all.complete || !inside.complete || !outside.complete) {
|
|
4038
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers");
|
|
4039
|
+
}
|
|
4040
|
+
const inIds = setOf(inside.items, ctx.identity);
|
|
4041
|
+
const ninIds = setOf(outside.items, ctx.identity);
|
|
4042
|
+
const overlap = [...inIds].filter((id) => ninIds.has(id));
|
|
4043
|
+
if (overlap.length > 0) {
|
|
4044
|
+
ctx.findings.backend(this.id, ctx.entityName, "in() and nin() both match the same record", `${overlap.length} record(s) appear in both ${picked.field.field}.in and .nin.`, [inside.last.exchange, outside.last.exchange]);
|
|
4045
|
+
return;
|
|
4046
|
+
}
|
|
4047
|
+
const expected = ids(all.items, ctx.identity).filter((id) => {
|
|
4048
|
+
const record = ctx.records.find((row) => String(row[ctx.identity]) === id);
|
|
4049
|
+
return record !== undefined && record[picked.field.field] !== null && record[picked.field.field] !== undefined;
|
|
4050
|
+
});
|
|
4051
|
+
const union = new Set([...inIds, ...ninIds]);
|
|
4052
|
+
const missing = expected.filter((id) => !union.has(id));
|
|
4053
|
+
if (missing.length === 0)
|
|
4054
|
+
return;
|
|
4055
|
+
ctx.findings.backend(this.id, ctx.entityName, "in() and nin() do not cover the non-null set", `${missing.length} non-null record(s) match neither side.`, [all.last.exchange, inside.last.exchange, outside.last.exchange]);
|
|
4056
|
+
},
|
|
4057
|
+
};
|
|
4058
|
+
const filterGteIsGtOrEq = {
|
|
4059
|
+
applicable: (ctx) => pickOrderedField(ctx) !== null && fieldsAllowing(ctx, "gte").length > 0,
|
|
4060
|
+
dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one", "filter.numeric-comparison-is-numeric"],
|
|
4061
|
+
id: "filter.gte-is-gt-or-eq",
|
|
4062
|
+
needs: "an ordered field that allows `gte` and `gt`",
|
|
4063
|
+
async run(ctx) {
|
|
4064
|
+
const picked = pickOrderedField(ctx);
|
|
4065
|
+
if (picked === null || !canUseOp(ctx, picked.field, "gte"))
|
|
4066
|
+
return;
|
|
4067
|
+
const threshold = [...new Set(picked.values)].sort((a, b) => a - b)[Math.floor(picked.values.length / 3)];
|
|
4068
|
+
if (threshold === undefined)
|
|
4069
|
+
return;
|
|
4070
|
+
await assertRangeUnion(ctx, this.id, picked.field.field, "gte", "gt", threshold);
|
|
4071
|
+
},
|
|
4072
|
+
};
|
|
4073
|
+
const filterLteIsLtOrEq = {
|
|
4074
|
+
applicable: (ctx) => pickOrderedField(ctx) !== null && fieldsAllowing(ctx, "lte").length > 0,
|
|
4075
|
+
dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one", "filter.numeric-comparison-is-numeric"],
|
|
4076
|
+
id: "filter.lte-is-lt-or-eq",
|
|
4077
|
+
needs: "an ordered field that allows `lte` and `lt`",
|
|
4078
|
+
async run(ctx) {
|
|
4079
|
+
const picked = pickOrderedField(ctx);
|
|
4080
|
+
if (picked === null || !canUseOp(ctx, picked.field, "lte"))
|
|
4081
|
+
return;
|
|
4082
|
+
const threshold = [...new Set(picked.values)].sort((a, b) => a - b)[Math.floor(picked.values.length / 3)];
|
|
4083
|
+
if (threshold === undefined)
|
|
4084
|
+
return;
|
|
4085
|
+
await assertRangeUnion(ctx, this.id, picked.field.field, "lte", "lt", threshold);
|
|
4086
|
+
},
|
|
4087
|
+
};
|
|
4088
|
+
async function assertRangeUnion(ctx, check, field, closed, open, threshold) {
|
|
4089
|
+
const conventions = conv(ctx);
|
|
4090
|
+
const closedTerm = filterTerm(conventions, field, closed, threshold);
|
|
4091
|
+
const openTerm = filterTerm(conventions, field, open, threshold);
|
|
4092
|
+
const eqTerm = filterTerm(conventions, field, "eq", threshold);
|
|
4093
|
+
if (closedTerm === null || openTerm === null || eqTerm === null)
|
|
4094
|
+
return;
|
|
4095
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
4096
|
+
const closedSet = await collectSet(ctx, limit, closedTerm);
|
|
4097
|
+
const openSet = await collectSet(ctx, limit, openTerm);
|
|
4098
|
+
const eqSet = await collectSet(ctx, limit, eqTerm);
|
|
4099
|
+
if (closedSet === null || openSet === null || eqSet === null) {
|
|
4100
|
+
return ctx.findings.unresolved(check, ctx.entityName, "one of the range listings was rejected");
|
|
4101
|
+
}
|
|
4102
|
+
if (!closedSet.complete || !openSet.complete || !eqSet.complete) {
|
|
4103
|
+
return ctx.findings.unresolved(check, ctx.entityName, "the collection is larger than the walk covers");
|
|
4104
|
+
}
|
|
4105
|
+
const expected = new Set([...ids(openSet.items, ctx.identity), ...ids(eqSet.items, ctx.identity)]);
|
|
4106
|
+
const got = setOf(closedSet.items, ctx.identity);
|
|
4107
|
+
if (sameSet(expected, got))
|
|
4108
|
+
return;
|
|
4109
|
+
ctx.findings.backend(check, ctx.entityName, `${closed} is not ${open} ∪ eq`, `${field}.${closed}.${threshold} returned ${got.size}; ${open} ∪ eq is ${expected.size}.`, [closedSet.last.exchange, openSet.last.exchange, eqSet.last.exchange]);
|
|
4110
|
+
}
|
|
4111
|
+
const filterOrderedTriplePartitions = {
|
|
4112
|
+
applicable: (ctx) => pickOrderedField(ctx) !== null,
|
|
4113
|
+
dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one", "filter.numeric-comparison-is-numeric"],
|
|
4114
|
+
id: "filter.ordered-triple-partitions",
|
|
4115
|
+
needs: "an ordered field that allows `lt`, `eq`, and `gt`",
|
|
4116
|
+
async run(ctx) {
|
|
4117
|
+
const picked = pickOrderedField(ctx);
|
|
4118
|
+
if (picked === null)
|
|
4119
|
+
return;
|
|
4120
|
+
const threshold = [...new Set(picked.values)].sort((a, b) => a - b)[Math.floor(picked.values.length / 2)];
|
|
4121
|
+
if (threshold === undefined)
|
|
4122
|
+
return;
|
|
4123
|
+
const conventions = conv(ctx);
|
|
4124
|
+
const lt = filterTerm(conventions, picked.field.field, "lt", threshold);
|
|
4125
|
+
const eq = filterTerm(conventions, picked.field.field, "eq", threshold);
|
|
4126
|
+
const gt = filterTerm(conventions, picked.field.field, "gt", threshold);
|
|
4127
|
+
if (lt === null || eq === null || gt === null)
|
|
4128
|
+
return;
|
|
4129
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
4130
|
+
const all = await collectSet(ctx, limit);
|
|
4131
|
+
const lower = await collectSet(ctx, limit, lt);
|
|
4132
|
+
const equal = await collectSet(ctx, limit, eq);
|
|
4133
|
+
const higher = await collectSet(ctx, limit, gt);
|
|
4134
|
+
if (all === null || lower === null || equal === null || higher === null) {
|
|
4135
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "one of the triple listings was rejected");
|
|
4136
|
+
}
|
|
4137
|
+
if (!all.complete || !lower.complete || !equal.complete || !higher.complete) {
|
|
4138
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers");
|
|
4139
|
+
}
|
|
4140
|
+
const sets = [setOf(lower.items, ctx.identity), setOf(equal.items, ctx.identity), setOf(higher.items, ctx.identity)];
|
|
4141
|
+
for (let i = 0; i < sets.length; i++) {
|
|
4142
|
+
for (let j = i + 1; j < sets.length; j++) {
|
|
4143
|
+
const left = sets[i];
|
|
4144
|
+
const right = sets[j];
|
|
4145
|
+
if (left === undefined || right === undefined)
|
|
4146
|
+
continue;
|
|
4147
|
+
const overlap = [...left].filter((id) => right.has(id));
|
|
4148
|
+
if (overlap.length > 0) {
|
|
4149
|
+
ctx.findings.backend(this.id, ctx.entityName, "lt / eq / gt are not pairwise disjoint", `${overlap.length} record(s) appear in more than one of ${picked.field.field} lt/eq/gt.`, [lower.last.exchange, equal.last.exchange, higher.last.exchange]);
|
|
4150
|
+
return;
|
|
4151
|
+
}
|
|
4152
|
+
}
|
|
4153
|
+
}
|
|
4154
|
+
const union = new Set([...(sets[0] ?? []), ...(sets[1] ?? []), ...(sets[2] ?? [])]);
|
|
4155
|
+
const expected = ids(all.items, ctx.identity).filter((id) => {
|
|
4156
|
+
const record = ctx.records.find((row) => String(row[ctx.identity]) === id);
|
|
4157
|
+
return record !== undefined && typeof record[picked.field.field] === "number";
|
|
4158
|
+
});
|
|
4159
|
+
const missing = expected.filter((id) => !union.has(id));
|
|
4160
|
+
if (missing.length === 0)
|
|
4161
|
+
return;
|
|
4162
|
+
ctx.findings.backend(this.id, ctx.entityName, "lt ∪ eq ∪ gt does not cover the numeric set", `${missing.length} numeric record(s) match none of the three predicates.`, [all.last.exchange, lower.last.exchange, equal.last.exchange, higher.last.exchange]);
|
|
4163
|
+
},
|
|
4164
|
+
};
|
|
4165
|
+
const filterIlikeIsCaseInsensitive = {
|
|
4166
|
+
applicable: (ctx) => fieldsAllowing(ctx, "ilike").some((field) => canUseOp(ctx, field, "like")),
|
|
4167
|
+
dependsOn: [...FOUNDATIONS, "filter.like-metacharacters-escaped"],
|
|
4168
|
+
id: "filter.ilike-is-case-insensitive",
|
|
4169
|
+
needs: "a field that allows both `ilike` and `like`, and a string with a letter",
|
|
4170
|
+
async run(ctx) {
|
|
4171
|
+
const field = fieldsAllowing(ctx, "ilike").find((item) => canUseOp(ctx, item, "like"));
|
|
4172
|
+
if (field === undefined)
|
|
4173
|
+
return;
|
|
4174
|
+
const sample = ctx.records
|
|
4175
|
+
.map((row) => row[field.field])
|
|
4176
|
+
.find((value) => typeof value === "string" && /[A-Za-z]/.test(value));
|
|
4177
|
+
if (sample === undefined) {
|
|
4178
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "no searchable string on the field has a letter");
|
|
4179
|
+
}
|
|
4180
|
+
const flipped = sample.replace(/[A-Za-z]/, (ch) => (ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase()));
|
|
4181
|
+
if (flipped === sample) {
|
|
4182
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "could not case-flip a letter in the sample");
|
|
4183
|
+
}
|
|
4184
|
+
const conventions = conv(ctx);
|
|
4185
|
+
const likeTerm = filterTerm(conventions, field.field, "like", flipped);
|
|
4186
|
+
const ilikeTerm = filterTerm(conventions, field.field, "ilike", flipped);
|
|
4187
|
+
if (likeTerm === null || ilikeTerm === null)
|
|
4188
|
+
return;
|
|
4189
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
4190
|
+
const like = await list(ctx, { ...q(ctx, { limit }), ...likeTerm });
|
|
4191
|
+
const ilike = await list(ctx, { ...q(ctx, { limit }), ...ilikeTerm });
|
|
4192
|
+
if (like.exchange.status >= 400 || ilike.exchange.status >= 400) {
|
|
4193
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "like/ilike probe was rejected");
|
|
4194
|
+
}
|
|
4195
|
+
const likeHits = ids(like.items, ctx.identity);
|
|
4196
|
+
const ilikeHits = ids(ilike.items, ctx.identity);
|
|
4197
|
+
const original = ctx.records.filter((row) => row[field.field] === sample).map((row) => String(row[ctx.identity]));
|
|
4198
|
+
if (original.some((id) => ilikeHits.includes(id)) && !original.some((id) => likeHits.includes(id)))
|
|
4199
|
+
return;
|
|
4200
|
+
if (original.some((id) => ilikeHits.includes(id)) && original.some((id) => likeHits.includes(id))) {
|
|
4201
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "like also matched the case-flipped value — the store may already be case-insensitive");
|
|
4202
|
+
}
|
|
4203
|
+
ctx.findings.backend(this.id, ctx.entityName, "ilike is not case-insensitive relative to like", `ilike on ${JSON.stringify(flipped)} did not select the record whose ${field.field} is ${JSON.stringify(sample)}.`, [like.exchange, ilike.exchange]);
|
|
4204
|
+
},
|
|
4205
|
+
};
|
|
4206
|
+
function mixedNullField(ctx, field) {
|
|
4207
|
+
if (ctx.softDelete !== null && field === ctx.softDelete)
|
|
4208
|
+
return false;
|
|
4209
|
+
const values = ctx.records.map((row) => row[field]);
|
|
4210
|
+
const nulls = values.filter((value) => value === null || value === undefined).length;
|
|
4211
|
+
return nulls > 0 && nulls < values.length;
|
|
4212
|
+
}
|
|
4213
|
+
const filterIsNullSelectsNulls = {
|
|
4214
|
+
applicable: (ctx) => fieldsAllowing(ctx, "is").some((field) => mixedNullField(ctx, field.field)),
|
|
4215
|
+
dependsOn: [...FOUNDATIONS],
|
|
4216
|
+
id: "filter.is-null-selects-nulls",
|
|
4217
|
+
needs: "a field that allows `is` and a cohort that contains a null",
|
|
4218
|
+
async run(ctx) {
|
|
4219
|
+
const field = fieldsAllowing(ctx, "is").find((item) => mixedNullField(ctx, item.field));
|
|
4220
|
+
if (field === undefined)
|
|
4221
|
+
return;
|
|
4222
|
+
const conventions = conv(ctx);
|
|
4223
|
+
const nullTerm = filterTerm(conventions, field.field, "is", "null");
|
|
4224
|
+
const notNullTerm = filterTerm(conventions, field.field, "is", "notnull");
|
|
4225
|
+
if (nullTerm === null || notNullTerm === null)
|
|
4226
|
+
return;
|
|
4227
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
4228
|
+
const all = await collectSet(ctx, limit);
|
|
4229
|
+
const nulls = await collectSet(ctx, limit, nullTerm);
|
|
4230
|
+
const present = await collectSet(ctx, limit, notNullTerm);
|
|
4231
|
+
if (all === null || nulls === null || present === null) {
|
|
4232
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "an is.null / is.notnull listing was rejected");
|
|
4233
|
+
}
|
|
4234
|
+
if (!all.complete || !nulls.complete || !present.complete) {
|
|
4235
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers");
|
|
4236
|
+
}
|
|
4237
|
+
const expectedNulls = new Set(ctx.records
|
|
4238
|
+
.filter((row) => row[field.field] === null || row[field.field] === undefined)
|
|
4239
|
+
.map((row) => String(row[ctx.identity])));
|
|
4240
|
+
const gotNulls = setOf(nulls.items, ctx.identity);
|
|
4241
|
+
const overlap = [...gotNulls].filter((id) => setOf(present.items, ctx.identity).has(id));
|
|
4242
|
+
if (!sameSet(expectedNulls, gotNulls) || overlap.length > 0) {
|
|
4243
|
+
ctx.findings.backend(this.id, ctx.entityName, "is.null / is.notnull do not partition on nulls", `is.null returned ${gotNulls.size} record(s); ${expectedNulls.size} cohort rows are null. ` +
|
|
4244
|
+
(overlap.length > 0 ? `${overlap.length} appear on both sides.` : ""), [nulls.last.exchange, present.last.exchange]);
|
|
4245
|
+
}
|
|
4246
|
+
},
|
|
4247
|
+
};
|
|
4248
|
+
const filterContainsMembership = {
|
|
4249
|
+
applicable: (ctx) => resolvedCaps(ctx).filterable.some((field) => (field.type === "array" || fieldAllows(field, "contains", resolvedCaps(ctx))) &&
|
|
4250
|
+
canWriteFilterOp(conv(ctx), "contains") &&
|
|
4251
|
+
ctx.records.some((row) => Array.isArray(row[field.field]) && row[field.field].length > 0)),
|
|
4252
|
+
dependsOn: [...FOUNDATIONS],
|
|
4253
|
+
id: "filter.contains-membership",
|
|
4254
|
+
needs: "an array field that allows `contains` and a known element",
|
|
4255
|
+
async run(ctx) {
|
|
4256
|
+
const field = resolvedCaps(ctx).filterable.find((item) => canUseOp(ctx, item, "contains") &&
|
|
4257
|
+
ctx.records.some((row) => Array.isArray(row[item.field]) && row[item.field].length > 0));
|
|
4258
|
+
if (field === undefined)
|
|
4259
|
+
return;
|
|
4260
|
+
const sample = ctx.records.find((row) => Array.isArray(row[field.field]) && row[field.field].length > 0);
|
|
4261
|
+
const element = Array.isArray(sample?.[field.field]) ? sample[field.field][0] : undefined;
|
|
4262
|
+
if (element === undefined)
|
|
4263
|
+
return;
|
|
4264
|
+
const term = filterTerm(conv(ctx), field.field, "contains", asTermValue(element));
|
|
4265
|
+
if (term === null)
|
|
4266
|
+
return;
|
|
4267
|
+
const result = await collectSet(ctx, ctx.query?.maxLimit ?? 100, term);
|
|
4268
|
+
if (result === null) {
|
|
4269
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "contains probe was rejected");
|
|
4270
|
+
}
|
|
4271
|
+
if (!result.complete) {
|
|
4272
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers");
|
|
4273
|
+
}
|
|
4274
|
+
const expected = new Set(ctx.records
|
|
4275
|
+
.filter((row) => Array.isArray(row[field.field]) &&
|
|
4276
|
+
row[field.field].some((item) => String(item) === String(element)))
|
|
4277
|
+
.map((row) => String(row[ctx.identity])));
|
|
4278
|
+
const got = setOf(result.items, ctx.identity);
|
|
4279
|
+
if (sameSet(expected, got))
|
|
4280
|
+
return;
|
|
4281
|
+
ctx.findings.backend(this.id, ctx.entityName, "contains does not select membership of the given element", `${field.field}.contains.${String(element)} returned ${got.size}; ${expected.size} records hold that element.`, [result.last.exchange]);
|
|
4282
|
+
},
|
|
4283
|
+
};
|
|
4284
|
+
const filterNestedAndOrDistributes = {
|
|
4285
|
+
applicable: (ctx) => conv(ctx).grammar === "postgrest" && filterableNames(ctx).length > 1,
|
|
4286
|
+
dependsOn: [...FOUNDATIONS, "filter.and-composes-as-intersection", "filter.or-composes-as-union"],
|
|
4287
|
+
id: "filter.nested-and-or-distributes",
|
|
4288
|
+
needs: "a postgrest-shaped grammar and two filterable fields",
|
|
4289
|
+
async run(ctx) {
|
|
4290
|
+
const picked = twoFilterableFields(ctx);
|
|
4291
|
+
if (picked === null) {
|
|
4292
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "no record holds two non-null filterable fields");
|
|
4293
|
+
}
|
|
4294
|
+
const idField = filterIdentity(ctx);
|
|
4295
|
+
if (idField === picked.fieldA || idField === picked.fieldB || !identityIsFilterable(ctx)) {
|
|
4296
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "need a third identity filter term to nest and/or");
|
|
4297
|
+
}
|
|
4298
|
+
const conventions = conv(ctx);
|
|
4299
|
+
const fragA = filterFragment(conventions, picked.fieldA, String(picked.target[picked.fieldA]));
|
|
4300
|
+
const fragB = filterFragment(conventions, picked.fieldB, String(picked.target[picked.fieldB]));
|
|
4301
|
+
const fragC = filterFragment(conventions, idField, String(picked.target[ctx.identity]));
|
|
4302
|
+
if (fragA === null || fragB === null || fragC === null || conventions.filter === undefined)
|
|
4303
|
+
return;
|
|
4304
|
+
const nested = { [conventions.filter]: `and(${fragA},or(${fragB},${fragC}))` };
|
|
4305
|
+
const termA = filterTerm(conventions, picked.fieldA, "eq", String(picked.target[picked.fieldA]));
|
|
4306
|
+
const termB = filterTerm(conventions, picked.fieldB, "eq", String(picked.target[picked.fieldB]));
|
|
4307
|
+
const termC = filterTerm(conventions, idField, "eq", String(picked.target[ctx.identity]));
|
|
4308
|
+
if (termA === null || termB === null || termC === null)
|
|
4309
|
+
return;
|
|
4310
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
4311
|
+
const onlyA = await collectSet(ctx, limit, termA);
|
|
4312
|
+
const onlyB = await collectSet(ctx, limit, termB);
|
|
4313
|
+
const onlyC = await collectSet(ctx, limit, termC);
|
|
4314
|
+
const combined = await collectSet(ctx, limit, nested);
|
|
4315
|
+
if (onlyA === null || onlyB === null || onlyC === null || combined === null) {
|
|
4316
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "a nested and/or listing was rejected");
|
|
4317
|
+
}
|
|
4318
|
+
if (!onlyA.complete || !onlyB.complete || !onlyC.complete || !combined.complete) {
|
|
4319
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers");
|
|
4320
|
+
}
|
|
4321
|
+
const setA = setOf(onlyA.items, ctx.identity);
|
|
4322
|
+
const setB = setOf(onlyB.items, ctx.identity);
|
|
4323
|
+
const setC = setOf(onlyC.items, ctx.identity);
|
|
4324
|
+
const expected = new Set([...setA].filter((id) => setB.has(id) || setC.has(id)));
|
|
4325
|
+
const got = setOf(combined.items, ctx.identity);
|
|
4326
|
+
if (sameSet(expected, got))
|
|
4327
|
+
return;
|
|
4328
|
+
ctx.findings.backend(this.id, ctx.entityName, "and(A,or(B,C)) is not (A∩B) ∪ (A∩C)", `nested combinator returned ${got.size}; the distributed form is ${expected.size}.`, [combined.last.exchange, onlyA.last.exchange, onlyB.last.exchange, onlyC.last.exchange]);
|
|
4329
|
+
},
|
|
4330
|
+
};
|
|
4331
|
+
const filterAliasMatchesCanonical = {
|
|
4332
|
+
applicable: (ctx) => Object.keys(resolvedCaps(ctx).aliases).length > 0 && filterable(ctx),
|
|
4333
|
+
dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one"],
|
|
4334
|
+
id: "filter.alias-matches-canonical",
|
|
4335
|
+
needs: "a declared filter operator alias",
|
|
4336
|
+
async run(ctx) {
|
|
4337
|
+
const caps = resolvedCaps(ctx);
|
|
4338
|
+
const conventions = conv(ctx);
|
|
4339
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
4340
|
+
for (const [alias, target] of Object.entries(caps.aliases)) {
|
|
4341
|
+
if (!isFilterOp(alias) || target === undefined)
|
|
4342
|
+
continue;
|
|
4343
|
+
const field = resolvedCaps(ctx).filterable.find((item) => fieldAllows(item, target, caps));
|
|
4344
|
+
if (field === undefined || !canWriteFilterOp(conventions, alias))
|
|
4345
|
+
continue;
|
|
4346
|
+
const sample = ctx.records.find((row) => row[field.field] != null);
|
|
4347
|
+
if (sample === undefined)
|
|
4348
|
+
continue;
|
|
4349
|
+
const value = asTermValue(sample[field.field]);
|
|
4350
|
+
const aliasTerm = filterTerm(conventions, field.field, alias, value);
|
|
4351
|
+
const targetTerm = filterTerm(conventions, field.field, target, value);
|
|
4352
|
+
if (aliasTerm === null || targetTerm === null)
|
|
4353
|
+
continue;
|
|
4354
|
+
const left = await collectSet(ctx, limit, aliasTerm);
|
|
4355
|
+
const right = await collectSet(ctx, limit, targetTerm);
|
|
4356
|
+
if (left === null || right === null) {
|
|
4357
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `alias ${alias} or ${target} was rejected`);
|
|
4358
|
+
}
|
|
4359
|
+
if (!sameSet(setOf(left.items, ctx.identity), setOf(right.items, ctx.identity))) {
|
|
4360
|
+
ctx.findings.backend(this.id, ctx.entityName, `alias ${alias} does not select the same set as ${target}`, `${field.field}.${alias} and ${field.field}.${target} disagreed on membership.`, [left.last.exchange, right.last.exchange]);
|
|
4361
|
+
return;
|
|
4362
|
+
}
|
|
4363
|
+
}
|
|
4364
|
+
},
|
|
4365
|
+
};
|
|
4366
|
+
function firstIllegalOp(ctx, field) {
|
|
4367
|
+
const caps = resolvedCaps(ctx);
|
|
4368
|
+
if (!opsAreClosed(field, caps))
|
|
4369
|
+
return undefined;
|
|
4370
|
+
const allowed = new Set(opsForField(field, caps));
|
|
4371
|
+
return FILTER_OPS.find((op) => !allowed.has(op) && canWriteFilterOp(conv(ctx), op));
|
|
4372
|
+
}
|
|
4373
|
+
const filterIllegalOpRejected = {
|
|
4374
|
+
applicable: (ctx) => resolvedCaps(ctx).filterable.some((field) => firstIllegalOp(ctx, field) !== undefined),
|
|
4375
|
+
dependsOn: ["list.read-after-write", "filter.unknown-field-rejected", "error.malformed-filter-not-5xx"],
|
|
4376
|
+
id: "filter.illegal-op-rejected",
|
|
4377
|
+
needs: "a field with a closed operator list",
|
|
4378
|
+
async run(ctx) {
|
|
4379
|
+
const field = resolvedCaps(ctx).filterable.find((item) => firstIllegalOp(ctx, item) !== undefined);
|
|
4380
|
+
const op = field === undefined ? undefined : firstIllegalOp(ctx, field);
|
|
4381
|
+
if (field === undefined || op === undefined)
|
|
4382
|
+
return;
|
|
4383
|
+
const sample = ctx.records.find((row) => row[field.field] != null);
|
|
4384
|
+
const term = filterTerm(conv(ctx), field.field, op, sample === undefined ? "oat-probe" : asTermValue(sample[field.field]));
|
|
4385
|
+
if (term === null)
|
|
4386
|
+
return;
|
|
4387
|
+
const baseline = await list(ctx, q(ctx, { limit: 100 }));
|
|
4388
|
+
const result = await list(ctx, { ...q(ctx, { limit: 100 }), ...term });
|
|
4389
|
+
if (result.exchange.status >= 500) {
|
|
4390
|
+
ctx.findings.backend(this.id, ctx.entityName, "an illegal filter operator produces a server error", `${field.field}.${op} returned ${result.exchange.status}; a rejected operator should be 4xx.`, [result.exchange]);
|
|
4391
|
+
return;
|
|
4392
|
+
}
|
|
4393
|
+
if (result.exchange.status >= 400)
|
|
4394
|
+
return;
|
|
4395
|
+
const same = ids(result.items, ctx.identity).join(",") === ids(baseline.items, ctx.identity).join(",");
|
|
4396
|
+
ctx.findings.backend(this.id, ctx.entityName, "an illegal filter operator is accepted", `${field.field}.${op} returned ${result.exchange.status}` +
|
|
4397
|
+
(same ? " with the unfiltered set, so the operator was ignored" : "") +
|
|
4398
|
+
". A closed operator list is a contract: anything outside it must be rejected.", [baseline.exchange, result.exchange]);
|
|
4399
|
+
},
|
|
4400
|
+
};
|
|
4401
|
+
const filterEmptyIn = {
|
|
4402
|
+
applicable: (ctx) => resolvedCaps(ctx).emptyIn !== undefined && pickFieldForOp(ctx, "in", 1) !== null,
|
|
4403
|
+
dependsOn: [...FOUNDATIONS, "filter.in-is-union-of-eq"],
|
|
4404
|
+
id: "filter.empty-in",
|
|
4405
|
+
needs: "`emptyIn` declared and a field that allows `in`",
|
|
4406
|
+
async run(ctx) {
|
|
4407
|
+
const picked = pickFieldForOp(ctx, "in", 1);
|
|
4408
|
+
const policy = resolvedCaps(ctx).emptyIn;
|
|
4409
|
+
if (picked === null || policy === undefined)
|
|
4410
|
+
return;
|
|
4411
|
+
const term = filterTerm(conv(ctx), picked.field.field, "in", []);
|
|
4412
|
+
if (term === null)
|
|
4413
|
+
return;
|
|
4414
|
+
const baseline = await list(ctx, q(ctx, { limit: 100 }));
|
|
4415
|
+
const result = await list(ctx, { ...q(ctx, { limit: 100 }), ...term });
|
|
4416
|
+
if (policy === "reject") {
|
|
4417
|
+
if (result.exchange.status >= 400 && result.exchange.status < 500)
|
|
4418
|
+
return;
|
|
4419
|
+
ctx.findings.backend(this.id, ctx.entityName, "empty in() was not rejected", `emptyIn=reject but ${picked.field.field}.in.() returned ${result.exchange.status}.`, [result.exchange]);
|
|
4420
|
+
return;
|
|
4421
|
+
}
|
|
4422
|
+
if (result.exchange.status >= 400) {
|
|
4423
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "empty in() was rejected under match-none");
|
|
4424
|
+
}
|
|
4425
|
+
if (result.items.length === 0)
|
|
4426
|
+
return;
|
|
4427
|
+
const same = ids(result.items, ctx.identity).join(",") === ids(baseline.items, ctx.identity).join(",");
|
|
4428
|
+
ctx.findings.backend(this.id, ctx.entityName, "empty in() did not match none", `emptyIn=match-none but ${picked.field.field}.in.() returned ${result.items.length} record(s)` +
|
|
4429
|
+
(same ? " — the unfiltered set, so the filter was ignored" : "") +
|
|
4430
|
+
".", [baseline.exchange, result.exchange]);
|
|
4431
|
+
},
|
|
4432
|
+
};
|
|
4433
|
+
const filterInOverLimitRejected = {
|
|
4434
|
+
applicable: (ctx) => resolvedCaps(ctx).maxInValues !== undefined && pickFieldForOp(ctx, "in", 1) !== null,
|
|
4435
|
+
dependsOn: [...FOUNDATIONS, "error.malformed-filter-not-5xx"],
|
|
4436
|
+
id: "filter.in-over-limit-rejected",
|
|
4437
|
+
needs: "`maxInValues` declared and a field that allows `in`",
|
|
4438
|
+
async run(ctx) {
|
|
4439
|
+
const max = resolvedCaps(ctx).maxInValues;
|
|
4440
|
+
const picked = pickFieldForOp(ctx, "in", 1);
|
|
4441
|
+
if (max === undefined || picked === null)
|
|
4442
|
+
return;
|
|
4443
|
+
const members = Array.from({ length: max + 1 }, (_, i) => `oat-over-limit-${i}`);
|
|
4444
|
+
const term = filterTerm(conv(ctx), picked.field.field, "in", members);
|
|
4445
|
+
if (term === null)
|
|
4446
|
+
return;
|
|
4447
|
+
const result = await list(ctx, { ...q(ctx, { limit: 5 }), ...term });
|
|
4448
|
+
if (result.exchange.status >= 400 && result.exchange.status < 500)
|
|
4449
|
+
return;
|
|
4450
|
+
ctx.findings.backend(this.id, ctx.entityName, "an over-limit in() list was accepted", `maxInValues=${max} but ${picked.field.field}.in with ${max + 1} members returned ${result.exchange.status}.`, [result.exchange]);
|
|
4451
|
+
},
|
|
4452
|
+
};
|
|
4453
|
+
const filterConditionCapRejected = {
|
|
4454
|
+
applicable: (ctx) => resolvedCaps(ctx).maxFilterConditions !== undefined &&
|
|
4455
|
+
conv(ctx).grammar === "postgrest" &&
|
|
4456
|
+
identityIsFilterable(ctx),
|
|
4457
|
+
dependsOn: [...FOUNDATIONS, "error.malformed-filter-not-5xx"],
|
|
4458
|
+
id: "filter.condition-cap-rejected",
|
|
4459
|
+
needs: "`maxFilterConditions` declared and a grammar that can group eq terms",
|
|
4460
|
+
async run(ctx) {
|
|
4461
|
+
const max = resolvedCaps(ctx).maxFilterConditions;
|
|
4462
|
+
const parameter = conv(ctx).filter;
|
|
4463
|
+
if (max === undefined || parameter === undefined)
|
|
4464
|
+
return;
|
|
4465
|
+
const field = filterIdentity(ctx);
|
|
4466
|
+
const terms = Array.from({ length: max + 1 }, () => `${field}.eq.oat-cap`);
|
|
4467
|
+
const result = await list(ctx, { ...q(ctx, { limit: 5 }), [parameter]: `and(${terms.join(",")})` });
|
|
4468
|
+
if (result.exchange.status >= 400 && result.exchange.status < 500)
|
|
4469
|
+
return;
|
|
4470
|
+
ctx.findings.backend(this.id, ctx.entityName, "an over-limit filter expression was accepted", `maxFilterConditions=${max} but ${max + 1} eq terms returned ${result.exchange.status}.`, [result.exchange]);
|
|
4471
|
+
},
|
|
4472
|
+
};
|
|
4473
|
+
function probeValueForOp(ctx, field, op) {
|
|
4474
|
+
const sample = ctx.records.find((row) => row[field.field] != null);
|
|
4475
|
+
if (op === "is")
|
|
4476
|
+
return "null";
|
|
4477
|
+
if (op === "in" || op === "nin")
|
|
4478
|
+
return [sample === undefined ? "oat-probe" : asTermValue(sample[field.field])];
|
|
4479
|
+
if (op === "contains") {
|
|
4480
|
+
const arr = sample?.[field.field];
|
|
4481
|
+
if (Array.isArray(arr) && arr[0] !== undefined)
|
|
4482
|
+
return asTermValue(arr[0]);
|
|
4483
|
+
return "oat-probe";
|
|
4484
|
+
}
|
|
4485
|
+
if (sample === undefined)
|
|
4486
|
+
return "oat-probe";
|
|
4487
|
+
return asTermValue(sample[field.field]);
|
|
4488
|
+
}
|
|
4489
|
+
const declaredFilterableOpsAccepted = {
|
|
4490
|
+
applicable: (ctx) => ctx.query?.source === "tag" && resolvedCaps(ctx).filterable.some((field) => opsAreClosed(field, resolvedCaps(ctx))),
|
|
4491
|
+
dependsOn: ["list.read-after-write", "spec.declared-filterable-is-filterable", "error.malformed-filter-not-5xx"],
|
|
4492
|
+
id: "spec.declared-filterable-ops-accepted",
|
|
4493
|
+
needs: "a closed operator list on at least one declared field",
|
|
4494
|
+
async run(ctx) {
|
|
4495
|
+
const caps = resolvedCaps(ctx);
|
|
4496
|
+
const rejected = [];
|
|
4497
|
+
for (const field of caps.filterable) {
|
|
4498
|
+
if (!opsAreClosed(field, caps))
|
|
4499
|
+
continue;
|
|
4500
|
+
for (const op of opsForField(field, caps)) {
|
|
4501
|
+
if (!canWriteFilterOp(conv(ctx), op))
|
|
4502
|
+
continue;
|
|
4503
|
+
const term = filterTerm(conv(ctx), field.field, op, probeValueForOp(ctx, field, op));
|
|
4504
|
+
if (term === null)
|
|
4505
|
+
continue;
|
|
4506
|
+
const result = await list(ctx, { ...q(ctx, { limit: 5 }), ...term });
|
|
4507
|
+
if (result.exchange.status >= 400 && result.exchange.status < 500) {
|
|
4508
|
+
rejected.push(`${field.field}.${op} (${result.exchange.status})`);
|
|
4509
|
+
}
|
|
4510
|
+
}
|
|
4511
|
+
}
|
|
4512
|
+
if (rejected.length === 0)
|
|
4513
|
+
return;
|
|
4514
|
+
ctx.findings.spec(this.id, ctx.entityName, "the document declares a filter operator the backend rejects", `declared ops that 4xx: ${rejected.slice(0, 8).join(", ")}.`, []);
|
|
4515
|
+
},
|
|
4516
|
+
};
|
|
4517
|
+
const declaredFilterableIllegalOpRejected = {
|
|
4518
|
+
applicable: (ctx) => ctx.query?.source === "tag" &&
|
|
4519
|
+
resolvedCaps(ctx).filterable.some((field) => firstIllegalOp(ctx, field) !== undefined),
|
|
4520
|
+
dependsOn: ["filter.illegal-op-rejected", "spec.declared-filterable-is-filterable"],
|
|
4521
|
+
id: "spec.declared-filterable-illegal-op-rejected",
|
|
4522
|
+
needs: "a closed operator list on a declared field",
|
|
4523
|
+
async run(ctx) {
|
|
4524
|
+
await filterIllegalOpRejected.run(ctx);
|
|
4525
|
+
},
|
|
4526
|
+
};
|
|
4527
|
+
const sortUnknownFieldRejected = {
|
|
4528
|
+
applicable: (ctx) => conv(ctx).order !== undefined,
|
|
4529
|
+
dependsOn: ["error.malformed-filter-not-5xx"],
|
|
4530
|
+
id: "sort.unknown-field-rejected",
|
|
4531
|
+
needs: "an order parameter",
|
|
4532
|
+
async run(ctx) {
|
|
4533
|
+
const result = await list(ctx, q(ctx, { limit: 5, order: sortTerm(conv(ctx), "oat_no_such_sort_xyz", "asc") }));
|
|
4534
|
+
if (result.exchange.status >= 500) {
|
|
4535
|
+
ctx.findings.backend(this.id, ctx.entityName, "unknown sort field produces a server error", `ordering on an undeclared field returned ${result.exchange.status}; a rejected input should be 4xx.`, [result.exchange]);
|
|
4536
|
+
return;
|
|
4537
|
+
}
|
|
4538
|
+
if (result.exchange.status >= 400)
|
|
4539
|
+
return;
|
|
4540
|
+
const baseline = await list(ctx, q(ctx, { limit: 5 }));
|
|
4541
|
+
const same = ids(result.items, ctx.identity).join(",") === ids(baseline.items, ctx.identity).join(",");
|
|
4542
|
+
ctx.findings.backend(this.id, ctx.entityName, "unknown sort field is silently ignored", `order on an undeclared field returned ${result.exchange.status}` +
|
|
4543
|
+
(same ? " with the default order, so the field was dropped" : "") +
|
|
4544
|
+
".", [baseline.exchange, result.exchange]);
|
|
4545
|
+
},
|
|
4546
|
+
};
|
|
4547
|
+
const sortNumericOrderIsNumeric = {
|
|
4548
|
+
applicable: (ctx) => conv(ctx).order !== undefined &&
|
|
4549
|
+
resolvedCaps(ctx).sortable.some((field) => numericLexicalDisagrees(ctx, field.field)),
|
|
4550
|
+
dependsOn: ["sort.order-is-applied"],
|
|
4551
|
+
id: "sort.numeric-order-is-numeric",
|
|
4552
|
+
needs: "a numeric sortable field whose lexical order disagrees with numeric order",
|
|
4553
|
+
async run(ctx) {
|
|
4554
|
+
const field = resolvedCaps(ctx).sortable.find((item) => numericLexicalDisagrees(ctx, item.field));
|
|
4555
|
+
if (field === undefined)
|
|
4556
|
+
return;
|
|
4557
|
+
const result = await list(ctx, q(ctx, { limit: ctx.query?.maxLimit ?? 100, order: sortTerm(conv(ctx), field.field, "asc") }));
|
|
4558
|
+
if (result.exchange.status >= 400)
|
|
4559
|
+
return;
|
|
4560
|
+
const numbers = result.items.map((item) => item[field.field]).filter((v) => typeof v === "number");
|
|
4561
|
+
const sorted = [...numbers].sort((a, b) => a - b);
|
|
4562
|
+
if (numbers.join(",") === sorted.join(","))
|
|
4563
|
+
return;
|
|
4564
|
+
ctx.findings.backend(this.id, ctx.entityName, `"${field.field}" is sorted as text rather than as a number`, `order=${field.field}.asc returned ${numbers.slice(0, 8).join(", ")}.`, [result.exchange]);
|
|
4565
|
+
},
|
|
4566
|
+
};
|
|
4567
|
+
const sortNullsFirstLast = {
|
|
4568
|
+
applicable: (ctx) => conv(ctx).order !== undefined &&
|
|
4569
|
+
sortTermWithNulls(conv(ctx), "x", "asc", "first") !== null &&
|
|
4570
|
+
resolvedCaps(ctx).sortable.some((field) => (fieldAllowsNulls(field, resolvedCaps(ctx), "first") || fieldAllowsNulls(field, resolvedCaps(ctx), "last")) &&
|
|
4571
|
+
ctx.records.some((row) => row[field.field] === null || row[field.field] === undefined)),
|
|
4572
|
+
dependsOn: ["sort.order-is-applied", "sort.reverse-symmetry"],
|
|
4573
|
+
id: "sort.nulls-first-last",
|
|
4574
|
+
needs: "a declared nulls token, a dotted sort grammar, and a null in the cohort",
|
|
4575
|
+
async run(ctx) {
|
|
4576
|
+
const field = resolvedCaps(ctx).sortable.find((item) => (fieldAllowsNulls(item, resolvedCaps(ctx), "first") || fieldAllowsNulls(item, resolvedCaps(ctx), "last")) &&
|
|
4577
|
+
ctx.records.some((row) => row[item.field] === null || row[item.field] === undefined));
|
|
4578
|
+
if (field === undefined)
|
|
4579
|
+
return;
|
|
4580
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
4581
|
+
if (fieldAllowsNulls(field, resolvedCaps(ctx), "first")) {
|
|
4582
|
+
const clause = sortTermWithNulls(conv(ctx), field.field, "asc", "first");
|
|
4583
|
+
if (clause === null)
|
|
4584
|
+
return;
|
|
4585
|
+
const result = await collectSet(ctx, limit, {}, MAX_WALK_PAGES, clause);
|
|
4586
|
+
if (result === null)
|
|
4587
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "nullsfirst listing was rejected");
|
|
4588
|
+
const first = result.items[0];
|
|
4589
|
+
if (first !== undefined && first[field.field] !== null && first[field.field] !== undefined) {
|
|
4590
|
+
ctx.findings.backend(this.id, ctx.entityName, "nullsfirst did not put nulls first", `order=${clause} started with ${JSON.stringify(first[field.field])}.`, [result.last.exchange]);
|
|
4591
|
+
return;
|
|
4592
|
+
}
|
|
4593
|
+
}
|
|
4594
|
+
if (fieldAllowsNulls(field, resolvedCaps(ctx), "last")) {
|
|
4595
|
+
const clause = sortTermWithNulls(conv(ctx), field.field, "asc", "last");
|
|
4596
|
+
if (clause === null)
|
|
4597
|
+
return;
|
|
4598
|
+
const result = await collectSet(ctx, limit, {}, MAX_WALK_PAGES, clause);
|
|
4599
|
+
if (result === null)
|
|
4600
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "nullslast listing was rejected");
|
|
4601
|
+
const last = result.items.at(-1);
|
|
4602
|
+
if (last !== undefined && last[field.field] !== null && last[field.field] !== undefined) {
|
|
4603
|
+
ctx.findings.backend(this.id, ctx.entityName, "nullslast did not put nulls last", `order=${clause} ended with ${JSON.stringify(last[field.field])}.`, [result.last.exchange]);
|
|
4604
|
+
}
|
|
4605
|
+
}
|
|
4606
|
+
},
|
|
4607
|
+
};
|
|
4608
|
+
function pickMultiKeyPair(ctx) {
|
|
4609
|
+
const fields = ctx.query?.sortable ?? [];
|
|
4610
|
+
for (const primary of fields) {
|
|
4611
|
+
for (const secondary of fields) {
|
|
4612
|
+
if (secondary === primary || secondary === ctx.identity)
|
|
4613
|
+
continue;
|
|
4614
|
+
const groups = new Map();
|
|
4615
|
+
for (const row of ctx.records) {
|
|
4616
|
+
const key = JSON.stringify(row[primary]);
|
|
4617
|
+
const group = groups.get(key) ?? [];
|
|
4618
|
+
group.push(row);
|
|
4619
|
+
groups.set(key, group);
|
|
4620
|
+
}
|
|
4621
|
+
const tied = [...groups.values()].find((group) => group.length >= 2 && new Set(group.map((row) => JSON.stringify(row[secondary]))).size > 1);
|
|
4622
|
+
if (tied !== undefined)
|
|
4623
|
+
return { primary, secondary, tied };
|
|
4624
|
+
}
|
|
4625
|
+
}
|
|
4626
|
+
return null;
|
|
4627
|
+
}
|
|
4628
|
+
const sortMultiKeyTiebreak = {
|
|
4629
|
+
applicable: (ctx) => {
|
|
4630
|
+
const max = resolvedCaps(ctx).sort?.maxKeys;
|
|
4631
|
+
return conv(ctx).order !== undefined && (max === undefined || max >= 2) && pickMultiKeyPair(ctx) !== null;
|
|
4632
|
+
},
|
|
4633
|
+
dependsOn: ["sort.order-is-applied", "sort.reverse-symmetry"],
|
|
4634
|
+
id: "sort.multi-key-tiebreak",
|
|
4635
|
+
needs: "two sortable fields and rows that tie on the first and differ on the second",
|
|
4636
|
+
async run(ctx) {
|
|
4637
|
+
const pair = pickMultiKeyPair(ctx);
|
|
4638
|
+
if (pair === null) {
|
|
4639
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "no cohort tie on the first key differs on the second");
|
|
4640
|
+
}
|
|
4641
|
+
const { primary, secondary, tied } = pair;
|
|
4642
|
+
const conventions = conv(ctx);
|
|
4643
|
+
const order = `${sortTerm(conventions, primary, "asc")},${sortTerm(conventions, secondary, "asc")}`;
|
|
4644
|
+
const result = await collectSet(ctx, ctx.query?.maxLimit ?? 100, {}, MAX_WALK_PAGES, order);
|
|
4645
|
+
if (result === null)
|
|
4646
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "multi-key order was rejected");
|
|
4647
|
+
const slice = result.items.filter((item) => JSON.stringify(item[primary]) === JSON.stringify(tied[0]?.[primary]));
|
|
4648
|
+
const seconds = slice.map((item) => item[secondary]);
|
|
4649
|
+
const sorted = [...seconds].sort(compareValues);
|
|
4650
|
+
if (JSON.stringify(seconds) === JSON.stringify(sorted))
|
|
4651
|
+
return;
|
|
4652
|
+
ctx.findings.backend(this.id, ctx.entityName, "the second sort key is not applied on ties", `order=${order} left ties on "${primary}" unordered by "${secondary}".`, [result.last.exchange]);
|
|
4653
|
+
},
|
|
4654
|
+
};
|
|
4655
|
+
const sortDefaultOrderApplied = {
|
|
4656
|
+
applicable: (ctx) => (resolvedCaps(ctx).sort?.defaultOrder ?? ctx.query?.defaultOrder) !== undefined && ctx.records.length > 1,
|
|
4657
|
+
dependsOn: ["sort.order-is-applied", "pagination.page-walk-covers-set"],
|
|
4658
|
+
id: "sort.default-order-applied",
|
|
4659
|
+
needs: "a declared defaultOrder and a complete walk",
|
|
4660
|
+
async run(ctx) {
|
|
4661
|
+
const declared = resolvedCaps(ctx).sort?.defaultOrder ?? ctx.query?.defaultOrder;
|
|
4662
|
+
if (declared === undefined)
|
|
4663
|
+
return;
|
|
4664
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
4665
|
+
const implicit = await collectSet(ctx, limit);
|
|
4666
|
+
const explicit = await collectSet(ctx, limit, {}, MAX_WALK_PAGES, declared);
|
|
4667
|
+
if (implicit === null || explicit === null) {
|
|
4668
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "default-order walk was rejected");
|
|
4669
|
+
}
|
|
4670
|
+
if (!implicit.complete || !explicit.complete) {
|
|
4671
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the walk is incomplete, so default order cannot be compared");
|
|
4672
|
+
}
|
|
4673
|
+
if (ids(implicit.items, ctx.identity).join(",") === ids(explicit.items, ctx.identity).join(","))
|
|
4674
|
+
return;
|
|
4675
|
+
ctx.findings.backend(this.id, ctx.entityName, "omitting order does not match defaultOrder", `defaultOrder=${declared} produced a different sequence than the implicit listing.`, [implicit.last.exchange, explicit.last.exchange]);
|
|
4676
|
+
},
|
|
4677
|
+
};
|
|
4678
|
+
const sortStableTiebreak = {
|
|
4679
|
+
applicable: (ctx) => (resolvedCaps(ctx).sort?.stableTiebreak ?? ctx.query?.stableTiebreak) !== undefined,
|
|
4680
|
+
dependsOn: ["sort.order-is-applied"],
|
|
4681
|
+
id: "sort.stable-tiebreak",
|
|
4682
|
+
needs: "a declared stableTiebreak and a tie on the primary key",
|
|
4683
|
+
async run(ctx) {
|
|
4684
|
+
const tiebreak = resolvedCaps(ctx).sort?.stableTiebreak ?? ctx.query?.stableTiebreak;
|
|
4685
|
+
const primary = ctx.query?.sortable.find((name) => name !== tiebreak) ?? ctx.query?.sortable[0];
|
|
4686
|
+
if (tiebreak === undefined || primary === undefined)
|
|
4687
|
+
return;
|
|
4688
|
+
const order = sortTerm(conv(ctx), primary, "asc");
|
|
4689
|
+
const first = await collectSet(ctx, ctx.query?.maxLimit ?? 100, {}, MAX_WALK_PAGES, order);
|
|
4690
|
+
const second = await collectSet(ctx, ctx.query?.maxLimit ?? 100, {}, MAX_WALK_PAGES, order);
|
|
4691
|
+
if (first === null || second === null)
|
|
4692
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "repeat sort was rejected");
|
|
4693
|
+
if (ids(first.items, ctx.identity).join(",") === ids(second.items, ctx.identity).join(","))
|
|
4694
|
+
return;
|
|
4695
|
+
ctx.findings.backend(this.id, ctx.entityName, "the same order is not deterministic across two walks", `order=${order} (stableTiebreak=${tiebreak}) returned two different sequences.`, [first.last.exchange, second.last.exchange]);
|
|
4696
|
+
},
|
|
4697
|
+
};
|
|
4698
|
+
const declaredSortableNullsAccepted = {
|
|
4699
|
+
applicable: (ctx) => conv(ctx).order !== undefined &&
|
|
4700
|
+
sortTermWithNulls(conv(ctx), "x", "asc", "first") !== null &&
|
|
4701
|
+
resolvedCaps(ctx).sortable.some((field) => fieldAllowsNulls(field, resolvedCaps(ctx), "first") || fieldAllowsNulls(field, resolvedCaps(ctx), "last")),
|
|
4702
|
+
dependsOn: ["sort.order-is-applied"],
|
|
4703
|
+
id: "spec.declared-sortable-nulls-accepted",
|
|
4704
|
+
needs: "a declared nulls token on a sortable field",
|
|
4705
|
+
async run(ctx) {
|
|
4706
|
+
const rejected = [];
|
|
4707
|
+
for (const field of resolvedCaps(ctx).sortable) {
|
|
4708
|
+
for (const token of ["first", "last"]) {
|
|
4709
|
+
if (!fieldAllowsNulls(field, resolvedCaps(ctx), token))
|
|
4710
|
+
continue;
|
|
4711
|
+
const clause = sortTermWithNulls(conv(ctx), field.field, "asc", token);
|
|
4712
|
+
if (clause === null)
|
|
4713
|
+
continue;
|
|
4714
|
+
const result = await list(ctx, q(ctx, { limit: 5, order: clause }));
|
|
4715
|
+
if (result.exchange.status >= 400 && result.exchange.status < 500) {
|
|
4716
|
+
rejected.push(`${clause} (${result.exchange.status})`);
|
|
4717
|
+
}
|
|
4718
|
+
}
|
|
4719
|
+
}
|
|
4720
|
+
if (rejected.length === 0)
|
|
4721
|
+
return;
|
|
4722
|
+
ctx.findings.spec(this.id, ctx.entityName, "the document declares a nulls sort token the backend rejects", `rejected: ${rejected.join(", ")}.`, []);
|
|
4723
|
+
},
|
|
4724
|
+
};
|
|
4725
|
+
const searchTokensAnd = {
|
|
4726
|
+
applicable: (ctx) => conv(ctx).search !== undefined && (ctx.query?.searchable.length ?? 0) > 0 && ctx.records.length > 2,
|
|
4727
|
+
dependsOn: ["search.q-narrows-result"],
|
|
4728
|
+
id: "search.tokens-and",
|
|
4729
|
+
needs: "searchable fields and two tokens that split the cohort",
|
|
4730
|
+
async run(ctx) {
|
|
4731
|
+
const field = ctx.query?.searchable[0];
|
|
4732
|
+
if (field === undefined)
|
|
4733
|
+
return;
|
|
4734
|
+
const tokens = [];
|
|
4735
|
+
for (const row of ctx.records) {
|
|
4736
|
+
const value = row[field];
|
|
4737
|
+
if (typeof value !== "string")
|
|
4738
|
+
continue;
|
|
4739
|
+
const word = value.split(/\s+/).find((part) => part.length >= 3);
|
|
4740
|
+
if (word !== undefined)
|
|
4741
|
+
tokens.push(word.toLowerCase());
|
|
4742
|
+
}
|
|
4743
|
+
const unique = [...new Set(tokens)];
|
|
4744
|
+
if (unique.length < 2) {
|
|
4745
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "could not find two discriminating search tokens");
|
|
4746
|
+
}
|
|
4747
|
+
const [a, b] = unique;
|
|
4748
|
+
if (a === undefined || b === undefined)
|
|
4749
|
+
return;
|
|
4750
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
4751
|
+
const onlyA = await list(ctx, q(ctx, { limit, search: a }));
|
|
4752
|
+
const onlyB = await list(ctx, q(ctx, { limit, search: b }));
|
|
4753
|
+
const both = await list(ctx, q(ctx, { limit, search: `${a} ${b}` }));
|
|
4754
|
+
if (onlyA.exchange.status >= 400 || onlyB.exchange.status >= 400 || both.exchange.status >= 400) {
|
|
4755
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "a token search was rejected");
|
|
4756
|
+
}
|
|
4757
|
+
const setA = setOf(onlyA.items, ctx.identity);
|
|
4758
|
+
const setB = setOf(onlyB.items, ctx.identity);
|
|
4759
|
+
const expected = new Set([...setA].filter((id) => setB.has(id)));
|
|
4760
|
+
const got = setOf(both.items, ctx.identity);
|
|
4761
|
+
if (sameSet(expected, got) || sameSet(got, new Set([...setA, ...setB])))
|
|
4762
|
+
return;
|
|
4763
|
+
ctx.findings.backend(this.id, ctx.entityName, "two search tokens are not applied as AND", `q="${a} ${b}" returned ${got.size}; the intersection of each token is ${expected.size}.`, [onlyA.exchange, onlyB.exchange, both.exchange]);
|
|
4764
|
+
},
|
|
4765
|
+
};
|
|
4766
|
+
const searchCaseInsensitive = {
|
|
4767
|
+
applicable: (ctx) => conv(ctx).search !== undefined &&
|
|
4768
|
+
(ctx.query?.searchable.length ?? 0) > 0 &&
|
|
4769
|
+
ctx.records.some((row) => ctx.query?.searchable.some((field) => typeof row[field] === "string" && /[A-Za-z]/.test(row[field]))),
|
|
4770
|
+
dependsOn: ["search.q-narrows-result"],
|
|
4771
|
+
id: "search.case-insensitive",
|
|
4772
|
+
needs: "a searchable string with a letter",
|
|
4773
|
+
async run(ctx) {
|
|
4774
|
+
let sample;
|
|
4775
|
+
for (const field of ctx.query?.searchable ?? []) {
|
|
4776
|
+
const value = ctx.records
|
|
4777
|
+
.map((row) => row[field])
|
|
4778
|
+
.find((item) => typeof item === "string" && /[A-Za-z]/.test(item));
|
|
4779
|
+
if (value !== undefined) {
|
|
4780
|
+
sample = value;
|
|
4781
|
+
break;
|
|
4782
|
+
}
|
|
4783
|
+
}
|
|
4784
|
+
if (sample === undefined)
|
|
4785
|
+
return;
|
|
4786
|
+
const token = sample.split(/\s+/).find((part) => /[A-Za-z]/.test(part)) ?? sample;
|
|
4787
|
+
const flipped = token.replace(/[A-Za-z]/, (ch) => (ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase()));
|
|
4788
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
4789
|
+
const original = await list(ctx, q(ctx, { limit, search: token }));
|
|
4790
|
+
const other = await list(ctx, q(ctx, { limit, search: flipped }));
|
|
4791
|
+
if (original.exchange.status >= 400 || other.exchange.status >= 400)
|
|
4792
|
+
return;
|
|
4793
|
+
const a = setOf(original.items, ctx.identity);
|
|
4794
|
+
const b = setOf(other.items, ctx.identity);
|
|
4795
|
+
if (sameSet(a, b))
|
|
4796
|
+
return;
|
|
4797
|
+
if (resolvedCaps(ctx).searchCase === "insensitive") {
|
|
4798
|
+
ctx.findings.backend(this.id, ctx.entityName, "search is not case-insensitive", `q=${JSON.stringify(token)} and q=${JSON.stringify(flipped)} selected different sets.`, [original.exchange, other.exchange]);
|
|
4799
|
+
return;
|
|
4800
|
+
}
|
|
4801
|
+
ctx.findings.unresolved(this.id, ctx.entityName, "case-flipped search selected a different set; declare searchCase: insensitive to treat that as a defect");
|
|
4802
|
+
},
|
|
4803
|
+
};
|
|
4804
|
+
const searchEmptyQ = {
|
|
4805
|
+
applicable: (ctx) => conv(ctx).search !== undefined && resolvedCaps(ctx).searchEmpty !== undefined,
|
|
4806
|
+
dependsOn: ["search.q-narrows-result"],
|
|
4807
|
+
id: "search.empty-q",
|
|
4808
|
+
needs: "`searchEmpty` declared",
|
|
4809
|
+
async run(ctx) {
|
|
4810
|
+
const policy = resolvedCaps(ctx).searchEmpty;
|
|
4811
|
+
if (policy === undefined)
|
|
4812
|
+
return;
|
|
4813
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
4814
|
+
const baseline = await list(ctx, q(ctx, { limit }));
|
|
4815
|
+
const empty = await list(ctx, q(ctx, { limit, search: "" }));
|
|
4816
|
+
if (policy === "reject") {
|
|
4817
|
+
if (empty.exchange.status >= 400 && empty.exchange.status < 500)
|
|
4818
|
+
return;
|
|
4819
|
+
ctx.findings.backend(this.id, ctx.entityName, "empty q was not rejected", `searchEmpty=reject but q= returned ${empty.exchange.status}.`, [empty.exchange]);
|
|
4820
|
+
return;
|
|
4821
|
+
}
|
|
4822
|
+
if (empty.exchange.status >= 400) {
|
|
4823
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "empty q was rejected");
|
|
4824
|
+
}
|
|
4825
|
+
if (sameSet(setOf(empty.items, ctx.identity), setOf(baseline.items, ctx.identity)))
|
|
4826
|
+
return;
|
|
4827
|
+
ctx.findings.backend(this.id, ctx.entityName, "empty q did not match the unfiltered set", `searchEmpty=${policy} but q= returned ${empty.items.length} of ${baseline.items.length} records.`, [baseline.exchange, empty.exchange]);
|
|
4828
|
+
},
|
|
4829
|
+
};
|
|
4830
|
+
const searchUndeclaredFieldNotRequired = {
|
|
4831
|
+
applicable: (ctx) => {
|
|
4832
|
+
if (conv(ctx).search === undefined || (ctx.query?.searchable.length ?? 0) === 0)
|
|
4833
|
+
return false;
|
|
4834
|
+
const searchable = new Set(ctx.query?.searchable ?? []);
|
|
4835
|
+
return ctx.records.some((row) => Object.entries(row).some(([key, value]) => !searchable.has(key) && typeof value === "string" && value.length >= 4 && !searchableHasToken(ctx, value)));
|
|
4836
|
+
},
|
|
4837
|
+
dependsOn: ["search.q-narrows-result"],
|
|
4838
|
+
id: "search.undeclared-field-not-required",
|
|
4839
|
+
needs: "a searchable field and a non-searchable string",
|
|
4840
|
+
async run() {
|
|
4841
|
+
/* Extra recall is not a defect. This check exists so undeclared-field hits are not
|
|
4842
|
+
* reported as SEARCH_IGNORED by other checks. */
|
|
4843
|
+
},
|
|
4844
|
+
};
|
|
4845
|
+
function searchableHasToken(ctx, token) {
|
|
4846
|
+
const needle = token.toLowerCase();
|
|
4847
|
+
return ctx.records.some((row) => (ctx.query?.searchable ?? []).some((field) => String(row[field] ?? "")
|
|
4848
|
+
.toLowerCase()
|
|
4849
|
+
.includes(needle)));
|
|
4850
|
+
}
|
|
4851
|
+
const searchModeAccepted = {
|
|
4852
|
+
applicable: (ctx) => conv(ctx).searchMode !== undefined &&
|
|
4853
|
+
(resolvedCaps(ctx).searchModes?.length ?? 0) > 0 &&
|
|
4854
|
+
conv(ctx).search !== undefined,
|
|
4855
|
+
id: "search.mode-accepted",
|
|
4856
|
+
needs: "declared searchModes and a search-mode parameter",
|
|
4857
|
+
async run(ctx) {
|
|
4858
|
+
const rejected = [];
|
|
4859
|
+
for (const mode of resolvedCaps(ctx).searchModes ?? []) {
|
|
4860
|
+
const result = await list(ctx, q(ctx, { limit: 5, search: "oat", searchMode: mode }));
|
|
4861
|
+
if (result.exchange.status >= 400 && result.exchange.status < 500)
|
|
4862
|
+
rejected.push(`${mode} (${result.exchange.status})`);
|
|
4863
|
+
}
|
|
4864
|
+
if (rejected.length === 0)
|
|
4865
|
+
return;
|
|
4866
|
+
ctx.findings.spec(this.id, ctx.entityName, "a declared search mode is rejected", `rejected: ${rejected.join(", ")}.`, []);
|
|
4867
|
+
},
|
|
4868
|
+
};
|
|
4869
|
+
const searchModesDiffer = {
|
|
4870
|
+
applicable: (ctx) => conv(ctx).searchMode !== undefined &&
|
|
4871
|
+
(resolvedCaps(ctx).searchModes?.length ?? 0) >= 2 &&
|
|
4872
|
+
conv(ctx).search !== undefined,
|
|
4873
|
+
dependsOn: ["search.mode-accepted"],
|
|
4874
|
+
id: "search.modes-differ",
|
|
4875
|
+
needs: "at least two declared searchModes and a mode parameter",
|
|
4876
|
+
async run(ctx) {
|
|
4877
|
+
const modes = resolvedCaps(ctx).searchModes ?? [];
|
|
4878
|
+
const a = modes[0];
|
|
4879
|
+
const b = modes[1];
|
|
4880
|
+
if (a === undefined || b === undefined)
|
|
4881
|
+
return;
|
|
4882
|
+
const field = ctx.query?.searchable[0];
|
|
4883
|
+
const token = field === undefined
|
|
4884
|
+
? "oat"
|
|
4885
|
+
: ctx.records
|
|
4886
|
+
.map((row) => row[field])
|
|
4887
|
+
.find((value) => typeof value === "string" && value.length > 2);
|
|
4888
|
+
if (token === undefined)
|
|
4889
|
+
return;
|
|
4890
|
+
const left = await list(ctx, q(ctx, { limit: 100, search: token, searchMode: a }));
|
|
4891
|
+
const right = await list(ctx, q(ctx, { limit: 100, search: token, searchMode: b }));
|
|
4892
|
+
if (left.exchange.status >= 400 || right.exchange.status >= 400)
|
|
4893
|
+
return;
|
|
4894
|
+
if (ids(left.items, ctx.identity).join(",") !== ids(right.items, ctx.identity).join(","))
|
|
4895
|
+
return;
|
|
4896
|
+
ctx.findings.unresolved(this.id, ctx.entityName, `modes ${a} and ${b} returned the same set; cannot prove they are distinct implementations`);
|
|
4897
|
+
},
|
|
4898
|
+
};
|
|
4899
|
+
const selectRequestedFieldsPresent = {
|
|
4900
|
+
applicable: (ctx) => conv(ctx).select !== undefined && (ctx.query?.selectable.length ?? 0) > 0,
|
|
4901
|
+
dependsOn: ["select.projection-honoured"],
|
|
4902
|
+
id: "select.requested-fields-present",
|
|
4903
|
+
needs: "a select parameter and at least one selectable field",
|
|
4904
|
+
async run(ctx) {
|
|
4905
|
+
const requested = [
|
|
4906
|
+
ctx.identity,
|
|
4907
|
+
...(ctx.query?.selectable.filter((name) => name !== ctx.identity).slice(0, 2) ?? []),
|
|
4908
|
+
];
|
|
4909
|
+
const projection = selectTerm(conv(ctx), requested, ctx.entityName);
|
|
4910
|
+
if (projection === null)
|
|
4911
|
+
return;
|
|
4912
|
+
const result = await list(ctx, { ...q(ctx, { limit: 5 }), ...projection });
|
|
4913
|
+
if (result.exchange.status >= 400 || result.items[0] === undefined) {
|
|
4914
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "select probe was rejected or empty");
|
|
4915
|
+
}
|
|
4916
|
+
const missing = requested.filter((name) => !Object.hasOwn(result.items[0], name));
|
|
4917
|
+
if (missing.length === 0)
|
|
4918
|
+
return;
|
|
4919
|
+
if (missing.length === 1 &&
|
|
4920
|
+
missing[0] === ctx.identity &&
|
|
4921
|
+
!(ctx.query?.selectable.includes(ctx.identity) ?? false)) {
|
|
4922
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "identity was omitted from select and missing from items");
|
|
4923
|
+
}
|
|
4924
|
+
ctx.findings.backend(this.id, ctx.entityName, "a requested select field is missing from the projection", `select=${requested.join(",")} omitted ${missing.join(", ")}.`, [result.exchange]);
|
|
4925
|
+
},
|
|
4926
|
+
};
|
|
4927
|
+
const selectUnknownFieldRejected = {
|
|
4928
|
+
applicable: (ctx) => conv(ctx).select !== undefined && resolvedCaps(ctx).select?.unknown !== undefined,
|
|
4929
|
+
dependsOn: ["select.projection-honoured", "error.malformed-filter-not-5xx"],
|
|
4930
|
+
id: "select.unknown-field-rejected",
|
|
4931
|
+
needs: "`select.unknown` declared",
|
|
4932
|
+
async run(ctx) {
|
|
4933
|
+
const policy = resolvedCaps(ctx).select?.unknown;
|
|
4934
|
+
if (policy === undefined)
|
|
4935
|
+
return;
|
|
4936
|
+
const requested = [ctx.identity, "oat_no_such_select_xyz"];
|
|
4937
|
+
const projection = selectTerm(conv(ctx), requested, ctx.entityName);
|
|
4938
|
+
if (projection === null)
|
|
4939
|
+
return;
|
|
4940
|
+
const result = await list(ctx, { ...q(ctx, { limit: 5 }), ...projection });
|
|
4941
|
+
if (policy === "reject") {
|
|
4942
|
+
if (result.exchange.status >= 400 && result.exchange.status < 500)
|
|
4943
|
+
return;
|
|
4944
|
+
ctx.findings.backend(this.id, ctx.entityName, "unknown select field was not rejected", `select.unknown=reject but the probe returned ${result.exchange.status}.`, [result.exchange]);
|
|
4945
|
+
return;
|
|
4946
|
+
}
|
|
4947
|
+
if (result.exchange.status >= 400) {
|
|
4948
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "unknown select field was rejected under ignore");
|
|
4949
|
+
}
|
|
4950
|
+
const extras = Object.keys(result.items[0] ?? {}).filter((key) => key === "oat_no_such_select_xyz");
|
|
4951
|
+
if (extras.length === 0)
|
|
4952
|
+
return;
|
|
4953
|
+
ctx.findings.backend(this.id, ctx.entityName, "unknown select field was honoured under ignore", "select.unknown=ignore should drop the undeclared name, not return it.", [result.exchange]);
|
|
4954
|
+
},
|
|
4955
|
+
};
|
|
4956
|
+
const selectNestedHonoured = {
|
|
4957
|
+
applicable: (ctx) => conv(ctx).select !== undefined &&
|
|
4958
|
+
resolvedCaps(ctx).select?.nested === true &&
|
|
4959
|
+
(resolvedCaps(ctx).select?.relations?.length ?? 0) > 0,
|
|
4960
|
+
dependsOn: ["select.projection-honoured"],
|
|
4961
|
+
id: "select.nested-honoured",
|
|
4962
|
+
needs: "select.nested and a named relation",
|
|
4963
|
+
async run(ctx) {
|
|
4964
|
+
const relation = resolvedCaps(ctx).select?.relations?.[0];
|
|
4965
|
+
if (relation === undefined || relation.fields[0] === undefined)
|
|
4966
|
+
return;
|
|
4967
|
+
const clause = `${relation.name}(${relation.fields[0]})`;
|
|
4968
|
+
const projection = selectTerm(conv(ctx), [ctx.identity, clause], ctx.entityName);
|
|
4969
|
+
if (projection === null)
|
|
4970
|
+
return;
|
|
4971
|
+
const result = await list(ctx, { ...q(ctx, { limit: 5 }), ...projection });
|
|
4972
|
+
if (result.exchange.status >= 400 || result.items[0] === undefined) {
|
|
4973
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "nested select was rejected or empty");
|
|
4974
|
+
}
|
|
4975
|
+
const nested = result.items[0][relation.name];
|
|
4976
|
+
if (nested === null || typeof nested !== "object") {
|
|
4977
|
+
ctx.findings.backend(this.id, ctx.entityName, "nested select did not return the relation as an object", `select=${clause} left "${relation.name}" as ${typeof nested}.`, [result.exchange]);
|
|
4978
|
+
return;
|
|
4979
|
+
}
|
|
4980
|
+
const keys = Object.keys(nested);
|
|
4981
|
+
if (keys.every((key) => key === relation.fields[0]))
|
|
4982
|
+
return;
|
|
4983
|
+
ctx.findings.backend(this.id, ctx.entityName, "nested select did not restrict the relation to the requested field", `select=${clause} returned ${keys.join(", ") || "(empty)"}.`, [result.exchange]);
|
|
4984
|
+
},
|
|
4985
|
+
};
|
|
4986
|
+
const querySortAndSelectCompose = {
|
|
4987
|
+
applicable: (ctx) => conv(ctx).order !== undefined &&
|
|
4988
|
+
conv(ctx).select !== undefined &&
|
|
4989
|
+
(ctx.query?.sortable.length ?? 0) > 0 &&
|
|
4990
|
+
(ctx.query?.selectable.length ?? 0) > 0 &&
|
|
4991
|
+
ctx.records.length > 1,
|
|
4992
|
+
dependsOn: ["sort.order-is-applied", "select.projection-honoured", "select.requested-fields-present"],
|
|
4993
|
+
id: "query.sort-and-select-compose",
|
|
4994
|
+
needs: "sortable and selectable fields",
|
|
4995
|
+
async run(ctx) {
|
|
4996
|
+
const field = ctx.query?.sortable.find((name) => name !== ctx.identity) ?? ctx.query?.sortable[0];
|
|
4997
|
+
const extra = ctx.query?.selectable.find((name) => name !== ctx.identity);
|
|
4998
|
+
if (field === undefined)
|
|
4999
|
+
return;
|
|
5000
|
+
const requested = extra === undefined ? [ctx.identity] : [ctx.identity, extra];
|
|
5001
|
+
const projection = selectTerm(conv(ctx), requested, ctx.entityName);
|
|
5002
|
+
if (projection === null)
|
|
5003
|
+
return;
|
|
5004
|
+
const result = await list(ctx, {
|
|
5005
|
+
...q(ctx, { limit: 20, order: sortTerm(conv(ctx), field, "asc") }),
|
|
5006
|
+
...projection,
|
|
5007
|
+
});
|
|
5008
|
+
if (result.exchange.status >= 400 || result.items.length < 2) {
|
|
5009
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "sort+select probe was rejected");
|
|
5010
|
+
}
|
|
5011
|
+
const extras = Object.keys(result.items[0] ?? {}).filter((key) => !requested.includes(key));
|
|
5012
|
+
const values = result.items.map((item) => item[field]);
|
|
5013
|
+
const sorted = [...values].sort(compareValues);
|
|
5014
|
+
if (extras.length === 0 && JSON.stringify(values) === JSON.stringify(sorted))
|
|
5015
|
+
return;
|
|
5016
|
+
ctx.findings.backend(this.id, ctx.entityName, "sort and select do not compose", extras.length > 0
|
|
5017
|
+
? `select leaked ${extras.slice(0, 5).join(", ")}.`
|
|
5018
|
+
: `order=${field}.asc did not hold under the projection.`, [result.exchange]);
|
|
5019
|
+
},
|
|
5020
|
+
};
|
|
5021
|
+
const querySearchAndSelectCompose = {
|
|
5022
|
+
applicable: (ctx) => conv(ctx).search !== undefined &&
|
|
5023
|
+
conv(ctx).select !== undefined &&
|
|
5024
|
+
(ctx.query?.searchable.length ?? 0) > 0 &&
|
|
5025
|
+
(ctx.query?.selectable.length ?? 0) > 0,
|
|
5026
|
+
dependsOn: ["search.q-narrows-result", "select.projection-honoured"],
|
|
5027
|
+
id: "query.search-and-select-compose",
|
|
5028
|
+
needs: "searchable and selectable fields",
|
|
5029
|
+
async run(ctx) {
|
|
5030
|
+
const field = ctx.query?.searchable[0];
|
|
5031
|
+
if (field === undefined)
|
|
5032
|
+
return;
|
|
5033
|
+
const token = ctx.records
|
|
5034
|
+
.map((row) => row[field])
|
|
5035
|
+
.find((value) => typeof value === "string" && value.length > 2);
|
|
5036
|
+
if (token === undefined)
|
|
5037
|
+
return;
|
|
5038
|
+
const requested = [ctx.identity, field];
|
|
5039
|
+
const projection = selectTerm(conv(ctx), requested, ctx.entityName);
|
|
5040
|
+
if (projection === null)
|
|
5041
|
+
return;
|
|
5042
|
+
const searched = await list(ctx, q(ctx, { limit: 100, search: token }));
|
|
5043
|
+
const combined = await list(ctx, { ...q(ctx, { limit: 100, search: token }), ...projection });
|
|
5044
|
+
if (searched.exchange.status >= 400 || combined.exchange.status >= 400) {
|
|
5045
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "search+select probe was rejected");
|
|
5046
|
+
}
|
|
5047
|
+
if (combined.items.some((item) => item[ctx.identity] === undefined)) {
|
|
5048
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "projection omitted the identity");
|
|
5049
|
+
}
|
|
5050
|
+
if (sameSet(setOf(searched.items, ctx.identity), setOf(combined.items, ctx.identity)))
|
|
5051
|
+
return;
|
|
5052
|
+
ctx.findings.backend(this.id, ctx.entityName, "adding a select changes which records a search returns", `q=${JSON.stringify(token)} alone matched ${searched.items.length}; with select it matched ${combined.items.length}.`, [searched.exchange, combined.exchange]);
|
|
5053
|
+
},
|
|
5054
|
+
};
|
|
5055
|
+
const querySearchAndSortCompose = {
|
|
5056
|
+
applicable: (ctx) => conv(ctx).search !== undefined &&
|
|
5057
|
+
conv(ctx).order !== undefined &&
|
|
5058
|
+
(ctx.query?.searchable.length ?? 0) > 0 &&
|
|
5059
|
+
(ctx.query?.sortable.length ?? 0) > 0,
|
|
5060
|
+
dependsOn: ["search.q-narrows-result", "sort.order-is-applied"],
|
|
5061
|
+
id: "query.search-and-sort-compose",
|
|
5062
|
+
needs: "searchable and sortable fields",
|
|
5063
|
+
async run(ctx) {
|
|
5064
|
+
const searchField = ctx.query?.searchable[0];
|
|
5065
|
+
const sortField = ctx.query?.sortable.find((name) => name !== ctx.identity) ?? ctx.query?.sortable[0];
|
|
5066
|
+
if (searchField === undefined || sortField === undefined)
|
|
5067
|
+
return;
|
|
5068
|
+
const token = ctx.records
|
|
5069
|
+
.map((row) => row[searchField])
|
|
5070
|
+
.find((value) => typeof value === "string" && value.length > 2);
|
|
5071
|
+
if (token === undefined)
|
|
5072
|
+
return;
|
|
5073
|
+
const searched = await list(ctx, q(ctx, { limit: 100, search: token }));
|
|
5074
|
+
const combined = await list(ctx, q(ctx, { limit: 100, search: token, order: sortTerm(conv(ctx), sortField, "asc") }));
|
|
5075
|
+
if (searched.exchange.status >= 400 || combined.exchange.status >= 400) {
|
|
5076
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "search+sort probe was rejected");
|
|
5077
|
+
}
|
|
5078
|
+
if (!sameSet(setOf(searched.items, ctx.identity), setOf(combined.items, ctx.identity))) {
|
|
5079
|
+
ctx.findings.backend(this.id, ctx.entityName, "adding a sort changes which records a search returns", `q=${JSON.stringify(token)} membership changed when order=${sortField} was added.`, [searched.exchange, combined.exchange]);
|
|
5080
|
+
return;
|
|
5081
|
+
}
|
|
5082
|
+
const values = combined.items.map((item) => item[sortField]);
|
|
5083
|
+
const sorted = [...values].sort(compareValues);
|
|
5084
|
+
if (JSON.stringify(values) === JSON.stringify(sorted))
|
|
5085
|
+
return;
|
|
5086
|
+
ctx.findings.backend(this.id, ctx.entityName, "search results are not ordered", `q + order=${sortField}.asc did not keep ${sortField} ascending.`, [combined.exchange]);
|
|
5087
|
+
},
|
|
5088
|
+
};
|
|
5089
|
+
const queryFilterSearchSortSelectCompose = {
|
|
5090
|
+
applicable: (ctx) => filterable(ctx) &&
|
|
5091
|
+
conv(ctx).search !== undefined &&
|
|
5092
|
+
conv(ctx).order !== undefined &&
|
|
5093
|
+
conv(ctx).select !== undefined &&
|
|
5094
|
+
(ctx.query?.searchable.length ?? 0) > 0 &&
|
|
5095
|
+
(ctx.query?.sortable.length ?? 0) > 0 &&
|
|
5096
|
+
(ctx.query?.selectable.length ?? 0) > 0 &&
|
|
5097
|
+
ctx.records.length > 2,
|
|
5098
|
+
dependsOn: [
|
|
5099
|
+
"query.search-and-filter-compose",
|
|
5100
|
+
"query.axes-compose",
|
|
5101
|
+
"query.filter-and-select-compose",
|
|
5102
|
+
"query.sort-and-select-compose",
|
|
5103
|
+
"query.filter-sort-select-compose",
|
|
5104
|
+
"query.filter-search-sort-compose",
|
|
5105
|
+
"query.filter-search-select-compose",
|
|
5106
|
+
"pagination.page-walk-covers-set",
|
|
5107
|
+
],
|
|
5108
|
+
id: "query.filter-search-sort-select-compose",
|
|
5109
|
+
needs: "all four list axes declared and at least three records",
|
|
5110
|
+
async run(ctx) {
|
|
5111
|
+
const picked = overlappingFilterAndSearch(ctx);
|
|
5112
|
+
if (picked === null) {
|
|
5113
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "no overlapping filter/search token");
|
|
5114
|
+
}
|
|
5115
|
+
const sortField = ctx.query?.sortable.find((name) => name !== ctx.identity) ?? ctx.query?.sortable[0];
|
|
5116
|
+
const extra = ctx.query?.selectable.find((name) => name !== ctx.identity);
|
|
5117
|
+
if (sortField === undefined)
|
|
5118
|
+
return;
|
|
5119
|
+
const conventions = conv(ctx);
|
|
5120
|
+
const term = filterTerm(conventions, picked.field, "eq", String(picked.target[picked.field]));
|
|
5121
|
+
if (term === null)
|
|
5122
|
+
return;
|
|
5123
|
+
const requested = extra === undefined ? [ctx.identity] : [ctx.identity, extra];
|
|
5124
|
+
const projection = selectTerm(conventions, requested, ctx.entityName);
|
|
5125
|
+
if (projection === null)
|
|
5126
|
+
return;
|
|
5127
|
+
const base = await collectSet(ctx, ctx.query?.maxLimit ?? 100, {
|
|
5128
|
+
...term,
|
|
5129
|
+
...(conventions.search === undefined ? {} : { [conventions.search]: picked.token }),
|
|
5130
|
+
});
|
|
5131
|
+
const combined = await collectSet(ctx, ctx.query?.maxLimit ?? 100, { ...term, ...projection, ...(conventions.search === undefined ? {} : { [conventions.search]: picked.token }) }, MAX_WALK_PAGES, sortTerm(conventions, sortField, "asc"));
|
|
5132
|
+
if (base === null || combined === null) {
|
|
5133
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "four-axis probe was rejected");
|
|
5134
|
+
}
|
|
5135
|
+
if (combined.items.some((item) => item[ctx.identity] === undefined)) {
|
|
5136
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "projection omitted the identity");
|
|
5137
|
+
}
|
|
5138
|
+
if (sameSet(setOf(base.items, ctx.identity), setOf(combined.items, ctx.identity)))
|
|
5139
|
+
return;
|
|
5140
|
+
ctx.findings.backend(this.id, ctx.entityName, "filter ∩ search membership changes when sort and select join", `the four-axis listing returned ${combined.items.length}; filter+search alone returned ${base.items.length}.`, [base.last.exchange, combined.last.exchange]);
|
|
5141
|
+
},
|
|
5142
|
+
};
|
|
3784
5143
|
export const CHECKS = [
|
|
3785
5144
|
/* foundations — did the write land, is it visible, and do the paging primitives work at all.
|
|
3786
5145
|
* Everything below assumes these hold, so they must be evaluated first for cascade
|
|
@@ -3798,23 +5157,47 @@ export const CHECKS = [
|
|
|
3798
5157
|
* compare. Establishing paging first is what lets those failures be suppressed as cascades
|
|
3799
5158
|
* rather than re-reported once per predicate. */
|
|
3800
5159
|
orderChangesResult,
|
|
5160
|
+
sortNumericOrderIsNumeric,
|
|
5161
|
+
sortNullsFirstLast,
|
|
5162
|
+
sortMultiKeyTiebreak,
|
|
5163
|
+
sortDefaultOrderApplied,
|
|
5164
|
+
sortStableTiebreak,
|
|
3801
5165
|
pageWalkCoversSet,
|
|
3802
5166
|
cursorAgreesWithPage,
|
|
3803
5167
|
/* query semantics */
|
|
3804
5168
|
unknownFilterRejected,
|
|
3805
5169
|
malformedFilterNot5xx,
|
|
5170
|
+
sortUnknownFieldRejected,
|
|
3806
5171
|
equalityFilterSelectsOne,
|
|
3807
5172
|
zeroMatchFilter,
|
|
3808
5173
|
negationPartitions,
|
|
3809
5174
|
filterAndComposesAsIntersection,
|
|
3810
5175
|
filterOrComposesAsUnion,
|
|
5176
|
+
filterInIsUnionOfEq,
|
|
5177
|
+
filterNinComplementsIn,
|
|
5178
|
+
filterNestedAndOrDistributes,
|
|
3811
5179
|
likeEscaping,
|
|
5180
|
+
filterIlikeIsCaseInsensitive,
|
|
5181
|
+
filterIsNullSelectsNulls,
|
|
5182
|
+
filterContainsMembership,
|
|
3812
5183
|
sortReverseSymmetry,
|
|
3813
5184
|
searchNarrowsResult,
|
|
5185
|
+
searchTokensAnd,
|
|
5186
|
+
searchCaseInsensitive,
|
|
5187
|
+
searchEmptyQ,
|
|
5188
|
+
searchUndeclaredFieldNotRequired,
|
|
5189
|
+
searchModeAccepted,
|
|
5190
|
+
searchModesDiffer,
|
|
3814
5191
|
selectProjection,
|
|
5192
|
+
selectRequestedFieldsPresent,
|
|
5193
|
+
selectUnknownFieldRejected,
|
|
5194
|
+
selectNestedHonoured,
|
|
3815
5195
|
countIsConsistentWithPage,
|
|
3816
5196
|
countMatchesWalk,
|
|
3817
5197
|
numericComparisonIsNumeric,
|
|
5198
|
+
filterGteIsGtOrEq,
|
|
5199
|
+
filterLteIsLtOrEq,
|
|
5200
|
+
filterOrderedTriplePartitions,
|
|
3818
5201
|
/* write semantics */
|
|
3819
5202
|
patchMinimality,
|
|
3820
5203
|
immutableRejected,
|
|
@@ -3829,9 +5212,21 @@ export const CHECKS = [
|
|
|
3829
5212
|
filterSortSelectCompose,
|
|
3830
5213
|
filterSearchSortCompose,
|
|
3831
5214
|
filterSearchSelectCompose,
|
|
5215
|
+
querySortAndSelectCompose,
|
|
5216
|
+
querySearchAndSelectCompose,
|
|
5217
|
+
querySearchAndSortCompose,
|
|
5218
|
+
queryFilterSearchSortSelectCompose,
|
|
3832
5219
|
declaredFilterableWorks,
|
|
5220
|
+
declaredFilterableOpsAccepted,
|
|
5221
|
+
declaredFilterableIllegalOpRejected,
|
|
3833
5222
|
declaredSortableWorks,
|
|
5223
|
+
declaredSortableNullsAccepted,
|
|
3834
5224
|
declaredSelectableWorks,
|
|
5225
|
+
filterAliasMatchesCanonical,
|
|
5226
|
+
filterIllegalOpRejected,
|
|
5227
|
+
filterEmptyIn,
|
|
5228
|
+
filterInOverLimitRejected,
|
|
5229
|
+
filterConditionCapRejected,
|
|
3835
5230
|
noLostUpdate,
|
|
3836
5231
|
deleteMissingIs404,
|
|
3837
5232
|
softDeleteHidden,
|
|
@@ -3849,6 +5244,7 @@ export const CHECKS = [
|
|
|
3849
5244
|
/* declared side effects and async lifecycles, last: both invoke operations that change the
|
|
3850
5245
|
* world, and both are meaningless if the read surface above is already known broken */
|
|
3851
5246
|
declaredEffectsOccur,
|
|
5247
|
+
sideEffectArrives,
|
|
3852
5248
|
asyncReachesTerminalState,
|
|
3853
5249
|
asyncReceiptIsResolvable,
|
|
3854
5250
|
/* After every other check has written to the transcript — create is owned by
|