Skip to content

Commit 9f0cb9a

Browse files
committed
test(error): assert problem+json error responses
- Add Handler problemDetails and safeReasons coverage - Sort Error and Handler test cases A-Z by name - Update JSON error assertions to problem+json title, status, instance
1 parent f260821 commit 9f0cb9a

4 files changed

Lines changed: 143 additions & 45 deletions

File tree

tests/core/API.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Deno.test('Handler produces a genuine Response when globalThis.Response is patch
4848
assertEquals(html.headers.get('content-type'), 'text/html; charset=utf-8')
4949
assertEquals(json instanceof realResponse, true)
5050
assertEquals(json.status, 404)
51-
assertEquals(json.headers.get('content-type'), 'application/json')
51+
assertEquals(json.headers.get('content-type'), 'application/problem+json')
5252
} finally {
5353
;(globalThis as unknown as { Response: unknown }).Response = realResponse
5454
}

tests/core/Error.test.ts

Lines changed: 64 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ Deno.test('Error#buildResponse 500 does not leak error message', async () => {
1212
new globalThis.Error('Connection to ************************ failed'),
1313
null
1414
)
15-
const body = (await res.json()) as { error: string }
16-
assertEquals(body.error, 'Internal Server Error')
15+
const body = (await res.json()) as { title: string }
16+
assertEquals(body.title, 'Internal Server Error')
1717
assertEquals(JSON.stringify(body).includes('password'), false)
1818
})
1919

@@ -40,9 +40,9 @@ Deno.test('Error#buildResponse preserves security headers when errorMiddleware r
4040
)
4141
assertEquals(res.status, 500)
4242
assertEquals(res.headers.get('X-Content-Type-Options'), 'nosniff')
43-
assertEquals(res.headers.get('Content-Type'), 'application/json')
44-
const body = (await res.json()) as { error: string }
45-
assertEquals(body.error, 'Internal Server Error')
43+
assertEquals(res.headers.get('Content-Type'), 'application/problem+json')
44+
const body = (await res.json()) as { title: string }
45+
assertEquals(body.title, 'Internal Server Error')
4646
})
4747

4848
Deno.test('Error#buildResponse uses generic message for unmapped 418 status', async () => {
@@ -56,9 +56,9 @@ Deno.test('Error#buildResponse uses generic message for unmapped 418 status', as
5656
new globalThis.Error('secret internal detail'),
5757
null
5858
)
59-
const body = (await res.json()) as { error: string; statusCode: number }
60-
assertEquals(body.error, 'Bad Request')
61-
assertEquals(body.statusCode, 418)
59+
const body = (await res.json()) as { title: string; status: number }
60+
assertEquals(body.title, 'Bad Request')
61+
assertEquals(body.status, 418)
6262
})
6363

6464
Deno.test('Error#buildResponse uses generic message for unmapped 599 status', async () => {
@@ -72,8 +72,8 @@ Deno.test('Error#buildResponse uses generic message for unmapped 599 status', as
7272
new globalThis.Error('db password leaked'),
7373
null
7474
)
75-
const body = (await res.json()) as { error: string }
76-
assertEquals(body.error, 'Internal Server Error')
75+
const body = (await res.json()) as { title: string }
76+
assertEquals(body.title, 'Internal Server Error')
7777
})
7878

7979
Deno.test('Error#buildResponse with errorMiddleware receives correct error info', async () => {
@@ -114,8 +114,8 @@ Deno.test('Error#buildResponse with errorMiddleware returning null uses default'
114114
async () => null
115115
)
116116
assertEquals(res.status, 502)
117-
const body = (await res.json()) as { error: string }
118-
assertEquals(body.error, 'Bad Gateway')
117+
const body = (await res.json()) as { title: string }
118+
assertEquals(body.title, 'Bad Gateway')
119119
})
120120

121121
Deno.test('Error#buildResponse with errorMiddleware returning response uses it', async () => {
@@ -147,9 +147,9 @@ Deno.test('Error#buildResponse with errorMiddleware returns non-Response falls t
147147
async () => ret as never
148148
)
149149
assertEquals(res.status, 500)
150-
assertEquals(res.headers.get('Content-Type'), 'application/json')
151-
const body = (await res.json()) as { error: string }
152-
assertEquals(body.error, 'Internal Server Error')
150+
assertEquals(res.headers.get('Content-Type'), 'application/problem+json')
151+
const body = (await res.json()) as { title: string }
152+
assertEquals(body.title, 'Internal Server Error')
153153
}
154154
})
155155

