Skip to content

Commit 6176ee9

Browse files
authored
test(server): plugins (#220)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Refactor** - Streamlined server configuration options for improved consistency and reliability. - Enhanced CORS settings to enforce stricter, immutable configurations ensuring proper header behavior. - **Tests** - Added comprehensive test coverage to validate plugin integrations, including composite handling, CORS responses, and custom header modifications. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 6c5bfe4 commit 6176ee9

File tree

5 files changed

+263
-9
lines changed

5 files changed

+263
-9
lines changed

packages/server/src/adapters/standard/handler.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ export type StandardHandleOptions<T extends Context> =
1515
& { prefix?: HTTPPath }
1616
& (Record<never, never> extends T ? { context?: T } : { context: T })
1717

18-
export type WellStandardHandleOptions<T extends Context> = StandardHandleOptions<T> & { context: T }
19-
2018
export type StandardHandleResult = { matched: true, response: StandardResponse } | { matched: false, response: undefined }
2119

22-
export type StandardHandlerInterceptorOptions<TContext extends Context> = WellStandardHandleOptions<TContext> & { request: StandardLazyRequest }
20+
export type StandardHandlerInterceptorOptions<T extends Context> =
21+
& StandardHandleOptions<T>
22+
& { context: T, request: StandardLazyRequest }
2323

2424
export interface StandardHandlerOptions<TContext extends Context> {
2525
plugins?: HandlerPlugin<TContext>[]
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { CompositePlugin } from './base'
2+
3+
it('compositePlugin', () => {
4+
const plugin1 = { init: vi.fn() }
5+
const plugin2 = {}
6+
const plugin3 = { init: vi.fn() }
7+
8+
const composite = new CompositePlugin([plugin1, plugin2, plugin3])
9+
10+
composite.init('__OPTIONS__' as any)
11+
12+
expect(plugin1.init).toHaveBeenCalledTimes(1)
13+
expect(plugin1.init).toHaveBeenCalledWith('__OPTIONS__')
14+
expect(plugin3.init).toHaveBeenCalledTimes(1)
15+
expect(plugin3.init).toHaveBeenCalledWith('__OPTIONS__')
16+
})
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { OpenAPIHandler } from '../../../openapi/src/adapters/fetch/openapi-handler'
2+
import { os } from '../builder'
3+
import { CORSPlugin } from './cors'
4+
5+
function assertResponse(response: Response | undefined): asserts response is Response {
6+
if (!response) {
7+
throw new Error('response is undefined')
8+
}
9+
}
10+
11+
beforeEach(() => {
12+
vi.clearAllMocks()
13+
})
14+
15+
describe('corsPlugin', () => {
16+
const handlerFn = vi.fn(() => 'pong')
17+
const router = os
18+
.route({
19+
method: 'GET',
20+
path: '/ping',
21+
})
22+
.handler(handlerFn)
23+
24+
it('handles OPTIONS request with default options', async () => {
25+
const handler = new OpenAPIHandler(router, {
26+
plugins: [new CORSPlugin()],
27+
})
28+
29+
const { response } = await handler.handle(new Request('https://example.com', {
30+
method: 'OPTIONS',
31+
headers: {
32+
origin: 'https://example.com',
33+
},
34+
}))
35+
36+
assertResponse(response)
37+
expect(handlerFn).toHaveBeenCalledTimes(0)
38+
expect(response.status).toBe(204)
39+
expect(response.headers.get('access-control-allow-origin')).toBe('https://example.com')
40+
expect(response.headers.get('vary')).toBe('origin')
41+
expect(response.headers.get('access-control-allow-methods')).toBe('GET,HEAD,PUT,POST,DELETE,PATCH')
42+
expect(response.headers.get('access-control-max-age')).toBeNull()
43+
})
44+
45+
it('handles GET request and sets CORS headers with default origin function', async () => {
46+
const handler = new OpenAPIHandler(router, {
47+
plugins: [new CORSPlugin()],
48+
})
49+
50+
const { response } = await handler.handle(new Request('https://example.com/ping', {
51+
headers: {
52+
origin: 'https://example.com',
53+
},
54+
}))
55+
56+
assertResponse(response)
57+
expect(handlerFn).toHaveBeenCalledTimes(1)
58+
expect(response.headers.get('access-control-allow-origin')).toBe('https://example.com')
59+
expect(response.headers.get('vary')).toBe('origin')
60+
})
61+
62+
it('applies maxAge and allowHeaders on OPTIONS requests when specified', async () => {
63+
const plugin = new CORSPlugin({
64+
maxAge: 600,
65+
allowHeaders: ['Content-Type', 'Authorization'],
66+
})
67+
68+
const handler = new OpenAPIHandler(router, {
69+
plugins: [plugin],
70+
})
71+
72+
const { response } = await handler.handle(new Request('https://example.com/test', {
73+
method: 'OPTIONS',
74+
headers: {
75+
origin: 'https://example.com',
76+
},
77+
}))
78+
79+
assertResponse(response)
80+
expect(response.headers.get('access-control-max-age')).toBe('600')
81+
expect(response.headers.get('access-control-allow-methods')).toBe('GET,HEAD,PUT,POST,DELETE,PATCH')
82+
expect(response.headers.get('access-control-allow-headers')).toBe('Content-Type,Authorization')
83+
})
84+
85+
it('sets allowed origin only when custom origin function approves', async () => {
86+
// Custom origin function: only allow 'https://allowed.com'
87+
const customOrigin = (origin: string) => origin === 'https://allowed.com' ? origin : ''
88+
const router = os
89+
.route({
90+
method: 'GET',
91+
path: '/custom',
92+
})
93+
.handler(() => 'ok')
94+
95+
const plugin = new CORSPlugin({ origin: customOrigin })
96+
const handler = new OpenAPIHandler(router, {
97+
plugins: [plugin],
98+
})
99+
100+
// Request from allowed origin
101+
const { response } = await handler.handle(new Request('https://example.com/custom', {
102+
headers: {
103+
origin: 'https://allowed.com',
104+
},
105+
}))
106+
assertResponse(response)
107+
expect(response.headers.get('access-control-allow-origin')).toBe('https://allowed.com')
108+
109+
// Request from a disallowed origin should not get the header set
110+
const { response: response2 } = await handler.handle(new Request('https://example.com/custom', {
111+
headers: {
112+
origin: 'https://disallowed.com',
113+
},
114+
}))
115+
assertResponse(response2)
116+
expect(response2.headers.get('access-control-allow-origin')).toBeNull()
117+
})
118+
119+
it('handles timingOrigin option correctly', async () => {
120+
// Custom timingOrigin: only allow 'https://timing.com'
121+
const customTimingOrigin = (origin: string) => origin === 'https://timing.com' ? origin : ''
122+
const router = os
123+
.route({
124+
method: 'GET',
125+
path: '/timing',
126+
})
127+
.handler(() => 'ok')
128+
129+
const plugin = new CORSPlugin({ timingOrigin: customTimingOrigin })
130+
const handler = new OpenAPIHandler(router, {
131+
plugins: [plugin],
132+
})
133+
134+
// Request with allowed timing origin
135+
const { response } = await handler.handle(new Request('https://example.com/timing', {
136+
headers: {
137+
origin: 'https://timing.com',
138+
},
139+
}))
140+
assertResponse(response)
141+
expect(response.headers.get('timing-allow-origin')).toBe('https://timing.com')
142+
143+
// Request with not allowed timing origin should not have the header
144+
const { response: response2 } = await handler.handle(new Request('https://example.com/timing', {
145+
headers: {
146+
origin: 'https://not-timing.com',
147+
},
148+
}))
149+
assertResponse(response2)
150+
expect(response2.headers.get('timing-allow-origin')).toBeNull()
151+
})
152+
153+
it('sets credentials and exposeHeaders when specified in options', async () => {
154+
const plugin = new CORSPlugin({
155+
credentials: true,
156+
exposeHeaders: ['X-Custom-Header', 'X-Another-Header'],
157+
})
158+
159+
const handler = new OpenAPIHandler(router, {
160+
plugins: [plugin],
161+
})
162+
163+
const { response } = await handler.handle(new Request('https://example.com/ping', {
164+
headers: {
165+
origin: 'https://example.com',
166+
},
167+
}))
168+
assertResponse(response)
169+
expect(response.headers.get('access-control-allow-credentials')).toBe('true')
170+
expect(response.headers.get('access-control-expose-headers')).toBe('X-Custom-Header,X-Another-Header')
171+
})
172+
173+
it('returns "*" for access-control-allow-origin when origin function returns "*"', async () => {
174+
const plugin = new CORSPlugin({ origin: () => '*' })
175+
const handler = new OpenAPIHandler(router, {
176+
plugins: [plugin],
177+
})
178+
179+
const { response } = await handler.handle(new Request('https://example.com/ping', {
180+
headers: {
181+
origin: 'https://any-origin.com',
182+
},
183+
}))
184+
assertResponse(response)
185+
expect(response.headers.get('access-control-allow-origin')).toBe('*')
186+
expect(response.headers.get('vary')).toBeNull()
187+
})
188+
})

packages/server/src/plugins/cors.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,19 @@ import type { HandlerPlugin } from './base'
55
import { value, type Value } from '@orpc/shared'
66

77
export interface CORSOptions<TContext extends Context> {
8-
origin?: Value<string | string[] | null | undefined, [origin: string, options: StandardHandlerInterceptorOptions<TContext>]>
9-
timingOrigin?: Value<string | string[] | null | undefined, [origin: string, options: StandardHandlerInterceptorOptions<TContext>]>
10-
allowMethods?: string[]
11-
allowHeaders?: string[]
8+
origin?: Value<string | readonly string[] | null | undefined, [origin: string, options: StandardHandlerInterceptorOptions<TContext>]>
9+
timingOrigin?: Value<string | readonly string[] | null | undefined, [origin: string, options: StandardHandlerInterceptorOptions<TContext>]>
10+
allowMethods?: readonly string[]
11+
allowHeaders?: readonly string[]
1212
maxAge?: number
1313
credentials?: boolean
14-
exposeHeaders?: string[]
14+
exposeHeaders?: readonly string[]
1515
}
1616

1717
export class CORSPlugin<TContext extends Context> implements HandlerPlugin<TContext> {
1818
private readonly options: CORSOptions<TContext>
1919

20-
constructor(options?: Partial<CORSOptions<TContext>>) {
20+
constructor(options: CORSOptions<TContext> = {}) {
2121
const defaults: CORSOptions<TContext> = {
2222
origin: origin => origin,
2323
allowMethods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH'],
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { ResponseHeadersPluginContext } from './response-headers'
2+
import { OpenAPIHandler } from '../../../openapi/src/adapters/fetch/openapi-handler'
3+
import { os } from '../builder'
4+
import { ResponseHeadersPlugin } from './response-headers'
5+
6+
it('responseHeadersPlugin', async () => {
7+
const router = os
8+
.$context<ResponseHeadersPluginContext>()
9+
.use(({ context, next }) => {
10+
context.resHeaders?.set('x-custom-1', 'mid')
11+
context.resHeaders?.set('x-custom-2', 'mid')
12+
context.resHeaders?.set('x-custom-3', 'mid')
13+
context.resHeaders?.set('x-custom-4', 'mid')
14+
15+
return next()
16+
})
17+
.route({
18+
method: 'GET',
19+
path: '/ping',
20+
outputStructure: 'detailed',
21+
})
22+
.handler(() => {
23+
return {
24+
headers: {
25+
'x-custom-1': 'value',
26+
'x-custom-2': ['1', '2'],
27+
'x-custom-3': undefined,
28+
},
29+
}
30+
})
31+
32+
const handler = new OpenAPIHandler(router, {
33+
plugins: [
34+
new ResponseHeadersPlugin(),
35+
],
36+
})
37+
38+
const { response } = await handler.handle(new Request('https://example.com/ping'))
39+
40+
if (!response) {
41+
throw new Error('response is undefined')
42+
}
43+
44+
expect(response.headers.get('x-custom-1')).toBe('value, mid')
45+
expect(response.headers.get('x-custom-2')).toBe('1, 2, mid')
46+
expect(response.headers.get('x-custom-3')).toBe('mid')
47+
expect(response.headers.get('x-custom-4')).toBe('mid')
48+
49+
await handler.handle(new Request('https://example.com/not_found'))
50+
})

0 commit comments

Comments
 (0)