|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#17625, the runtime half of #7898's ruling A] The API root reaches the |
| 5 | + * discovery payload for a GATED session. |
| 6 | + * |
| 7 | + * ## What moved, and why this file exists at all |
| 8 | + * |
| 9 | + * `dispatch()` strips one trailing slash, so BOTH root spellings the |
| 10 | + * dispatcher accepts — `${prefix}/` (arriving as `'/'`) and `${prefix}` |
| 11 | + * (arriving as `''`, the MSW/base-URL-stripped form) — used to travel on as |
| 12 | + * `''`. Only the discovery branch at the foot of the method knew that meant |
| 13 | + * the API root; the ADR-0069 gate, which runs far above it, did not. While |
| 14 | + * `isAuthGateAllowlisted` answered `true` for a falsy path that disagreement |
| 15 | + * was invisible. #7898 made the predicate fail-closed, and the bare-root |
| 16 | + * discovery request started answering 403. |
| 17 | + * |
| 18 | + * ⚠️ The obvious repair is measured WRONG upstream and must not be re-tried |
| 19 | + * here: normalising `'' → '/'` relocates the 403 instead of removing it. |
| 20 | + * `isAuthGateAllowlisted('/')` is `false` (a segment-less path matches no |
| 21 | + * `ALLOW_ROUTES` entry) and the discovery branch tests `'/discovery'` or `''`, |
| 22 | + * which `'/'` satisfies neither. Both legs are pinned in |
| 23 | + * `packages/core/src/security/auth-gate.test.ts` → "does not exempt the |
| 24 | + * dispatcher bare-root `cleanPath` — step 2 is #17625". |
| 25 | + * |
| 26 | + * The delivered repair canonicalises the root to `'/discovery'` — the route it |
| 27 | + * has always served — so the gate and the branch read one spelling. ⛔ Core is |
| 28 | + * untouched and `ALLOW_ROUTES` is unchanged: the root gains exactly the |
| 29 | + * exemption `/discovery` already carried, and gains it by BEING that route. |
| 30 | + * |
| 31 | + * ## The genuinely-absent-path leg is NOT restated here |
| 32 | + * |
| 33 | + * "A caller that reaches the gate with no path at all is still refused" is |
| 34 | + * `packages/core`'s pin, delivered by #7898's own round |
| 35 | + * (`auth-gate.test.ts` → "[#7898] a falsy path is not exempt (fail-closed)", |
| 36 | + * which drives `isAuthGateAllowlisted` over `undefined`, `null` and `''`). |
| 37 | + * ⛔ Restating it against `HttpDispatcher` would measure nothing new: this |
| 38 | + * transport has no pathless call shape — `dispatch()` takes `path: string` and |
| 39 | + * the root canonicalisation below is reached only from the two ROOT spellings. |
| 40 | + * Referenced, not duplicated. |
| 41 | + * |
| 42 | + * ## Why every case runs on a fixture whose gate is provably ON |
| 43 | + * |
| 44 | + * `enforceAuthGate` fails open in a great many ways — no `auth` service, no |
| 45 | + * `isAuthGateActive`, no `getSession`, an unreadable header bag, any thrown |
| 46 | + * error — and under every one of them a 200 on the root is indistinguishable |
| 47 | + * from the repair working. So the gated fixture below is paired with a |
| 48 | + * POSITIVE CONTROL on a protected path in the same `describe`: if the control |
| 49 | + * stops answering 403 with the gate's own code, every other case in this file |
| 50 | + * is measuring a gate that is simply off, and the file says so by going red. |
| 51 | + */ |
| 52 | + |
| 53 | +import { describe, it, expect, vi } from 'vitest'; |
| 54 | +import { HttpDispatcher } from './http-dispatcher.js'; |
| 55 | + |
| 56 | +/** A session user carrying an ADR-0069 gate posture (`normalizeAuthGate`'s shape). */ |
| 57 | +const GATED_USER = { |
| 58 | + id: 'u_gated', |
| 59 | + authGate: { code: 'PASSWORD_EXPIRED', message: 'Your password has expired.' }, |
| 60 | +}; |
| 61 | + |
| 62 | +/** The same user with no gate — the negative-direction control. */ |
| 63 | +const UNGATED_USER = { id: 'u_clear' }; |
| 64 | + |
| 65 | +/** A path nothing allow-lists, used as the gate's positive control. */ |
| 66 | +const PROTECTED_PATH = '/data/task'; |
| 67 | + |
| 68 | +function makeDispatcher(sessionUser: unknown, gateActive = true) { |
| 69 | + const services: Record<string, any> = { |
| 70 | + objectql: { |
| 71 | + find: vi.fn().mockResolvedValue([]), |
| 72 | + getObjects: vi.fn().mockReturnValue({}), |
| 73 | + registry: { |
| 74 | + getObject: vi.fn().mockReturnValue(null), |
| 75 | + getRegisteredTypes: vi.fn().mockReturnValue([]), |
| 76 | + }, |
| 77 | + }, |
| 78 | + auth: { |
| 79 | + isAuthGateActive: () => gateActive, |
| 80 | + getApi: async () => ({ getSession: async () => ({ user: sessionUser }) }), |
| 81 | + }, |
| 82 | + }; |
| 83 | + const kernel: any = { |
| 84 | + getState: () => 'running', |
| 85 | + getService: (n: string) => services[n] ?? null, |
| 86 | + getServiceAsync: async (n: string) => services[n] ?? null, |
| 87 | + context: { getService: (n: string) => services[n] ?? null }, |
| 88 | + }; |
| 89 | + return new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false }); |
| 90 | +} |
| 91 | + |
| 92 | +/** |
| 93 | + * Drive one request and hand back the result plus the context the dispatcher |
| 94 | + * wrote through. `routePath` is the value `prepareResolverHints` recorded, and |
| 95 | + * therefore the spelling every stage below it — the gate included — was handed. |
| 96 | + */ |
| 97 | +async function dispatch(sessionUser: unknown, method: string, path: string, gateActive = true) { |
| 98 | + const dispatcher = makeDispatcher(sessionUser, gateActive); |
| 99 | + const context: any = { request: new Request(`http://localhost/api/v1${path}`) }; |
| 100 | + const result = await dispatcher.dispatch(method, path, undefined, {}, context, '/api/v1'); |
| 101 | + return { result, context }; |
| 102 | +} |
| 103 | + |
| 104 | +/** The gate's 403 carries its `code` in the envelope's `details` (`error(msg, 403, { code })`). */ |
| 105 | +const gateCodeOf = (result: any) => |
| 106 | + result.response?.body?.error?.details?.code ?? result.response?.body?.error?.code; |
| 107 | + |
| 108 | +describe('[#17625] the API root resolves to the discovery route for a gated session', () => { |
| 109 | + it('⭐ POSITIVE CONTROL — this fixture really does gate: a protected path answers 403 with the gate code', async () => { |
| 110 | + // ⛔ Do not delete or weaken this. `enforceAuthGate` fails open on any |
| 111 | + // hiccup, so without a request that the SAME fixture refuses, every |
| 112 | + // 200 below is compatible with "the gate never ran". |
| 113 | + const { result } = await dispatch(GATED_USER, 'GET', PROTECTED_PATH); |
| 114 | + expect(result.handled).toBe(true); |
| 115 | + expect(result.response?.status).toBe(403); |
| 116 | + expect(gateCodeOf(result)).toBe('PASSWORD_EXPIRED'); |
| 117 | + }); |
| 118 | + |
| 119 | + it('PIN 1 — `GET ${prefix}/` returns the discovery payload (was 403 after #7898)', async () => { |
| 120 | + const { result } = await dispatch(GATED_USER, 'GET', '/'); |
| 121 | + expect(result.handled).toBe(true); |
| 122 | + expect(result.response?.status).toBe(200); |
| 123 | + // The discovery document itself, not merely "not a 403". |
| 124 | + expect(result.response?.body?.data?.name).toBe('ObjectOS'); |
| 125 | + expect(result.response?.body?.data?.routes).toBeDefined(); |
| 126 | + }); |
| 127 | + |
| 128 | + it('PIN 2 — `GET ${prefix}` (no trailing slash) is unchanged: still the discovery payload', async () => { |
| 129 | + const { result } = await dispatch(GATED_USER, 'GET', ''); |
| 130 | + expect(result.handled).toBe(true); |
| 131 | + expect(result.response?.status).toBe(200); |
| 132 | + expect(result.response?.body?.data?.name).toBe('ObjectOS'); |
| 133 | + expect(result.response?.body?.data?.routes).toBeDefined(); |
| 134 | + }); |
| 135 | + |
| 136 | + it('serves the SAME document for both root spellings and for the named route', async () => { |
| 137 | + // One route, three spellings — the property the canonicalisation buys. |
| 138 | + const [slash, bare, named] = await Promise.all([ |
| 139 | + dispatch(GATED_USER, 'GET', '/'), |
| 140 | + dispatch(GATED_USER, 'GET', ''), |
| 141 | + dispatch(GATED_USER, 'GET', '/discovery'), |
| 142 | + ]); |
| 143 | + for (const r of [slash, bare, named]) expect(r.result.response?.status).toBe(200); |
| 144 | + expect(slash.result.response?.body?.data).toEqual(named.result.response?.body?.data); |
| 145 | + expect(bare.result.response?.body?.data).toEqual(named.result.response?.body?.data); |
| 146 | + }); |
| 147 | + |
| 148 | + it('⭐ THE MECHANISM — the gate is handed the allow-listed route NAME, never `""` or `"/"`', async () => { |
| 149 | + // This is the assertion that makes the repair the RULED one rather than |
| 150 | + // a coincidence: both root spellings are canonicalised BEFORE the gate, |
| 151 | + // so what the gate evaluates is `/discovery` — a name `ALLOW_ROUTES` |
| 152 | + // already carries. ⛔ If this ever reads `'/'`, the fix has regressed to |
| 153 | + // the shape upstream measured insufficient, and PIN 1 would only still |
| 154 | + // pass because something else started exempting the root. |
| 155 | + for (const path of ['/', '']) { |
| 156 | + const { context } = await dispatch(GATED_USER, 'GET', path); |
| 157 | + expect(context.routePath, path).toBe('/discovery'); |
| 158 | + } |
| 159 | + // …and a path that is NOT the root is not rewritten. |
| 160 | + const { context } = await dispatch(GATED_USER, 'GET', PROTECTED_PATH); |
| 161 | + expect(context.routePath).toBe(PROTECTED_PATH); |
| 162 | + }); |
| 163 | + |
| 164 | + it('⭐ NEGATIVE-DIRECTION CONTROL — the repair narrows nothing: an UNGATED session still reads the root', async () => { |
| 165 | + for (const path of ['/', '', '/discovery']) { |
| 166 | + const { result } = await dispatch(UNGATED_USER, 'GET', path); |
| 167 | + expect(result.response?.status, path).toBe(200); |
| 168 | + expect(result.response?.body?.data?.name, path).toBe('ObjectOS'); |
| 169 | + } |
| 170 | + }); |
| 171 | + |
| 172 | + it('the named `/discovery` route keeps answering for a gated session', async () => { |
| 173 | + const { result } = await dispatch(GATED_USER, 'GET', '/discovery'); |
| 174 | + expect(result.response?.status).toBe(200); |
| 175 | + expect(result.response?.body?.data?.name).toBe('ObjectOS'); |
| 176 | + }); |
| 177 | +}); |
| 178 | + |
| 179 | +describe('[#17625] the boundary — what the canonicalisation deliberately does NOT move', () => { |
| 180 | + it('the ENVIRONMENT-SCOPED root keeps its own answer: `${prefix}/environments/<id>` is still gated', async () => { |
| 181 | + // ⚠️ A different input class, and deliberately untouched. The gate runs |
| 182 | + // BEFORE the scoped-URL strip, so this request is judged on |
| 183 | + // `/environments/<id>` — which matched no `ALLOW_ROUTES` entry before |
| 184 | + // #7898 either, so its answer did not move in that card and must not |
| 185 | + // move in this one. The `''` the strip produces afterwards is why the |
| 186 | + // discovery branch keeps its `''` arm. |
| 187 | + const { result } = await dispatch(GATED_USER, 'GET', '/environments/env-1'); |
| 188 | + expect(result.response?.status).toBe(403); |
| 189 | + expect(gateCodeOf(result)).toBe('PASSWORD_EXPIRED'); |
| 190 | + }); |
| 191 | + |
| 192 | + it('a non-root path that merely LOOKS empty after the strip is not the root', async () => { |
| 193 | + // `//` strips to `'/'`, not to `''`, so it is not canonicalised and is |
| 194 | + // not exempt — recorded so a later reader does not widen the rule into |
| 195 | + // "any number of trailing slashes is the root". |
| 196 | + const { result, context } = await dispatch(GATED_USER, 'GET', '//'); |
| 197 | + expect(context.routePath).toBe('/'); |
| 198 | + expect(result.response?.status).toBe(403); |
| 199 | + }); |
| 200 | +}); |
0 commit comments