Skip to content

Commit 2b81d89

Browse files
authored
fix(nest): run method-level guards on routes synthesized by router-contract @implement (#1775)
Method-level `@UseGuards` (and `@UsePipes`, `@UseFilters`, `@SetMetadata`) on a handler decorated with a router-contract `@Implement` were silently ignored: Nest stores that metadata on the decorated function object, but `@Implement` registers freshly synthesized functions as the actual routes, so the guards never ran. Routes that looked protected were publicly reachable. Each synthesized route function now inherits from the original method via its prototype chain, so Nest resolves the metadata from the registered callback in either decorator order. ## Fixes - Method-level guards, pipes, filters, and `@SetMetadata` now execute on every route synthesized from a router contract, whether the decorator is placed above or below `@Implement`. An eager metadata copy could not achieve this: decorators evaluate bottom-up, so anything written above `@Implement` attaches its metadata after `@Implement` has already run. - User-supplied method-level `@UseInterceptors` is merged ahead of oRPC's own interceptor when placed below `@Implement`. Placing it above still loses it — an inherent limit of Nest's array-metadata scheme, since the synthesized route must define its own interceptor entry. - Single-procedure `@Implement`, class-level enhancers, and global providers were unaffected and remain so. ## Testing - New end-to-end tests boot a Nest app with a `CanActivate` guard on the router method in both decorator orderings: denied requests get 403 with the procedure handler never invoked, allowed requests succeed. All 4 new tests fail against the previous code (unauthenticated requests returned 200). - Full `@orpc/nest` suite passes (29 tests).
1 parent e5e7ee7 commit 2b81d89

2 files changed

Lines changed: 84 additions & 1 deletion

File tree

packages/nest/src/implement.test.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
import type { CanActivate, ExecutionContext } from '@nestjs/common'
12
import type { NodeHttpRequest } from '@orpc/standard-server-node'
23
import type { Request } from 'express'
34
import type { FastifyReply } from 'fastify'
45
import FastifyCookie from '@fastify/cookie'
56
import { HonoAdapter } from '@mnigos/platform-hono'
6-
import { Controller, Req, Res } from '@nestjs/common'
7+
import { Controller, Req, Res, UseGuards } from '@nestjs/common'
78
import { REQUEST } from '@nestjs/core'
89
import { FastifyAdapter } from '@nestjs/platform-fastify'
910
import { Test } from '@nestjs/testing'
@@ -244,6 +245,86 @@ describe('@Implement', async () => {
244245
expect(Reflect.getMetadata('orpc:meta', controller, 'router_nested_peng_0')).toEqual({ path: '/advanced' })
245246
})
246247

248+
describe('method-level guards on synthesized router methods', () => {
249+
const can_activate = vi.fn((ctx: ExecutionContext) => {
250+
const request: NodeHttpRequest = ctx.switchToHttp().getRequest()
251+
return request.headers?.['x-allow'] === 'yes'
252+
})
253+
254+
class TestGuard implements CanActivate {
255+
canActivate(ctx: ExecutionContext): boolean {
256+
return can_activate(ctx)
257+
}
258+
}
259+
260+
const router_impl = () => ({
261+
ping: implement(contract.ping).handler(ping_handler),
262+
pong: implement(contract.pong).handler(pong_handler),
263+
nested: {
264+
peng: implement(contract.nested.peng).handler(peng_handler),
265+
},
266+
})
267+
268+
@Controller()
269+
class GuardAboveController {
270+
@UseGuards(TestGuard)
271+
@Implement(contract)
272+
router() {
273+
return router_impl()
274+
}
275+
}
276+
277+
@Controller()
278+
class GuardBelowController {
279+
@Implement(contract)
280+
@UseGuards(TestGuard)
281+
router() {
282+
return router_impl()
283+
}
284+
}
285+
286+
describe.each([
287+
[GuardAboveController, '@UseGuards above @Implement'],
288+
[GuardBelowController, '@UseGuards below @Implement'],
289+
] as const)('order: $1', async (Controller, _) => {
290+
const moduleRef = await Test.createTestingModule({
291+
controllers: [Controller],
292+
}).compile()
293+
294+
const app = moduleRef.createNestApplication()
295+
await app.init()
296+
297+
const httpServer = app.getHttpServer()
298+
299+
it('rejects requests when the guard denies', async () => {
300+
const res = await supertest(httpServer)
301+
.post('/ping')
302+
.send({ hello: 'world' })
303+
304+
expect(res.statusCode).toEqual(403)
305+
expect(can_activate).toHaveBeenCalledTimes(1)
306+
expect(ping_handler).not.toHaveBeenCalled()
307+
308+
const res2 = await supertest(httpServer).get('/pong/world')
309+
310+
expect(res2.statusCode).toEqual(403)
311+
expect(pong_handler).not.toHaveBeenCalled()
312+
})
313+
314+
it('allows requests when the guard accepts', async () => {
315+
const res = await supertest(httpServer)
316+
.post('/ping')
317+
.set('x-allow', 'yes')
318+
.send({ hello: 'world' })
319+
320+
expect(res.statusCode).toEqual(200)
321+
expect(res.body).toEqual('pong')
322+
expect(can_activate).toHaveBeenCalledTimes(1)
323+
expect(ping_handler).toHaveBeenCalledTimes(1)
324+
})
325+
})
326+
})
327+
247328
it('on body parsing error', async () => {
248329
const moduleRef = await Test.createTestingModule({
249330
controllers: [ImplProcedureController],

packages/nest/src/implement.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ export function Implement<T extends ContractRouter<any>>(
8585
return getRouter(router, [key])
8686
}
8787

88+
Object.setPrototypeOf(target[methodName], descriptor.value!)
89+
8890
for (const p of Reflect.getOwnMetadataKeys(target, propertyKey)) {
8991
Reflect.defineMetadata(p, Reflect.getOwnMetadata(p, target, propertyKey), target, methodName)
9092
}

0 commit comments

Comments
 (0)