Skip to content

Commit 70c0e7a

Browse files
os-zhuangclaude
andauthored
test(route-parity): declared===enforced e2e gate for os serve routes (#3369) (#3386)
Adds the "boot-the-real-server" route-parity gate #3369 calls for, rebased onto main after the #3361 Server-Timing fix (#3384) and the notifications mark-read e2e (#3388) landed independently. Keeps only the net-new, non-overlapping work. - runtime: route-parity e2e gate (`route-parity.integration.test.ts`). Boots the real hono app the way `os serve` mounts it (hono server + dispatcher plugin, services provisioned), reads `/api/v1/discovery`, and asserts every advertised / dispatcher-registered route is reachable (never 404/405/501) for an anonymous AND an admin principal, and that discovery is service-aware in both directions (no dead advertisement). Closes the "works in the dispatcher, dead on os serve" class (#3361/#3362/MCP 501) with one test. Does not duplicate the Server-Timing (#3384) / notifications mark-read (#3388) / REST `/data`·`/meta`·`/ui` (@objectstack/client) coverage. - http-dispatcher: document why `routes.mcp` is advertised on the `isMcpServerEnabled()` flag (the #2698 auto-load lockstep) rather than gated on service presence — comment only, keeps the dispatcher symmetric with @objectstack/rest. Test + comment only; no runtime code changes (no-release changeset). Claude-Session: https://claude.ai/code/session_015si15Q1KWMKpVQWYsEvgcS Co-authored-by: Claude <noreply@anthropic.com>
1 parent db160dd commit 70c0e7a

3 files changed

Lines changed: 253 additions & 0 deletions

File tree

.changeset/route-parity-gate.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
Add a route-parity e2e gate (`declared === enforced` for HTTP routes, #3369). It boots the real hono app the way `os serve` mounts it (hono server + dispatcher plugin, services provisioned), reads `/api/v1/discovery`, and asserts every advertised / dispatcher-registered route is reachable — never 404/405/501 — for an anonymous AND an admin principal, and that discovery is service-aware in both directions (no dead advertisement). Also documents (comment-only) why the dispatcher advertises `routes.mcp` on the `isMcpServerEnabled()` flag rather than gating on service presence (the #2698 auto-load lockstep). Test + comment only; no runtime code changes, so this releases nothing.

packages/runtime/src/http-dispatcher.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1661,6 +1661,17 @@ export class HttpDispatcher {
16611661
// MCP (Streamable HTTP) is a default-on core capability —
16621662
// advertised unless OS_MCP_SERVER_ENABLED=false opts the env
16631663
// out. The objectui Integrations page reads this.
1664+
//
1665+
// `declared === enforced` here is guaranteed by a LOCKSTEP, not
1666+
// by service-presence gating like the routes above (#3369 /
1667+
// #2698): `os serve` auto-loads plugin-mcp from the SAME
1668+
// `isMcpServerEnabled()` flag that gates this advertisement, so
1669+
// whenever `/mcp` is advertised the handler is mounted (a key /
1670+
// token yields 401, never a 404/501). Kept flag-based on purpose
1671+
// — `@objectstack/rest` advertises `mcp` from the identical
1672+
// single source (rest-server.ts), so the two discovery producers
1673+
// stay symmetric. The route-parity gate asserts the lockstep
1674+
// holds (advertised ⇒ reachable, never 501).
16641675
mcp: HttpDispatcher.isMcpEnabled() ? `${prefix}/mcp` : undefined,
16651676
};
16661677

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
4+
import { LiteKernel } from '@objectstack/core';
5+
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
6+
7+
import { createDispatcherPlugin } from './dispatcher-plugin.js';
8+
9+
/**
10+
* Route-parity gate — `declared === enforced` for HTTP routes (issue #3369).
11+
*
12+
* ObjectStack has TWO route-registration paths: the runtime `HttpDispatcher` /
13+
* `createDispatcherPlugin`, and the standalone hono server
14+
* (`@objectstack/plugin-hono-server`). `os serve` / `os dev` / `os start` mount
15+
* BOTH — the dispatcher registers its routes on the hono `http.server`. A class
16+
* of v16.0 bugs (#3361 Server-Timing, #3362 notifications, the MCP `501`) shared
17+
* one root cause: a handler was reachable on ONE path while the shipped server
18+
* ran the OTHER, so discovery advertised routes that 404/501'd on the real
19+
* listener — and every unit test exercised the two paths in isolation, so the
20+
* integration gap was invisible.
21+
*
22+
* This gate boots the REAL hono app the way `os serve` mounts it (hono server +
23+
* dispatcher plugin, services provisioned), opens a socket, reads
24+
* `/api/v1/discovery`, and asserts:
25+
* 1. every route advertised in discovery is REACHABLE (never 404 / 405 / 501)
26+
* — for an anonymous AND an authenticated (admin) principal;
27+
* 2. discovery is service-aware in BOTH directions — a route is advertised
28+
* IFF its backing service is present (no dead advertisement).
29+
*
30+
* Scope: this covers the dispatcher ↔ hono seam, where the #3361/#3362/MCP
31+
* regressions lived. The admin-gated `Server-Timing` behaviour is covered
32+
* end-to-end by `@objectstack/plugin-hono-server`'s `server-timing-e2e.test.ts`
33+
* and `@objectstack/rest`'s `rest-server-timing.test.ts` (#3384); the
34+
* notifications mark-read flow by `notifications.hono.integration.test.ts`
35+
* (#3388); and the REST-owned surface (`/data`, `/meta`, `/ui`) by the
36+
* `@objectstack/client` integration suite. This gate deliberately does not
37+
* duplicate those.
38+
*/
39+
40+
// ── Stub services provisioned exactly as a real `os serve` would ─────────────
41+
42+
/** A super-user permission set (the PLATFORM_ADMIN `admin_full_access` rung). */
43+
const ADMIN_SET = {
44+
id: 'ps-admin',
45+
name: 'admin_full_access',
46+
object_permissions: { '*': { viewAllRecords: true, modifyAllRecords: true } },
47+
};
48+
49+
/**
50+
* objectql stub answering the `find()` calls the identity resolvers make, plus
51+
* the data route's own list query. `admin1` → `admin_full_access`.
52+
*/
53+
function fakeObjectQL() {
54+
return {
55+
async find(object: string, opts: any) {
56+
const where = opts?.where ?? {};
57+
if (object === 'sys_user_permission_set') {
58+
const uid = where.user_id;
59+
return uid === 'admin1'
60+
? [{ user_id: uid, permission_set_id: 'ps-admin', organization_id: null }]
61+
: [];
62+
}
63+
if (object === 'sys_permission_set') {
64+
const ids: string[] = where?.id?.$in ?? [];
65+
return ids.includes('ps-admin') ? [ADMIN_SET] : [];
66+
}
67+
return [];
68+
},
69+
};
70+
}
71+
72+
/** better-auth-style session getter resolving the user from a test header. */
73+
function fakeAuth() {
74+
return {
75+
api: {
76+
async getSession({ headers }: { headers: Headers }) {
77+
const uid = headers.get('x-test-user');
78+
return uid ? { user: { id: uid } } : null;
79+
},
80+
},
81+
};
82+
}
83+
84+
function fakeNotification() {
85+
return {
86+
async listInbox() { return { items: [], unreadCount: 0 }; },
87+
async markRead() { return { updated: 0 }; },
88+
async markAllRead() { return { updated: 0 }; },
89+
};
90+
}
91+
92+
function fakeMcp() {
93+
return {
94+
async handleHttpRequest() {
95+
return { status: 200, headers: {}, body: { jsonrpc: '2.0', result: {} } };
96+
},
97+
};
98+
}
99+
100+
function stubServicesPlugin(opts: { notification?: boolean; mcp?: boolean } = {}) {
101+
return {
102+
name: 'com.objectstack.test.route-parity-stubs',
103+
version: '1.0.0',
104+
init: async (ctx: any) => {
105+
ctx.registerService('auth', fakeAuth());
106+
ctx.registerService('objectql', fakeObjectQL());
107+
if (opts.notification !== false) ctx.registerService('notification', fakeNotification());
108+
if (opts.mcp !== false) ctx.registerService('mcp', fakeMcp());
109+
},
110+
};
111+
}
112+
113+
async function bootServe(stubOpts: { notification?: boolean; mcp?: boolean } = {}) {
114+
const kernel = new LiteKernel();
115+
// Register stub services FIRST so identity + capability services are live
116+
// for both registration paths (mirrors a provisioned `os serve`).
117+
kernel.use(stubServicesPlugin(stubOpts));
118+
kernel.use(new HonoServerPlugin({ port: 0, registerStandardEndpoints: true, cors: false }));
119+
kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false, requireAuth: true }));
120+
await kernel.bootstrap();
121+
const httpServer = kernel.getService<any>('http.server');
122+
const baseUrl = `http://127.0.0.1:${httpServer.getPort()}`;
123+
return { kernel, baseUrl };
124+
}
125+
126+
async function shutdown(kernel: LiteKernel) {
127+
await Promise.race([kernel.shutdown(), new Promise<void>((r) => setTimeout(r, 10_000))]);
128+
}
129+
130+
describe('Route parity: discovery-advertised routes are reachable on os serve (#3369)', () => {
131+
let kernel: LiteKernel;
132+
let baseUrl: string;
133+
134+
beforeAll(async () => {
135+
({ kernel, baseUrl } = await bootServe());
136+
}, 30_000);
137+
138+
afterAll(async () => { if (kernel) await shutdown(kernel); }, 30_000);
139+
140+
it('serves discovery and advertises the provisioned capability routes', async () => {
141+
const res = await fetch(`${baseUrl}/api/v1/discovery`);
142+
expect(res.status).toBe(200);
143+
const routes = (await res.json())?.data?.routes ?? {};
144+
// Always-on kernel routes.
145+
expect(routes.data).toBeTruthy();
146+
expect(routes.metadata).toBeTruthy();
147+
// Provisioned optional capabilities → advertised (declared).
148+
expect(routes.notifications, 'notifications must be advertised when the service is present').toBeTruthy();
149+
expect(routes.mcp, 'mcp must be advertised when enabled').toBeTruthy();
150+
});
151+
152+
/**
153+
* The core gate: every route the server ADVERTISES must be ENFORCED —
154+
* reachable on the actual listener. 404 (route not mounted), 405 (wrong
155+
* method sink) and 501 (advertised but no backing handler/service) all mean
156+
* declared ≠ enforced. An anonymous caller legitimately gets 401/403; that
157+
* still proves the route is mounted.
158+
*/
159+
const DEAD_STATUSES = new Set([404, 405, 501]);
160+
const probes: Array<{ method: string; path: string; note: string }> = [
161+
{ method: 'GET', path: '/api/v1/health', note: 'liveness probe' },
162+
{ method: 'GET', path: '/api/v1/ready', note: 'readiness probe' },
163+
{ method: 'GET', path: '/api/v1/notifications', note: '#3362 inbox list' },
164+
{ method: 'POST', path: '/api/v1/notifications/read', note: '#3362 mark-read' },
165+
{ method: 'POST', path: '/api/v1/notifications/read/all', note: '#3362 mark-all-read' },
166+
{ method: 'POST', path: '/api/v1/mcp', note: 'MCP 501 regression' },
167+
];
168+
169+
it('every advertised/dispatcher route is reachable (not 404/405/501) for an anonymous caller', async () => {
170+
for (const { method, path, note } of probes) {
171+
const res = await fetch(`${baseUrl}${path}`, { method });
172+
expect(
173+
DEAD_STATUSES.has(res.status),
174+
`${method} ${path} (${note}) returned ${res.status} — declared but not enforced`,
175+
).toBe(false);
176+
}
177+
});
178+
179+
it('every advertised/dispatcher route is reachable (not 404/405/501) for an admin principal', async () => {
180+
for (const { method, path, note } of probes) {
181+
const res = await fetch(`${baseUrl}${path}`, { method, headers: { 'x-test-user': 'admin1' } });
182+
expect(
183+
DEAD_STATUSES.has(res.status),
184+
`${method} ${path} (${note}) returned ${res.status} for admin — declared but not enforced`,
185+
).toBe(false);
186+
}
187+
});
188+
189+
it('marks notifications read for an authenticated user (the #3362 end-to-end path)', async () => {
190+
// The exact call the Console makes (AppHeader mark-all-read). With the
191+
// notification service present it must reach the handler and succeed —
192+
// NOT 404 (the shipped-server regression #3354 set out to fix).
193+
const res = await fetch(`${baseUrl}/api/v1/notifications/read/all`, {
194+
method: 'POST',
195+
headers: { 'x-test-user': 'admin1', 'content-type': 'application/json' },
196+
body: '{}',
197+
});
198+
expect(res.status).toBe(200);
199+
});
200+
201+
it('MCP is advertised AND reachable — the discovery/route lockstep holds (not 501)', async () => {
202+
const disc = await (await fetch(`${baseUrl}/api/v1/discovery`)).json();
203+
expect(disc.data.routes.mcp).toBeTruthy();
204+
// With the mcp service provisioned, /mcp must NOT 501 ("not available").
205+
// Anonymous → 401 (a key/token is required) — reachable.
206+
const res = await fetch(`${baseUrl}/api/v1/mcp`, { method: 'POST', body: '{}' });
207+
expect(res.status).not.toBe(501);
208+
expect(res.status).not.toBe(404);
209+
});
210+
});
211+
212+
describe('Route parity: discovery is service-aware — no dead advertisement (#3369)', () => {
213+
let kernel: LiteKernel;
214+
let baseUrl: string;
215+
216+
beforeAll(async () => {
217+
// Boot WITHOUT the notification service.
218+
({ kernel, baseUrl } = await bootServe({ notification: false }));
219+
}, 30_000);
220+
221+
afterAll(async () => { if (kernel) await shutdown(kernel); }, 30_000);
222+
223+
it('does NOT advertise a capability whose backing service is absent', async () => {
224+
const disc = await (await fetch(`${baseUrl}/api/v1/discovery`)).json();
225+
expect(
226+
disc.data.routes.notifications,
227+
'notifications must NOT be advertised when the service is absent (declared === enforced)',
228+
).toBeFalsy();
229+
});
230+
231+
it('a request to the un-provisioned notifications route resolves to 404 (consistent with not advertising it)', async () => {
232+
// Not advertised AND 404 → declared === enforced (neither over- nor
233+
// under-promised). The failure mode #3369 forbids is the inverse:
234+
// advertised in discovery yet 404 on the listener.
235+
const res = await fetch(`${baseUrl}/api/v1/notifications`, { headers: { 'x-test-user': 'admin1' } });
236+
expect(res.status).toBe(404);
237+
});
238+
});

0 commit comments

Comments
 (0)