-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathclient-url-conformance.test.ts
More file actions
385 lines (348 loc) · 17.5 KB
/
Copy pathclient-url-conformance.test.ts
File metadata and controls
385 lines (348 loc) · 17.5 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
// 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 and tracked separately (#3655).
*
* 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.';
// ---------------------------------------------------------------------------
// 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 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; }
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);
// 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.
// What remains is `* /ai/**`, whose routes service-ai builds at plugin start.
// Ratcheted: enumerating that family lowers it; nothing raises it without a
// deliberate decision.
expect(
wildcardOnly.length,
'methods matched only by a wildcard `**` family — weaker evidence than an exact ' +
`route. Enumerate a dynamic family to lower this bound; do not raise it:\n${wildcardOnly.join('\n')}`,
).toBeLessThanOrEqual(3);
});
});