|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * REST route-ledger conformance (#3587) — the guard that keeps the REST |
| 5 | + * server's route surface and `@objectstack/client` from drifting apart |
| 6 | + * silently, mirroring the dispatcher guard (#3569). |
| 7 | + * |
| 8 | + * Directions made loud here: |
| 9 | + * |
| 10 | + * 1. A route mounted by `@objectstack/rest` with no ledger entry — a new |
| 11 | + * route landed without a reviewed SDK disposition. |
| 12 | + * 2. A ledger entry for a route the server no longer mounts — the ledger |
| 13 | + * went stale. |
| 14 | + * |
| 15 | + * Enumeration is real on BOTH sources: `route-manager` rows against |
| 16 | + * `RestServer.getRoutes()` (the introspection seam RouteManager already |
| 17 | + * provides), and `direct-mount` rows against the registration calls the two |
| 18 | + * bypass registrars make on a mock `IHttpServer` — no pinned-by-hand list. |
| 19 | + * |
| 20 | + * The third direction — "every `sdk` row names a client method that exists" — |
| 21 | + * lives in `packages/client/src/rest-route-ledger-coverage.test.ts`, next to |
| 22 | + * the SDK it introspects, for the same build-cycle reason as tranche 1: a |
| 23 | + * rest→client edge is unbuildable (client's devDeps already reach back into |
| 24 | + * the server packages, and CI's per-package test tasks build only their own |
| 25 | + * dependency closure). |
| 26 | + */ |
| 27 | + |
| 28 | +import { describe, it, expect, vi } from 'vitest'; |
| 29 | +import { RestServer } from './rest-server'; |
| 30 | +import { registerPackageRoutes } from './package-routes'; |
| 31 | +import { registerExternalDatasourceRoutes } from './external-datasource-routes'; |
| 32 | +import { REST_ROUTE_LEDGER } from './rest-route-ledger'; |
| 33 | + |
| 34 | +/** Minimal IHttpServer mock that records registrations. */ |
| 35 | +function createMockServer() { |
| 36 | + return { |
| 37 | + get: vi.fn(), |
| 38 | + post: vi.fn(), |
| 39 | + put: vi.fn(), |
| 40 | + delete: vi.fn(), |
| 41 | + patch: vi.fn(), |
| 42 | + use: vi.fn(), |
| 43 | + listen: vi.fn().mockResolvedValue(undefined), |
| 44 | + close: vi.fn().mockResolvedValue(undefined), |
| 45 | + }; |
| 46 | +} |
| 47 | + |
| 48 | +/** |
| 49 | + * Protocol mock with the batch capabilities present so the four |
| 50 | + * protocol-capability-gated batch routes register (rest-server.ts). |
| 51 | + */ |
| 52 | +function createCapableProtocol() { |
| 53 | + return { |
| 54 | + getDiscovery: vi.fn().mockResolvedValue({}), |
| 55 | + getMetaTypes: vi.fn().mockResolvedValue([]), |
| 56 | + getMetaItems: vi.fn().mockResolvedValue([]), |
| 57 | + getMetaItem: vi.fn().mockResolvedValue({}), |
| 58 | + findData: vi.fn().mockResolvedValue([]), |
| 59 | + getData: vi.fn().mockResolvedValue({}), |
| 60 | + createData: vi.fn().mockResolvedValue({ id: '1' }), |
| 61 | + updateData: vi.fn().mockResolvedValue({}), |
| 62 | + deleteData: vi.fn().mockResolvedValue({ success: true }), |
| 63 | + batchData: vi.fn().mockResolvedValue({}), |
| 64 | + createManyData: vi.fn().mockResolvedValue([]), |
| 65 | + updateManyData: vi.fn().mockResolvedValue([]), |
| 66 | + deleteManyData: vi.fn().mockResolvedValue([]), |
| 67 | + }; |
| 68 | +} |
| 69 | + |
| 70 | +/** `VERB /path` keys for every route RouteManager holds at default config. */ |
| 71 | +function enumerateRouteManagerRoutes(): Set<string> { |
| 72 | + const rest = new RestServer(createMockServer() as any, createCapableProtocol() as any, {} as any); |
| 73 | + rest.registerRoutes(); |
| 74 | + return new Set(rest.getRoutes().map((r) => `${r.method.toUpperCase()} ${r.path}`)); |
| 75 | +} |
| 76 | + |
| 77 | +/** `VERB /path` keys captured from the two RouteManager-bypassing registrars. */ |
| 78 | +function enumerateDirectMountRoutes(): Set<string> { |
| 79 | + const server = createMockServer(); |
| 80 | + registerPackageRoutes(server as any, {} as any); |
| 81 | + registerExternalDatasourceRoutes(server as any, { getService: () => undefined } as any); |
| 82 | + const keys = new Set<string>(); |
| 83 | + for (const verb of ['get', 'post', 'put', 'patch', 'delete'] as const) { |
| 84 | + for (const call of (server[verb] as any).mock.calls) { |
| 85 | + keys.add(`${verb.toUpperCase()} ${call[0]}`); |
| 86 | + } |
| 87 | + } |
| 88 | + return keys; |
| 89 | +} |
| 90 | + |
| 91 | +function ledgerKeys(source: 'route-manager' | 'direct-mount'): Set<string> { |
| 92 | + return new Set(REST_ROUTE_LEDGER.filter((e) => e.source === source).map((e) => e.route)); |
| 93 | +} |
| 94 | + |
| 95 | +describe('REST route ledger ↔ RouteManager enumeration', () => { |
| 96 | + it('every RouteManager-registered route has a ledger entry', () => { |
| 97 | + const ledger = ledgerKeys('route-manager'); |
| 98 | + const missing = [...enumerateRouteManagerRoutes()].filter((k) => !ledger.has(k)); |
| 99 | + expect( |
| 100 | + missing, |
| 101 | + `REST routes with no rest-route-ledger entry: ${missing.join(', ')}. ` + |
| 102 | + 'A new route needs a reviewed disposition in rest-route-ledger.ts (#3587).', |
| 103 | + ).toEqual([]); |
| 104 | + }); |
| 105 | + |
| 106 | + it('every route-manager ledger entry is a live RouteManager route', () => { |
| 107 | + const live = enumerateRouteManagerRoutes(); |
| 108 | + const stale = [...ledgerKeys('route-manager')].filter((k) => !live.has(k)); |
| 109 | + expect( |
| 110 | + stale, |
| 111 | + `rest-route-ledger entries the server no longer mounts: ${stale.join(', ')}. ` + |
| 112 | + 'Remove or reclassify them so the ledger stays truthful.', |
| 113 | + ).toEqual([]); |
| 114 | + }); |
| 115 | +}); |
| 116 | + |
| 117 | +describe('REST route ledger ↔ direct-mount registrars', () => { |
| 118 | + it('every directly-mounted route has a ledger entry', () => { |
| 119 | + const ledger = ledgerKeys('direct-mount'); |
| 120 | + const missing = [...enumerateDirectMountRoutes()].filter((k) => !ledger.has(k)); |
| 121 | + expect( |
| 122 | + missing, |
| 123 | + `Directly-mounted routes with no rest-route-ledger entry: ${missing.join(', ')}.`, |
| 124 | + ).toEqual([]); |
| 125 | + }); |
| 126 | + |
| 127 | + it('every direct-mount ledger entry is really registered by its registrar', () => { |
| 128 | + const live = enumerateDirectMountRoutes(); |
| 129 | + const stale = [...ledgerKeys('direct-mount')].filter((k) => !live.has(k)); |
| 130 | + expect( |
| 131 | + stale, |
| 132 | + `direct-mount rest-route-ledger entries no registrar mounts: ${stale.join(', ')}.`, |
| 133 | + ).toEqual([]); |
| 134 | + }); |
| 135 | +}); |
| 136 | + |
| 137 | +describe('REST route ledger hygiene', () => { |
| 138 | + it('every `sdk` entry names its client method; every non-sdk entry carries a rationale', () => { |
| 139 | + const sdkWithout = REST_ROUTE_LEDGER.filter((e) => e.disposition === 'sdk' && !e.client).map((e) => e.route); |
| 140 | + expect(sdkWithout, 'sdk-disposition entries missing a client method name').toEqual([]); |
| 141 | + |
| 142 | + const bareNonSdk = REST_ROUTE_LEDGER.filter((e) => e.disposition !== 'sdk' && !e.note).map((e) => e.route); |
| 143 | + expect(bareNonSdk, 'non-sdk entries must say WHY they are not SDK surface').toEqual([]); |
| 144 | + }); |
| 145 | + |
| 146 | + it('gap count only shrinks — update the ledger (and this number) when closing gaps', () => { |
| 147 | + // Ratchet, not aspiration: 43 audited gaps at #3587 PR-1 (metadata 9, |
| 148 | + // data-actions 2, search 1, email 1, analytics 1, security-explain 2, |
| 149 | + // record-shares 3, sharing-rules 5, reports 8, approvals 6, |
| 150 | + // external-datasource 5). Closing a gap = reclassify to `sdk` AND lower |
| 151 | + // this bound. Raising it demands an explicit, reviewed decision. |
| 152 | + const gaps = REST_ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length; |
| 153 | + expect(gaps).toBeLessThanOrEqual(43); |
| 154 | + }); |
| 155 | +}); |
0 commit comments