@ontrails/http 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/testing.ts ADDED
@@ -0,0 +1,378 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import {
4
+ PermissionError,
5
+ Result,
6
+ getWebhookHeader,
7
+ trail,
8
+ topo,
9
+ webhook,
10
+ } from '@ontrails/core';
11
+ import type { Topo } from '@ontrails/core';
12
+ import { z } from 'zod';
13
+
14
+ import type {
15
+ DeriveHttpRoutesOptions,
16
+ HttpHeaderSource,
17
+ ResolveHttpPermit,
18
+ } from './build.js';
19
+ import type { CreateRouteHandlerOptions } from './fetch.js';
20
+
21
+ export interface HttpAdapterConformanceApp {
22
+ readonly fetch: (request: Request) => Response | Promise<Response>;
23
+ }
24
+
25
+ export interface HttpAdapterConformanceOptions
26
+ extends DeriveHttpRoutesOptions, CreateRouteHandlerOptions {
27
+ readonly resolvePermit?: ResolveHttpPermit | undefined;
28
+ }
29
+
30
+ export interface HttpAdapterConformanceAdapter {
31
+ readonly createApp: (
32
+ graph: Topo,
33
+ options?: HttpAdapterConformanceOptions
34
+ ) => HttpAdapterConformanceApp | Promise<HttpAdapterConformanceApp>;
35
+ readonly name: string;
36
+ }
37
+
38
+ export interface HttpAdapterConformanceCase {
39
+ readonly check: (adapter: HttpAdapterConformanceAdapter) => Promise<void>;
40
+ readonly name: string;
41
+ }
42
+
43
+ const echoTrail = trail('echo', {
44
+ implementation: (input) => Result.ok({ reply: input.message }),
45
+ input: z.object({ message: z.string() }),
46
+ intent: 'read',
47
+ output: z.object({ reply: z.string() }),
48
+ });
49
+
50
+ const tagsTrail = trail('tags', {
51
+ implementation: (input) => Result.ok({ tags: input.tags }),
52
+ input: z.object({ tags: z.array(z.string()) }),
53
+ intent: 'read',
54
+ output: z.object({ tags: z.array(z.string()) }),
55
+ });
56
+
57
+ const typedQuerySchema = z.object({
58
+ count: z.number(),
59
+ enabled: z.boolean(),
60
+ });
61
+
62
+ const typedQueryTrail = trail('typed.query', {
63
+ implementation: (input) => Result.ok(input),
64
+ input: typedQuerySchema,
65
+ intent: 'read',
66
+ output: typedQuerySchema,
67
+ });
68
+
69
+ const echoBodyTrail = trail('echo.body', {
70
+ implementation: (input) => Result.ok({ length: input.message.length }),
71
+ input: z.object({ message: z.string() }),
72
+ intent: 'write',
73
+ output: z.object({ length: z.number() }),
74
+ });
75
+
76
+ const genericRedactionError = (): Error =>
77
+ new Error('database password=secret');
78
+
79
+ const genericErrorTrail = trail('generic.error', {
80
+ implementation: () => Result.err(genericRedactionError()),
81
+ input: z.object({}),
82
+ intent: 'read',
83
+ output: z.object({ ok: z.boolean() }),
84
+ });
85
+
86
+ const protectedTrail = trail('permit.scope', {
87
+ implementation: (_input, ctx) =>
88
+ Result.ok({
89
+ permitId: ctx.permit?.id,
90
+ requestId: ctx.requestId,
91
+ }),
92
+ input: z.object({}),
93
+ intent: 'read',
94
+ output: z.object({
95
+ permitId: z.string().optional(),
96
+ requestId: z.string().optional(),
97
+ }),
98
+ permit: { scopes: ['thing:read'] },
99
+ });
100
+
101
+ const abortingTrail = trail('abort.check', {
102
+ implementation: (_input, ctx) =>
103
+ Result.ok({ aborted: ctx.abortSignal.aborted }),
104
+ input: z.object({}),
105
+ intent: 'read',
106
+ output: z.object({ aborted: z.boolean() }),
107
+ });
108
+
109
+ const webhookSecret = 'secret';
110
+ const paymentWebhook = webhook('webhook.payment.received', {
111
+ parse: z.object({ paymentId: z.string() }),
112
+ path: '/webhooks/payment',
113
+ verify: (request) =>
114
+ getWebhookHeader(request, 'x-webhook-secret') === webhookSecret
115
+ ? Result.ok()
116
+ : Result.err(new PermissionError('Invalid webhook secret')),
117
+ });
118
+
119
+ const paymentWebhookTrail = trail('payment.receive', {
120
+ implementation: (input) => Result.ok({ paymentId: input.paymentId }),
121
+ input: z.object({ paymentId: z.string() }),
122
+ on: [paymentWebhook],
123
+ output: z.object({ paymentId: z.string() }),
124
+ });
125
+
126
+ const buildRequest = (path: string, init: RequestInit = {}): Request =>
127
+ new Request(new URL(path, 'http://localhost').toString(), init);
128
+
129
+ const readHeader = (
130
+ headers: HttpHeaderSource | undefined,
131
+ name: string
132
+ ): string | undefined => {
133
+ if (headers === undefined) {
134
+ return undefined;
135
+ }
136
+ if (headers instanceof Headers) {
137
+ return headers.get(name) ?? undefined;
138
+ }
139
+ const needle = name.toLowerCase();
140
+ for (const [key, value] of Object.entries(headers)) {
141
+ if (key.toLowerCase() !== needle || value === undefined) {
142
+ continue;
143
+ }
144
+ return typeof value === 'string' ? value : value[0];
145
+ }
146
+ return undefined;
147
+ };
148
+
149
+ const expectJson = async (
150
+ response: Response
151
+ ): Promise<Record<string, unknown>> =>
152
+ (await response.json()) as Record<string, unknown>;
153
+
154
+ const materialize = async (
155
+ adapter: HttpAdapterConformanceAdapter,
156
+ graph: Topo,
157
+ options?: HttpAdapterConformanceOptions
158
+ ): Promise<HttpAdapterConformanceApp> =>
159
+ await adapter.createApp(graph, options);
160
+
161
+ const expectOkResponse = async (
162
+ response: Response,
163
+ data: Record<string, unknown>
164
+ ): Promise<void> => {
165
+ expect(response.status).toBe(200);
166
+ expect(await expectJson(response)).toEqual({ data });
167
+ };
168
+
169
+ const request = async (
170
+ adapter: HttpAdapterConformanceAdapter,
171
+ graph: Topo,
172
+ path: string,
173
+ init?: RequestInit,
174
+ options?: HttpAdapterConformanceOptions
175
+ ): Promise<Response> => {
176
+ const app = await materialize(adapter, graph, options);
177
+ return await app.fetch(buildRequest(path, init));
178
+ };
179
+
180
+ const readRouteCase = async (
181
+ adapter: HttpAdapterConformanceAdapter
182
+ ): Promise<void> => {
183
+ const response = await request(
184
+ adapter,
185
+ topo('http-conformance-read', { echoTrail }),
186
+ '/echo?message=hello'
187
+ );
188
+
189
+ await expectOkResponse(response, { reply: 'hello' });
190
+ };
191
+
192
+ const writeRouteCase = async (
193
+ adapter: HttpAdapterConformanceAdapter
194
+ ): Promise<void> => {
195
+ const response = await request(
196
+ adapter,
197
+ topo('http-conformance-write', { echoBodyTrail }),
198
+ '/echo/body',
199
+ {
200
+ body: JSON.stringify({ message: 'hello' }),
201
+ headers: { 'Content-Type': 'application/json' },
202
+ method: 'POST',
203
+ }
204
+ );
205
+
206
+ await expectOkResponse(response, { length: 5 });
207
+ };
208
+
209
+ const repeatedQueryCase = async (
210
+ adapter: HttpAdapterConformanceAdapter
211
+ ): Promise<void> => {
212
+ const graph = topo('http-conformance-query', { tagsTrail });
213
+ const repeated = await request(adapter, graph, '/tags?tags=red&tags=blue');
214
+ const singleton = await request(adapter, graph, '/tags?tags=solo');
215
+
216
+ await expectOkResponse(repeated, { tags: ['red', 'blue'] });
217
+ expect(singleton.status).toBe(400);
218
+ expect(await expectJson(singleton)).toMatchObject({
219
+ error: { category: 'validation' },
220
+ });
221
+ };
222
+
223
+ const typedQueryCase = async (
224
+ adapter: HttpAdapterConformanceAdapter
225
+ ): Promise<void> => {
226
+ const graph = topo('http-conformance-typed-query', { typedQueryTrail });
227
+ const valid = await request(
228
+ adapter,
229
+ graph,
230
+ '/typed/query?count=0&enabled=false'
231
+ );
232
+ const malformed = await request(
233
+ adapter,
234
+ graph,
235
+ '/typed/query?count=2x&enabled=true'
236
+ );
237
+
238
+ await expectOkResponse(valid, { count: 0, enabled: false });
239
+ expect(malformed.status).toBe(400);
240
+ expect(await expectJson(malformed)).toMatchObject({
241
+ error: { category: 'validation' },
242
+ });
243
+ };
244
+
245
+ const publicErrorCase = async (
246
+ adapter: HttpAdapterConformanceAdapter
247
+ ): Promise<void> => {
248
+ const response = await request(
249
+ adapter,
250
+ topo('http-conformance-errors', { genericErrorTrail }),
251
+ '/generic/error',
252
+ { headers: { 'X-Request-ID': 'req-123 forged/line' } }
253
+ );
254
+ const body = await expectJson(response);
255
+
256
+ expect(response.status).toBe(500);
257
+ expect(body).toEqual({
258
+ error: {
259
+ category: 'internal',
260
+ code: 'InternalError',
261
+ message: 'Internal server error',
262
+ },
263
+ });
264
+ expect(JSON.stringify(body)).not.toContain('secret');
265
+ };
266
+
267
+ const requestContextCase = async (
268
+ adapter: HttpAdapterConformanceAdapter
269
+ ): Promise<void> => {
270
+ let observedTenant: string | null | undefined;
271
+ const resolvePermit: ResolveHttpPermit = ({ headers }) => {
272
+ observedTenant = readHeader(headers, 'x-tenant-id');
273
+ return Result.ok({ id: 'user-1', scopes: ['thing:read'] });
274
+ };
275
+ const graph = topo('http-conformance-context', {
276
+ abortingTrail,
277
+ protectedTrail,
278
+ });
279
+ const app = await materialize(adapter, graph, { resolvePermit });
280
+ const controller = new AbortController();
281
+ controller.abort();
282
+
283
+ const permitResponse = await app.fetch(
284
+ buildRequest('/permit/scope', {
285
+ headers: {
286
+ Authorization: 'Bearer strong',
287
+ 'X-Request-ID': 'req-1',
288
+ 'X-Tenant-ID': 'tenant-1',
289
+ },
290
+ })
291
+ );
292
+ const abortResponse = await app.fetch(
293
+ buildRequest('/abort/check', { signal: controller.signal })
294
+ );
295
+
296
+ await expectOkResponse(permitResponse, {
297
+ permitId: 'user-1',
298
+ requestId: 'req-1',
299
+ });
300
+ expect(observedTenant).toBe('tenant-1');
301
+ await expectOkResponse(abortResponse, { aborted: true });
302
+ };
303
+
304
+ const webhookCase = async (
305
+ adapter: HttpAdapterConformanceAdapter
306
+ ): Promise<void> => {
307
+ const graph = topo('http-conformance-webhooks', { paymentWebhookTrail });
308
+ const verified = await request(adapter, graph, '/webhooks/payment', {
309
+ body: JSON.stringify({ paymentId: 'pay_1' }),
310
+ headers: {
311
+ 'Content-Type': 'application/json',
312
+ 'X-Webhook-Secret': webhookSecret,
313
+ },
314
+ method: 'POST',
315
+ });
316
+ const denied = await request(adapter, graph, '/webhooks/payment', {
317
+ body: JSON.stringify({ paymentId: 'pay_1' }),
318
+ headers: {
319
+ 'Content-Type': 'application/json',
320
+ 'X-Webhook-Secret': 'wrong',
321
+ },
322
+ method: 'POST',
323
+ });
324
+ const invalidPayload = await request(adapter, graph, '/webhooks/payment', {
325
+ body: JSON.stringify({ paymentId: 123 }),
326
+ headers: {
327
+ 'Content-Type': 'application/json',
328
+ 'X-Webhook-Secret': webhookSecret,
329
+ },
330
+ method: 'POST',
331
+ });
332
+
333
+ await expectOkResponse(verified, { paymentId: 'pay_1' });
334
+ expect(denied.status).toBe(403);
335
+ expect(await expectJson(denied)).toMatchObject({
336
+ error: { category: 'permission' },
337
+ });
338
+ expect(invalidPayload.status).toBe(400);
339
+ expect(await expectJson(invalidPayload)).toMatchObject({
340
+ error: { category: 'validation' },
341
+ });
342
+ };
343
+
344
+ export const createHttpAdapterConformanceCases =
345
+ (): readonly HttpAdapterConformanceCase[] => [
346
+ { check: readRouteCase, name: 'serves read trails from query parameters' },
347
+ { check: writeRouteCase, name: 'serves write trails from JSON bodies' },
348
+ {
349
+ check: repeatedQueryCase,
350
+ name: 'preserves repeated query keys before validation',
351
+ },
352
+ {
353
+ check: typedQueryCase,
354
+ name: 'converts schema-declared query primitives before validation',
355
+ },
356
+ {
357
+ check: publicErrorCase,
358
+ name: 'renders generic errors as redacted public 500 responses',
359
+ },
360
+ {
361
+ check: requestContextCase,
362
+ name: 'threads request context and abort signals',
363
+ },
364
+ { check: webhookCase, name: 'handles webhook verification and parsing' },
365
+ ];
366
+
367
+ export const runConformance = (
368
+ adapter: HttpAdapterConformanceAdapter,
369
+ cases: readonly HttpAdapterConformanceCase[] = createHttpAdapterConformanceCases()
370
+ ): void => {
371
+ describe(`${adapter.name} HTTP adapter conformance`, () => {
372
+ for (const conformanceCase of cases) {
373
+ test(conformanceCase.name, async () => {
374
+ await conformanceCase.check(adapter);
375
+ });
376
+ }
377
+ });
378
+ };