|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { describe, it, expect } from 'vitest'; |
| 4 | +import { HttpDispatcher, type HttpDispatcherResult } from './http-dispatcher.js'; |
| 5 | + |
| 6 | +/** |
| 7 | + * The scoped-URL prefix is ONE convention, read three times in one request — |
| 8 | + * and the three readings had drifted apart. |
| 9 | + * |
| 10 | + * `dispatch()` handles an environment-scoped URL by reading the same prefix in |
| 11 | + * three places: |
| 12 | + * |
| 13 | + * 1. `extractEnvironmentIdFromPath` (via `prepareResolverHints`) parses the |
| 14 | + * environment id out of it and hands it to the host's `KernelResolver`; |
| 15 | + * 2. the `acceptOAuthAccessToken` test decides whether an OAuth 2.1 access |
| 16 | + * token is honoured, and runs against the STILL-SCOPED path; |
| 17 | + * 3. the scope strip removes the prefix so `DomainHandlerRegistry` — which |
| 18 | + * matches from the head of the path — can claim the remainder. |
| 19 | + * |
| 20 | + * Reading 1 said `/environments/`; readings 2 and 3 still said the |
| 21 | + * pre-ADR-0006 `/projects/`. What that cost, measured through the real |
| 22 | + * `@objectstack/hono` catch-all before the repair: |
| 23 | + * |
| 24 | + * ``` |
| 25 | + * GET /api/v1/environments/env_alpha/health -> 404 ROUTE_NOT_FOUND |
| 26 | + * GET /api/v1/environments/env_alpha/data/task -> 404 ROUTE_NOT_FOUND |
| 27 | + * GET /api/v1/health (control) -> 200 |
| 28 | + * GET /api/v1/data/task (control) -> 503 SERVICE_UNAVAILABLE (i.e. it REACHED /data) |
| 29 | + * GET /api/v1/projects/env_alpha/health -> 200 <- stripped, but nothing ever parsed the id |
| 30 | + * ``` |
| 31 | + * |
| 32 | + * The legacy spelling was not a working alias in exchange: nothing parses |
| 33 | + * `/projects/<id>`, so stripping it discarded the only place the environment |
| 34 | + * was named and served the request from the host default. ADR-0006 D2 retired |
| 35 | + * `project` on the API surface with NO aliases, and |
| 36 | + * `content/docs/api/environment-routing.mdx` tells callers to replace |
| 37 | + * `/api/v1/projects/:projectId/...` with `/api/v1/environments/:environmentId/...` |
| 38 | + * — so the repair is one spelling everywhere, not a two-prefix alternation. |
| 39 | + * |
| 40 | + * ## Why this suite drives `dispatch()` with a hand-derived subpath |
| 41 | + * |
| 42 | + * `packages/runtime` cannot depend on `@objectstack/hono` (that adapter depends |
| 43 | + * on THIS package). What the adapter contributes is one line — |
| 44 | + * `const subPath = c.req.path.substring(prefix.length)` in the |
| 45 | + * `app.all(`${prefix}/*`)` catch-all of `packages/adapters/hono/src/index.ts` — |
| 46 | + * so {@link subPathAsTheCatchAllDerivesIt} reproduces exactly that, and nothing |
| 47 | + * else. It matters that it is the catch-all: it is the ONLY entry that hands |
| 48 | + * `dispatch()` a still-scoped path. Every scoped mount in `dispatcher-plugin.ts` |
| 49 | + * passes a pre-stripped subpath (`registerAutomationRoutes` mounts |
| 50 | + * `${prefix}/environments/:environmentId/automation` but dispatches the literal |
| 51 | + * `'/automation'`), which is why the standalone server never saw this. |
| 52 | + */ |
| 53 | + |
| 54 | +const PREFIX = '/api/v1'; |
| 55 | +const ENV_ID = 'env_alpha'; |
| 56 | + |
| 57 | +/** The `@objectstack/hono` catch-all's one contribution, reproduced exactly. */ |
| 58 | +function subPathAsTheCatchAllDerivesIt(url: string): string { |
| 59 | + return url.substring(PREFIX.length); |
| 60 | +} |
| 61 | + |
| 62 | +/** |
| 63 | + * A kernel that provides NO services at all. |
| 64 | + * |
| 65 | + * Deliberate: it makes "which domain claimed the path" the only variable. A |
| 66 | + * claimed `/data` answers 503 SERVICE_UNAVAILABLE (the domain ran and found no |
| 67 | + * data service); an unclaimed path answers 404 ROUTE_NOT_FOUND. Those two are |
| 68 | + * the whole signal, and a kernel with services wired would blur them. |
| 69 | + */ |
| 70 | +function bareKernel(): any { |
| 71 | + return { |
| 72 | + getState: () => 'running', |
| 73 | + getService: () => undefined, |
| 74 | + getServiceAsync: async () => undefined, |
| 75 | + }; |
| 76 | +} |
| 77 | + |
| 78 | +function dispatcher(): HttpDispatcher { |
| 79 | + return new HttpDispatcher(bareKernel(), undefined, { enforceProjectMembership: false }); |
| 80 | +} |
| 81 | + |
| 82 | +/** |
| 83 | + * Narrow the optional `response`. Lifted from `http-dispatcher.ready.test.ts` |
| 84 | + * for the reason stated there: this package's test layer IS type-checked, and |
| 85 | + * `expect(res.response).toBeDefined()` narrows nothing. "Answered no response |
| 86 | + * at all" and "answered the wrong status" must stay distinguishable. |
| 87 | + */ |
| 88 | +function responseOf(res: HttpDispatcherResult, what: string): NonNullable<HttpDispatcherResult['response']> { |
| 89 | + const { response } = res; |
| 90 | + if (!response) throw new Error(`${what} answered no response at all`); |
| 91 | + return response; |
| 92 | +} |
| 93 | + |
| 94 | +/** `GET <url>` through the catch-all derivation; returns status + error code + the context it mutated. */ |
| 95 | +async function get(url: string): Promise<{ status: number; code: unknown; ctx: any }> { |
| 96 | + const ctx: any = { request: new Request(`http://pin.local${url}`) }; |
| 97 | + const res = await dispatcher().dispatch('GET', subPathAsTheCatchAllDerivesIt(url), undefined, {}, ctx, PREFIX); |
| 98 | + const response = responseOf(res, `GET ${url}`); |
| 99 | + return { status: response.status, code: (response.body as any)?.error?.code, ctx }; |
| 100 | +} |
| 101 | + |
| 102 | +describe('scoped-URL prefix — the environment-scoped URL reaches a dispatcher domain', () => { |
| 103 | + it('CONTROL: the unscoped forms reach their domains (a suite that measured nothing would fail here first)', async () => { |
| 104 | + expect((await get(`${PREFIX}/health`)).status).toBe(200); |
| 105 | + |
| 106 | + // `/data` has no exact/short-circuit answer, so it proves the DOMAIN ran |
| 107 | + // rather than merely that some route matched: the domain body is what |
| 108 | + // raises 503 on a missing data service. |
| 109 | + await expect(get(`${PREFIX}/data/task`)).rejects.toMatchObject({ statusCode: 503 }); |
| 110 | + }); |
| 111 | + |
| 112 | + it('NEGATIVE CONTROL: an unclaimed path answers 404 ROUTE_NOT_FOUND — the shape "matched no domain" takes', async () => { |
| 113 | + const res = await get(`${PREFIX}/no-such-domain`); |
| 114 | + expect(res.status).toBe(404); |
| 115 | + expect(res.code).toBe('ROUTE_NOT_FOUND'); |
| 116 | + }); |
| 117 | + |
| 118 | + it('the environment-scoped URL is stripped and reaches the same domain as the unscoped one', async () => { |
| 119 | + expect((await get(`${PREFIX}/environments/${ENV_ID}/health`)).status).toBe(200); |
| 120 | + await expect(get(`${PREFIX}/environments/${ENV_ID}/data/task`)).rejects.toMatchObject({ statusCode: 503 }); |
| 121 | + }); |
| 122 | + |
| 123 | + it('parses the environment id off the SAME prefix it strips — the two readings must not drift again', async () => { |
| 124 | + const { ctx } = await get(`${PREFIX}/environments/${ENV_ID}/health`); |
| 125 | + expect(ctx.urlEnvironmentId).toBe(ENV_ID); |
| 126 | + }); |
| 127 | + |
| 128 | + it('the retired `/projects/` spelling resolves nothing — ADR-0006 D2 grants no alias', async () => { |
| 129 | + const res = await get(`${PREFIX}/projects/${ENV_ID}/health`); |
| 130 | + expect(res.status).toBe(404); |
| 131 | + expect(res.code).toBe('ROUTE_NOT_FOUND'); |
| 132 | + |
| 133 | + // The point of removing it, stated as an assertion: this spelling was |
| 134 | + // never a working alias. Nothing parses it, so the pre-repair strip |
| 135 | + // deleted the only mention of the environment and served the request |
| 136 | + // from the host default. A 404 is the honest answer to a URL naming an |
| 137 | + // environment the dispatcher cannot resolve. |
| 138 | + expect(res.ctx.urlEnvironmentId).toBeUndefined(); |
| 139 | + }); |
| 140 | +}); |
| 141 | + |
| 142 | +describe('scoped-URL prefix — the OAuth-on-MCP gate reads the same prefix', () => { |
| 143 | + /** |
| 144 | + * Capture the `acceptOAuthAccessToken` decision `resolveRequestScope` |
| 145 | + * computes. It is handed to a private method, so the instance-level |
| 146 | + * override is the observation seam; throwing afterwards is deliberate — |
| 147 | + * `resolveRequestScope` catches it and leaves `executionContext` undefined, |
| 148 | + * which is precisely the anonymous-request path and keeps this pin free of |
| 149 | + * any identity stack. |
| 150 | + */ |
| 151 | + async function acceptsOAuthFor(url: string): Promise<boolean | undefined> { |
| 152 | + const d = dispatcher(); |
| 153 | + let seen: boolean | undefined; |
| 154 | + (d as any).timedResolveExecutionContext = async (opts: { acceptOAuthAccessToken?: boolean }) => { |
| 155 | + seen = opts.acceptOAuthAccessToken; |
| 156 | + throw new Error('captured — anonymous from here'); |
| 157 | + }; |
| 158 | + const ctx: any = { request: new Request(`http://pin.local${url}`) }; |
| 159 | + await d.resolveRequestScope(ctx, subPathAsTheCatchAllDerivesIt(url)); |
| 160 | + return seen; |
| 161 | + } |
| 162 | + |
| 163 | + it('honours OAuth access tokens on the plain AND the environment-scoped MCP route', async () => { |
| 164 | + expect(await acceptsOAuthFor(`${PREFIX}/mcp`)).toBe(true); |
| 165 | + expect(await acceptsOAuthFor(`${PREFIX}/environments/${ENV_ID}/mcp`)).toBe(true); |
| 166 | + }); |
| 167 | + |
| 168 | + it('CONTROL: refuses them off the MCP surface, and on the retired `/projects/` spelling', async () => { |
| 169 | + expect(await acceptsOAuthFor(`${PREFIX}/data/task`)).toBe(false); |
| 170 | + expect(await acceptsOAuthFor(`${PREFIX}/projects/${ENV_ID}/mcp`)).toBe(false); |
| 171 | + }); |
| 172 | +}); |
0 commit comments