|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #4073 — is `registerStandardEndpoints` really all DUPLICATE supply? |
| 5 | + * |
| 6 | + * The retirement plan (flip the default to `false` → observe a release → delete |
| 7 | + * `registerDiscoveryAndCrudEndpoints`) rests on that premise: every path the |
| 8 | + * flag mounts is also served, more completely, by `@objectstack/rest` or the |
| 9 | + * runtime dispatcher, so flipping the default changes nothing a caller can see. |
| 10 | + * |
| 11 | + * The premise is not uniform across the surface — the two halves reach it by |
| 12 | + * different mechanisms, and only one of them holds: |
| 13 | + * |
| 14 | + * - **`/discovery` + `/.well-known/objectstack` — ceded explicitly (#4018).** |
| 15 | + * `registerDiscoveryEndpoints` checks `kernel.hasPlugin(rest|dispatcher)` and |
| 16 | + * declines to register at all. That is order-independent, so it holds however |
| 17 | + * Hono resolves precedence. Verified below against the REAL dispatcher. |
| 18 | + * |
| 19 | + * - **`/data/:object` — no such check.** Its shadowing is asserted purely on |
| 20 | + * "REST registers first and wins". Booted for real, that does not hold: with |
| 21 | + * the flag off the path is a 404, with REST mounted or not. The flag's raw |
| 22 | + * surface is the ONLY thing answering `/api/v1/data/:object` in every |
| 23 | + * composition this harness can boot. |
| 24 | + * |
| 25 | + * So the flip is NOT the no-op the plan describes, and this file exists to keep |
| 26 | + * it from being made on the assumption. If you are here because you are about to |
| 27 | + * flip the default: first make `rest=true, flag=OFF` serve `/data/:object`, then |
| 28 | + * update these expectations in the same commit. |
| 29 | + * |
| 30 | + * Caveat, stated so the next reader does not over-read this: REST is mounted |
| 31 | + * here with a minimal service set (`objectql` + `auth`). A fully provisioned |
| 32 | + * `os serve` also has metadata/protocol services, and REST may mount `/data` |
| 33 | + * only once those resolve. What is proven is narrower than "REST never serves |
| 34 | + * `/data`" — it is "nothing in these compositions serves it once the flag is |
| 35 | + * off", which is enough to block a default flip that assumes otherwise. |
| 36 | + */ |
| 37 | + |
| 38 | +import { describe, it, expect, afterEach } from 'vitest'; |
| 39 | +import { LiteKernel } from '@objectstack/core'; |
| 40 | +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; |
| 41 | +import { createDispatcherPlugin } from './dispatcher-plugin.js'; |
| 42 | + |
| 43 | +function stubServices() { |
| 44 | + return { |
| 45 | + name: 'com.objectstack.test.standard-endpoints-precedence', |
| 46 | + version: '1.0.0', |
| 47 | + init: async (ctx: any) => { |
| 48 | + ctx.registerService('objectql', { |
| 49 | + async find() { return [{ id: 'r1' }]; }, |
| 50 | + async findOne() { return { id: 'r1' }; }, |
| 51 | + async insert(_o: string, d: any) { return d; }, |
| 52 | + }); |
| 53 | + ctx.registerService('auth', { |
| 54 | + async getSession() { return { user: { id: 'u1' }, session: {} }; }, |
| 55 | + }); |
| 56 | + }, |
| 57 | + }; |
| 58 | +} |
| 59 | + |
| 60 | +async function boot(registerStandardEndpoints: boolean, withRest: boolean) { |
| 61 | + const kernel = new LiteKernel(); |
| 62 | + kernel.use(stubServices()); |
| 63 | + // `serve.ts` order, real plugins: HonoServerPlugin (line 1173) long before |
| 64 | + // REST (line 1872) and the dispatcher (line 1883). |
| 65 | + kernel.use(new HonoServerPlugin({ port: 0, registerStandardEndpoints, cors: false })); |
| 66 | + if (withRest) { |
| 67 | + const { createRestApiPlugin } = await import('@objectstack/rest'); |
| 68 | + kernel.use(createRestApiPlugin({} as any)); |
| 69 | + } |
| 70 | + kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false, requireAuth: false })); |
| 71 | + await kernel.bootstrap(); |
| 72 | + const server = kernel.getService<any>('http.server'); |
| 73 | + return { kernel, baseUrl: `http://127.0.0.1:${server.getPort()}` }; |
| 74 | +} |
| 75 | + |
| 76 | +let kernel: LiteKernel | undefined; |
| 77 | +afterEach(async () => { |
| 78 | + if (kernel) await Promise.race([kernel.shutdown(), new Promise<void>((r) => setTimeout(r, 5_000))]); |
| 79 | + kernel = undefined; |
| 80 | +}); |
| 81 | + |
| 82 | +describe('#4073 — registerStandardEndpoints is not uniformly duplicate supply', () => { |
| 83 | + for (const withRest of [true, false]) { |
| 84 | + const label = withRest ? 'REST + dispatcher' : 'dispatcher only'; |
| 85 | + |
| 86 | + it(`${label}: with the flag ON, /data/:object is served and gated`, async () => { |
| 87 | + const booted = await boot(true, withRest); |
| 88 | + kernel = booted.kernel; |
| 89 | + const res = await fetch(`${booted.baseUrl}/api/v1/data/account`); |
| 90 | + // 401 — the anonymous-deny gate (#2567) fired, which proves the route |
| 91 | + // is MOUNTED. A 404 would mean nothing is serving it. |
| 92 | + expect(res.status, 'flag ON must mount /data/:object').toBe(401); |
| 93 | + }, 30_000); |
| 94 | + |
| 95 | + it(`${label}: with the flag OFF, /data/:object 404s — nothing else picks it up`, async () => { |
| 96 | + const booted = await boot(false, withRest); |
| 97 | + kernel = booted.kernel; |
| 98 | + const res = await fetch(`${booted.baseUrl}/api/v1/data/account`); |
| 99 | + expect( |
| 100 | + res.status, |
| 101 | + 'if this is no longer 404, another plugin now serves /data — re-read #4073, the flip may be safe', |
| 102 | + ).toBe(404); |
| 103 | + }, 30_000); |
| 104 | + } |
| 105 | + |
| 106 | + /** |
| 107 | + * The half of the surface that IS safe to retire: discovery cedes by an |
| 108 | + * explicit `hasPlugin` check, so the dispatcher's computed payload answers |
| 109 | + * whether the flag is on or off. |
| 110 | + */ |
| 111 | + for (const flag of [true, false]) { |
| 112 | + it(`discovery is the dispatcher's either way — flag ${flag ? 'ON' : 'OFF'} (#4018 cede)`, async () => { |
| 113 | + const booted = await boot(flag, false); |
| 114 | + kernel = booted.kernel; |
| 115 | + const res = await fetch(`${booted.baseUrl}/api/v1/discovery`); |
| 116 | + expect(res.status).toBe(200); |
| 117 | + const body = await res.json(); |
| 118 | + // `data.routes` is the dispatcher's shape; the flag's own payload is |
| 119 | + // a flat `{ version, apiName, routes, capabilities }`. |
| 120 | + expect(body?.data?.routes, 'the dispatcher must own discovery').toBeTruthy(); |
| 121 | + }, 30_000); |
| 122 | + } |
| 123 | +}); |
0 commit comments