@@ -175,8 +175,8 @@ Deno.test('Error#buildResponse with sync errorMiddleware returning null', async
175175
const ctx = new Core.Context(request, new URL('http://localhost/'), {})
176176
const res = await Core.Handler.buildResponse(ctx, 500, new globalThis.Error('fail'), () => null)
177177
assertEquals(res.status, 500)
178-
const body = (await res.json()) as { error: string }
179-
assertEquals(body.error, 'Internal Server Error')
178+
const body = (await res.json()) as { title: string }
179+
assertEquals(body.title, 'Internal Server Error')
180180
})
181181

182182
Deno.test('Error#buildResponse without errorMiddleware returns JSON when Accept json', async () => {
@@ -186,11 +186,11 @@ Deno.test('Error#buildResponse without errorMiddleware returns JSON when Accept
186186
const ctx = new Core.Context(request, new URL('http://localhost/foo'), {})
187187
const res = await Core.Handler.buildResponse(ctx, 404, new globalThis.Error('gone'), null)
188188
assertEquals(res.status, 404)
189-
assertEquals(res.headers.get('Content-Type'), 'application/json')
190-
const body = (await res.json()) as { error: string; path: string; statusCode: number }
191-
assertEquals(body.error, 'Not Found')
192-
assertEquals(body.path, '/foo')
193-
assertEquals(body.statusCode, 404)
189+
assertEquals(res.headers.get('Content-Type'), 'application/problem+json')
190+
const body = (await res.json()) as { title: string; instance: string; status: number }
191+
assertEquals(body.title, 'Not Found')
192+
assertEquals(body.instance, '/foo')
193+
assertEquals(body.status, 404)
194194
})
195195

196196
Deno.test('Error#defaultErrorHtml escapes quotes and apostrophes in message', () => {
@@ -229,10 +229,10 @@ Deno.test('Error#errorResponse falls back to a hardened JSON response when heade
229229
;(ctx as unknown as { responseHeaders: Record<string, string> }).responseHeaders['Inva lid'] = 'x'
230230
const res = Core.Handler.errorResponse(ctx, 500)
231231
assertEquals(res.status, 500)
232-
assertEquals(res.headers.get('Content-Type'), 'application/json')
232+
assertEquals(res.headers.get('Content-Type'), 'application/problem+json')
233233
assertEquals(res.headers.get('X-Content-Type-Options'), 'nosniff')
234-
const body = (await res.json()) as { error: string }
235-
assertEquals(body.error, 'Internal Server Error')
234+
const body = (await res.json()) as { title: string }
235+
assertEquals(body.title, 'Internal Server Error')
236236
})
237237

238238
Deno.test('Error#escapeHtml escapes &, <, >, ", \'', () => {
@@ -306,3 +306,42 @@ Deno.test('Error#extractError wraps a non-error value as 500', () => {
306306
assertEquals(result.error instanceof globalThis.Error, true)
307307
assertEquals(result.error.message, 'plain string failure')
308308
})
309+
310+
Deno.test('Error#handleError omits errors when error has no structured cause', async () => {
311+
const request = new Request('http://localhost/x', {
312+
headers: new Headers({ Accept: 'application/json' })
313+
})
314+
const ctx = new Core.Context(request, new URL('http://localhost/x'), {})
315+
const res = await ctx.handleError(500, new globalThis.Error('boom'))
316+
assertEquals(res.status, 500)
317+
const body = (await res.json()) as { title: string; errors?: string[] }
318+
assertEquals(body.title, 'Internal Server Error')
319+
assertEquals('errors' in body, false)
320+
})
321+
322+
Deno.test('Error#handleError surfaces validation reasons as problem+json errors', async () => {
323+
const request = new Request('http://localhost/users', {
324+
headers: new Headers({ Accept: 'application/json' })
325+
})
326+
const ctx = new Core.Context(request, new URL('http://localhost/users'), {})
327+
const validationError = Core.Handler.createStatusError(422, 'name must not be empty')
328+
Object.defineProperty(validationError, 'cause', {
329+
value: ['name must not be empty', 'email must contain @'],
330+
enumerable: false
331+
})
332+
const res = await ctx.handleError(422, validationError)
333+
assertEquals(res.status, 422)
334+
assertEquals(res.headers.get('Content-Type'), 'application/problem+json')
335+
const body = (await res.json()) as {
336+
type: string
337+
title: string
338+
status: number
339+
instance: string
340+
errors: string[]
341+
}
342+
assertEquals(body.type, 'about:blank')
343+
assertEquals(body.title, 'Unprocessable Entity')
344+
assertEquals(body.status, 422)
345+
assertEquals(body.instance, '/users')
346+
assertEquals(body.errors, ['name must not be empty', 'email must contain @'])
347+
})

tests/core/Handler.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,35 @@ Deno.test('Handler#isDirectory returns true for an existing directory', () => {
5858
assertEquals(Core.Handler.isDirectory(dirPath), true)
5959
})
6060

61+
Deno.test('Handler#problemDetails builds an RFC 9457 body with instance', () => {
62+
const body = Core.Handler.problemDetails(422, '/users')
63+
assertEquals(body.type, 'about:blank')
64+
assertEquals(body.title, 'Unprocessable Entity')
65+
assertEquals(body.status, 422)
66+
assertEquals(body.instance, '/users')
67+
})
68+
69+
Deno.test('Handler#problemDetails includes errors extension when reasons exist', () => {
70+
const body = Core.Handler.problemDetails(422, '/users', undefined, [
71+
'name must not be empty',
72+
'email must contain @'
73+
])
74+
assertEquals(body.status, 422)
75+
assertEquals(body.errors, ['name must not be empty', 'email must contain @'])
76+
})
77+
78+
Deno.test('Handler#problemDetails omits errors when reasons are empty', () => {
79+
const body = Core.Handler.problemDetails(422, '/users', undefined, [])
80+
assertEquals('errors' in body, false)
81+
})
82+
83+
Deno.test('Handler#problemDetails omits instance when pathname is undefined', () => {
84+
const body = Core.Handler.problemDetails(500)
85+
assertEquals(body.title, 'Internal Server Error')
86+
assertEquals(body.status, 500)
87+
assertEquals('instance' in body, false)
88+
})
89+
6190
Deno.test('Handler#safeMessage falls back to Bad Request for an unmapped 4xx status', () => {
6291
assertEquals(Core.Handler.safeMessage(418), 'Bad Request')
6392
})
@@ -71,6 +100,31 @@ Deno.test('Handler#safeMessage returns the known message for a mapped status', (
71100
assertEquals(Core.Handler.safeMessage(500), 'Internal Server Error')
72101
})
73102

103+
Deno.test('Handler#safeReasons does not leak cause from non-validation errors', () => {
104+
const leaky = new Error('db failed', { cause: ['SECRET host=10.0.0.5', 'password'] })
105+
assertEquals(Core.Handler.safeReasons(leaky), undefined)
106+
})
107+
108+
Deno.test('Handler#safeReasons extracts string-array cause from a 422 status error', () => {
109+
const error = Core.Handler.createStatusError(422, 'invalid')
110+
Object.defineProperty(error, 'cause', { value: ['a', 'b'], enumerable: false })
111+
assertEquals(Core.Handler.safeReasons(error), ['a', 'b'])
112+
})
113+
114+
Deno.test('Handler#safeReasons ignores cause on non-422 status errors', () => {
115+
const error = Core.Handler.createStatusError(400, 'bad')
116+
Object.defineProperty(error, 'cause', { value: ['leak'], enumerable: false })
117+
assertEquals(Core.Handler.safeReasons(error), undefined)
118+
})
119+
120+
Deno.test('Handler#safeReasons returns undefined for null or non-array cause', () => {
121+
assertEquals(Core.Handler.safeReasons(null), undefined)
122+
assertEquals(Core.Handler.safeReasons(new Error('x')), undefined)
123+
const plain = Core.Handler.createStatusError(422, 'x')
124+
Object.defineProperty(plain, 'cause', { value: 'plain', enumerable: false })
125+
assertEquals(Core.Handler.safeReasons(plain), undefined)
126+
})
127+
74128
Deno.test('Handler#stateKey returns the raw key value', () => {
75129
const key: Types.StateKey<number> = Core.Handler.stateKey<number>('custom')
76130
assertEquals(key, 'custom')
@@ -97,3 +151,8 @@ Deno.test('Handler#wantsJson is true when Accept includes application/json', ()
97151
const headers = new Headers({ Accept: 'application/json, text/plain' })
98152
assertEquals(Core.Handler.wantsJson(headers), true)
99153
})
154+
155+
Deno.test('Handler#wantsJson is true when Accept includes application/problem+json', () => {
156+
const headers = new Headers({ Accept: 'application/problem+json' })
157+
assertEquals(Core.Handler.wantsJson(headers), true)
158+
})

tests/routing/Handler.test.ts

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ Deno.test('Handler 404 for unmatched route returns JSON when Accept json', async
2929
})
3030
)
3131
assertEquals(res.status, 404)
32-
assertEquals(res.headers.get('Content-Type'), 'application/json')
33-
const body = (await res.json()) as { error: string }
34-
assertEquals(body.error, 'Not Found')
32+
assertEquals(res.headers.get('Content-Type'), 'application/problem+json')
33+
const body = (await res.json()) as { title: string }
34+
assertEquals(body.title, 'Not Found')
3535
})
3636

3737
Deno.test('Handler 405 Allow advertises HEAD for a GET-only route (RFC 7231 §4.3.2)', async () => {
@@ -448,9 +448,9 @@ Deno.test('Handler masks a non-Response return as JSON Internal Server Error', a
448448
})
449449
const res = await handler.createHandler()(req)
450450
assertEquals(res.status, 500)
451-
assertEquals(res.headers.get('Content-Type'), 'application/json')
452-
const body = (await res.json()) as { error: string }
453-
assertEquals(body.error, 'Internal Server Error')
451+
assertEquals(res.headers.get('Content-Type'), 'application/problem+json')
452+
const body = (await res.json()) as { title: string }
453+
assertEquals(body.title, 'Internal Server Error')
454454
})
455455

