-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathclient-url-conformance.test.ts
More file actions
443 lines (404 loc) · 20.8 KB
/
Copy pathclient-url-conformance.test.ts
File metadata and controls
443 lines (404 loc) · 20.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
/**
* Client-URL conformance — the capstone guard (#3642).
*
* WHAT THE SERVER-SIDE GUARDS DO NOT ASK. The dispatcher (#3563), REST (#3587)
* and service-mount (#3636) ledgers all run server → client: enumerate what a
* surface mounts, demand a reviewed disposition, and for `sdk` rows demand the
* named client method exists. *The method exists* is not *the method can be
* called.* No guard compared the URL the client BUILDS against the patterns
* any server MOUNTS, so a client method could name a real function, carry a
* green ledger row, and still 404 everywhere.
*
* That gap shipped four times, found one at a time by hand:
* - `analytics.explain` called `/explain`; nothing served it (#3584)
* - `analytics.meta` called `/meta/:cube`; no server mounted it (#3584)
* - `meta.getView` sent `?type=`; REST mounts `/ui/view/:object/:type` (#3611)
* - `i18n.getTranslations` / `getFieldLabels` sent `?locale=` against
* path-param-only mounts (#3636) — both had carried a green `sdk` row
* since tranche 1.
*
* This suite closes the direction. It drives every method on a real client
* with a recording `fetch`, then matches each captured URL against the UNION
* of all five ledgers — dispatcher, REST, storage, i18n, auth. A union, not an
* intersection: a route mounted by only one surface is still legitimately
* reachable.
*
* WHY A REAL DRIVE, NOT A DECLARATION. Asserting "method X targets route Y" in
* a table would be an assertion *about* the code that the code can drift away
* from — the same failure the audit keeps finding. Running the method and
* catching what it actually puts on the wire cannot drift.
*
* ANTI-ROT. The sweep's own completeness is the part that must not be skipped,
* so it is itself asserted:
* 1. Every method reachable on the client is either driven or carries an
* explicit skip reason (`NON_HTTP`) — a new method is a failure until
* someone classifies it.
* 2. A driven method that emits ZERO requests fails. That is how a sweep
* silently rots: the placeholder args stop satisfying the method, it
* throws before fetching, and the guard keeps passing while covering
* nothing.
* 3. A URL containing `undefined`/`[object Object]`/`NaN` fails, so a
* placeholder that is accepted but wrong cannot masquerade as coverage.
*/
import { describe, it, expect } from 'vitest';
import { ObjectStackClient } from './index';
import { ROUTE_LEDGER } from '../../runtime/src/route-ledger';
import { REST_ROUTE_LEDGER } from '../../rest/src/rest-route-ledger';
import { STORAGE_ROUTE_LEDGER } from '../../services/service-storage/src/storage-route-ledger';
import { I18N_ROUTE_LEDGER } from '../../services/service-i18n/src/i18n-route-ledger';
import { AUTH_ROUTE_LEDGER } from '../../plugins/plugin-auth/src/auth-route-ledger';
const BASE = 'http://localhost:9';
// ---------------------------------------------------------------------------
// 1. The ledger union → matchable patterns
// ---------------------------------------------------------------------------
interface Pattern { verb: string; source: string; route: string; re: RegExp }
/**
* `(unmatched)` is the `__api-endpoint` catch-all: metadata-declared custom
* endpoints, whose route set exists only at runtime. Treating it as a pattern
* would match every URL and make this whole suite vacuous, so it is excluded
* — the one ledger row this guard deliberately cannot use.
*/
const UNUSABLE_ROWS = new Set(['* (unmatched)']);
function compile(route: string, prefix: string, source: string): Pattern[] {
const sp = route.indexOf(' ');
const verb = route.slice(0, sp);
const path = route.slice(sp + 1);
const body = (prefix + path)
.split('/')
.map((seg) => {
if (seg === '**') return '.*';
if (seg.startsWith(':') && seg.endsWith('?')) return null; // optional — handled below
if (seg.startsWith(':')) return '[^/]+';
return seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
});
// A trailing `:param?` makes the whole segment (with its slash) optional.
const optionalTail = body[body.length - 1] === null;
const head = (optionalTail ? body.slice(0, -1) : body).join('/');
const src = `^${head}${optionalTail ? '(?:/[^/]+)?' : ''}$`;
const pats: Pattern[] = [{ verb, source, route, re: new RegExp(src) }];
// Every surface additionally mirrors its routes under the environment scope
// when project scoping is on (rest-server registerRoutes, the dispatcher's
// scoped automation mounts) — that mirror is a mechanical duplication and is
// deliberately not re-ledgered, so it is generated here instead.
if (src.startsWith('^/api/v1')) {
pats.push({
verb,
source: `${source} (env-scoped mirror)`,
route: route.replace('/api/v1', '/api/v1/environments/:environmentId'),
re: new RegExp(src.replace('^/api/v1', '^/api/v1/environments/[^/]+')),
});
}
return pats;
}
const PATTERNS: Pattern[] = [
...ROUTE_LEDGER.map((r) => r.route).filter((r) => !UNUSABLE_ROWS.has(r)).flatMap((r) => compile(r, '/api/v1', 'dispatcher')),
...REST_ROUTE_LEDGER.map((r) => r.route).flatMap((r) => compile(r, '', 'rest')),
...STORAGE_ROUTE_LEDGER.map((r) => r.route).flatMap((r) => compile(r, '', 'storage')),
...I18N_ROUTE_LEDGER.map((r) => r.route).flatMap((r) => compile(r, '', 'i18n')),
...AUTH_ROUTE_LEDGER.map((r) => r.route).flatMap((r) => compile(r, '', 'auth')),
]
// Exact rows before wildcard families, so a URL that a real route covers is
// never CREDITED to a `**` prefix claim that happens to sit earlier in the
// list. Without this, adding the auth ledger would change nothing: every
// `/api/v1/auth/*` URL would still be absorbed by the dispatcher's
// `* /auth/**` row and keep counting as weak evidence.
.sort((a, b) => Number(a.route.includes('**')) - Number(b.route.includes('**')));
function matches(verb: string, path: string): Pattern | undefined {
return PATTERNS.find((p) => (p.verb === '*' || p.verb === verb) && p.re.test(path));
}
/**
* The control plane. `/api/v1/cloud/*` is served by the sibling `cloud` repo,
* not by anything in this one — this repo's dispatcher explicitly REFUSES those
* paths (`http-dispatcher.ts`: "Guard against matching control-plane routes
* like /cloud/environments"). No in-repo ledger can vouch for them, so they are
* exempt here.
*
* NO LONGER UNGUARDED — guarded on the OTHER side of the boundary (#3655).
* `cloud`'s `packages/service-cloud/src/cloud-route-ledger.ts` gives all 90
* routes its artifact API plugin mounts a reviewed disposition, and
* `projects-namespace-coverage.test.ts` there drives this very SDK with a
* recording `fetch` and matches every `projects.*` URL against it. That had to
* live in `cloud`: it depends on this repo, never the reverse, so the cloud
* repo is the only place the mounted route set and the SDK are both in scope.
* The exemption below stays because THIS suite still cannot see those routes —
* it is a statement about where the coverage lives, not that there is none.
*
* That guard immediately found `projects.listTemplates` building
* `/api/v1/cloud/templates`, which no registrar in either repo mounts — the
* sixth instance of the class above, and the first one only a cross-repo guard
* could see. The method has since been deleted (#3702): the route was never
* mounted anywhere, so there was nothing to reconcile it against.
*
* Exempt by PREFIX, and bounded from both ends: the assertions below pin which
* methods are allowed to use it, so the hole cannot quietly widen into a place
* to park an unmatched URL.
*/
const CONTROL_PLANE = '/api/v1/cloud/';
const CONTROL_PLANE_NAMESPACE = 'projects.';
/**
* The AI surface — the SECOND cross-repo prefix, and exempt for the same
* reason as the control plane rather than a different one.
*
* `/api/v1/ai/*` routes are built by `service-ai`'s `buildAIRoutes()` at plugin
* start, and `service-ai` is a Cloud/EE package living in the `cloud` repo.
* This repo's dispatcher only proxies to whatever that table contains (or 404s
* "AI service is not configured" when it is absent), so no ledger here can
* enumerate them — the same boundary `projects.*` sits behind.
*
* It used to be handled as a `* /ai/**` WILDCARD match instead, which was
* strictly worse: a wildcard says the family is claimed, so all three `ai.*`
* methods counted as matched. #3718 enumerated the real table in `cloud` and
* found the SDK's three URLs are not in it — `/nlq`, `/suggest` and
* `/insights` are mounted by nothing, in any repo (#3718). The wildcard was
* not weak evidence, it was wrong evidence, which is exactly why the ratchet
* below treats `**` matches as something to drive to zero rather than tolerate.
*
* Bounded the same way as the control plane: only `ai.*` may use the prefix.
*/
const AI_PLANE = '/api/v1/ai/';
const AI_NAMESPACE = 'ai.';
// ---------------------------------------------------------------------------
// 2. The recorder
// ---------------------------------------------------------------------------
interface Recorded { verb: string; url: string }
/**
* Response permissive enough that a method reaches its LAST request rather
* than throwing at its first. Methods needing a sharper body carry an
* `expect`-shaped override in DRIVE below.
*/
function makeResponse(body: unknown): Response {
return {
ok: true,
status: 200,
statusText: 'OK',
headers: new Headers({ 'content-type': 'application/json' }),
json: async () => body,
text: async () => JSON.stringify(body),
blob: async () => new Blob([]),
arrayBuffer: async () => new ArrayBuffer(0),
clone() { return this as Response; },
} as unknown as Response;
}
function createRecordingClient(body: unknown) {
const calls: Recorded[] = [];
const client = new ObjectStackClient({
baseUrl: BASE,
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ verb: (init?.method ?? 'GET').toUpperCase(), url: String(input) });
return makeResponse(body);
},
});
return { client, calls };
}
// ---------------------------------------------------------------------------
// 3. The surface sweep
// ---------------------------------------------------------------------------
/** Dotted paths of every callable reachable on a client instance. */
function enumerateMethods(client: object): string[] {
const found: string[] = [];
const walk = (obj: Record<string, unknown>, prefix: string, depth: number) => {
if (depth > 3) return;
for (const key of Object.keys(obj)) {
if (key.startsWith('_')) continue;
let value: unknown;
try { value = obj[key]; } catch { continue; }
const path = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'function') found.push(path);
else if (value && typeof value === 'object' && !Array.isArray(value)) {
walk(value as Record<string, unknown>, path, depth + 1);
}
}
};
walk(client as Record<string, unknown>, '', 0);
for (const key of Object.getOwnPropertyNames(Object.getPrototypeOf(client))) {
if (key === 'constructor' || key.startsWith('_')) continue;
const d = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(client), key);
if (d && typeof d.value === 'function') found.push(key);
}
return found.sort();
}
/**
* Methods that legitimately put nothing on the wire. Every entry is a REASON,
* not a mute: a method parked here is claiming it makes no HTTP request, and
* assertion 2 below is what stops that claim from being used to hide a broken
* call — a `NON_HTTP` method that DOES fetch is itself a failure.
*/
const NON_HTTP: Record<string, string> = {
'fetch': 'the transport itself',
'getRoute': 'pure route-table lookup',
'unwrapResponse': 'pure envelope unwrap',
'isFilterAST': 'pure type predicate',
'project': 'constructs a ScopedProjectClient; its methods are swept separately',
'setProjectId': 'local state',
'getProjectId': 'local state',
'setLocale': 'local state',
'getLocale': 'local state',
'fetchImpl': 'the injected transport',
};
/**
* Overrides for methods a bare placeholder cannot drive: those building the
* URL out of a FIELD of an object argument, and the composite flows whose
* later requests are addressed by the earlier responses.
*
* Kept deliberately small. Every entry is a place the generic driver stopped
* working, and the malformed/silent assertions below are what force one to be
* written rather than letting the method fall out of coverage.
*/
interface Drive { args?: unknown[]; body?: unknown; browser?: boolean }
const DRIVE: Record<string, Drive> = {
'auth.verifyEmail': { args: [{ token: 'tok' }] },
// Guards on `window` and throws in node BEFORE fetching. It is a real HTTP
// method, so it gets a browser stub rather than a NON_HTTP exemption —
// parking it there would drop a live call out of coverage, which is the
// exact rot this suite exists to prevent.
'auth.signInWithProvider': { args: ['github'], browser: true },
'storage.completeChunkedUpload': { args: [{ uploadId: 'up1', parts: [] }] },
// The composite upload: presign → PUT the returned uploadUrl → commit. Hop 2
// is addressed by hop 1's response, so the body has to carry a real target.
// Pointed at the local driver's loopback (a ledgered route) rather than an
// S3 URL, so all three hops stay in scope for the match below.
'storage.upload': {
args: [{ name: 'a.png', type: 'image/png', size: 1 }, 'user'],
body: {
success: true,
data: { uploadUrl: `${BASE}/api/v1/storage/_local/raw/tok`, method: 'PUT', headers: {}, fileId: 'f1', expiresIn: 60 },
},
},
// Resume reads progress, re-PUTs the outstanding chunks, then completes.
'storage.resumeUpload': {
args: ['up1', new ArrayBuffer(8), 8, 'rtok'],
body: { success: true, data: { totalChunks: 1, uploadedChunks: 0, eTag: 'e1' } },
},
};
/** Placeholder positional args — enough for the id-in-path majority. */
function autoArgs(fn: (...a: unknown[]) => unknown): unknown[] {
return Array.from({ length: fn.length }, (_, i) => `p${i + 1}`);
}
/**
* Resolve a dotted path to its function AND its owner. Namespace methods are
* arrow functions that closed over `this`, but the top-level ones live on the
* prototype and lose `this` when plucked off by name — calling those unbound
* makes them throw before fetching, which would quietly read as "emits no
* request" and drop them from the sweep.
*/
function resolve(client: object, path: string): { fn: (...a: unknown[]) => unknown; owner: object } {
const keys = path.split('.');
let owner: object = client;
for (const k of keys.slice(0, -1)) owner = (owner as Record<string, object>)[k];
return { fn: (owner as Record<string, (...a: unknown[]) => unknown>)[keys[keys.length - 1]], owner };
}
/** Run `body` with a minimal `window` in place, for browser-guarded methods. */
async function withBrowser<T>(enabled: boolean, body: () => Promise<T>): Promise<T> {
if (!enabled) return body();
const g = globalThis as Record<string, unknown>;
const had = 'window' in g;
const prev = g.window;
g.window = { location: { href: `${BASE}/app`, origin: BASE } };
try { return await body(); } finally { if (had) g.window = prev; else delete g.window; }
}
// ---------------------------------------------------------------------------
describe('client URL conformance ↔ the union of all four route ledgers (#3642)', () => {
const probe = createRecordingClient({ success: true, data: {} });
const METHODS = enumerateMethods(probe.client).filter((m) => !(m in NON_HTTP));
it('the ledger union compiles to a usable pattern set', () => {
expect(PATTERNS.length).toBeGreaterThan(100);
// Guard the guard: if `(unmatched)` ever slipped in, every URL would match
// and this suite would pass while asserting nothing.
expect(PATTERNS.some((p) => p.route.includes('(unmatched)'))).toBe(false);
});
it('every client method is classified — driven or explicitly non-HTTP', () => {
const all = enumerateMethods(probe.client);
const unclassified = all.filter((m) => !(m in NON_HTTP) && !METHODS.includes(m));
expect(
unclassified,
`client methods neither driven nor declared NON_HTTP: ${unclassified.join(', ')}`,
).toEqual([]);
expect(METHODS.length, 'the sweep should cover the whole SDK surface').toBeGreaterThan(150);
});
it('every URL the SDK builds matches a route some surface mounts', async () => {
const unmatched: string[] = [];
const silent: string[] = [];
const malformed: string[] = [];
const controlPlane: string[] = [];
const aiPlane: string[] = [];
const wildcardOnly: string[] = [];
for (const name of METHODS) {
const drive = DRIVE[name] ?? {};
const { client, calls } = createRecordingClient(drive.body ?? { success: true, data: {} });
const { fn, owner } = resolve(client, name);
const args = drive.args ?? autoArgs(fn);
try {
await withBrowser(drive.browser === true, async () => fn.apply(owner, args));
} catch {
// A throw after the request still counts — the URL is already recorded.
}
if (calls.length === 0) { silent.push(name); continue; }
for (const call of calls) {
if (/undefined|\[object Object\]|NaN/.test(call.url)) {
malformed.push(`${name} → ${call.url}`);
continue;
}
const path = new URL(call.url, BASE).pathname;
if (path.startsWith(CONTROL_PLANE)) { controlPlane.push(`${name} → ${call.verb} ${path}`); continue; }
if (path.startsWith(AI_PLANE)) { aiPlane.push(`${name} → ${call.verb} ${path}`); continue; }
const hit = matches(call.verb, path);
if (!hit) { unmatched.push(`${name} → ${call.verb} ${path}`); continue; }
if (hit.route.includes('**')) wildcardOnly.push(`${name} → ${call.verb} ${path} (via ${hit.route})`);
}
}
expect(
malformed,
'placeholder args produced a malformed URL — give these an ARGS override so the sweep really covers them:\n' +
malformed.join('\n'),
).toEqual([]);
expect(
silent,
'these methods emitted NO request, so the sweep does not cover them — fix the args ' +
'via ARGS or declare them NON_HTTP with a reason:\n' + silent.join('\n'),
).toEqual([]);
expect(
unmatched,
'SDK methods whose URL matches no route on ANY surface — these are wire-level 404s ' +
'of the #3584 / #3611 / #3636 class:\n' + unmatched.join('\n'),
).toEqual([]);
// The control-plane hole, bounded from the other end: only `projects.*` may
// use it. Anything else reaching /api/v1/cloud/ is a method that has wandered
// off the data plane, and must not inherit this exemption.
const trespassers = controlPlane.filter((e) => !e.startsWith(CONTROL_PLANE_NAMESPACE));
expect(
trespassers,
`non-projects methods targeting the control plane, which no in-repo ledger can vouch for:\n${trespassers.join('\n')}`,
).toEqual([]);
expect(controlPlane.length, 'the projects namespace should still be reaching the control plane').toBeGreaterThan(0);
// Same bounding for the AI prefix. `ai.*` is the ONLY namespace allowed to
// use it; anything else reaching /api/v1/ai/ is a method that has wandered
// into a surface no in-repo ledger can vouch for.
const aiTrespassers = aiPlane.filter((e) => !e.startsWith(AI_NAMESPACE));
expect(
aiTrespassers,
`non-ai methods targeting the AI plane, which service-ai owns in the cloud repo:\n${aiTrespassers.join('\n')}`,
).toEqual([]);
expect(aiPlane.length, 'the ai namespace should still be reaching the AI plane').toBeGreaterThan(0);
// HOW STRONG IS THIS GUARD, HONESTLY. A `**` row asserts only that a prefix
// family is CLAIMED, not that the specific URL resolves. That was this
// guard's biggest weakness at #3642: 60 of ~196 matched calls rested on
// nothing better, 54 of them on `* /auth/**`. #3656 enumerated better-auth's
// real route table, so those now match exact rows and the bound fell 60 → 3.
// The last 3 were `ai.nlq/suggest/insights` on `* /ai/**`, and enumerating
// THAT family (#3718, in `cloud`, where service-ai lives) showed the
// wildcard had not been weak evidence but WRONG evidence: none of the three
// URLs is in the real table, and nothing in any repo mounts them (#3718).
// They are now handled by the AI_PLANE exemption above and pinned as dead
// on the cloud side, so this bound is 0.
//
// ZERO IS THE POINT: every remaining matched call rests on an exact route
// some ledger enumerated. Raising this bound reintroduces the one kind of
// evidence this audit family has caught being wrong.
expect(
wildcardOnly.length,
'methods matched only by a wildcard `**` family — weaker evidence than an exact ' +
`route, and demonstrably able to be wrong (#3718). Enumerate the family instead:\n${wildcardOnly.join('\n')}`,
).toBe(0);
});
});