|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #21806 — `PATCH` reaches a declared AI route over HTTP, and a method the AI |
| 5 | + * route table does not declare for a path still answers `405`. |
| 6 | + * |
| 7 | + * ## The defect |
| 8 | + * |
| 9 | + * `registerAIRoutes` mounted the `${base}/ai/*` method wildcards for `get`, |
| 10 | + * `post`, `delete` and `put` only. On a host where the wildcards are the ONLY |
| 11 | + * door into `/ai/**` — cloud's hosted composition, whose measured `405` listed |
| 12 | + * `Allowed: DELETE, GET, HEAD, POST, PUT`, i.e. no concrete `PATCH` mount |
| 13 | + * either — a `PATCH` never reached the dispatcher: Hono routed it to |
| 14 | + * `notFound`, and the adapter's `unmatchedResponse()` answered `405` because |
| 15 | + * the path matched the wildcards under the four other verbs. The declared |
| 16 | + * `PATCH /api/v1/ai/conversations/:id` (the SDK's `ai.conversations.update`, |
| 17 | + * the console's conversation rename) was therefore unreachable. |
| 18 | + * |
| 19 | + * ## Where the 405 came from, and why it needed a second producer |
| 20 | + * |
| 21 | + * Measured on `origin/main` before the fix, through this file: the `405` for |
| 22 | + * an undeclared method was the ADAPTER's, and only for the one verb the |
| 23 | + * wildcard did not mount. For the four mounted verbs the AI route table itself |
| 24 | + * answered a method it does not declare with `404 ROUTE_NOT_FOUND` (its only |
| 25 | + * miss exit). Mounting `patch` alone would therefore have moved an undeclared |
| 26 | + * `PATCH` from the adapter's `405` to the table's `404` — so the table now |
| 27 | + * tells the two misses apart, for every verb alike: a path declared under |
| 28 | + * other methods answers `405 METHOD_NOT_ALLOWED` with an `Allow` header naming |
| 29 | + * exactly the methods the table declares for it, and a path declared under |
| 30 | + * none stays `404 ROUTE_NOT_FOUND`. |
| 31 | + * |
| 32 | + * ## The composition, and why the route table is installed AFTER boot |
| 33 | + * |
| 34 | + * `plugin-hono-server` + the dispatcher, scoping on under `auto`, so BOTH |
| 35 | + * bases `registerAIRoutes` serves are mounted (the unscoped `${prefix}` and |
| 36 | + * `${prefix}/environments/:environmentId`) and each case runs at each. |
| 37 | + * |
| 38 | + * The AI route table is written onto the kernel only once `bootstrap()` has |
| 39 | + * returned, and no `ai:routes` hook ever fires. Either of those would make the |
| 40 | + * dispatcher ALSO mount every declared route concretely (`mountAiRoute`), and |
| 41 | + * a concrete `PATCH` mount would answer a `PATCH` whether or not the wildcard |
| 42 | + * lets the verb through — the very door this file exists to measure would then |
| 43 | + * be shadowed out of the reading. Installed late, the method wildcards are the |
| 44 | + * only door, exactly as on the host where the defect was measured. |
| 45 | + * |
| 46 | + * Every route is declared `auth: false` so no session plumbing is needed; the |
| 47 | + * route-level auth contract is pinned in `domains/ai-anonymous-deny-ordering.test.ts` |
| 48 | + * and is not what this file measures. |
| 49 | + */ |
| 50 | + |
| 51 | +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; |
| 52 | +import { LiteKernel } from '@objectstack/core'; |
| 53 | +import type { Plugin, PluginContext } from '@objectstack/core'; |
| 54 | +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; |
| 55 | +import type { IHttpServer } from '@objectstack/spec/contracts'; |
| 56 | + |
| 57 | +import { createDispatcherPlugin } from './dispatcher-plugin.js'; |
| 58 | + |
| 59 | +const PREFIX = '/api/v1'; |
| 60 | +const ENV_ID = 'env_alpha'; |
| 61 | + |
| 62 | +const BASES: Array<[string, string]> = [ |
| 63 | + ['unscoped', PREFIX], |
| 64 | + ['scoped', `${PREFIX}/environments/${ENV_ID}`], |
| 65 | +]; |
| 66 | + |
| 67 | +/** Every handler invocation, so a refusal can prove no handler ran. */ |
| 68 | +const calls: Array<{ route: string; params: Record<string, string>; body: any }> = []; |
| 69 | + |
| 70 | +function route(method: string, path: string) { |
| 71 | + return { |
| 72 | + method, |
| 73 | + path, |
| 74 | + auth: false, |
| 75 | + handler: async (req: any) => { |
| 76 | + calls.push({ route: `${method} ${path}`, params: req.params, body: req.body }); |
| 77 | + return { |
| 78 | + status: 200, |
| 79 | + body: { success: true, data: { route: `${method} ${path}`, params: req.params, body: req.body ?? null } }, |
| 80 | + }; |
| 81 | + }, |
| 82 | + }; |
| 83 | +} |
| 84 | + |
| 85 | +/** |
| 86 | + * The AI route table. `/conversations/:id` carries the SDK's |
| 87 | + * `ai.conversations.update` verb beside a read; `/models` is GET-only, the |
| 88 | + * path the undeclared-method direction is asked on. |
| 89 | + */ |
| 90 | +const AI_ROUTES = [ |
| 91 | + route('GET', '/api/v1/ai/conversations/:id'), |
| 92 | + route('PATCH', '/api/v1/ai/conversations/:id'), |
| 93 | + route('GET', '/api/v1/ai/models'), |
| 94 | +]; |
| 95 | + |
| 96 | +/** A serveable `ai` slot — the domain reads the route table only behind one. */ |
| 97 | +function fakeAiServicePlugin(): Plugin { |
| 98 | + return { |
| 99 | + name: 'com.objectstack.test.fake-ai-service', |
| 100 | + version: '1.0.0', |
| 101 | + init: async (ctx: PluginContext) => { |
| 102 | + ctx.registerService('ai', { name: 'ai' }); |
| 103 | + }, |
| 104 | + }; |
| 105 | +} |
| 106 | + |
| 107 | +let kernel: LiteKernel | undefined; |
| 108 | +let baseUrl = ''; |
| 109 | + |
| 110 | +beforeAll(async () => { |
| 111 | + kernel = new LiteKernel(); |
| 112 | + kernel.use(fakeAiServicePlugin()); |
| 113 | + kernel.use(new HonoServerPlugin({ port: 0, cors: false })); |
| 114 | + kernel.use(createDispatcherPlugin({ |
| 115 | + prefix: PREFIX, |
| 116 | + scoping: { enableProjectScoping: true, projectResolution: 'auto' }, |
| 117 | + enforceProjectMembership: false, |
| 118 | + securityHeaders: false, |
| 119 | + })); |
| 120 | + await kernel.bootstrap(); |
| 121 | + // AFTER boot, deliberately — see the header: no concrete mount may exist. |
| 122 | + (kernel as any).__aiRoutes = AI_ROUTES; |
| 123 | + const httpServer = kernel.getService<IHttpServer>('http.server'); |
| 124 | + baseUrl = `http://127.0.0.1:${httpServer.getPort!()}`; |
| 125 | +}, 60_000); |
| 126 | + |
| 127 | +afterAll(async () => { |
| 128 | + if (!kernel) return; |
| 129 | + await Promise.race([ |
| 130 | + kernel.shutdown(), |
| 131 | + new Promise<void>((resolve) => setTimeout(resolve, 10_000)), |
| 132 | + ]); |
| 133 | +}, 60_000); |
| 134 | + |
| 135 | +beforeEach(() => { |
| 136 | + calls.length = 0; |
| 137 | +}); |
| 138 | + |
| 139 | +async function probe(method: string, path: string, body?: unknown): Promise<{ status: number; allow: string | null; body: any }> { |
| 140 | + const res = await fetch(`${baseUrl}${path}`, { |
| 141 | + method, |
| 142 | + ...(body !== undefined |
| 143 | + ? { headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) } |
| 144 | + : {}), |
| 145 | + }); |
| 146 | + let parsed: any; |
| 147 | + try { parsed = await res.json(); } catch { parsed = undefined; } |
| 148 | + return { status: res.status, allow: res.headers.get('allow'), body: parsed }; |
| 149 | +} |
| 150 | + |
| 151 | +describe.each(BASES)('#21806 — /ai/* method wildcards at the %s base', (_label, base) => { |
| 152 | + it('PATCH to a declared AI route reaches its handler', async () => { |
| 153 | + const r = await probe('PATCH', `${base}/ai/conversations/conv_1`, { title: 'Renamed' }); |
| 154 | + |
| 155 | + expect(r.status, JSON.stringify(r.body)).toBe(200); |
| 156 | + expect(r.body).toEqual({ |
| 157 | + success: true, |
| 158 | + data: { |
| 159 | + route: 'PATCH /api/v1/ai/conversations/:id', |
| 160 | + params: { id: 'conv_1' }, |
| 161 | + body: { title: 'Renamed' }, |
| 162 | + }, |
| 163 | + }); |
| 164 | + expect(calls).toEqual([ |
| 165 | + { route: 'PATCH /api/v1/ai/conversations/:id', params: { id: 'conv_1' }, body: { title: 'Renamed' } }, |
| 166 | + ]); |
| 167 | + }, 60_000); |
| 168 | + |
| 169 | + it('PATCH to a path the table declares under GET only answers 405 and runs no handler', async () => { |
| 170 | + const r = await probe('PATCH', `${base}/ai/models`, { anything: true }); |
| 171 | + |
| 172 | + expect(r.status, JSON.stringify(r.body)).toBe(405); |
| 173 | + expect(r.body?.success).toBe(false); |
| 174 | + expect(r.body?.error?.code).toBe('METHOD_NOT_ALLOWED'); |
| 175 | + expect(r.allow).toBe('GET'); |
| 176 | + expect(calls).toEqual([]); |
| 177 | + }, 60_000); |
| 178 | + |
| 179 | + it('the same rule holds for a wildcard verb that is not PATCH — no per-verb case', async () => { |
| 180 | + const r = await probe('PUT', `${base}/ai/conversations/conv_1`, { title: 'Renamed' }); |
| 181 | + |
| 182 | + expect(r.status, JSON.stringify(r.body)).toBe(405); |
| 183 | + expect(r.body?.success).toBe(false); |
| 184 | + expect(r.body?.error?.code).toBe('METHOD_NOT_ALLOWED'); |
| 185 | + expect(r.allow).toBe('GET, PATCH'); |
| 186 | + expect(calls).toEqual([]); |
| 187 | + }, 60_000); |
| 188 | + |
| 189 | + it('a path the table declares under no method stays 404 ROUTE_NOT_FOUND', async () => { |
| 190 | + const r = await probe('PATCH', `${base}/ai/not-a-route`, {}); |
| 191 | + |
| 192 | + expect(r.status, JSON.stringify(r.body)).toBe(404); |
| 193 | + expect(r.body?.success).toBe(false); |
| 194 | + expect(r.body?.error?.code).toBe('ROUTE_NOT_FOUND'); |
| 195 | + expect(r.allow).toBeNull(); |
| 196 | + expect(calls).toEqual([]); |
| 197 | + }, 60_000); |
| 198 | +}); |
0 commit comments