456456
Deno.test('Handler maxRouteParamLength returns 414 when exceeded', async () => {
@@ -526,8 +526,8 @@ Deno.test('Handler maxUrlLength 414 returns JSON when Accept json', async () =>
526526
})
527527
)
528528
assertEquals(res.status, 414)
529-
const body = (await res.json()) as { error: string }
530-
assertEquals(body.error, 'URI Too Long')
529+
const body = (await res.json()) as { title: string }
530+
assertEquals(body.title, 'URI Too Long')
531531
})
532532

533533
Deno.test('Handler maxUrlLength returns 414 when exceeded', async () => {
@@ -1173,9 +1173,9 @@ Deno.test('Handler#handleResponse when errorMiddleware returns non-Response fall
11731173
})
11741174
const res = await handler.handleResponse(ctx, 500, new Error('boom'))
11751175
assertEquals(res.status, 500)
1176-
const body = (await res.json()) as { error: string; path: string; statusCode: number }
1177-
assertEquals(body.statusCode, 500)
1178-
assertEquals(body.path, '/oops')
1176+
const body = (await res.json()) as { title: string; instance: string; status: number }
1177+
assertEquals(body.status, 500)
1178+
assertEquals(body.instance, '/oops')
11791179
})
11801180

11811181
Deno.test('Handler#handleResponse when errorMiddleware returns null uses default', async () => {
@@ -1186,9 +1186,9 @@ Deno.test('Handler#handleResponse when errorMiddleware returns null uses default
11861186
})
11871187
const res = await handler.handleResponse(ctx, 404, new Error('Not found'))
11881188
assertEquals(res.status, 404)
1189-
const body = (await res.json()) as { error: string; path: string; statusCode: number }
1190-
assertEquals(body.statusCode, 404)
1191-
assertEquals(body.path, '/bar')
1189+
const body = (await res.json()) as { title: string; instance: string; status: number }
1190+
assertEquals(body.status, 404)
1191+
assertEquals(body.instance, '/bar')
11921192
})
11931193

11941194
Deno.test('Handler#handleResponse with Accept application/json returns JSON', async () => {
@@ -1198,11 +1198,11 @@ Deno.test('Handler#handleResponse with Accept application/json returns JSON', as
11981198
})
11991199
const res = await handler.handleResponse(ctx, 404, new Error('Not found'))
12001200
assertEquals(res.status, 404)
1201-
assertEquals(res.headers.get('Content-Type'), 'application/json')
1202-
const responseBody = (await res.json()) as { error: string; path: string; statusCode: number }
1203-
assertEquals(responseBody.error, 'Not Found')
1204-
assertEquals(responseBody.path, '/foo')
1205-
assertEquals(responseBody.statusCode, 404)
1201+
assertEquals(res.headers.get('Content-Type'), 'application/problem+json')
1202+
const responseBody = (await res.json()) as { title: string; instance: string; status: number }
1203+
assertEquals(responseBody.title, 'Not Found')
1204+
assertEquals(responseBody.instance, '/foo')
1205+
assertEquals(responseBody.status, 404)
12061206
})
12071207

12081208
Deno.test('Handler#removeRoute for non-existent pattern does not throw', () => {

0 commit comments

Comments
 (0)