Skip to content

Commit a69713e

Browse files
committed
test(onerror): cover local error installer
1 parent 31b6ae2 commit a69713e

2 files changed

Lines changed: 235 additions & 5 deletions

File tree

plugins/onerror/src/lib/onerror.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export function onerror(app: any, options?: OnerrorOptions): any {
3535
debug('onerror: %s', err);
3636
if (err == null) return;
3737

38-
if (this.req) {
38+
if (typeof this.req?.resume === 'function') {
3939
this.req.resume();
4040
debug('resume the req stream');
4141
}
@@ -80,7 +80,9 @@ export function onerror(app: any, options?: OnerrorOptions): any {
8080
}
8181
this.status = err.status;
8282

83-
this.set(err.headers);
83+
if (err.headers) {
84+
this.set(err.headers);
85+
}
8486
let type: string;
8587
if (options.accepts) {
8688
type = options.accepts.call(this, 'html', 'text', 'json');
@@ -99,7 +101,7 @@ export function onerror(app: any, options?: OnerrorOptions): any {
99101
this.type = type;
100102
}
101103

102-
if (type === 'json') {
104+
if (type === 'json' && typeof this.body !== 'string') {
103105
this.body = JSON.stringify(this.body);
104106
}
105107
debug('end the response, body: %s', this.body);
@@ -120,8 +122,10 @@ function isDev(): boolean {
120122
}
121123

122124
function text(err: OnerrorError, ctx: any): void {
123-
ctx.res._headers = {};
124-
ctx.set(err.headers);
125+
clearResponseHeaders(ctx);
126+
if (err.headers) {
127+
ctx.set(err.headers);
128+
}
125129
ctx.body = (isDev() || err.expose) && err.message ? err.message : http.STATUS_CODES[ctx.status];
126130
}
127131

@@ -152,3 +156,10 @@ function escapeHtml(value: string): string {
152156
}
153157
});
154158
}
159+
160+
function clearResponseHeaders(ctx: any): void {
161+
const headers = ctx.response?.header ?? ctx.response?.headers ?? ctx.res.getHeaders?.() ?? {};
162+
for (const name of Object.keys(headers)) {
163+
ctx.res.removeHeader(name);
164+
}
165+
}
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { strict as assert } from 'node:assert';
2+
3+
import { afterEach, describe, it } from 'vitest';
4+
5+
import { onerror, type OnerrorError, type OnerrorOptions } from '../src/lib/onerror.ts';
6+
7+
interface TestContext {
8+
app: TestApp;
9+
req: { resumed: boolean; resume: () => void };
10+
res: { end: (body: unknown) => void; removeHeader: (name: string) => void; getHeaders: () => Record<string, string> };
11+
response: { header: Record<string, string> };
12+
writable: boolean;
13+
headerSent: boolean;
14+
status: number;
15+
type?: string;
16+
body?: unknown;
17+
endedBody?: unknown;
18+
removedHeaders: string[];
19+
setCalls: unknown[];
20+
accepts: () => string;
21+
set: (headers: unknown) => void;
22+
redirect: (url: string) => void;
23+
redirectedTo?: string;
24+
}
25+
26+
interface TestApp {
27+
context: { onerror?: (this: TestContext, err: unknown) => void };
28+
emitted: unknown[][];
29+
emit: (...args: unknown[]) => void;
30+
}
31+
32+
function createApp(options?: OnerrorOptions): TestApp {
33+
const app: TestApp = {
34+
context: {},
35+
emitted: [],
36+
emit(...args: unknown[]) {
37+
this.emitted.push(args);
38+
},
39+
};
40+
onerror(app, options);
41+
return app;
42+
}
43+
44+
function createContext(app: TestApp, type: string): TestContext {
45+
const headers: Record<string, string> = { 'x-old': '1' };
46+
const ctx = {
47+
app,
48+
req: {
49+
resumed: false,
50+
resume() {
51+
this.resumed = true;
52+
},
53+
},
54+
res: {
55+
end(body: unknown) {
56+
ctx.endedBody = body;
57+
},
58+
removeHeader(name: string) {
59+
ctx.removedHeaders.push(name);
60+
delete headers[name];
61+
},
62+
getHeaders() {
63+
return headers;
64+
},
65+
},
66+
response: { header: headers },
67+
writable: true,
68+
headerSent: false,
69+
status: 200,
70+
removedHeaders: [] as string[],
71+
setCalls: [] as unknown[],
72+
accepts() {
73+
return type;
74+
},
75+
set(value: unknown) {
76+
ctx.setCalls.push(value);
77+
},
78+
redirect(url: string) {
79+
ctx.redirectedTo = url;
80+
},
81+
} as TestContext;
82+
return ctx;
83+
}
84+
85+
function callOnerror(app: TestApp, ctx: TestContext, err: unknown): void {
86+
assert(app.context.onerror);
87+
app.context.onerror.call(ctx, err);
88+
}
89+
90+
function makeError(status: number, message = 'boom', extra?: Partial<OnerrorError>): OnerrorError {
91+
return Object.assign(new Error(message), { status }, extra) as OnerrorError;
92+
}
93+
94+
describe('lib/onerror.ts', () => {
95+
const originalNodeEnv = process.env.NODE_ENV;
96+
97+
afterEach(() => {
98+
process.env.NODE_ENV = originalNodeEnv;
99+
});
100+
101+
it('drains the request, emits the error, clears text headers, and reapplies error headers', () => {
102+
const app = createApp();
103+
const ctx = createContext(app, 'text');
104+
const err = makeError(418, 'teapot', { expose: true, headers: { 'x-new': '2' } });
105+
106+
callOnerror(app, ctx, err);
107+
108+
assert.equal(ctx.req.resumed, true);
109+
assert.equal(ctx.status, 418);
110+
assert.equal(ctx.body, 'teapot');
111+
assert.equal(ctx.endedBody, 'teapot');
112+
assert.equal(ctx.type, 'text');
113+
assert.deepEqual(ctx.removedHeaders, ['x-old']);
114+
assert.deepEqual(ctx.setCalls, [{ 'x-new': '2' }, { 'x-new': '2' }]);
115+
assert.equal(app.emitted[0][0], 'error');
116+
assert.equal(app.emitted[0][1], err);
117+
});
118+
119+
it('does not pass undefined headers into ctx.set', () => {
120+
const app = createApp();
121+
const ctx = createContext(app, 'json');
122+
123+
callOnerror(app, ctx, makeError(500, 'boom', { expose: true }));
124+
125+
assert.deepEqual(ctx.setCalls, []);
126+
assert.equal(ctx.body, '{"error":"boom"}');
127+
assert.equal(ctx.endedBody, '{"error":"boom"}');
128+
});
129+
130+
it('escapes default html responses', () => {
131+
const app = createApp();
132+
const ctx = createContext(app, 'html');
133+
134+
callOnerror(app, ctx, makeError(400, `&<>"'`, { expose: true }));
135+
136+
assert.equal(ctx.type, 'html');
137+
assert.equal(ctx.body, '<h2>400 &amp;&lt;&gt;&quot;&#39;</h2>');
138+
assert.equal(ctx.endedBody, '<h2>400 &amp;&lt;&gt;&quot;&#39;</h2>');
139+
});
140+
141+
it('does not double stringify custom json string bodies', () => {
142+
const app = createApp({
143+
json(_err, ctx) {
144+
ctx.body = '{"ok":true}';
145+
},
146+
});
147+
const ctx = createContext(app, 'json');
148+
149+
callOnerror(app, ctx, makeError(500));
150+
151+
assert.equal(ctx.body, '{"ok":true}');
152+
assert.equal(ctx.endedBody, '{"ok":true}');
153+
});
154+
155+
it('wraps non-error throws and normalizes invalid status to 500', () => {
156+
const app = createApp();
157+
const ctx = createContext(app, 'json');
158+
159+
callOnerror(app, ctx, { message: 'bad', status: 1 });
160+
161+
assert.equal(ctx.status, 500);
162+
assert(app.emitted[0][1] instanceof Error);
163+
assert.equal((app.emitted[0][1] as Error).message, 'bad');
164+
});
165+
166+
it('formats circular non-error throws when JSON.stringify fails', () => {
167+
const app = createApp();
168+
const ctx = createContext(app, 'json');
169+
const circular: Record<string, unknown> = { status: 400 };
170+
circular.self = circular;
171+
172+
callOnerror(app, ctx, circular);
173+
174+
assert(app.emitted[0][1] instanceof Error);
175+
assert.match((app.emitted[0][1] as Error).message, /^non-error thrown: TypeError:/);
176+
});
177+
178+
it('supports custom accepts and all handlers', () => {
179+
const app = createApp({
180+
accepts() {
181+
return 'html';
182+
},
183+
all(err, ctx) {
184+
ctx.body = `all:${err.status}`;
185+
},
186+
});
187+
const ctx = createContext(app, 'json');
188+
const err = makeError(451, 'blocked', { headers: { 'x-reason': 'legal' } });
189+
190+
callOnerror(app, ctx, err);
191+
192+
assert.equal(ctx.body, 'all:451');
193+
assert.equal(ctx.endedBody, 'all:451');
194+
assert.deepEqual(ctx.setCalls, [{ 'x-reason': 'legal' }]);
195+
});
196+
197+
it('redirects non-json responses when configured', () => {
198+
const app = createApp({ redirect: '/error-page' });
199+
const ctx = createContext(app, 'html');
200+
201+
callOnerror(app, ctx, makeError(500));
202+
203+
assert.equal(ctx.redirectedTo, '/error-page');
204+
assert.equal(ctx.endedBody, undefined);
205+
});
206+
207+
it('only emits when headers were already sent', () => {
208+
const app = createApp();
209+
const ctx = createContext(app, 'text');
210+
ctx.headerSent = true;
211+
const err = makeError(500);
212+
213+
callOnerror(app, ctx, err);
214+
215+
assert.equal((err as OnerrorError & { headerSent?: boolean }).headerSent, true);
216+
assert.equal(app.emitted.length, 1);
217+
assert.equal(ctx.endedBody, undefined);
218+
});
219+
});

0 commit comments

Comments
 (0)