Skip to content

Commit e75a904

Browse files
os-litantclaude
andauthored
test(rest): pin the MOUNT half of every RestServerConfig switch (#15851)
The suite pinned what a switch normalizes to (rest-sub-config-parse-not-cast §D) and the effect of batch.maxBatchSize (rest-batch-size-cap), but nothing pinned that a `false` switch removes its route from the mounted table. That left the declared-not-enforced direction green: a refactor that stops reading a switch at the registrar keeps the normalized config correct, keeps the cap working, and simply leaves the route mounted. Adds rest-config-mount-table.pin.test.ts: for each mount-gating switch, the set difference between the all-true baseline table and the switch-off table must be exactly a named set, in both directions. Re-measured the population rather than transcribing it: the card says nine switches, its own enumeration adds to twelve, and the real mount-gating count is nineteen once the seven api.enable* gates in registerRoutes are included. All nineteen are pinned. Anti-vacuity, since an absence assertion is exactly the shape that passes for free: every case carries its presence twin (switch on, route mounted), the case table asserts its own size and per-row non-emptiness, and the baseline is asserted non-empty and duplicate-free. The batch gates are conjunctions (switch AND protocol member), so the baseline protocol carries every member and a separate case pins the other conjunct with a CRUD positive control. Records the measured radius of two switches whose declared meaning is narrower than what they gate: metadata.endpoints.items takes a write door (#15542), and metadata.endpoints.item does NOT take the per-item writes or the history family. Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7b68254 commit e75a904

2 files changed

Lines changed: 307 additions & 1 deletion

File tree

docs/qa/platform-checklist/FOLLOW-UPS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -565,7 +565,7 @@ card filed against it*. Recorded, not acted on — the channel question is #1173
565565
|---|---|---|---|---|
566566
| E1 | **`metadata.endpoints.items` gates four routes, three of which its declared meaning does not cover** — its `describe()` says "GET /meta/:type — List items of type", and it also gates `GET {prefix}/diagnostics`, `GET {prefix}/_drafts` and the **`POST {prefix}/_migrate-stored` write door**. An operator switching off a listing read silently disarms a migration door and the cross-type spec-validation sweep. `endpoints.item` is milder but the same shape: it also takes `{prefix}/book/:name/tree`. | `packages/rest/src/rest-server.ts#registerMetadataEndpointsInner` (four `endpoints.items` gates, four `endpoints.item` gates) vs `packages/spec/src/api/rest-server.zod.ts#MetadataEndpointsConfigSchema` (one route named per switch) | api-backend.rest-metadata-config-contract (a clause requires the run to ENUMERATE each switch's real radius) | design/docs — filed as #15542 |
567567
| E2 | **No shipped boot path authors `RestServerConfig` at all.** `os serve` constructs the REST plugin with a fixed config (only `enableProjectScoping` / `projectResolution` are threaded) and the dev plugin calls `createRestApiPlugin()` with none, so `crud` / `metadata` / `batch` / `routes` are reachable only from embedder code (`createRestApiPlugin({ api })`, `createHonoServerPlugin({ restConfig })`). A deployment cannot set `batch.maxBatchSize`, move `crud.dataPrefix`, or opt out of ADR-0106 D8 masking without embedding. | `packages/cli/src/commands/serve.ts` (the fixed construction) · `packages/plugins/plugin-dev/src/dev-plugin.ts` (no config) | the three config items' `knownGaps` — every non-default clause is scored `oracle: test` in a harness, and the run record must say so instead of claiming a reconfigured deployment | capability gap — filed as #15543 |
568-
| E3 | **The MOUNT half of every sub-config switch is unpinned.** `packages/rest/src/rest-sub-config-parse-not-cast.test.ts` pins what a switch normalizes to, and `rest-batch-size-cap.test.ts` pins the cap's effect; nothing asserts that a `false` switch removes its route from the table `getRoutes()` returns. The declared-not-enforced direction — a switch that normalizes correctly and gates nothing — is exactly what no current test would catch. | the two test files above; the gates live in `registerCrudEndpoints` / `registerBatchEndpoints` / `registerMetadataEndpointsInner` | the three config items (the mount clauses, each with the gap named in `knownGaps`) | test gap — filed as #15544 |
568+
| E3 | **The MOUNT half of every sub-config switch is unpinned.** `packages/rest/src/rest-sub-config-parse-not-cast.test.ts` pins what a switch normalizes to, and `rest-batch-size-cap.test.ts` pins the cap's effect; nothing asserts that a `false` switch removes its route from the table `getRoutes()` returns. The declared-not-enforced direction — a switch that normalizes correctly and gates nothing — is exactly what no current test would catch. ⚠️ The card said **nine** switches; re-measured on `cc5b3dd0c27` the mount-gating population is **nineteen** — the twelve sub-config switches the card enumerates (its own list adds to twelve, not nine) plus the seven `api.enable*` gates in `registerRoutes`, which are the same seam and were equally unpinned. | the two test files above; the gates live in `registerCrudEndpoints` / `registerBatchEndpoints` / `registerMetadataEndpointsInner` / `registerRoutes` | the three config items (the mount clauses, each with the gap named in `knownGaps`) | test gap — filed as #15544, **closed by `packages/rest/src/rest-config-mount-table.pin.test.ts`**: all nineteen gates pinned as a set difference against the all-true baseline, each with its presence twin. ⚠️ The three config items' `knownGaps` still say the harness is the only observation — stale in the good direction, refresh pending (`areas/api-backend.json` was held by another branch when this landed). |
569569

570570
### 10c. Checked and CLEAN (so the next sweep does not re-derive)
571571

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#15544] The MOUNT half of every `RestServerConfig` switch.
5+
*
6+
* The suite already pins what a switch NORMALIZES to
7+
* (`rest-sub-config-parse-not-cast.test.ts` §D) and the EFFECT of
8+
* `batch.maxBatchSize` (`rest-batch-size-cap.test.ts`). Nothing pinned the
9+
* direction in between: **that a `false` switch removes its route from the
10+
* mounted table.**
11+
*
12+
* That is the dangerous direction. A refactor that stops reading a switch at
13+
* the registrar — or reads the wrong one — leaves every existing test green:
14+
* the normalized config is still correct, the cap still works, and the route
15+
* is simply still mounted. The operator sets config that nothing honours,
16+
* which is the declared-not-enforced state ADR-0049 exists to catch, inside
17+
* the test suite meant to catch it.
18+
*
19+
* ⛔ This file pins CURRENT mount behaviour. It is not a judgement that each
20+
* switch's radius is the right one — where a radius disagrees with the
21+
* switch's own `describe()` that is a defect filed elsewhere (#15542 for
22+
* `metadata.endpoints.items`), and the table below records the radius as
23+
* MEASURED so such a defect is visible here rather than hidden.
24+
*
25+
* ## What is pinned, and why it is a diff rather than an existence check
26+
*
27+
* Every case asserts the SET DIFFERENCE between the all-true baseline table
28+
* and the switch-off table, in both directions. Two shapes make a per-route
29+
* existence check insufficient:
30+
*
31+
* - `crud.operations.list` gates TWO mounts — `GET {dataPrefix}/:object` and
32+
* `POST {dataPrefix}/:object/query`. The query door has no switch of its
33+
* own, so a pin asserting "the list route disappeared" passes while half
34+
* the intent is broken.
35+
* - `metadata.endpoints.items` gates FOUR, one of them the write door
36+
* `POST {prefix}/_migrate-stored`, while its `describe()` names one read.
37+
*
38+
* Asserting the difference is EXACTLY a named set catches a gate that grows a
39+
* route as loudly as one that loses a route.
40+
*
41+
* ## ⛔ ANTI-VACUITY — this pin is the exact shape that passes for free
42+
*
43+
* An assertion that a route is ABSENT is green when the server failed to
44+
* build, when the route name is misspelled, when `getRoutes()` returns empty,
45+
* and when a table-driven suite iterates zero cases (#15410 measured 20 of 178
46+
* self-tests failing on zero cases; this is not the 179th). So:
47+
*
48+
* 1. Every absence has its PRESENCE TWIN in the same case — with the switch
49+
* on, each route it gates must be in the baseline. A misspelled path
50+
* fails there before the absence is ever consulted.
51+
* 2. `§0` asserts the case table is exhaustive at its measured size, the
52+
* baseline is non-empty and free of duplicates, and every route the table
53+
* names is really in the baseline.
54+
*
55+
* ## ⚠️ The batch gates are conjunctions, so a mount can be absent for TWO reasons
56+
*
57+
* The four bulk mounts read `switch AND protocol member`
58+
* (`operations.createMany && this.protocol.createManyData`). A pin that only
59+
* checks absence goes green against a protocol that merely lacks the member —
60+
* measuring the wrong conjunct. So the baseline protocol here carries EVERY
61+
* member, and §2 pins the other conjunct separately: with every switch true
62+
* and the members gone, exactly those four mounts drop and CRUD is untouched.
63+
*
64+
* ## Route COUNT is deliberately a floor, not an equality
65+
*
66+
* An exact `toBe(85)` on the baseline would redden on every unrelated PR that
67+
* adds a REST route — a maintenance tax on other lanes for no extra safety
68+
* here. The anti-vacuity job is done strictly better by requiring every route
69+
* the table names to be present (an equality over the surface this file is
70+
* about) plus a floor that no broken-harness table could clear.
71+
*
72+
* Measured on `origin/main` `cc5b3dd0c27` — baseline 85 routes.
73+
*/
74+
75+
import { describe, it, expect, vi } from 'vitest';
76+
import { RestServer } from './rest-server';
77+
78+
function createMockServer() {
79+
return {
80+
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(),
81+
listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
82+
};
83+
}
84+
85+
/**
86+
* A protocol carrying EVERY optional member a mount conjunct reads, so the
87+
* baseline measures the SWITCH and never the member. Dropping a member here
88+
* silently converts the batch cases into tautologies — see §2, which is the
89+
* guard that would catch it.
90+
*/
91+
function protocolWithEveryMember(): any {
92+
return {
93+
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }),
94+
getMetaTypes: vi.fn().mockResolvedValue([]),
95+
getMetaItems: vi.fn().mockResolvedValue([]),
96+
getMetaItem: vi.fn().mockResolvedValue({}),
97+
findData: vi.fn().mockResolvedValue([]),
98+
createManyData: vi.fn(),
99+
updateManyData: vi.fn(),
100+
deleteManyData: vi.fn(),
101+
batchData: vi.fn(),
102+
};
103+
}
104+
105+
/** Every switch this file is about, spelled TRUE rather than left to defaults. */
106+
const ALL_TRUE = {
107+
api: {
108+
requireAuth: false,
109+
enableCrud: true,
110+
enableBatch: true,
111+
enableMetadata: true,
112+
enableDiscovery: true,
113+
enableOpenApi: true,
114+
enableUi: true,
115+
enableSearch: true,
116+
},
117+
crud: { operations: { create: true, read: true, update: true, delete: true, list: true } },
118+
batch: { enableBatchEndpoint: true, operations: { createMany: true, updateMany: true, deleteMany: true } },
119+
metadata: { endpoints: { types: true, items: true, item: true } },
120+
};
121+
122+
/** Deep-merge just enough to flip one leaf switch off inside ALL_TRUE. */
123+
function withSwitchOff(path: string): any {
124+
const cfg = JSON.parse(JSON.stringify(ALL_TRUE));
125+
const parts = path.split('.');
126+
let node = cfg;
127+
for (const key of parts.slice(0, -1)) node = node[key];
128+
node[parts[parts.length - 1]] = false;
129+
return cfg;
130+
}
131+
132+
function mountedRoutes(config: any, protocol: any = protocolWithEveryMember()): string[] {
133+
const rest = new RestServer(createMockServer() as any, protocol, config as any);
134+
rest.registerRoutes();
135+
return rest.getRoutes().map((r: any) => `${r.method} ${r.path}`).sort();
136+
}
137+
138+
const BASELINE = mountedRoutes(ALL_TRUE);
139+
140+
const DATA = '/api/v1/data/:object';
141+
const META = '/api/v1/meta';
142+
143+
/**
144+
* switch → the routes it gates, MEASURED (baseline minus switch-off), not
145+
* transcribed from the schema's `describe()`. Where the two disagree the
146+
* disagreement is a defect about the describe(), and this table is the side
147+
* that was measured.
148+
*/
149+
const CASES: Array<{ path: string; removes: string[] }> = [
150+
// --- crud.operations.* -------------------------------------------------
151+
{ path: 'crud.operations.create', removes: [`POST ${DATA}`] },
152+
{ path: 'crud.operations.read', removes: [`GET ${DATA}/:id`] },
153+
{ path: 'crud.operations.update', removes: [`PATCH ${DATA}/:id`] },
154+
{ path: 'crud.operations.delete', removes: [`DELETE ${DATA}/:id`] },
155+
// TWO mounts — the query door has no switch of its own.
156+
{ path: 'crud.operations.list', removes: [`GET ${DATA}`, `POST ${DATA}/query`] },
157+
158+
// --- batch.* -----------------------------------------------------------
159+
// Only the PER-OBJECT door. The cross-object `POST /api/v1/batch` survives
160+
// this switch and answers to `api.enableBatch` instead, which is what the
161+
// switch's own describe() says ('Enable POST /data/:object/batch').
162+
{ path: 'batch.enableBatchEndpoint', removes: [`POST ${DATA}/batch`] },
163+
{ path: 'batch.operations.createMany', removes: [`POST ${DATA}/createMany`] },
164+
{ path: 'batch.operations.updateMany', removes: [`POST ${DATA}/updateMany`] },
165+
{ path: 'batch.operations.deleteMany', removes: [`POST ${DATA}/deleteMany`] },
166+
167+
// --- metadata.endpoints.* ----------------------------------------------
168+
// Two spellings, one handler.
169+
{ path: 'metadata.endpoints.types', removes: [`GET ${META}`, `GET ${META}/types`] },
170+
// ⚠️ FOUR routes, and one of them is a WRITE door (#15542): the declared
171+
// meaning is "GET /meta/:type - List items of type", but switching it off
172+
// also disarms `_migrate-stored`, `_drafts` and `diagnostics`.
173+
{
174+
path: 'metadata.endpoints.items',
175+
removes: [`GET ${META}/:type`, `GET ${META}/_drafts`, `GET ${META}/diagnostics`, `POST ${META}/_migrate-stored`],
176+
},
177+
// ⚠️ FOUR routes, and NOT the ones a reader would guess: the per-item
178+
// WRITES (`PUT`/`DELETE {prefix}/:type/:name`) and the history family
179+
// (`history`, `audit`, `diff`, `published`, `publish`, `rollback`) are NOT
180+
// gated by it — they answer to `api.enableMetadata` alone.
181+
{
182+
path: 'metadata.endpoints.item',
183+
removes: [
184+
`GET ${META}/:type/:name`,
185+
`GET ${META}/:type/:name/layers`,
186+
`GET ${META}/:type/:name/references`,
187+
`GET ${META}/book/:name/tree`,
188+
],
189+
},
190+
191+
// --- api.* — the family the card's title reaches, same registrar seam ---
192+
{
193+
path: 'api.enableCrud',
194+
removes: [
195+
`DELETE ${DATA}/:id`, `GET ${DATA}`, `GET ${DATA}/:id`,
196+
`PATCH ${DATA}/:id`, `POST ${DATA}`, `POST ${DATA}/query`,
197+
],
198+
},
199+
{
200+
path: 'api.enableBatch',
201+
removes: [
202+
'POST /api/v1/batch',
203+
`POST ${DATA}/batch`, `POST ${DATA}/createMany`,
204+
`POST ${DATA}/deleteMany`, `POST ${DATA}/updateMany`,
205+
],
206+
},
207+
{
208+
path: 'api.enableMetadata',
209+
removes: [
210+
`DELETE ${META}/:type/:name`,
211+
`GET ${META}`,
212+
`GET ${META}/:type`,
213+
`GET ${META}/:type/:name`,
214+
`GET ${META}/:type/:name/audit`,
215+
`GET ${META}/:type/:name/diff`,
216+
`GET ${META}/:type/:name/history`,
217+
`GET ${META}/:type/:name/layers`,
218+
`GET ${META}/:type/:name/published`,
219+
`GET ${META}/:type/:name/references`,
220+
`GET ${META}/_drafts`,
221+
`GET ${META}/book/:name/tree`,
222+
`GET ${META}/diagnostics`,
223+
`GET ${META}/object/:name/state/:field`,
224+
`GET ${META}/types`,
225+
`POST ${META}/:type/:name/publish`,
226+
`POST ${META}/:type/:name/rollback`,
227+
`POST ${META}/_migrate-stored`,
228+
`PUT ${META}/:type/:name`,
229+
],
230+
},
231+
{ path: 'api.enableDiscovery', removes: ['GET /api/v1', 'GET /api/v1/discovery'] },
232+
{ path: 'api.enableOpenApi', removes: ['GET /api/v1/docs', 'GET /api/v1/openapi.json'] },
233+
{ path: 'api.enableUi', removes: ['GET /api/v1/ui/view/:object/:type'] },
234+
{ path: 'api.enableSearch', removes: ['GET /api/v1/search'] },
235+
];
236+
237+
describe('[#15544] §0 the harness measures something', () => {
238+
it('the case table is exhaustive at its measured size', () => {
239+
// ⛔ A table-driven pin that silently iterates zero cases is the
240+
// failure this number exists to prevent. Nineteen mount-gating
241+
// switches were measured on `origin/main` `cc5b3dd0c27`. A switch
242+
// retired or added moves this number DELIBERATELY, with its row.
243+
expect(CASES.length).toBe(19);
244+
expect(new Set(CASES.map((c) => c.path)).size).toBe(CASES.length);
245+
expect(CASES.every((c) => c.removes.length > 0)).toBe(true);
246+
});
247+
248+
it('the all-true baseline is a real, duplicate-free route table', () => {
249+
expect(BASELINE.length).toBeGreaterThanOrEqual(60);
250+
expect(new Set(BASELINE).size).toBe(BASELINE.length);
251+
});
252+
253+
it('every route the table claims to gate is really mounted when all switches are on', () => {
254+
// The collective presence twin: a misspelled path in ANY row fails
255+
// here, so no row can reach its absence assertion by misspelling.
256+
const gated = [...new Set(CASES.flatMap((c) => c.removes))].sort();
257+
expect(gated.length).toBeGreaterThan(0);
258+
expect(gated.filter((r) => !BASELINE.includes(r))).toEqual([]);
259+
});
260+
});
261+
262+
describe('[#15544] §1 a false switch removes exactly its routes from the mounted table', () => {
263+
for (const { path, removes } of CASES) {
264+
it(`${path}: on → mounted, off → exactly ${removes.length} route(s) gone`, () => {
265+
// Presence twin FIRST — an absence assertion alone is
266+
// indistinguishable from a broken harness.
267+
const expectedGone = [...removes].sort();
268+
expect(expectedGone.filter((r) => !BASELINE.includes(r))).toEqual([]);
269+
270+
const off = mountedRoutes(withSwitchOff(path));
271+
272+
// The set difference, both directions: a gate that GROWS a route
273+
// is as much a defect as one that loses a route.
274+
expect(BASELINE.filter((r) => !off.includes(r))).toEqual(expectedGone);
275+
expect(off.filter((r) => !BASELINE.includes(r))).toEqual([]);
276+
});
277+
}
278+
});
279+
280+
describe('[#15544] §2 the batch mounts are conjunctions — the OTHER conjunct', () => {
281+
it('the four bulk mounts drop when the protocol lacks the member, with every switch still true', () => {
282+
// Without this case, §1's four batch rows would still pass against a
283+
// protocol that merely lacks `createManyData` — measuring the member
284+
// rather than the switch, which is the defect one layer over.
285+
const bare = protocolWithEveryMember();
286+
delete bare.createManyData;
287+
delete bare.updateManyData;
288+
delete bare.deleteManyData;
289+
delete bare.batchData;
290+
291+
const routes = mountedRoutes(ALL_TRUE, bare);
292+
293+
expect(BASELINE.filter((r) => !routes.includes(r))).toEqual([
294+
`POST ${DATA}/batch`,
295+
`POST ${DATA}/createMany`,
296+
`POST ${DATA}/deleteMany`,
297+
`POST ${DATA}/updateMany`,
298+
]);
299+
300+
// Positive control: the CRUD doors, which read no protocol member,
301+
// are untouched — so the four above dropped for the member and not
302+
// because the server failed to register anything at all.
303+
expect(routes).toContain(`GET ${DATA}/:id`);
304+
expect(routes).toContain(`POST ${DATA}`);
305+
});
306+
});

0 commit comments

Comments
 (0)