@lovrozagar/oat 0.7.0 → 0.7.3
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 +327 -151
- package/dist/cli.js +32 -29
- package/dist/config/define-config.d.ts +31 -3
- package/dist/config/define-config.d.ts.map +1 -1
- package/dist/config/define-config.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- 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 -3
- package/dist/report/console.js.map +1 -1
- package/dist/runtime/checks.d.ts +3 -0
- package/dist/runtime/checks.d.ts.map +1 -1
- package/dist/runtime/checks.js +1361 -19
- package/dist/runtime/checks.js.map +1 -1
- package/dist/runtime/client.d.ts +17 -1
- package/dist/runtime/client.d.ts.map +1 -1
- package/dist/runtime/client.js +21 -4
- package/dist/runtime/client.js.map +1 -1
- 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 +6 -2
- package/dist/runtime/run.d.ts.map +1 -1
- package/dist/runtime/run.js +105 -37
- 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/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 +11 -0
- package/dist/spec/extensions.d.ts.map +1 -1
- package/dist/spec/extensions.js +16 -4
- package/dist/spec/extensions.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 +1 -1
package/dist/runtime/checks.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
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";
|
|
@@ -86,6 +87,8 @@ function q(ctx, roles) {
|
|
|
86
87
|
out[c.order] = roles.order;
|
|
87
88
|
if (roles.search !== undefined && c.search !== undefined)
|
|
88
89
|
out[c.search] = roles.search;
|
|
90
|
+
if (roles.searchMode !== undefined && c.searchMode !== undefined)
|
|
91
|
+
out[c.searchMode] = roles.searchMode;
|
|
89
92
|
if (roles.filter !== undefined && c.filter !== undefined)
|
|
90
93
|
out[c.filter] = roles.filter;
|
|
91
94
|
return out;
|
|
@@ -110,6 +113,48 @@ function filterable(ctx) {
|
|
|
110
113
|
* which fields are filterable — otherwise oat would be guessing at parameter names. */
|
|
111
114
|
return c.grammar === "equality" && (ctx.query?.filterable.length ?? 0) > 0;
|
|
112
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
|
+
}
|
|
113
158
|
/**
|
|
114
159
|
* Whether the endpoint can be walked forward at all, however it counts.
|
|
115
160
|
*
|
|
@@ -315,15 +360,49 @@ function nullableField(ctx, candidates) {
|
|
|
315
360
|
return null;
|
|
316
361
|
}
|
|
317
362
|
function firstFilterable(ctx, predicate) {
|
|
318
|
-
|
|
319
|
-
|
|
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;
|
|
320
398
|
}
|
|
321
399
|
/**
|
|
322
400
|
* Finds a record holding non-null values on two distinct, non-identity filterable fields — the
|
|
323
401
|
* minimum needed to build a compound predicate that is guaranteed to match at least that record.
|
|
324
402
|
*/
|
|
325
403
|
function twoFilterableFields(ctx) {
|
|
326
|
-
const
|
|
404
|
+
const skip = new Set([ctx.identity, filterIdentity(ctx)]);
|
|
405
|
+
const candidates = filterableNames(ctx).filter((f) => !skip.has(f));
|
|
327
406
|
for (const target of ctx.records) {
|
|
328
407
|
const present = candidates.filter((f) => target[f] !== null && target[f] !== undefined);
|
|
329
408
|
if (present[0] !== undefined && present[1] !== undefined) {
|
|
@@ -487,7 +566,7 @@ const unknownFilterRejected = {
|
|
|
487
566
|
},
|
|
488
567
|
};
|
|
489
568
|
const equalityFilterSelectsOne = {
|
|
490
|
-
applicable: (ctx) => filterable(ctx) && ctx.records.length > 0 && (ctx
|
|
569
|
+
applicable: (ctx) => filterable(ctx) && ctx.records.length > 0 && identityIsFilterable(ctx),
|
|
491
570
|
dependsOn: ["list.read-after-write"],
|
|
492
571
|
id: "filter.equality-selects-exactly-one",
|
|
493
572
|
needs: "a `filter` parameter that accepts the identity field",
|
|
@@ -496,7 +575,8 @@ const equalityFilterSelectsOne = {
|
|
|
496
575
|
if (target === undefined)
|
|
497
576
|
return;
|
|
498
577
|
const id = String(target[ctx.identity]);
|
|
499
|
-
const
|
|
578
|
+
const field = filterIdentity(ctx);
|
|
579
|
+
const term = filterTerm(conv(ctx), field, "eq", id);
|
|
500
580
|
if (term === null)
|
|
501
581
|
return;
|
|
502
582
|
const result = await list(ctx, { ...q(ctx, { limit: 100 }), ...term });
|
|
@@ -508,16 +588,16 @@ const equalityFilterSelectsOne = {
|
|
|
508
588
|
const got = ids(result.items, ctx.identity);
|
|
509
589
|
if (got.length === 1 && got[0] === id)
|
|
510
590
|
return;
|
|
511
|
-
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]);
|
|
512
592
|
},
|
|
513
593
|
};
|
|
514
594
|
const zeroMatchFilter = {
|
|
515
|
-
applicable: (ctx) => filterable(ctx) && (ctx
|
|
595
|
+
applicable: (ctx) => filterable(ctx) && identityIsFilterable(ctx),
|
|
516
596
|
dependsOn: ["list.read-after-write"],
|
|
517
597
|
id: "filter.zero-match-returns-none",
|
|
518
598
|
needs: "a `filter` parameter that accepts the identity field",
|
|
519
599
|
async run(ctx) {
|
|
520
|
-
const term = filterTerm(conv(ctx), ctx
|
|
600
|
+
const term = filterTerm(conv(ctx), filterIdentity(ctx), "eq", "oat-nonexistent-value-000");
|
|
521
601
|
if (term === null)
|
|
522
602
|
return;
|
|
523
603
|
const result = await list(ctx, { ...q(ctx, { limit: 100 }), ...term });
|
|
@@ -530,7 +610,7 @@ const zeroMatchFilter = {
|
|
|
530
610
|
},
|
|
531
611
|
};
|
|
532
612
|
const negationPartitions = {
|
|
533
|
-
applicable: (ctx) => filterable(ctx) && ctx.records.length > 1 && (ctx
|
|
613
|
+
applicable: (ctx) => filterable(ctx) && ctx.records.length > 1 && identityIsFilterable(ctx),
|
|
534
614
|
dependsOn: [
|
|
535
615
|
"list.read-after-write",
|
|
536
616
|
/* Partitioning is asserted over field *values*. A backend that drops submitted fields
|
|
@@ -548,7 +628,7 @@ const negationPartitions = {
|
|
|
548
628
|
return;
|
|
549
629
|
/* Prefer a field with nulls in the cohort: partitioning on the identity can never expose
|
|
550
630
|
* three-valued-logic bugs, because an identity is never null. */
|
|
551
|
-
const field = nullableField(ctx, ctx
|
|
631
|
+
const field = nullableField(ctx, filterableNames(ctx)) ?? filterIdentity(ctx);
|
|
552
632
|
const probe = ctx.records.map((r) => r[field]).find((v) => v !== null && v !== undefined);
|
|
553
633
|
if (probe === undefined) {
|
|
554
634
|
return ctx.findings.unresolved(this.id, ctx.entityName, `every record in the cohort has a null "${field}", leaving no value to negate`);
|
|
@@ -923,13 +1003,13 @@ const countMatchesWalk = {
|
|
|
923
1003
|
id: "count.matches-filtered-set",
|
|
924
1004
|
needs: "a total-count field and a `filter` parameter",
|
|
925
1005
|
async run(ctx) {
|
|
926
|
-
if (!(ctx
|
|
1006
|
+
if (!identityIsFilterable(ctx))
|
|
927
1007
|
return;
|
|
928
1008
|
const target = ctx.records[0];
|
|
929
1009
|
if (target === undefined)
|
|
930
1010
|
return;
|
|
931
1011
|
const id = String(target[ctx.identity]);
|
|
932
|
-
const countTerm = filterTerm(conv(ctx), ctx
|
|
1012
|
+
const countTerm = filterTerm(conv(ctx), filterIdentity(ctx), "eq", id);
|
|
933
1013
|
if (countTerm === null)
|
|
934
1014
|
return;
|
|
935
1015
|
const filtered = await list(ctx, { ...q(ctx, { limit: 100 }), ...countTerm });
|
|
@@ -1466,6 +1546,7 @@ const projectionsAgree = {
|
|
|
1466
1546
|
* disagreement here is that same defect seen a second time. */
|
|
1467
1547
|
"filter.equality-selects-exactly-one",
|
|
1468
1548
|
"select.projection-honoured",
|
|
1549
|
+
"select.requested-fields-present",
|
|
1469
1550
|
"sort.order-is-applied",
|
|
1470
1551
|
],
|
|
1471
1552
|
id: "consistency.projections-agree",
|
|
@@ -2445,21 +2526,25 @@ const crossTenantItemRead = {
|
|
|
2445
2526
|
},
|
|
2446
2527
|
};
|
|
2447
2528
|
const crossTenantFilterBypass = {
|
|
2448
|
-
applicable: (ctx) => ctx.altAuth !== undefined &&
|
|
2529
|
+
applicable: (ctx) => ctx.altAuth !== undefined &&
|
|
2530
|
+
ctx.altScope !== undefined &&
|
|
2531
|
+
filterable(ctx) &&
|
|
2532
|
+
ctx.records.length > 0 &&
|
|
2533
|
+
tenantBoundary(ctx.listOp),
|
|
2449
2534
|
/* The probe filters, so anything that stops a filter from selecting over the whole collection
|
|
2450
2535
|
* hides the very record whose visibility is in question — a leak that cannot be observed is
|
|
2451
2536
|
* not a leak that can be reported. */
|
|
2452
2537
|
dependsOn: ["query.filter-selects-from-whole-set"],
|
|
2453
2538
|
id: "tenant.filter-does-not-bypass-scope",
|
|
2454
|
-
needs: "a second principal and a
|
|
2539
|
+
needs: "a second principal, a filter, and a tenant tagged or inferred from the path",
|
|
2455
2540
|
async run(ctx) {
|
|
2456
2541
|
const target = ctx.records[0];
|
|
2457
2542
|
if (target === undefined || ctx.altAuth === undefined || ctx.altScope === undefined)
|
|
2458
2543
|
return;
|
|
2459
|
-
if (!(ctx
|
|
2544
|
+
if (!identityIsFilterable(ctx))
|
|
2460
2545
|
return;
|
|
2461
2546
|
const id = String(target[ctx.identity]);
|
|
2462
|
-
const tenantTerm = filterTerm(conv(ctx), ctx
|
|
2547
|
+
const tenantTerm = filterTerm(conv(ctx), filterIdentity(ctx), "eq", id);
|
|
2463
2548
|
if (tenantTerm === null)
|
|
2464
2549
|
return;
|
|
2465
2550
|
const result = await list(ctx, { ...q(ctx, { limit: 100 }), ...tenantTerm }, ctx.altAuth, ctx.altScope);
|
|
@@ -2467,9 +2552,28 @@ const crossTenantFilterBypass = {
|
|
|
2467
2552
|
return;
|
|
2468
2553
|
if (!ids(result.items, ctx.identity).includes(id))
|
|
2469
2554
|
return;
|
|
2470
|
-
|
|
2555
|
+
/* Same split as the item check. A public catalogue has no tenant at all — a 200 for
|
|
2556
|
+
* `id.eq.<someone else's public row>` is the contract, not a leak. */
|
|
2557
|
+
const declared = ctx.listOp.tenantSource === "tag";
|
|
2558
|
+
const detail = `a principal in another tenant filtered on ${ctx.identity}.eq.${id} and received the ` +
|
|
2471
2559
|
"record. The tenant predicate is applied to the base listing but not re-applied to " +
|
|
2472
|
-
"filter matches, so any caller who can guess an id can read it."
|
|
2560
|
+
"filter matches, so any caller who can guess an id can read it.";
|
|
2561
|
+
if (declared) {
|
|
2562
|
+
ctx.findings.security(this.id, ctx.entityName, "a filter reaches records outside the caller's tenant", `${detail} The list declares x-tenant: "${ctx.listOp.tenantParam}", so this crosses ` +
|
|
2563
|
+
"a boundary the document states exists.", [result.exchange]);
|
|
2564
|
+
return;
|
|
2565
|
+
}
|
|
2566
|
+
ctx.findings.report({
|
|
2567
|
+
check: this.id,
|
|
2568
|
+
detail: `${detail} oat inferred tenant scoping from the "${ctx.listOp.tenantParam}" path ` +
|
|
2569
|
+
"parameter; the document does not state it. A public catalogue has no tenant at all — " +
|
|
2570
|
+
"no x-tenant and no tenant-named path parameter — and this check then does not apply. " +
|
|
2571
|
+
"If the resource is not shared, declare x-tenant so the same 200 is SECURITY.",
|
|
2572
|
+
entity: ctx.entityName,
|
|
2573
|
+
evidence: [result.exchange],
|
|
2574
|
+
summary: "a filter crosses an inferred tenant boundary; the document does not say whether that is intended",
|
|
2575
|
+
verdict: "AMBIGUITY",
|
|
2576
|
+
});
|
|
2473
2577
|
},
|
|
2474
2578
|
};
|
|
2475
2579
|
function sameTenantScope(a, b) {
|
|
@@ -2779,6 +2883,15 @@ const orderChangesResult = {
|
|
|
2779
2883
|
"backend ignores silently gives every caller arbitrary ordering.", [ascending.exchange]);
|
|
2780
2884
|
},
|
|
2781
2885
|
};
|
|
2886
|
+
function numericLexicalDisagrees(ctx, field) {
|
|
2887
|
+
const values = ctx.records.map((row) => row[field]).filter((v) => typeof v === "number");
|
|
2888
|
+
if (values.length < 3)
|
|
2889
|
+
return false;
|
|
2890
|
+
const unique = [...new Set(values)];
|
|
2891
|
+
const asNumbers = [...unique].sort((a, b) => a - b);
|
|
2892
|
+
const asText = [...unique].sort((a, b) => String(a).localeCompare(String(b)));
|
|
2893
|
+
return asNumbers.join(",") !== asText.join(",");
|
|
2894
|
+
}
|
|
2782
2895
|
function compareValues(a, b) {
|
|
2783
2896
|
if (a === null || a === undefined)
|
|
2784
2897
|
return -1;
|
|
@@ -3857,6 +3970,1199 @@ const documentedStatusHonoured = {
|
|
|
3857
3970
|
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);
|
|
3858
3971
|
},
|
|
3859
3972
|
};
|
|
3973
|
+
function pickFieldForOp(ctx, op, minDistinct = 2) {
|
|
3974
|
+
for (const field of fieldsAllowing(ctx, op)) {
|
|
3975
|
+
const values = distinctValues(ctx.records, field.field);
|
|
3976
|
+
if (values.length >= minDistinct)
|
|
3977
|
+
return { field, values };
|
|
3978
|
+
}
|
|
3979
|
+
return null;
|
|
3980
|
+
}
|
|
3981
|
+
function pickOrderedField(ctx) {
|
|
3982
|
+
const caps = resolvedCaps(ctx);
|
|
3983
|
+
for (const field of caps.filterable) {
|
|
3984
|
+
if (field.type !== undefined && !ORDERED_TYPES.has(field.type))
|
|
3985
|
+
continue;
|
|
3986
|
+
if (!canUseOp(ctx, field, "gt") || !canUseOp(ctx, field, "lt") || !canUseOp(ctx, field, "eq"))
|
|
3987
|
+
continue;
|
|
3988
|
+
const values = ctx.records.map((r) => r[field.field]).filter((v) => typeof v === "number");
|
|
3989
|
+
if (values.length < 3)
|
|
3990
|
+
continue;
|
|
3991
|
+
return { field, values };
|
|
3992
|
+
}
|
|
3993
|
+
return null;
|
|
3994
|
+
}
|
|
3995
|
+
const FOUNDATIONS = [
|
|
3996
|
+
"list.read-after-write",
|
|
3997
|
+
"create.persists-submitted-fields",
|
|
3998
|
+
"pagination.page-walk-covers-set",
|
|
3999
|
+
];
|
|
4000
|
+
const filterInIsUnionOfEq = {
|
|
4001
|
+
applicable: (ctx) => filterable(ctx) && pickFieldForOp(ctx, "in", 2) !== null,
|
|
4002
|
+
dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one"],
|
|
4003
|
+
id: "filter.in-is-union-of-eq",
|
|
4004
|
+
needs: "a field that allows `in` and at least two distinct values",
|
|
4005
|
+
async run(ctx) {
|
|
4006
|
+
const picked = pickFieldForOp(ctx, "in", 2);
|
|
4007
|
+
if (picked === null)
|
|
4008
|
+
return;
|
|
4009
|
+
const [a, b] = picked.values;
|
|
4010
|
+
if (a === undefined || b === undefined)
|
|
4011
|
+
return;
|
|
4012
|
+
const conventions = conv(ctx);
|
|
4013
|
+
const inTerm = filterTerm(conventions, picked.field.field, "in", [asTermValue(a), asTermValue(b)]);
|
|
4014
|
+
const eqA = filterTerm(conventions, picked.field.field, "eq", asTermValue(a));
|
|
4015
|
+
const eqB = filterTerm(conventions, picked.field.field, "eq", asTermValue(b));
|
|
4016
|
+
if (inTerm === null || eqA === null || eqB === null)
|
|
4017
|
+
return;
|
|
4018
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
4019
|
+
const together = await collectSet(ctx, limit, inTerm);
|
|
4020
|
+
const onlyA = await collectSet(ctx, limit, eqA);
|
|
4021
|
+
const onlyB = await collectSet(ctx, limit, eqB);
|
|
4022
|
+
if (together === null || onlyA === null || onlyB === null) {
|
|
4023
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "one of the listings needed for in() was rejected");
|
|
4024
|
+
}
|
|
4025
|
+
if (!together.complete || !onlyA.complete || !onlyB.complete) {
|
|
4026
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers");
|
|
4027
|
+
}
|
|
4028
|
+
const expected = new Set([...ids(onlyA.items, ctx.identity), ...ids(onlyB.items, ctx.identity)]);
|
|
4029
|
+
const got = setOf(together.items, ctx.identity);
|
|
4030
|
+
if (sameSet(expected, got))
|
|
4031
|
+
return;
|
|
4032
|
+
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); ` +
|
|
4033
|
+
`eq on each value together match ${expected.size}.`, [together.last.exchange, onlyA.last.exchange, onlyB.last.exchange]);
|
|
4034
|
+
},
|
|
4035
|
+
};
|
|
4036
|
+
const filterNinComplementsIn = {
|
|
4037
|
+
applicable: (ctx) => filterable(ctx) && pickFieldForOp(ctx, "in", 1) !== null && pickFieldForOp(ctx, "nin", 1) !== null,
|
|
4038
|
+
dependsOn: [...FOUNDATIONS, "filter.in-is-union-of-eq"],
|
|
4039
|
+
id: "filter.nin-complements-in",
|
|
4040
|
+
needs: "a field that allows both `in` and `nin`",
|
|
4041
|
+
async run(ctx) {
|
|
4042
|
+
const picked = pickFieldForOp(ctx, "in", 1);
|
|
4043
|
+
if (picked === null || !canUseOp(ctx, picked.field, "nin"))
|
|
4044
|
+
return;
|
|
4045
|
+
const value = picked.values[0];
|
|
4046
|
+
if (value === undefined)
|
|
4047
|
+
return;
|
|
4048
|
+
const conventions = conv(ctx);
|
|
4049
|
+
const inTerm = filterTerm(conventions, picked.field.field, "in", [asTermValue(value)]);
|
|
4050
|
+
const ninTerm = filterTerm(conventions, picked.field.field, "nin", [asTermValue(value)]);
|
|
4051
|
+
if (inTerm === null || ninTerm === null)
|
|
4052
|
+
return;
|
|
4053
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
4054
|
+
const all = await collectSet(ctx, limit);
|
|
4055
|
+
const inside = await collectSet(ctx, limit, inTerm);
|
|
4056
|
+
const outside = await collectSet(ctx, limit, ninTerm);
|
|
4057
|
+
if (all === null || inside === null || outside === null) {
|
|
4058
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "one of the listings needed for nin() was rejected");
|
|
4059
|
+
}
|
|
4060
|
+
if (!all.complete || !inside.complete || !outside.complete) {
|
|
4061
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers");
|
|
4062
|
+
}
|
|
4063
|
+
const inIds = setOf(inside.items, ctx.identity);
|
|
4064
|
+
const ninIds = setOf(outside.items, ctx.identity);
|
|
4065
|
+
const overlap = [...inIds].filter((id) => ninIds.has(id));
|
|
4066
|
+
if (overlap.length > 0) {
|
|
4067
|
+
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]);
|
|
4068
|
+
return;
|
|
4069
|
+
}
|
|
4070
|
+
const expected = ids(all.items, ctx.identity).filter((id) => {
|
|
4071
|
+
const record = ctx.records.find((row) => String(row[ctx.identity]) === id);
|
|
4072
|
+
return record !== undefined && record[picked.field.field] !== null && record[picked.field.field] !== undefined;
|
|
4073
|
+
});
|
|
4074
|
+
const union = new Set([...inIds, ...ninIds]);
|
|
4075
|
+
const missing = expected.filter((id) => !union.has(id));
|
|
4076
|
+
if (missing.length === 0)
|
|
4077
|
+
return;
|
|
4078
|
+
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]);
|
|
4079
|
+
},
|
|
4080
|
+
};
|
|
4081
|
+
const filterGteIsGtOrEq = {
|
|
4082
|
+
applicable: (ctx) => pickOrderedField(ctx) !== null && fieldsAllowing(ctx, "gte").length > 0,
|
|
4083
|
+
dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one", "filter.numeric-comparison-is-numeric"],
|
|
4084
|
+
id: "filter.gte-is-gt-or-eq",
|
|
4085
|
+
needs: "an ordered field that allows `gte` and `gt`",
|
|
4086
|
+
async run(ctx) {
|
|
4087
|
+
const picked = pickOrderedField(ctx);
|
|
4088
|
+
if (picked === null || !canUseOp(ctx, picked.field, "gte"))
|
|
4089
|
+
return;
|
|
4090
|
+
const threshold = [...new Set(picked.values)].sort((a, b) => a - b)[Math.floor(picked.values.length / 3)];
|
|
4091
|
+
if (threshold === undefined)
|
|
4092
|
+
return;
|
|
4093
|
+
await assertRangeUnion(ctx, this.id, picked.field.field, "gte", "gt", threshold);
|
|
4094
|
+
},
|
|
4095
|
+
};
|
|
4096
|
+
const filterLteIsLtOrEq = {
|
|
4097
|
+
applicable: (ctx) => pickOrderedField(ctx) !== null && fieldsAllowing(ctx, "lte").length > 0,
|
|
4098
|
+
dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one", "filter.numeric-comparison-is-numeric"],
|
|
4099
|
+
id: "filter.lte-is-lt-or-eq",
|
|
4100
|
+
needs: "an ordered field that allows `lte` and `lt`",
|
|
4101
|
+
async run(ctx) {
|
|
4102
|
+
const picked = pickOrderedField(ctx);
|
|
4103
|
+
if (picked === null || !canUseOp(ctx, picked.field, "lte"))
|
|
4104
|
+
return;
|
|
4105
|
+
const threshold = [...new Set(picked.values)].sort((a, b) => a - b)[Math.floor(picked.values.length / 3)];
|
|
4106
|
+
if (threshold === undefined)
|
|
4107
|
+
return;
|
|
4108
|
+
await assertRangeUnion(ctx, this.id, picked.field.field, "lte", "lt", threshold);
|
|
4109
|
+
},
|
|
4110
|
+
};
|
|
4111
|
+
async function assertRangeUnion(ctx, check, field, closed, open, threshold) {
|
|
4112
|
+
const conventions = conv(ctx);
|
|
4113
|
+
const closedTerm = filterTerm(conventions, field, closed, threshold);
|
|
4114
|
+
const openTerm = filterTerm(conventions, field, open, threshold);
|
|
4115
|
+
const eqTerm = filterTerm(conventions, field, "eq", threshold);
|
|
4116
|
+
if (closedTerm === null || openTerm === null || eqTerm === null)
|
|
4117
|
+
return;
|
|
4118
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
4119
|
+
const closedSet = await collectSet(ctx, limit, closedTerm);
|
|
4120
|
+
const openSet = await collectSet(ctx, limit, openTerm);
|
|
4121
|
+
const eqSet = await collectSet(ctx, limit, eqTerm);
|
|
4122
|
+
if (closedSet === null || openSet === null || eqSet === null) {
|
|
4123
|
+
return ctx.findings.unresolved(check, ctx.entityName, "one of the range listings was rejected");
|
|
4124
|
+
}
|
|
4125
|
+
if (!closedSet.complete || !openSet.complete || !eqSet.complete) {
|
|
4126
|
+
return ctx.findings.unresolved(check, ctx.entityName, "the collection is larger than the walk covers");
|
|
4127
|
+
}
|
|
4128
|
+
const expected = new Set([...ids(openSet.items, ctx.identity), ...ids(eqSet.items, ctx.identity)]);
|
|
4129
|
+
const got = setOf(closedSet.items, ctx.identity);
|
|
4130
|
+
if (sameSet(expected, got))
|
|
4131
|
+
return;
|
|
4132
|
+
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]);
|
|
4133
|
+
}
|
|
4134
|
+
const filterOrderedTriplePartitions = {
|
|
4135
|
+
applicable: (ctx) => pickOrderedField(ctx) !== null,
|
|
4136
|
+
dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one", "filter.numeric-comparison-is-numeric"],
|
|
4137
|
+
id: "filter.ordered-triple-partitions",
|
|
4138
|
+
needs: "an ordered field that allows `lt`, `eq`, and `gt`",
|
|
4139
|
+
async run(ctx) {
|
|
4140
|
+
const picked = pickOrderedField(ctx);
|
|
4141
|
+
if (picked === null)
|
|
4142
|
+
return;
|
|
4143
|
+
const threshold = [...new Set(picked.values)].sort((a, b) => a - b)[Math.floor(picked.values.length / 2)];
|
|
4144
|
+
if (threshold === undefined)
|
|
4145
|
+
return;
|
|
4146
|
+
const conventions = conv(ctx);
|
|
4147
|
+
const lt = filterTerm(conventions, picked.field.field, "lt", threshold);
|
|
4148
|
+
const eq = filterTerm(conventions, picked.field.field, "eq", threshold);
|
|
4149
|
+
const gt = filterTerm(conventions, picked.field.field, "gt", threshold);
|
|
4150
|
+
if (lt === null || eq === null || gt === null)
|
|
4151
|
+
return;
|
|
4152
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
4153
|
+
const all = await collectSet(ctx, limit);
|
|
4154
|
+
const lower = await collectSet(ctx, limit, lt);
|
|
4155
|
+
const equal = await collectSet(ctx, limit, eq);
|
|
4156
|
+
const higher = await collectSet(ctx, limit, gt);
|
|
4157
|
+
if (all === null || lower === null || equal === null || higher === null) {
|
|
4158
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "one of the triple listings was rejected");
|
|
4159
|
+
}
|
|
4160
|
+
if (!all.complete || !lower.complete || !equal.complete || !higher.complete) {
|
|
4161
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers");
|
|
4162
|
+
}
|
|
4163
|
+
const sets = [setOf(lower.items, ctx.identity), setOf(equal.items, ctx.identity), setOf(higher.items, ctx.identity)];
|
|
4164
|
+
for (let i = 0; i < sets.length; i++) {
|
|
4165
|
+
for (let j = i + 1; j < sets.length; j++) {
|
|
4166
|
+
const left = sets[i];
|
|
4167
|
+
const right = sets[j];
|
|
4168
|
+
if (left === undefined || right === undefined)
|
|
4169
|
+
continue;
|
|
4170
|
+
const overlap = [...left].filter((id) => right.has(id));
|
|
4171
|
+
if (overlap.length > 0) {
|
|
4172
|
+
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]);
|
|
4173
|
+
return;
|
|
4174
|
+
}
|
|
4175
|
+
}
|
|
4176
|
+
}
|
|
4177
|
+
const union = new Set([...(sets[0] ?? []), ...(sets[1] ?? []), ...(sets[2] ?? [])]);
|
|
4178
|
+
const expected = ids(all.items, ctx.identity).filter((id) => {
|
|
4179
|
+
const record = ctx.records.find((row) => String(row[ctx.identity]) === id);
|
|
4180
|
+
return record !== undefined && typeof record[picked.field.field] === "number";
|
|
4181
|
+
});
|
|
4182
|
+
const missing = expected.filter((id) => !union.has(id));
|
|
4183
|
+
if (missing.length === 0)
|
|
4184
|
+
return;
|
|
4185
|
+
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]);
|
|
4186
|
+
},
|
|
4187
|
+
};
|
|
4188
|
+
const filterIlikeIsCaseInsensitive = {
|
|
4189
|
+
applicable: (ctx) => fieldsAllowing(ctx, "ilike").some((field) => canUseOp(ctx, field, "like")),
|
|
4190
|
+
dependsOn: [...FOUNDATIONS, "filter.like-metacharacters-escaped"],
|
|
4191
|
+
id: "filter.ilike-is-case-insensitive",
|
|
4192
|
+
needs: "a field that allows both `ilike` and `like`, and a string with a letter",
|
|
4193
|
+
async run(ctx) {
|
|
4194
|
+
const field = fieldsAllowing(ctx, "ilike").find((item) => canUseOp(ctx, item, "like"));
|
|
4195
|
+
if (field === undefined)
|
|
4196
|
+
return;
|
|
4197
|
+
const sample = ctx.records
|
|
4198
|
+
.map((row) => row[field.field])
|
|
4199
|
+
.find((value) => typeof value === "string" && /[A-Za-z]/.test(value));
|
|
4200
|
+
if (sample === undefined) {
|
|
4201
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "no searchable string on the field has a letter");
|
|
4202
|
+
}
|
|
4203
|
+
const flipped = sample.replace(/[A-Za-z]/, (ch) => (ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase()));
|
|
4204
|
+
if (flipped === sample) {
|
|
4205
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "could not case-flip a letter in the sample");
|
|
4206
|
+
}
|
|
4207
|
+
const conventions = conv(ctx);
|
|
4208
|
+
const likeTerm = filterTerm(conventions, field.field, "like", flipped);
|
|
4209
|
+
const ilikeTerm = filterTerm(conventions, field.field, "ilike", flipped);
|
|
4210
|
+
if (likeTerm === null || ilikeTerm === null)
|
|
4211
|
+
return;
|
|
4212
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
4213
|
+
const like = await list(ctx, { ...q(ctx, { limit }), ...likeTerm });
|
|
4214
|
+
const ilike = await list(ctx, { ...q(ctx, { limit }), ...ilikeTerm });
|
|
4215
|
+
if (like.exchange.status >= 400 || ilike.exchange.status >= 400) {
|
|
4216
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "like/ilike probe was rejected");
|
|
4217
|
+
}
|
|
4218
|
+
const likeHits = ids(like.items, ctx.identity);
|
|
4219
|
+
const ilikeHits = ids(ilike.items, ctx.identity);
|
|
4220
|
+
const original = ctx.records.filter((row) => row[field.field] === sample).map((row) => String(row[ctx.identity]));
|
|
4221
|
+
if (original.some((id) => ilikeHits.includes(id)) && !original.some((id) => likeHits.includes(id)))
|
|
4222
|
+
return;
|
|
4223
|
+
if (original.some((id) => ilikeHits.includes(id)) && original.some((id) => likeHits.includes(id))) {
|
|
4224
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "like also matched the case-flipped value — the store may already be case-insensitive");
|
|
4225
|
+
}
|
|
4226
|
+
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]);
|
|
4227
|
+
},
|
|
4228
|
+
};
|
|
4229
|
+
function mixedNullField(ctx, field) {
|
|
4230
|
+
if (ctx.softDelete !== null && field === ctx.softDelete)
|
|
4231
|
+
return false;
|
|
4232
|
+
const values = ctx.records.map((row) => row[field]);
|
|
4233
|
+
const nulls = values.filter((value) => value === null || value === undefined).length;
|
|
4234
|
+
return nulls > 0 && nulls < values.length;
|
|
4235
|
+
}
|
|
4236
|
+
const filterIsNullSelectsNulls = {
|
|
4237
|
+
applicable: (ctx) => fieldsAllowing(ctx, "is").some((field) => mixedNullField(ctx, field.field)),
|
|
4238
|
+
dependsOn: [...FOUNDATIONS],
|
|
4239
|
+
id: "filter.is-null-selects-nulls",
|
|
4240
|
+
needs: "a field that allows `is` and a cohort that contains a null",
|
|
4241
|
+
async run(ctx) {
|
|
4242
|
+
const field = fieldsAllowing(ctx, "is").find((item) => mixedNullField(ctx, item.field));
|
|
4243
|
+
if (field === undefined)
|
|
4244
|
+
return;
|
|
4245
|
+
const conventions = conv(ctx);
|
|
4246
|
+
const nullTerm = filterTerm(conventions, field.field, "is", "null");
|
|
4247
|
+
const notNullTerm = filterTerm(conventions, field.field, "is", "notnull");
|
|
4248
|
+
if (nullTerm === null || notNullTerm === null)
|
|
4249
|
+
return;
|
|
4250
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
4251
|
+
const all = await collectSet(ctx, limit);
|
|
4252
|
+
const nulls = await collectSet(ctx, limit, nullTerm);
|
|
4253
|
+
const present = await collectSet(ctx, limit, notNullTerm);
|
|
4254
|
+
if (all === null || nulls === null || present === null) {
|
|
4255
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "an is.null / is.notnull listing was rejected");
|
|
4256
|
+
}
|
|
4257
|
+
if (!all.complete || !nulls.complete || !present.complete) {
|
|
4258
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers");
|
|
4259
|
+
}
|
|
4260
|
+
const expectedNulls = new Set(ctx.records
|
|
4261
|
+
.filter((row) => row[field.field] === null || row[field.field] === undefined)
|
|
4262
|
+
.map((row) => String(row[ctx.identity])));
|
|
4263
|
+
const gotNulls = setOf(nulls.items, ctx.identity);
|
|
4264
|
+
const overlap = [...gotNulls].filter((id) => setOf(present.items, ctx.identity).has(id));
|
|
4265
|
+
if (!sameSet(expectedNulls, gotNulls) || overlap.length > 0) {
|
|
4266
|
+
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. ` +
|
|
4267
|
+
(overlap.length > 0 ? `${overlap.length} appear on both sides.` : ""), [nulls.last.exchange, present.last.exchange]);
|
|
4268
|
+
}
|
|
4269
|
+
},
|
|
4270
|
+
};
|
|
4271
|
+
const filterContainsMembership = {
|
|
4272
|
+
applicable: (ctx) => resolvedCaps(ctx).filterable.some((field) => (field.type === "array" || fieldAllows(field, "contains", resolvedCaps(ctx))) &&
|
|
4273
|
+
canWriteFilterOp(conv(ctx), "contains") &&
|
|
4274
|
+
ctx.records.some((row) => Array.isArray(row[field.field]) && row[field.field].length > 0)),
|
|
4275
|
+
dependsOn: [...FOUNDATIONS],
|
|
4276
|
+
id: "filter.contains-membership",
|
|
4277
|
+
needs: "an array field that allows `contains` and a known element",
|
|
4278
|
+
async run(ctx) {
|
|
4279
|
+
const field = resolvedCaps(ctx).filterable.find((item) => canUseOp(ctx, item, "contains") &&
|
|
4280
|
+
ctx.records.some((row) => Array.isArray(row[item.field]) && row[item.field].length > 0));
|
|
4281
|
+
if (field === undefined)
|
|
4282
|
+
return;
|
|
4283
|
+
const sample = ctx.records.find((row) => Array.isArray(row[field.field]) && row[field.field].length > 0);
|
|
4284
|
+
const element = Array.isArray(sample?.[field.field]) ? sample[field.field][0] : undefined;
|
|
4285
|
+
if (element === undefined)
|
|
4286
|
+
return;
|
|
4287
|
+
const term = filterTerm(conv(ctx), field.field, "contains", asTermValue(element));
|
|
4288
|
+
if (term === null)
|
|
4289
|
+
return;
|
|
4290
|
+
const result = await collectSet(ctx, ctx.query?.maxLimit ?? 100, term);
|
|
4291
|
+
if (result === null) {
|
|
4292
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "contains probe was rejected");
|
|
4293
|
+
}
|
|
4294
|
+
if (!result.complete) {
|
|
4295
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers");
|
|
4296
|
+
}
|
|
4297
|
+
const expected = new Set(ctx.records
|
|
4298
|
+
.filter((row) => Array.isArray(row[field.field]) &&
|
|
4299
|
+
row[field.field].some((item) => String(item) === String(element)))
|
|
4300
|
+
.map((row) => String(row[ctx.identity])));
|
|
4301
|
+
const got = setOf(result.items, ctx.identity);
|
|
4302
|
+
if (sameSet(expected, got))
|
|
4303
|
+
return;
|
|
4304
|
+
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]);
|
|
4305
|
+
},
|
|
4306
|
+
};
|
|
4307
|
+
const filterNestedAndOrDistributes = {
|
|
4308
|
+
applicable: (ctx) => conv(ctx).grammar === "postgrest" && filterableNames(ctx).length > 1,
|
|
4309
|
+
dependsOn: [...FOUNDATIONS, "filter.and-composes-as-intersection", "filter.or-composes-as-union"],
|
|
4310
|
+
id: "filter.nested-and-or-distributes",
|
|
4311
|
+
needs: "a postgrest-shaped grammar and two filterable fields",
|
|
4312
|
+
async run(ctx) {
|
|
4313
|
+
const picked = twoFilterableFields(ctx);
|
|
4314
|
+
if (picked === null) {
|
|
4315
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "no record holds two non-null filterable fields");
|
|
4316
|
+
}
|
|
4317
|
+
const idField = filterIdentity(ctx);
|
|
4318
|
+
if (idField === picked.fieldA || idField === picked.fieldB || !identityIsFilterable(ctx)) {
|
|
4319
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "need a third identity filter term to nest and/or");
|
|
4320
|
+
}
|
|
4321
|
+
const conventions = conv(ctx);
|
|
4322
|
+
const fragA = filterFragment(conventions, picked.fieldA, String(picked.target[picked.fieldA]));
|
|
4323
|
+
const fragB = filterFragment(conventions, picked.fieldB, String(picked.target[picked.fieldB]));
|
|
4324
|
+
const fragC = filterFragment(conventions, idField, String(picked.target[ctx.identity]));
|
|
4325
|
+
if (fragA === null || fragB === null || fragC === null || conventions.filter === undefined)
|
|
4326
|
+
return;
|
|
4327
|
+
const nested = { [conventions.filter]: `and(${fragA},or(${fragB},${fragC}))` };
|
|
4328
|
+
const termA = filterTerm(conventions, picked.fieldA, "eq", String(picked.target[picked.fieldA]));
|
|
4329
|
+
const termB = filterTerm(conventions, picked.fieldB, "eq", String(picked.target[picked.fieldB]));
|
|
4330
|
+
const termC = filterTerm(conventions, idField, "eq", String(picked.target[ctx.identity]));
|
|
4331
|
+
if (termA === null || termB === null || termC === null)
|
|
4332
|
+
return;
|
|
4333
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
4334
|
+
const onlyA = await collectSet(ctx, limit, termA);
|
|
4335
|
+
const onlyB = await collectSet(ctx, limit, termB);
|
|
4336
|
+
const onlyC = await collectSet(ctx, limit, termC);
|
|
4337
|
+
const combined = await collectSet(ctx, limit, nested);
|
|
4338
|
+
if (onlyA === null || onlyB === null || onlyC === null || combined === null) {
|
|
4339
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "a nested and/or listing was rejected");
|
|
4340
|
+
}
|
|
4341
|
+
if (!onlyA.complete || !onlyB.complete || !onlyC.complete || !combined.complete) {
|
|
4342
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers");
|
|
4343
|
+
}
|
|
4344
|
+
const setA = setOf(onlyA.items, ctx.identity);
|
|
4345
|
+
const setB = setOf(onlyB.items, ctx.identity);
|
|
4346
|
+
const setC = setOf(onlyC.items, ctx.identity);
|
|
4347
|
+
const expected = new Set([...setA].filter((id) => setB.has(id) || setC.has(id)));
|
|
4348
|
+
const got = setOf(combined.items, ctx.identity);
|
|
4349
|
+
if (sameSet(expected, got))
|
|
4350
|
+
return;
|
|
4351
|
+
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]);
|
|
4352
|
+
},
|
|
4353
|
+
};
|
|
4354
|
+
const filterAliasMatchesCanonical = {
|
|
4355
|
+
applicable: (ctx) => Object.keys(resolvedCaps(ctx).aliases).length > 0 && filterable(ctx),
|
|
4356
|
+
dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one"],
|
|
4357
|
+
id: "filter.alias-matches-canonical",
|
|
4358
|
+
needs: "a declared filter operator alias",
|
|
4359
|
+
async run(ctx) {
|
|
4360
|
+
const caps = resolvedCaps(ctx);
|
|
4361
|
+
const conventions = conv(ctx);
|
|
4362
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
4363
|
+
for (const [alias, target] of Object.entries(caps.aliases)) {
|
|
4364
|
+
if (!isFilterOp(alias) || target === undefined)
|
|
4365
|
+
continue;
|
|
4366
|
+
const field = resolvedCaps(ctx).filterable.find((item) => fieldAllows(item, target, caps));
|
|
4367
|
+
if (field === undefined || !canWriteFilterOp(conventions, alias))
|
|
4368
|
+
continue;
|
|
4369
|
+
const sample = ctx.records.find((row) => row[field.field] != null);
|
|
4370
|
+
if (sample === undefined)
|
|
4371
|
+
continue;
|
|
4372
|
+
const value = asTermValue(sample[field.field]);
|
|
4373
|
+
const aliasTerm = filterTerm(conventions, field.field, alias, value);
|
|
4374
|
+
const targetTerm = filterTerm(conventions, field.field, target, value);
|
|
4375
|
+
if (aliasTerm === null || targetTerm === null)
|
|
4376
|
+
continue;
|
|
4377
|
+
const left = await collectSet(ctx, limit, aliasTerm);
|
|
4378
|
+
const right = await collectSet(ctx, limit, targetTerm);
|
|
4379
|
+
if (left === null || right === null) {
|
|
4380
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `alias ${alias} or ${target} was rejected`);
|
|
4381
|
+
}
|
|
4382
|
+
if (!sameSet(setOf(left.items, ctx.identity), setOf(right.items, ctx.identity))) {
|
|
4383
|
+
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]);
|
|
4384
|
+
return;
|
|
4385
|
+
}
|
|
4386
|
+
}
|
|
4387
|
+
},
|
|
4388
|
+
};
|
|
4389
|
+
function firstIllegalOp(ctx, field) {
|
|
4390
|
+
const caps = resolvedCaps(ctx);
|
|
4391
|
+
if (!opsAreClosed(field, caps))
|
|
4392
|
+
return undefined;
|
|
4393
|
+
const allowed = new Set(opsForField(field, caps));
|
|
4394
|
+
return FILTER_OPS.find((op) => !allowed.has(op) && canWriteFilterOp(conv(ctx), op));
|
|
4395
|
+
}
|
|
4396
|
+
const filterIllegalOpRejected = {
|
|
4397
|
+
applicable: (ctx) => resolvedCaps(ctx).filterable.some((field) => firstIllegalOp(ctx, field) !== undefined),
|
|
4398
|
+
dependsOn: ["list.read-after-write", "filter.unknown-field-rejected", "error.malformed-filter-not-5xx"],
|
|
4399
|
+
id: "filter.illegal-op-rejected",
|
|
4400
|
+
needs: "a field with a closed operator list",
|
|
4401
|
+
async run(ctx) {
|
|
4402
|
+
const field = resolvedCaps(ctx).filterable.find((item) => firstIllegalOp(ctx, item) !== undefined);
|
|
4403
|
+
const op = field === undefined ? undefined : firstIllegalOp(ctx, field);
|
|
4404
|
+
if (field === undefined || op === undefined)
|
|
4405
|
+
return;
|
|
4406
|
+
const sample = ctx.records.find((row) => row[field.field] != null);
|
|
4407
|
+
const term = filterTerm(conv(ctx), field.field, op, sample === undefined ? "oat-probe" : asTermValue(sample[field.field]));
|
|
4408
|
+
if (term === null)
|
|
4409
|
+
return;
|
|
4410
|
+
const baseline = await list(ctx, q(ctx, { limit: 100 }));
|
|
4411
|
+
const result = await list(ctx, { ...q(ctx, { limit: 100 }), ...term });
|
|
4412
|
+
if (result.exchange.status >= 500) {
|
|
4413
|
+
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]);
|
|
4414
|
+
return;
|
|
4415
|
+
}
|
|
4416
|
+
if (result.exchange.status >= 400)
|
|
4417
|
+
return;
|
|
4418
|
+
const same = ids(result.items, ctx.identity).join(",") === ids(baseline.items, ctx.identity).join(",");
|
|
4419
|
+
ctx.findings.backend(this.id, ctx.entityName, "an illegal filter operator is accepted", `${field.field}.${op} returned ${result.exchange.status}` +
|
|
4420
|
+
(same ? " with the unfiltered set, so the operator was ignored" : "") +
|
|
4421
|
+
". A closed operator list is a contract: anything outside it must be rejected.", [baseline.exchange, result.exchange]);
|
|
4422
|
+
},
|
|
4423
|
+
};
|
|
4424
|
+
const filterEmptyIn = {
|
|
4425
|
+
applicable: (ctx) => resolvedCaps(ctx).emptyIn !== undefined && pickFieldForOp(ctx, "in", 1) !== null,
|
|
4426
|
+
dependsOn: [...FOUNDATIONS, "filter.in-is-union-of-eq"],
|
|
4427
|
+
id: "filter.empty-in",
|
|
4428
|
+
needs: "`emptyIn` declared and a field that allows `in`",
|
|
4429
|
+
async run(ctx) {
|
|
4430
|
+
const picked = pickFieldForOp(ctx, "in", 1);
|
|
4431
|
+
const policy = resolvedCaps(ctx).emptyIn;
|
|
4432
|
+
if (picked === null || policy === undefined)
|
|
4433
|
+
return;
|
|
4434
|
+
const term = filterTerm(conv(ctx), picked.field.field, "in", []);
|
|
4435
|
+
if (term === null)
|
|
4436
|
+
return;
|
|
4437
|
+
const baseline = await list(ctx, q(ctx, { limit: 100 }));
|
|
4438
|
+
const result = await list(ctx, { ...q(ctx, { limit: 100 }), ...term });
|
|
4439
|
+
if (policy === "reject") {
|
|
4440
|
+
if (result.exchange.status >= 400 && result.exchange.status < 500)
|
|
4441
|
+
return;
|
|
4442
|
+
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]);
|
|
4443
|
+
return;
|
|
4444
|
+
}
|
|
4445
|
+
if (result.exchange.status >= 400) {
|
|
4446
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "empty in() was rejected under match-none");
|
|
4447
|
+
}
|
|
4448
|
+
if (result.items.length === 0)
|
|
4449
|
+
return;
|
|
4450
|
+
const same = ids(result.items, ctx.identity).join(",") === ids(baseline.items, ctx.identity).join(",");
|
|
4451
|
+
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)` +
|
|
4452
|
+
(same ? " — the unfiltered set, so the filter was ignored" : "") +
|
|
4453
|
+
".", [baseline.exchange, result.exchange]);
|
|
4454
|
+
},
|
|
4455
|
+
};
|
|
4456
|
+
const filterInOverLimitRejected = {
|
|
4457
|
+
applicable: (ctx) => resolvedCaps(ctx).maxInValues !== undefined && pickFieldForOp(ctx, "in", 1) !== null,
|
|
4458
|
+
dependsOn: [...FOUNDATIONS, "error.malformed-filter-not-5xx"],
|
|
4459
|
+
id: "filter.in-over-limit-rejected",
|
|
4460
|
+
needs: "`maxInValues` declared and a field that allows `in`",
|
|
4461
|
+
async run(ctx) {
|
|
4462
|
+
const max = resolvedCaps(ctx).maxInValues;
|
|
4463
|
+
const picked = pickFieldForOp(ctx, "in", 1);
|
|
4464
|
+
if (max === undefined || picked === null)
|
|
4465
|
+
return;
|
|
4466
|
+
const members = Array.from({ length: max + 1 }, (_, i) => `oat-over-limit-${i}`);
|
|
4467
|
+
const term = filterTerm(conv(ctx), picked.field.field, "in", members);
|
|
4468
|
+
if (term === null)
|
|
4469
|
+
return;
|
|
4470
|
+
const result = await list(ctx, { ...q(ctx, { limit: 5 }), ...term });
|
|
4471
|
+
if (result.exchange.status >= 400 && result.exchange.status < 500)
|
|
4472
|
+
return;
|
|
4473
|
+
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]);
|
|
4474
|
+
},
|
|
4475
|
+
};
|
|
4476
|
+
const filterConditionCapRejected = {
|
|
4477
|
+
applicable: (ctx) => resolvedCaps(ctx).maxFilterConditions !== undefined &&
|
|
4478
|
+
conv(ctx).grammar === "postgrest" &&
|
|
4479
|
+
identityIsFilterable(ctx),
|
|
4480
|
+
dependsOn: [...FOUNDATIONS, "error.malformed-filter-not-5xx"],
|
|
4481
|
+
id: "filter.condition-cap-rejected",
|
|
4482
|
+
needs: "`maxFilterConditions` declared and a grammar that can group eq terms",
|
|
4483
|
+
async run(ctx) {
|
|
4484
|
+
const max = resolvedCaps(ctx).maxFilterConditions;
|
|
4485
|
+
const parameter = conv(ctx).filter;
|
|
4486
|
+
if (max === undefined || parameter === undefined)
|
|
4487
|
+
return;
|
|
4488
|
+
const field = filterIdentity(ctx);
|
|
4489
|
+
const terms = Array.from({ length: max + 1 }, () => `${field}.eq.oat-cap`);
|
|
4490
|
+
const result = await list(ctx, { ...q(ctx, { limit: 5 }), [parameter]: `and(${terms.join(",")})` });
|
|
4491
|
+
if (result.exchange.status >= 400 && result.exchange.status < 500)
|
|
4492
|
+
return;
|
|
4493
|
+
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]);
|
|
4494
|
+
},
|
|
4495
|
+
};
|
|
4496
|
+
function probeValueForOp(ctx, field, op) {
|
|
4497
|
+
const sample = ctx.records.find((row) => row[field.field] != null);
|
|
4498
|
+
if (op === "is")
|
|
4499
|
+
return "null";
|
|
4500
|
+
if (op === "in" || op === "nin")
|
|
4501
|
+
return [sample === undefined ? "oat-probe" : asTermValue(sample[field.field])];
|
|
4502
|
+
if (op === "contains") {
|
|
4503
|
+
const arr = sample?.[field.field];
|
|
4504
|
+
if (Array.isArray(arr) && arr[0] !== undefined)
|
|
4505
|
+
return asTermValue(arr[0]);
|
|
4506
|
+
return "oat-probe";
|
|
4507
|
+
}
|
|
4508
|
+
if (sample === undefined)
|
|
4509
|
+
return "oat-probe";
|
|
4510
|
+
return asTermValue(sample[field.field]);
|
|
4511
|
+
}
|
|
4512
|
+
const declaredFilterableOpsAccepted = {
|
|
4513
|
+
applicable: (ctx) => ctx.query?.source === "tag" && resolvedCaps(ctx).filterable.some((field) => opsAreClosed(field, resolvedCaps(ctx))),
|
|
4514
|
+
dependsOn: ["list.read-after-write", "spec.declared-filterable-is-filterable", "error.malformed-filter-not-5xx"],
|
|
4515
|
+
id: "spec.declared-filterable-ops-accepted",
|
|
4516
|
+
needs: "a closed operator list on at least one declared field",
|
|
4517
|
+
async run(ctx) {
|
|
4518
|
+
const caps = resolvedCaps(ctx);
|
|
4519
|
+
const rejected = [];
|
|
4520
|
+
for (const field of caps.filterable) {
|
|
4521
|
+
if (!opsAreClosed(field, caps))
|
|
4522
|
+
continue;
|
|
4523
|
+
for (const op of opsForField(field, caps)) {
|
|
4524
|
+
if (!canWriteFilterOp(conv(ctx), op))
|
|
4525
|
+
continue;
|
|
4526
|
+
const term = filterTerm(conv(ctx), field.field, op, probeValueForOp(ctx, field, op));
|
|
4527
|
+
if (term === null)
|
|
4528
|
+
continue;
|
|
4529
|
+
const result = await list(ctx, { ...q(ctx, { limit: 5 }), ...term });
|
|
4530
|
+
if (result.exchange.status >= 400 && result.exchange.status < 500) {
|
|
4531
|
+
rejected.push(`${field.field}.${op} (${result.exchange.status})`);
|
|
4532
|
+
}
|
|
4533
|
+
}
|
|
4534
|
+
}
|
|
4535
|
+
if (rejected.length === 0)
|
|
4536
|
+
return;
|
|
4537
|
+
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(", ")}.`, []);
|
|
4538
|
+
},
|
|
4539
|
+
};
|
|
4540
|
+
const declaredFilterableIllegalOpRejected = {
|
|
4541
|
+
applicable: (ctx) => ctx.query?.source === "tag" &&
|
|
4542
|
+
resolvedCaps(ctx).filterable.some((field) => firstIllegalOp(ctx, field) !== undefined),
|
|
4543
|
+
dependsOn: ["filter.illegal-op-rejected", "spec.declared-filterable-is-filterable"],
|
|
4544
|
+
id: "spec.declared-filterable-illegal-op-rejected",
|
|
4545
|
+
needs: "a closed operator list on a declared field",
|
|
4546
|
+
async run(ctx) {
|
|
4547
|
+
await filterIllegalOpRejected.run(ctx);
|
|
4548
|
+
},
|
|
4549
|
+
};
|
|
4550
|
+
const sortUnknownFieldRejected = {
|
|
4551
|
+
applicable: (ctx) => conv(ctx).order !== undefined,
|
|
4552
|
+
dependsOn: ["error.malformed-filter-not-5xx"],
|
|
4553
|
+
id: "sort.unknown-field-rejected",
|
|
4554
|
+
needs: "an order parameter",
|
|
4555
|
+
async run(ctx) {
|
|
4556
|
+
const result = await list(ctx, q(ctx, { limit: 5, order: sortTerm(conv(ctx), "oat_no_such_sort_xyz", "asc") }));
|
|
4557
|
+
if (result.exchange.status >= 500) {
|
|
4558
|
+
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]);
|
|
4559
|
+
return;
|
|
4560
|
+
}
|
|
4561
|
+
if (result.exchange.status >= 400)
|
|
4562
|
+
return;
|
|
4563
|
+
const baseline = await list(ctx, q(ctx, { limit: 5 }));
|
|
4564
|
+
const same = ids(result.items, ctx.identity).join(",") === ids(baseline.items, ctx.identity).join(",");
|
|
4565
|
+
ctx.findings.backend(this.id, ctx.entityName, "unknown sort field is silently ignored", `order on an undeclared field returned ${result.exchange.status}` +
|
|
4566
|
+
(same ? " with the default order, so the field was dropped" : "") +
|
|
4567
|
+
".", [baseline.exchange, result.exchange]);
|
|
4568
|
+
},
|
|
4569
|
+
};
|
|
4570
|
+
const sortNumericOrderIsNumeric = {
|
|
4571
|
+
applicable: (ctx) => conv(ctx).order !== undefined &&
|
|
4572
|
+
resolvedCaps(ctx).sortable.some((field) => numericLexicalDisagrees(ctx, field.field)),
|
|
4573
|
+
dependsOn: ["sort.order-is-applied"],
|
|
4574
|
+
id: "sort.numeric-order-is-numeric",
|
|
4575
|
+
needs: "a numeric sortable field whose lexical order disagrees with numeric order",
|
|
4576
|
+
async run(ctx) {
|
|
4577
|
+
const field = resolvedCaps(ctx).sortable.find((item) => numericLexicalDisagrees(ctx, item.field));
|
|
4578
|
+
if (field === undefined)
|
|
4579
|
+
return;
|
|
4580
|
+
const result = await list(ctx, q(ctx, { limit: ctx.query?.maxLimit ?? 100, order: sortTerm(conv(ctx), field.field, "asc") }));
|
|
4581
|
+
if (result.exchange.status >= 400)
|
|
4582
|
+
return;
|
|
4583
|
+
const numbers = result.items.map((item) => item[field.field]).filter((v) => typeof v === "number");
|
|
4584
|
+
const sorted = [...numbers].sort((a, b) => a - b);
|
|
4585
|
+
if (numbers.join(",") === sorted.join(","))
|
|
4586
|
+
return;
|
|
4587
|
+
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]);
|
|
4588
|
+
},
|
|
4589
|
+
};
|
|
4590
|
+
const sortNullsFirstLast = {
|
|
4591
|
+
applicable: (ctx) => conv(ctx).order !== undefined &&
|
|
4592
|
+
sortTermWithNulls(conv(ctx), "x", "asc", "first") !== null &&
|
|
4593
|
+
resolvedCaps(ctx).sortable.some((field) => (fieldAllowsNulls(field, resolvedCaps(ctx), "first") || fieldAllowsNulls(field, resolvedCaps(ctx), "last")) &&
|
|
4594
|
+
ctx.records.some((row) => row[field.field] === null || row[field.field] === undefined)),
|
|
4595
|
+
dependsOn: ["sort.order-is-applied", "sort.reverse-symmetry"],
|
|
4596
|
+
id: "sort.nulls-first-last",
|
|
4597
|
+
needs: "a declared nulls token, a dotted sort grammar, and a null in the cohort",
|
|
4598
|
+
async run(ctx) {
|
|
4599
|
+
const field = resolvedCaps(ctx).sortable.find((item) => (fieldAllowsNulls(item, resolvedCaps(ctx), "first") || fieldAllowsNulls(item, resolvedCaps(ctx), "last")) &&
|
|
4600
|
+
ctx.records.some((row) => row[item.field] === null || row[item.field] === undefined));
|
|
4601
|
+
if (field === undefined)
|
|
4602
|
+
return;
|
|
4603
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
4604
|
+
if (fieldAllowsNulls(field, resolvedCaps(ctx), "first")) {
|
|
4605
|
+
const clause = sortTermWithNulls(conv(ctx), field.field, "asc", "first");
|
|
4606
|
+
if (clause === null)
|
|
4607
|
+
return;
|
|
4608
|
+
const result = await collectSet(ctx, limit, {}, MAX_WALK_PAGES, clause);
|
|
4609
|
+
if (result === null)
|
|
4610
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "nullsfirst listing was rejected");
|
|
4611
|
+
const first = result.items[0];
|
|
4612
|
+
if (first !== undefined && first[field.field] !== null && first[field.field] !== undefined) {
|
|
4613
|
+
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]);
|
|
4614
|
+
return;
|
|
4615
|
+
}
|
|
4616
|
+
}
|
|
4617
|
+
if (fieldAllowsNulls(field, resolvedCaps(ctx), "last")) {
|
|
4618
|
+
const clause = sortTermWithNulls(conv(ctx), field.field, "asc", "last");
|
|
4619
|
+
if (clause === null)
|
|
4620
|
+
return;
|
|
4621
|
+
const result = await collectSet(ctx, limit, {}, MAX_WALK_PAGES, clause);
|
|
4622
|
+
if (result === null)
|
|
4623
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "nullslast listing was rejected");
|
|
4624
|
+
const last = result.items.at(-1);
|
|
4625
|
+
if (last !== undefined && last[field.field] !== null && last[field.field] !== undefined) {
|
|
4626
|
+
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]);
|
|
4627
|
+
}
|
|
4628
|
+
}
|
|
4629
|
+
},
|
|
4630
|
+
};
|
|
4631
|
+
function pickMultiKeyPair(ctx) {
|
|
4632
|
+
const fields = ctx.query?.sortable ?? [];
|
|
4633
|
+
for (const primary of fields) {
|
|
4634
|
+
for (const secondary of fields) {
|
|
4635
|
+
if (secondary === primary || secondary === ctx.identity)
|
|
4636
|
+
continue;
|
|
4637
|
+
const groups = new Map();
|
|
4638
|
+
for (const row of ctx.records) {
|
|
4639
|
+
const key = JSON.stringify(row[primary]);
|
|
4640
|
+
const group = groups.get(key) ?? [];
|
|
4641
|
+
group.push(row);
|
|
4642
|
+
groups.set(key, group);
|
|
4643
|
+
}
|
|
4644
|
+
const tied = [...groups.values()].find((group) => group.length >= 2 && new Set(group.map((row) => JSON.stringify(row[secondary]))).size > 1);
|
|
4645
|
+
if (tied !== undefined)
|
|
4646
|
+
return { primary, secondary, tied };
|
|
4647
|
+
}
|
|
4648
|
+
}
|
|
4649
|
+
return null;
|
|
4650
|
+
}
|
|
4651
|
+
const sortMultiKeyTiebreak = {
|
|
4652
|
+
applicable: (ctx) => {
|
|
4653
|
+
const max = resolvedCaps(ctx).sort?.maxKeys;
|
|
4654
|
+
return conv(ctx).order !== undefined && (max === undefined || max >= 2) && pickMultiKeyPair(ctx) !== null;
|
|
4655
|
+
},
|
|
4656
|
+
dependsOn: ["sort.order-is-applied", "sort.reverse-symmetry"],
|
|
4657
|
+
id: "sort.multi-key-tiebreak",
|
|
4658
|
+
needs: "two sortable fields and rows that tie on the first and differ on the second",
|
|
4659
|
+
async run(ctx) {
|
|
4660
|
+
const pair = pickMultiKeyPair(ctx);
|
|
4661
|
+
if (pair === null) {
|
|
4662
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "no cohort tie on the first key differs on the second");
|
|
4663
|
+
}
|
|
4664
|
+
const { primary, secondary, tied } = pair;
|
|
4665
|
+
const conventions = conv(ctx);
|
|
4666
|
+
const order = `${sortTerm(conventions, primary, "asc")},${sortTerm(conventions, secondary, "asc")}`;
|
|
4667
|
+
const result = await collectSet(ctx, ctx.query?.maxLimit ?? 100, {}, MAX_WALK_PAGES, order);
|
|
4668
|
+
if (result === null)
|
|
4669
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "multi-key order was rejected");
|
|
4670
|
+
const slice = result.items.filter((item) => JSON.stringify(item[primary]) === JSON.stringify(tied[0]?.[primary]));
|
|
4671
|
+
const seconds = slice.map((item) => item[secondary]);
|
|
4672
|
+
const sorted = [...seconds].sort(compareValues);
|
|
4673
|
+
if (JSON.stringify(seconds) === JSON.stringify(sorted))
|
|
4674
|
+
return;
|
|
4675
|
+
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]);
|
|
4676
|
+
},
|
|
4677
|
+
};
|
|
4678
|
+
const sortDefaultOrderApplied = {
|
|
4679
|
+
applicable: (ctx) => (resolvedCaps(ctx).sort?.defaultOrder ?? ctx.query?.defaultOrder) !== undefined && ctx.records.length > 1,
|
|
4680
|
+
dependsOn: ["sort.order-is-applied", "pagination.page-walk-covers-set"],
|
|
4681
|
+
id: "sort.default-order-applied",
|
|
4682
|
+
needs: "a declared defaultOrder and a complete walk",
|
|
4683
|
+
async run(ctx) {
|
|
4684
|
+
const declared = resolvedCaps(ctx).sort?.defaultOrder ?? ctx.query?.defaultOrder;
|
|
4685
|
+
if (declared === undefined)
|
|
4686
|
+
return;
|
|
4687
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
4688
|
+
const implicit = await collectSet(ctx, limit);
|
|
4689
|
+
const explicit = await collectSet(ctx, limit, {}, MAX_WALK_PAGES, declared);
|
|
4690
|
+
if (implicit === null || explicit === null) {
|
|
4691
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "default-order walk was rejected");
|
|
4692
|
+
}
|
|
4693
|
+
if (!implicit.complete || !explicit.complete) {
|
|
4694
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the walk is incomplete, so default order cannot be compared");
|
|
4695
|
+
}
|
|
4696
|
+
if (ids(implicit.items, ctx.identity).join(",") === ids(explicit.items, ctx.identity).join(","))
|
|
4697
|
+
return;
|
|
4698
|
+
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]);
|
|
4699
|
+
},
|
|
4700
|
+
};
|
|
4701
|
+
const sortStableTiebreak = {
|
|
4702
|
+
applicable: (ctx) => (resolvedCaps(ctx).sort?.stableTiebreak ?? ctx.query?.stableTiebreak) !== undefined,
|
|
4703
|
+
dependsOn: ["sort.order-is-applied"],
|
|
4704
|
+
id: "sort.stable-tiebreak",
|
|
4705
|
+
needs: "a declared stableTiebreak and a tie on the primary key",
|
|
4706
|
+
async run(ctx) {
|
|
4707
|
+
const tiebreak = resolvedCaps(ctx).sort?.stableTiebreak ?? ctx.query?.stableTiebreak;
|
|
4708
|
+
const primary = ctx.query?.sortable.find((name) => name !== tiebreak) ?? ctx.query?.sortable[0];
|
|
4709
|
+
if (tiebreak === undefined || primary === undefined)
|
|
4710
|
+
return;
|
|
4711
|
+
const order = sortTerm(conv(ctx), primary, "asc");
|
|
4712
|
+
const first = await collectSet(ctx, ctx.query?.maxLimit ?? 100, {}, MAX_WALK_PAGES, order);
|
|
4713
|
+
const second = await collectSet(ctx, ctx.query?.maxLimit ?? 100, {}, MAX_WALK_PAGES, order);
|
|
4714
|
+
if (first === null || second === null)
|
|
4715
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "repeat sort was rejected");
|
|
4716
|
+
if (ids(first.items, ctx.identity).join(",") === ids(second.items, ctx.identity).join(","))
|
|
4717
|
+
return;
|
|
4718
|
+
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]);
|
|
4719
|
+
},
|
|
4720
|
+
};
|
|
4721
|
+
const declaredSortableNullsAccepted = {
|
|
4722
|
+
applicable: (ctx) => conv(ctx).order !== undefined &&
|
|
4723
|
+
sortTermWithNulls(conv(ctx), "x", "asc", "first") !== null &&
|
|
4724
|
+
resolvedCaps(ctx).sortable.some((field) => fieldAllowsNulls(field, resolvedCaps(ctx), "first") || fieldAllowsNulls(field, resolvedCaps(ctx), "last")),
|
|
4725
|
+
dependsOn: ["sort.order-is-applied"],
|
|
4726
|
+
id: "spec.declared-sortable-nulls-accepted",
|
|
4727
|
+
needs: "a declared nulls token on a sortable field",
|
|
4728
|
+
async run(ctx) {
|
|
4729
|
+
const rejected = [];
|
|
4730
|
+
for (const field of resolvedCaps(ctx).sortable) {
|
|
4731
|
+
for (const token of ["first", "last"]) {
|
|
4732
|
+
if (!fieldAllowsNulls(field, resolvedCaps(ctx), token))
|
|
4733
|
+
continue;
|
|
4734
|
+
const clause = sortTermWithNulls(conv(ctx), field.field, "asc", token);
|
|
4735
|
+
if (clause === null)
|
|
4736
|
+
continue;
|
|
4737
|
+
const result = await list(ctx, q(ctx, { limit: 5, order: clause }));
|
|
4738
|
+
if (result.exchange.status >= 400 && result.exchange.status < 500) {
|
|
4739
|
+
rejected.push(`${clause} (${result.exchange.status})`);
|
|
4740
|
+
}
|
|
4741
|
+
}
|
|
4742
|
+
}
|
|
4743
|
+
if (rejected.length === 0)
|
|
4744
|
+
return;
|
|
4745
|
+
ctx.findings.spec(this.id, ctx.entityName, "the document declares a nulls sort token the backend rejects", `rejected: ${rejected.join(", ")}.`, []);
|
|
4746
|
+
},
|
|
4747
|
+
};
|
|
4748
|
+
const searchTokensAnd = {
|
|
4749
|
+
applicable: (ctx) => conv(ctx).search !== undefined && (ctx.query?.searchable.length ?? 0) > 0 && ctx.records.length > 2,
|
|
4750
|
+
dependsOn: ["search.q-narrows-result"],
|
|
4751
|
+
id: "search.tokens-and",
|
|
4752
|
+
needs: "searchable fields and two tokens that split the cohort",
|
|
4753
|
+
async run(ctx) {
|
|
4754
|
+
const field = ctx.query?.searchable[0];
|
|
4755
|
+
if (field === undefined)
|
|
4756
|
+
return;
|
|
4757
|
+
const tokens = [];
|
|
4758
|
+
for (const row of ctx.records) {
|
|
4759
|
+
const value = row[field];
|
|
4760
|
+
if (typeof value !== "string")
|
|
4761
|
+
continue;
|
|
4762
|
+
const word = value.split(/\s+/).find((part) => part.length >= 3);
|
|
4763
|
+
if (word !== undefined)
|
|
4764
|
+
tokens.push(word.toLowerCase());
|
|
4765
|
+
}
|
|
4766
|
+
const unique = [...new Set(tokens)];
|
|
4767
|
+
if (unique.length < 2) {
|
|
4768
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "could not find two discriminating search tokens");
|
|
4769
|
+
}
|
|
4770
|
+
const [a, b] = unique;
|
|
4771
|
+
if (a === undefined || b === undefined)
|
|
4772
|
+
return;
|
|
4773
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
4774
|
+
const onlyA = await list(ctx, q(ctx, { limit, search: a }));
|
|
4775
|
+
const onlyB = await list(ctx, q(ctx, { limit, search: b }));
|
|
4776
|
+
const both = await list(ctx, q(ctx, { limit, search: `${a} ${b}` }));
|
|
4777
|
+
if (onlyA.exchange.status >= 400 || onlyB.exchange.status >= 400 || both.exchange.status >= 400) {
|
|
4778
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "a token search was rejected");
|
|
4779
|
+
}
|
|
4780
|
+
const setA = setOf(onlyA.items, ctx.identity);
|
|
4781
|
+
const setB = setOf(onlyB.items, ctx.identity);
|
|
4782
|
+
const expected = new Set([...setA].filter((id) => setB.has(id)));
|
|
4783
|
+
const got = setOf(both.items, ctx.identity);
|
|
4784
|
+
if (sameSet(expected, got) || sameSet(got, new Set([...setA, ...setB])))
|
|
4785
|
+
return;
|
|
4786
|
+
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]);
|
|
4787
|
+
},
|
|
4788
|
+
};
|
|
4789
|
+
const searchCaseInsensitive = {
|
|
4790
|
+
applicable: (ctx) => conv(ctx).search !== undefined &&
|
|
4791
|
+
(ctx.query?.searchable.length ?? 0) > 0 &&
|
|
4792
|
+
ctx.records.some((row) => ctx.query?.searchable.some((field) => typeof row[field] === "string" && /[A-Za-z]/.test(row[field]))),
|
|
4793
|
+
dependsOn: ["search.q-narrows-result"],
|
|
4794
|
+
id: "search.case-insensitive",
|
|
4795
|
+
needs: "a searchable string with a letter",
|
|
4796
|
+
async run(ctx) {
|
|
4797
|
+
let sample;
|
|
4798
|
+
for (const field of ctx.query?.searchable ?? []) {
|
|
4799
|
+
const value = ctx.records
|
|
4800
|
+
.map((row) => row[field])
|
|
4801
|
+
.find((item) => typeof item === "string" && /[A-Za-z]/.test(item));
|
|
4802
|
+
if (value !== undefined) {
|
|
4803
|
+
sample = value;
|
|
4804
|
+
break;
|
|
4805
|
+
}
|
|
4806
|
+
}
|
|
4807
|
+
if (sample === undefined)
|
|
4808
|
+
return;
|
|
4809
|
+
const token = sample.split(/\s+/).find((part) => /[A-Za-z]/.test(part)) ?? sample;
|
|
4810
|
+
const flipped = token.replace(/[A-Za-z]/, (ch) => (ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase()));
|
|
4811
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
4812
|
+
const original = await list(ctx, q(ctx, { limit, search: token }));
|
|
4813
|
+
const other = await list(ctx, q(ctx, { limit, search: flipped }));
|
|
4814
|
+
if (original.exchange.status >= 400 || other.exchange.status >= 400)
|
|
4815
|
+
return;
|
|
4816
|
+
const a = setOf(original.items, ctx.identity);
|
|
4817
|
+
const b = setOf(other.items, ctx.identity);
|
|
4818
|
+
if (sameSet(a, b))
|
|
4819
|
+
return;
|
|
4820
|
+
if (resolvedCaps(ctx).searchCase === "insensitive") {
|
|
4821
|
+
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]);
|
|
4822
|
+
return;
|
|
4823
|
+
}
|
|
4824
|
+
ctx.findings.unresolved(this.id, ctx.entityName, "case-flipped search selected a different set; declare searchCase: insensitive to treat that as a defect");
|
|
4825
|
+
},
|
|
4826
|
+
};
|
|
4827
|
+
const searchEmptyQ = {
|
|
4828
|
+
applicable: (ctx) => conv(ctx).search !== undefined && resolvedCaps(ctx).searchEmpty !== undefined,
|
|
4829
|
+
dependsOn: ["search.q-narrows-result"],
|
|
4830
|
+
id: "search.empty-q",
|
|
4831
|
+
needs: "`searchEmpty` declared",
|
|
4832
|
+
async run(ctx) {
|
|
4833
|
+
const policy = resolvedCaps(ctx).searchEmpty;
|
|
4834
|
+
if (policy === undefined)
|
|
4835
|
+
return;
|
|
4836
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
4837
|
+
const baseline = await list(ctx, q(ctx, { limit }));
|
|
4838
|
+
const empty = await list(ctx, q(ctx, { limit, search: "" }));
|
|
4839
|
+
if (policy === "reject") {
|
|
4840
|
+
if (empty.exchange.status >= 400 && empty.exchange.status < 500)
|
|
4841
|
+
return;
|
|
4842
|
+
ctx.findings.backend(this.id, ctx.entityName, "empty q was not rejected", `searchEmpty=reject but q= returned ${empty.exchange.status}.`, [empty.exchange]);
|
|
4843
|
+
return;
|
|
4844
|
+
}
|
|
4845
|
+
if (empty.exchange.status >= 400) {
|
|
4846
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "empty q was rejected");
|
|
4847
|
+
}
|
|
4848
|
+
if (sameSet(setOf(empty.items, ctx.identity), setOf(baseline.items, ctx.identity)))
|
|
4849
|
+
return;
|
|
4850
|
+
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]);
|
|
4851
|
+
},
|
|
4852
|
+
};
|
|
4853
|
+
const searchUndeclaredFieldNotRequired = {
|
|
4854
|
+
applicable: (ctx) => {
|
|
4855
|
+
if (conv(ctx).search === undefined || (ctx.query?.searchable.length ?? 0) === 0)
|
|
4856
|
+
return false;
|
|
4857
|
+
const searchable = new Set(ctx.query?.searchable ?? []);
|
|
4858
|
+
return ctx.records.some((row) => Object.entries(row).some(([key, value]) => !searchable.has(key) && typeof value === "string" && value.length >= 4 && !searchableHasToken(ctx, value)));
|
|
4859
|
+
},
|
|
4860
|
+
dependsOn: ["search.q-narrows-result"],
|
|
4861
|
+
id: "search.undeclared-field-not-required",
|
|
4862
|
+
needs: "a searchable field and a non-searchable string",
|
|
4863
|
+
async run() {
|
|
4864
|
+
/* Extra recall is not a defect. This check exists so undeclared-field hits are not
|
|
4865
|
+
* reported as SEARCH_IGNORED by other checks. */
|
|
4866
|
+
},
|
|
4867
|
+
};
|
|
4868
|
+
function searchableHasToken(ctx, token) {
|
|
4869
|
+
const needle = token.toLowerCase();
|
|
4870
|
+
return ctx.records.some((row) => (ctx.query?.searchable ?? []).some((field) => String(row[field] ?? "")
|
|
4871
|
+
.toLowerCase()
|
|
4872
|
+
.includes(needle)));
|
|
4873
|
+
}
|
|
4874
|
+
const searchModeAccepted = {
|
|
4875
|
+
applicable: (ctx) => conv(ctx).searchMode !== undefined &&
|
|
4876
|
+
(resolvedCaps(ctx).searchModes?.length ?? 0) > 0 &&
|
|
4877
|
+
conv(ctx).search !== undefined,
|
|
4878
|
+
id: "search.mode-accepted",
|
|
4879
|
+
needs: "declared searchModes and a search-mode parameter",
|
|
4880
|
+
async run(ctx) {
|
|
4881
|
+
const rejected = [];
|
|
4882
|
+
for (const mode of resolvedCaps(ctx).searchModes ?? []) {
|
|
4883
|
+
const result = await list(ctx, q(ctx, { limit: 5, search: "oat", searchMode: mode }));
|
|
4884
|
+
if (result.exchange.status >= 400 && result.exchange.status < 500)
|
|
4885
|
+
rejected.push(`${mode} (${result.exchange.status})`);
|
|
4886
|
+
}
|
|
4887
|
+
if (rejected.length === 0)
|
|
4888
|
+
return;
|
|
4889
|
+
ctx.findings.spec(this.id, ctx.entityName, "a declared search mode is rejected", `rejected: ${rejected.join(", ")}.`, []);
|
|
4890
|
+
},
|
|
4891
|
+
};
|
|
4892
|
+
const searchModesDiffer = {
|
|
4893
|
+
applicable: (ctx) => conv(ctx).searchMode !== undefined &&
|
|
4894
|
+
(resolvedCaps(ctx).searchModes?.length ?? 0) >= 2 &&
|
|
4895
|
+
conv(ctx).search !== undefined,
|
|
4896
|
+
dependsOn: ["search.mode-accepted"],
|
|
4897
|
+
id: "search.modes-differ",
|
|
4898
|
+
needs: "at least two declared searchModes and a mode parameter",
|
|
4899
|
+
async run(ctx) {
|
|
4900
|
+
const modes = resolvedCaps(ctx).searchModes ?? [];
|
|
4901
|
+
const a = modes[0];
|
|
4902
|
+
const b = modes[1];
|
|
4903
|
+
if (a === undefined || b === undefined)
|
|
4904
|
+
return;
|
|
4905
|
+
const field = ctx.query?.searchable[0];
|
|
4906
|
+
const token = field === undefined
|
|
4907
|
+
? "oat"
|
|
4908
|
+
: ctx.records
|
|
4909
|
+
.map((row) => row[field])
|
|
4910
|
+
.find((value) => typeof value === "string" && value.length > 2);
|
|
4911
|
+
if (token === undefined)
|
|
4912
|
+
return;
|
|
4913
|
+
const left = await list(ctx, q(ctx, { limit: 100, search: token, searchMode: a }));
|
|
4914
|
+
const right = await list(ctx, q(ctx, { limit: 100, search: token, searchMode: b }));
|
|
4915
|
+
if (left.exchange.status >= 400 || right.exchange.status >= 400)
|
|
4916
|
+
return;
|
|
4917
|
+
if (ids(left.items, ctx.identity).join(",") !== ids(right.items, ctx.identity).join(","))
|
|
4918
|
+
return;
|
|
4919
|
+
ctx.findings.unresolved(this.id, ctx.entityName, `modes ${a} and ${b} returned the same set; cannot prove they are distinct implementations`);
|
|
4920
|
+
},
|
|
4921
|
+
};
|
|
4922
|
+
const selectRequestedFieldsPresent = {
|
|
4923
|
+
applicable: (ctx) => conv(ctx).select !== undefined && (ctx.query?.selectable.length ?? 0) > 0,
|
|
4924
|
+
dependsOn: ["select.projection-honoured"],
|
|
4925
|
+
id: "select.requested-fields-present",
|
|
4926
|
+
needs: "a select parameter and at least one selectable field",
|
|
4927
|
+
async run(ctx) {
|
|
4928
|
+
const requested = [
|
|
4929
|
+
ctx.identity,
|
|
4930
|
+
...(ctx.query?.selectable.filter((name) => name !== ctx.identity).slice(0, 2) ?? []),
|
|
4931
|
+
];
|
|
4932
|
+
const projection = selectTerm(conv(ctx), requested, ctx.entityName);
|
|
4933
|
+
if (projection === null)
|
|
4934
|
+
return;
|
|
4935
|
+
const result = await list(ctx, { ...q(ctx, { limit: 5 }), ...projection });
|
|
4936
|
+
if (result.exchange.status >= 400 || result.items[0] === undefined) {
|
|
4937
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "select probe was rejected or empty");
|
|
4938
|
+
}
|
|
4939
|
+
const missing = requested.filter((name) => !Object.hasOwn(result.items[0], name));
|
|
4940
|
+
if (missing.length === 0)
|
|
4941
|
+
return;
|
|
4942
|
+
if (missing.length === 1 &&
|
|
4943
|
+
missing[0] === ctx.identity &&
|
|
4944
|
+
!(ctx.query?.selectable.includes(ctx.identity) ?? false)) {
|
|
4945
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "identity was omitted from select and missing from items");
|
|
4946
|
+
}
|
|
4947
|
+
ctx.findings.backend(this.id, ctx.entityName, "a requested select field is missing from the projection", `select=${requested.join(",")} omitted ${missing.join(", ")}.`, [result.exchange]);
|
|
4948
|
+
},
|
|
4949
|
+
};
|
|
4950
|
+
const selectUnknownFieldRejected = {
|
|
4951
|
+
applicable: (ctx) => conv(ctx).select !== undefined && resolvedCaps(ctx).select?.unknown !== undefined,
|
|
4952
|
+
dependsOn: ["select.projection-honoured", "error.malformed-filter-not-5xx"],
|
|
4953
|
+
id: "select.unknown-field-rejected",
|
|
4954
|
+
needs: "`select.unknown` declared",
|
|
4955
|
+
async run(ctx) {
|
|
4956
|
+
const policy = resolvedCaps(ctx).select?.unknown;
|
|
4957
|
+
if (policy === undefined)
|
|
4958
|
+
return;
|
|
4959
|
+
const requested = [ctx.identity, "oat_no_such_select_xyz"];
|
|
4960
|
+
const projection = selectTerm(conv(ctx), requested, ctx.entityName);
|
|
4961
|
+
if (projection === null)
|
|
4962
|
+
return;
|
|
4963
|
+
const result = await list(ctx, { ...q(ctx, { limit: 5 }), ...projection });
|
|
4964
|
+
if (policy === "reject") {
|
|
4965
|
+
if (result.exchange.status >= 400 && result.exchange.status < 500)
|
|
4966
|
+
return;
|
|
4967
|
+
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]);
|
|
4968
|
+
return;
|
|
4969
|
+
}
|
|
4970
|
+
if (result.exchange.status >= 400) {
|
|
4971
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "unknown select field was rejected under ignore");
|
|
4972
|
+
}
|
|
4973
|
+
const extras = Object.keys(result.items[0] ?? {}).filter((key) => key === "oat_no_such_select_xyz");
|
|
4974
|
+
if (extras.length === 0)
|
|
4975
|
+
return;
|
|
4976
|
+
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]);
|
|
4977
|
+
},
|
|
4978
|
+
};
|
|
4979
|
+
const selectNestedHonoured = {
|
|
4980
|
+
applicable: (ctx) => conv(ctx).select !== undefined &&
|
|
4981
|
+
resolvedCaps(ctx).select?.nested === true &&
|
|
4982
|
+
(resolvedCaps(ctx).select?.relations?.length ?? 0) > 0,
|
|
4983
|
+
dependsOn: ["select.projection-honoured"],
|
|
4984
|
+
id: "select.nested-honoured",
|
|
4985
|
+
needs: "select.nested and a named relation",
|
|
4986
|
+
async run(ctx) {
|
|
4987
|
+
const relation = resolvedCaps(ctx).select?.relations?.[0];
|
|
4988
|
+
if (relation === undefined || relation.fields[0] === undefined)
|
|
4989
|
+
return;
|
|
4990
|
+
const clause = `${relation.name}(${relation.fields[0]})`;
|
|
4991
|
+
const projection = selectTerm(conv(ctx), [ctx.identity, clause], ctx.entityName);
|
|
4992
|
+
if (projection === null)
|
|
4993
|
+
return;
|
|
4994
|
+
const result = await list(ctx, { ...q(ctx, { limit: 5 }), ...projection });
|
|
4995
|
+
if (result.exchange.status >= 400 || result.items[0] === undefined) {
|
|
4996
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "nested select was rejected or empty");
|
|
4997
|
+
}
|
|
4998
|
+
const nested = result.items[0][relation.name];
|
|
4999
|
+
if (nested === null || typeof nested !== "object") {
|
|
5000
|
+
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]);
|
|
5001
|
+
return;
|
|
5002
|
+
}
|
|
5003
|
+
const keys = Object.keys(nested);
|
|
5004
|
+
if (keys.every((key) => key === relation.fields[0]))
|
|
5005
|
+
return;
|
|
5006
|
+
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]);
|
|
5007
|
+
},
|
|
5008
|
+
};
|
|
5009
|
+
const querySortAndSelectCompose = {
|
|
5010
|
+
applicable: (ctx) => conv(ctx).order !== undefined &&
|
|
5011
|
+
conv(ctx).select !== undefined &&
|
|
5012
|
+
(ctx.query?.sortable.length ?? 0) > 0 &&
|
|
5013
|
+
(ctx.query?.selectable.length ?? 0) > 0 &&
|
|
5014
|
+
ctx.records.length > 1,
|
|
5015
|
+
dependsOn: ["sort.order-is-applied", "select.projection-honoured", "select.requested-fields-present"],
|
|
5016
|
+
id: "query.sort-and-select-compose",
|
|
5017
|
+
needs: "sortable and selectable fields",
|
|
5018
|
+
async run(ctx) {
|
|
5019
|
+
const field = ctx.query?.sortable.find((name) => name !== ctx.identity) ?? ctx.query?.sortable[0];
|
|
5020
|
+
const extra = ctx.query?.selectable.find((name) => name !== ctx.identity);
|
|
5021
|
+
if (field === undefined)
|
|
5022
|
+
return;
|
|
5023
|
+
const requested = extra === undefined ? [ctx.identity] : [ctx.identity, extra];
|
|
5024
|
+
const projection = selectTerm(conv(ctx), requested, ctx.entityName);
|
|
5025
|
+
if (projection === null)
|
|
5026
|
+
return;
|
|
5027
|
+
const result = await list(ctx, {
|
|
5028
|
+
...q(ctx, { limit: 20, order: sortTerm(conv(ctx), field, "asc") }),
|
|
5029
|
+
...projection,
|
|
5030
|
+
});
|
|
5031
|
+
if (result.exchange.status >= 400 || result.items.length < 2) {
|
|
5032
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "sort+select probe was rejected");
|
|
5033
|
+
}
|
|
5034
|
+
const extras = Object.keys(result.items[0] ?? {}).filter((key) => !requested.includes(key));
|
|
5035
|
+
const values = result.items.map((item) => item[field]);
|
|
5036
|
+
const sorted = [...values].sort(compareValues);
|
|
5037
|
+
if (extras.length === 0 && JSON.stringify(values) === JSON.stringify(sorted))
|
|
5038
|
+
return;
|
|
5039
|
+
ctx.findings.backend(this.id, ctx.entityName, "sort and select do not compose", extras.length > 0
|
|
5040
|
+
? `select leaked ${extras.slice(0, 5).join(", ")}.`
|
|
5041
|
+
: `order=${field}.asc did not hold under the projection.`, [result.exchange]);
|
|
5042
|
+
},
|
|
5043
|
+
};
|
|
5044
|
+
const querySearchAndSelectCompose = {
|
|
5045
|
+
applicable: (ctx) => conv(ctx).search !== undefined &&
|
|
5046
|
+
conv(ctx).select !== undefined &&
|
|
5047
|
+
(ctx.query?.searchable.length ?? 0) > 0 &&
|
|
5048
|
+
(ctx.query?.selectable.length ?? 0) > 0,
|
|
5049
|
+
dependsOn: ["search.q-narrows-result", "select.projection-honoured"],
|
|
5050
|
+
id: "query.search-and-select-compose",
|
|
5051
|
+
needs: "searchable and selectable fields",
|
|
5052
|
+
async run(ctx) {
|
|
5053
|
+
const field = ctx.query?.searchable[0];
|
|
5054
|
+
if (field === undefined)
|
|
5055
|
+
return;
|
|
5056
|
+
const token = ctx.records
|
|
5057
|
+
.map((row) => row[field])
|
|
5058
|
+
.find((value) => typeof value === "string" && value.length > 2);
|
|
5059
|
+
if (token === undefined)
|
|
5060
|
+
return;
|
|
5061
|
+
const requested = [ctx.identity, field];
|
|
5062
|
+
const projection = selectTerm(conv(ctx), requested, ctx.entityName);
|
|
5063
|
+
if (projection === null)
|
|
5064
|
+
return;
|
|
5065
|
+
const searched = await list(ctx, q(ctx, { limit: 100, search: token }));
|
|
5066
|
+
const combined = await list(ctx, { ...q(ctx, { limit: 100, search: token }), ...projection });
|
|
5067
|
+
if (searched.exchange.status >= 400 || combined.exchange.status >= 400) {
|
|
5068
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "search+select probe was rejected");
|
|
5069
|
+
}
|
|
5070
|
+
if (combined.items.some((item) => item[ctx.identity] === undefined)) {
|
|
5071
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "projection omitted the identity");
|
|
5072
|
+
}
|
|
5073
|
+
if (sameSet(setOf(searched.items, ctx.identity), setOf(combined.items, ctx.identity)))
|
|
5074
|
+
return;
|
|
5075
|
+
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]);
|
|
5076
|
+
},
|
|
5077
|
+
};
|
|
5078
|
+
const querySearchAndSortCompose = {
|
|
5079
|
+
applicable: (ctx) => conv(ctx).search !== undefined &&
|
|
5080
|
+
conv(ctx).order !== undefined &&
|
|
5081
|
+
(ctx.query?.searchable.length ?? 0) > 0 &&
|
|
5082
|
+
(ctx.query?.sortable.length ?? 0) > 0,
|
|
5083
|
+
dependsOn: ["search.q-narrows-result", "sort.order-is-applied"],
|
|
5084
|
+
id: "query.search-and-sort-compose",
|
|
5085
|
+
needs: "searchable and sortable fields",
|
|
5086
|
+
async run(ctx) {
|
|
5087
|
+
const searchField = ctx.query?.searchable[0];
|
|
5088
|
+
const sortField = ctx.query?.sortable.find((name) => name !== ctx.identity) ?? ctx.query?.sortable[0];
|
|
5089
|
+
if (searchField === undefined || sortField === undefined)
|
|
5090
|
+
return;
|
|
5091
|
+
const token = ctx.records
|
|
5092
|
+
.map((row) => row[searchField])
|
|
5093
|
+
.find((value) => typeof value === "string" && value.length > 2);
|
|
5094
|
+
if (token === undefined)
|
|
5095
|
+
return;
|
|
5096
|
+
const searched = await list(ctx, q(ctx, { limit: 100, search: token }));
|
|
5097
|
+
const combined = await list(ctx, q(ctx, { limit: 100, search: token, order: sortTerm(conv(ctx), sortField, "asc") }));
|
|
5098
|
+
if (searched.exchange.status >= 400 || combined.exchange.status >= 400) {
|
|
5099
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "search+sort probe was rejected");
|
|
5100
|
+
}
|
|
5101
|
+
if (!sameSet(setOf(searched.items, ctx.identity), setOf(combined.items, ctx.identity))) {
|
|
5102
|
+
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]);
|
|
5103
|
+
return;
|
|
5104
|
+
}
|
|
5105
|
+
const values = combined.items.map((item) => item[sortField]);
|
|
5106
|
+
const sorted = [...values].sort(compareValues);
|
|
5107
|
+
if (JSON.stringify(values) === JSON.stringify(sorted))
|
|
5108
|
+
return;
|
|
5109
|
+
ctx.findings.backend(this.id, ctx.entityName, "search results are not ordered", `q + order=${sortField}.asc did not keep ${sortField} ascending.`, [combined.exchange]);
|
|
5110
|
+
},
|
|
5111
|
+
};
|
|
5112
|
+
const queryFilterSearchSortSelectCompose = {
|
|
5113
|
+
applicable: (ctx) => filterable(ctx) &&
|
|
5114
|
+
conv(ctx).search !== undefined &&
|
|
5115
|
+
conv(ctx).order !== undefined &&
|
|
5116
|
+
conv(ctx).select !== undefined &&
|
|
5117
|
+
(ctx.query?.searchable.length ?? 0) > 0 &&
|
|
5118
|
+
(ctx.query?.sortable.length ?? 0) > 0 &&
|
|
5119
|
+
(ctx.query?.selectable.length ?? 0) > 0 &&
|
|
5120
|
+
ctx.records.length > 2,
|
|
5121
|
+
dependsOn: [
|
|
5122
|
+
"query.search-and-filter-compose",
|
|
5123
|
+
"query.axes-compose",
|
|
5124
|
+
"query.filter-and-select-compose",
|
|
5125
|
+
"query.sort-and-select-compose",
|
|
5126
|
+
"query.filter-sort-select-compose",
|
|
5127
|
+
"query.filter-search-sort-compose",
|
|
5128
|
+
"query.filter-search-select-compose",
|
|
5129
|
+
"pagination.page-walk-covers-set",
|
|
5130
|
+
],
|
|
5131
|
+
id: "query.filter-search-sort-select-compose",
|
|
5132
|
+
needs: "all four list axes declared and at least three records",
|
|
5133
|
+
async run(ctx) {
|
|
5134
|
+
const picked = overlappingFilterAndSearch(ctx);
|
|
5135
|
+
if (picked === null) {
|
|
5136
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "no overlapping filter/search token");
|
|
5137
|
+
}
|
|
5138
|
+
const sortField = ctx.query?.sortable.find((name) => name !== ctx.identity) ?? ctx.query?.sortable[0];
|
|
5139
|
+
const extra = ctx.query?.selectable.find((name) => name !== ctx.identity);
|
|
5140
|
+
if (sortField === undefined)
|
|
5141
|
+
return;
|
|
5142
|
+
const conventions = conv(ctx);
|
|
5143
|
+
const term = filterTerm(conventions, picked.field, "eq", String(picked.target[picked.field]));
|
|
5144
|
+
if (term === null)
|
|
5145
|
+
return;
|
|
5146
|
+
const requested = extra === undefined ? [ctx.identity] : [ctx.identity, extra];
|
|
5147
|
+
const projection = selectTerm(conventions, requested, ctx.entityName);
|
|
5148
|
+
if (projection === null)
|
|
5149
|
+
return;
|
|
5150
|
+
const base = await collectSet(ctx, ctx.query?.maxLimit ?? 100, {
|
|
5151
|
+
...term,
|
|
5152
|
+
...(conventions.search === undefined ? {} : { [conventions.search]: picked.token }),
|
|
5153
|
+
});
|
|
5154
|
+
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"));
|
|
5155
|
+
if (base === null || combined === null) {
|
|
5156
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "four-axis probe was rejected");
|
|
5157
|
+
}
|
|
5158
|
+
if (combined.items.some((item) => item[ctx.identity] === undefined)) {
|
|
5159
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "projection omitted the identity");
|
|
5160
|
+
}
|
|
5161
|
+
if (sameSet(setOf(base.items, ctx.identity), setOf(combined.items, ctx.identity)))
|
|
5162
|
+
return;
|
|
5163
|
+
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]);
|
|
5164
|
+
},
|
|
5165
|
+
};
|
|
3860
5166
|
export const CHECKS = [
|
|
3861
5167
|
/* foundations — did the write land, is it visible, and do the paging primitives work at all.
|
|
3862
5168
|
* Everything below assumes these hold, so they must be evaluated first for cascade
|
|
@@ -3874,23 +5180,47 @@ export const CHECKS = [
|
|
|
3874
5180
|
* compare. Establishing paging first is what lets those failures be suppressed as cascades
|
|
3875
5181
|
* rather than re-reported once per predicate. */
|
|
3876
5182
|
orderChangesResult,
|
|
5183
|
+
sortNumericOrderIsNumeric,
|
|
5184
|
+
sortNullsFirstLast,
|
|
5185
|
+
sortMultiKeyTiebreak,
|
|
5186
|
+
sortDefaultOrderApplied,
|
|
5187
|
+
sortStableTiebreak,
|
|
3877
5188
|
pageWalkCoversSet,
|
|
3878
5189
|
cursorAgreesWithPage,
|
|
3879
5190
|
/* query semantics */
|
|
3880
5191
|
unknownFilterRejected,
|
|
3881
5192
|
malformedFilterNot5xx,
|
|
5193
|
+
sortUnknownFieldRejected,
|
|
3882
5194
|
equalityFilterSelectsOne,
|
|
3883
5195
|
zeroMatchFilter,
|
|
3884
5196
|
negationPartitions,
|
|
3885
5197
|
filterAndComposesAsIntersection,
|
|
3886
5198
|
filterOrComposesAsUnion,
|
|
5199
|
+
filterInIsUnionOfEq,
|
|
5200
|
+
filterNinComplementsIn,
|
|
5201
|
+
filterNestedAndOrDistributes,
|
|
3887
5202
|
likeEscaping,
|
|
5203
|
+
filterIlikeIsCaseInsensitive,
|
|
5204
|
+
filterIsNullSelectsNulls,
|
|
5205
|
+
filterContainsMembership,
|
|
3888
5206
|
sortReverseSymmetry,
|
|
3889
5207
|
searchNarrowsResult,
|
|
5208
|
+
searchTokensAnd,
|
|
5209
|
+
searchCaseInsensitive,
|
|
5210
|
+
searchEmptyQ,
|
|
5211
|
+
searchUndeclaredFieldNotRequired,
|
|
5212
|
+
searchModeAccepted,
|
|
5213
|
+
searchModesDiffer,
|
|
3890
5214
|
selectProjection,
|
|
5215
|
+
selectRequestedFieldsPresent,
|
|
5216
|
+
selectUnknownFieldRejected,
|
|
5217
|
+
selectNestedHonoured,
|
|
3891
5218
|
countIsConsistentWithPage,
|
|
3892
5219
|
countMatchesWalk,
|
|
3893
5220
|
numericComparisonIsNumeric,
|
|
5221
|
+
filterGteIsGtOrEq,
|
|
5222
|
+
filterLteIsLtOrEq,
|
|
5223
|
+
filterOrderedTriplePartitions,
|
|
3894
5224
|
/* write semantics */
|
|
3895
5225
|
patchMinimality,
|
|
3896
5226
|
immutableRejected,
|
|
@@ -3905,9 +5235,21 @@ export const CHECKS = [
|
|
|
3905
5235
|
filterSortSelectCompose,
|
|
3906
5236
|
filterSearchSortCompose,
|
|
3907
5237
|
filterSearchSelectCompose,
|
|
5238
|
+
querySortAndSelectCompose,
|
|
5239
|
+
querySearchAndSelectCompose,
|
|
5240
|
+
querySearchAndSortCompose,
|
|
5241
|
+
queryFilterSearchSortSelectCompose,
|
|
3908
5242
|
declaredFilterableWorks,
|
|
5243
|
+
declaredFilterableOpsAccepted,
|
|
5244
|
+
declaredFilterableIllegalOpRejected,
|
|
3909
5245
|
declaredSortableWorks,
|
|
5246
|
+
declaredSortableNullsAccepted,
|
|
3910
5247
|
declaredSelectableWorks,
|
|
5248
|
+
filterAliasMatchesCanonical,
|
|
5249
|
+
filterIllegalOpRejected,
|
|
5250
|
+
filterEmptyIn,
|
|
5251
|
+
filterInOverLimitRejected,
|
|
5252
|
+
filterConditionCapRejected,
|
|
3911
5253
|
noLostUpdate,
|
|
3912
5254
|
deleteMissingIs404,
|
|
3913
5255
|
softDeleteHidden,
|