From 1f4ae3dd88e97a2bf85b2e1d3c9146560e91d41f Mon Sep 17 00:00:00 2001 From: Mir Arif Hasan Date: Tue, 3 Mar 2026 00:05:19 +0600 Subject: [PATCH] fix(backend): enforce user ownership when deleting PAT (#5916) Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com> --- .../access-token/access-token.controller.ts | 10 +++- .../access-token/access-token.service.spec.ts | 46 +++++++++++++++---- .../src/access-token/access-token.service.ts | 16 ++++--- 3 files changed, 55 insertions(+), 17 deletions(-) diff --git a/packages/hoppscotch-backend/src/access-token/access-token.controller.ts b/packages/hoppscotch-backend/src/access-token/access-token.controller.ts index 4812f02e..19eb572a 100644 --- a/packages/hoppscotch-backend/src/access-token/access-token.controller.ts +++ b/packages/hoppscotch-backend/src/access-token/access-token.controller.ts @@ -51,8 +51,14 @@ export class AccessTokenController { @Delete('revoke') @UseGuards(JwtAuthGuard) - async deletePAT(@Query('id') id: string) { - const result = await this.accessTokenService.deletePAT(id); + async deletePAT(@GqlUser() user: AuthUser, @Query('id') id: string) { + if (!id) { + throw new BadRequestException( + createCLIErrorResponse(ACCESS_TOKENS_INVALID_DATA_ID), + ); + } + + const result = await this.accessTokenService.deletePAT(id, user.uid); if (E.isLeft(result)) throwHTTPErr(result.left); return result.right; diff --git a/packages/hoppscotch-backend/src/access-token/access-token.service.spec.ts b/packages/hoppscotch-backend/src/access-token/access-token.service.spec.ts index c5f617c7..befa0108 100644 --- a/packages/hoppscotch-backend/src/access-token/access-token.service.spec.ts +++ b/packages/hoppscotch-backend/src/access-token/access-token.service.spec.ts @@ -112,11 +112,17 @@ describe('AccessTokenService', () => { describe('deletePAT', () => { test('should throw ACCESS_TOKEN_NOT_FOUND if Access Token is not found', async () => { - mockPrisma.personalAccessToken.delete.mockRejectedValueOnce( - 'RecordNotFound', - ); + mockPrisma.personalAccessToken.deleteMany.mockResolvedValueOnce({ + count: 0, + }); - const result = await accessTokenService.deletePAT(userAccessToken.id); + const result = await accessTokenService.deletePAT( + userAccessToken.id, + user.uid, + ); + expect(mockPrisma.personalAccessToken.deleteMany).toHaveBeenCalledWith({ + where: { id: userAccessToken.id, userUid: user.uid }, + }); expect(result).toEqualLeft({ message: ACCESS_TOKEN_NOT_FOUND, statusCode: HttpStatus.NOT_FOUND, @@ -124,13 +130,37 @@ describe('AccessTokenService', () => { }); test('should successfully delete a new Access Token', async () => { - mockPrisma.personalAccessToken.delete.mockResolvedValueOnce( - userAccessToken, - ); + mockPrisma.personalAccessToken.deleteMany.mockResolvedValueOnce({ + count: 1, + }); - const result = await accessTokenService.deletePAT(userAccessToken.id); + const result = await accessTokenService.deletePAT( + userAccessToken.id, + user.uid, + ); + expect(mockPrisma.personalAccessToken.deleteMany).toHaveBeenCalledWith({ + where: { id: userAccessToken.id, userUid: user.uid }, + }); expect(result).toEqualRight(true); }); + + test('should throw ACCESS_TOKEN_NOT_FOUND when token belongs to a different user', async () => { + mockPrisma.personalAccessToken.deleteMany.mockResolvedValueOnce({ + count: 0, + }); + + const result = await accessTokenService.deletePAT( + userAccessToken.id, + 'different-user-uid', + ); + expect(mockPrisma.personalAccessToken.deleteMany).toHaveBeenCalledWith({ + where: { id: userAccessToken.id, userUid: 'different-user-uid' }, + }); + expect(result).toEqualLeft({ + message: ACCESS_TOKEN_NOT_FOUND, + statusCode: HttpStatus.NOT_FOUND, + }); + }); }); describe('listAllUserPAT', () => { diff --git a/packages/hoppscotch-backend/src/access-token/access-token.service.ts b/packages/hoppscotch-backend/src/access-token/access-token.service.ts index 17421a29..70659c6d 100644 --- a/packages/hoppscotch-backend/src/access-token/access-token.service.ts +++ b/packages/hoppscotch-backend/src/access-token/access-token.service.ts @@ -103,20 +103,22 @@ export class AccessTokenService { * Delete a Personal Access Token * * @param accessTokenID ID of the Personal Access Token + * @param userUid UID of the user requesting the deletion * @returns Either of true or error message */ - async deletePAT(accessTokenID: string) { - try { - await this.prisma.personalAccessToken.delete({ - where: { id: accessTokenID }, - }); - return E.right(true); - } catch { + async deletePAT(accessTokenID: string, userUid: string) { + const { count } = await this.prisma.personalAccessToken.deleteMany({ + where: { id: accessTokenID, userUid }, + }); + + if (count === 0) { return E.left({ message: ACCESS_TOKEN_NOT_FOUND, statusCode: HttpStatus.NOT_FOUND, }); } + + return E.right(true); } /**