diff --git a/src/users/users.controller.spec.ts b/src/users/users.controller.spec.ts index c593826..204d006 100644 --- a/src/users/users.controller.spec.ts +++ b/src/users/users.controller.spec.ts @@ -12,6 +12,7 @@ describe('UsersController', () => { list: jest.Mock; findById: jest.Mock; setStellarAddress: jest.Mock; + clearStellarAddress: jest.Mock; }; const mockPublicUser: PublicUserDto = { @@ -32,6 +33,9 @@ describe('UsersController', () => { ...mockPublicUser, stellarAddress: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBWHF', }), + clearStellarAddress: jest + .fn() + .mockResolvedValue({ ...mockPublicUser, stellarAddress: null }), }; const module: TestingModule = await Test.createTestingModule({ @@ -93,4 +97,33 @@ describe('UsersController', () => { expect(usersService.setStellarAddress).not.toHaveBeenCalled(); }); + + describe('clearStellarAddress (DELETE :id/stellar-address) (#714)', () => { + it('clears the stellar address for the authenticated user', async () => { + const caller: AuthenticatedUser = { + userId: 'user-1', + username: 'alice', + roles: [UserRole.CONTRIBUTOR], + }; + + const result = await controller.clearStellarAddress('user-1', caller); + + expect(result.stellarAddress).toBeNull(); + expect(usersService.clearStellarAddress).toHaveBeenCalledWith('user-1'); + }); + + it('throws 403 ForbiddenException when trying to clear another user address', async () => { + const caller: AuthenticatedUser = { + userId: 'user-2', + username: 'bob', + roles: [UserRole.CONTRIBUTOR], + }; + + expect(() => + controller.clearStellarAddress('user-1', caller), + ).toThrow(ForbiddenException); + + expect(usersService.clearStellarAddress).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 8710259..f51bd6d 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -1,8 +1,11 @@ import { Body, Controller, + Delete, ForbiddenException, Get, + HttpCode, + HttpStatus, Param, ParseUUIDPipe, Patch, @@ -60,4 +63,24 @@ export class UsersController { } return this.usersService.setStellarAddress(id, dto.stellarAddress); } + + // #714: disconnect() was sending PATCH { stellarAddress: null } which the + // @IsStellarAddress() validator on SetStellarAddressDto always rejects with + // 400. A dedicated DELETE route accepts no body and sets the column to null, + // keeping the PATCH route strictly typed for real addresses. + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Delete(':id/stellar-address') + @HttpCode(HttpStatus.OK) + clearStellarAddress( + @Param('id', new ParseUUIDPipe()) id: string, + @CurrentUser() user: AuthenticatedUser, + ) { + if (user && user.userId !== id) { + throw new ForbiddenException( + 'You may only update your own Stellar address', + ); + } + return this.usersService.clearStellarAddress(id); + } } diff --git a/src/users/users.service.spec.ts b/src/users/users.service.spec.ts index ba1fe1a..17463ae 100644 --- a/src/users/users.service.spec.ts +++ b/src/users/users.service.spec.ts @@ -320,6 +320,29 @@ describe('UsersService', () => { }); }); + describe('clearStellarAddress', () => { + it('throws NotFoundException when the user does not exist', async () => { + userRepo.findOne.mockResolvedValue(null); + await expect(service.clearStellarAddress('missing')).rejects.toThrow( + NotFoundException, + ); + }); + + it('sets stellarAddress to null and persists the change (#714)', async () => { + userRepo.findOne.mockResolvedValue({ + id: 'u1', + stellarAddress: 'GLINKEDADDRESS', + }); + + const user = await service.clearStellarAddress('u1'); + + expect(user.stellarAddress).toBeNull(); + expect(userRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ id: 'u1', stellarAddress: null }), + ); + }); + }); + describe('list', () => { it('returns every user mapped to its public shape', async () => { userRepo.find = jest.fn().mockResolvedValue([ diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 079ffae..0c35109 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -180,6 +180,12 @@ export class UsersService { return toPublicUser(await this.userRepo.save(user)); } + async clearStellarAddress(userId: string): Promise { + const user = await this.findOneRaw(userId); + user.stellarAddress = null; + return toPublicUser(await this.userRepo.save(user)); + } + async list(): Promise { const users = await this.userRepo.find(); return users.map(toPublicUser);