From 42cef52d4e3c59290a31340f888c08956b9bd197 Mon Sep 17 00:00:00 2001 From: chonilius Date: Thu, 29 Oct 2026 21:03:13 +0100 Subject: [PATCH 1/2] feat(users): add DELETE /users/:id/stellar-address to unlink wallet (#714) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PATCH { stellarAddress: null } always returned 400 because the @IsStellarAddress() validator on SetStellarAddressDto only accepts a valid Stellar Ed25519 public key — null never passes StrKey validation. The disconnect() call on the frontend was silently swallowed by its .catch(() => {}) so the failure was invisible to users and maintainers. Adds a dedicated DELETE :id/stellar-address route that accepts no body and sets user.stellarAddress to null via a new clearStellarAddress() service method, keeping the PATCH route strictly typed for real addresses. --- src/users/users.controller.ts | 23 +++++++++++++++++++++++ src/users/users.service.ts | 6 ++++++ 2 files changed, 29 insertions(+) diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 8710259c..f51bd6d1 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.ts b/src/users/users.service.ts index 079ffae6..0c351097 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); From 4bc7a9d823b4378a3b93f56b5f33e560ec5007ad Mon Sep 17 00:00:00 2001 From: chonilius Date: Thu, 29 Oct 2026 21:04:08 +0100 Subject: [PATCH 2/2] test(users): cover clearStellarAddress in service and controller specs (#714) Adds two spec blocks: - users.service.spec.ts: NotFoundException on missing user; null written to stellarAddress and persisted via userRepo.save. - users.controller.spec.ts: wires the clearStellarAddress mock; asserts the happy path returns stellarAddress: null and the cross-user attempt throws ForbiddenException without calling the service. --- src/users/users.controller.spec.ts | 33 ++++++++++++++++++++++++++++++ src/users/users.service.spec.ts | 23 +++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/users/users.controller.spec.ts b/src/users/users.controller.spec.ts index c5938269..204d0064 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.service.spec.ts b/src/users/users.service.spec.ts index ba1fe1a0..17463aec 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([