diff --git a/src/users/users.controller.spec.ts b/src/users/users.controller.spec.ts new file mode 100644 index 0000000..f7b1b7d --- /dev/null +++ b/src/users/users.controller.spec.ts @@ -0,0 +1,63 @@ +import { ForbiddenException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; + +describe('UsersController', () => { + let controller: UsersController; + let usersService: jest.Mocked>; + + beforeEach(async () => { + usersService = { + list: jest.fn(), + findById: jest.fn(), + setStellarAddress: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [UsersController], + providers: [{ provide: UsersService, useValue: usersService }], + }).compile(); + + controller = module.get(UsersController); + }); + + describe('setStellarAddress (#39 IDOR guard)', () => { + const validDto = { + stellarAddress: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + }; + + it('allows a user to update their own payout address', async () => { + const userId = '11111111-1111-1111-1111-111111111111'; + const req = { user: { userId } } as any; + const expectedUser = { id: userId, stellarAddress: validDto.stellarAddress } as any; + usersService.setStellarAddress.mockResolvedValue(expectedUser); + + const result = await controller.setStellarAddress(userId, validDto, req); + + expect(result).toEqual(expectedUser); + expect(usersService.setStellarAddress).toHaveBeenCalledWith(userId, validDto.stellarAddress); + }); + + it('throws ForbiddenException when user A attempts to overwrite user B address', async () => { + const victimId = '11111111-1111-1111-1111-111111111111'; + const attackerId = '22222222-2222-2222-2222-222222222222'; + const req = { user: { userId: attackerId } } as any; + + expect(() => controller.setStellarAddress(victimId, validDto, req)).toThrow( + ForbiddenException, + ); + expect(usersService.setStellarAddress).not.toHaveBeenCalled(); + }); + + it('throws ForbiddenException when req.user is missing or lacks userId', async () => { + const targetId = '11111111-1111-1111-1111-111111111111'; + const req = {} as any; + + expect(() => controller.setStellarAddress(targetId, validDto, req)).toThrow( + ForbiddenException, + ); + expect(usersService.setStellarAddress).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 21559de..f4841cf 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -1,13 +1,16 @@ import { Body, Controller, + ForbiddenException, Get, Param, ParseUUIDPipe, Patch, + Req, UseGuards, } from '@nestjs/common'; import { ApiBearerAuth, ApiProperty, ApiTags } from '@nestjs/swagger'; +import type { Request } from 'express'; import { UsersService } from './users.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { IsStellarAddress } from '../common/validators/stellar-address.validator'; @@ -48,7 +51,12 @@ export class UsersController { setStellarAddress( @Param('id', new ParseUUIDPipe()) id: string, @Body() dto: SetStellarAddressDto, + @Req() req: Request, ) { + const callerId = (req.user as any)?.userId; + if (!callerId || callerId !== id) { + throw new ForbiddenException("Cannot modify another user's payout address"); + } return this.usersService.setStellarAddress(id, dto.stellarAddress); } }