diff --git a/src/users/users.controller.spec.ts b/src/users/users.controller.spec.ts new file mode 100644 index 00000000..4c9ecc8f --- /dev/null +++ b/src/users/users.controller.spec.ts @@ -0,0 +1,65 @@ +import { ForbiddenException } from '@nestjs/common'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; +import { Reflector } from '@nestjs/core'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { GUARDS_METADATA } from '@nestjs/common/constants'; + +describe('UsersController (#597)', () => { + const usersService = { + list: jest.fn(), + findById: jest.fn(), + setStellarAddress: jest.fn(), + } as unknown as UsersService; + const controller = new UsersController(usersService); + + it('list delegates to usersService.list', async () => { + usersService.list = jest.fn().mockResolvedValue([{ id: 'u1' }]); + await expect(controller.list()).resolves.toEqual([{ id: 'u1' }]); + expect(usersService.list).toHaveBeenCalled(); + }); + + it('findOne delegates with the parsed id', async () => { + usersService.findById = jest.fn().mockResolvedValue({ id: 'u1' }); + await controller.findOne('u1'); + expect(usersService.findById).toHaveBeenCalledWith('u1'); + }); + + it('setStellarAddress delegates for the authenticated self', async () => { + usersService.setStellarAddress = jest + .fn() + .mockResolvedValue({ stellarAddress: 'G...' }); + const req = { user: { userId: 'u1' } } as never; + await controller.setStellarAddress( + 'u1', + { stellarAddress: 'GABC' } as never, + req, + ); + expect(usersService.setStellarAddress).toHaveBeenCalledWith('u1', 'GABC'); + }); + + // The self-only check (#39) — anyone setting ANOTHER user's stellar-address + // (the on-chain payout recipient) is a payout-hijack. Must 403. + it("setStellarAddress rejects setting another user's address", async () => { + const req = { user: { userId: 'attacker' } } as never; + await expect( + controller.setStellarAddress( + 'victim-id', + { stellarAddress: 'GABC' } as never, + req, + ), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(usersService.setStellarAddress).not.toHaveBeenCalled(); + }); + + it('all routes are behind JwtAuthGuard', () => { + const reflector = new Reflector(); + for (const method of ['list', 'findOne', 'setStellarAddress'] as const) { + const guards = reflector.get( + GUARDS_METADATA, + UsersController.prototype[method], + ); + expect(guards).toContain(JwtAuthGuard); + } + }); +}); diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 21559de8..fac78368 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -1,12 +1,15 @@ import { Body, Controller, + ForbiddenException, Get, Param, ParseUUIDPipe, Patch, + Req, UseGuards, } from '@nestjs/common'; +import { Request } from 'express'; import { ApiBearerAuth, ApiProperty, ApiTags } from '@nestjs/swagger'; import { UsersService } from './users.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @@ -48,7 +51,15 @@ export class UsersController { setStellarAddress( @Param('id', new ParseUUIDPipe()) id: string, @Body() dto: SetStellarAddressDto, + @Req() req: Request, ) { + // Self-only (#39): the URL :id must be the authenticated caller — the + // stellar-address is the on-chain payout recipient, so letting anyone set + // anyone's address would misdirect escrow releases. Reject non-self. + const callerId = (req.user as { userId?: string }).userId; + if (callerId !== id) { + throw new ForbiddenException('Can only set your own stellar address'); + } return this.usersService.setStellarAddress(id, dto.stellarAddress); } }