Skip to content

Commit 84672ff

Browse files
committed
test(http-conformance): wire pins for createHonoApp's declared-envelope rendering (#23365)
Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude <noreply@anthropic.com>
1 parent 869a381 commit 84672ff

1 file changed

Lines changed: 263 additions & 0 deletions

File tree

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
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

Comments
 (0)