|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#23365] Through a REAL `createHonoApp` host, an escaped throw that carries a |
| 5 | + * declared ADR-0112 envelope is answered as that envelope by BOTH of the |
| 6 | + * adapter's catch arms — the composed-host pin. |
| 7 | + * |
| 8 | + * ## What this pins |
| 9 | + * |
| 10 | + * `createHonoApp` has two catch arms: the `${authMount}/*` mount's and the |
| 11 | + * `${prefix}/*` catch-all's around `HttpDispatcher.dispatch()`. Both used to |
| 12 | + * answer `errorJson(c, err.message || …, err.statusCode || 500)`, reading |
| 13 | + * `statusCode` only, so a throw that declared `status` and a registered `code` |
| 14 | + * lost both and answered `500 INTERNAL_ERROR`. Measured on this host before the |
| 15 | + * change, every declared row below answered `500 INTERNAL_ERROR`. |
| 16 | + * |
| 17 | + * Both arms now ask the ONE recogniser #16545 landed for the direct-mount |
| 18 | + * adapter (`declaredEnvelopeForThrow`, `@objectstack/plugin-hono-server`), so a |
| 19 | + * `createHonoApp` host and a `HonoHttpServer` host answer the same throw alike. |
| 20 | + * A throw the recogniser does not accept takes the arm's unchanged answer. |
| 21 | + * |
| 22 | + * Each declared row's producer is the REAL one, reached the way a caller |
| 23 | + * reaches it: |
| 24 | + * |
| 25 | + * - a missing record — `recordNotFoundError` (`@objectstack/core`), raised by |
| 26 | + * the real protocol's `getData` / `updateData` / `deleteData`; |
| 27 | + * - a stale `If-Match` — the protocol's `ConcurrentUpdateError` (409); |
| 28 | + * - `If-Match: ""` — the protocol's `MalformedVersionTokenError` (400); |
| 29 | + * - an authorization-store outage — `AuthzStoreUnavailableError` (503), |
| 30 | + * raised by the dispatcher's own ADR-0069 gate when an active gate's session |
| 31 | + * read fails (`enforceAuthGate`, which sits above `dispatch()`'s `try`). |
| 32 | + * |
| 33 | + * Each row reads the answer on the wire (status, `success`, nested |
| 34 | + * `error.code`), and the write rows also read the STORE afterwards. |
| 35 | + * |
| 36 | + * ## The other direction |
| 37 | + * |
| 38 | + * A throw that is NOT a declared envelope keeps the arm's answer: a bare |
| 39 | + * `Error` is still `500 INTERNAL_ERROR` carrying `code` and `message` only, with |
| 40 | + * no `cause` in the body; a throw that declares `statusCode` and no registered |
| 41 | + * code (the shape `callData`'s exposure gate throws) keeps the status it |
| 42 | + * declared. The declared 503 also carries no `cause`: the session read's own |
| 43 | + * failure stays off the wire. |
| 44 | + * |
| 45 | + * ## The auth-mount arm |
| 46 | + * |
| 47 | + * No in-repo producer was found that leaves `authService.handleRequest` with a |
| 48 | + * declared envelope: better-auth's router answers its own failures as a |
| 49 | + * `Response`. The arm takes the same rendering for uniformity — one rule, not |
| 50 | + * two — and its rows use a host-supplied auth service whose `handleRequest` |
| 51 | + * throws, which is the shape a host's own `IAuthService` could take. |
| 52 | + * |
| 53 | + * ## ⚠️ Why this lives HERE and not in `packages/adapters/hono` |
| 54 | + * |
| 55 | + * `packages/adapters/hono/vitest.config.ts` aliases `@objectstack/runtime` to a |
| 56 | + * stub, so no test there reaches the real dispatcher. Here `@objectstack/hono` |
| 57 | + * is aliased to this checkout's adapter SOURCE (the subject), and |
| 58 | + * `@objectstack/runtime` resolves through its `dist/` (a ledgered pair in |
| 59 | + * `scripts/check-test-source-alias.mjs`), as in the sibling |
| 60 | + * `hono-*.conformance.test.ts` files. `@objectstack/plugin-hono-server`, where |
| 61 | + * the recogniser lives, also resolves through its `dist/`, so a change there |
| 62 | + * reaches these rows once that package is rebuilt, which is how CI builds the |
| 63 | + * closure. |
| 64 | + * |
| 65 | + * ## The one seam that is stubbed |
| 66 | + * |
| 67 | + * WHO the caller is comes from the dispatcher's identity step |
| 68 | + * (`timedResolveExecutionContext`), replaced on the prototype with one member, |
| 69 | + * as in the sibling files. The protocol is the real one: `ObjectQLPlugin` |
| 70 | + * registers `ObjectStackProtocolImplementation` over an in-memory SQLite |
| 71 | + * (pure-JS WASM) driver. The two fallback controls on the catch-all replace one |
| 72 | + * protocol call with a throw of the shape under test. |
| 73 | + */ |
| 74 | + |
| 75 | +import { describe, it, expect, vi, afterEach } from 'vitest'; |
| 76 | +import { LiteKernel, AuthzStoreUnavailableError } from '@objectstack/core'; |
| 77 | +import { ObjectQLPlugin } from '@objectstack/objectql'; |
| 78 | +import type { ObjectQL } from '@objectstack/objectql'; |
| 79 | +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; |
| 80 | +import { HttpDispatcher } from '@objectstack/runtime'; |
| 81 | +import { createHonoApp } from '@objectstack/hono'; |
| 82 | + |
| 83 | +const PREFIX = '/api/v1'; |
| 84 | +const MEMBER = { userId: 'u_member', isSystem: false, systemPermissions: [] as string[] }; |
| 85 | +const MISSING_ID = 'no_such_row'; |
| 86 | +/** A version token that names a real instant, and never the stored one. */ |
| 87 | +const STALE_VERSION = '"2001-01-01T00:00:00.000Z"'; |
| 88 | +/** A cause's text, so a row can assert it never reaches the wire. */ |
| 89 | +const CAUSE_TEXT = 'SQLITE_BUSY: database is locked while reading sys_session'; |
| 90 | + |
| 91 | +const TASK = { |
| 92 | + name: 'task', |
| 93 | + label: 'Task', |
| 94 | + fields: { |
| 95 | + name: { name: 'name', type: 'text' as const, label: 'Name' }, |
| 96 | + }, |
| 97 | +}; |
| 98 | + |
| 99 | +const liveKernels: LiteKernel[] = []; |
| 100 | +afterEach(async () => { |
| 101 | + vi.restoreAllMocks(); |
| 102 | + while (liveKernels.length) { |
| 103 | + const kernel = liveKernels.pop(); |
| 104 | + await Promise.race([ |
| 105 | + kernel?.shutdown(), |
| 106 | + new Promise<void>((resolve) => setTimeout(resolve, 10_000)), |
| 107 | + ]); |
| 108 | + } |
| 109 | +}); |
| 110 | + |
| 111 | +interface Answer { status: number; body: any } |
| 112 | + |
| 113 | +/** |
| 114 | + * The real store, the real protocol over it, and the REAL `createHonoApp` over |
| 115 | + * both. `auth`, when given, is registered as the kernel's `auth` service. |
| 116 | + */ |
| 117 | +async function boot(auth?: Record<string, unknown>) { |
| 118 | + const driver = new SqliteWasmDriver({ filename: ':memory:' }); |
| 119 | + await driver.connect(); |
| 120 | + |
| 121 | + const kernel = new LiteKernel(); |
| 122 | + liveKernels.push(kernel); |
| 123 | + kernel.use({ |
| 124 | + name: 'test-declared-envelope-services', |
| 125 | + version: '1.0.0', |
| 126 | + init: (c: any) => { |
| 127 | + c.registerService('driver.memory', driver); |
| 128 | + if (auth) c.registerService('auth', auth); |
| 129 | + }, |
| 130 | + } as any); |
| 131 | + kernel.use(new ObjectQLPlugin()); |
| 132 | + await kernel.bootstrap(); |
| 133 | + |
| 134 | + const ql = kernel.getService<ObjectQL>('objectql'); |
| 135 | + ql.registerObject(TASK as any); |
| 136 | + await ql.syncObjectSchema('task'); |
| 137 | + |
| 138 | + vi.spyOn(HttpDispatcher.prototype as any, 'timedResolveExecutionContext') |
| 139 | + .mockImplementation(async () => ({ ...MEMBER })); |
| 140 | + |
| 141 | + const app = createHonoApp({ kernel: kernel as any, prefix: PREFIX, cors: false }); |
| 142 | + |
| 143 | + const send = async (method: string, path: string, opts: { headers?: Record<string, string>; body?: unknown } = {}): Promise<Answer> => { |
| 144 | + const res = await app.request(`http://localhost${PREFIX}${path}`, { |
| 145 | + method, |
| 146 | + headers: opts.body === undefined ? (opts.headers ?? {}) : { 'content-type': 'application/json', ...opts.headers }, |
| 147 | + ...(opts.body === undefined ? {} : { body: JSON.stringify(opts.body) }), |
| 148 | + }); |
| 149 | + const text = await res.text(); |
| 150 | + return { status: res.status, body: text === '' ? undefined : JSON.parse(text) }; |
| 151 | + }; |
| 152 | + |
| 153 | + /** A stored task, written through the engine, read back with its version. */ |
| 154 | + const seed = async (name: string): Promise<string> => { |
| 155 | + const created: any = await ql.insert('task', { name }); |
| 156 | + return String(created.id); |
| 157 | + }; |
| 158 | + /** The stored names, by id — the store, not the door's word for it. */ |
| 159 | + const stored = async (id: string): Promise<string | undefined> => { |
| 160 | + const rows: any[] = await ql.find('task', { where: { id } }) ?? []; |
| 161 | + return rows[0]?.name; |
| 162 | + }; |
| 163 | + |
| 164 | + return { send, seed, stored, protocol: kernel.getService<any>('protocol') }; |
| 165 | +} |
| 166 | + |
| 167 | +/** The ADR-0112 envelope's three load-bearing slots. */ |
| 168 | +const envelope = (a: Answer) => ({ status: a.status, success: a.body?.success, code: a.body?.error?.code }); |
| 169 | + |
| 170 | +describe('[#23365] a real createHonoApp host: the dispatch() catch-all renders a declared envelope', () => { |
| 171 | + it('control: an existing record is served, and the rig answers 200', async () => { |
| 172 | + const host = await boot(); |
| 173 | + const id = await host.seed('present'); |
| 174 | + const answer = await host.send('GET', `/data/task/${id}`); |
| 175 | + expect(answer.status, JSON.stringify(answer.body)).toBe(200); |
| 176 | + }, 60_000); |
| 177 | + |
| 178 | + it.each(['GET', 'PATCH', 'DELETE'])('%s of a missing record: 404 RECORD_NOT_FOUND', async (method) => { |
| 179 | + const host = await boot(); |
| 180 | + const answer = await host.send(method, `/data/task/${MISSING_ID}`, method === 'PATCH' ? { body: { name: 'x' } } : {}); |
| 181 | + expect(envelope(answer), JSON.stringify(answer.body)).toEqual({ status: 404, success: false, code: 'RECORD_NOT_FOUND' }); |
| 182 | + }, 60_000); |
| 183 | + |
| 184 | + it('PATCH with a stale If-Match: 409 CONCURRENT_UPDATE, and the stored row is unchanged', async () => { |
| 185 | + const host = await boot(); |
| 186 | + const id = await host.seed('original'); |
| 187 | + const answer = await host.send('PATCH', `/data/task/${id}`, { headers: { 'If-Match': STALE_VERSION }, body: { name: 'lost update' } }); |
| 188 | + expect(envelope(answer), JSON.stringify(answer.body)).toEqual({ status: 409, success: false, code: 'CONCURRENT_UPDATE' }); |
| 189 | + expect(await host.stored(id)).toBe('original'); |
| 190 | + }, 60_000); |
| 191 | + |
| 192 | + it('DELETE with a stale If-Match: 409 CONCURRENT_UPDATE, and the row is still stored', async () => { |
| 193 | + const host = await boot(); |
| 194 | + const id = await host.seed('kept'); |
| 195 | + const answer = await host.send('DELETE', `/data/task/${id}`, { headers: { 'If-Match': STALE_VERSION } }); |
| 196 | + expect(envelope(answer), JSON.stringify(answer.body)).toEqual({ status: 409, success: false, code: 'CONCURRENT_UPDATE' }); |
| 197 | + expect(await host.stored(id)).toBe('kept'); |
| 198 | + }, 60_000); |
| 199 | + |
| 200 | + it.each(['PATCH', 'DELETE'])('%s with If-Match: "" (the empty entity-tag): 400 VALIDATION_FAILED, the row untouched', async (method) => { |
| 201 | + const host = await boot(); |
| 202 | + const id = await host.seed('untouched'); |
| 203 | + const answer = await host.send(method, `/data/task/${id}`, { |
| 204 | + headers: { 'If-Match': '""' }, |
| 205 | + ...(method === 'PATCH' ? { body: { name: 'unguarded write' } } : {}), |
| 206 | + }); |
| 207 | + expect(envelope(answer), JSON.stringify(answer.body)).toEqual({ status: 400, success: false, code: 'VALIDATION_FAILED' }); |
| 208 | + expect(await host.stored(id)).toBe('untouched'); |
| 209 | + }, 60_000); |
| 210 | + |
| 211 | + it('an authorization-store outage (the ADR-0069 gate\'s session read fails): 503 SERVICE_UNAVAILABLE, no cause in the body', async () => { |
| 212 | + const host = await boot({ |
| 213 | + isAuthGateActive: () => true, |
| 214 | + api: { getSession: async () => { throw new Error(CAUSE_TEXT); } }, |
| 215 | + }); |
| 216 | + const answer = await host.send('GET', `/data/task/${MISSING_ID}`); |
| 217 | + expect(envelope(answer), JSON.stringify(answer.body)).toEqual({ status: 503, success: false, code: 'SERVICE_UNAVAILABLE' }); |
| 218 | + expect(answer.body.error).not.toHaveProperty('cause'); |
| 219 | + expect(JSON.stringify(answer.body)).not.toContain(CAUSE_TEXT); |
| 220 | + }, 60_000); |
| 221 | + |
| 222 | + it('control: a bare Error is still 500 INTERNAL_ERROR, carrying code and message only, with no cause in the body', async () => { |
| 223 | + const host = await boot(); |
| 224 | + const id = await host.seed('present'); |
| 225 | + vi.spyOn(host.protocol, 'getData').mockRejectedValueOnce(new Error('handler fault', { cause: new Error(CAUSE_TEXT) })); |
| 226 | + const answer = await host.send('GET', `/data/task/${id}`); |
| 227 | + expect(envelope(answer), JSON.stringify(answer.body)).toEqual({ status: 500, success: false, code: 'INTERNAL_ERROR' }); |
| 228 | + expect(Object.keys(answer.body.error).sort()).toEqual(['code', 'message']); |
| 229 | + expect(JSON.stringify(answer.body)).not.toContain(CAUSE_TEXT); |
| 230 | + }, 60_000); |
| 231 | + |
| 232 | + it('control: a throw declaring `statusCode` and no registered code keeps the status it declared', async () => { |
| 233 | + const host = await boot(); |
| 234 | + const id = await host.seed('present'); |
| 235 | + // The shape `callData` throws when the data service is missing. |
| 236 | + vi.spyOn(host.protocol, 'getData').mockRejectedValueOnce({ statusCode: 503, message: 'Data service not available' }); |
| 237 | + const answer = await host.send('GET', `/data/task/${id}`); |
| 238 | + expect(answer.status, JSON.stringify(answer.body)).toBe(503); |
| 239 | + expect(answer.body).toEqual({ success: false, error: { code: 'SERVICE_UNAVAILABLE', message: 'Data service not available' } }); |
| 240 | + }, 60_000); |
| 241 | +}); |
| 242 | + |
| 243 | +describe('[#23365] a real createHonoApp host: the auth-mount arm renders a declared envelope', () => { |
| 244 | + it('an auth service that throws a declared envelope: that envelope, with no cause in the body', async () => { |
| 245 | + const host = await boot({ |
| 246 | + handleRequest: async () => { throw new AuthzStoreUnavailableError('sys_session', new Error(CAUSE_TEXT)); }, |
| 247 | + }); |
| 248 | + const answer = await host.send('GET', '/auth/get-session'); |
| 249 | + expect(envelope(answer), JSON.stringify(answer.body)).toEqual({ status: 503, success: false, code: 'SERVICE_UNAVAILABLE' }); |
| 250 | + expect(answer.body.error).not.toHaveProperty('cause'); |
| 251 | + expect(JSON.stringify(answer.body)).not.toContain(CAUSE_TEXT); |
| 252 | + }, 60_000); |
| 253 | + |
| 254 | + it('control: an auth service that throws a bare Error is still 500 INTERNAL_ERROR, code and message only', async () => { |
| 255 | + const host = await boot({ |
| 256 | + handleRequest: async () => { throw new Error('auth handler fault', { cause: new Error(CAUSE_TEXT) }); }, |
| 257 | + }); |
| 258 | + const answer = await host.send('GET', '/auth/get-session'); |
| 259 | + expect(envelope(answer), JSON.stringify(answer.body)).toEqual({ status: 500, success: false, code: 'INTERNAL_ERROR' }); |
| 260 | + expect(Object.keys(answer.body.error).sort()).toEqual(['code', 'message']); |
| 261 | + expect(JSON.stringify(answer.body)).not.toContain(CAUSE_TEXT); |
| 262 | + }, 60_000); |
| 263 | +}); |
0 commit comments