|
| 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