@lovrozagar/oat 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +401 -0
- package/bin/oat.js +2 -0
- package/dist/cli.js +386 -0
- package/dist/config/define-config.d.ts +165 -0
- package/dist/config/define-config.d.ts.map +1 -0
- package/dist/config/define-config.js +24 -0
- package/dist/config/define-config.js.map +1 -0
- package/dist/config/load.d.ts +10 -0
- package/dist/config/load.d.ts.map +1 -0
- package/dist/config/load.js +61 -0
- package/dist/config/load.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +9 -0
- package/dist/reference/defects.d.ts +254 -0
- package/dist/reference/defects.d.ts.map +1 -0
- package/dist/reference/defects.js +265 -0
- package/dist/reference/defects.js.map +1 -0
- package/dist/reference/dialect.d.ts +104 -0
- package/dist/reference/dialect.d.ts.map +1 -0
- package/dist/reference/dialect.js +174 -0
- package/dist/reference/dialect.js.map +1 -0
- package/dist/reference/http.d.ts +79 -0
- package/dist/reference/http.d.ts.map +1 -0
- package/dist/reference/http.js +889 -0
- package/dist/reference/http.js.map +1 -0
- package/dist/reference/model.d.ts +56 -0
- package/dist/reference/model.d.ts.map +1 -0
- package/dist/reference/model.js +156 -0
- package/dist/reference/model.js.map +1 -0
- package/dist/reference/query.d.ts +45 -0
- package/dist/reference/query.d.ts.map +1 -0
- package/dist/reference/query.js +353 -0
- package/dist/reference/query.js.map +1 -0
- package/dist/reference/spec.d.ts +13 -0
- package/dist/reference/spec.d.ts.map +1 -0
- package/dist/reference/spec.js +547 -0
- package/dist/reference/spec.js.map +1 -0
- package/dist/reference/store-api.d.ts +59 -0
- package/dist/reference/store-api.d.ts.map +1 -0
- package/dist/reference/store-api.js +76 -0
- package/dist/reference/store-api.js.map +1 -0
- package/dist/reference/stores/memory.d.ts +27 -0
- package/dist/reference/stores/memory.d.ts.map +1 -0
- package/dist/reference/stores/memory.js +97 -0
- package/dist/reference/stores/memory.js.map +1 -0
- package/dist/reference/stores/postgres.d.ts +61 -0
- package/dist/reference/stores/postgres.d.ts.map +1 -0
- package/dist/reference/stores/postgres.js +406 -0
- package/dist/reference/stores/postgres.js.map +1 -0
- package/dist/reference/stores/sqlite-driver.d.ts +54 -0
- package/dist/reference/stores/sqlite-driver.d.ts.map +1 -0
- package/dist/reference/stores/sqlite-driver.js +99 -0
- package/dist/reference/stores/sqlite-driver.js.map +1 -0
- package/dist/reference/stores/sqlite.d.ts +68 -0
- package/dist/reference/stores/sqlite.d.ts.map +1 -0
- package/dist/reference/stores/sqlite.js +464 -0
- package/dist/reference/stores/sqlite.js.map +1 -0
- package/dist/report/console.d.ts +33 -0
- package/dist/report/console.d.ts.map +1 -0
- package/dist/report/console.js +237 -0
- package/dist/report/console.js.map +1 -0
- package/dist/report/matrix.d.ts +131 -0
- package/dist/report/matrix.d.ts.map +1 -0
- package/dist/report/matrix.js +865 -0
- package/dist/report/matrix.js.map +1 -0
- package/dist/report/render.d.ts +66 -0
- package/dist/report/render.d.ts.map +1 -0
- package/dist/report/render.js +423 -0
- package/dist/report/render.js.map +1 -0
- package/dist/runtime/async.d.ts +34 -0
- package/dist/runtime/async.d.ts.map +1 -0
- package/dist/runtime/async.js +130 -0
- package/dist/runtime/async.js.map +1 -0
- package/dist/runtime/auth.d.ts +46 -0
- package/dist/runtime/auth.d.ts.map +1 -0
- package/dist/runtime/auth.js +236 -0
- package/dist/runtime/auth.js.map +1 -0
- package/dist/runtime/checks.d.ts +89 -0
- package/dist/runtime/checks.d.ts.map +1 -0
- package/dist/runtime/checks.js +3400 -0
- package/dist/runtime/checks.js.map +1 -0
- package/dist/runtime/client.d.ts +65 -0
- package/dist/runtime/client.d.ts.map +1 -0
- package/dist/runtime/client.js +122 -0
- package/dist/runtime/client.js.map +1 -0
- package/dist/runtime/finding.d.ts +50 -0
- package/dist/runtime/finding.d.ts.map +1 -0
- package/dist/runtime/finding.js +50 -0
- package/dist/runtime/finding.js.map +1 -0
- package/dist/runtime/fixture.d.ts +20 -0
- package/dist/runtime/fixture.d.ts.map +1 -0
- package/dist/runtime/fixture.js +175 -0
- package/dist/runtime/fixture.js.map +1 -0
- package/dist/runtime/progress.d.ts +41 -0
- package/dist/runtime/progress.d.ts.map +1 -0
- package/dist/runtime/progress.js +186 -0
- package/dist/runtime/progress.js.map +1 -0
- package/dist/runtime/run.d.ts +69 -0
- package/dist/runtime/run.d.ts.map +1 -0
- package/dist/runtime/run.js +575 -0
- package/dist/runtime/run.js.map +1 -0
- package/dist/runtime/teardown.d.ts +38 -0
- package/dist/runtime/teardown.d.ts.map +1 -0
- package/dist/runtime/teardown.js +129 -0
- package/dist/runtime/teardown.js.map +1 -0
- package/dist/runtime/validate.d.ts +31 -0
- package/dist/runtime/validate.d.ts.map +1 -0
- package/dist/runtime/validate.js +111 -0
- package/dist/runtime/validate.js.map +1 -0
- package/dist/runtime/world.d.ts +49 -0
- package/dist/runtime/world.d.ts.map +1 -0
- package/dist/runtime/world.js +119 -0
- package/dist/runtime/world.js.map +1 -0
- package/dist/spec/collection.d.ts +31 -0
- package/dist/spec/collection.d.ts.map +1 -0
- package/dist/spec/collection.js +121 -0
- package/dist/spec/collection.js.map +1 -0
- package/dist/spec/conventions.d.ts +103 -0
- package/dist/spec/conventions.d.ts.map +1 -0
- package/dist/spec/conventions.js +275 -0
- package/dist/spec/conventions.js.map +1 -0
- package/dist/spec/extensions.d.ts +74 -0
- package/dist/spec/extensions.d.ts.map +1 -0
- package/dist/spec/extensions.js +335 -0
- package/dist/spec/extensions.js.map +1 -0
- package/dist/spec/graph.d.ts +89 -0
- package/dist/spec/graph.d.ts.map +1 -0
- package/dist/spec/graph.js +318 -0
- package/dist/spec/graph.js.map +1 -0
- package/dist/spec/load.d.ts +36 -0
- package/dist/spec/load.d.ts.map +1 -0
- package/dist/spec/load.js +166 -0
- package/dist/spec/load.js.map +1 -0
- package/dist/spec/types.d.ts +57 -0
- package/dist/spec/types.d.ts.map +1 -0
- package/dist/spec/types.js +24 -0
- package/dist/spec/types.js.map +1 -0
- package/labs/README.md +130 -0
- package/labs/annotated-openapi.yaml +311 -0
- package/labs/anyrow.config.ts +107 -0
- package/labs/demo.mjs +124 -0
- package/labs/local.config.ts +41 -0
- package/labs/minimal.config.ts +32 -0
- package/package.json +80 -0
|
@@ -0,0 +1,3400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The check registry.
|
|
3
|
+
*
|
|
4
|
+
* Each check asserts one property that must hold for any correct implementation. They need no
|
|
5
|
+
* ground truth — they compare the API against itself, through independent projections, or against
|
|
6
|
+
* the oracle of what oat just wrote. Check ids are stable: the conformance suite asserts on them.
|
|
7
|
+
*/
|
|
8
|
+
import { filterTerm, selectTerm, sortTerm } from "../spec/conventions.js";
|
|
9
|
+
import { driveAsync } from "./async.js";
|
|
10
|
+
import { buildCohort } from "./fixture.js";
|
|
11
|
+
import { fillPath } from "./world.js";
|
|
12
|
+
/* ------------------------------------------------------------------- helpers */
|
|
13
|
+
/** The list endpoint's derived conventions — parameter roles and envelope spellings. */
|
|
14
|
+
function conv(ctx) {
|
|
15
|
+
return ctx.listOp.conventions;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Builds a query using whatever this endpoint actually calls each role.
|
|
19
|
+
*
|
|
20
|
+
* `{ limit: 2, page: 1 }` becomes `?per_page=2&page_number=1` where that is how the document
|
|
21
|
+
* spells them, so a check written once works against an API that shares no parameter names with
|
|
22
|
+
* the fixture.
|
|
23
|
+
*/
|
|
24
|
+
function q(ctx, roles) {
|
|
25
|
+
const c = conv(ctx);
|
|
26
|
+
const out = {};
|
|
27
|
+
if (roles.limit !== undefined && c.limit !== undefined)
|
|
28
|
+
out[c.limit] = roles.limit;
|
|
29
|
+
if (roles.page !== undefined && c.page !== undefined)
|
|
30
|
+
out[c.page] = roles.page;
|
|
31
|
+
else if (roles.page !== undefined && c.offset !== undefined) {
|
|
32
|
+
/*
|
|
33
|
+
* Offset paging expressed from a page number.
|
|
34
|
+
*
|
|
35
|
+
* Some APIs count pages, some count rows skipped; the *property* every paging check is
|
|
36
|
+
* asserting — that walking forward covers the set without gaps or repeats — is identical
|
|
37
|
+
* either way. Translating here means a check says "page 3" once and works against both.
|
|
38
|
+
* Without it a page-numbered request against an offset API silently omits the parameter
|
|
39
|
+
* and every page comes back as the first one, which reads as a backend that ignores
|
|
40
|
+
* pagination rather than a tool that cannot express it.
|
|
41
|
+
*/
|
|
42
|
+
/* A page size must be assumed when the caller did not state one; 20 is the common default
|
|
43
|
+
* and any consistent value keeps the walk's arithmetic self-consistent, which is what the
|
|
44
|
+
* property depends on. */
|
|
45
|
+
const size = roles.limit ?? 20;
|
|
46
|
+
out[c.offset] = Math.max(roles.page - 1, 0) * size;
|
|
47
|
+
}
|
|
48
|
+
if (roles.cursor !== undefined && c.cursor !== undefined)
|
|
49
|
+
out[c.cursor] = roles.cursor;
|
|
50
|
+
if (roles.order !== undefined && c.order !== undefined)
|
|
51
|
+
out[c.order] = roles.order;
|
|
52
|
+
if (roles.search !== undefined && c.search !== undefined)
|
|
53
|
+
out[c.search] = roles.search;
|
|
54
|
+
if (roles.filter !== undefined && c.filter !== undefined)
|
|
55
|
+
out[c.filter] = roles.filter;
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Whether an equality predicate can be expressed against this endpoint at all.
|
|
60
|
+
*
|
|
61
|
+
* Not "is there a `filter` parameter": the most common shape in the wild has no such parameter and
|
|
62
|
+
* instead accepts one query parameter per field — `?status=active`. Gating on the parameter meant
|
|
63
|
+
* every filter check silently skipped itself against those APIs, which is the same failure as a
|
|
64
|
+
* check that cannot express a request being mistaken for a backend that ignores one.
|
|
65
|
+
*
|
|
66
|
+
* Checks needing an operator the grammar cannot write — a negation under equality-only filtering —
|
|
67
|
+
* still stand down individually, because `filterTerm` returns null for them and there is genuinely
|
|
68
|
+
* no request to send.
|
|
69
|
+
*/
|
|
70
|
+
function filterable(ctx) {
|
|
71
|
+
const c = conv(ctx);
|
|
72
|
+
if (c.filter !== undefined)
|
|
73
|
+
return true;
|
|
74
|
+
/* Equality-per-field needs a field to attach the predicate to, and the document has to say
|
|
75
|
+
* which fields are filterable — otherwise oat would be guessing at parameter names. */
|
|
76
|
+
return c.grammar === "equality" && (ctx.query?.filterable.length ?? 0) > 0;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Whether the endpoint can be walked forward at all, however it counts.
|
|
80
|
+
*
|
|
81
|
+
* Page numbers and row offsets are two ways of expressing the same request, and every paging
|
|
82
|
+
* property — that walking forward covers the set without gaps or repeats, that a more-pages
|
|
83
|
+
* signal is honest — holds identically under both. Gating those checks on a *page* parameter
|
|
84
|
+
* meant an offset-paged API silently skipped them, which reads exactly like a clean result.
|
|
85
|
+
*/
|
|
86
|
+
function pageable(ctx) {
|
|
87
|
+
const c = conv(ctx);
|
|
88
|
+
return c.page !== undefined || c.offset !== undefined;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Reads a pagination fact by whatever the document calls it — and from wherever it lives.
|
|
92
|
+
*
|
|
93
|
+
* Most APIs put these in the body. Some put them nowhere at all and publish a `Link` header
|
|
94
|
+
* instead, in which case "are there more pages" is answered by the presence of `rel="next"`
|
|
95
|
+
* rather than by a boolean field. Both are answers to the same question, so both are resolved
|
|
96
|
+
* here and every check that asks stays unaware of the difference.
|
|
97
|
+
*/
|
|
98
|
+
function envelopeValue(ctx, result, role) {
|
|
99
|
+
const key = conv(ctx).envelope[role];
|
|
100
|
+
if (key !== undefined)
|
|
101
|
+
return result.envelope[key];
|
|
102
|
+
const c = conv(ctx);
|
|
103
|
+
if (c.linkHeader === undefined)
|
|
104
|
+
return undefined;
|
|
105
|
+
/* The document declares a Link header, so its *absence* on a given response is meaningful:
|
|
106
|
+
* no `rel="next"` means there is no next page. Treating a missing header as "unknown" would
|
|
107
|
+
* let a backend that simply stops emitting links look untestable rather than wrong. */
|
|
108
|
+
const link = linkRelations(result.exchange);
|
|
109
|
+
if (role === "hasMore")
|
|
110
|
+
return link?.has("next") ?? false;
|
|
111
|
+
if (role === "nextCursor")
|
|
112
|
+
return link?.get("next") ?? null;
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
/** Parses RFC 8288 `Link` into rel → URL, or null when the response carries no such header. */
|
|
116
|
+
function linkRelations(exchange) {
|
|
117
|
+
const header = exchange.responseHeaders?.link ?? exchange.responseHeaders?.Link;
|
|
118
|
+
if (typeof header !== "string" || header === "")
|
|
119
|
+
return null;
|
|
120
|
+
const relations = new Map();
|
|
121
|
+
for (const part of header.split(",")) {
|
|
122
|
+
const match = /<([^>]+)>\s*;\s*rel\s*=\s*"?([^";]+)"?/.exec(part.trim());
|
|
123
|
+
if (match?.[1] !== undefined && match[2] !== undefined)
|
|
124
|
+
relations.set(match[2].trim(), match[1]);
|
|
125
|
+
}
|
|
126
|
+
return relations.size === 0 ? null : relations;
|
|
127
|
+
}
|
|
128
|
+
async function list(ctx, query = {}, auth = ctx.auth, scope = ctx.scope) {
|
|
129
|
+
const exchange = await ctx.client.get(fillPath(ctx.listOp.path, scope), {
|
|
130
|
+
headers: auth(),
|
|
131
|
+
query,
|
|
132
|
+
});
|
|
133
|
+
const body = exchange.responseBody;
|
|
134
|
+
const items = extractItems(body, ctx.collectionKey);
|
|
135
|
+
return { envelope: (body ?? {}), exchange, items };
|
|
136
|
+
}
|
|
137
|
+
function extractItems(body, key) {
|
|
138
|
+
if (Array.isArray(body))
|
|
139
|
+
return body;
|
|
140
|
+
if (body === null || typeof body !== "object")
|
|
141
|
+
return [];
|
|
142
|
+
if (key !== null) {
|
|
143
|
+
const value = body[key];
|
|
144
|
+
return Array.isArray(value) ? value : [];
|
|
145
|
+
}
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
function ids(records, identity) {
|
|
149
|
+
return records.map((r) => String(r[identity]));
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* How many pages a walk will traverse.
|
|
153
|
+
*
|
|
154
|
+
* Walking a whole collection is O(n) strictly sequential requests, so against a large existing
|
|
155
|
+
* dataset it dominates the entire run — and buys nothing. Pagination defects manifest at the
|
|
156
|
+
* *first* page boundary: a missing tiebreak, an off-by-one offset, a drifting cursor all show up
|
|
157
|
+
* within the first few pages or not at all. Bounding the walk makes runtime independent of how
|
|
158
|
+
* much data the system under test happens to be holding.
|
|
159
|
+
*/
|
|
160
|
+
const MAX_WALK_PAGES = 6;
|
|
161
|
+
/** Hard cap so a live collection of thousands cannot turn one check into a crawl. */
|
|
162
|
+
const WALK_RECORD_CAP = 200;
|
|
163
|
+
/** Pages needed to cover `total` rows at `pageSize`, never fewer than the default bound. */
|
|
164
|
+
function pagesToCover(total, pageSize) {
|
|
165
|
+
const size = Math.max(1, pageSize);
|
|
166
|
+
if (total === undefined || !Number.isFinite(total) || total < 0)
|
|
167
|
+
return MAX_WALK_PAGES;
|
|
168
|
+
const needed = Math.ceil(total / size) + 1;
|
|
169
|
+
const cap = Math.ceil(WALK_RECORD_CAP / size);
|
|
170
|
+
return Math.min(cap, Math.max(MAX_WALK_PAGES, needed));
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Collects a whole result set by paging, for checks that reason about set algebra.
|
|
174
|
+
*
|
|
175
|
+
* A single page is only the complete set when the collection happens to be smaller than one page,
|
|
176
|
+
* which is true of a fixture and false of most real APIs. Bailing out in that case left the
|
|
177
|
+
* strongest properties — partitioning, counting, zero-match — untested on exactly the collections
|
|
178
|
+
* where they matter, so the set is gathered across pages instead.
|
|
179
|
+
*
|
|
180
|
+
* Stops on a short page rather than on `hasMore`, because a backend whose more-pages flag is
|
|
181
|
+
* wrong is one of the things these checks exist to catch.
|
|
182
|
+
*/
|
|
183
|
+
async function collectSet(ctx, pageSize, extra = {}, maxPages = MAX_WALK_PAGES, order) {
|
|
184
|
+
const items = [];
|
|
185
|
+
let last = null;
|
|
186
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
187
|
+
const result = await list(ctx, { ...q(ctx, { limit: pageSize, order, page }), ...extra });
|
|
188
|
+
last = result;
|
|
189
|
+
if (result.exchange.status >= 400)
|
|
190
|
+
return null;
|
|
191
|
+
items.push(...result.items);
|
|
192
|
+
if (result.items.length < pageSize)
|
|
193
|
+
return { complete: true, items, last: result };
|
|
194
|
+
}
|
|
195
|
+
return last === null ? null : { complete: false, items, last };
|
|
196
|
+
}
|
|
197
|
+
/** Walks pages in order, returning ids as encountered (duplicates preserved). */
|
|
198
|
+
async function walkPages(ctx, pageSize, order, maxPages = MAX_WALK_PAGES) {
|
|
199
|
+
const seen = [];
|
|
200
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
201
|
+
const result = await list(ctx, q(ctx, { limit: pageSize, order, page }));
|
|
202
|
+
seen.push(...ids(result.items, ctx.identity));
|
|
203
|
+
const hasMore = envelopeValue(ctx, result, "hasMore");
|
|
204
|
+
if (hasMore !== true || result.items.length === 0)
|
|
205
|
+
return { ids: seen, truncated: false };
|
|
206
|
+
}
|
|
207
|
+
return { ids: seen, truncated: true };
|
|
208
|
+
}
|
|
209
|
+
async function walkCursor(ctx, pageSize, order, maxPages = MAX_WALK_PAGES) {
|
|
210
|
+
const seen = [];
|
|
211
|
+
let cursor;
|
|
212
|
+
for (let hop = 0; hop < maxPages; hop++) {
|
|
213
|
+
const result = await list(ctx, q(ctx, { cursor, limit: pageSize, order }));
|
|
214
|
+
seen.push(...ids(result.items, ctx.identity));
|
|
215
|
+
const next = envelopeValue(ctx, result, "nextCursor");
|
|
216
|
+
if (typeof next !== "string" || next === "" || result.items.length === 0) {
|
|
217
|
+
return { ids: seen, truncated: false };
|
|
218
|
+
}
|
|
219
|
+
cursor = next;
|
|
220
|
+
}
|
|
221
|
+
return { ids: seen, truncated: true };
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Whether a result is the complete set rather than a truncated page.
|
|
225
|
+
*
|
|
226
|
+
* Does not consult `hasMore`, and that is the whole point: checks reasoning about set algebra
|
|
227
|
+
* have to stay correct on a backend whose more-pages flag is itself wrong, or one defect
|
|
228
|
+
* masquerades as another. This previously did read the flag, which meant a backend reporting
|
|
229
|
+
* `hasMore` from a miscounted total silently disabled every downstream set check — they returned
|
|
230
|
+
* without a word and the report looked clean.
|
|
231
|
+
*
|
|
232
|
+
* A page holding fewer records than it asked for cannot have been truncated. That is decidable
|
|
233
|
+
* from the response alone, and it is enough.
|
|
234
|
+
*/
|
|
235
|
+
function isComplete(_ctx, result, requestedLimit) {
|
|
236
|
+
return result.items.length < requestedLimit;
|
|
237
|
+
}
|
|
238
|
+
function duplicates(values) {
|
|
239
|
+
const seen = new Set();
|
|
240
|
+
const dupes = new Set();
|
|
241
|
+
for (const value of values) {
|
|
242
|
+
if (seen.has(value))
|
|
243
|
+
dupes.add(value);
|
|
244
|
+
seen.add(value);
|
|
245
|
+
}
|
|
246
|
+
return [...dupes];
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* A sortable field whose values repeat across the cohort. Ties are what expose an unstable sort:
|
|
250
|
+
* with distinct keys every implementation looks correct, because there is only one valid order.
|
|
251
|
+
*/
|
|
252
|
+
function tiedSortField(ctx) {
|
|
253
|
+
for (const field of ctx.query?.sortable ?? []) {
|
|
254
|
+
if (field === ctx.identity)
|
|
255
|
+
continue;
|
|
256
|
+
const values = ctx.records.map((r) => JSON.stringify(r[field]));
|
|
257
|
+
if (new Set(values).size < values.length)
|
|
258
|
+
return field;
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* A field that is null on some seeded records and populated on others.
|
|
264
|
+
*
|
|
265
|
+
* Null handling is where query engines break — SQL's three-valued logic means `col <> x` is NULL
|
|
266
|
+
* rather than true for null rows, so they silently vanish from negated predicates, and
|
|
267
|
+
* nullsfirst/nullslast is easy to get backwards. A check that only ever probes the identity
|
|
268
|
+
* column, which is never null, cannot see any of it.
|
|
269
|
+
*/
|
|
270
|
+
function nullableField(ctx, candidates) {
|
|
271
|
+
for (const field of candidates) {
|
|
272
|
+
if (field === ctx.identity)
|
|
273
|
+
continue;
|
|
274
|
+
const values = ctx.records.map((r) => r[field]);
|
|
275
|
+
const nulls = values.filter((v) => v === null || v === undefined).length;
|
|
276
|
+
if (nulls > 0 && nulls < values.length)
|
|
277
|
+
return field;
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
function firstFilterable(ctx, predicate) {
|
|
282
|
+
const candidates = ctx.query?.filterable ?? [];
|
|
283
|
+
return candidates.find(predicate) ?? null;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Finds a record holding non-null values on two distinct, non-identity filterable fields — the
|
|
287
|
+
* minimum needed to build a compound predicate that is guaranteed to match at least that record.
|
|
288
|
+
*/
|
|
289
|
+
function twoFilterableFields(ctx) {
|
|
290
|
+
const candidates = (ctx.query?.filterable ?? []).filter((f) => f !== ctx.identity);
|
|
291
|
+
for (const target of ctx.records) {
|
|
292
|
+
const present = candidates.filter((f) => target[f] !== null && target[f] !== undefined);
|
|
293
|
+
if (present[0] !== undefined && present[1] !== undefined) {
|
|
294
|
+
return { fieldA: present[0], fieldB: present[1], target };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
/** The bare `field.op.value` / `field=op:value` fragment `filterTerm` wraps in its parameter. */
|
|
300
|
+
function filterFragment(conventions, field, value) {
|
|
301
|
+
const term = filterTerm(conventions, field, "eq", value);
|
|
302
|
+
if (term === null)
|
|
303
|
+
return null;
|
|
304
|
+
const fragment = Object.values(term)[0];
|
|
305
|
+
return typeof fragment === "string" ? fragment : null;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* A conjunction of two equality predicates, in whatever grammar the endpoint speaks.
|
|
309
|
+
*
|
|
310
|
+
* Per-field equality ANDs implicitly — two query parameters together already mean "both must
|
|
311
|
+
* hold" — so the two terms are simply merged. A raw filter expression has no such shortcut: the
|
|
312
|
+
* postgrest grammar needs an explicit `and(...)`, and the colon grammar (which has no grouping
|
|
313
|
+
* syntax at all — see `toCanonicalFilter`) ANDs by joining terms with a comma.
|
|
314
|
+
*/
|
|
315
|
+
function andTerm(conventions, fieldA, valueA, fieldB, valueB) {
|
|
316
|
+
if (conventions.grammar === "equality") {
|
|
317
|
+
const a = filterTerm(conventions, fieldA, "eq", valueA);
|
|
318
|
+
const b = filterTerm(conventions, fieldB, "eq", valueB);
|
|
319
|
+
if (a === null || b === null)
|
|
320
|
+
return null;
|
|
321
|
+
return { ...a, ...b };
|
|
322
|
+
}
|
|
323
|
+
if (conventions.filter === undefined)
|
|
324
|
+
return null;
|
|
325
|
+
const a = filterFragment(conventions, fieldA, valueA);
|
|
326
|
+
const b = filterFragment(conventions, fieldB, valueB);
|
|
327
|
+
if (a === null || b === null)
|
|
328
|
+
return null;
|
|
329
|
+
return { [conventions.filter]: conventions.grammar === "postgrest" ? `and(${a},${b})` : `${a},${b}` };
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* A disjunction of two equality predicates.
|
|
333
|
+
*
|
|
334
|
+
* Only the postgrest grammar has an `or()` combinator at all: per-field equality has no way to
|
|
335
|
+
* ask for "either" rather than "both", and the colon grammar's comma join is AND-only.
|
|
336
|
+
*/
|
|
337
|
+
function orTerm(conventions, fieldA, valueA, fieldB, valueB) {
|
|
338
|
+
if (conventions.grammar !== "postgrest" || conventions.filter === undefined)
|
|
339
|
+
return null;
|
|
340
|
+
const a = filterFragment(conventions, fieldA, valueA);
|
|
341
|
+
const b = filterFragment(conventions, fieldB, valueB);
|
|
342
|
+
if (a === null || b === null)
|
|
343
|
+
return null;
|
|
344
|
+
return { [conventions.filter]: `or(${a},${b})` };
|
|
345
|
+
}
|
|
346
|
+
/* -------------------------------------------------------------------- checks */
|
|
347
|
+
const readAfterWrite = {
|
|
348
|
+
applicable: (ctx) => ctx.createOp !== undefined && ctx.records.length > 0,
|
|
349
|
+
mutates: true,
|
|
350
|
+
id: "list.read-after-write",
|
|
351
|
+
needs: "a create operation and at least one seeded record",
|
|
352
|
+
async run(ctx) {
|
|
353
|
+
const target = ctx.records[0];
|
|
354
|
+
if (target === undefined)
|
|
355
|
+
return;
|
|
356
|
+
const id = String(target[ctx.identity]);
|
|
357
|
+
const pageSize = ctx.query?.maxLimit ?? 100;
|
|
358
|
+
/*
|
|
359
|
+
* Walk by short page, never by `hasMore`. Trusting the flag is the same trap
|
|
360
|
+
* {@link isComplete} documents: a backend whose more-pages signal is wrong makes a
|
|
361
|
+
* record on page two look like a lost write.
|
|
362
|
+
*
|
|
363
|
+
* Deliberately unsorted. STALE_LIST only freezes the default listing — adding `order`
|
|
364
|
+
* takes a live path and the defect vanishes. An unstable default order can hide a
|
|
365
|
+
* record for one walk; a repeat that finds it was never a lost write.
|
|
366
|
+
*/
|
|
367
|
+
const locate = async () => {
|
|
368
|
+
const gathered = await collectSet(ctx, pageSize);
|
|
369
|
+
if (gathered === null)
|
|
370
|
+
return { last: null, status: "unresolved" };
|
|
371
|
+
if (gathered.items.some((item) => String(item[ctx.identity]) === id)) {
|
|
372
|
+
return { last: gathered.last, status: "found" };
|
|
373
|
+
}
|
|
374
|
+
return { last: gathered.last, status: "missing" };
|
|
375
|
+
};
|
|
376
|
+
let located = await locate();
|
|
377
|
+
/* A record that appears on a repeat walk was never lost — it was unreachable for one
|
|
378
|
+
* query. That is an ordering defect, which the pagination checks diagnose precisely;
|
|
379
|
+
* reporting it here as a lost write would name the wrong cause. This check is about
|
|
380
|
+
* records the list *never* shows. */
|
|
381
|
+
for (let attempt = 0; attempt < 2 && located.status === "missing"; attempt++) {
|
|
382
|
+
located = await locate();
|
|
383
|
+
}
|
|
384
|
+
if (located.status === "found")
|
|
385
|
+
return;
|
|
386
|
+
if (located.status === "unresolved") {
|
|
387
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the list was rejected, so whether the write is visible cannot be decided");
|
|
388
|
+
}
|
|
389
|
+
const evidence = located.last === null ? [] : [located.last.exchange];
|
|
390
|
+
let detail = `created ${ctx.entityName} ${id} is absent from the list projection`;
|
|
391
|
+
if (ctx.readOp !== undefined) {
|
|
392
|
+
const item = await ctx.client.get(fillPath(ctx.readOp.path, { ...ctx.scope, ...itemParamFor(ctx, id) }), { headers: ctx.auth() });
|
|
393
|
+
evidence.push(item);
|
|
394
|
+
if (item.status < 300) {
|
|
395
|
+
detail =
|
|
396
|
+
`created ${ctx.entityName} ${id} is served by the item route (${item.status}) but ` +
|
|
397
|
+
"does not appear in the list route — the two projections disagree";
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
ctx.findings.backend(this.id, ctx.entityName, "list projection does not reflect a completed write", detail, evidence);
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
function itemParamFor(ctx, id) {
|
|
404
|
+
const op = ctx.readOp ?? ctx.updateOp ?? ctx.deleteOp;
|
|
405
|
+
const param = op?.pathParams.at(-1);
|
|
406
|
+
return param === undefined ? {} : { [param]: id };
|
|
407
|
+
}
|
|
408
|
+
const unknownFilterRejected = {
|
|
409
|
+
/*
|
|
410
|
+
* Deliberately narrower than `filterable`: this asserts that a *filter expression* naming a
|
|
411
|
+
* field the document does not declare is rejected rather than dropped.
|
|
412
|
+
*
|
|
413
|
+
* Under one-parameter-per-field equality there is no expression language, so an unrecognised
|
|
414
|
+
* parameter is just an unrecognised query parameter — and ignoring those is conventional,
|
|
415
|
+
* widely relied upon, and not a defect. Reporting it as one would fire against a large share
|
|
416
|
+
* of real APIs, which is how a tool earns a reputation for crying wolf.
|
|
417
|
+
*/
|
|
418
|
+
applicable: (ctx) => conv(ctx).filter !== undefined,
|
|
419
|
+
id: "filter.unknown-field-rejected",
|
|
420
|
+
needs: "a way to express a filter — a filter expression parameter, or filterable fields",
|
|
421
|
+
async run(ctx) {
|
|
422
|
+
const baseline = await list(ctx, q(ctx, { limit: 100 }));
|
|
423
|
+
const unknownField = filterTerm(conv(ctx), "oat_no_such_field_xyz", "eq", 1);
|
|
424
|
+
if (unknownField === null)
|
|
425
|
+
return;
|
|
426
|
+
const result = await list(ctx, { ...q(ctx, { limit: 100 }), ...unknownField });
|
|
427
|
+
if (result.exchange.status >= 500) {
|
|
428
|
+
ctx.findings.backend("error.malformed-filter-not-5xx", ctx.entityName, "unknown filter field produces a server error", `filtering on an undeclared field returned ${result.exchange.status}; a rejected input ` +
|
|
429
|
+
"should be a 4xx", [result.exchange]);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (result.exchange.status >= 400)
|
|
433
|
+
return;
|
|
434
|
+
const same = ids(result.items, ctx.identity).join(",") === ids(baseline.items, ctx.identity).join(",");
|
|
435
|
+
ctx.findings.backend(this.id, ctx.entityName, "unknown filter field is silently ignored", `filtering on an undeclared field returned ${result.exchange.status} with ` +
|
|
436
|
+
`${result.items.length} of ${baseline.items.length} records` +
|
|
437
|
+
(same ? " — identical to the unfiltered result, so the filter was dropped entirely" : "") +
|
|
438
|
+
". A filter the backend does not understand must be rejected, never ignored: silently " +
|
|
439
|
+
"ignoring it means every caller's filter may be doing nothing.", [baseline.exchange, result.exchange]);
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
const equalityFilterSelectsOne = {
|
|
443
|
+
applicable: (ctx) => filterable(ctx) &&
|
|
444
|
+
ctx.records.length > 0 &&
|
|
445
|
+
(ctx.query?.filterable.includes(ctx.identity) ?? false),
|
|
446
|
+
dependsOn: ["list.read-after-write"],
|
|
447
|
+
id: "filter.equality-selects-exactly-one",
|
|
448
|
+
needs: "a `filter` parameter that accepts the identity field",
|
|
449
|
+
async run(ctx) {
|
|
450
|
+
const target = ctx.records[0];
|
|
451
|
+
if (target === undefined)
|
|
452
|
+
return;
|
|
453
|
+
const id = String(target[ctx.identity]);
|
|
454
|
+
const term = filterTerm(conv(ctx), ctx.identity, "eq", id);
|
|
455
|
+
if (term === null)
|
|
456
|
+
return;
|
|
457
|
+
const result = await list(ctx, { ...q(ctx, { limit: 100 }), ...term });
|
|
458
|
+
/* A rejected filter is not a wrong answer. If the backend says this field is not
|
|
459
|
+
* filterable, that is a capability statement — the gap belongs to x-query, which is
|
|
460
|
+
* already reported, not here. Reading a 4xx as "returned zero records" invents a defect. */
|
|
461
|
+
if (result.exchange.status >= 400)
|
|
462
|
+
return;
|
|
463
|
+
const got = ids(result.items, ctx.identity);
|
|
464
|
+
if (got.length === 1 && got[0] === id)
|
|
465
|
+
return;
|
|
466
|
+
ctx.findings.backend(this.id, ctx.entityName, "equality filter on the identity does not select exactly one record", `filter=${ctx.identity}.eq.${id} returned ${got.length} records (${got.slice(0, 5).join(", ")})`, [result.exchange]);
|
|
467
|
+
},
|
|
468
|
+
};
|
|
469
|
+
const zeroMatchFilter = {
|
|
470
|
+
applicable: (ctx) => filterable(ctx) &&
|
|
471
|
+
(ctx.query?.filterable.includes(ctx.identity) ?? false),
|
|
472
|
+
dependsOn: ["list.read-after-write"],
|
|
473
|
+
id: "filter.zero-match-returns-none",
|
|
474
|
+
needs: "a `filter` parameter that accepts the identity field",
|
|
475
|
+
async run(ctx) {
|
|
476
|
+
const term = filterTerm(conv(ctx), ctx.identity, "eq", "oat-nonexistent-value-000");
|
|
477
|
+
if (term === null)
|
|
478
|
+
return;
|
|
479
|
+
const result = await list(ctx, { ...q(ctx, { limit: 100 }), ...term });
|
|
480
|
+
if (result.exchange.status >= 400)
|
|
481
|
+
return;
|
|
482
|
+
if (result.items.length === 0)
|
|
483
|
+
return;
|
|
484
|
+
ctx.findings.backend(this.id, ctx.entityName, "filter that cannot match returns records anyway", `a filter on a value no record holds returned ${result.items.length} records, so the ` +
|
|
485
|
+
"predicate is not being applied", [result.exchange]);
|
|
486
|
+
},
|
|
487
|
+
};
|
|
488
|
+
const negationPartitions = {
|
|
489
|
+
applicable: (ctx) => filterable(ctx) &&
|
|
490
|
+
ctx.records.length > 1 &&
|
|
491
|
+
(ctx.query?.filterable.includes(ctx.identity) ?? false),
|
|
492
|
+
dependsOn: [
|
|
493
|
+
"list.read-after-write",
|
|
494
|
+
/* Partitioning is asserted over field *values*. A backend that drops submitted fields
|
|
495
|
+
* leaves the cohort without the value the predicate is built from. */
|
|
496
|
+
"create.persists-submitted-fields",
|
|
497
|
+
/* The three sets are gathered across pages, so a walk that skips or repeats records makes
|
|
498
|
+
* the partition fail for a reason that has nothing to do with the predicate. */
|
|
499
|
+
"pagination.page-walk-covers-set",
|
|
500
|
+
],
|
|
501
|
+
id: "filter.negation-partitions-the-set",
|
|
502
|
+
needs: "a `filter` parameter supporting eq and neq",
|
|
503
|
+
async run(ctx) {
|
|
504
|
+
const target = ctx.records[0];
|
|
505
|
+
if (target === undefined)
|
|
506
|
+
return;
|
|
507
|
+
/* Prefer a field with nulls in the cohort: partitioning on the identity can never expose
|
|
508
|
+
* three-valued-logic bugs, because an identity is never null. */
|
|
509
|
+
const field = nullableField(ctx, ctx.query?.filterable ?? []) ?? ctx.identity;
|
|
510
|
+
const probe = ctx.records.map((r) => r[field]).find((v) => v !== null && v !== undefined);
|
|
511
|
+
if (probe === undefined) {
|
|
512
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `every record in the cohort has a null "${field}", leaving no value to negate`);
|
|
513
|
+
}
|
|
514
|
+
const value = String(probe);
|
|
515
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
516
|
+
const eqTerm = filterTerm(conv(ctx), field, "eq", value);
|
|
517
|
+
const neqTerm = filterTerm(conv(ctx), field, "neq", value);
|
|
518
|
+
/* Negation has no representation in an equality-only grammar, so this property simply
|
|
519
|
+
* cannot be expressed against such an API — better to stand down than to send something
|
|
520
|
+
* meaningless and read the answer as a defect. */
|
|
521
|
+
if (eqTerm === null || neqTerm === null)
|
|
522
|
+
return;
|
|
523
|
+
const all = await collectSet(ctx, limit);
|
|
524
|
+
const matching = await collectSet(ctx, limit, eqTerm);
|
|
525
|
+
const complement = await collectSet(ctx, limit, neqTerm);
|
|
526
|
+
if (all === null || matching === null || complement === null) {
|
|
527
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "one of the three listings needed for the partition was rejected");
|
|
528
|
+
}
|
|
529
|
+
/* Set algebra only holds over complete sets. A walk that hit the page cap has seen a
|
|
530
|
+
* prefix, and the union would legitimately miss whatever lies beyond it. */
|
|
531
|
+
if (!all.complete || !matching.complete || !complement.complete) {
|
|
532
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers, so the union cannot be compared "
|
|
533
|
+
+ "against the whole set");
|
|
534
|
+
}
|
|
535
|
+
const union = new Set([
|
|
536
|
+
...ids(matching.items, ctx.identity),
|
|
537
|
+
...ids(complement.items, ctx.identity),
|
|
538
|
+
]);
|
|
539
|
+
const expected = new Set(ids(all.items, ctx.identity));
|
|
540
|
+
const overlap = ids(matching.items, ctx.identity).filter((value) => ids(complement.items, ctx.identity).includes(value));
|
|
541
|
+
if (overlap.length > 0) {
|
|
542
|
+
ctx.findings.backend(this.id, ctx.entityName, "a predicate and its negation both match the same record", `${overlap.length} record(s) appear in both ${field}.eq and ${field}.neq results: ` +
|
|
543
|
+
overlap.slice(0, 3).join(", "), [matching.last.exchange, complement.last.exchange]);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const missing = [...expected].filter((id) => !union.has(id));
|
|
547
|
+
if (missing.length > 0) {
|
|
548
|
+
const nulls = missing.filter((id) => {
|
|
549
|
+
const record = ctx.records.find((r) => String(r[ctx.identity]) === id);
|
|
550
|
+
return record !== undefined && (record[field] === null || record[field] === undefined);
|
|
551
|
+
});
|
|
552
|
+
ctx.findings.backend(this.id, ctx.entityName, "a predicate and its negation do not cover the full set", `${missing.length} record(s) match neither ${field}.eq nor ${field}.neq: ` +
|
|
553
|
+
`${missing.slice(0, 3).join(", ")}. ` +
|
|
554
|
+
(nulls.length === missing.length
|
|
555
|
+
? `Every one of them has ${field} = null, so the negated predicate is dropping ` +
|
|
556
|
+
"nulls — SQL evaluates `col <> x` as NULL, not true, unless the query also " +
|
|
557
|
+
"tests `col IS NULL`."
|
|
558
|
+
: "A record must satisfy either a predicate or its negation."), [all.last.exchange, matching.last.exchange, complement.last.exchange]);
|
|
559
|
+
}
|
|
560
|
+
},
|
|
561
|
+
};
|
|
562
|
+
/**
|
|
563
|
+
* Every filter check above uses a single predicate. Real filters compose them: `and(a,b)`,
|
|
564
|
+
* `or(a,b)`, and nested combinations of both. This is the first of two checks giving that
|
|
565
|
+
* combinator its own set-algebra property, needing no ground truth just like the rest: a
|
|
566
|
+
* conjunction of two predicates must select exactly the intersection of what each selects alone.
|
|
567
|
+
*/
|
|
568
|
+
const filterAndComposesAsIntersection = {
|
|
569
|
+
applicable: (ctx) => filterable(ctx) && (ctx.query?.filterable.length ?? 0) > 1,
|
|
570
|
+
dependsOn: [
|
|
571
|
+
"list.read-after-write",
|
|
572
|
+
"create.persists-submitted-fields",
|
|
573
|
+
"filter.equality-selects-exactly-one",
|
|
574
|
+
/* Both sides are gathered across pages, so a walk that skips or repeats records changes
|
|
575
|
+
* the membership being compared for reasons unrelated to composition. */
|
|
576
|
+
"pagination.page-walk-covers-set",
|
|
577
|
+
],
|
|
578
|
+
id: "filter.and-composes-as-intersection",
|
|
579
|
+
needs: "two filterable fields and a filter parameter or grammar supporting eq",
|
|
580
|
+
async run(ctx) {
|
|
581
|
+
const conventions = conv(ctx);
|
|
582
|
+
const picked = twoFilterableFields(ctx);
|
|
583
|
+
if (picked === null) {
|
|
584
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "no record holds non-null values on two distinct filterable fields to combine");
|
|
585
|
+
}
|
|
586
|
+
const { fieldA, fieldB, target } = picked;
|
|
587
|
+
const valueA = String(target[fieldA]);
|
|
588
|
+
const valueB = String(target[fieldB]);
|
|
589
|
+
const termA = filterTerm(conventions, fieldA, "eq", valueA);
|
|
590
|
+
const termB = filterTerm(conventions, fieldB, "eq", valueB);
|
|
591
|
+
const combined = andTerm(conventions, fieldA, valueA, fieldB, valueB);
|
|
592
|
+
if (termA === null || termB === null || combined === null) {
|
|
593
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "this API's filter grammar cannot express a combined predicate");
|
|
594
|
+
}
|
|
595
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
596
|
+
const onlyA = await collectSet(ctx, limit, termA);
|
|
597
|
+
const onlyB = await collectSet(ctx, limit, termB);
|
|
598
|
+
const both = await collectSet(ctx, limit, combined);
|
|
599
|
+
if (onlyA === null || onlyB === null || both === null) {
|
|
600
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "one of the three listings needed for the intersection was rejected");
|
|
601
|
+
}
|
|
602
|
+
if (!onlyA.complete || !onlyB.complete || !both.complete) {
|
|
603
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers, so the intersection cannot be verified");
|
|
604
|
+
}
|
|
605
|
+
const setA = new Set(ids(onlyA.items, ctx.identity));
|
|
606
|
+
const setB = new Set(ids(onlyB.items, ctx.identity));
|
|
607
|
+
const expected = new Set([...setA].filter((id) => setB.has(id)));
|
|
608
|
+
const got = new Set(ids(both.items, ctx.identity));
|
|
609
|
+
const missing = [...expected].filter((id) => !got.has(id));
|
|
610
|
+
const extra = [...got].filter((id) => !expected.has(id));
|
|
611
|
+
if (missing.length === 0 && extra.length === 0)
|
|
612
|
+
return;
|
|
613
|
+
ctx.findings.backend(this.id, ctx.entityName, "and() does not compose as the intersection of its terms", `and(${fieldA}.eq.${valueA},${fieldB}.eq.${valueB}) returned ${got.size} record(s); ` +
|
|
614
|
+
`${fieldA}.eq.${valueA} alone returned ${setA.size}, ${fieldB}.eq.${valueB} alone returned ` +
|
|
615
|
+
`${setB.size}, whose intersection is ${expected.size}. ` +
|
|
616
|
+
(missing.length > 0 ? `Missing from the combined result: ${missing.slice(0, 3).join(", ")}. ` : "") +
|
|
617
|
+
(extra.length > 0 ? `Present but should not match both terms: ${extra.slice(0, 3).join(", ")}. ` : "") +
|
|
618
|
+
"A conjunction must select exactly the records both terms match individually.", [onlyA.last.exchange, onlyB.last.exchange, both.last.exchange]);
|
|
619
|
+
},
|
|
620
|
+
};
|
|
621
|
+
/**
|
|
622
|
+
* The disjunction counterpart of {@link filterAndComposesAsIntersection}: `or(a,b)` must select
|
|
623
|
+
* exactly the union of what each predicate selects alone. Only the postgrest-shaped grammar has
|
|
624
|
+
* an `or()` combinator at all — per-field equality and the colon grammar's comma join are both
|
|
625
|
+
* AND-only — so this stands down everywhere else rather than inventing a request to send.
|
|
626
|
+
*/
|
|
627
|
+
const filterOrComposesAsUnion = {
|
|
628
|
+
applicable: (ctx) => conv(ctx).grammar === "postgrest" && (ctx.query?.filterable.length ?? 0) > 1,
|
|
629
|
+
dependsOn: [
|
|
630
|
+
"list.read-after-write",
|
|
631
|
+
"create.persists-submitted-fields",
|
|
632
|
+
"filter.equality-selects-exactly-one",
|
|
633
|
+
"pagination.page-walk-covers-set",
|
|
634
|
+
/* Deliberately does *not* depend on filter.and-composes-as-intersection: and() and or()
|
|
635
|
+
* being broken by the same defect is two independent manifestations of one root cause, not
|
|
636
|
+
* one causing the other. A defect that swaps the combinators corrupts both requests, and
|
|
637
|
+
* suppressing this as and()'s cascade would hide the half of the bug this check is the
|
|
638
|
+
* only thing that can see. */
|
|
639
|
+
],
|
|
640
|
+
id: "filter.or-composes-as-union",
|
|
641
|
+
needs: "two filterable fields and an or() combinator (postgrest-shaped filter grammar)",
|
|
642
|
+
async run(ctx) {
|
|
643
|
+
const conventions = conv(ctx);
|
|
644
|
+
const picked = twoFilterableFields(ctx);
|
|
645
|
+
if (picked === null) {
|
|
646
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "no record holds non-null values on two distinct filterable fields to combine");
|
|
647
|
+
}
|
|
648
|
+
const { fieldA, fieldB, target } = picked;
|
|
649
|
+
const valueA = String(target[fieldA]);
|
|
650
|
+
const valueB = String(target[fieldB]);
|
|
651
|
+
const termA = filterTerm(conventions, fieldA, "eq", valueA);
|
|
652
|
+
const termB = filterTerm(conventions, fieldB, "eq", valueB);
|
|
653
|
+
const combined = orTerm(conventions, fieldA, valueA, fieldB, valueB);
|
|
654
|
+
if (termA === null || termB === null || combined === null) {
|
|
655
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "this API's filter grammar cannot express a combined predicate");
|
|
656
|
+
}
|
|
657
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
658
|
+
const onlyA = await collectSet(ctx, limit, termA);
|
|
659
|
+
const onlyB = await collectSet(ctx, limit, termB);
|
|
660
|
+
const either = await collectSet(ctx, limit, combined);
|
|
661
|
+
if (onlyA === null || onlyB === null || either === null) {
|
|
662
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "one of the three listings needed for the union was rejected");
|
|
663
|
+
}
|
|
664
|
+
if (!onlyA.complete || !onlyB.complete || !either.complete) {
|
|
665
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers, so the union cannot be verified");
|
|
666
|
+
}
|
|
667
|
+
const setA = new Set(ids(onlyA.items, ctx.identity));
|
|
668
|
+
const setB = new Set(ids(onlyB.items, ctx.identity));
|
|
669
|
+
const expected = new Set([...setA, ...setB]);
|
|
670
|
+
const got = new Set(ids(either.items, ctx.identity));
|
|
671
|
+
const missing = [...expected].filter((id) => !got.has(id));
|
|
672
|
+
const extra = [...got].filter((id) => !expected.has(id));
|
|
673
|
+
if (missing.length === 0 && extra.length === 0)
|
|
674
|
+
return;
|
|
675
|
+
ctx.findings.backend(this.id, ctx.entityName, "or() does not compose as the union of its terms", `or(${fieldA}.eq.${valueA},${fieldB}.eq.${valueB}) returned ${got.size} record(s); ` +
|
|
676
|
+
`${fieldA}.eq.${valueA} alone returned ${setA.size}, ${fieldB}.eq.${valueB} alone returned ` +
|
|
677
|
+
`${setB.size}, whose union is ${expected.size}. ` +
|
|
678
|
+
(missing.length > 0 ? `Missing from the combined result: ${missing.slice(0, 3).join(", ")}. ` : "") +
|
|
679
|
+
(extra.length > 0 ? `Present but matches neither term individually: ${extra.slice(0, 3).join(", ")}. ` : "") +
|
|
680
|
+
"A disjunction must select exactly the records either term matches alone.", [onlyA.last.exchange, onlyB.last.exchange, either.last.exchange]);
|
|
681
|
+
},
|
|
682
|
+
};
|
|
683
|
+
const sortReverseSymmetry = {
|
|
684
|
+
applicable: (ctx) => (ctx.query?.sortable.length ?? 0) > 0 && ctx.records.length > 1,
|
|
685
|
+
/* The property is asserted over a *nullable* sort key, because that is where null-ordering
|
|
686
|
+
* bugs live. A backend that drops submitted fields leaves that column uniformly null, and a
|
|
687
|
+
* sort over one repeated value is symmetric no matter how badly the backend sorts. */
|
|
688
|
+
dependsOn: [
|
|
689
|
+
"create.persists-submitted-fields",
|
|
690
|
+
/* Reversing an order that is never applied returns the same page twice. Every sort
|
|
691
|
+
* property is untestable until ordering itself is known to work. */
|
|
692
|
+
"sort.order-is-applied",
|
|
693
|
+
/* Both directions are gathered across pages, so a walk that skips or repeats records
|
|
694
|
+
* changes the membership this check compares — for reasons that are not about sorting. */
|
|
695
|
+
"pagination.page-walk-covers-set",
|
|
696
|
+
],
|
|
697
|
+
id: "sort.reverse-symmetry",
|
|
698
|
+
needs: "an `order` parameter supporting asc and desc",
|
|
699
|
+
async run(ctx) {
|
|
700
|
+
/* A nullable sort key exercises null ordering, where the interesting bugs live. */
|
|
701
|
+
const field = nullableField(ctx, ctx.query?.sortable ?? []) ??
|
|
702
|
+
ctx.query?.sortable.find((f) => f !== ctx.identity) ??
|
|
703
|
+
ctx.query?.sortable[0];
|
|
704
|
+
if (field === undefined) {
|
|
705
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "no sortable field is available to order by");
|
|
706
|
+
}
|
|
707
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
708
|
+
/* Gathered across pages: a collection larger than one page would otherwise leave this
|
|
709
|
+
* property — that a reversal reorders a set without changing its membership — untested on
|
|
710
|
+
* exactly the collections where sorting matters most. */
|
|
711
|
+
const ascending = await collectSet(ctx, limit, {}, MAX_WALK_PAGES, sortTerm(conv(ctx), field, "asc"));
|
|
712
|
+
const descending = await collectSet(ctx, limit, {}, MAX_WALK_PAGES, sortTerm(conv(ctx), field, "desc"));
|
|
713
|
+
if (ascending === null || descending === null) {
|
|
714
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `ordering by "${field}" was rejected in one direction, so the two cannot be compared`);
|
|
715
|
+
}
|
|
716
|
+
/* Only comparable when a single page holds the whole collection. Otherwise asc and desc
|
|
717
|
+
* return opposite *windows* of it — legitimately different sets, and comparing them would
|
|
718
|
+
* report every capped collection as broken. */
|
|
719
|
+
if (!ascending.complete || !descending.complete) {
|
|
720
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers, so the two directions return "
|
|
721
|
+
+ "different windows of it rather than the same set reversed");
|
|
722
|
+
}
|
|
723
|
+
const forward = ids(ascending.items, ctx.identity);
|
|
724
|
+
const backward = ids(descending.items, ctx.identity);
|
|
725
|
+
if (forward.length !== backward.length) {
|
|
726
|
+
ctx.findings.backend(this.id, ctx.entityName, "ascending and descending sorts return different numbers of records", `order=${field}.asc returned ${forward.length}, order=${field}.desc returned ${backward.length}`, [ascending.last.exchange, descending.last.exchange]);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
if (forward.join(",") === [...backward].reverse().join(","))
|
|
730
|
+
return;
|
|
731
|
+
/* Values may legitimately tie; only flag when the multisets differ or a strict field
|
|
732
|
+
* ordering is violated, not when equal keys land in a different arrangement. */
|
|
733
|
+
const sameSet = [...forward].sort().join(",") === [...backward].sort().join(",");
|
|
734
|
+
if (!sameSet) {
|
|
735
|
+
const onlyAsc = forward.filter((id) => !backward.includes(id));
|
|
736
|
+
const onlyDesc = backward.filter((id) => !forward.includes(id));
|
|
737
|
+
ctx.findings.backend(this.id, ctx.entityName, "ascending and descending sorts return different sets", `order=${field}.asc and order=${field}.desc disagree about which records exist. ` +
|
|
738
|
+
`Only ascending: ${onlyAsc.slice(0, 3).join(", ") || "—"}. ` +
|
|
739
|
+
`Only descending: ${onlyDesc.slice(0, 3).join(", ") || "—"}. ` +
|
|
740
|
+
"Sort direction must reorder a collection, never change its membership.", [ascending.last.exchange, descending.last.exchange]);
|
|
741
|
+
}
|
|
742
|
+
},
|
|
743
|
+
};
|
|
744
|
+
const pageWalkCoversSet = {
|
|
745
|
+
applicable: (ctx) => pageable(ctx) && ctx.records.length > 2,
|
|
746
|
+
dependsOn: [
|
|
747
|
+
"list.read-after-write",
|
|
748
|
+
"pagination.limit-bounds-page-size",
|
|
749
|
+
"pagination.has-more-is-accurate",
|
|
750
|
+
/* A page walk is only sound over a total order. Where the requested order is discarded,
|
|
751
|
+
* the pages are windows onto an arbitrary sequence, and any drift they show is the
|
|
752
|
+
* missing sort rather than a pagination defect. */
|
|
753
|
+
"sort.order-is-applied",
|
|
754
|
+
],
|
|
755
|
+
id: "pagination.page-walk-covers-set",
|
|
756
|
+
needs: "a way to page forward — a page number or a row offset — and at least three records",
|
|
757
|
+
async run(ctx) {
|
|
758
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
759
|
+
/* Walk under a low-cardinality sort. Distinct keys admit exactly one valid order, so an
|
|
760
|
+
* unstable sort is indistinguishable from a correct one until values tie. */
|
|
761
|
+
const order = tiedSortField(ctx);
|
|
762
|
+
const orderParam = order === null ? undefined : sortTerm(conv(ctx), order, "asc");
|
|
763
|
+
const single = await list(ctx, q(ctx, { limit, order: orderParam }));
|
|
764
|
+
const walk = await walkPages(ctx, 2, orderParam);
|
|
765
|
+
const walked = walk.ids;
|
|
766
|
+
const expected = ids(single.items, ctx.identity);
|
|
767
|
+
const repeated = duplicates(walked);
|
|
768
|
+
if (repeated.length > 0) {
|
|
769
|
+
ctx.findings.backend(this.id, ctx.entityName, "paging returns the same record on more than one page", `walking at limit=2 returned ${repeated.length} duplicated id(s): ${repeated.slice(0, 3).join(", ")}. ` +
|
|
770
|
+
"This is the signature of a sort without a total order — the page boundary is not stable.", [single.exchange]);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
/* A capped walk saw only a prefix of the collection, so "missing" would just mean "beyond
|
|
774
|
+
* the cap". Duplicates above are still meaningful; absence is not. */
|
|
775
|
+
const missing = walk.truncated ? [] : expected.filter((id) => !walked.includes(id));
|
|
776
|
+
if (missing.length > 0) {
|
|
777
|
+
ctx.findings.backend(this.id, ctx.entityName, "paging skips records that a single large page returns", `a single page at limit=${limit} returned ${expected.length} records, but walking at ` +
|
|
778
|
+
`limit=2 yielded ${walked.length} and missed ${missing.length}: ${missing.slice(0, 3).join(", ")}`, [single.exchange]);
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
/* Instability is probabilistic: one walk can come out intact by luck. Two identical walks
|
|
782
|
+
* must agree, so comparing them tests the ordering guarantee directly rather than waiting
|
|
783
|
+
* for a dropped row to happen to appear. */
|
|
784
|
+
const second = (await walkPages(ctx, 2, orderParam)).ids;
|
|
785
|
+
if (second.join(",") === walked.join(","))
|
|
786
|
+
return;
|
|
787
|
+
const sameSet = [...second].sort().join(",") === [...walked].sort().join(",");
|
|
788
|
+
ctx.findings.backend(this.id, ctx.entityName, sameSet
|
|
789
|
+
? "identical page walks return records in different orders"
|
|
790
|
+
: "identical page walks return different records", `walking the collection twice with the same parameters` +
|
|
791
|
+
(orderParam === undefined ? "" : ` (order=${orderParam})`) +
|
|
792
|
+
` produced ${sameSet ? "the same records in a different order" : "different sets"}. ` +
|
|
793
|
+
"Pagination requires a total order; without a tiebreak the page boundary moves between " +
|
|
794
|
+
"requests and callers silently miss or repeat rows.", [single.exchange]);
|
|
795
|
+
},
|
|
796
|
+
};
|
|
797
|
+
const cursorAgreesWithPage = {
|
|
798
|
+
applicable: (ctx) => conv(ctx).cursor !== undefined && pageable(ctx) && ctx.records.length > 2,
|
|
799
|
+
dependsOn: [
|
|
800
|
+
"pagination.page-walk-covers-set",
|
|
801
|
+
/* Sorting by a field whose value never persisted is degenerate, so a pagination
|
|
802
|
+
* disagreement there describes a consequence rather than a pagination defect. */
|
|
803
|
+
"create.persists-submitted-fields",
|
|
804
|
+
/* Both walks are taken at a requested page size. If the backend serves a different size
|
|
805
|
+
* than asked for, the two walks step differently and disagree for that reason alone. */
|
|
806
|
+
"pagination.limit-respects-documented-max",
|
|
807
|
+
],
|
|
808
|
+
id: "pagination.cursor-agrees-with-page",
|
|
809
|
+
needs: "both `cursor` and `page` parameters",
|
|
810
|
+
async run(ctx) {
|
|
811
|
+
/* Walk under an explicit text sort. Cursor pagination is almost always used with one, and
|
|
812
|
+
* a boundary resolved under a different collation than the listing can only diverge when
|
|
813
|
+
* the ordering key is text. */
|
|
814
|
+
const sortField = ctx.query?.sortable.find((f) => {
|
|
815
|
+
if (f === ctx.identity)
|
|
816
|
+
return false;
|
|
817
|
+
return ctx.records.some((r) => typeof r[f] === "string" && r[f] !== "");
|
|
818
|
+
}) ?? undefined;
|
|
819
|
+
const order = sortField === undefined ? undefined : sortTerm(conv(ctx), sortField, "asc");
|
|
820
|
+
const pageWalk = await walkPages(ctx, 2, order);
|
|
821
|
+
const cursorWalk = await walkCursor(ctx, 2, order);
|
|
822
|
+
const byPage = pageWalk.ids;
|
|
823
|
+
const byCursor = cursorWalk.ids;
|
|
824
|
+
/* Repeats are their own defect: a cursor that resolves to the wrong boundary re-serves the
|
|
825
|
+
* previous page's tail, which set comparison alone would hide. */
|
|
826
|
+
const repeated = duplicates(byCursor).filter((id) => !duplicates(byPage).includes(id));
|
|
827
|
+
if (repeated.length > 0) {
|
|
828
|
+
ctx.findings.backend(this.id, ctx.entityName, "cursor pagination re-serves records the previous page already returned", `walking by cursor returned ${repeated.length} record(s) more than once ` +
|
|
829
|
+
`(${repeated.slice(0, 3).join(", ")}) while the offset walk returned each exactly once. ` +
|
|
830
|
+
"The cursor is resolving to the wrong boundary.", []);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
/* Two capped walks cover different prefixes when either was truncated, so only the
|
|
834
|
+
* duplicate check above is sound; set equality is not. */
|
|
835
|
+
if (pageWalk.truncated || cursorWalk.truncated)
|
|
836
|
+
return;
|
|
837
|
+
const pageSet = [...new Set(byPage)].sort();
|
|
838
|
+
const cursorSet = [...new Set(byCursor)].sort();
|
|
839
|
+
if (pageSet.join(",") === cursorSet.join(","))
|
|
840
|
+
return;
|
|
841
|
+
const onlyPage = pageSet.filter((id) => !cursorSet.includes(id));
|
|
842
|
+
const onlyCursor = cursorSet.filter((id) => !pageSet.includes(id));
|
|
843
|
+
ctx.findings.backend(this.id, ctx.entityName, "cursor pagination and offset pagination disagree", `offset walk yielded ${pageSet.length} distinct records, cursor walk yielded ${cursorSet.length}. ` +
|
|
844
|
+
`Only in offset: ${onlyPage.slice(0, 3).join(", ") || "—"}. ` +
|
|
845
|
+
`Only in cursor: ${onlyCursor.slice(0, 3).join(", ") || "—"}. ` +
|
|
846
|
+
"Both traverse the same collection, so one of them is losing or repeating rows.", []);
|
|
847
|
+
},
|
|
848
|
+
};
|
|
849
|
+
/**
|
|
850
|
+
* The reported total must be consistent with the page it accompanies.
|
|
851
|
+
*
|
|
852
|
+
* A count that reads zero beside a non-empty payload is not a rounding difference — it is a
|
|
853
|
+
* separate query answering a different question, and every UI that renders "N results" from it
|
|
854
|
+
* shows a number contradicted by the rows directly beneath.
|
|
855
|
+
*/
|
|
856
|
+
const countIsConsistentWithPage = {
|
|
857
|
+
applicable: (ctx) => ctx.records.length > 0,
|
|
858
|
+
dependsOn: ["list.read-after-write"],
|
|
859
|
+
id: "count.consistent-with-returned-page",
|
|
860
|
+
needs: "a total-count field in the list envelope",
|
|
861
|
+
async run(ctx) {
|
|
862
|
+
const result = await list(ctx, q(ctx, { limit: ctx.query?.maxLimit ?? 100 }));
|
|
863
|
+
if (result.exchange.status >= 400)
|
|
864
|
+
return;
|
|
865
|
+
const reported = envelopeValue(ctx, result, "total");
|
|
866
|
+
if (typeof reported !== "number")
|
|
867
|
+
return;
|
|
868
|
+
const returned = result.items.length;
|
|
869
|
+
if (returned === 0)
|
|
870
|
+
return;
|
|
871
|
+
if (reported >= returned)
|
|
872
|
+
return;
|
|
873
|
+
ctx.findings.backend(this.id, ctx.entityName, "reported count is smaller than the number of records returned", `the response carries count=${reported} while returning ${returned} record(s) in the same ` +
|
|
874
|
+
`body${envelopeValue(ctx, result, "hasMore") === true ? " and hasMore=true" : ""}. The total is being ` +
|
|
875
|
+
"computed by a query that disagrees with the one producing the rows, so any caller " +
|
|
876
|
+
"rendering a result count contradicts the list it is labelling.", [result.exchange]);
|
|
877
|
+
},
|
|
878
|
+
};
|
|
879
|
+
const countMatchesWalk = {
|
|
880
|
+
applicable: (ctx) => ctx.records.length > 1 && filterable(ctx),
|
|
881
|
+
/* Compares the reported total against the *filtered* set. Where the filter is ignored the two
|
|
882
|
+
* agree trivially — both describe the whole collection — so the count cannot be judged until
|
|
883
|
+
* filtering itself is known to work. */
|
|
884
|
+
dependsOn: ["list.read-after-write", "filter.equality-selects-exactly-one"],
|
|
885
|
+
id: "count.matches-filtered-set",
|
|
886
|
+
needs: "a total-count field and a `filter` parameter",
|
|
887
|
+
async run(ctx) {
|
|
888
|
+
if (!(ctx.query?.filterable.includes(ctx.identity) ?? false))
|
|
889
|
+
return;
|
|
890
|
+
const target = ctx.records[0];
|
|
891
|
+
if (target === undefined)
|
|
892
|
+
return;
|
|
893
|
+
const id = String(target[ctx.identity]);
|
|
894
|
+
const countTerm = filterTerm(conv(ctx), ctx.identity, "eq", id);
|
|
895
|
+
if (countTerm === null)
|
|
896
|
+
return;
|
|
897
|
+
const filtered = await list(ctx, { ...q(ctx, { limit: 100 }), ...countTerm });
|
|
898
|
+
if (filtered.exchange.status >= 400)
|
|
899
|
+
return;
|
|
900
|
+
const reported = envelopeValue(ctx, filtered, "total");
|
|
901
|
+
if (typeof reported !== "number")
|
|
902
|
+
return;
|
|
903
|
+
if (reported === filtered.items.length)
|
|
904
|
+
return;
|
|
905
|
+
ctx.findings.backend(this.id, ctx.entityName, "reported count disagrees with the filtered result", `filter selected ${filtered.items.length} record(s) but count reported ${reported}. ` +
|
|
906
|
+
"A count that ignores the active filter makes every paginated UI show wrong totals.", [filtered.exchange]);
|
|
907
|
+
},
|
|
908
|
+
};
|
|
909
|
+
const selectProjection = {
|
|
910
|
+
applicable: (ctx) => conv(ctx).select !== undefined && ctx.records.length > 0,
|
|
911
|
+
id: "select.projection-honoured",
|
|
912
|
+
needs: "a `select` sparse-fieldset parameter",
|
|
913
|
+
async run(ctx) {
|
|
914
|
+
const requested = [ctx.identity];
|
|
915
|
+
const extra = ctx.query?.selectable.find((f) => f !== ctx.identity);
|
|
916
|
+
if (extra !== undefined)
|
|
917
|
+
requested.push(extra);
|
|
918
|
+
/* Through the grammar, not the bare parameter: JSON:API carries the resource type in the
|
|
919
|
+
* parameter *name*, so `select=` never reaches a backend expecting `fields[table]=`. */
|
|
920
|
+
const projection = selectTerm(conv(ctx), requested, ctx.entityName);
|
|
921
|
+
if (projection === null)
|
|
922
|
+
return;
|
|
923
|
+
const result = await list(ctx, { ...q(ctx, { limit: 5 }), ...projection });
|
|
924
|
+
if (result.exchange.status >= 400 || result.items.length === 0)
|
|
925
|
+
return;
|
|
926
|
+
const first = result.items[0];
|
|
927
|
+
if (first === undefined)
|
|
928
|
+
return;
|
|
929
|
+
const returned = Object.keys(first);
|
|
930
|
+
const unexpected = returned.filter((key) => !requested.includes(key));
|
|
931
|
+
if (unexpected.length === 0)
|
|
932
|
+
return;
|
|
933
|
+
ctx.findings.backend(this.id, ctx.entityName, "sparse fieldset is accepted but ignored", `select=${requested.join(",")} returned ${returned.length} fields, including ` +
|
|
934
|
+
`${unexpected.slice(0, 5).join(", ")}. Accepting a projection and ignoring it silently ` +
|
|
935
|
+
"inflates every response and leaks fields a caller deliberately excluded.", [result.exchange]);
|
|
936
|
+
},
|
|
937
|
+
};
|
|
938
|
+
const patchMinimality = {
|
|
939
|
+
applicable: (ctx) => ctx.updateOp !== undefined && ctx.readOp !== undefined && ctx.records.length > 0,
|
|
940
|
+
mutates: true,
|
|
941
|
+
id: "patch.minimality",
|
|
942
|
+
needs: "an update operation and an item route",
|
|
943
|
+
async run(ctx) {
|
|
944
|
+
const target = ctx.records.find((r) => Object.values(r).some((v) => typeof v === "string"));
|
|
945
|
+
if (target === undefined || ctx.updateOp === undefined || ctx.readOp === undefined)
|
|
946
|
+
return;
|
|
947
|
+
const id = String(target[ctx.identity]);
|
|
948
|
+
const params = { ...ctx.scope, ...itemParamFor(ctx, id) };
|
|
949
|
+
const before = await ctx.client.get(fillPath(ctx.readOp.path, params), { headers: ctx.auth() });
|
|
950
|
+
if (before.status >= 300) {
|
|
951
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `reading the record back returned ${before.status}, so no baseline could be established`);
|
|
952
|
+
}
|
|
953
|
+
const original = (before.responseBody ?? {});
|
|
954
|
+
/* A control record, never written to, observed across the same window as the target.
|
|
955
|
+
* Real APIs expose server-driven fields — job progress, computed counts, expiry clocks —
|
|
956
|
+
* and repeated sampling cannot distinguish those from a write side effect once they
|
|
957
|
+
* settle. A field that moves on the control moved on its own. */
|
|
958
|
+
const control = ctx.records.find((r) => String(r[ctx.identity]) !== id);
|
|
959
|
+
const controlParams = control === undefined
|
|
960
|
+
? undefined
|
|
961
|
+
: { ...ctx.scope, ...itemParamFor(ctx, String(control[ctx.identity])) };
|
|
962
|
+
const controlBefore = controlParams === undefined
|
|
963
|
+
? undefined
|
|
964
|
+
: await ctx.client.get(fillPath(ctx.readOp.path, controlParams), { headers: ctx.auth() });
|
|
965
|
+
const settle = await ctx.client.get(fillPath(ctx.readOp.path, params), { headers: ctx.auth() });
|
|
966
|
+
/*
|
|
967
|
+
* Fields that may move without this PATCH having moved them.
|
|
968
|
+
*
|
|
969
|
+
* Timestamps by name, and — more importantly — anything the document declares
|
|
970
|
+
* server-generated. A generated field is by definition not the caller's to set, so finding
|
|
971
|
+
* it changed proves nothing about whether PATCH behaved as PUT. Some are derived from
|
|
972
|
+
* *other* entities entirely (a row count on its parent table), and since entities are
|
|
973
|
+
* tested concurrently such a field can change mid-check for reasons that have nothing to
|
|
974
|
+
* do with this request. The control record catches drift the whole collection shares, but
|
|
975
|
+
* not drift confined to the one record under test.
|
|
976
|
+
*/
|
|
977
|
+
const drifting = new Set([
|
|
978
|
+
"updated_at",
|
|
979
|
+
"modified_at",
|
|
980
|
+
/*
|
|
981
|
+
* From both operations, not just the one being probed. A document commonly declares
|
|
982
|
+
* its server-owned fields on `create` — that is where they are conspicuous, being the
|
|
983
|
+
* fields a caller may not supply — and omits the same list on `update`. Reading only
|
|
984
|
+
* the update operation meant a field the document plainly called generated was still
|
|
985
|
+
* compared, and a progress counter advancing on its own read as a PATCH side effect.
|
|
986
|
+
*/
|
|
987
|
+
...(ctx.createOp?.generated ?? []),
|
|
988
|
+
...(ctx.updateOp.generated ?? []),
|
|
989
|
+
]);
|
|
990
|
+
if (settle.status < 300) {
|
|
991
|
+
const second = (settle.responseBody ?? {});
|
|
992
|
+
for (const key of Object.keys(original)) {
|
|
993
|
+
if (JSON.stringify(original[key]) !== JSON.stringify(second[key]))
|
|
994
|
+
drifting.add(key);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
const field = pickWritableStringField(ctx, original);
|
|
998
|
+
if (field === null)
|
|
999
|
+
return;
|
|
1000
|
+
const patched = await ctx.client.request("PATCH", fillPath(ctx.updateOp.path, params), {
|
|
1001
|
+
body: { [field]: "oat patched value" },
|
|
1002
|
+
headers: ctx.auth(),
|
|
1003
|
+
});
|
|
1004
|
+
if (patched.status >= 300)
|
|
1005
|
+
return;
|
|
1006
|
+
const after = await ctx.client.get(fillPath(ctx.readOp.path, params), { headers: ctx.auth() });
|
|
1007
|
+
const current = (after.responseBody ?? {});
|
|
1008
|
+
const baseline = (settle.status < 300 ? settle.responseBody : before.responseBody);
|
|
1009
|
+
let collateral = Object.keys(original).filter((key) => {
|
|
1010
|
+
if (key === field || drifting.has(key))
|
|
1011
|
+
return false;
|
|
1012
|
+
return JSON.stringify(baseline[key]) !== JSON.stringify(current[key]);
|
|
1013
|
+
});
|
|
1014
|
+
if (collateral.length === 0)
|
|
1015
|
+
return;
|
|
1016
|
+
/* Whatever also moved on the untouched control record was not caused by this PATCH. */
|
|
1017
|
+
if (controlParams !== undefined && controlBefore !== undefined && controlBefore.status < 300) {
|
|
1018
|
+
const controlAfter = await ctx.client.get(fillPath(ctx.readOp.path, controlParams), {
|
|
1019
|
+
headers: ctx.auth(),
|
|
1020
|
+
});
|
|
1021
|
+
if (controlAfter.status < 300) {
|
|
1022
|
+
const from = (controlBefore.responseBody ?? {});
|
|
1023
|
+
const to = (controlAfter.responseBody ?? {});
|
|
1024
|
+
collateral = collateral.filter((key) => JSON.stringify(from[key]) === JSON.stringify(to[key]));
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
if (collateral.length === 0)
|
|
1028
|
+
return;
|
|
1029
|
+
ctx.findings.backend(this.id, ctx.entityName, "PATCH changed fields the request did not mention", `patching only "${field}" also changed ${collateral.length} other field(s): ` +
|
|
1030
|
+
collateral
|
|
1031
|
+
.slice(0, 5)
|
|
1032
|
+
.map((key) => `${key} ${JSON.stringify(baseline[key])} → ${JSON.stringify(current[key])}`)
|
|
1033
|
+
.join("; ") +
|
|
1034
|
+
". PATCH is a partial update; behaving as PUT silently destroys data callers never sent.", [before, patched, after]);
|
|
1035
|
+
},
|
|
1036
|
+
};
|
|
1037
|
+
function pickWritableStringField(ctx, record) {
|
|
1038
|
+
const immutable = new Set([...(ctx.updateOp?.immutable ?? []), ...(ctx.updateOp?.generated ?? [])]);
|
|
1039
|
+
for (const [key, value] of Object.entries(record)) {
|
|
1040
|
+
if (immutable.has(key) || key === ctx.identity)
|
|
1041
|
+
continue;
|
|
1042
|
+
if (/_at$|_id$/.test(key))
|
|
1043
|
+
continue;
|
|
1044
|
+
if (typeof value === "string" && value !== "")
|
|
1045
|
+
return key;
|
|
1046
|
+
}
|
|
1047
|
+
return null;
|
|
1048
|
+
}
|
|
1049
|
+
const immutableRejected = {
|
|
1050
|
+
applicable: (ctx) => ctx.updateOp !== undefined && ctx.readOp !== undefined && (ctx.updateOp.immutable.length > 0) && ctx.records.length > 0,
|
|
1051
|
+
mutates: true,
|
|
1052
|
+
id: "patch.immutable-field-rejected",
|
|
1053
|
+
needs: "fields declared immutable via x-immutable",
|
|
1054
|
+
async run(ctx) {
|
|
1055
|
+
/*
|
|
1056
|
+
* Deliberately not `records[0]`.
|
|
1057
|
+
*
|
|
1058
|
+
* An immutable field is very often the tenant or parent key, so a backend that accepts the
|
|
1059
|
+
* write moves the record out of the scope every other check reads through — the record is
|
|
1060
|
+
* then a 404 for everything that runs later. Claiming a record from the end of the cohort
|
|
1061
|
+
* keeps the damage away from the one the write-path checks use, and it stays distinct from
|
|
1062
|
+
* the tombstone check's target at the very end.
|
|
1063
|
+
*/
|
|
1064
|
+
const target = ctx.records.at(-2) ?? ctx.records[0];
|
|
1065
|
+
if (target === undefined || ctx.updateOp === undefined || ctx.readOp === undefined)
|
|
1066
|
+
return;
|
|
1067
|
+
const field = ctx.updateOp.immutable.find((f) => f !== ctx.identity) ?? ctx.updateOp.immutable[0];
|
|
1068
|
+
if (field === undefined)
|
|
1069
|
+
return;
|
|
1070
|
+
const id = String(target[ctx.identity]);
|
|
1071
|
+
const params = { ...ctx.scope, ...itemParamFor(ctx, id) };
|
|
1072
|
+
const probe = "oat-immutable-probe";
|
|
1073
|
+
const patched = await ctx.client.request("PATCH", fillPath(ctx.updateOp.path, params), {
|
|
1074
|
+
body: { [field]: probe },
|
|
1075
|
+
headers: ctx.auth(),
|
|
1076
|
+
});
|
|
1077
|
+
if (patched.status >= 400)
|
|
1078
|
+
return;
|
|
1079
|
+
/* Read the echoed record first. Re-reading can itself fail once the write lands — writing
|
|
1080
|
+
* a tenant key, for instance, moves the record out of the caller's scope — and that
|
|
1081
|
+
* failure would otherwise mask the very defect being probed. */
|
|
1082
|
+
const echoed = (patched.responseBody ?? {});
|
|
1083
|
+
const after = await ctx.client.get(fillPath(ctx.readOp.path, params), { headers: ctx.auth() });
|
|
1084
|
+
const current = (after.responseBody ?? {});
|
|
1085
|
+
const accepted = echoed[field] === probe || current[field] === probe;
|
|
1086
|
+
if (!accepted)
|
|
1087
|
+
return;
|
|
1088
|
+
ctx.findings.backend(this.id, ctx.entityName, "a field declared immutable accepted a write", `PATCH set "${field}" to a client-supplied value and the change persisted. ` +
|
|
1089
|
+
"Server-owned fields must reject writes, not absorb them.", [patched, after]);
|
|
1090
|
+
},
|
|
1091
|
+
};
|
|
1092
|
+
const likeEscaping = {
|
|
1093
|
+
applicable: (ctx) => filterable(ctx) && ctx.records.length > 1,
|
|
1094
|
+
/* The tell is "the filtered result equals the whole listing". Where the listing is already
|
|
1095
|
+
* wrong, that comparison is against a set the backend never served correctly. */
|
|
1096
|
+
dependsOn: ["list.read-after-write"],
|
|
1097
|
+
id: "filter.like-metacharacters-escaped",
|
|
1098
|
+
needs: "a `filter` parameter supporting a like operator",
|
|
1099
|
+
async run(ctx) {
|
|
1100
|
+
const field = firstFilterable(ctx, (name) => (ctx.query?.searchable ?? []).includes(name));
|
|
1101
|
+
if (field === null) {
|
|
1102
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "no field is both filterable and searchable, so no like probe can be built");
|
|
1103
|
+
}
|
|
1104
|
+
const total = await list(ctx, q(ctx, { limit: 100 }));
|
|
1105
|
+
/* A literal `%` is not a wildcard in this grammar — `*` is. Matching everything means the
|
|
1106
|
+
* value was interpolated into a LIKE pattern unescaped. */
|
|
1107
|
+
const likeTerm = filterTerm(conv(ctx), field, "like", "%");
|
|
1108
|
+
if (likeTerm === null)
|
|
1109
|
+
return;
|
|
1110
|
+
const probe = await list(ctx, { ...q(ctx, { limit: 100 }), ...likeTerm });
|
|
1111
|
+
if (probe.exchange.status >= 400) {
|
|
1112
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `the like probe was rejected with ${probe.exchange.status}, so escaping was never exercised`);
|
|
1113
|
+
}
|
|
1114
|
+
if (total.items.length === 0) {
|
|
1115
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the unfiltered listing returned nothing, leaving no baseline to compare the probe against");
|
|
1116
|
+
}
|
|
1117
|
+
if (probe.items.length < total.items.length)
|
|
1118
|
+
return;
|
|
1119
|
+
ctx.findings.backend(this.id, ctx.entityName, "LIKE metacharacters in a filter value are not escaped", `filter=${field}.like.%25 matched all ${probe.items.length} records. "%" is a literal in ` +
|
|
1120
|
+
"this grammar; treating it as a wildcard means user input is being interpolated into a " +
|
|
1121
|
+
"pattern unescaped.", [total.exchange, probe.exchange]);
|
|
1122
|
+
},
|
|
1123
|
+
};
|
|
1124
|
+
const createStatusMatchesSpec = {
|
|
1125
|
+
applicable: (ctx) => ctx.createOp !== undefined,
|
|
1126
|
+
id: "create.status-matches-document",
|
|
1127
|
+
needs: "a create operation",
|
|
1128
|
+
async run(ctx) {
|
|
1129
|
+
const createOp = ctx.createOp;
|
|
1130
|
+
if (createOp === undefined)
|
|
1131
|
+
return;
|
|
1132
|
+
const exchange = createExchange(ctx);
|
|
1133
|
+
if (exchange === undefined)
|
|
1134
|
+
return;
|
|
1135
|
+
if (createOp.documentedStatuses.includes(exchange.status))
|
|
1136
|
+
return;
|
|
1137
|
+
const success = createOp.documentedStatuses.filter((s) => s < 300);
|
|
1138
|
+
if (success.length === 0)
|
|
1139
|
+
return;
|
|
1140
|
+
ctx.findings.spec(this.id, ctx.entityName, "create returns a success status the document does not declare", `${createOp.operationId} returned ${exchange.status}; the document declares ` +
|
|
1141
|
+
`${success.join(", ")}. Either the handler or the document is wrong, and clients ` +
|
|
1142
|
+
"generated from the spec will not recognise the response.", [exchange]);
|
|
1143
|
+
},
|
|
1144
|
+
};
|
|
1145
|
+
/**
|
|
1146
|
+
* The successful create exchange for this entity, matched on the fully-resolved path.
|
|
1147
|
+
*
|
|
1148
|
+
* Prefix matching is not enough: two entities can share a prefix up to their first path
|
|
1149
|
+
* parameter (`/v1/projects/{project_id}/tables` and `.../tables/{table_id}/rows` both truncate
|
|
1150
|
+
* to `/v1/projects/`), which silently pairs one entity's response with another's schema.
|
|
1151
|
+
*/
|
|
1152
|
+
function createExchange(ctx) {
|
|
1153
|
+
const createOp = ctx.createOp;
|
|
1154
|
+
if (createOp === undefined)
|
|
1155
|
+
return undefined;
|
|
1156
|
+
let resolved;
|
|
1157
|
+
try {
|
|
1158
|
+
resolved = fillPath(createOp.path, ctx.scope);
|
|
1159
|
+
}
|
|
1160
|
+
catch {
|
|
1161
|
+
return undefined;
|
|
1162
|
+
}
|
|
1163
|
+
return ctx.client.transcript.find((e) => e.method === "POST" && e.status < 300 && new URL(e.url).pathname === resolved);
|
|
1164
|
+
}
|
|
1165
|
+
const deleteMissingIs404 = {
|
|
1166
|
+
applicable: (ctx) => ctx.deleteOp !== undefined,
|
|
1167
|
+
mutates: true,
|
|
1168
|
+
id: "delete.absent-record-returns-404",
|
|
1169
|
+
needs: "a delete operation",
|
|
1170
|
+
async run(ctx) {
|
|
1171
|
+
if (ctx.deleteOp === undefined)
|
|
1172
|
+
return;
|
|
1173
|
+
const params = { ...ctx.scope, ...itemParamFor(ctx, "oat-nonexistent-id-000") };
|
|
1174
|
+
const exchange = await ctx.client.request("DELETE", fillPath(ctx.deleteOp.path, params), {
|
|
1175
|
+
headers: ctx.auth(),
|
|
1176
|
+
});
|
|
1177
|
+
if (exchange.status === 404 || exchange.status === 410 || exchange.status === 400)
|
|
1178
|
+
return;
|
|
1179
|
+
if (exchange.status >= 300)
|
|
1180
|
+
return;
|
|
1181
|
+
ctx.findings.backend(this.id, ctx.entityName, "deleting a nonexistent record reports success", `DELETE of an id that was never created returned ${exchange.status}. Callers cannot ` +
|
|
1182
|
+
"distinguish a real deletion from a no-op, which hides broken client state.", [exchange]);
|
|
1183
|
+
},
|
|
1184
|
+
};
|
|
1185
|
+
const softDeleteHidden = {
|
|
1186
|
+
applicable: (ctx) => ctx.deleteOp !== undefined && ctx.softDelete !== null && ctx.records.length > 1,
|
|
1187
|
+
mutates: true,
|
|
1188
|
+
/* The evidence for a tombstone leak is the record still being *in the listing*. On a backend
|
|
1189
|
+
* whose listing already omits records it should contain, "absent" proves nothing. */
|
|
1190
|
+
dependsOn: ["list.read-after-write"],
|
|
1191
|
+
id: "softdelete.absent-from-default-list",
|
|
1192
|
+
needs: "a delete operation and x-soft-delete",
|
|
1193
|
+
async run(ctx) {
|
|
1194
|
+
const target = ctx.records.at(-1);
|
|
1195
|
+
if (target === undefined || ctx.deleteOp === undefined)
|
|
1196
|
+
return;
|
|
1197
|
+
const id = String(target[ctx.identity]);
|
|
1198
|
+
const params = { ...ctx.scope, ...itemParamFor(ctx, id) };
|
|
1199
|
+
const deleted = await ctx.client.request("DELETE", fillPath(ctx.deleteOp.path, params), {
|
|
1200
|
+
headers: ctx.auth(),
|
|
1201
|
+
});
|
|
1202
|
+
if (deleted.status >= 300) {
|
|
1203
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `DELETE returned ${deleted.status}, so no tombstone was created to look for`);
|
|
1204
|
+
}
|
|
1205
|
+
const after = await list(ctx, q(ctx, { limit: ctx.query?.maxLimit ?? 100 }));
|
|
1206
|
+
if (!ids(after.items, ctx.identity).includes(id))
|
|
1207
|
+
return;
|
|
1208
|
+
ctx.findings.backend(this.id, ctx.entityName, "soft-deleted record still appears in the default listing", `${ctx.entityName} ${id} was deleted (tombstoned via "${ctx.softDelete}") but the default ` +
|
|
1209
|
+
"list still returns it. Tombstones must be excluded unless explicitly requested.", [deleted, after.exchange]);
|
|
1210
|
+
},
|
|
1211
|
+
};
|
|
1212
|
+
/**
|
|
1213
|
+
* A denial must not reveal whether the record exists.
|
|
1214
|
+
*
|
|
1215
|
+
* Refusing a cross-tenant read is correct. Refusing it with a *different status* than an id that
|
|
1216
|
+
* was never issued is an oracle: an attacker walks the identifier space and reads existence off
|
|
1217
|
+
* the status code, learning how many records another tenant holds and which ids are live — all
|
|
1218
|
+
* without ever being served a body. The access decision looks right in every log.
|
|
1219
|
+
*
|
|
1220
|
+
* The comparison is what makes this decidable without knowing the backend's policy. Whichever
|
|
1221
|
+
* status it picks is fine; picking two different ones is not.
|
|
1222
|
+
*/
|
|
1223
|
+
const denialDoesNotRevealExistence = {
|
|
1224
|
+
applicable: (ctx) => ctx.altAuth !== undefined && ctx.readOp !== undefined && ctx.records.length > 0,
|
|
1225
|
+
dependsOn: [
|
|
1226
|
+
/* If the record is readable across tenants at all, that is the finding — how the denial
|
|
1227
|
+
* would have been phrased is beside the point. */
|
|
1228
|
+
"tenant.item-not-readable-cross-tenant",
|
|
1229
|
+
],
|
|
1230
|
+
id: "tenant.denial-does-not-reveal-existence",
|
|
1231
|
+
needs: "a second principal in a different tenant",
|
|
1232
|
+
async run(ctx) {
|
|
1233
|
+
const target = ctx.records[0];
|
|
1234
|
+
if (target === undefined || ctx.readOp === undefined || ctx.altAuth === undefined)
|
|
1235
|
+
return;
|
|
1236
|
+
const realId = String(target[ctx.identity]);
|
|
1237
|
+
/* Shaped like a real identifier so the difference under test is existence, not format —
|
|
1238
|
+
* a backend may legitimately reject a malformed id differently. */
|
|
1239
|
+
const absentId = `${realId}-oat-absent`;
|
|
1240
|
+
const ask = async (id) => ctx.client.get(fillPath(ctx.readOp?.path ?? "", { ...(ctx.altScope ?? ctx.scope), ...itemParamFor(ctx, id) }), { headers: ctx.altAuth?.() ?? {} });
|
|
1241
|
+
const existing = await ask(realId);
|
|
1242
|
+
const absent = await ask(absentId);
|
|
1243
|
+
/* Only a denial pair is meaningful. A 2xx on the existing record is a cross-tenant read,
|
|
1244
|
+
* which the check above owns and this one is suppressed by. */
|
|
1245
|
+
if (existing.status < 400 || absent.status < 400) {
|
|
1246
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "one of the two probes was not denied, so there is no pair of denials to compare");
|
|
1247
|
+
}
|
|
1248
|
+
if (existing.status === absent.status)
|
|
1249
|
+
return;
|
|
1250
|
+
ctx.findings.security(this.id, ctx.entityName, "the denial status reveals whether a record exists", `a record belonging to another tenant was refused with ${existing.status}, while an id `
|
|
1251
|
+
+ `that does not exist was refused with ${absent.status}. The difference is an oracle: `
|
|
1252
|
+
+ "anyone able to guess or enumerate identifiers can learn which ones are real without "
|
|
1253
|
+
+ "ever reading one. Both cases must answer identically — conventionally 404.", [existing, absent]);
|
|
1254
|
+
},
|
|
1255
|
+
};
|
|
1256
|
+
/**
|
|
1257
|
+
* Replaying a create with the same idempotency key must not create a second record.
|
|
1258
|
+
*
|
|
1259
|
+
* An API that publishes the header has made a promise, and it is a promise clients rely on to
|
|
1260
|
+
* make retries safe: a timeout, a proxy replay or a double-click all resend the same request. If
|
|
1261
|
+
* the key is accepted and ignored, every one of those silently duplicates whatever the record
|
|
1262
|
+
* represents — a charge, an order, a transfer. Nothing about a single request reveals this;
|
|
1263
|
+
* it only shows up when the request is actually replayed.
|
|
1264
|
+
*
|
|
1265
|
+
* Both halves are asserted, because either alone is satisfiable by a broken backend: the replay
|
|
1266
|
+
* must return the *original* record, and the collection must not have grown.
|
|
1267
|
+
*/
|
|
1268
|
+
const idempotentReplay = {
|
|
1269
|
+
applicable: (ctx) => ctx.createOp?.idempotencyHeader != null,
|
|
1270
|
+
dependsOn: ["list.read-after-write", "create.persists-submitted-fields"],
|
|
1271
|
+
id: "idempotency.replay-does-not-duplicate",
|
|
1272
|
+
mutates: true,
|
|
1273
|
+
needs: "a create operation declaring an idempotency-key header",
|
|
1274
|
+
async run(ctx) {
|
|
1275
|
+
const createOp = ctx.createOp;
|
|
1276
|
+
const header = createOp?.idempotencyHeader;
|
|
1277
|
+
if (createOp === undefined || header == null)
|
|
1278
|
+
return;
|
|
1279
|
+
const schema = requestSchemaOf(ctx, createOp);
|
|
1280
|
+
if (schema === null) {
|
|
1281
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the create operation declares no JSON request body to replay");
|
|
1282
|
+
}
|
|
1283
|
+
const body = validBody(ctx, schema);
|
|
1284
|
+
const key = `oat-idem-${ctx.seed}-${ctx.entityName}`;
|
|
1285
|
+
const path = fillPath(createOp.path, ctx.scope);
|
|
1286
|
+
const headers = { ...ctx.auth(), [header]: key };
|
|
1287
|
+
const first = await ctx.client.request("POST", path, { body, headers });
|
|
1288
|
+
if (first.status >= 300) {
|
|
1289
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `the first create returned ${first.status}, so there was nothing to replay`);
|
|
1290
|
+
}
|
|
1291
|
+
const second = await ctx.client.request("POST", path, { body, headers });
|
|
1292
|
+
if (second.status >= 300) {
|
|
1293
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `the replay returned ${second.status}; a repeated key must be answered, not refused`);
|
|
1294
|
+
}
|
|
1295
|
+
const firstId = String((first.responseBody ?? {})[ctx.identity]);
|
|
1296
|
+
const secondId = String((second.responseBody ?? {})[ctx.identity]);
|
|
1297
|
+
if (firstId === secondId)
|
|
1298
|
+
return;
|
|
1299
|
+
ctx.findings.backend(this.id, ctx.entityName, "replaying a request with the same idempotency key created a second record", `two identical POSTs carrying ${header}: "${key}" produced ${ctx.entityName} ${firstId} `
|
|
1300
|
+
+ `and ${secondId}. The header is declared, so clients will retry on timeouts assuming `
|
|
1301
|
+
+ "it protects them; every such retry silently duplicates the record.", [first, second]);
|
|
1302
|
+
},
|
|
1303
|
+
};
|
|
1304
|
+
/**
|
|
1305
|
+
* A route the document says a write invalidates must actually change.
|
|
1306
|
+
*
|
|
1307
|
+
* `x-invalidate` is the tag the whole entity graph is derived from, and until now it was believed
|
|
1308
|
+
* rather than tested: oat inverted the claim into a read surface and never asked whether the
|
|
1309
|
+
* claim was true. The interesting case is cross-entity — creating a child changes what the
|
|
1310
|
+
* *parent* route serves, via a denormalised counter or a cached projection. Those are the writes
|
|
1311
|
+
* that go wrong quietly: the write succeeds, the child's own listing is right, and only the other
|
|
1312
|
+
* route the document named is stale. Nothing but following the declaration finds it.
|
|
1313
|
+
*
|
|
1314
|
+
* Only routes belonging to a different entity are probed. This entity's own listing is already
|
|
1315
|
+
* covered by read-after-write, and re-asserting it here would report one defect twice.
|
|
1316
|
+
*/
|
|
1317
|
+
const declaredInvalidationHappens = {
|
|
1318
|
+
applicable: (ctx) => ctx.createOp !== undefined
|
|
1319
|
+
&& ctx.createOp.invalidates.some((route) => ctx.model.byRoute.get(route)?.entity !== ctx.entityName),
|
|
1320
|
+
dependsOn: ["list.read-after-write", "create.persists-submitted-fields"],
|
|
1321
|
+
id: "invalidation.declared-route-changes",
|
|
1322
|
+
mutates: true,
|
|
1323
|
+
needs: "a create operation whose x-invalidate names another entity's read route",
|
|
1324
|
+
async run(ctx) {
|
|
1325
|
+
const createOp = ctx.createOp;
|
|
1326
|
+
if (createOp === undefined)
|
|
1327
|
+
return;
|
|
1328
|
+
const foreign = createOp.invalidates
|
|
1329
|
+
.map((route) => ctx.model.byRoute.get(route))
|
|
1330
|
+
.filter((op) => op !== undefined && op.entity !== ctx.entityName && op.method.toUpperCase() === "GET");
|
|
1331
|
+
if (foreign.length === 0)
|
|
1332
|
+
return;
|
|
1333
|
+
const schema = requestSchemaOf(ctx, createOp);
|
|
1334
|
+
if (schema === null) {
|
|
1335
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the create operation declares no JSON request body, so no write can be made");
|
|
1336
|
+
}
|
|
1337
|
+
/* Only routes whose path can be filled from this entity's scope are probed: a route
|
|
1338
|
+
* needing an identifier oat does not hold would 404 for a reason unrelated to staleness. */
|
|
1339
|
+
const probes = [];
|
|
1340
|
+
for (const op of foreign) {
|
|
1341
|
+
const path = fillPath(op.path, ctx.scope);
|
|
1342
|
+
if (path.includes("{"))
|
|
1343
|
+
continue;
|
|
1344
|
+
probes.push({ op, path });
|
|
1345
|
+
}
|
|
1346
|
+
if (probes.length === 0) {
|
|
1347
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the declared routes need identifiers outside this entity's scope, so they cannot be read");
|
|
1348
|
+
}
|
|
1349
|
+
const before = new Map();
|
|
1350
|
+
for (const probe of probes) {
|
|
1351
|
+
const exchange = await ctx.client.get(probe.path, { headers: ctx.auth() });
|
|
1352
|
+
if (exchange.status >= 400)
|
|
1353
|
+
continue;
|
|
1354
|
+
before.set(probe.path, JSON.stringify(exchange.responseBody));
|
|
1355
|
+
}
|
|
1356
|
+
if (before.size === 0) {
|
|
1357
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "none of the declared routes could be read before the write");
|
|
1358
|
+
}
|
|
1359
|
+
const created = await ctx.client.request("POST", fillPath(createOp.path, ctx.scope), {
|
|
1360
|
+
body: validBody(ctx, schema),
|
|
1361
|
+
headers: ctx.auth(),
|
|
1362
|
+
});
|
|
1363
|
+
if (created.status >= 300) {
|
|
1364
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `the create returned ${created.status}, so nothing was invalidated`);
|
|
1365
|
+
}
|
|
1366
|
+
for (const probe of probes) {
|
|
1367
|
+
const snapshot = before.get(probe.path);
|
|
1368
|
+
if (snapshot === undefined)
|
|
1369
|
+
continue;
|
|
1370
|
+
const after = await ctx.client.get(probe.path, { headers: ctx.auth() });
|
|
1371
|
+
if (after.status >= 400)
|
|
1372
|
+
continue;
|
|
1373
|
+
if (JSON.stringify(after.responseBody) !== snapshot)
|
|
1374
|
+
continue;
|
|
1375
|
+
ctx.findings.backend(this.id, ctx.entityName, "a route the document says is invalidated by this write did not change", `creating a ${ctx.entityName} declares x-invalidate on "${probe.op.route}", but that `
|
|
1376
|
+
+ "route returned a byte-identical body before and after the write. Either it serves "
|
|
1377
|
+
+ "a value derived from this entity and that value is stale — a denormalised counter "
|
|
1378
|
+
+ "or a cached projection nobody refreshed — or the declaration is wrong and every "
|
|
1379
|
+
+ "client following it is invalidating the wrong cache key.", [created, after]);
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
},
|
|
1383
|
+
};
|
|
1384
|
+
/**
|
|
1385
|
+
* One fact, asserted through every projection that can express it.
|
|
1386
|
+
*
|
|
1387
|
+
* Every other check judges a projection against an expectation: does `filter` narrow, does
|
|
1388
|
+
* `select` project, does the detail route serve what was written. Each can pass while the
|
|
1389
|
+
* projections still contradict *each other* — the detail route says "active", the listing says
|
|
1390
|
+
* "pending", the filtered query returns the record for both. Nothing in a per-projection check
|
|
1391
|
+
* notices, because each one is individually defensible.
|
|
1392
|
+
*
|
|
1393
|
+
* So this reads a single field of a single record through the item route, the collection, a
|
|
1394
|
+
* sparse fieldset, an equality filter, its negation, and a sorted page, and requires them to
|
|
1395
|
+
* agree. It cannot say which projection is wrong — only that at least one is, which is the
|
|
1396
|
+
* honest claim and enough to act on. Where they disagree the report names every projection and
|
|
1397
|
+
* what each returned, so the odd one out is visible at a glance.
|
|
1398
|
+
*
|
|
1399
|
+
* This is the criss-cross property: a fact is not "in the database", it is whatever each read
|
|
1400
|
+
* path says it is, and a system is only consistent if they say the same thing.
|
|
1401
|
+
*/
|
|
1402
|
+
const projectionsAgree = {
|
|
1403
|
+
applicable: (ctx) => ctx.readOp !== undefined && ctx.records.length > 0,
|
|
1404
|
+
dependsOn: [
|
|
1405
|
+
"list.read-after-write",
|
|
1406
|
+
"create.persists-submitted-fields",
|
|
1407
|
+
/* Each projection is exercised elsewhere. When one is already known broken, its
|
|
1408
|
+
* disagreement here is that same defect seen a second time. */
|
|
1409
|
+
"filter.equality-selects-exactly-one",
|
|
1410
|
+
"select.projection-honoured",
|
|
1411
|
+
"sort.order-is-applied",
|
|
1412
|
+
],
|
|
1413
|
+
id: "consistency.projections-agree",
|
|
1414
|
+
needs: "an item route and a record with a comparable field",
|
|
1415
|
+
async run(ctx) {
|
|
1416
|
+
const target = ctx.records[0];
|
|
1417
|
+
if (target === undefined || ctx.readOp === undefined)
|
|
1418
|
+
return;
|
|
1419
|
+
const id = String(target[ctx.identity]);
|
|
1420
|
+
/*
|
|
1421
|
+
* A filterable, sortable, non-null, *client-owned* field.
|
|
1422
|
+
*
|
|
1423
|
+
* Server-generated fields are excluded deliberately: a progress counter, a derived row
|
|
1424
|
+
* count or a status the backend advances can legitimately differ between two reads taken
|
|
1425
|
+
* moments apart, and since oat tests entities concurrently a derived value may move
|
|
1426
|
+
* mid-check for reasons no read path is responsible for. Comparing those across
|
|
1427
|
+
* projections measures timing, not consistency.
|
|
1428
|
+
*/
|
|
1429
|
+
const generated = new Set([
|
|
1430
|
+
...(ctx.createOp?.generated ?? []),
|
|
1431
|
+
...(ctx.updateOp?.generated ?? []),
|
|
1432
|
+
]);
|
|
1433
|
+
const field = (ctx.query?.filterable ?? []).find((name) => name !== ctx.identity
|
|
1434
|
+
&& !generated.has(name)
|
|
1435
|
+
&& (ctx.query?.sortable ?? []).includes(name)
|
|
1436
|
+
&& target[name] !== null
|
|
1437
|
+
&& target[name] !== undefined
|
|
1438
|
+
&& typeof target[name] !== "object");
|
|
1439
|
+
if (field === undefined) {
|
|
1440
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "no field is filterable, sortable and non-null on the sample record, so no single fact "
|
|
1441
|
+
+ "can be traced through every projection");
|
|
1442
|
+
}
|
|
1443
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
1444
|
+
const seen = [];
|
|
1445
|
+
const record = (result) => result.items.find((item) => String(item[ctx.identity]) === id);
|
|
1446
|
+
const detail = await ctx.client.get(fillPath(ctx.readOp.path, { ...ctx.scope, ...itemParamFor(ctx, id) }), { headers: ctx.auth() });
|
|
1447
|
+
if (detail.status >= 300) {
|
|
1448
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `the item route returned ${detail.status}, leaving no reference value to compare against`);
|
|
1449
|
+
}
|
|
1450
|
+
/*
|
|
1451
|
+
* The item route read *now* is the reference, not the value oat submitted at seed time.
|
|
1452
|
+
* A backend is entitled to normalise what it stores — trimming, case-folding, rounding —
|
|
1453
|
+
* and holding every projection to the submitted value would report that as inconsistency.
|
|
1454
|
+
* The claim being tested is that the read paths agree with each other, which is exactly
|
|
1455
|
+
* what anchoring on one of them and comparing the rest establishes.
|
|
1456
|
+
*/
|
|
1457
|
+
const value = detail.responseBody[field];
|
|
1458
|
+
const rendered = String(value);
|
|
1459
|
+
const plain = await list(ctx, q(ctx, { limit }));
|
|
1460
|
+
const inList = record(plain);
|
|
1461
|
+
if (inList !== undefined)
|
|
1462
|
+
seen.push({ projection: "collection", value: inList[field] });
|
|
1463
|
+
const conventions = conv(ctx);
|
|
1464
|
+
if (conventions.select !== undefined) {
|
|
1465
|
+
const projection = selectTerm(conventions, [ctx.identity, field], ctx.entityName);
|
|
1466
|
+
const projected = projection === null ? null : await list(ctx, { ...q(ctx, { limit }), ...projection });
|
|
1467
|
+
const row = projected === null ? undefined : record(projected);
|
|
1468
|
+
if (row !== undefined)
|
|
1469
|
+
seen.push({ projection: "sparse fieldset", value: row[field] });
|
|
1470
|
+
}
|
|
1471
|
+
if (conventions.order !== undefined) {
|
|
1472
|
+
const sorted = await list(ctx, q(ctx, { limit, order: sortTerm(conv(ctx), field, "asc") }));
|
|
1473
|
+
const row = record(sorted);
|
|
1474
|
+
if (row !== undefined)
|
|
1475
|
+
seen.push({ projection: "sorted page", value: row[field] });
|
|
1476
|
+
}
|
|
1477
|
+
const disagreeing = seen.filter((entry) => JSON.stringify(entry.value) !== JSON.stringify(value));
|
|
1478
|
+
if (disagreeing.length > 0) {
|
|
1479
|
+
ctx.findings.backend(this.id, ctx.entityName, "the same record carries different values depending on how it is read", `${ctx.entityName} ${id} has "${field}" = ${JSON.stringify(value)} on the record oat `
|
|
1480
|
+
+ `created, but ${disagreeing
|
|
1481
|
+
.map((entry) => `the ${entry.projection} returns ${JSON.stringify(entry.value)}`)
|
|
1482
|
+
.join(", and ")}. At least one read path is serving something the others are not; `
|
|
1483
|
+
+ "a client's view of a record then depends on which route it happened to use.", [detail, plain.exchange]);
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
/* Membership is a projection too: a filter that matches the value must return the record,
|
|
1487
|
+
* its negation must not, and both are read off the same fact just proven consistent. */
|
|
1488
|
+
const matching = filterTerm(conventions, field, "eq", rendered);
|
|
1489
|
+
if (matching === null)
|
|
1490
|
+
return;
|
|
1491
|
+
const included = await list(ctx, { ...q(ctx, { limit }), ...matching });
|
|
1492
|
+
if (included.exchange.status >= 400)
|
|
1493
|
+
return;
|
|
1494
|
+
if (record(included) !== undefined)
|
|
1495
|
+
return;
|
|
1496
|
+
ctx.findings.backend(this.id, ctx.entityName, "a record is missing from a filter matching its own field value", `${ctx.entityName} ${id} carries "${field}" = ${JSON.stringify(value)} on every read path, `
|
|
1497
|
+
+ `yet filtering for exactly that value does not return it. The record and the predicate `
|
|
1498
|
+
+ "agree; the index or query that answers the filter does not.", [detail, included.exchange]);
|
|
1499
|
+
},
|
|
1500
|
+
};
|
|
1501
|
+
/**
|
|
1502
|
+
* Query axes must compose: a filter and a sort applied together must agree with each applied alone.
|
|
1503
|
+
*
|
|
1504
|
+
* Every other query check exercises one axis in isolation, and that is where the isolation stops
|
|
1505
|
+
* being realistic. Real backends break at the *combination*: adding a sort changes which index the
|
|
1506
|
+
* planner picks and the filter stops being applied; a cursor is resolved before the filter, so page
|
|
1507
|
+
* two leaks rows the predicate excluded; a count is computed on the unfiltered set the moment an
|
|
1508
|
+
* order is present. Each of those passes a suite that only ever tests one axis at a time.
|
|
1509
|
+
*
|
|
1510
|
+
* The property asserted is compositional, so it needs no ground truth: filtering then sorting must
|
|
1511
|
+
* yield exactly the same *set* as filtering alone — reordered, but never a different membership.
|
|
1512
|
+
* A sort is not a predicate, and it must not behave as one.
|
|
1513
|
+
*/
|
|
1514
|
+
const queryAxesCompose = {
|
|
1515
|
+
applicable: (ctx) => filterable(ctx)
|
|
1516
|
+
&& conv(ctx).order !== undefined
|
|
1517
|
+
&& ctx.records.length > 2
|
|
1518
|
+
&& (ctx.query?.sortable.length ?? 0) > 0,
|
|
1519
|
+
dependsOn: [
|
|
1520
|
+
"list.read-after-write",
|
|
1521
|
+
/* Each axis has to work alone before "they disagree when combined" means anything. */
|
|
1522
|
+
"filter.equality-selects-exactly-one",
|
|
1523
|
+
"sort.order-is-applied",
|
|
1524
|
+
"select.projection-honoured",
|
|
1525
|
+
/* Both sides are gathered by walking pages, so a walk that skips or repeats records
|
|
1526
|
+
* changes the membership being compared for reasons unrelated to composition. */
|
|
1527
|
+
"pagination.page-walk-covers-set",
|
|
1528
|
+
],
|
|
1529
|
+
id: "query.axes-compose",
|
|
1530
|
+
needs: "a filterable field, a sortable field, and more than two records",
|
|
1531
|
+
async run(ctx) {
|
|
1532
|
+
const conventions = conv(ctx);
|
|
1533
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
1534
|
+
/*
|
|
1535
|
+
* A field, and a value on it, selecting a *proper subset* of the cohort: more than one
|
|
1536
|
+
* record so there is something to reorder, and fewer than all so a dropped predicate is
|
|
1537
|
+
* visible as a change in membership. A value matching everything would make the filtered
|
|
1538
|
+
* and unfiltered sets identical, and the check would pass whatever the backend did.
|
|
1539
|
+
*/
|
|
1540
|
+
let field;
|
|
1541
|
+
let target;
|
|
1542
|
+
for (const candidate of ctx.query?.filterable ?? []) {
|
|
1543
|
+
if (candidate === ctx.identity)
|
|
1544
|
+
continue;
|
|
1545
|
+
const values = ctx.records.map((record) => JSON.stringify(record[candidate]));
|
|
1546
|
+
const match = ctx.records.find((record) => {
|
|
1547
|
+
if (record[candidate] === null || record[candidate] === undefined)
|
|
1548
|
+
return false;
|
|
1549
|
+
const count = values.filter((v) => v === JSON.stringify(record[candidate])).length;
|
|
1550
|
+
return count > 1 && count < ctx.records.length;
|
|
1551
|
+
});
|
|
1552
|
+
if (match !== undefined) {
|
|
1553
|
+
field = candidate;
|
|
1554
|
+
target = match;
|
|
1555
|
+
break;
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
if (field === undefined || target === undefined) {
|
|
1559
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "no filterable field holds a value shared by several records but not all of them, so "
|
|
1560
|
+
+ "no filter can select a proper subset to reorder");
|
|
1561
|
+
}
|
|
1562
|
+
const sortField = (ctx.query?.sortable ?? []).find((name) => name !== field && name !== ctx.identity)
|
|
1563
|
+
?? ctx.identity;
|
|
1564
|
+
const term = filterTerm(conventions, field, "eq", String(target[field]));
|
|
1565
|
+
if (term === null) {
|
|
1566
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `this API's filter grammar cannot express equality on "${field}"`);
|
|
1567
|
+
}
|
|
1568
|
+
/*
|
|
1569
|
+
* Both sides gathered across pages. Comparing single pages would compare two *windows* of
|
|
1570
|
+
* the same set — a sorted page and an unsorted one legitimately hold different records once
|
|
1571
|
+
* the set is larger than one page, and the difference would be read as a dropped filter.
|
|
1572
|
+
*/
|
|
1573
|
+
const filtered = await collectSet(ctx, limit, term);
|
|
1574
|
+
const both = await collectSet(ctx, limit, term, MAX_WALK_PAGES, sortTerm(conventions, sortField, "desc"));
|
|
1575
|
+
if (filtered === null || both === null) {
|
|
1576
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "combining a filter with a sort was rejected, so the two cannot be compared");
|
|
1577
|
+
}
|
|
1578
|
+
if (!filtered.complete || !both.complete) {
|
|
1579
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the filtered set is larger than the walk covers, so the two runs cannot be compared "
|
|
1580
|
+
+ "as whole sets");
|
|
1581
|
+
}
|
|
1582
|
+
const alone = new Set(ids(filtered.items, ctx.identity));
|
|
1583
|
+
const combined = new Set(ids(both.items, ctx.identity));
|
|
1584
|
+
const missing = [...alone].filter((id) => !combined.has(id));
|
|
1585
|
+
const extra = [...combined].filter((id) => !alone.has(id));
|
|
1586
|
+
if (missing.length === 0 && extra.length === 0)
|
|
1587
|
+
return;
|
|
1588
|
+
ctx.findings.backend(this.id, ctx.entityName, "adding a sort changes which records a filter returns", `filter on "${field}" alone matched ${alone.size} record(s); the same filter with `
|
|
1589
|
+
+ `order=${sortField} desc matched ${combined.size}. `
|
|
1590
|
+
+ (missing.length > 0 ? `Dropped: ${missing.slice(0, 3).join(", ")}. ` : "")
|
|
1591
|
+
+ (extra.length > 0 ? `Appeared: ${extra.slice(0, 3).join(", ")}. ` : "")
|
|
1592
|
+
+ "Ordering must reorder a result, never change its membership — a filter that only "
|
|
1593
|
+
+ "holds while unsorted is one an index or query plan is silently dropping.", [filtered.last.exchange, both.last.exchange]);
|
|
1594
|
+
},
|
|
1595
|
+
};
|
|
1596
|
+
/**
|
|
1597
|
+
* A filter must select from the whole collection, not from whichever page happened to be built.
|
|
1598
|
+
*
|
|
1599
|
+
* The failure this catches is one of *ordering of operations*: resolve the offset or cursor first,
|
|
1600
|
+
* then apply the predicate to whatever that window contained. Page one usually looks correct,
|
|
1601
|
+
* which is how it survives review — the damage is further in, where pages come back short and
|
|
1602
|
+
* matching records are never returned at all.
|
|
1603
|
+
*
|
|
1604
|
+
* The oracle needs no ground truth because oat can compute the answer two ways. Walk the
|
|
1605
|
+
* collection unfiltered and apply the predicate client-side; walk it again with the predicate
|
|
1606
|
+
* pushed to the server. A backend that filters before paging returns the same set both times. One
|
|
1607
|
+
* that pages first returns a subset, and the difference is exactly the records it skipped.
|
|
1608
|
+
*/
|
|
1609
|
+
const filterAndPagingCompose = {
|
|
1610
|
+
applicable: (ctx) => filterable(ctx) && ctx.records.length > 2,
|
|
1611
|
+
dependsOn: [
|
|
1612
|
+
"list.read-after-write",
|
|
1613
|
+
"filter.equality-selects-exactly-one",
|
|
1614
|
+
/* Both sides are walks, so a broken walk changes both for reasons that are not about
|
|
1615
|
+
* where the predicate is applied. */
|
|
1616
|
+
"pagination.page-walk-covers-set",
|
|
1617
|
+
/* Both walks pin an explicit order so the two sets are comparable — which means a backend
|
|
1618
|
+
* that stops applying the filter once a sort is present serves the *unfiltered* set to
|
|
1619
|
+
* both sides, and nothing is ever missing. That defect has to be reported first; until it
|
|
1620
|
+
* is fixed, where the predicate sits relative to paging cannot be observed at all. */
|
|
1621
|
+
"query.axes-compose",
|
|
1622
|
+
],
|
|
1623
|
+
id: "query.filter-selects-from-whole-set",
|
|
1624
|
+
needs: "a filterable field and more than two records",
|
|
1625
|
+
async run(ctx) {
|
|
1626
|
+
const conventions = conv(ctx);
|
|
1627
|
+
/* A small page deliberately: the bug only shows once the window excludes matching rows,
|
|
1628
|
+
* so a page large enough to hold the collection would hide it entirely. */
|
|
1629
|
+
const pageSize = Math.max(1, Math.min(2, ctx.query?.maxLimit ?? 2));
|
|
1630
|
+
const field = (ctx.query?.filterable ?? []).find((name) => {
|
|
1631
|
+
if (name === ctx.identity)
|
|
1632
|
+
return false;
|
|
1633
|
+
const values = ctx.records.map((record) => JSON.stringify(record[name]));
|
|
1634
|
+
return ctx.records.some((record) => {
|
|
1635
|
+
if (record[name] === null || record[name] === undefined)
|
|
1636
|
+
return false;
|
|
1637
|
+
const count = values.filter((v) => v === JSON.stringify(record[name])).length;
|
|
1638
|
+
return count > 1 && count < ctx.records.length;
|
|
1639
|
+
});
|
|
1640
|
+
});
|
|
1641
|
+
if (field === undefined) {
|
|
1642
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "no filterable field holds a value shared by several records but not all of them");
|
|
1643
|
+
}
|
|
1644
|
+
const sample = ctx.records.find((record) => record[field] != null);
|
|
1645
|
+
if (sample === undefined)
|
|
1646
|
+
return;
|
|
1647
|
+
const value = sample[field];
|
|
1648
|
+
const term = filterTerm(conventions, field, "eq", String(value));
|
|
1649
|
+
if (term === null) {
|
|
1650
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `this API's filter grammar cannot express equality on "${field}"`);
|
|
1651
|
+
}
|
|
1652
|
+
/*
|
|
1653
|
+
* Both walks pinned to the same explicit order.
|
|
1654
|
+
*
|
|
1655
|
+
* A set gathered by paging is only well-defined if the pages come from a stable sequence.
|
|
1656
|
+
* Left to the backend's default order — which may have no total order at all — two walks
|
|
1657
|
+
* over the same collection legitimately return different sets, and the difference reads as
|
|
1658
|
+
* a filter dropping records. Pinning the order removes that entirely rather than excusing
|
|
1659
|
+
* it after the fact.
|
|
1660
|
+
*/
|
|
1661
|
+
const tiebreak = (ctx.query?.sortable ?? []).includes(ctx.identity) ? ctx.identity : undefined;
|
|
1662
|
+
const walkOrder = tiebreak === undefined ? undefined : sortTerm(conventions, tiebreak, "asc");
|
|
1663
|
+
const probe = await list(ctx, q(ctx, { limit: 1, order: walkOrder }));
|
|
1664
|
+
const reported = envelopeValue(ctx, probe, "total");
|
|
1665
|
+
const pages = pagesToCover(typeof reported === "number" ? reported : undefined, pageSize);
|
|
1666
|
+
const everything = await collectSet(ctx, pageSize, {}, pages, walkOrder);
|
|
1667
|
+
const serverSide = await collectSet(ctx, pageSize, term, pages, walkOrder);
|
|
1668
|
+
if (everything === null || serverSide === null) {
|
|
1669
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the collection could not be walked in both filtered and unfiltered form");
|
|
1670
|
+
}
|
|
1671
|
+
if (!everything.complete || !serverSide.complete) {
|
|
1672
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers, so the two sets are not comparable");
|
|
1673
|
+
}
|
|
1674
|
+
/* The predicate applied by oat, over everything the API served. */
|
|
1675
|
+
const clientSide = new Set(everything.items
|
|
1676
|
+
.filter((row) => JSON.stringify(row[field]) === JSON.stringify(value))
|
|
1677
|
+
.map((row) => String(row[ctx.identity])));
|
|
1678
|
+
const returned = new Set(ids(serverSide.items, ctx.identity));
|
|
1679
|
+
const skipped = [...clientSide].filter((id) => !returned.has(id));
|
|
1680
|
+
if (skipped.length === 0)
|
|
1681
|
+
return;
|
|
1682
|
+
ctx.findings.backend(this.id, ctx.entityName, "paging a filtered query skips records that match the filter", `walking the collection at ${pageSize} per page and filtering client-side on `
|
|
1683
|
+
+ `"${field}" = ${JSON.stringify(value)} finds ${clientSide.size} record(s); asking the `
|
|
1684
|
+
+ `backend for the same filter returns ${returned.size}. Never returned: `
|
|
1685
|
+
+ `${skipped.slice(0, 3).join(", ")}. The page window is being computed before the `
|
|
1686
|
+
+ "predicate is applied, so matching records fall outside it and are lost — page one "
|
|
1687
|
+
+ "looks correct and later pages silently omit data.", [everything.last.exchange, serverSide.last.exchange]);
|
|
1688
|
+
},
|
|
1689
|
+
};
|
|
1690
|
+
/**
|
|
1691
|
+
* A filter and a sparse fieldset must compose: projecting columns must never change which
|
|
1692
|
+
* records match.
|
|
1693
|
+
*
|
|
1694
|
+
* The failure this catches is a query builder that projects first and then cannot apply a
|
|
1695
|
+
* predicate to a column it just omitted, or that takes a different path the moment `fields=` is
|
|
1696
|
+
* present and forgets the WHERE. Each axis is correct alone — the filter returns the right set,
|
|
1697
|
+
* the select returns the right columns — and together the filter silently vanishes.
|
|
1698
|
+
*
|
|
1699
|
+
* The oracle is the same compositional property as {@link queryAxesCompose}: the filter alone
|
|
1700
|
+
* and the same filter with a select must return the same **set**. Identity is always requested
|
|
1701
|
+
* so the two sides remain comparable.
|
|
1702
|
+
*/
|
|
1703
|
+
const filterAndSelectCompose = {
|
|
1704
|
+
applicable: (ctx) => filterable(ctx)
|
|
1705
|
+
&& conv(ctx).select !== undefined
|
|
1706
|
+
&& ctx.records.length > 2,
|
|
1707
|
+
dependsOn: [
|
|
1708
|
+
"list.read-after-write",
|
|
1709
|
+
"create.persists-submitted-fields",
|
|
1710
|
+
"filter.equality-selects-exactly-one",
|
|
1711
|
+
/* If select is accepted and ignored, both sides still carry every column and the
|
|
1712
|
+
* membership comparison remains well-defined — but a backend that has stopped honouring
|
|
1713
|
+
* select at all may also have stopped taking the code path this check is probing. */
|
|
1714
|
+
"select.projection-honoured",
|
|
1715
|
+
"pagination.page-walk-covers-set",
|
|
1716
|
+
],
|
|
1717
|
+
id: "query.filter-and-select-compose",
|
|
1718
|
+
needs: "a filterable field, a select parameter, and more than two records",
|
|
1719
|
+
async run(ctx) {
|
|
1720
|
+
const conventions = conv(ctx);
|
|
1721
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
1722
|
+
let field;
|
|
1723
|
+
let target;
|
|
1724
|
+
for (const candidate of ctx.query?.filterable ?? []) {
|
|
1725
|
+
if (candidate === ctx.identity)
|
|
1726
|
+
continue;
|
|
1727
|
+
const values = ctx.records.map((record) => JSON.stringify(record[candidate]));
|
|
1728
|
+
const match = ctx.records.find((record) => {
|
|
1729
|
+
if (record[candidate] === null || record[candidate] === undefined)
|
|
1730
|
+
return false;
|
|
1731
|
+
const count = values.filter((v) => v === JSON.stringify(record[candidate])).length;
|
|
1732
|
+
return count > 1 && count < ctx.records.length;
|
|
1733
|
+
});
|
|
1734
|
+
if (match !== undefined) {
|
|
1735
|
+
field = candidate;
|
|
1736
|
+
target = match;
|
|
1737
|
+
break;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
if (field === undefined || target === undefined) {
|
|
1741
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "no filterable field holds a value shared by several records but not all of them, so "
|
|
1742
|
+
+ "no filter can select a proper subset to project");
|
|
1743
|
+
}
|
|
1744
|
+
const extra = (ctx.query?.selectable ?? []).find((name) => name !== ctx.identity)
|
|
1745
|
+
?? ctx.identity;
|
|
1746
|
+
const projection = selectTerm(conventions, [ctx.identity, extra], ctx.entityName);
|
|
1747
|
+
if (projection === null) {
|
|
1748
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "this API's select grammar cannot express a sparse fieldset");
|
|
1749
|
+
}
|
|
1750
|
+
const term = filterTerm(conventions, field, "eq", String(target[field]));
|
|
1751
|
+
if (term === null) {
|
|
1752
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `this API's filter grammar cannot express equality on "${field}"`);
|
|
1753
|
+
}
|
|
1754
|
+
const filtered = await collectSet(ctx, limit, term);
|
|
1755
|
+
const both = await collectSet(ctx, limit, { ...term, ...projection });
|
|
1756
|
+
if (filtered === null || both === null) {
|
|
1757
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "combining a filter with a select was rejected, so the two cannot be compared");
|
|
1758
|
+
}
|
|
1759
|
+
if (!filtered.complete || !both.complete) {
|
|
1760
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the filtered set is larger than the walk covers, so the two runs cannot be compared "
|
|
1761
|
+
+ "as whole sets");
|
|
1762
|
+
}
|
|
1763
|
+
const alone = new Set(ids(filtered.items, ctx.identity));
|
|
1764
|
+
const combined = new Set(ids(both.items, ctx.identity));
|
|
1765
|
+
/* A projection that omitted the identity makes membership unobservable — that is a
|
|
1766
|
+
* missing column, not a changed set, and select.projection-honoured already owns it. */
|
|
1767
|
+
if (both.items.some((item) => item[ctx.identity] === undefined)) {
|
|
1768
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the projected page omitted the identity field, so the two sets cannot be compared");
|
|
1769
|
+
}
|
|
1770
|
+
const missing = [...alone].filter((id) => !combined.has(id));
|
|
1771
|
+
const extraIds = [...combined].filter((id) => !alone.has(id));
|
|
1772
|
+
if (missing.length === 0 && extraIds.length === 0)
|
|
1773
|
+
return;
|
|
1774
|
+
ctx.findings.backend(this.id, ctx.entityName, "adding a select changes which records a filter returns", `filter on "${field}" alone matched ${alone.size} record(s); the same filter with `
|
|
1775
|
+
+ `select=${[ctx.identity, extra].join(",")} matched ${combined.size}. `
|
|
1776
|
+
+ (missing.length > 0 ? `Dropped: ${missing.slice(0, 3).join(", ")}. ` : "")
|
|
1777
|
+
+ (extraIds.length > 0 ? `Appeared: ${extraIds.slice(0, 3).join(", ")}. ` : "")
|
|
1778
|
+
+ "A projection must change which columns come back, never which rows — a filter "
|
|
1779
|
+
+ "that only holds while every column is selected is one a query plan is dropping "
|
|
1780
|
+
+ "once it has to name the columns.", [filtered.last.exchange, both.last.exchange]);
|
|
1781
|
+
},
|
|
1782
|
+
};
|
|
1783
|
+
/**
|
|
1784
|
+
* A structured filter and a free-text search must compose as their intersection.
|
|
1785
|
+
*
|
|
1786
|
+
* Adding `q` often switches a backend onto a search-index path that does not honour the
|
|
1787
|
+
* structured predicate — or the reverse: a filter makes the search term a no-op. Each axis is
|
|
1788
|
+
* correct alone; together one of them vanishes. The oracle needs no ground truth: walk each
|
|
1789
|
+
* axis, walk both, and the combined set must equal the intersection.
|
|
1790
|
+
*
|
|
1791
|
+
* The two sides have to overlap without nesting. If the search matches only records the filter
|
|
1792
|
+
* already selected, dropping the filter is invisible — the combined set still equals the
|
|
1793
|
+
* intersection. The token is chosen so each axis matches something the other does not.
|
|
1794
|
+
*/
|
|
1795
|
+
const searchAndFilterCompose = {
|
|
1796
|
+
applicable: (ctx) => filterable(ctx)
|
|
1797
|
+
&& conv(ctx).search !== undefined
|
|
1798
|
+
&& (ctx.query?.searchable.length ?? 0) > 0
|
|
1799
|
+
&& ctx.records.length > 2,
|
|
1800
|
+
dependsOn: [
|
|
1801
|
+
"list.read-after-write",
|
|
1802
|
+
"create.persists-submitted-fields",
|
|
1803
|
+
"filter.equality-selects-exactly-one",
|
|
1804
|
+
"search.q-narrows-result",
|
|
1805
|
+
"pagination.page-walk-covers-set",
|
|
1806
|
+
],
|
|
1807
|
+
id: "query.search-and-filter-compose",
|
|
1808
|
+
needs: "a filterable field, a free-text search parameter, and more than two records",
|
|
1809
|
+
async run(ctx) {
|
|
1810
|
+
const conventions = conv(ctx);
|
|
1811
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
1812
|
+
/*
|
|
1813
|
+
* The first proper-subset filter is not always the one that overlaps a search. A status
|
|
1814
|
+
* enum that happens to put both "alphabetically" records outside the chosen bucket makes
|
|
1815
|
+
* every token nested, and dropping the filter becomes invisible. Walk every candidate
|
|
1816
|
+
* until one overlaps without nesting.
|
|
1817
|
+
*/
|
|
1818
|
+
let field;
|
|
1819
|
+
let target;
|
|
1820
|
+
let token = null;
|
|
1821
|
+
for (const candidate of ctx.query?.filterable ?? []) {
|
|
1822
|
+
if (candidate === ctx.identity)
|
|
1823
|
+
continue;
|
|
1824
|
+
const groups = new Map();
|
|
1825
|
+
for (const record of ctx.records) {
|
|
1826
|
+
if (record[candidate] === null || record[candidate] === undefined)
|
|
1827
|
+
continue;
|
|
1828
|
+
const key = JSON.stringify(record[candidate]);
|
|
1829
|
+
const group = groups.get(key) ?? [];
|
|
1830
|
+
group.push(record);
|
|
1831
|
+
groups.set(key, group);
|
|
1832
|
+
}
|
|
1833
|
+
for (const group of groups.values()) {
|
|
1834
|
+
if (group.length <= 1 || group.length >= ctx.records.length)
|
|
1835
|
+
continue;
|
|
1836
|
+
const sample = group[0];
|
|
1837
|
+
if (sample === undefined)
|
|
1838
|
+
continue;
|
|
1839
|
+
const guessed = new Set(ids(group, ctx.identity));
|
|
1840
|
+
const found = overlappingSearchToken(ctx, guessed);
|
|
1841
|
+
if (found === null)
|
|
1842
|
+
continue;
|
|
1843
|
+
field = candidate;
|
|
1844
|
+
target = sample;
|
|
1845
|
+
token = found;
|
|
1846
|
+
break;
|
|
1847
|
+
}
|
|
1848
|
+
if (token !== null)
|
|
1849
|
+
break;
|
|
1850
|
+
}
|
|
1851
|
+
if (field === undefined || target === undefined || token === null) {
|
|
1852
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "no filterable field and search token overlap without nesting, so dropping either "
|
|
1853
|
+
+ "axis would be invisible");
|
|
1854
|
+
}
|
|
1855
|
+
const term = filterTerm(conventions, field, "eq", String(target[field]));
|
|
1856
|
+
if (term === null) {
|
|
1857
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `this API's filter grammar cannot express equality on "${field}"`);
|
|
1858
|
+
}
|
|
1859
|
+
const filtered = await collectSet(ctx, limit, term);
|
|
1860
|
+
if (filtered === null) {
|
|
1861
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the filtered listing was rejected, so it cannot be compared with a search");
|
|
1862
|
+
}
|
|
1863
|
+
if (!filtered.complete) {
|
|
1864
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the filtered set is larger than the walk covers");
|
|
1865
|
+
}
|
|
1866
|
+
const filterIds = new Set(ids(filtered.items, ctx.identity));
|
|
1867
|
+
const searched = await collectSet(ctx, limit, q(ctx, { search: token }));
|
|
1868
|
+
const both = await collectSet(ctx, limit, {
|
|
1869
|
+
...term,
|
|
1870
|
+
...q(ctx, { search: token }),
|
|
1871
|
+
});
|
|
1872
|
+
if (searched === null || both === null) {
|
|
1873
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "combining a filter with a search was rejected, so the two cannot be compared");
|
|
1874
|
+
}
|
|
1875
|
+
if (!searched.complete || !both.complete) {
|
|
1876
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "a side of the comparison is larger than the walk covers");
|
|
1877
|
+
}
|
|
1878
|
+
const searchIds = new Set(ids(searched.items, ctx.identity));
|
|
1879
|
+
const expected = new Set([...filterIds].filter((id) => searchIds.has(id)));
|
|
1880
|
+
const got = new Set(ids(both.items, ctx.identity));
|
|
1881
|
+
const missing = [...expected].filter((id) => !got.has(id));
|
|
1882
|
+
const extraIds = [...got].filter((id) => !expected.has(id));
|
|
1883
|
+
if (missing.length === 0 && extraIds.length === 0)
|
|
1884
|
+
return;
|
|
1885
|
+
ctx.findings.backend(this.id, ctx.entityName, "a filter and a search do not compose as their intersection", `filter on "${field}" matched ${filterIds.size}; q=${JSON.stringify(token)} matched `
|
|
1886
|
+
+ `${searchIds.size}; both together matched ${got.size} (intersection is `
|
|
1887
|
+
+ `${expected.size}). `
|
|
1888
|
+
+ (missing.length > 0 ? `Missing: ${missing.slice(0, 3).join(", ")}. ` : "")
|
|
1889
|
+
+ (extraIds.length > 0 ? `Extra: ${extraIds.slice(0, 3).join(", ")}. ` : "")
|
|
1890
|
+
+ "A structured predicate and a free-text search must narrow each other. When they "
|
|
1891
|
+
+ "do not, one of them is being dropped the moment the other is present.", [filtered.last.exchange, searched.last.exchange, both.last.exchange]);
|
|
1892
|
+
},
|
|
1893
|
+
};
|
|
1894
|
+
/**
|
|
1895
|
+
* A filter, a sort and a select must compose: adding both extra axes must not change which
|
|
1896
|
+
* records the filter matches.
|
|
1897
|
+
*
|
|
1898
|
+
* Pairwise composition is not enough. A planner that has a working two-axis path and a
|
|
1899
|
+
* broken three-axis path — the moment `order` and `fields` are both present, the WHERE is
|
|
1900
|
+
* dropped — passes every pair check. The oracle is the same membership property as the
|
|
1901
|
+
* pairs: the filter alone and the filter with both extras must return the same set.
|
|
1902
|
+
*/
|
|
1903
|
+
const filterSortSelectCompose = {
|
|
1904
|
+
applicable: (ctx) => filterable(ctx)
|
|
1905
|
+
&& conv(ctx).order !== undefined
|
|
1906
|
+
&& conv(ctx).select !== undefined
|
|
1907
|
+
&& (ctx.query?.sortable.length ?? 0) > 0
|
|
1908
|
+
&& ctx.records.length > 2,
|
|
1909
|
+
dependsOn: [
|
|
1910
|
+
"list.read-after-write",
|
|
1911
|
+
"create.persists-submitted-fields",
|
|
1912
|
+
"filter.equality-selects-exactly-one",
|
|
1913
|
+
"sort.order-is-applied",
|
|
1914
|
+
"select.projection-honoured",
|
|
1915
|
+
"pagination.page-walk-covers-set",
|
|
1916
|
+
/* Each pair has to hold before "the triple disagrees" means anything. A pair defect
|
|
1917
|
+
* would also break this request, and reporting it again would name the wrong cause. */
|
|
1918
|
+
"query.axes-compose",
|
|
1919
|
+
"query.filter-and-select-compose",
|
|
1920
|
+
"query.filter-selects-from-whole-set",
|
|
1921
|
+
],
|
|
1922
|
+
id: "query.filter-sort-select-compose",
|
|
1923
|
+
needs: "a filterable field, a sortable field, a select parameter, and more than two records",
|
|
1924
|
+
async run(ctx) {
|
|
1925
|
+
const conventions = conv(ctx);
|
|
1926
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
1927
|
+
const picked = properSubsetFilter(ctx);
|
|
1928
|
+
if (picked === null) {
|
|
1929
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "no filterable field holds a value shared by several records but not all of them");
|
|
1930
|
+
}
|
|
1931
|
+
const { field, target } = picked;
|
|
1932
|
+
const term = filterTerm(conventions, field, "eq", String(target[field]));
|
|
1933
|
+
const extra = (ctx.query?.selectable ?? []).find((name) => name !== ctx.identity) ?? ctx.identity;
|
|
1934
|
+
const projection = selectTerm(conventions, [ctx.identity, extra], ctx.entityName);
|
|
1935
|
+
const sortField = (ctx.query?.sortable ?? []).find((name) => name !== field && name !== ctx.identity)
|
|
1936
|
+
?? ctx.identity;
|
|
1937
|
+
if (term === null || projection === null) {
|
|
1938
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "this API cannot express the filter, the sort or the select together");
|
|
1939
|
+
}
|
|
1940
|
+
const filtered = await collectSet(ctx, limit, term);
|
|
1941
|
+
const triple = await collectSet(ctx, limit, { ...term, ...projection }, MAX_WALK_PAGES, sortTerm(conventions, sortField, "desc"));
|
|
1942
|
+
if (filtered === null || triple === null) {
|
|
1943
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "combining a filter with a sort and a select was rejected");
|
|
1944
|
+
}
|
|
1945
|
+
if (!filtered.complete || !triple.complete) {
|
|
1946
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "a side of the comparison is larger than the walk covers");
|
|
1947
|
+
}
|
|
1948
|
+
if (triple.items.some((item) => item[ctx.identity] === undefined)) {
|
|
1949
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the projected page omitted the identity field, so the two sets cannot be compared");
|
|
1950
|
+
}
|
|
1951
|
+
const alone = new Set(ids(filtered.items, ctx.identity));
|
|
1952
|
+
const combined = new Set(ids(triple.items, ctx.identity));
|
|
1953
|
+
const missing = [...alone].filter((id) => !combined.has(id));
|
|
1954
|
+
const extraIds = [...combined].filter((id) => !alone.has(id));
|
|
1955
|
+
if (missing.length === 0 && extraIds.length === 0)
|
|
1956
|
+
return;
|
|
1957
|
+
ctx.findings.backend(this.id, ctx.entityName, "adding a sort and a select together changes which records a filter returns", `filter on "${field}" alone matched ${alone.size}; the same filter with `
|
|
1958
|
+
+ `order=${sortField} desc and select=${[ctx.identity, extra].join(",")} matched `
|
|
1959
|
+
+ `${combined.size}. `
|
|
1960
|
+
+ (missing.length > 0 ? `Dropped: ${missing.slice(0, 3).join(", ")}. ` : "")
|
|
1961
|
+
+ (extraIds.length > 0 ? `Appeared: ${extraIds.slice(0, 3).join(", ")}. ` : "")
|
|
1962
|
+
+ "Each pair composes; the triple must too. A filter that only holds until both a "
|
|
1963
|
+
+ "sort and a projection are present is one a three-axis query plan is dropping.", [filtered.last.exchange, triple.last.exchange]);
|
|
1964
|
+
},
|
|
1965
|
+
};
|
|
1966
|
+
/**
|
|
1967
|
+
* A filter, a search and a sort must compose: the intersection of filter and search must not
|
|
1968
|
+
* change when a sort is added.
|
|
1969
|
+
*
|
|
1970
|
+
* The search-index path that also tries to honour an ORDER BY is a common place to drop the
|
|
1971
|
+
* structured predicate. Pairwise, filter+search and filter+sort both hold; together they do not.
|
|
1972
|
+
*/
|
|
1973
|
+
const filterSearchSortCompose = {
|
|
1974
|
+
applicable: (ctx) => filterable(ctx)
|
|
1975
|
+
&& conv(ctx).search !== undefined
|
|
1976
|
+
&& conv(ctx).order !== undefined
|
|
1977
|
+
&& (ctx.query?.searchable.length ?? 0) > 0
|
|
1978
|
+
&& (ctx.query?.sortable.length ?? 0) > 0
|
|
1979
|
+
&& ctx.records.length > 2,
|
|
1980
|
+
dependsOn: [
|
|
1981
|
+
"list.read-after-write",
|
|
1982
|
+
"create.persists-submitted-fields",
|
|
1983
|
+
"filter.equality-selects-exactly-one",
|
|
1984
|
+
"search.q-narrows-result",
|
|
1985
|
+
"sort.order-is-applied",
|
|
1986
|
+
"pagination.page-walk-covers-set",
|
|
1987
|
+
"query.search-and-filter-compose",
|
|
1988
|
+
"query.axes-compose",
|
|
1989
|
+
"query.filter-selects-from-whole-set",
|
|
1990
|
+
],
|
|
1991
|
+
id: "query.filter-search-sort-compose",
|
|
1992
|
+
needs: "a filterable field, a search parameter, a sortable field, and more than two records",
|
|
1993
|
+
async run(ctx) {
|
|
1994
|
+
const conventions = conv(ctx);
|
|
1995
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
1996
|
+
const picked = overlappingFilterAndSearch(ctx);
|
|
1997
|
+
if (picked === null) {
|
|
1998
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "no filterable field and search token overlap without nesting");
|
|
1999
|
+
}
|
|
2000
|
+
const { field, target, token } = picked;
|
|
2001
|
+
const term = filterTerm(conventions, field, "eq", String(target[field]));
|
|
2002
|
+
if (term === null) {
|
|
2003
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `this API's filter grammar cannot express equality on "${field}"`);
|
|
2004
|
+
}
|
|
2005
|
+
const sortField = (ctx.query?.sortable ?? []).find((name) => name !== field && name !== ctx.identity)
|
|
2006
|
+
?? ctx.identity;
|
|
2007
|
+
const pair = await collectSet(ctx, limit, {
|
|
2008
|
+
...term,
|
|
2009
|
+
...q(ctx, { search: token }),
|
|
2010
|
+
});
|
|
2011
|
+
const triple = await collectSet(ctx, limit, { ...term, ...q(ctx, { search: token }) }, MAX_WALK_PAGES, sortTerm(conventions, sortField, "desc"));
|
|
2012
|
+
if (pair === null || triple === null) {
|
|
2013
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "combining a filter, a search and a sort was rejected");
|
|
2014
|
+
}
|
|
2015
|
+
if (!pair.complete || !triple.complete) {
|
|
2016
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "a side of the comparison is larger than the walk covers");
|
|
2017
|
+
}
|
|
2018
|
+
const expected = new Set(ids(pair.items, ctx.identity));
|
|
2019
|
+
const got = new Set(ids(triple.items, ctx.identity));
|
|
2020
|
+
const missing = [...expected].filter((id) => !got.has(id));
|
|
2021
|
+
const extraIds = [...got].filter((id) => !expected.has(id));
|
|
2022
|
+
if (missing.length === 0 && extraIds.length === 0)
|
|
2023
|
+
return;
|
|
2024
|
+
ctx.findings.backend(this.id, ctx.entityName, "adding a sort changes which records a filter and a search return", `filter on "${field}" with q=${JSON.stringify(token)} matched ${expected.size}; `
|
|
2025
|
+
+ `the same request with order=${sortField} desc matched ${got.size}. `
|
|
2026
|
+
+ (missing.length > 0 ? `Dropped: ${missing.slice(0, 3).join(", ")}. ` : "")
|
|
2027
|
+
+ (extraIds.length > 0 ? `Appeared: ${extraIds.slice(0, 3).join(", ")}. ` : "")
|
|
2028
|
+
+ "A sort must reorder the intersection, never change it.", [pair.last.exchange, triple.last.exchange]);
|
|
2029
|
+
},
|
|
2030
|
+
};
|
|
2031
|
+
/**
|
|
2032
|
+
* A filter, a search and a select must compose: projecting columns must not change the
|
|
2033
|
+
* intersection of filter and search.
|
|
2034
|
+
*/
|
|
2035
|
+
const filterSearchSelectCompose = {
|
|
2036
|
+
applicable: (ctx) => filterable(ctx)
|
|
2037
|
+
&& conv(ctx).search !== undefined
|
|
2038
|
+
&& conv(ctx).select !== undefined
|
|
2039
|
+
&& (ctx.query?.searchable.length ?? 0) > 0
|
|
2040
|
+
&& ctx.records.length > 2,
|
|
2041
|
+
dependsOn: [
|
|
2042
|
+
"list.read-after-write",
|
|
2043
|
+
"create.persists-submitted-fields",
|
|
2044
|
+
"filter.equality-selects-exactly-one",
|
|
2045
|
+
"search.q-narrows-result",
|
|
2046
|
+
"select.projection-honoured",
|
|
2047
|
+
"pagination.page-walk-covers-set",
|
|
2048
|
+
"query.search-and-filter-compose",
|
|
2049
|
+
"query.filter-and-select-compose",
|
|
2050
|
+
],
|
|
2051
|
+
id: "query.filter-search-select-compose",
|
|
2052
|
+
needs: "a filterable field, a search parameter, a select parameter, and more than two records",
|
|
2053
|
+
async run(ctx) {
|
|
2054
|
+
const conventions = conv(ctx);
|
|
2055
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
2056
|
+
const picked = overlappingFilterAndSearch(ctx);
|
|
2057
|
+
if (picked === null) {
|
|
2058
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "no filterable field and search token overlap without nesting");
|
|
2059
|
+
}
|
|
2060
|
+
const { field, target, token } = picked;
|
|
2061
|
+
const term = filterTerm(conventions, field, "eq", String(target[field]));
|
|
2062
|
+
const extra = (ctx.query?.selectable ?? []).find((name) => name !== ctx.identity) ?? ctx.identity;
|
|
2063
|
+
const projection = selectTerm(conventions, [ctx.identity, extra], ctx.entityName);
|
|
2064
|
+
if (term === null || projection === null) {
|
|
2065
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "this API cannot express the filter, the search or the select together");
|
|
2066
|
+
}
|
|
2067
|
+
const pair = await collectSet(ctx, limit, {
|
|
2068
|
+
...term,
|
|
2069
|
+
...q(ctx, { search: token }),
|
|
2070
|
+
});
|
|
2071
|
+
const triple = await collectSet(ctx, limit, {
|
|
2072
|
+
...term,
|
|
2073
|
+
...q(ctx, { search: token }),
|
|
2074
|
+
...projection,
|
|
2075
|
+
});
|
|
2076
|
+
if (pair === null || triple === null) {
|
|
2077
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "combining a filter, a search and a select was rejected");
|
|
2078
|
+
}
|
|
2079
|
+
if (!pair.complete || !triple.complete) {
|
|
2080
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "a side of the comparison is larger than the walk covers");
|
|
2081
|
+
}
|
|
2082
|
+
if (triple.items.some((item) => item[ctx.identity] === undefined)) {
|
|
2083
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the projected page omitted the identity field, so the two sets cannot be compared");
|
|
2084
|
+
}
|
|
2085
|
+
const expected = new Set(ids(pair.items, ctx.identity));
|
|
2086
|
+
const got = new Set(ids(triple.items, ctx.identity));
|
|
2087
|
+
const missing = [...expected].filter((id) => !got.has(id));
|
|
2088
|
+
const extraIds = [...got].filter((id) => !expected.has(id));
|
|
2089
|
+
if (missing.length === 0 && extraIds.length === 0)
|
|
2090
|
+
return;
|
|
2091
|
+
ctx.findings.backend(this.id, ctx.entityName, "adding a select changes which records a filter and a search return", `filter on "${field}" with q=${JSON.stringify(token)} matched ${expected.size}; `
|
|
2092
|
+
+ `the same request with select=${[ctx.identity, extra].join(",")} matched ${got.size}. `
|
|
2093
|
+
+ (missing.length > 0 ? `Dropped: ${missing.slice(0, 3).join(", ")}. ` : "")
|
|
2094
|
+
+ (extraIds.length > 0 ? `Appeared: ${extraIds.slice(0, 3).join(", ")}. ` : "")
|
|
2095
|
+
+ "A projection must change columns, never the intersection a filter and a search "
|
|
2096
|
+
+ "already agreed on.", [pair.last.exchange, triple.last.exchange]);
|
|
2097
|
+
},
|
|
2098
|
+
};
|
|
2099
|
+
/** A filterable field whose value selects a proper subset of the cohort. */
|
|
2100
|
+
function properSubsetFilter(ctx) {
|
|
2101
|
+
for (const candidate of ctx.query?.filterable ?? []) {
|
|
2102
|
+
if (candidate === ctx.identity)
|
|
2103
|
+
continue;
|
|
2104
|
+
const values = ctx.records.map((record) => JSON.stringify(record[candidate]));
|
|
2105
|
+
const match = ctx.records.find((record) => {
|
|
2106
|
+
if (record[candidate] === null || record[candidate] === undefined)
|
|
2107
|
+
return false;
|
|
2108
|
+
const count = values.filter((v) => v === JSON.stringify(record[candidate])).length;
|
|
2109
|
+
return count > 1 && count < ctx.records.length;
|
|
2110
|
+
});
|
|
2111
|
+
if (match !== undefined)
|
|
2112
|
+
return { field: candidate, target: match };
|
|
2113
|
+
}
|
|
2114
|
+
return null;
|
|
2115
|
+
}
|
|
2116
|
+
/**
|
|
2117
|
+
* A filter value and a search token that overlap without nesting, so dropping either axis
|
|
2118
|
+
* changes the intersection.
|
|
2119
|
+
*/
|
|
2120
|
+
function overlappingFilterAndSearch(ctx) {
|
|
2121
|
+
for (const candidate of ctx.query?.filterable ?? []) {
|
|
2122
|
+
if (candidate === ctx.identity)
|
|
2123
|
+
continue;
|
|
2124
|
+
const groups = new Map();
|
|
2125
|
+
for (const record of ctx.records) {
|
|
2126
|
+
if (record[candidate] === null || record[candidate] === undefined)
|
|
2127
|
+
continue;
|
|
2128
|
+
const key = JSON.stringify(record[candidate]);
|
|
2129
|
+
const group = groups.get(key) ?? [];
|
|
2130
|
+
group.push(record);
|
|
2131
|
+
groups.set(key, group);
|
|
2132
|
+
}
|
|
2133
|
+
for (const group of groups.values()) {
|
|
2134
|
+
if (group.length <= 1 || group.length >= ctx.records.length)
|
|
2135
|
+
continue;
|
|
2136
|
+
const sample = group[0];
|
|
2137
|
+
if (sample === undefined)
|
|
2138
|
+
continue;
|
|
2139
|
+
const found = overlappingSearchToken(ctx, new Set(ids(group, ctx.identity)));
|
|
2140
|
+
if (found === null)
|
|
2141
|
+
continue;
|
|
2142
|
+
return { field: candidate, target: sample, token: found };
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
return null;
|
|
2146
|
+
}
|
|
2147
|
+
/**
|
|
2148
|
+
* A search token that overlaps a filtered set without nesting — each side matches something
|
|
2149
|
+
* the other does not, so dropping either axis changes the intersection.
|
|
2150
|
+
*/
|
|
2151
|
+
function overlappingSearchToken(ctx, filterIds) {
|
|
2152
|
+
const fields = ctx.query?.searchable ?? [];
|
|
2153
|
+
if (fields.length === 0)
|
|
2154
|
+
return null;
|
|
2155
|
+
const matches = (token) => {
|
|
2156
|
+
const needle = token.toLowerCase();
|
|
2157
|
+
const hit = new Set();
|
|
2158
|
+
for (const record of ctx.records) {
|
|
2159
|
+
if (fields.some((field) => String(record[field] ?? "").toLowerCase().includes(needle))) {
|
|
2160
|
+
hit.add(String(record[ctx.identity]));
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
return hit;
|
|
2164
|
+
};
|
|
2165
|
+
const candidates = [];
|
|
2166
|
+
for (const record of ctx.records) {
|
|
2167
|
+
for (const name of fields) {
|
|
2168
|
+
const value = record[name];
|
|
2169
|
+
if (typeof value !== "string" || value.length < 2)
|
|
2170
|
+
continue;
|
|
2171
|
+
candidates.push(value);
|
|
2172
|
+
if (value.length >= 3)
|
|
2173
|
+
candidates.push(value.slice(0, 3));
|
|
2174
|
+
for (const word of value.split(/\s+/)) {
|
|
2175
|
+
if (word.length >= 2)
|
|
2176
|
+
candidates.push(word);
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
for (const token of candidates) {
|
|
2181
|
+
const searchIds = matches(token);
|
|
2182
|
+
const inter = [...searchIds].filter((id) => filterIds.has(id)).length;
|
|
2183
|
+
const onlySearch = [...searchIds].filter((id) => !filterIds.has(id)).length;
|
|
2184
|
+
const onlyFilter = [...filterIds].filter((id) => !searchIds.has(id)).length;
|
|
2185
|
+
if (inter > 0 && onlySearch > 0 && onlyFilter > 0)
|
|
2186
|
+
return token;
|
|
2187
|
+
}
|
|
2188
|
+
return null;
|
|
2189
|
+
}
|
|
2190
|
+
/**
|
|
2191
|
+
* A capability the document declares must actually exist.
|
|
2192
|
+
*
|
|
2193
|
+
* `x-query.filterable` is a promise: every client generated from that document will offer a filter
|
|
2194
|
+
* on each field named there. When the backend rejects one, the backend is not necessarily wrong —
|
|
2195
|
+
* it is entitled to refuse a column it never indexed — but the *document* is, and it is wrong in
|
|
2196
|
+
* the most expensive way, because the failure only appears at runtime in someone else's client.
|
|
2197
|
+
*
|
|
2198
|
+
* oat is uniquely positioned to catch this: it is the only thing that reads the claim and then
|
|
2199
|
+
* tries it. Note the verdict is SPEC_BUG rather than BACKEND_BUG — the fix is to correct the
|
|
2200
|
+
* document or index the column, and saying which is not oat's call.
|
|
2201
|
+
*
|
|
2202
|
+
* This also closes a silent-skip: every filter check treats a 4xx as a capability statement and
|
|
2203
|
+
* stands down, which is right when the field was merely inferred and wrong when it was declared.
|
|
2204
|
+
*/
|
|
2205
|
+
const declaredFilterableWorks = {
|
|
2206
|
+
applicable: (ctx) => conv(ctx).filter !== undefined
|
|
2207
|
+
&& ctx.query?.source === "tag"
|
|
2208
|
+
&& (ctx.query?.filterable.length ?? 0) > 0
|
|
2209
|
+
&& ctx.records.length > 0,
|
|
2210
|
+
dependsOn: [
|
|
2211
|
+
"list.read-after-write",
|
|
2212
|
+
/* The evidence is a *rejection*. A backend that silently drops unknown filter fields never
|
|
2213
|
+
* rejects anything, so an overclaimed field comes back 200 and this check would report the
|
|
2214
|
+
* document as honest — the dropped-filter defect is the finding to act on first. */
|
|
2215
|
+
"filter.unknown-field-rejected",
|
|
2216
|
+
/* And only a 4xx counts: a backend whose filter parser throws answers *every* bad filter
|
|
2217
|
+
* with a 500, which makes "the document declared a field the backend refuses"
|
|
2218
|
+
* indistinguishable from "the parser is broken". That is the finding to fix first. */
|
|
2219
|
+
"error.malformed-filter-not-5xx",
|
|
2220
|
+
],
|
|
2221
|
+
id: "spec.declared-filterable-is-filterable",
|
|
2222
|
+
needs: "x-query naming filterable fields",
|
|
2223
|
+
async run(ctx) {
|
|
2224
|
+
const conventions = conv(ctx);
|
|
2225
|
+
const rejected = [];
|
|
2226
|
+
let probed = 0;
|
|
2227
|
+
for (const field of ctx.query?.filterable ?? []) {
|
|
2228
|
+
/* A declared field that never appears on the cohort is still a promise — often the
|
|
2229
|
+
* most expensive kind, a column the document invented. Probe it with a sentinel. */
|
|
2230
|
+
const sample = ctx.records.find((record) => record[field] != null);
|
|
2231
|
+
const term = filterTerm(conventions, field, "eq", sample === undefined ? "oat-declared-field" : String(sample[field]));
|
|
2232
|
+
if (term === null)
|
|
2233
|
+
continue;
|
|
2234
|
+
probed += 1;
|
|
2235
|
+
const result = await list(ctx, { ...q(ctx, { limit: 5 }), ...term });
|
|
2236
|
+
/* Only a rejection counts. A filter that returns the wrong rows is a backend defect
|
|
2237
|
+
* other checks own; this one is strictly about the capability existing. */
|
|
2238
|
+
if (result.exchange.status >= 400 && result.exchange.status < 500) {
|
|
2239
|
+
rejected.push({ field, status: result.exchange.status });
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
if (probed === 0) {
|
|
2243
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "no declared filterable field holds a non-null value in the cohort, so none could be probed");
|
|
2244
|
+
}
|
|
2245
|
+
if (rejected.length === 0)
|
|
2246
|
+
return;
|
|
2247
|
+
ctx.findings.spec(this.id, ctx.entityName, "the document declares a filter the backend does not accept", `x-query lists ${rejected.length} field(s) as filterable that the backend rejects: `
|
|
2248
|
+
+ `${rejected.map((r) => `${r.field} (${r.status})`).join(", ")}. `
|
|
2249
|
+
+ "Every client generated from this document will offer a filter that fails at runtime. "
|
|
2250
|
+
+ "Either the column needs an index or the declaration needs removing — but the "
|
|
2251
|
+
+ "document and the backend currently disagree about what this API can do.", []);
|
|
2252
|
+
},
|
|
2253
|
+
};
|
|
2254
|
+
/**
|
|
2255
|
+
* The sort analogue of {@link declaredFilterableWorks}: `x-query.sortable` is a promise too, and
|
|
2256
|
+
* an `order` value the backend rejects breaks every client generated from the document exactly
|
|
2257
|
+
* the same way a rejected filter does.
|
|
2258
|
+
*/
|
|
2259
|
+
const declaredSortableWorks = {
|
|
2260
|
+
applicable: (ctx) => conv(ctx).order !== undefined
|
|
2261
|
+
&& ctx.query?.source === "tag"
|
|
2262
|
+
&& (ctx.query?.sortable.length ?? 0) > 0
|
|
2263
|
+
&& ctx.records.length > 0,
|
|
2264
|
+
dependsOn: [
|
|
2265
|
+
"list.read-after-write",
|
|
2266
|
+
/* ERROR_500_ON_BAD_FILTER, despite its name, turns *any* SqlError the reference throws
|
|
2267
|
+
* into a 500 — including a rejected `order` field, not just a malformed filter. Without
|
|
2268
|
+
* this dependency a rejection lands outside the 4xx window this check looks for and the
|
|
2269
|
+
* overclaim goes unreported: the same "SILENT" failure error.malformed-filter-not-5xx
|
|
2270
|
+
* exists to catch, just reached from the sort path instead of the filter path. */
|
|
2271
|
+
"error.malformed-filter-not-5xx",
|
|
2272
|
+
/* Found by the fuzzer, not the matrix: on the SQL stores, ORDER_IGNORED short-circuits
|
|
2273
|
+
* before the sortable whitelist is even consulted (the field-rejection check sits *after*
|
|
2274
|
+
* the ignore-order early return), so a backend that has stopped applying `order` at all
|
|
2275
|
+
* also stops rejecting an overclaimed field — the request simply succeeds with the default
|
|
2276
|
+
* order. The overclaim is real but unobservable until ordering itself works again. */
|
|
2277
|
+
"sort.order-is-applied",
|
|
2278
|
+
],
|
|
2279
|
+
id: "spec.declared-sortable-is-sortable",
|
|
2280
|
+
needs: "x-query naming sortable fields",
|
|
2281
|
+
async run(ctx) {
|
|
2282
|
+
const conventions = conv(ctx);
|
|
2283
|
+
const rejected = [];
|
|
2284
|
+
let probed = 0;
|
|
2285
|
+
for (const field of ctx.query?.sortable ?? []) {
|
|
2286
|
+
probed += 1;
|
|
2287
|
+
const result = await list(ctx, q(ctx, { limit: 5, order: sortTerm(conventions, field, "asc") }));
|
|
2288
|
+
/* Only a rejection counts. Order accepted but not applied is sort.order-is-applied's
|
|
2289
|
+
* finding, not a capability claim breaking. */
|
|
2290
|
+
if (result.exchange.status >= 400 && result.exchange.status < 500) {
|
|
2291
|
+
rejected.push({ field, status: result.exchange.status });
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
if (probed === 0) {
|
|
2295
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "no declared sortable field could be probed");
|
|
2296
|
+
}
|
|
2297
|
+
if (rejected.length === 0)
|
|
2298
|
+
return;
|
|
2299
|
+
ctx.findings.spec(this.id, ctx.entityName, "the document declares a sort field the backend does not accept", `x-query lists ${rejected.length} field(s) as sortable that the backend rejects: `
|
|
2300
|
+
+ `${rejected.map((r) => `${r.field} (${r.status})`).join(", ")}. `
|
|
2301
|
+
+ "Every client generated from this document will offer an order value that fails at "
|
|
2302
|
+
+ "runtime. Either the column needs an index or the declaration needs removing — but "
|
|
2303
|
+
+ "the document and the backend currently disagree about what this API can do.", []);
|
|
2304
|
+
},
|
|
2305
|
+
};
|
|
2306
|
+
/**
|
|
2307
|
+
* The select analogue of {@link declaredFilterableWorks} and {@link declaredSortableWorks}:
|
|
2308
|
+
* `x-query.selectable` is a promise too, and a sparse fieldset the backend rejects breaks every
|
|
2309
|
+
* client generated from the document exactly the same way a rejected filter or sort does.
|
|
2310
|
+
*/
|
|
2311
|
+
const declaredSelectableWorks = {
|
|
2312
|
+
applicable: (ctx) => conv(ctx).select !== undefined
|
|
2313
|
+
&& ctx.query?.source === "tag"
|
|
2314
|
+
&& (ctx.query?.selectable.length ?? 0) > 0
|
|
2315
|
+
&& ctx.records.length > 0,
|
|
2316
|
+
dependsOn: [
|
|
2317
|
+
"list.read-after-write",
|
|
2318
|
+
/* Same masking risk as the filter and sort overclaim checks: ERROR_500_ON_BAD_FILTER turns
|
|
2319
|
+
* any rejection — this one included — into a 500, which would fall outside the 4xx window
|
|
2320
|
+
* below and read as accepted. */
|
|
2321
|
+
"error.malformed-filter-not-5xx",
|
|
2322
|
+
/* Same shape as the sort check's dependency on sort.order-is-applied, found by inspection
|
|
2323
|
+
* before the fuzzer had to: `project()` returns the row untouched under SELECT_IGNORED
|
|
2324
|
+
* *before* the excluded-field check runs, so a backend that has stopped honouring `select`
|
|
2325
|
+
* at all also stops rejecting an overclaimed field — the request just returns every column. */
|
|
2326
|
+
"select.projection-honoured",
|
|
2327
|
+
],
|
|
2328
|
+
id: "spec.declared-selectable-is-selectable",
|
|
2329
|
+
needs: "x-query naming selectable fields",
|
|
2330
|
+
async run(ctx) {
|
|
2331
|
+
const conventions = conv(ctx);
|
|
2332
|
+
const rejected = [];
|
|
2333
|
+
let probed = 0;
|
|
2334
|
+
for (const field of ctx.query?.selectable ?? []) {
|
|
2335
|
+
const projection = selectTerm(conventions, [ctx.identity, field], ctx.entityName);
|
|
2336
|
+
if (projection === null)
|
|
2337
|
+
continue;
|
|
2338
|
+
probed += 1;
|
|
2339
|
+
const result = await list(ctx, { ...q(ctx, { limit: 5 }), ...projection });
|
|
2340
|
+
/* Only a rejection counts. A field accepted and then ignored is select.projection-
|
|
2341
|
+
* honoured's finding, not a capability claim breaking. */
|
|
2342
|
+
if (result.exchange.status >= 400 && result.exchange.status < 500) {
|
|
2343
|
+
rejected.push({ field, status: result.exchange.status });
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
if (probed === 0) {
|
|
2347
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "no declared selectable field could be probed");
|
|
2348
|
+
}
|
|
2349
|
+
if (rejected.length === 0)
|
|
2350
|
+
return;
|
|
2351
|
+
ctx.findings.spec(this.id, ctx.entityName, "the document declares a select field the backend does not accept", `x-query lists ${rejected.length} field(s) as selectable that the backend rejects: `
|
|
2352
|
+
+ `${rejected.map((r) => `${r.field} (${r.status})`).join(", ")}. `
|
|
2353
|
+
+ "Every client generated from this document will offer a sparse fieldset that fails at "
|
|
2354
|
+
+ "runtime. Either the field needs to stay projectable or the declaration needs "
|
|
2355
|
+
+ "removing — but the document and the backend currently disagree about what this API "
|
|
2356
|
+
+ "can do.", []);
|
|
2357
|
+
},
|
|
2358
|
+
};
|
|
2359
|
+
const crossTenantItemRead = {
|
|
2360
|
+
applicable: (ctx) => ctx.altAuth !== undefined && ctx.readOp !== undefined && ctx.records.length > 0,
|
|
2361
|
+
id: "tenant.item-not-readable-cross-tenant",
|
|
2362
|
+
needs: "a second principal in a different tenant",
|
|
2363
|
+
async run(ctx) {
|
|
2364
|
+
const target = ctx.records[0];
|
|
2365
|
+
if (target === undefined || ctx.readOp === undefined || ctx.altAuth === undefined)
|
|
2366
|
+
return;
|
|
2367
|
+
const id = String(target[ctx.identity]);
|
|
2368
|
+
const params = { ...(ctx.altScope ?? ctx.scope), ...itemParamFor(ctx, id) };
|
|
2369
|
+
const exchange = await ctx.client.get(fillPath(ctx.readOp.path, params), {
|
|
2370
|
+
headers: ctx.altAuth(),
|
|
2371
|
+
});
|
|
2372
|
+
if (exchange.status === 403 || exchange.status === 404)
|
|
2373
|
+
return;
|
|
2374
|
+
if (exchange.status >= 400)
|
|
2375
|
+
return;
|
|
2376
|
+
/* Only assert a breach when the document says the route is tenant-scoped. Where oat merely
|
|
2377
|
+
* guessed scoping from a path parameter, a shared catalogue and a data leak are the same
|
|
2378
|
+
* observation, and claiming the worse one erodes trust in every other finding. */
|
|
2379
|
+
const declared = ctx.readOp.tenantSource === "tag";
|
|
2380
|
+
const detail = `${ctx.entityName} ${id} was created by one principal and read successfully ` +
|
|
2381
|
+
`(${exchange.status}) by a principal in a different tenant.`;
|
|
2382
|
+
if (declared) {
|
|
2383
|
+
ctx.findings.security(this.id, ctx.entityName, "a record is readable by a principal in another tenant", `${detail} The operation declares x-tenant: "${ctx.readOp.tenantParam}", so this ` +
|
|
2384
|
+
"crosses a boundary the document states exists.", [exchange]);
|
|
2385
|
+
return;
|
|
2386
|
+
}
|
|
2387
|
+
ctx.findings.report({
|
|
2388
|
+
check: this.id,
|
|
2389
|
+
detail: `${detail} oat inferred tenant scoping from the "${ctx.readOp.tenantParam}" path ` +
|
|
2390
|
+
"parameter; the document does not state it. If this resource is deliberately shared — " +
|
|
2391
|
+
"a public catalogue or gallery — this is correct behaviour and declaring x-tenant, or " +
|
|
2392
|
+
"omitting it, will settle it. If it is not shared, it is a cross-tenant read.",
|
|
2393
|
+
entity: ctx.entityName,
|
|
2394
|
+
evidence: [exchange],
|
|
2395
|
+
summary: "a record crosses an inferred tenant boundary; the document does not say whether that is intended",
|
|
2396
|
+
verdict: "AMBIGUITY",
|
|
2397
|
+
});
|
|
2398
|
+
},
|
|
2399
|
+
};
|
|
2400
|
+
const crossTenantFilterBypass = {
|
|
2401
|
+
applicable: (ctx) => ctx.altAuth !== undefined &&
|
|
2402
|
+
ctx.altScope !== undefined &&
|
|
2403
|
+
filterable(ctx) &&
|
|
2404
|
+
ctx.records.length > 0,
|
|
2405
|
+
/* The probe filters, so anything that stops a filter from selecting over the whole collection
|
|
2406
|
+
* hides the very record whose visibility is in question — a leak that cannot be observed is
|
|
2407
|
+
* not a leak that can be reported. */
|
|
2408
|
+
dependsOn: ["query.filter-selects-from-whole-set"],
|
|
2409
|
+
id: "tenant.filter-does-not-bypass-scope",
|
|
2410
|
+
needs: "a second principal and a `filter` parameter",
|
|
2411
|
+
async run(ctx) {
|
|
2412
|
+
const target = ctx.records[0];
|
|
2413
|
+
if (target === undefined || ctx.altAuth === undefined || ctx.altScope === undefined)
|
|
2414
|
+
return;
|
|
2415
|
+
if (!(ctx.query?.filterable.includes(ctx.identity) ?? false))
|
|
2416
|
+
return;
|
|
2417
|
+
const id = String(target[ctx.identity]);
|
|
2418
|
+
const tenantTerm = filterTerm(conv(ctx), ctx.identity, "eq", id);
|
|
2419
|
+
if (tenantTerm === null)
|
|
2420
|
+
return;
|
|
2421
|
+
const result = await list(ctx, { ...q(ctx, { limit: 100 }), ...tenantTerm }, ctx.altAuth, ctx.altScope);
|
|
2422
|
+
if (result.exchange.status >= 400)
|
|
2423
|
+
return;
|
|
2424
|
+
if (!ids(result.items, ctx.identity).includes(id))
|
|
2425
|
+
return;
|
|
2426
|
+
ctx.findings.security(this.id, ctx.entityName, "a filter reaches records outside the caller's tenant", `a principal in another tenant filtered on ${ctx.identity}.eq.${id} and received the ` +
|
|
2427
|
+
"record. The tenant predicate is applied to the base listing but not re-applied to " +
|
|
2428
|
+
"filter matches, so any caller who can guess an id can read it.", [result.exchange]);
|
|
2429
|
+
},
|
|
2430
|
+
};
|
|
2431
|
+
function sameTenantScope(a, b) {
|
|
2432
|
+
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])];
|
|
2433
|
+
if (keys.length === 0)
|
|
2434
|
+
return true;
|
|
2435
|
+
return keys.every((key) => a[key] === b[key]);
|
|
2436
|
+
}
|
|
2437
|
+
/**
|
|
2438
|
+
* Privilege must be monotonic in rank: anything a lower-ranked role can do, a higher-ranked
|
|
2439
|
+
* role in the same tenant must also be able to do.
|
|
2440
|
+
*
|
|
2441
|
+
* The oracle needs no ground truth about what "owner" means. Two principals, same tenant,
|
|
2442
|
+
* different ranks, one item. If the lower one is served and the higher one is denied, the
|
|
2443
|
+
* lattice is upside down — the failure mode of every hand-rolled role table that inverted
|
|
2444
|
+
* a comparison or attached the wrong policy to a name.
|
|
2445
|
+
*/
|
|
2446
|
+
const rankIsMonotonic = {
|
|
2447
|
+
applicable: (ctx) => {
|
|
2448
|
+
if (ctx.readOp === undefined || ctx.records.length === 0)
|
|
2449
|
+
return false;
|
|
2450
|
+
const primary = ctx.actors[0];
|
|
2451
|
+
if (primary === undefined)
|
|
2452
|
+
return false;
|
|
2453
|
+
const home = ctx.actors.filter((actor) => sameTenantScope(actor.roots, primary.roots));
|
|
2454
|
+
return new Set(home.map((actor) => actor.rank)).size >= 2;
|
|
2455
|
+
},
|
|
2456
|
+
dependsOn: ["list.read-after-write"],
|
|
2457
|
+
id: "auth.rank-is-monotonic",
|
|
2458
|
+
needs: "two same-tenant principals at different ranks, and an item route",
|
|
2459
|
+
async run(ctx) {
|
|
2460
|
+
const target = ctx.records[0];
|
|
2461
|
+
const primary = ctx.actors[0];
|
|
2462
|
+
if (target === undefined || ctx.readOp === undefined || primary === undefined)
|
|
2463
|
+
return;
|
|
2464
|
+
const id = String(target[ctx.identity]);
|
|
2465
|
+
const home = ctx.actors.filter((actor) => sameTenantScope(actor.roots, primary.roots));
|
|
2466
|
+
const readOp = ctx.readOp;
|
|
2467
|
+
const probe = async (actor) => {
|
|
2468
|
+
/* Tenant from the actor; remaining path params (parent ids) from the seeded scope. */
|
|
2469
|
+
const params = { ...ctx.scope, ...actor.roots, ...itemParamFor(ctx, id) };
|
|
2470
|
+
const exchange = await ctx.client.get(fillPath(readOp.path, params), {
|
|
2471
|
+
headers: actor.headers(),
|
|
2472
|
+
});
|
|
2473
|
+
if (exchange.status < 300)
|
|
2474
|
+
return "allow";
|
|
2475
|
+
if (exchange.status === 403 || exchange.status === 404)
|
|
2476
|
+
return "deny";
|
|
2477
|
+
return "error";
|
|
2478
|
+
};
|
|
2479
|
+
const outcomes = new Map();
|
|
2480
|
+
for (const actor of home) {
|
|
2481
|
+
outcomes.set(actor.id, await probe(actor));
|
|
2482
|
+
}
|
|
2483
|
+
for (const lower of home) {
|
|
2484
|
+
for (const higher of home) {
|
|
2485
|
+
if (lower.rank >= higher.rank)
|
|
2486
|
+
continue;
|
|
2487
|
+
const lo = outcomes.get(lower.id);
|
|
2488
|
+
const hi = outcomes.get(higher.id);
|
|
2489
|
+
if (lo !== "allow" || hi !== "deny")
|
|
2490
|
+
continue;
|
|
2491
|
+
ctx.findings.backend(this.id, ctx.entityName, "a lower-ranked role can read a record a higher-ranked role cannot", `${lower.role ?? lower.id} (rank ${lower.rank}) was served ${ctx.entityName} ${id}; ` +
|
|
2492
|
+
`${higher.role ?? higher.id} (rank ${higher.rank}) was denied. Privilege must be ` +
|
|
2493
|
+
"monotonic — a member who can see what an owner cannot is the lattice inverted, " +
|
|
2494
|
+
"not a finer policy.", []);
|
|
2495
|
+
return;
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
},
|
|
2499
|
+
};
|
|
2500
|
+
function pointerValue(body, pointer) {
|
|
2501
|
+
const path = pointer.replace(/^\$\.?/, "").split(".").filter(Boolean);
|
|
2502
|
+
let node = body;
|
|
2503
|
+
for (const segment of path) {
|
|
2504
|
+
if (node === null || typeof node !== "object")
|
|
2505
|
+
return undefined;
|
|
2506
|
+
node = node[segment];
|
|
2507
|
+
}
|
|
2508
|
+
return typeof node === "string" && node !== "" ? node : undefined;
|
|
2509
|
+
}
|
|
2510
|
+
/**
|
|
2511
|
+
* Delegated access is a flow, not a request: B cannot read A's record, A invites B, B accepts,
|
|
2512
|
+
* B can read, A revokes, B cannot. Each step is a capability statement the next one depends on.
|
|
2513
|
+
* A grant that appears before accept, never appears, or survives revoke is a different bug
|
|
2514
|
+
* at the same check — the timeline is the property.
|
|
2515
|
+
*/
|
|
2516
|
+
const inviteGrantsThenRevokes = {
|
|
2517
|
+
applicable: (ctx) => ctx.invite !== null
|
|
2518
|
+
&& ctx.readOp !== undefined
|
|
2519
|
+
&& ctx.records.length > 0
|
|
2520
|
+
&& ctx.actors.some((actor) => actor.inviteAs !== undefined
|
|
2521
|
+
&& ctx.actors[0] !== undefined
|
|
2522
|
+
&& !sameTenantScope(actor.roots, ctx.actors[0].roots)),
|
|
2523
|
+
dependsOn: ["list.read-after-write", "tenant.item-not-readable-cross-tenant"],
|
|
2524
|
+
id: "auth.invite-grants-then-revokes",
|
|
2525
|
+
needs: "x-invite naming invite/accept/revoke, a peer principal with inviteAs, and an item route",
|
|
2526
|
+
async run(ctx) {
|
|
2527
|
+
const spec = ctx.invite;
|
|
2528
|
+
const owner = ctx.actors[0];
|
|
2529
|
+
const target = ctx.records[0];
|
|
2530
|
+
if (spec === null || owner === undefined || target === undefined || ctx.readOp === undefined) {
|
|
2531
|
+
return;
|
|
2532
|
+
}
|
|
2533
|
+
const delegate = ctx.actors.find((actor) => actor.inviteAs !== undefined && !sameTenantScope(actor.roots, owner.roots));
|
|
2534
|
+
if (delegate === undefined || delegate.inviteAs === undefined) {
|
|
2535
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "no peer principal declares inviteAs, so there is no one to invite");
|
|
2536
|
+
}
|
|
2537
|
+
const inviteOp = ctx.model.byOperationId.get(spec.invite);
|
|
2538
|
+
const acceptOp = ctx.model.byOperationId.get(spec.accept);
|
|
2539
|
+
const revokeOp = ctx.model.byOperationId.get(spec.revoke);
|
|
2540
|
+
if (inviteOp === undefined || acceptOp === undefined || revokeOp === undefined) {
|
|
2541
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "x-invite names an operation that is not in the document");
|
|
2542
|
+
}
|
|
2543
|
+
const id = String(target[ctx.identity]);
|
|
2544
|
+
const resource = { ...ctx.scope, ...itemParamFor(ctx, id) };
|
|
2545
|
+
const itemPath = ctx.readOp.path;
|
|
2546
|
+
const canRead = async () => {
|
|
2547
|
+
const exchange = await ctx.client.get(fillPath(itemPath, { ...delegate.roots, ...resource }), {
|
|
2548
|
+
headers: delegate.headers(),
|
|
2549
|
+
});
|
|
2550
|
+
return exchange.status < 300;
|
|
2551
|
+
};
|
|
2552
|
+
if (await canRead()) {
|
|
2553
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, "the delegate could already read the record, so invite cannot be distinguished from a leak");
|
|
2554
|
+
}
|
|
2555
|
+
const invited = await ctx.client.request(inviteOp.method, fillPath(inviteOp.path, resource), {
|
|
2556
|
+
body: { [spec.granteeField]: delegate.inviteAs },
|
|
2557
|
+
headers: { ...owner.headers(), "content-type": "application/json" },
|
|
2558
|
+
});
|
|
2559
|
+
if (invited.status >= 300) {
|
|
2560
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `invite returned ${invited.status}, so accept/revoke were never exercised`);
|
|
2561
|
+
}
|
|
2562
|
+
if (await canRead()) {
|
|
2563
|
+
ctx.findings.backend(this.id, ctx.entityName, "an invite grants access before it is accepted", `inviting ${delegate.id} made ${ctx.entityName} ${id} readable immediately. The accept ` +
|
|
2564
|
+
"step is then theatre — anyone who can be named in an invite body is already in.", [invited]);
|
|
2565
|
+
return;
|
|
2566
|
+
}
|
|
2567
|
+
const token = pointerValue(invited.responseBody, spec.tokenPointer);
|
|
2568
|
+
const grantId = pointerValue(invited.responseBody, spec.grantPointer);
|
|
2569
|
+
if (token === undefined) {
|
|
2570
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `invite response has no token at ${spec.tokenPointer}`);
|
|
2571
|
+
}
|
|
2572
|
+
const accepted = await ctx.client.request(acceptOp.method, fillPath(acceptOp.path, { token }), { headers: delegate.headers() });
|
|
2573
|
+
if (accepted.status >= 300) {
|
|
2574
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `accept returned ${accepted.status}`);
|
|
2575
|
+
}
|
|
2576
|
+
if (!(await canRead())) {
|
|
2577
|
+
ctx.findings.backend(this.id, ctx.entityName, "accepting an invite does not grant access", `${delegate.id} accepted an invite to ${ctx.entityName} ${id} and still cannot read it. ` +
|
|
2578
|
+
"The invite flow completed; the grant did not.", [invited, accepted]);
|
|
2579
|
+
return;
|
|
2580
|
+
}
|
|
2581
|
+
if (grantId === undefined) {
|
|
2582
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `invite response has no grant id at ${spec.grantPointer}, so revoke cannot be expressed`);
|
|
2583
|
+
}
|
|
2584
|
+
const revoked = await ctx.client.request(revokeOp.method, fillPath(revokeOp.path, { ...resource, grant_id: grantId }), { headers: owner.headers() });
|
|
2585
|
+
if (revoked.status >= 300) {
|
|
2586
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `revoke returned ${revoked.status}`);
|
|
2587
|
+
}
|
|
2588
|
+
if (await canRead()) {
|
|
2589
|
+
ctx.findings.backend(this.id, ctx.entityName, "revoking an invite does not remove access", `${delegate.id} can still read ${ctx.entityName} ${id} after the grant was revoked. ` +
|
|
2590
|
+
"A share that cannot be taken back is a standing leak.", [invited, accepted, revoked]);
|
|
2591
|
+
}
|
|
2592
|
+
},
|
|
2593
|
+
};
|
|
2594
|
+
const malformedFilterNot5xx = {
|
|
2595
|
+
applicable: (ctx) => filterable(ctx),
|
|
2596
|
+
id: "error.malformed-filter-not-5xx",
|
|
2597
|
+
needs: "a way to express a filter — a filter expression parameter, or filterable fields",
|
|
2598
|
+
async run(ctx) {
|
|
2599
|
+
const result = await list(ctx, q(ctx, { filter: "((((", limit: 10 }));
|
|
2600
|
+
if (result.exchange.status < 500)
|
|
2601
|
+
return;
|
|
2602
|
+
ctx.findings.backend(this.id, ctx.entityName, "malformed filter input produces a server error", `a syntactically invalid filter returned ${result.exchange.status}. Bad client input must ` +
|
|
2603
|
+
"be rejected with a 4xx; a 5xx means the parser is throwing rather than validating.", [result.exchange]);
|
|
2604
|
+
},
|
|
2605
|
+
};
|
|
2606
|
+
const limitBoundsPageSize = {
|
|
2607
|
+
applicable: (ctx) => conv(ctx).limit !== undefined && ctx.records.length > 2,
|
|
2608
|
+
dependsOn: ["list.read-after-write"],
|
|
2609
|
+
id: "pagination.limit-bounds-page-size",
|
|
2610
|
+
needs: "a page-size query parameter named `limit`",
|
|
2611
|
+
async run(ctx) {
|
|
2612
|
+
const result = await list(ctx, q(ctx, { limit: 2 }));
|
|
2613
|
+
if (result.exchange.status >= 400)
|
|
2614
|
+
return;
|
|
2615
|
+
if (result.items.length <= 2)
|
|
2616
|
+
return;
|
|
2617
|
+
ctx.findings.backend(this.id, ctx.entityName, "limit does not bound the number of records returned", `limit=2 returned ${result.items.length} records. A page size the backend accepts and ` +
|
|
2618
|
+
"ignores means callers cannot bound response size, and every paging loop is wrong.", [result.exchange]);
|
|
2619
|
+
},
|
|
2620
|
+
};
|
|
2621
|
+
const limitRespectsMax = {
|
|
2622
|
+
applicable: (ctx) => conv(ctx).limit !== undefined &&
|
|
2623
|
+
ctx.query?.maxLimit !== undefined &&
|
|
2624
|
+
/* Unless the collection holds more records than the cap, an uncapped backend and a
|
|
2625
|
+
* capped one return the same thing and the check would prove nothing. */
|
|
2626
|
+
ctx.records.length > (ctx.query.maxLimit ?? Number.POSITIVE_INFINITY),
|
|
2627
|
+
dependsOn: ["pagination.limit-bounds-page-size"],
|
|
2628
|
+
id: "pagination.limit-respects-documented-max",
|
|
2629
|
+
needs: "a declared maxLimit, and more records than it",
|
|
2630
|
+
async run(ctx) {
|
|
2631
|
+
const max = ctx.query?.maxLimit;
|
|
2632
|
+
if (max === undefined)
|
|
2633
|
+
return;
|
|
2634
|
+
const result = await list(ctx, q(ctx, { limit: max + 50 }));
|
|
2635
|
+
if (result.exchange.status >= 400)
|
|
2636
|
+
return;
|
|
2637
|
+
if (result.items.length <= max)
|
|
2638
|
+
return;
|
|
2639
|
+
ctx.findings.backend(this.id, ctx.entityName, "page size exceeds the documented maximum", `limit=${max + 50} returned ${result.items.length} records; the document caps limit at ` +
|
|
2640
|
+
`${max}. Either the cap is not enforced or the document overstates it.`, [result.exchange]);
|
|
2641
|
+
},
|
|
2642
|
+
};
|
|
2643
|
+
const hasMoreIsAccurate = {
|
|
2644
|
+
applicable: (ctx) => pageable(ctx) && ctx.records.length > 2,
|
|
2645
|
+
dependsOn: ["pagination.limit-bounds-page-size"],
|
|
2646
|
+
id: "pagination.has-more-is-accurate",
|
|
2647
|
+
needs: "a way to page forward, and a more-pages signal in the body or a Link header",
|
|
2648
|
+
async run(ctx) {
|
|
2649
|
+
const first = await list(ctx, q(ctx, { limit: 1, page: 1 }));
|
|
2650
|
+
if (first.exchange.status >= 400 || first.items.length === 0)
|
|
2651
|
+
return;
|
|
2652
|
+
const flag = envelopeValue(ctx, first, "hasMore");
|
|
2653
|
+
if (typeof flag !== "boolean")
|
|
2654
|
+
return;
|
|
2655
|
+
const second = await list(ctx, q(ctx, { limit: 1, page: 2 }));
|
|
2656
|
+
const moreExist = second.items.length > 0;
|
|
2657
|
+
if (flag === moreExist)
|
|
2658
|
+
return;
|
|
2659
|
+
ctx.findings.backend(this.id, ctx.entityName, flag
|
|
2660
|
+
? "hasMore claims further pages exist when none do"
|
|
2661
|
+
: "hasMore claims no further pages while the next page returns records", `page 1 at limit=1 reported hasMore=${flag}, but page 2 returned ` +
|
|
2662
|
+
`${second.items.length} record(s). Callers that trust the flag will ` +
|
|
2663
|
+
(flag ? "request an empty page" : "silently stop after the first page"), [first.exchange, second.exchange]);
|
|
2664
|
+
},
|
|
2665
|
+
};
|
|
2666
|
+
const orderChangesResult = {
|
|
2667
|
+
applicable: (ctx) => (ctx.query?.sortable.length ?? 0) > 0 && ctx.records.length > 2,
|
|
2668
|
+
dependsOn: ["pagination.limit-bounds-page-size"],
|
|
2669
|
+
id: "sort.order-is-applied",
|
|
2670
|
+
needs: "an `order` parameter and a sortable field",
|
|
2671
|
+
async run(ctx) {
|
|
2672
|
+
const field = ctx.query?.sortable.find((f) => f !== ctx.identity) ?? ctx.query?.sortable[0];
|
|
2673
|
+
if (field === undefined)
|
|
2674
|
+
return;
|
|
2675
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
2676
|
+
const ascending = await list(ctx, q(ctx, { limit, order: sortTerm(conv(ctx), field, "asc") }));
|
|
2677
|
+
if (ascending.exchange.status >= 400 || ascending.items.length < 2)
|
|
2678
|
+
return;
|
|
2679
|
+
const values = ascending.items.map((item) => item[field]);
|
|
2680
|
+
if (values.some((value) => value === undefined))
|
|
2681
|
+
return;
|
|
2682
|
+
const sorted = [...values].sort(compareValues);
|
|
2683
|
+
if (JSON.stringify(values) === JSON.stringify(sorted))
|
|
2684
|
+
return;
|
|
2685
|
+
ctx.findings.backend(this.id, ctx.entityName, "order is accepted but the result is not sorted", `order=${field}.asc returned records whose "${field}" values are not ascending: ` +
|
|
2686
|
+
`${values.slice(0, 5).map((v) => JSON.stringify(v)).join(", ")}. A sort parameter the ` +
|
|
2687
|
+
"backend ignores silently gives every caller arbitrary ordering.", [ascending.exchange]);
|
|
2688
|
+
},
|
|
2689
|
+
};
|
|
2690
|
+
function compareValues(a, b) {
|
|
2691
|
+
if (a === null || a === undefined)
|
|
2692
|
+
return -1;
|
|
2693
|
+
if (b === null || b === undefined)
|
|
2694
|
+
return 1;
|
|
2695
|
+
if (typeof a === "number" && typeof b === "number")
|
|
2696
|
+
return a - b;
|
|
2697
|
+
const as = String(a);
|
|
2698
|
+
const bs = String(b);
|
|
2699
|
+
return as < bs ? -1 : as > bs ? 1 : 0;
|
|
2700
|
+
}
|
|
2701
|
+
const searchNarrowsResult = {
|
|
2702
|
+
applicable: (ctx) => conv(ctx).search !== undefined &&
|
|
2703
|
+
(ctx.query?.searchable.length ?? 0) > 0 &&
|
|
2704
|
+
ctx.records.length > 2,
|
|
2705
|
+
dependsOn: ["list.read-after-write"],
|
|
2706
|
+
id: "search.q-narrows-result",
|
|
2707
|
+
needs: "a free-text `q` parameter and declared searchable fields",
|
|
2708
|
+
async run(ctx) {
|
|
2709
|
+
const field = ctx.query?.searchable[0];
|
|
2710
|
+
if (field === undefined)
|
|
2711
|
+
return;
|
|
2712
|
+
const limit = ctx.query?.maxLimit ?? 100;
|
|
2713
|
+
const all = await list(ctx, q(ctx, { limit }));
|
|
2714
|
+
if (all.items.length < 2)
|
|
2715
|
+
return;
|
|
2716
|
+
/* A token no record can contain: a correct search returns nothing. */
|
|
2717
|
+
const result = await list(ctx, q(ctx, { limit, search: "zzqqxx-oat-no-match-token" }));
|
|
2718
|
+
if (result.exchange.status >= 400)
|
|
2719
|
+
return;
|
|
2720
|
+
if (result.items.length === 0)
|
|
2721
|
+
return;
|
|
2722
|
+
ctx.findings.backend(this.id, ctx.entityName, "free-text search returns records that cannot match", `q= a token no record contains returned ${result.items.length} of ${all.items.length} ` +
|
|
2723
|
+
"records, so the search term is being ignored rather than applied.", [all.exchange, result.exchange]);
|
|
2724
|
+
},
|
|
2725
|
+
};
|
|
2726
|
+
const createPersistsFields = {
|
|
2727
|
+
applicable: (ctx) => ctx.createOp !== undefined && ctx.records.length > 0,
|
|
2728
|
+
dependsOn: ["list.read-after-write"],
|
|
2729
|
+
id: "create.persists-submitted-fields",
|
|
2730
|
+
needs: "a create operation that echoes the record back",
|
|
2731
|
+
async run(ctx) {
|
|
2732
|
+
const createOp = ctx.createOp;
|
|
2733
|
+
if (createOp === undefined)
|
|
2734
|
+
return;
|
|
2735
|
+
const sent = createExchange(ctx);
|
|
2736
|
+
if (sent === undefined || sent.requestBody === undefined)
|
|
2737
|
+
return;
|
|
2738
|
+
const request = sent.requestBody;
|
|
2739
|
+
const response = (sent.responseBody ?? {});
|
|
2740
|
+
const dropped = Object.entries(request).filter(([key, value]) => {
|
|
2741
|
+
if (value === null || value === undefined)
|
|
2742
|
+
return false;
|
|
2743
|
+
if (!Object.hasOwn(response, key))
|
|
2744
|
+
return true;
|
|
2745
|
+
return JSON.stringify(response[key]) !== JSON.stringify(value);
|
|
2746
|
+
});
|
|
2747
|
+
if (dropped.length === 0)
|
|
2748
|
+
return;
|
|
2749
|
+
ctx.findings.backend(this.id, ctx.entityName, "create silently discards submitted fields", `the create response does not carry back ${dropped.length} submitted field(s): ` +
|
|
2750
|
+
dropped
|
|
2751
|
+
.slice(0, 4)
|
|
2752
|
+
.map(([key, value]) => `${key} sent ${JSON.stringify(value)}, got ${JSON.stringify(response[key])}`)
|
|
2753
|
+
.join("; ") +
|
|
2754
|
+
". A write that reports success and drops data is worse than one that fails.", [sent]);
|
|
2755
|
+
},
|
|
2756
|
+
};
|
|
2757
|
+
const enumValidated = {
|
|
2758
|
+
applicable: (ctx) => ctx.createOp !== undefined,
|
|
2759
|
+
mutates: true,
|
|
2760
|
+
id: "validation.enum-enforced",
|
|
2761
|
+
needs: "a request schema with an enum field",
|
|
2762
|
+
async run(ctx) {
|
|
2763
|
+
const createOp = ctx.createOp;
|
|
2764
|
+
if (createOp === undefined)
|
|
2765
|
+
return;
|
|
2766
|
+
const schema = requestSchemaOf(ctx, createOp);
|
|
2767
|
+
if (schema === null)
|
|
2768
|
+
return;
|
|
2769
|
+
const target = findConstrained(schema, (s) => Array.isArray(s.enum) && s.enum.length > 0);
|
|
2770
|
+
if (target === null)
|
|
2771
|
+
return;
|
|
2772
|
+
const body = { ...validBody(ctx, schema), [target.name]: "oat-not-a-member" };
|
|
2773
|
+
const exchange = await ctx.client.request("POST", fillPath(createOp.path, ctx.scope), {
|
|
2774
|
+
body,
|
|
2775
|
+
headers: ctx.auth(),
|
|
2776
|
+
});
|
|
2777
|
+
if (exchange.status >= 400)
|
|
2778
|
+
return;
|
|
2779
|
+
const declared = target.schema.enum.map((v) => JSON.stringify(v)).join(", ");
|
|
2780
|
+
ctx.findings.backend(this.id, ctx.entityName, "a value outside the declared enum was accepted", `"${target.name}" declares [${declared}] but the backend accepted "oat-not-a-member" ` +
|
|
2781
|
+
`with ${exchange.status}. Clients generated from this document will assume the field ` +
|
|
2782
|
+
"only ever holds a declared member.", [exchange]);
|
|
2783
|
+
},
|
|
2784
|
+
};
|
|
2785
|
+
const maxLengthValidated = {
|
|
2786
|
+
applicable: (ctx) => ctx.createOp !== undefined,
|
|
2787
|
+
mutates: true,
|
|
2788
|
+
id: "validation.max-length-enforced",
|
|
2789
|
+
needs: "a request schema with a maxLength constraint",
|
|
2790
|
+
async run(ctx) {
|
|
2791
|
+
const createOp = ctx.createOp;
|
|
2792
|
+
if (createOp === undefined)
|
|
2793
|
+
return;
|
|
2794
|
+
const schema = requestSchemaOf(ctx, createOp);
|
|
2795
|
+
if (schema === null)
|
|
2796
|
+
return;
|
|
2797
|
+
const target = findConstrained(schema, (s) => typeof s.maxLength === "number" && s.maxLength < 4096);
|
|
2798
|
+
if (target === null)
|
|
2799
|
+
return;
|
|
2800
|
+
const max = target.schema.maxLength;
|
|
2801
|
+
const body = { ...validBody(ctx, schema), [target.name]: "x".repeat(max + 25) };
|
|
2802
|
+
const exchange = await ctx.client.request("POST", fillPath(createOp.path, ctx.scope), {
|
|
2803
|
+
body,
|
|
2804
|
+
headers: ctx.auth(),
|
|
2805
|
+
});
|
|
2806
|
+
if (exchange.status >= 400)
|
|
2807
|
+
return;
|
|
2808
|
+
ctx.findings.backend(this.id, ctx.entityName, "a string longer than the declared maxLength was accepted", `"${target.name}" declares maxLength ${max} but a ${max + 25}-character value was stored ` +
|
|
2809
|
+
`(${exchange.status}). The constraint exists in the document only.`, [exchange]);
|
|
2810
|
+
},
|
|
2811
|
+
};
|
|
2812
|
+
const requiredValidated = {
|
|
2813
|
+
applicable: (ctx) => ctx.createOp !== undefined,
|
|
2814
|
+
mutates: true,
|
|
2815
|
+
id: "validation.required-enforced",
|
|
2816
|
+
needs: "a request schema with a required field",
|
|
2817
|
+
async run(ctx) {
|
|
2818
|
+
const createOp = ctx.createOp;
|
|
2819
|
+
if (createOp === undefined)
|
|
2820
|
+
return;
|
|
2821
|
+
const schema = requestSchemaOf(ctx, createOp);
|
|
2822
|
+
if (schema === null)
|
|
2823
|
+
return;
|
|
2824
|
+
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
2825
|
+
const field = required[0];
|
|
2826
|
+
if (field === undefined)
|
|
2827
|
+
return;
|
|
2828
|
+
const { [field]: _omitted, ...body } = validBody(ctx, schema);
|
|
2829
|
+
const exchange = await ctx.client.request("POST", fillPath(createOp.path, ctx.scope), {
|
|
2830
|
+
body,
|
|
2831
|
+
headers: ctx.auth(),
|
|
2832
|
+
});
|
|
2833
|
+
if (exchange.status >= 400)
|
|
2834
|
+
return;
|
|
2835
|
+
ctx.findings.backend(this.id, ctx.entityName, "create succeeded without a required field", `"${field}" is listed in the schema's required array, but omitting it returned ` +
|
|
2836
|
+
`${exchange.status}. Either the handler does not validate it or the document ` +
|
|
2837
|
+
"overstates the requirement.", [exchange]);
|
|
2838
|
+
},
|
|
2839
|
+
};
|
|
2840
|
+
const contentTypeEnforced = {
|
|
2841
|
+
applicable: (ctx) => ctx.createOp !== undefined && ctx.createOp.documentedStatuses.includes(415),
|
|
2842
|
+
mutates: true,
|
|
2843
|
+
id: "validation.content-type-enforced",
|
|
2844
|
+
needs: "a documented 415 response",
|
|
2845
|
+
async run(ctx) {
|
|
2846
|
+
const createOp = ctx.createOp;
|
|
2847
|
+
if (createOp === undefined)
|
|
2848
|
+
return;
|
|
2849
|
+
const schema = requestSchemaOf(ctx, createOp);
|
|
2850
|
+
if (schema === null)
|
|
2851
|
+
return;
|
|
2852
|
+
const exchange = await ctx.client.request("POST", fillPath(createOp.path, ctx.scope), {
|
|
2853
|
+
body: validBody(ctx, schema),
|
|
2854
|
+
contentType: "text/plain",
|
|
2855
|
+
headers: ctx.auth(),
|
|
2856
|
+
});
|
|
2857
|
+
if (exchange.status === 415 || exchange.status >= 400)
|
|
2858
|
+
return;
|
|
2859
|
+
ctx.findings.backend(this.id, ctx.entityName, "a non-JSON content type was accepted on a JSON endpoint", `the operation documents 415 and declares only a JSON request body, but a request sent ` +
|
|
2860
|
+
`as text/plain was processed (${exchange.status}). Content negotiation is documented ` +
|
|
2861
|
+
"but not implemented.", [exchange]);
|
|
2862
|
+
},
|
|
2863
|
+
};
|
|
2864
|
+
const errorSchemaHonoured = {
|
|
2865
|
+
applicable: (ctx) => ctx.validator !== undefined && ctx.readOp !== undefined,
|
|
2866
|
+
id: "schema.error-response-matches-document",
|
|
2867
|
+
needs: "a documented error schema on the item route",
|
|
2868
|
+
async run(ctx) {
|
|
2869
|
+
const readOp = ctx.readOp;
|
|
2870
|
+
const validator = ctx.validator;
|
|
2871
|
+
if (readOp === undefined || validator === undefined)
|
|
2872
|
+
return;
|
|
2873
|
+
const raw = ctx.model.rawOperations.get(readOp.operationId);
|
|
2874
|
+
if (raw === undefined)
|
|
2875
|
+
return;
|
|
2876
|
+
const params = { ...ctx.scope, ...itemParamFor(ctx, "oat-definitely-missing-id") };
|
|
2877
|
+
const exchange = await ctx.client.get(fillPath(readOp.path, params), { headers: ctx.auth() });
|
|
2878
|
+
if (exchange.status < 400)
|
|
2879
|
+
return;
|
|
2880
|
+
if (!validator.documents(raw, exchange.status))
|
|
2881
|
+
return;
|
|
2882
|
+
const result = validator.validate(readOp.operationId, raw, exchange.status, exchange.responseBody);
|
|
2883
|
+
if (result.ok)
|
|
2884
|
+
return;
|
|
2885
|
+
ctx.findings.spec(this.id, ctx.entityName, `${exchange.status} error body does not match its documented schema`, `${readOp.operationId} returned ${exchange.status} with a body that fails the schema the ` +
|
|
2886
|
+
`document declares for it: ${result.errors.join("; ")}. Clients that parse errors ` +
|
|
2887
|
+
"from the spec will not understand this response.", [exchange]);
|
|
2888
|
+
},
|
|
2889
|
+
};
|
|
2890
|
+
const successSchemaHonoured = {
|
|
2891
|
+
applicable: (ctx) => ctx.validator !== undefined && ctx.createOp !== undefined,
|
|
2892
|
+
/* The body is validated against the schema declared *for the status that came back*. When the
|
|
2893
|
+
* status itself is undocumented there is no schema to validate against, and the right finding
|
|
2894
|
+
* is the status one — not silence. */
|
|
2895
|
+
dependsOn: ["create.status-matches-document"],
|
|
2896
|
+
id: "schema.success-response-matches-document",
|
|
2897
|
+
needs: "a documented success schema on create",
|
|
2898
|
+
async run(ctx) {
|
|
2899
|
+
const createOp = ctx.createOp;
|
|
2900
|
+
const validator = ctx.validator;
|
|
2901
|
+
if (createOp === undefined || validator === undefined)
|
|
2902
|
+
return;
|
|
2903
|
+
const raw = ctx.model.rawOperations.get(createOp.operationId);
|
|
2904
|
+
if (raw === undefined)
|
|
2905
|
+
return;
|
|
2906
|
+
const exchange = createExchange(ctx);
|
|
2907
|
+
if (exchange === undefined)
|
|
2908
|
+
return;
|
|
2909
|
+
if (!validator.documents(raw, exchange.status)) {
|
|
2910
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `create returned ${exchange.status}, which the document declares no schema for — `
|
|
2911
|
+
+ "there is nothing to validate the body against");
|
|
2912
|
+
}
|
|
2913
|
+
const result = validator.validate(createOp.operationId, raw, exchange.status, exchange.responseBody);
|
|
2914
|
+
if (result.ok)
|
|
2915
|
+
return;
|
|
2916
|
+
ctx.findings.spec(this.id, ctx.entityName, "success response does not match its documented schema", `${createOp.operationId} returned ${exchange.status} with a body that fails the declared ` +
|
|
2917
|
+
`schema: ${result.errors.join("; ")}. Either the handler returns more than it promises ` +
|
|
2918
|
+
"or the document is out of date — both break generated clients.", [exchange]);
|
|
2919
|
+
},
|
|
2920
|
+
};
|
|
2921
|
+
/** Builds a body that should be accepted, for use as the base of a negative probe. */
|
|
2922
|
+
function validBody(ctx, schema) {
|
|
2923
|
+
const [member] = buildCohort(schema, ctx.seed, ["baseline"]);
|
|
2924
|
+
return member?.body ?? {};
|
|
2925
|
+
}
|
|
2926
|
+
function requestSchemaOf(ctx, op) {
|
|
2927
|
+
const raw = ctx.model.rawOperations.get(op.operationId);
|
|
2928
|
+
const content = raw?.requestBody?.content;
|
|
2929
|
+
if (content === undefined)
|
|
2930
|
+
return null;
|
|
2931
|
+
for (const [mediaType, media] of Object.entries(content)) {
|
|
2932
|
+
if (mediaType.includes("json") && media.schema !== undefined)
|
|
2933
|
+
return media.schema;
|
|
2934
|
+
}
|
|
2935
|
+
return null;
|
|
2936
|
+
}
|
|
2937
|
+
function findConstrained(schema, predicate) {
|
|
2938
|
+
const props = schema.properties;
|
|
2939
|
+
if (props === null || typeof props !== "object")
|
|
2940
|
+
return null;
|
|
2941
|
+
for (const [name, raw] of Object.entries(props)) {
|
|
2942
|
+
if (raw === null || typeof raw !== "object")
|
|
2943
|
+
continue;
|
|
2944
|
+
if (raw.readOnly === true)
|
|
2945
|
+
continue;
|
|
2946
|
+
if (predicate(raw))
|
|
2947
|
+
return { name, schema: raw };
|
|
2948
|
+
const union = raw.oneOf ?? raw.anyOf;
|
|
2949
|
+
if (Array.isArray(union)) {
|
|
2950
|
+
const branch = union.find((candidate) => candidate !== null &&
|
|
2951
|
+
typeof candidate === "object" &&
|
|
2952
|
+
predicate(candidate));
|
|
2953
|
+
if (branch !== undefined)
|
|
2954
|
+
return { name, schema: branch };
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
return null;
|
|
2958
|
+
}
|
|
2959
|
+
/**
|
|
2960
|
+
* Order matters. Cascade suppression consults findings already reported for this entity, so a
|
|
2961
|
+
* check must run after everything it depends on — primitives first, then the properties built
|
|
2962
|
+
* on top of them. Otherwise a broken primitive is reported once as itself and again as every
|
|
2963
|
+
* downstream consequence.
|
|
2964
|
+
*/
|
|
2965
|
+
/**
|
|
2966
|
+
* An operation declaring `x-effects` must produce exactly the stated change.
|
|
2967
|
+
*
|
|
2968
|
+
* `x-invalidate` says a read route changes; `x-effects` says *how*. That difference is what
|
|
2969
|
+
* separates "something differed" — which is satisfied by a stray timestamp and missed by a
|
|
2970
|
+
* cache-stale read — from an exact assertion on cardinality and membership.
|
|
2971
|
+
*/
|
|
2972
|
+
/**
|
|
2973
|
+
* A numeric field must compare numerically, not lexically.
|
|
2974
|
+
*
|
|
2975
|
+
* Query values arrive from a URL as text, and a backend that forgets to coerce them compares
|
|
2976
|
+
* "10" < "9" — so `amount.gt.9` silently omits 10, 20 and 100. The result is a plausible-looking
|
|
2977
|
+
* subset rather than an error, which is why it survives in production.
|
|
2978
|
+
*/
|
|
2979
|
+
const numericComparisonIsNumeric = {
|
|
2980
|
+
applicable: (ctx) => filterable(ctx) &&
|
|
2981
|
+
numericFilterField(ctx) !== null &&
|
|
2982
|
+
ctx.records.length > 2,
|
|
2983
|
+
dependsOn: ["list.read-after-write", "filter.unknown-field-rejected"],
|
|
2984
|
+
id: "filter.numeric-comparison-is-numeric",
|
|
2985
|
+
needs: "a `filter` parameter and a numeric field",
|
|
2986
|
+
async run(ctx) {
|
|
2987
|
+
const field = numericFilterField(ctx);
|
|
2988
|
+
if (field === null)
|
|
2989
|
+
return;
|
|
2990
|
+
const values = ctx.records
|
|
2991
|
+
.map((r) => r[field])
|
|
2992
|
+
.filter((v) => typeof v === "number");
|
|
2993
|
+
if (values.length < 3)
|
|
2994
|
+
return;
|
|
2995
|
+
/* A threshold that partitions the cohort, chosen so the lexical and numeric answers
|
|
2996
|
+
* differ — comparing as text has to produce a visibly wrong set for this to prove
|
|
2997
|
+
* anything. */
|
|
2998
|
+
const sorted = [...new Set(values)].sort((a, b) => a - b);
|
|
2999
|
+
const threshold = sorted[Math.floor(sorted.length / 2)];
|
|
3000
|
+
if (threshold === undefined)
|
|
3001
|
+
return;
|
|
3002
|
+
const expected = ctx.records
|
|
3003
|
+
.filter((r) => typeof r[field] === "number" && r[field] > threshold)
|
|
3004
|
+
.map((r) => String(r[ctx.identity]))
|
|
3005
|
+
.sort();
|
|
3006
|
+
if (expected.length === 0)
|
|
3007
|
+
return;
|
|
3008
|
+
const gtTerm = filterTerm(conv(ctx), field, "gt", threshold);
|
|
3009
|
+
if (gtTerm === null)
|
|
3010
|
+
return;
|
|
3011
|
+
const result = await list(ctx, { ...q(ctx, { limit: ctx.query?.maxLimit ?? 100 }), ...gtTerm });
|
|
3012
|
+
if (result.exchange.status >= 400)
|
|
3013
|
+
return;
|
|
3014
|
+
const got = ids(result.items, ctx.identity).sort();
|
|
3015
|
+
if (got.join(",") === expected.join(","))
|
|
3016
|
+
return;
|
|
3017
|
+
const missing = expected.filter((id) => !got.includes(id));
|
|
3018
|
+
const extra = got.filter((id) => !expected.includes(id));
|
|
3019
|
+
const lexical = expected.filter((id) => {
|
|
3020
|
+
const record = ctx.records.find((r) => String(r[ctx.identity]) === id);
|
|
3021
|
+
return record !== undefined && String(record[field]) < String(threshold);
|
|
3022
|
+
});
|
|
3023
|
+
ctx.findings.backend(this.id, ctx.entityName, `"${field}" is compared as text rather than as a number`, `filter=${field}.gt.${threshold} returned ${got.length} record(s); ${expected.length} hold a ` +
|
|
3024
|
+
`greater numeric value. Missing: ${missing.slice(0, 4).join(", ") || "none"}. ` +
|
|
3025
|
+
`Unexpected: ${extra.slice(0, 4).join(", ") || "none"}. ` +
|
|
3026
|
+
(missing.length > 0 && missing.every((id) => lexical.includes(id))
|
|
3027
|
+
? "Every missing record is one whose value sorts below the threshold as a string, " +
|
|
3028
|
+
"so the comparison is lexical: the query value was never coerced from text."
|
|
3029
|
+
: "The comparison does not agree with numeric ordering."), [result.exchange]);
|
|
3030
|
+
},
|
|
3031
|
+
};
|
|
3032
|
+
function numericFilterField(ctx) {
|
|
3033
|
+
for (const field of ctx.query?.filterable ?? []) {
|
|
3034
|
+
if (field === ctx.identity)
|
|
3035
|
+
continue;
|
|
3036
|
+
const values = ctx.records.map((r) => r[field]);
|
|
3037
|
+
const numbers = values.filter((v) => typeof v === "number");
|
|
3038
|
+
if (numbers.length < 3)
|
|
3039
|
+
continue;
|
|
3040
|
+
/* Only useful when text and numeric ordering actually disagree across the cohort. */
|
|
3041
|
+
const asNumbers = [...new Set(numbers)].sort((a, b) => a - b);
|
|
3042
|
+
const asText = [...new Set(numbers)].sort((a, b) => String(a).localeCompare(String(b)));
|
|
3043
|
+
if (asNumbers.join(",") !== asText.join(","))
|
|
3044
|
+
return field;
|
|
3045
|
+
}
|
|
3046
|
+
return null;
|
|
3047
|
+
}
|
|
3048
|
+
/**
|
|
3049
|
+
* Concurrent writes to different fields must both survive.
|
|
3050
|
+
*
|
|
3051
|
+
* A handler that reads the row, then writes every column back — the `save(entity)` pattern —
|
|
3052
|
+
* reinstates whatever it read, so the later write silently reverts the earlier one. No error is
|
|
3053
|
+
* returned to either caller, which is what makes it so hard to notice from the outside.
|
|
3054
|
+
*/
|
|
3055
|
+
const noLostUpdate = {
|
|
3056
|
+
applicable: (ctx) => ctx.updateOp !== undefined && ctx.readOp !== undefined && ctx.records.length > 0,
|
|
3057
|
+
dependsOn: [
|
|
3058
|
+
"list.read-after-write",
|
|
3059
|
+
"patch.minimality",
|
|
3060
|
+
/* If writes do not persist at all, "the write was lost" describes a consequence rather
|
|
3061
|
+
* than a concurrency defect. */
|
|
3062
|
+
"create.persists-submitted-fields",
|
|
3063
|
+
],
|
|
3064
|
+
id: "concurrency.no-lost-update",
|
|
3065
|
+
needs: "an update operation and two writable string fields",
|
|
3066
|
+
mutates: true,
|
|
3067
|
+
async run(ctx) {
|
|
3068
|
+
const target = ctx.records[0];
|
|
3069
|
+
if (target === undefined || ctx.updateOp === undefined || ctx.readOp === undefined)
|
|
3070
|
+
return;
|
|
3071
|
+
const id = String(target[ctx.identity]);
|
|
3072
|
+
const params = { ...ctx.scope, ...itemParamFor(ctx, id) };
|
|
3073
|
+
const before = await ctx.client.get(fillPath(ctx.readOp.path, params), { headers: ctx.auth() });
|
|
3074
|
+
if (before.status >= 300) {
|
|
3075
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `reading the record back returned ${before.status}, so there was no baseline to race `
|
|
3076
|
+
+ "against");
|
|
3077
|
+
}
|
|
3078
|
+
const original = (before.responseBody ?? {});
|
|
3079
|
+
/* Two distinct writable string fields, so the writes cannot legitimately clobber each
|
|
3080
|
+
* other — last-write-wins on the *same* field would be a defensible policy. */
|
|
3081
|
+
const fields = writableStringFields(ctx, original).slice(0, 2);
|
|
3082
|
+
const [first, second] = fields;
|
|
3083
|
+
if (first === undefined || second === undefined) {
|
|
3084
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `only ${fields.length} writable string field(s) are present on the record; two are `
|
|
3085
|
+
+ "needed so the concurrent writes cannot legitimately clobber each other");
|
|
3086
|
+
}
|
|
3087
|
+
const path = fillPath(ctx.updateOp.path, params);
|
|
3088
|
+
const [firstWrite, secondWrite] = await Promise.all([
|
|
3089
|
+
ctx.client.request("PATCH", path, {
|
|
3090
|
+
body: { [first]: "oat-concurrent-a" },
|
|
3091
|
+
headers: ctx.auth(),
|
|
3092
|
+
}),
|
|
3093
|
+
ctx.client.request("PATCH", path, {
|
|
3094
|
+
body: { [second]: "oat-concurrent-b" },
|
|
3095
|
+
headers: ctx.auth(),
|
|
3096
|
+
}),
|
|
3097
|
+
]);
|
|
3098
|
+
if (firstWrite.status >= 300 || secondWrite.status >= 300) {
|
|
3099
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `a concurrent PATCH was rejected (${firstWrite.status}, ${secondWrite.status}), so no `
|
|
3100
|
+
+ "race actually took place");
|
|
3101
|
+
}
|
|
3102
|
+
const after = await ctx.client.get(fillPath(ctx.readOp.path, params), { headers: ctx.auth() });
|
|
3103
|
+
if (after.status >= 300) {
|
|
3104
|
+
return ctx.findings.unresolved(this.id, ctx.entityName, `reading the record back after the race returned ${after.status}`);
|
|
3105
|
+
}
|
|
3106
|
+
const current = (after.responseBody ?? {});
|
|
3107
|
+
const lost = [];
|
|
3108
|
+
if (current[first] !== "oat-concurrent-a")
|
|
3109
|
+
lost.push(first);
|
|
3110
|
+
if (current[second] !== "oat-concurrent-b")
|
|
3111
|
+
lost.push(second);
|
|
3112
|
+
if (lost.length === 0) {
|
|
3113
|
+
return;
|
|
3114
|
+
}
|
|
3115
|
+
ctx.findings.backend(this.id, ctx.entityName, "a concurrent write to a different field was silently lost", `two simultaneous patches set "${first}" and "${second}" — different fields, so neither ` +
|
|
3116
|
+
`conflicts with the other — and both returned success, but ${lost.join(" and ")} ` +
|
|
3117
|
+
`read back as ${lost.map((f) => JSON.stringify(current[f])).join(", ")} afterwards. ` +
|
|
3118
|
+
"The update path reads the record and writes every column back, so whichever request " +
|
|
3119
|
+
"commits second reinstates the values it read before the first had committed. Callers " +
|
|
3120
|
+
"are told the write succeeded.", [firstWrite, secondWrite, after]);
|
|
3121
|
+
},
|
|
3122
|
+
};
|
|
3123
|
+
/**
|
|
3124
|
+
* Fields a probe may write an arbitrary string into without the backend having grounds to refuse.
|
|
3125
|
+
*
|
|
3126
|
+
* "Writable" is not enough: an enum, a pattern or a length cap makes an arbitrary value invalid,
|
|
3127
|
+
* and a backend that rejects or normalises it is behaving correctly. Reading that back as a lost
|
|
3128
|
+
* write turns sound validation into a fabricated concurrency bug — which is exactly what happened
|
|
3129
|
+
* when a probe value was written into a `status` enum.
|
|
3130
|
+
*/
|
|
3131
|
+
function writableStringFields(ctx, record) {
|
|
3132
|
+
const immutable = new Set([...(ctx.updateOp?.immutable ?? []), ...(ctx.updateOp?.generated ?? [])]);
|
|
3133
|
+
const schema = ctx.updateOp === undefined ? null : requestSchemaOf(ctx, ctx.updateOp);
|
|
3134
|
+
const properties = (schema?.properties ?? {});
|
|
3135
|
+
/* Where the update body enumerates its properties, that list *is* the set of writable fields —
|
|
3136
|
+
* a field the request schema does not mention is one the caller was never invited to send, and
|
|
3137
|
+
* a backend is right to ignore it. Reading the record instead of the schema is what let a probe
|
|
3138
|
+
* target `status` on an operation that only accepts `name`. */
|
|
3139
|
+
const enumerated = Object.keys(properties).length > 0;
|
|
3140
|
+
const out = [];
|
|
3141
|
+
for (const [key, value] of Object.entries(record)) {
|
|
3142
|
+
if (immutable.has(key) || key === ctx.identity)
|
|
3143
|
+
continue;
|
|
3144
|
+
if (/_at$|_id$/.test(key))
|
|
3145
|
+
continue;
|
|
3146
|
+
if (typeof value !== "string")
|
|
3147
|
+
continue;
|
|
3148
|
+
const declared = properties[key];
|
|
3149
|
+
if (enumerated && declared === undefined)
|
|
3150
|
+
continue;
|
|
3151
|
+
if (declared !== undefined) {
|
|
3152
|
+
if (Array.isArray(declared.enum))
|
|
3153
|
+
continue;
|
|
3154
|
+
if (typeof declared.pattern === "string")
|
|
3155
|
+
continue;
|
|
3156
|
+
if (typeof declared.format === "string")
|
|
3157
|
+
continue;
|
|
3158
|
+
/* The probe values are short, but a cap tight enough to reject them would make the
|
|
3159
|
+
* write fail for a reason that has nothing to do with concurrency. */
|
|
3160
|
+
if (typeof declared.maxLength === "number" && declared.maxLength < 32)
|
|
3161
|
+
continue;
|
|
3162
|
+
}
|
|
3163
|
+
out.push(key);
|
|
3164
|
+
}
|
|
3165
|
+
return out;
|
|
3166
|
+
}
|
|
3167
|
+
const declaredEffectsOccur = {
|
|
3168
|
+
applicable: (ctx) => ctx.effectOps.length > 0,
|
|
3169
|
+
dependsOn: ["list.read-after-write"],
|
|
3170
|
+
mutates: true,
|
|
3171
|
+
id: "effects.declared-effect-occurs",
|
|
3172
|
+
needs: "an operation declaring x-effects",
|
|
3173
|
+
async run(ctx) {
|
|
3174
|
+
for (const op of ctx.effectOps) {
|
|
3175
|
+
for (const effect of op.effects) {
|
|
3176
|
+
const target = ctx.model.entities.get(effect.entity);
|
|
3177
|
+
const listId = target?.list;
|
|
3178
|
+
if (listId === undefined) {
|
|
3179
|
+
ctx.findings.gap(this.id, ctx.entityName, `${op.operationId} declares an effect on "${effect.entity}", which has no list route`, "the effect cannot be observed, so it is not verified");
|
|
3180
|
+
continue;
|
|
3181
|
+
}
|
|
3182
|
+
const listOp = ctx.model.byOperationId.get(listId);
|
|
3183
|
+
if (listOp === undefined)
|
|
3184
|
+
continue;
|
|
3185
|
+
const before = await observe(ctx, listOp);
|
|
3186
|
+
if (before === null)
|
|
3187
|
+
continue;
|
|
3188
|
+
const body = op.hasRequestBody ? validBody(ctx, requestSchemaOf(ctx, op) ?? {}) : undefined;
|
|
3189
|
+
const invoked = await ctx.client.request(op.method, fillPath(op.path, ctx.scope), {
|
|
3190
|
+
headers: ctx.auth(),
|
|
3191
|
+
...(body === undefined ? {} : { body }),
|
|
3192
|
+
});
|
|
3193
|
+
if (invoked.status >= 400) {
|
|
3194
|
+
ctx.findings.gap(this.id, ctx.entityName, `${op.operationId} could not be invoked`, `returned ${invoked.status}; its declared effects are unverified`);
|
|
3195
|
+
continue;
|
|
3196
|
+
}
|
|
3197
|
+
const after = await observe(ctx, listOp);
|
|
3198
|
+
if (after === null)
|
|
3199
|
+
continue;
|
|
3200
|
+
const expected = effect.count ?? 1;
|
|
3201
|
+
const delta = after.ids.length - before.ids.length;
|
|
3202
|
+
const added = after.ids.filter((id) => !before.ids.includes(id));
|
|
3203
|
+
const removed = before.ids.filter((id) => !after.ids.includes(id));
|
|
3204
|
+
const wanted = effect.op === "create" || effect.op === "append"
|
|
3205
|
+
? expected
|
|
3206
|
+
: effect.op === "delete"
|
|
3207
|
+
? -expected
|
|
3208
|
+
: 0;
|
|
3209
|
+
if (effect.op === "create" || effect.op === "append") {
|
|
3210
|
+
if (delta === wanted && added.length === expected)
|
|
3211
|
+
continue;
|
|
3212
|
+
ctx.findings.backend(this.id, ctx.entityName, `${op.operationId} did not produce the "${effect.entity}" records it declares`, `x-effects declares ${effect.op} × ${expected} on "${effect.entity}", but the ` +
|
|
3213
|
+
`collection went from ${before.ids.length} to ${after.ids.length} ` +
|
|
3214
|
+
`(${added.length} added, ${removed.length} removed). A declared effect that does ` +
|
|
3215
|
+
"not occur means callers cannot rely on the operation having done anything.", [invoked, after.exchange]);
|
|
3216
|
+
continue;
|
|
3217
|
+
}
|
|
3218
|
+
if (effect.op === "delete") {
|
|
3219
|
+
if (delta === wanted && removed.length === expected)
|
|
3220
|
+
continue;
|
|
3221
|
+
ctx.findings.backend(this.id, ctx.entityName, `${op.operationId} did not remove the "${effect.entity}" records it declares`, `x-effects declares delete × ${expected} on "${effect.entity}", but the collection ` +
|
|
3222
|
+
`went from ${before.ids.length} to ${after.ids.length}`, [invoked, after.exchange]);
|
|
3223
|
+
continue;
|
|
3224
|
+
}
|
|
3225
|
+
/* update and replace change content, not cardinality. */
|
|
3226
|
+
if (delta !== 0) {
|
|
3227
|
+
ctx.findings.backend(this.id, ctx.entityName, `${op.operationId} changed the size of "${effect.entity}" while declaring ${effect.op}`, `x-effects declares ${effect.op}, which must not add or remove records, but the ` +
|
|
3228
|
+
`collection went from ${before.ids.length} to ${after.ids.length}`, [invoked, after.exchange]);
|
|
3229
|
+
}
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
3232
|
+
},
|
|
3233
|
+
};
|
|
3234
|
+
async function observe(ctx, listOp) {
|
|
3235
|
+
let path;
|
|
3236
|
+
try {
|
|
3237
|
+
path = fillPath(listOp.path, ctx.scope);
|
|
3238
|
+
}
|
|
3239
|
+
catch {
|
|
3240
|
+
return null;
|
|
3241
|
+
}
|
|
3242
|
+
const exchange = await ctx.client.get(path, {
|
|
3243
|
+
headers: ctx.auth(),
|
|
3244
|
+
query: { limit: listOp.query?.maxLimit ?? 100 },
|
|
3245
|
+
});
|
|
3246
|
+
if (exchange.status >= 400)
|
|
3247
|
+
return null;
|
|
3248
|
+
const identity = ctx.model.entities.get(listOp.entity ?? "")?.identity ?? "id";
|
|
3249
|
+
const items = extractItems(exchange.responseBody, listOp.collection?.key ?? null);
|
|
3250
|
+
return { exchange, ids: items.map((item) => String(item[identity])) };
|
|
3251
|
+
}
|
|
3252
|
+
const asyncReachesTerminalState = {
|
|
3253
|
+
applicable: (ctx) => ctx.asyncOps.length > 0,
|
|
3254
|
+
mutates: true,
|
|
3255
|
+
id: "async.reaches-terminal-state",
|
|
3256
|
+
needs: "an operation declaring x-async",
|
|
3257
|
+
async run(ctx) {
|
|
3258
|
+
for (const op of ctx.asyncOps) {
|
|
3259
|
+
const spec = op.async;
|
|
3260
|
+
if (spec === null)
|
|
3261
|
+
continue;
|
|
3262
|
+
const body = op.hasRequestBody
|
|
3263
|
+
? validBody(ctx, requestSchemaOf(ctx, op) ?? {})
|
|
3264
|
+
: undefined;
|
|
3265
|
+
const start = await ctx.client.request(op.method, fillPath(op.path, ctx.scope), {
|
|
3266
|
+
headers: ctx.auth(),
|
|
3267
|
+
...(body === undefined ? {} : { body }),
|
|
3268
|
+
});
|
|
3269
|
+
if (start.status >= 400) {
|
|
3270
|
+
ctx.findings.gap(this.id, ctx.entityName, `${op.operationId} could not be started`, `returned ${start.status}; the async lifecycle after it is untested`);
|
|
3271
|
+
continue;
|
|
3272
|
+
}
|
|
3273
|
+
const outcome = await driveAsync(ctx.client, spec, start.responseBody, ctx.scope, ctx.auth());
|
|
3274
|
+
if (outcome.timedOut) {
|
|
3275
|
+
ctx.findings.backend(this.id, ctx.entityName, `${op.operationId} never reached a terminal state`, `polled ${spec.poll} ${outcome.polls} time(s) over ${Math.round(outcome.elapsedMs)}ms ` +
|
|
3276
|
+
`without satisfying "${spec.until ?? "any terminal state"}". A job that neither ` +
|
|
3277
|
+
"completes nor fails leaves callers polling forever.", [start, ...outcome.exchanges.slice(-2)]);
|
|
3278
|
+
continue;
|
|
3279
|
+
}
|
|
3280
|
+
if (outcome.terminal === null) {
|
|
3281
|
+
ctx.findings.backend(this.id, ctx.entityName, `${op.operationId} job disappeared before completing`, `the poll route stopped serving the job after ${outcome.polls} poll(s). A job that ` +
|
|
3282
|
+
"vanishes is indistinguishable from one that never existed.", [start, ...outcome.exchanges.slice(-2)]);
|
|
3283
|
+
continue;
|
|
3284
|
+
}
|
|
3285
|
+
if (!outcome.succeeded) {
|
|
3286
|
+
ctx.findings.gap(this.id, ctx.entityName, `${op.operationId} reached a non-success terminal state`, `terminal state did not satisfy "${spec.successWhen ?? ""}"; downstream effects of ` +
|
|
3287
|
+
"this operation are untested");
|
|
3288
|
+
}
|
|
3289
|
+
}
|
|
3290
|
+
},
|
|
3291
|
+
};
|
|
3292
|
+
const asyncReceiptIsResolvable = {
|
|
3293
|
+
applicable: (ctx) => ctx.asyncOps.some((op) => op.async?.idFrom !== undefined),
|
|
3294
|
+
id: "async.receipt-identifies-the-job",
|
|
3295
|
+
needs: "x-async with an idFrom pointer",
|
|
3296
|
+
async run(ctx) {
|
|
3297
|
+
for (const op of ctx.asyncOps) {
|
|
3298
|
+
const spec = op.async;
|
|
3299
|
+
if (spec?.idFrom === undefined)
|
|
3300
|
+
continue;
|
|
3301
|
+
/* Match the async operation's own exchange by resolved path — any POST would do here
|
|
3302
|
+
* otherwise, and a sibling create's response would be inspected instead. */
|
|
3303
|
+
let resolved;
|
|
3304
|
+
try {
|
|
3305
|
+
resolved = fillPath(op.path, ctx.scope);
|
|
3306
|
+
}
|
|
3307
|
+
catch {
|
|
3308
|
+
continue;
|
|
3309
|
+
}
|
|
3310
|
+
const started = ctx.client.transcript.find((e) => e.method === op.method && e.status < 300 && new URL(e.url).pathname === resolved);
|
|
3311
|
+
if (started === undefined)
|
|
3312
|
+
continue;
|
|
3313
|
+
const body = started.responseBody;
|
|
3314
|
+
const path = spec.idFrom.replace(/^\$\.?/, "").split(".").filter(Boolean);
|
|
3315
|
+
let node = body;
|
|
3316
|
+
for (const segment of path) {
|
|
3317
|
+
if (node === null || typeof node !== "object") {
|
|
3318
|
+
node = undefined;
|
|
3319
|
+
break;
|
|
3320
|
+
}
|
|
3321
|
+
node = node[segment];
|
|
3322
|
+
}
|
|
3323
|
+
if (node !== undefined && node !== null)
|
|
3324
|
+
continue;
|
|
3325
|
+
ctx.findings.spec(this.id, ctx.entityName, `${op.operationId} response does not carry the job identifier it declares`, `x-async names "${spec.idFrom}" as the job id, but the response has no such value. ` +
|
|
3326
|
+
"Callers cannot poll for a job they cannot name.", [started]);
|
|
3327
|
+
}
|
|
3328
|
+
},
|
|
3329
|
+
};
|
|
3330
|
+
export const CHECKS = [
|
|
3331
|
+
/* foundations — did the write land, is it visible, and do the paging primitives work at all.
|
|
3332
|
+
* Everything below assumes these hold, so they must be evaluated first for cascade
|
|
3333
|
+
* suppression to have anything to consult. */
|
|
3334
|
+
readAfterWrite,
|
|
3335
|
+
createPersistsFields,
|
|
3336
|
+
createStatusMatchesSpec,
|
|
3337
|
+
successSchemaHonoured,
|
|
3338
|
+
errorSchemaHonoured,
|
|
3339
|
+
limitBoundsPageSize,
|
|
3340
|
+
limitRespectsMax,
|
|
3341
|
+
hasMoreIsAccurate,
|
|
3342
|
+
/* Ordering and the page walk are foundations too, not query niceties: the set-algebra checks
|
|
3343
|
+
* below gather their sets *across pages*, so a broken walk would corrupt the very sets they
|
|
3344
|
+
* compare. Establishing paging first is what lets those failures be suppressed as cascades
|
|
3345
|
+
* rather than re-reported once per predicate. */
|
|
3346
|
+
orderChangesResult,
|
|
3347
|
+
pageWalkCoversSet,
|
|
3348
|
+
cursorAgreesWithPage,
|
|
3349
|
+
/* query semantics */
|
|
3350
|
+
unknownFilterRejected,
|
|
3351
|
+
malformedFilterNot5xx,
|
|
3352
|
+
equalityFilterSelectsOne,
|
|
3353
|
+
zeroMatchFilter,
|
|
3354
|
+
negationPartitions,
|
|
3355
|
+
filterAndComposesAsIntersection,
|
|
3356
|
+
filterOrComposesAsUnion,
|
|
3357
|
+
likeEscaping,
|
|
3358
|
+
sortReverseSymmetry,
|
|
3359
|
+
searchNarrowsResult,
|
|
3360
|
+
selectProjection,
|
|
3361
|
+
countIsConsistentWithPage,
|
|
3362
|
+
countMatchesWalk,
|
|
3363
|
+
numericComparisonIsNumeric,
|
|
3364
|
+
/* write semantics */
|
|
3365
|
+
patchMinimality,
|
|
3366
|
+
immutableRejected,
|
|
3367
|
+
idempotentReplay,
|
|
3368
|
+
declaredInvalidationHappens,
|
|
3369
|
+
projectionsAgree,
|
|
3370
|
+
queryAxesCompose,
|
|
3371
|
+
filterAndPagingCompose,
|
|
3372
|
+
filterAndSelectCompose,
|
|
3373
|
+
searchAndFilterCompose,
|
|
3374
|
+
filterSortSelectCompose,
|
|
3375
|
+
filterSearchSortCompose,
|
|
3376
|
+
filterSearchSelectCompose,
|
|
3377
|
+
declaredFilterableWorks,
|
|
3378
|
+
declaredSortableWorks,
|
|
3379
|
+
declaredSelectableWorks,
|
|
3380
|
+
noLostUpdate,
|
|
3381
|
+
deleteMissingIs404,
|
|
3382
|
+
softDeleteHidden,
|
|
3383
|
+
/* input validation */
|
|
3384
|
+
enumValidated,
|
|
3385
|
+
maxLengthValidated,
|
|
3386
|
+
requiredValidated,
|
|
3387
|
+
contentTypeEnforced,
|
|
3388
|
+
/* isolation */
|
|
3389
|
+
crossTenantItemRead,
|
|
3390
|
+
denialDoesNotRevealExistence,
|
|
3391
|
+
crossTenantFilterBypass,
|
|
3392
|
+
rankIsMonotonic,
|
|
3393
|
+
inviteGrantsThenRevokes,
|
|
3394
|
+
/* declared side effects and async lifecycles, last: both invoke operations that change the
|
|
3395
|
+
* world, and both are meaningless if the read surface above is already known broken */
|
|
3396
|
+
declaredEffectsOccur,
|
|
3397
|
+
asyncReachesTerminalState,
|
|
3398
|
+
asyncReceiptIsResolvable,
|
|
3399
|
+
];
|
|
3400
|
+
//# sourceMappingURL=checks.js.map
|