From 83f8c7124b3c2e78fe79a81b461141b32577865f Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Mon, 27 Feb 2023 18:22:19 +0100 Subject: [PATCH 01/53] added lastPasswordChangeAt column to users --- ...227160000-add-lastPasswordChangeAt-to-users.js | 15 +++++++++++++++ src/modules/user/user.attributes.ts | 1 + src/modules/user/user.domain.ts | 4 ++++ src/modules/user/user.model.ts | 5 +++++ 4 files changed, 25 insertions(+) create mode 100644 migrations/20230227160000-add-lastPasswordChangeAt-to-users.js diff --git a/migrations/20230227160000-add-lastPasswordChangeAt-to-users.js b/migrations/20230227160000-add-lastPasswordChangeAt-to-users.js new file mode 100644 index 000000000..1a7b69737 --- /dev/null +++ b/migrations/20230227160000-add-lastPasswordChangeAt-to-users.js @@ -0,0 +1,15 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('users', 'lastPasswordChangeAt', { + type: Sequelize.DATE, + allowNull: true, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('users', 'lastPasswordChangeAt'); + }, +}; diff --git a/src/modules/user/user.attributes.ts b/src/modules/user/user.attributes.ts index 63bf13034..811a48868 100644 --- a/src/modules/user/user.attributes.ts +++ b/src/modules/user/user.attributes.ts @@ -26,4 +26,5 @@ export interface UserAttributes { sharedWorkspace: boolean; tempKey: string; avatar: string; + lastPasswordChangeAt: Date; } diff --git a/src/modules/user/user.domain.ts b/src/modules/user/user.domain.ts index 7126d552d..382093200 100644 --- a/src/modules/user/user.domain.ts +++ b/src/modules/user/user.domain.ts @@ -26,6 +26,7 @@ export class User implements UserAttributes { sharedWorkspace: boolean; tempKey: string; avatar: string; + lastPasswordChangeAt: Date; constructor({ id, userId, @@ -54,6 +55,7 @@ export class User implements UserAttributes { sharedWorkspace, tempKey, avatar, + lastPasswordChangeAt, }: UserAttributes) { this.id = id; this.userId = userId; @@ -81,6 +83,7 @@ export class User implements UserAttributes { this.sharedWorkspace = sharedWorkspace; this.tempKey = tempKey; this.avatar = avatar; + this.lastPasswordChangeAt = lastPasswordChangeAt; } static build(user: UserAttributes): User { @@ -114,6 +117,7 @@ export class User implements UserAttributes { backupsBucket: this.backupsBucket, sharedWorkspace: this.sharedWorkspace, avatar: this.avatar, + lastPasswordChangeAt: this.lastPasswordChangeAt, }; } } diff --git a/src/modules/user/user.model.ts b/src/modules/user/user.model.ts index 4902d8352..0e9105c5f 100644 --- a/src/modules/user/user.model.ts +++ b/src/modules/user/user.model.ts @@ -119,4 +119,9 @@ export class UserModel extends Model implements UserAttributes { @AllowNull @Column avatar: string; + + @AllowNull + @Default(new Date()) + @Column + lastPasswordChangeAt: Date; } From ec105b4abcb421816f599129dbcede2a86c3f45e Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Mon, 27 Feb 2023 18:23:58 +0100 Subject: [PATCH 02/53] added missing migration --- ...105113000-modify-index-folder-plain-name.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 migrations/20230105113000-modify-index-folder-plain-name.js diff --git a/migrations/20230105113000-modify-index-folder-plain-name.js b/migrations/20230105113000-modify-index-folder-plain-name.js new file mode 100644 index 000000000..49835cd05 --- /dev/null +++ b/migrations/20230105113000-modify-index-folder-plain-name.js @@ -0,0 +1,18 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.removeIndex('folders', 'folders_plainname_parentid_key'); + await queryInterface.addIndex('folders', { + fields: ['plain_name', 'parent_id'], + name: 'folders_plainname_parentid_key', + unique: true, + where: { deleted: { [Sequelize.Op.eq]: false } }, + }); + }, + + down: async (queryInterface) => { + await queryInterface.removeIndex('folders', 'folders_plainname_parentid_key'); + }, +}; From 86a66dff504448b811ddc00ab7280bcd8c7c03a3 Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Mon, 27 Feb 2023 20:45:23 +0100 Subject: [PATCH 03/53] fixed column name --- ... 20230227160000-add-lastPasswordChangedAt-to-users.js} | 4 ++-- src/modules/user/user.attributes.ts | 2 +- src/modules/user/user.domain.ts | 8 ++++---- src/modules/user/user.model.ts | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) rename migrations/{20230227160000-add-lastPasswordChangeAt-to-users.js => 20230227160000-add-lastPasswordChangedAt-to-users.js} (60%) diff --git a/migrations/20230227160000-add-lastPasswordChangeAt-to-users.js b/migrations/20230227160000-add-lastPasswordChangedAt-to-users.js similarity index 60% rename from migrations/20230227160000-add-lastPasswordChangeAt-to-users.js rename to migrations/20230227160000-add-lastPasswordChangedAt-to-users.js index 1a7b69737..33b17dc17 100644 --- a/migrations/20230227160000-add-lastPasswordChangeAt-to-users.js +++ b/migrations/20230227160000-add-lastPasswordChangedAt-to-users.js @@ -3,13 +3,13 @@ /** @type {import('sequelize-cli').Migration} */ module.exports = { async up(queryInterface, Sequelize) { - await queryInterface.addColumn('users', 'lastPasswordChangeAt', { + await queryInterface.addColumn('users', 'last_password_changed_at', { type: Sequelize.DATE, allowNull: true, }); }, async down(queryInterface) { - await queryInterface.removeColumn('users', 'lastPasswordChangeAt'); + await queryInterface.removeColumn('users', 'last_password_changed_at'); }, }; diff --git a/src/modules/user/user.attributes.ts b/src/modules/user/user.attributes.ts index 811a48868..cae62d51f 100644 --- a/src/modules/user/user.attributes.ts +++ b/src/modules/user/user.attributes.ts @@ -26,5 +26,5 @@ export interface UserAttributes { sharedWorkspace: boolean; tempKey: string; avatar: string; - lastPasswordChangeAt: Date; + lastPasswordChangedAt: Date; } diff --git a/src/modules/user/user.domain.ts b/src/modules/user/user.domain.ts index 382093200..f9131032f 100644 --- a/src/modules/user/user.domain.ts +++ b/src/modules/user/user.domain.ts @@ -26,7 +26,7 @@ export class User implements UserAttributes { sharedWorkspace: boolean; tempKey: string; avatar: string; - lastPasswordChangeAt: Date; + lastPasswordChangedAt: Date; constructor({ id, userId, @@ -55,7 +55,7 @@ export class User implements UserAttributes { sharedWorkspace, tempKey, avatar, - lastPasswordChangeAt, + lastPasswordChangedAt, }: UserAttributes) { this.id = id; this.userId = userId; @@ -83,7 +83,7 @@ export class User implements UserAttributes { this.sharedWorkspace = sharedWorkspace; this.tempKey = tempKey; this.avatar = avatar; - this.lastPasswordChangeAt = lastPasswordChangeAt; + this.lastPasswordChangedAt = lastPasswordChangedAt; } static build(user: UserAttributes): User { @@ -117,7 +117,7 @@ export class User implements UserAttributes { backupsBucket: this.backupsBucket, sharedWorkspace: this.sharedWorkspace, avatar: this.avatar, - lastPasswordChangeAt: this.lastPasswordChangeAt, + lastPasswordChangedAt: this.lastPasswordChangedAt, }; } } diff --git a/src/modules/user/user.model.ts b/src/modules/user/user.model.ts index 0e9105c5f..a9425a2ea 100644 --- a/src/modules/user/user.model.ts +++ b/src/modules/user/user.model.ts @@ -123,5 +123,5 @@ export class UserModel extends Model implements UserAttributes { @AllowNull @Default(new Date()) @Column - lastPasswordChangeAt: Date; + lastPasswordChangedAt: Date; } From ecaa7645a7bc636101b88ea962d3e8c458112595 Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Tue, 28 Feb 2023 16:54:29 +0100 Subject: [PATCH 04/53] changed allowNull to false and added defaultValue --- .../20230227160000-add-lastPasswordChangedAt-to-users.js | 3 ++- src/modules/user/user.model.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/migrations/20230227160000-add-lastPasswordChangedAt-to-users.js b/migrations/20230227160000-add-lastPasswordChangedAt-to-users.js index 33b17dc17..2ea1a08ae 100644 --- a/migrations/20230227160000-add-lastPasswordChangedAt-to-users.js +++ b/migrations/20230227160000-add-lastPasswordChangedAt-to-users.js @@ -5,7 +5,8 @@ module.exports = { async up(queryInterface, Sequelize) { await queryInterface.addColumn('users', 'last_password_changed_at', { type: Sequelize.DATE, - allowNull: true, + allowNull: false, + defaultValue: 0, }); }, diff --git a/src/modules/user/user.model.ts b/src/modules/user/user.model.ts index a9425a2ea..4b8f6704f 100644 --- a/src/modules/user/user.model.ts +++ b/src/modules/user/user.model.ts @@ -120,7 +120,7 @@ export class UserModel extends Model implements UserAttributes { @Column avatar: string; - @AllowNull + @AllowNull(false) @Default(new Date()) @Column lastPasswordChangedAt: Date; From 1988cdae7a06c4b72dd7928dbe5dd524d74d52f6 Mon Sep 17 00:00:00 2001 From: Sergio Gutierrez Villalba Date: Tue, 30 May 2023 13:22:28 +0200 Subject: [PATCH 05/53] refactor: remove old migration --- ...105113000-modify-index-folder-plain-name.js | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 migrations/20230105113000-modify-index-folder-plain-name.js diff --git a/migrations/20230105113000-modify-index-folder-plain-name.js b/migrations/20230105113000-modify-index-folder-plain-name.js deleted file mode 100644 index 49835cd05..000000000 --- a/migrations/20230105113000-modify-index-folder-plain-name.js +++ /dev/null @@ -1,18 +0,0 @@ -'use strict'; - -/** @type {import('sequelize-cli').Migration} */ -module.exports = { - up: async (queryInterface, Sequelize) => { - await queryInterface.removeIndex('folders', 'folders_plainname_parentid_key'); - await queryInterface.addIndex('folders', { - fields: ['plain_name', 'parent_id'], - name: 'folders_plainname_parentid_key', - unique: true, - where: { deleted: { [Sequelize.Op.eq]: false } }, - }); - }, - - down: async (queryInterface) => { - await queryInterface.removeIndex('folders', 'folders_plainname_parentid_key'); - }, -}; From b57cc1915bf40bea83909eaf24efb1828ab56585 Mon Sep 17 00:00:00 2001 From: Sergio Gutierrez Villalba Date: Tue, 30 May 2023 13:25:07 +0200 Subject: [PATCH 06/53] fix(users): update last passwordChangedAt field --- ...rs.js => 20230530160000-add-lastPasswordChangedAt-to-users.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename migrations/{20230227160000-add-lastPasswordChangedAt-to-users.js => 20230530160000-add-lastPasswordChangedAt-to-users.js} (100%) diff --git a/migrations/20230227160000-add-lastPasswordChangedAt-to-users.js b/migrations/20230530160000-add-lastPasswordChangedAt-to-users.js similarity index 100% rename from migrations/20230227160000-add-lastPasswordChangedAt-to-users.js rename to migrations/20230530160000-add-lastPasswordChangedAt-to-users.js From 00deb55ea8574f9597ad3177cac5715a47bbd7b1 Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Tue, 17 Oct 2023 12:10:23 +0200 Subject: [PATCH 07/53] fixed lastPasswordChangedAt tests --- src/externals/bridge/bridge.service.spec.ts | 1 + src/modules/file/file.usecase.spec.ts | 2 ++ src/modules/folder/folder.usecase.spec.ts | 3 +++ src/modules/send/send.usecase.spec.ts | 1 + src/modules/share/share.usecase.spec.ts | 2 ++ src/modules/trash/trash.usecase.spec.ts | 1 + src/modules/user/user.usecase.spec.ts | 1 + test/fixtures.ts | 1 + 8 files changed, 12 insertions(+) diff --git a/src/externals/bridge/bridge.service.spec.ts b/src/externals/bridge/bridge.service.spec.ts index aaedac07d..a39c3d2ef 100644 --- a/src/externals/bridge/bridge.service.spec.ts +++ b/src/externals/bridge/bridge.service.spec.ts @@ -41,6 +41,7 @@ describe('Bridge Service', () => { hKey: undefined, secret_2FA: '', tempKey: '', + lastPasswordChangedAt: new Date(), }); beforeEach(async () => { diff --git a/src/modules/file/file.usecase.spec.ts b/src/modules/file/file.usecase.spec.ts index def4df881..2d8e5da27 100644 --- a/src/modules/file/file.usecase.spec.ts +++ b/src/modules/file/file.usecase.spec.ts @@ -63,6 +63,7 @@ describe('FileUseCases', () => { hKey: undefined, secret_2FA: '', tempKey: '', + lastPasswordChangedAt: new Date(), }); beforeEach(async () => { @@ -270,6 +271,7 @@ describe('FileUseCases', () => { hKey: undefined, secret_2FA: '', tempKey: '', + lastPasswordChangedAt: new Date(), }); it.skip('should be able to delete a trashed file', async () => { diff --git a/src/modules/folder/folder.usecase.spec.ts b/src/modules/folder/folder.usecase.spec.ts index b379dff1e..a4ac16305 100644 --- a/src/modules/folder/folder.usecase.spec.ts +++ b/src/modules/folder/folder.usecase.spec.ts @@ -238,6 +238,7 @@ describe('FolderUseCases', () => { hKey: undefined, secret_2FA: '', tempKey: '', + lastPasswordChangedAt: new Date(), }); const folderId = 2713105696; const folder = Folder.build({ @@ -296,6 +297,7 @@ describe('FolderUseCases', () => { hKey: undefined, secret_2FA: '', tempKey: '', + lastPasswordChangedAt: new Date(), }); const folderId = 2713105696; const folder = Folder.build({ @@ -359,6 +361,7 @@ describe('FolderUseCases', () => { hKey: undefined, secret_2FA: '', tempKey: '', + lastPasswordChangedAt: new Date(), }); const folderId = 2713105696; const folder = Folder.build({ diff --git a/src/modules/send/send.usecase.spec.ts b/src/modules/send/send.usecase.spec.ts index 6dd5fe11c..979abbb88 100644 --- a/src/modules/send/send.usecase.spec.ts +++ b/src/modules/send/send.usecase.spec.ts @@ -48,6 +48,7 @@ describe('Send Use Cases', () => { hKey: undefined, secret_2FA: '', tempKey: '', + lastPasswordChangedAt: new Date(), }); beforeEach(async () => { diff --git a/src/modules/share/share.usecase.spec.ts b/src/modules/share/share.usecase.spec.ts index 884b66f91..41d7307e9 100644 --- a/src/modules/share/share.usecase.spec.ts +++ b/src/modules/share/share.usecase.spec.ts @@ -86,6 +86,7 @@ describe('Share Use Cases', () => { hKey: undefined, secret_2FA: '', tempKey: '', + lastPasswordChangedAt: new Date(), }); const userMock = User.build({ id: 2, @@ -114,6 +115,7 @@ describe('Share Use Cases', () => { hKey: undefined, secret_2FA: '', tempKey: '', + lastPasswordChangedAt: new Date(), }); const mockFolder = Folder.build({ id: 1, diff --git a/src/modules/trash/trash.usecase.spec.ts b/src/modules/trash/trash.usecase.spec.ts index f24648eb8..3b989c7c5 100644 --- a/src/modules/trash/trash.usecase.spec.ts +++ b/src/modules/trash/trash.usecase.spec.ts @@ -54,6 +54,7 @@ describe('Trash Use Cases', () => { hKey: undefined, secret_2FA: '', tempKey: '', + lastPasswordChangedAt: new Date(), }); beforeEach(async () => { diff --git a/src/modules/user/user.usecase.spec.ts b/src/modules/user/user.usecase.spec.ts index 72f40090c..8f59a1d32 100644 --- a/src/modules/user/user.usecase.spec.ts +++ b/src/modules/user/user.usecase.spec.ts @@ -54,6 +54,7 @@ describe('User use cases', () => { hKey: undefined, secret_2FA: '', tempKey: '', + lastPasswordChangedAt: new Date(), }); beforeEach(async () => { diff --git a/test/fixtures.ts b/test/fixtures.ts index 16f077f6f..ab8fc55e6 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -93,6 +93,7 @@ export const newUser = (): User => { sharedWorkspace: false, tempKey: '', avatar: v4(), + lastPasswordChangedAt: new Date(), }); }; From 14b4425cd67d804822f328f7b2f0ca68cfd16385 Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Thu, 9 Nov 2023 12:28:24 +0100 Subject: [PATCH 08/53] changed lastPasswordChangedAt default value to null --- .../20230530160000-add-lastPasswordChangedAt-to-users.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/migrations/20230530160000-add-lastPasswordChangedAt-to-users.js b/migrations/20230530160000-add-lastPasswordChangedAt-to-users.js index 2ea1a08ae..6a06d6e51 100644 --- a/migrations/20230530160000-add-lastPasswordChangedAt-to-users.js +++ b/migrations/20230530160000-add-lastPasswordChangedAt-to-users.js @@ -5,8 +5,8 @@ module.exports = { async up(queryInterface, Sequelize) { await queryInterface.addColumn('users', 'last_password_changed_at', { type: Sequelize.DATE, - allowNull: false, - defaultValue: 0, + allowNull: true, + defaultValue: null, }); }, From 7f447e9c6b41d97f3245a27490ee8ffb47ab2249 Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Thu, 9 Nov 2023 12:50:00 +0100 Subject: [PATCH 09/53] changed lastPasswordChangedAt default value to null --- src/modules/user/user.model.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/modules/user/user.model.ts b/src/modules/user/user.model.ts index 4b8f6704f..76fe7be28 100644 --- a/src/modules/user/user.model.ts +++ b/src/modules/user/user.model.ts @@ -120,8 +120,7 @@ export class UserModel extends Model implements UserAttributes { @Column avatar: string; - @AllowNull(false) - @Default(new Date()) + @AllowNull @Column lastPasswordChangedAt: Date; } From e23a862b81502e018d8b1a32441904a884bd0601 Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Thu, 9 Nov 2023 17:03:18 +0100 Subject: [PATCH 10/53] added missing lastPasswordChangedAt property --- src/modules/folder/folder.e2e.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/modules/folder/folder.e2e.ts b/src/modules/folder/folder.e2e.ts index 459df8786..687483b82 100644 --- a/src/modules/folder/folder.e2e.ts +++ b/src/modules/folder/folder.e2e.ts @@ -42,6 +42,7 @@ const user = new User({ sharedWorkspace: false, tempKey: '', avatar: '', + lastPasswordChangedAt: null, }); const wrongFolderIdException = new BadRequestWrongFolderIdException(); From 9d1a1616c6ed194431ccd46ca6d95e8159914457 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Tue, 2 Jan 2024 09:38:12 -0400 Subject: [PATCH 11/53] feat: added auto account unblock endpoints --- ...2070906-add-unblockToken-field-to-users.js | 19 +++ src/modules/user/dto/account-unblock.dto.ts | 11 ++ src/modules/user/user.attributes.ts | 1 + src/modules/user/user.controller.ts | 96 +++++++++++++++- src/modules/user/user.domain.ts | 10 ++ src/modules/user/user.model.ts | 4 + src/modules/user/user.usecase.spec.ts | 108 +++++++++++++++++- src/modules/user/user.usecase.ts | 56 +++++++++ 8 files changed, 303 insertions(+), 2 deletions(-) create mode 100644 migrations/20240102070906-add-unblockToken-field-to-users.js create mode 100644 src/modules/user/dto/account-unblock.dto.ts diff --git a/migrations/20240102070906-add-unblockToken-field-to-users.js b/migrations/20240102070906-add-unblockToken-field-to-users.js new file mode 100644 index 000000000..612acb01d --- /dev/null +++ b/migrations/20240102070906-add-unblockToken-field-to-users.js @@ -0,0 +1,19 @@ +'use strict'; + +const tableName = 'users'; +const newColumn = 'unblock_token'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn(tableName, newColumn, { + type: Sequelize.TEXT, + defaultValue: null, + allowNull: true, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn(tableName, newColumn); + }, +}; diff --git a/src/modules/user/dto/account-unblock.dto.ts b/src/modules/user/dto/account-unblock.dto.ts new file mode 100644 index 000000000..4a35d2b11 --- /dev/null +++ b/src/modules/user/dto/account-unblock.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty } from 'class-validator'; + +export class RequestAccountUnblock { + @ApiProperty({ + example: 'hello@internxt.com', + description: 'User email', + }) + @IsNotEmpty() + email: string; +} diff --git a/src/modules/user/user.attributes.ts b/src/modules/user/user.attributes.ts index 3d48f147d..10315ee9d 100644 --- a/src/modules/user/user.attributes.ts +++ b/src/modules/user/user.attributes.ts @@ -26,4 +26,5 @@ export interface UserAttributes { sharedWorkspace: boolean; tempKey: string; avatar: string; + unblockToken?: string; } diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index ab90621d7..eae1e12b8 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -19,6 +19,7 @@ import { UnauthorizedException, BadRequestException, UseFilters, + InternalServerErrorException, } from '@nestjs/common'; import { ApiBadRequestResponse, @@ -33,7 +34,7 @@ import { CreateUserDto } from './dto/create-user.dto'; import { Response, Request } from 'express'; import { SignUpSuccessEvent } from '../../externals/notifications/events/sign-up-success.event'; import { NotificationService } from '../../externals/notifications/notification.service'; -import { User } from './user.domain'; +import { AccountTokenAction, User } from './user.domain'; import { InvalidReferralCodeError, KeyServerNotFoundError, @@ -59,6 +60,7 @@ import { RegisterPreCreatedUserDto } from './dto/register-pre-created-user.dto'; import { SharingService } from '../sharing/sharing.service'; import { CreateAttemptChangeEmailDto } from './dto/create-attempt-change-email.dto'; import { HttpExceptionFilter } from '../../lib/http/http-exception.filter'; +import { RequestAccountUnblock } from './dto/account-unblock.dto'; @ApiTags('User') @Controller('users') @@ -428,6 +430,98 @@ export class UserController { } } + @UseGuards(ThrottlerGuard) + @Post('/unblock-account') + @HttpCode(200) + @ApiOperation({ + summary: 'Request account unblock', + }) + @Public() + async requestAccountUnblock( + @Body() body: RequestAccountUnblock, + @Res({ passthrough: true }) res: Response, + ) { + try { + return await this.userUseCases.sendAccountUnblockEmail(body.email); + } catch (err) { + if (err instanceof NotFoundException) { + throw err; + } + + new Logger().error( + `[USERS/UNBLOCK_ACCOUNT_REQUEST] ERROR: ${ + (err as Error).message + }, BODY ${JSON.stringify({ + ...body, + user: { email: body.email }, + })}, STACK: ${(err as Error).stack}`, + ); + + throw new InternalServerErrorException(); + } + } + + @UseGuards(ThrottlerGuard) + @Put('/unblock-account') + @HttpCode(200) + @ApiOperation({ + summary: 'Resets user error login counter to unblock account', + }) + @Public() + async accountUnblock(@Query('token') token: string) { + let decodedContent: { + payload: { uuid: string; action: string; email: string }; + }; + + try { + const decoded = verifyToken(token, getEnv().secrets.jwt); + + if (typeof decoded === 'string') { + throw new ForbiddenException(); + } + + decodedContent = decoded as { + payload: { uuid: string; action: string; email: string }; + }; + } catch (err) { + throw new ForbiddenException(); + } + + if ( + !decodedContent.payload || + !decodedContent.payload.action || + !decodedContent.payload.uuid || + !decodedContent.payload.email || + decodedContent.payload.action !== AccountTokenAction.Unblock || + !validate(decodedContent.payload.uuid) + ) { + throw new ForbiddenException(); + } + + const { uuid, email } = decodedContent.payload; + + try { + await this.userUseCases.unblockAccount(uuid, token); + } catch (err) { + if ( + err instanceof ForbiddenException || + err instanceof BadRequestException + ) { + throw err; + } + + new Logger().error( + `[USERS/UNBLOCK_ACCOUNT] ERROR: ${ + (err as Error).message + }, BODY ${JSON.stringify({ + user: { email, uuid }, + })}, STACK: ${(err as Error).stack}`, + ); + + throw new InternalServerErrorException(); + } + } + @UseGuards(ThrottlerGuard) @Put('/recover-account') @HttpCode(200) diff --git a/src/modules/user/user.domain.ts b/src/modules/user/user.domain.ts index 7833ed659..ae2a37d18 100644 --- a/src/modules/user/user.domain.ts +++ b/src/modules/user/user.domain.ts @@ -26,6 +26,8 @@ export class User implements UserAttributes { sharedWorkspace: boolean; tempKey: string; avatar: string; + unblockToken: string; + constructor({ id, userId, @@ -53,6 +55,7 @@ export class User implements UserAttributes { sharedWorkspace, tempKey, avatar, + unblockToken, }: UserAttributes) { this.id = id; this.userId = userId; @@ -80,6 +83,7 @@ export class User implements UserAttributes { this.sharedWorkspace = sharedWorkspace; this.tempKey = tempKey; this.avatar = avatar; + this.unblockToken = unblockToken; } static build(user: UserAttributes): User { @@ -113,6 +117,7 @@ export class User implements UserAttributes { backupsBucket: this.backupsBucket, sharedWorkspace: this.sharedWorkspace, avatar: this.avatar, + unblockToken: this.unblockToken, }; } } @@ -144,3 +149,8 @@ export interface UserReferralAttributes { applied: boolean; startDate: Date; } + +export enum AccountTokenAction { + Unblock = 'unblock-account', + Recover = 'recover-account', +} diff --git a/src/modules/user/user.model.ts b/src/modules/user/user.model.ts index 4902d8352..0ebec0dc9 100644 --- a/src/modules/user/user.model.ts +++ b/src/modules/user/user.model.ts @@ -119,4 +119,8 @@ export class UserModel extends Model implements UserAttributes { @AllowNull @Column avatar: string; + + @AllowNull + @Column + unblockToken: string; } diff --git a/src/modules/user/user.usecase.spec.ts b/src/modules/user/user.usecase.spec.ts index 147662138..ae5b768af 100644 --- a/src/modules/user/user.usecase.spec.ts +++ b/src/modules/user/user.usecase.spec.ts @@ -5,7 +5,7 @@ import { UserUseCases } from './user.usecase'; import { ShareUseCases } from '../share/share.usecase'; import { FolderUseCases } from '../folder/folder.usecase'; import { FileUseCases } from '../file/file.usecase'; -import { User } from './user.domain'; +import { AccountTokenAction, User } from './user.domain'; import { SequelizeUserRepository } from './user.repository'; import { SequelizeSharedWorkspaceRepository } from '../../shared-workspace/shared-workspace.repository'; import { SequelizeReferralRepository } from './referrals.repository'; @@ -24,12 +24,30 @@ import { SequelizePreCreatedUsersRepository } from './pre-created-users.reposito import { SequelizeSharingRepository } from '../sharing/sharing.repository'; import { SequelizeAttemptChangeEmailRepository } from './attempt-change-email.repository'; import { MailerService } from '../../externals/mailer/mailer.service'; +import { + BadRequestException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { SignWithCustomDuration } from '../../middlewares/passport'; + +jest.mock('../../middlewares/passport', () => { + const originalModule = jest.requireActual('../../middlewares/passport'); + return { + __esModule: true, + ...originalModule, + SignWithCustomDuration: jest.fn((payload, secret, expiresIn) => 'anyToken'), + }; +}); describe('User use cases', () => { let userUseCases: UserUseCases; let shareUseCases: ShareUseCases; let folderUseCases: FolderUseCases; let fileUseCases: FileUseCases; + let mailerService: MailerService; + let userRepository: SequelizeUserRepository; + let configService: ConfigService; const user = User.build({ id: 1, @@ -67,6 +85,11 @@ describe('User use cases', () => { folderUseCases = moduleRef.get(FolderUseCases); fileUseCases = moduleRef.get(FileUseCases); userUseCases = moduleRef.get(UserUseCases); + mailerService = moduleRef.get(MailerService); + configService = moduleRef.get(ConfigService); + userRepository = moduleRef.get( + SequelizeUserRepository, + ); }); describe('Resetting a user', () => { @@ -216,6 +239,89 @@ describe('User use cases', () => { }); }); }); + + describe('Request account unblock', () => { + it('When user does not exist, then fail', async () => { + const userFindByEmailSpy = jest.spyOn(userRepository, 'findByEmail'); + const email = 'email@test.com'; + userFindByEmailSpy.mockReturnValueOnce(null); + + await expect(userUseCases.sendAccountUnblockEmail(email)).rejects.toThrow( + NotFoundException, + ); + }); + + it('When user user exists, then add unblockToken and send email', async () => { + const userFindByEmailSpy = jest.spyOn(userRepository, 'findByEmail'); + const userUpdateSpy = jest.spyOn(userRepository, 'updateByUuid'); + const emailSendSpy = jest.spyOn(mailerService, 'send'); + const configServiceGetSpy = jest.spyOn(configService, 'get'); + const email = 'email@test.com'; + userFindByEmailSpy.mockResolvedValueOnce(user); + userUpdateSpy.mockResolvedValueOnce(null); + emailSendSpy.mockResolvedValueOnce(null); + configServiceGetSpy.mockReturnValue('secret'); + + await userUseCases.sendAccountUnblockEmail(email); + + expect(SignWithCustomDuration).toHaveBeenCalledWith( + { + payload: { + uuid: user.uuid, + email: user.email, + action: AccountTokenAction.Unblock, + }, + }, + 'secret', + '48h', + ); + expect(userUpdateSpy).toHaveBeenCalledWith(user.uuid, { + unblockToken: 'anyToken', + }); + }); + }); + + describe('Unblock user account', () => { + it('When user does not exist, then fail', async () => { + const userFindByUuidSpy = jest.spyOn(userRepository, 'findByUuid'); + userFindByUuidSpy.mockReturnValueOnce(null); + + await expect(userUseCases.unblockAccount(user.uuid)).rejects.toThrow( + BadRequestException, + ); + }); + + it('When token is not the last one requested, then fail', async () => { + const userFindByUuidSpy = jest.spyOn(userRepository, 'findByUuid'); + const unblockUser = User.build({ + ...user, + unblockToken: 'lastToken', + }); + + userFindByUuidSpy.mockResolvedValueOnce(unblockUser); + + await expect( + userUseCases.unblockAccount(unblockUser.uuid, 'notLastToken'), + ).rejects.toThrow(ForbiddenException); + }); + + it('When user found and tokens are equal, then user unblockToken and errorLoginCount are reset', async () => { + const userFindByUuidSpy = jest.spyOn(userRepository, 'findByUuid'); + const userUpdateByUuidSpy = jest.spyOn(userRepository, 'updateByUuid'); + const unblockUser = User.build({ + ...user, + unblockToken: 'lastToken', + }); + + userFindByUuidSpy.mockResolvedValueOnce(unblockUser); + await userUseCases.unblockAccount(unblockUser.uuid, 'lastToken'); + + expect(userUpdateByUuidSpy).toHaveBeenCalledWith(unblockUser.uuid, { + errorLoginCount: 0, + unblockToken: null, + }); + }); + }); }); const createTestingModule = (): Promise => { diff --git a/src/modules/user/user.usecase.ts b/src/modules/user/user.usecase.ts index 025aff066..03ce8df95 100644 --- a/src/modules/user/user.usecase.ts +++ b/src/modules/user/user.usecase.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ForbiddenException, Injectable, Logger, NotFoundException, @@ -11,6 +12,7 @@ import { generateMnemonic } from 'bip39'; import { SequelizeUserRepository } from './user.repository'; import { + AccountTokenAction, ReferralAttributes, ReferralKey, User, @@ -694,6 +696,60 @@ export class UserUseCases { }); } + async sendAccountUnblockEmail(email: User['email']): Promise { + const secret = this.configService.get('secrets.jwt'); + const user = await this.userRepository.findByEmail(email); + + if (!user) { + throw new NotFoundException(); + } + + const unblockAccountToken = SignWithCustomDuration( + { + payload: { + uuid: user.uuid, + email: user.email, + action: AccountTokenAction.Unblock, + }, + }, + secret, + '48h', + ); + + await this.userRepository.updateByUuid(user.uuid, { + unblockToken: unblockAccountToken, + }); + + const driveWebUrl = this.configService.get('clients.drive.web'); + const unblockAccountTemplateId = this.configService.get( + 'mailer.templates.unblockAccountEmail', + ); + + const url = `${driveWebUrl}/unblock-account/${unblockAccountToken}`; + + await this.mailerService.send(user.email, unblockAccountTemplateId, { + email, + unblock_url: url, + }); + } + + async unblockAccount(userUuid: User['uuid'], token?: string): Promise { + const user = await this.userRepository.findByUuid(userUuid); + + if (!user) { + throw new BadRequestException(); + } + + if (token && user?.unblockToken !== token) { + throw new ForbiddenException(); + } + + await this.userRepository.updateByUuid(userUuid, { + errorLoginCount: 0, + unblockToken: null, + }); + } + async updateCredentials( userUuid: User['uuid'], newCredentials: { From faaa09de1c806480b3ea244a2185d92f518bd303 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Tue, 2 Jan 2024 09:45:08 -0400 Subject: [PATCH 12/53] cfeat: SENDGRID_TEMPLATE_DRIVE_UNBLOCK_ACCOUNT added to config file --- .env.template | 1 + src/config/configuration.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.env.template b/.env.template index d4ed17dc5..49338768e 100644 --- a/.env.template +++ b/.env.template @@ -28,6 +28,7 @@ SENDGRID_TEMPLATE_DRIVE_SHARING_ROLE_UPDATED=d-0c9cbd0a649d4cee8eac6a4ae68736c9 SENDGRID_TEMPLATE_SEND_LINK_CREATE_RECEIVER=d-eb5a1fd73d764e9991a25e2a0297f279 SENDGRID_TEMPLATE_SEND_LINK_CREATE_SENDER=d-7889146930fa421083b4bf1cdcaedab3 SENDGRID_TEMPLATE_DRIVE_UPDATE_USER_EMAIL=d-46a66194ad8e4bb6919d39e20d9c34d1 +SENDGRID_TEMPLATE_DRIVE_UNBLOCK_ACCOUNT=d-0c9cbd0a649d4cee8eac6a4ae68736c9 SHARE_DOMAINS=http://localhost:3000 PCREATED_USERS_PASSWORD=example diff --git a/src/config/configuration.ts b/src/config/configuration.ts index aa6dceb34..924bd414d 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -93,6 +93,8 @@ export default () => ({ process.env.SENDGRID_TEMPLATE_DRIVE_SHARING_ROLE_UPDATED || '', updateUserEmail: process.env.SENDGRID_TEMPLATE_DRIVE_UPDATE_USER_EMAIL || '', + unblockAccountEmail: + process.env.SENDGRID_TEMPLATE_DRIVE_UNBLOCK_ACCOUNT || '', }, }, newsletter: { From eddd1c22ccccf633a44f6c67e445beb852f3cde9 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Tue, 2 Jan 2024 10:41:06 -0400 Subject: [PATCH 13/53] fix: refactor to fix a small code smell --- src/modules/user/user.controller.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index eae1e12b8..3419198a1 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -487,18 +487,19 @@ export class UserController { throw new ForbiddenException(); } + const tokenPayload = decodedContent?.payload; + if ( - !decodedContent.payload || - !decodedContent.payload.action || - !decodedContent.payload.uuid || - !decodedContent.payload.email || - decodedContent.payload.action !== AccountTokenAction.Unblock || - !validate(decodedContent.payload.uuid) + !tokenPayload.action || + !tokenPayload.uuid || + !tokenPayload.email || + tokenPayload.action !== AccountTokenAction.Unblock || + !validate(tokenPayload.uuid) ) { throw new ForbiddenException(); } - const { uuid, email } = decodedContent.payload; + const { uuid, email } = tokenPayload; try { await this.userUseCases.unblockAccount(uuid, token); From 0ce0970fd05659e8783ad2e0e447e9471bf984b7 Mon Sep 17 00:00:00 2001 From: Edison J Padilla Date: Tue, 2 Jan 2024 12:05:17 -0400 Subject: [PATCH 14/53] Change user email: handle user not found and user email already in use exceptions --- .../user/exception/referral-not-found.exception.ts | 7 ++++++- .../exception/referrals-not-available.exception.ts | 7 ++++++- .../exception/user-email-already-in-use.exception.ts | 12 ++++++++++++ .../user/exception/user-not-found.exception.ts | 3 ++- .../exception/user-referral-not-found.exception.ts | 7 ++++++- src/modules/user/exception/user.exception.ts | 8 +++++++- src/modules/user/user.usecase.ts | 8 +++++--- 7 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 src/modules/user/exception/user-email-already-in-use.exception.ts diff --git a/src/modules/user/exception/referral-not-found.exception.ts b/src/modules/user/exception/referral-not-found.exception.ts index 277b721e2..0d6ea536c 100644 --- a/src/modules/user/exception/referral-not-found.exception.ts +++ b/src/modules/user/exception/referral-not-found.exception.ts @@ -1,7 +1,12 @@ +import { HttpStatus } from '@nestjs/common'; import { UserException } from './user.exception'; export class ReferralNotFoundException extends UserException { constructor(message?: string) { - super(message ?? 'Referral: not found'); + super( + message ?? 'Referral: not found', + HttpStatus.NOT_FOUND, + 'REFERRAL_NOT_FOUND', + ); } } diff --git a/src/modules/user/exception/referrals-not-available.exception.ts b/src/modules/user/exception/referrals-not-available.exception.ts index eb94e0999..42321d4ca 100644 --- a/src/modules/user/exception/referrals-not-available.exception.ts +++ b/src/modules/user/exception/referrals-not-available.exception.ts @@ -1,7 +1,12 @@ +import { HttpStatus } from '@nestjs/common'; import { UserException } from './user.exception'; export class ReferralsNotAvailableException extends UserException { constructor(message?: string) { - super(message ?? 'Referrals: not available'); + super( + message ?? 'Referrals: not available', + HttpStatus.BAD_REQUEST, + 'REFERRALS_NOT_AVAILABLE', + ); } } diff --git a/src/modules/user/exception/user-email-already-in-use.exception.ts b/src/modules/user/exception/user-email-already-in-use.exception.ts new file mode 100644 index 000000000..8cd671986 --- /dev/null +++ b/src/modules/user/exception/user-email-already-in-use.exception.ts @@ -0,0 +1,12 @@ +import { HttpStatus } from '@nestjs/common'; +import { UserException } from './user.exception'; + +export class UserEmailAlreadyInUseException extends UserException { + constructor(email?: string) { + super( + `${email} email already in use`, + HttpStatus.BAD_REQUEST, + 'USER_EMAIL_ALREADY_IN_USE', + ); + } +} diff --git a/src/modules/user/exception/user-not-found.exception.ts b/src/modules/user/exception/user-not-found.exception.ts index 0be21a231..19aaed0b2 100644 --- a/src/modules/user/exception/user-not-found.exception.ts +++ b/src/modules/user/exception/user-not-found.exception.ts @@ -1,7 +1,8 @@ +import { HttpStatus } from '@nestjs/common'; import { UserException } from './user.exception'; export class UserNotFoundException extends UserException { constructor(message?: string) { - super(message ?? 'User: not found'); + super(message ?? 'not found', HttpStatus.NOT_FOUND, 'USER_NOT_FOUND'); } } diff --git a/src/modules/user/exception/user-referral-not-found.exception.ts b/src/modules/user/exception/user-referral-not-found.exception.ts index 7787bd103..7f3bc914a 100644 --- a/src/modules/user/exception/user-referral-not-found.exception.ts +++ b/src/modules/user/exception/user-referral-not-found.exception.ts @@ -1,7 +1,12 @@ +import { HttpStatus } from '@nestjs/common'; import { UserException } from './user.exception'; export class UserReferralNotFoundException extends UserException { constructor(message?: string) { - super(message ?? 'UserReferral: not found'); + super( + message ?? 'UserReferral: not found', + HttpStatus.NOT_FOUND, + 'USER_REFERRAL_NOT_FOUND', + ); } } diff --git a/src/modules/user/exception/user.exception.ts b/src/modules/user/exception/user.exception.ts index 0086ee9d3..5133fd7b7 100644 --- a/src/modules/user/exception/user.exception.ts +++ b/src/modules/user/exception/user.exception.ts @@ -1 +1,7 @@ -export class UserException extends Error {} +import { BaseHttpException } from '../../../common/base-http.exception'; + +export class UserException extends BaseHttpException { + constructor(message: string, statusCode: number, code?: string) { + super(`User -> ${message}`, statusCode, code); + } +} diff --git a/src/modules/user/user.usecase.ts b/src/modules/user/user.usecase.ts index 025aff066..a2dc19f87 100644 --- a/src/modules/user/user.usecase.ts +++ b/src/modules/user/user.usecase.ts @@ -56,6 +56,8 @@ import { SequelizeAttemptChangeEmailRepository } from './attempt-change-email.re import { AttemptChangeEmailAlreadyVerifiedException } from './exception/attempt-change-email-already-verified.exception'; import { AttemptChangeEmailHasExpiredException } from './exception/attempt-change-email-has-expired.exception'; import { AttemptChangeEmailNotFoundException } from './exception/attempt-change-email-not-found.exception'; +import { UserEmailAlreadyInUseException } from './exception/user-email-already-in-use.exception'; +import { UserNotFoundException } from './exception/user-not-found.exception'; class ReferralsNotAvailableError extends Error { constructor() { @@ -817,7 +819,7 @@ export class UserUseCases { const user = await this.userRepository.findByUuid(userUuid); if (!user) { - throw new UserNotFoundError(); + throw new UserNotFoundException(); } const maybeAlreadyExistentUser = @@ -826,7 +828,7 @@ export class UserUseCases { const userAlreadyExists = !!maybeAlreadyExistentUser; if (userAlreadyExists) { - throw new UserAlreadyRegisteredError(newEmail); + throw new UserEmailAlreadyInUseException(newEmail); } const { uuid, email } = user; @@ -861,7 +863,7 @@ export class UserUseCases { const userAlreadyExists = !!maybeAlreadyExistentUser; if (userAlreadyExists) { - throw new UserAlreadyRegisteredError(newEmail); + throw new UserEmailAlreadyInUseException(newEmail); } const isTheSameEmail = user.email === newEmail; From 4077afaf90a0ca5d20a8ab6faafd6d9277c36b1b Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Wed, 3 Jan 2024 07:18:26 -0400 Subject: [PATCH 15/53] chore: add sendgrid template ID --- .env.template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.template b/.env.template index 49338768e..1bd25f0b1 100644 --- a/.env.template +++ b/.env.template @@ -28,7 +28,7 @@ SENDGRID_TEMPLATE_DRIVE_SHARING_ROLE_UPDATED=d-0c9cbd0a649d4cee8eac6a4ae68736c9 SENDGRID_TEMPLATE_SEND_LINK_CREATE_RECEIVER=d-eb5a1fd73d764e9991a25e2a0297f279 SENDGRID_TEMPLATE_SEND_LINK_CREATE_SENDER=d-7889146930fa421083b4bf1cdcaedab3 SENDGRID_TEMPLATE_DRIVE_UPDATE_USER_EMAIL=d-46a66194ad8e4bb6919d39e20d9c34d1 -SENDGRID_TEMPLATE_DRIVE_UNBLOCK_ACCOUNT=d-0c9cbd0a649d4cee8eac6a4ae68736c9 +SENDGRID_TEMPLATE_DRIVE_UNBLOCK_ACCOUNT=d-d91905dc7a7549ada00d0fc0bb0a55b1 SHARE_DOMAINS=http://localhost:3000 PCREATED_USERS_PASSWORD=example From 4ba22f86fea05c0a3f38b1282feeab8d18df3d9c Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Wed, 3 Jan 2024 06:46:43 -0400 Subject: [PATCH 16/53] fix: decode sharing password header --- src/modules/sharing/sharing.controller.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/modules/sharing/sharing.controller.ts b/src/modules/sharing/sharing.controller.ts index a75d87512..02fb10e5d 100644 --- a/src/modules/sharing/sharing.controller.ts +++ b/src/modules/sharing/sharing.controller.ts @@ -21,6 +21,7 @@ import { import { Response } from 'express'; import { ApiBearerAuth, + ApiHeader, ApiOkResponse, ApiOperation, ApiParam, @@ -70,6 +71,10 @@ export class SharingController { description: 'Id of the sharing', type: String, }) + @ApiHeader({ + name: 'x-share-password', + description: 'URI Encoded password to get access to the sharing', + }) @ApiOkResponse({ description: 'Get sharing metadata' }) async getPublicSharing( @Param('sharingId') sharingId: Sharing['id'], @@ -79,7 +84,12 @@ export class SharingController { if (!code) { throw new BadRequestException('Code is required'); } - return this.sharingService.getPublicSharingById(sharingId, code, password); + const decodedPassword = password ? decodeURIComponent(password) : null; + return this.sharingService.getPublicSharingById( + sharingId, + code, + decodedPassword, + ); } @Get('/public/:sharingId/item') From 002ea8cf6afff073400964fc8df941ca73fa3b10 Mon Sep 17 00:00:00 2001 From: Edison J Padilla Date: Wed, 3 Jan 2024 10:42:05 -0400 Subject: [PATCH 17/53] Chore: remove middleware logger for production environment --- src/main.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main.ts b/src/main.ts index 8d553e162..390f0956f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,12 +12,15 @@ import { SwaggerCustomOptions, SwaggerModule, } from '@nestjs/swagger'; +import configuration from './config/configuration'; import { TransformInterceptor } from './lib/transform.interceptor'; import { RequestLoggerMiddleware } from './middlewares/requests-logger'; import { NestExpressApplication } from '@nestjs/platform-express'; import { AuthGuard } from './modules/auth/auth.guard'; -const APP_PORT = process.env.PORT || 3000; +const config = configuration(); +const APP_PORT = config.port || 3000; + async function bootstrap() { const logger = new Logger(); const app = await NestFactory.create(AppModule, { @@ -41,7 +44,7 @@ async function bootstrap() { // logger: WinstonLogger.getLogger(), }); - const enableTrustProxy = process.env.NODE_ENV === 'production'; + const enableTrustProxy = config.isProduction; app.set('trust proxy', enableTrustProxy); app.useGlobalPipes(new ValidationPipe({ transform: true })); @@ -50,7 +53,10 @@ async function bootstrap() { app.use(helmet()); app.use(apiMetrics()); - app.use(RequestLoggerMiddleware); + if (!config.isProduction) { + app.use(RequestLoggerMiddleware); + } + app.setGlobalPrefix('api'); app.disable('x-powered-by'); app.enableShutdownHooks(); From 4a3487a9d32577b22c3c7b7565c9e0f9784ed43f Mon Sep 17 00:00:00 2001 From: Edison J Padilla Date: Wed, 3 Jan 2024 20:36:00 -0400 Subject: [PATCH 18/53] PB-1142: Authenticate the user with the new email --- src/modules/user/user.usecase.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/modules/user/user.usecase.ts b/src/modules/user/user.usecase.ts index a2dc19f87..cbd907029 100644 --- a/src/modules/user/user.usecase.ts +++ b/src/modules/user/user.usecase.ts @@ -932,6 +932,16 @@ export class UserUseCases { attemptChangeEmailId, ); - return emails; + const user = await this.userRepository.findByEmail(emails.newEmail); + const newTokenPayload = this.getNewTokenPayload(user); + + return { + ...emails, + newAuthentication: { + token: SignEmail(user.email, this.configService.get('secrets.jwt')), + newToken: Sign(newTokenPayload, this.configService.get('secrets.jwt')), + user, + }, + }; } } From 436368ff048b06c903137ded6e03d133c1ad51bc Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Thu, 4 Jan 2024 09:58:23 -0400 Subject: [PATCH 19/53] chore: add users controller unit tests --- src/modules/user/user.controller.spec.ts | 157 +++++++++++++++++++++++ src/modules/user/user.controller.ts | 5 +- 2 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 src/modules/user/user.controller.spec.ts diff --git a/src/modules/user/user.controller.spec.ts b/src/modules/user/user.controller.spec.ts new file mode 100644 index 000000000..a73bc9fdc --- /dev/null +++ b/src/modules/user/user.controller.spec.ts @@ -0,0 +1,157 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { + BadRequestException, + ForbiddenException, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import getEnv from '../../config/configuration'; +import { UserController } from './user.controller'; +import { UserUseCases } from './user.usecase'; +import { NotificationService } from '../../externals/notifications/notification.service'; +import { KeyServerUseCases } from '../keyserver/key-server.usecase'; +import { CryptoService } from '../../externals/crypto/crypto.service'; +import { SharingService } from '../sharing/sharing.service'; +import { SignWithCustomDuration } from '../../middlewares/passport'; +import { newUser } from '../../../test/fixtures'; +import { AccountTokenAction } from './user.domain'; + +jest.mock('../../config/configuration', () => { + return { + __esModule: true, + default: jest.fn(() => ({ + secrets: { + jwt: 'Test', + }, + })), + }; +}); + +describe('User Controller', () => { + let userController: UserController; + let userUseCases: DeepMocked; + let notificationService: DeepMocked; + let keyServerUseCases: DeepMocked; + let cryptoService: DeepMocked; + let sharingService: DeepMocked; + + beforeEach(async () => { + userUseCases = createMock(); + notificationService = createMock(); + keyServerUseCases = createMock(); + cryptoService = createMock(); + sharingService = createMock(); + + userController = new UserController( + userUseCases, + notificationService, + keyServerUseCases, + cryptoService, + sharingService, + ); + }); + + it('should be defined', () => { + expect(userController).toBeDefined(); + }); + + describe('POST /unblock-account', () => { + it('When user is not found, then returns NotFoundException', async () => { + userUseCases.sendAccountUnblockEmail.mockRejectedValueOnce( + new NotFoundException(), + ); + await expect( + userController.requestAccountUnblock({ email: 'test@test.com' }), + ).rejects.toThrow(NotFoundException); + }); + it('When an unexpected error is throw, then returns InternalServerException', async () => { + userUseCases.sendAccountUnblockEmail.mockRejectedValueOnce(new Error()); + await expect( + userController.requestAccountUnblock({ email: 'test@test.com' }), + ).rejects.toThrow(InternalServerErrorException); + }); + }); + + describe('PUT /unblock-account', () => { + const user = newUser(); + const validToken = SignWithCustomDuration( + { + payload: { + uuid: user.uuid, + email: user.email, + action: AccountTokenAction.Unblock, + }, + }, + getEnv().secrets.jwt, + '48h', + ); + it('When token has invalid signature, then fails', async () => { + const invalidToken = SignWithCustomDuration( + { + payload: {}, + }, + 'Invalid Signature', + '48h', + ); + await expect(userController.accountUnblock(invalidToken)).rejects.toThrow( + ForbiddenException, + ); + }); + + it('When token has valid signature but incorrect properties, then fails', async () => { + const invalidToken = SignWithCustomDuration( + { + payload: { + uuid: 'invalid Uuid', + email: 'test@test.com', + action: 'not unlock action', + }, + }, + getEnv().secrets.jwt, + '48h', + ); + await expect(userController.accountUnblock(invalidToken)).rejects.toThrow( + ForbiddenException, + ); + }); + + it('When token is expired, then fails', async () => { + const expiredToken = SignWithCustomDuration( + { + payload: {}, + }, + getEnv().secrets.jwt, + '-48h', + ); + await expect(userController.accountUnblock(expiredToken)).rejects.toThrow( + ForbiddenException, + ); + }); + + it('When token is valid but useCase throws badRequest or Forbidden, then fails with respective error', async () => { + userUseCases.unblockAccount + .mockRejectedValueOnce(new BadRequestException()) + .mockRejectedValueOnce(new ForbiddenException()); + await expect(userController.accountUnblock(validToken)).rejects.toThrow( + BadRequestException, + ); + await expect(userController.accountUnblock(validToken)).rejects.toThrow( + ForbiddenException, + ); + }); + + it('When token is valid but useCase throws unexpected error, then fails with InternalServerError', async () => { + userUseCases.unblockAccount.mockRejectedValueOnce(new Error()); + await expect(userController.accountUnblock(validToken)).rejects.toThrow( + InternalServerErrorException, + ); + }); + + it('When token and user are correct, then resolves', async () => { + userUseCases.unblockAccount.mockResolvedValueOnce(); + await expect( + userController.accountUnblock(validToken), + ).resolves.toBeUndefined(); + }); + }); +}); diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index 3419198a1..34483070f 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -437,10 +437,7 @@ export class UserController { summary: 'Request account unblock', }) @Public() - async requestAccountUnblock( - @Body() body: RequestAccountUnblock, - @Res({ passthrough: true }) res: Response, - ) { + async requestAccountUnblock(@Body() body: RequestAccountUnblock) { try { return await this.userUseCases.sendAccountUnblockEmail(body.email); } catch (err) { From 59e13c6ef5a12e5aa89317e9f8eb72fd1165cfbc Mon Sep 17 00:00:00 2001 From: Sergio Gutierrez Villalba Date: Mon, 8 Jan 2024 10:25:24 +0100 Subject: [PATCH 20/53] hotfix(env): put the proper env value on configuration --- src/config/configuration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/configuration.ts b/src/config/configuration.ts index aa6dceb34..a4f7c35e2 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -50,7 +50,7 @@ export default () => ({ url: process.env.STORAGE_API_URL, auth: { username: process.env.GATEWAY_USER, - password: process.env.GATEWAY_PASSWORD, + password: process.env.GATEWAY_PASS, }, }, drive: { From 19ce178bfb7892bda2cd8064b2a02fed7eb3cc6c Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Tue, 9 Jan 2024 03:18:05 -0400 Subject: [PATCH 21/53] feat: add last passwordChangedAt middleware --- src/lib/jwt.ts | 8 ++ src/modules/auth/jwt.strategy.spec.ts | 117 ++++++++++++++++++++++++++ src/modules/auth/jwt.strategy.ts | 18 ++++ 3 files changed, 143 insertions(+) create mode 100644 src/modules/auth/jwt.strategy.spec.ts diff --git a/src/lib/jwt.ts b/src/lib/jwt.ts index c1b3cbd30..193ec3491 100644 --- a/src/lib/jwt.ts +++ b/src/lib/jwt.ts @@ -41,3 +41,11 @@ export function verifyToken(token: string, secret: string) { export function verifyWithDefaultSecret(token: string) { return verify(token, getEnv().secrets.jwt); } + +export function getTokenDefaultIat() { + return Math.floor(Date.now() / 1000); +} + +export function isTokenIatGreaterThanDate(date: Date, iat: number) { + return Math.floor(date.getTime() / 1000) < iat; +} diff --git a/src/modules/auth/jwt.strategy.spec.ts b/src/modules/auth/jwt.strategy.spec.ts new file mode 100644 index 000000000..1dcba419c --- /dev/null +++ b/src/modules/auth/jwt.strategy.spec.ts @@ -0,0 +1,117 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock } from '@golevelup/ts-jest'; +import { UserUseCases } from '../user/user.usecase'; +import { JwtStrategy } from './jwt.strategy'; +import { newUser } from '../../../test/fixtures'; +import { ConfigService } from '@nestjs/config'; +import { UnauthorizedException } from '@nestjs/common'; +import { getTokenDefaultIat } from '../../lib/jwt'; + +describe('Jwt strategy', () => { + let userUseCases: UserUseCases; + let strategy: JwtStrategy; + + beforeEach(async () => { + const moduleRef = await createTestingModule(); + userUseCases = moduleRef.get(UserUseCases); + strategy = moduleRef.get(JwtStrategy); + }); + + it('When token is old, then fail', async () => { + await expect(strategy.validate({ email: 'test@test.com' })).rejects.toThrow( + new UnauthorizedException('Old token version detected'), + ); + }); + + it('When user does not exist, then fail', async () => { + jest.spyOn(userUseCases, 'getUser').mockResolvedValue(null); + + await expect( + strategy.validate({ payload: { uuid: 'anyUuid' } }), + ).rejects.toThrow(UnauthorizedException); + }); + + it('When token iat is older than lastPasswordChangedAt , then fail', async () => { + const user = newUser(); + const greaterDate = new Date(); + greaterDate.setMinutes(greaterDate.getMinutes() + 1); + user.lastPasswordChangedAt = greaterDate; + const tokenIat = getTokenDefaultIat(); + + jest.spyOn(userUseCases, 'getUser').mockResolvedValue(user); + + await expect( + strategy.validate({ payload: { uuid: 'anyUuid' }, iat: tokenIat }), + ).rejects.toThrow(UnauthorizedException); + }); + + it('When user has lastPasswordChangedAt older than token iat, then return user', async () => { + const tokenIat = getTokenDefaultIat(); + const user = newUser(); + const olderDate = new Date(); + olderDate.setMinutes(olderDate.getMinutes() - 1); + user.lastPasswordChangedAt = olderDate; + + jest.spyOn(userUseCases, 'getUser').mockResolvedValue(user); + + await expect( + strategy.validate({ payload: { uuid: 'anyUuid' }, iat: tokenIat }), + ).resolves.toBe(user); + }); + + it('When token has iat but user has not lastPasswordChangedAt, then return user', async () => { + const tokenIat = getTokenDefaultIat(); + const user = newUser(); + user.lastPasswordChangedAt = null; + + jest.spyOn(userUseCases, 'getUser').mockResolvedValue(user); + + await expect( + strategy.validate({ payload: { uuid: 'anyUuid' }, iat: tokenIat }), + ).resolves.toBe(user); + }); + + it('When user is guest on shared workspace, then return owner', async () => { + const guestUser = newUser(); + const owner = newUser(); + const anyUuid = 'testUuid'; + guestUser.bridgeUser = owner.username; + const olderDate = new Date(); + olderDate.setMinutes(olderDate.getMinutes() - 1); + guestUser.lastPasswordChangedAt = olderDate; + + const tokenIat = getTokenDefaultIat(); + + const getUserSpy = jest + .spyOn(userUseCases, 'getUser') + .mockResolvedValue(guestUser); + + const getUserByUsernameSpy = jest + .spyOn(userUseCases, 'getUserByUsername') + .mockResolvedValue(owner); + + await expect( + strategy.validate({ payload: { uuid: anyUuid }, iat: tokenIat }), + ).resolves.toBe(owner); + + expect(getUserSpy).toHaveBeenCalledWith(anyUuid); + expect(getUserByUsernameSpy).toHaveBeenCalledWith(owner.username); + }); +}); + +const createTestingModule = (): Promise => { + return Test.createTestingModule({ + controllers: [], + providers: [ + { + provide: UserUseCases, + useValue: createMock(), + }, + { + provide: ConfigService, + useValue: createMock(), + }, + JwtStrategy, + ], + }).compile(); +}; diff --git a/src/modules/auth/jwt.strategy.ts b/src/modules/auth/jwt.strategy.ts index 1dc4f350c..788a35787 100644 --- a/src/modules/auth/jwt.strategy.ts +++ b/src/modules/auth/jwt.strategy.ts @@ -9,6 +9,7 @@ import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { User } from '../user/user.domain'; import { UserUseCases } from '../user/user.usecase'; +import { isTokenIatGreaterThanDate } from '../../lib/jwt'; export interface JwtPayload { email: string; @@ -42,6 +43,23 @@ export class JwtStrategy extends PassportStrategy(Strategy, strategyId) { throw new UnauthorizedException(); } + const userWithoutLastPasswordChangedAt = + user.lastPasswordChangedAt === null; + + const tokenOlderThanLastPasswordChangedAt = + user.lastPasswordChangedAt && + !isTokenIatGreaterThanDate( + new Date(user.lastPasswordChangedAt), + payload.iat, + ); + + if ( + !userWithoutLastPasswordChangedAt && + tokenOlderThanLastPasswordChangedAt + ) { + throw new UnauthorizedException(); + } + if (user.isGuestOnSharedWorkspace()) { return this.userUseCases.getUserByUsername(user.bridgeUser); } From 3808787a25a639a9308ed2d8327adabd6d092f5a Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Tue, 9 Jan 2024 03:35:50 -0400 Subject: [PATCH 22/53] feat: add iat to userUseCase getNewTokenPayload --- src/modules/user/user.usecase.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/modules/user/user.usecase.ts b/src/modules/user/user.usecase.ts index cbd907029..14606e77a 100644 --- a/src/modules/user/user.usecase.ts +++ b/src/modules/user/user.usecase.ts @@ -58,6 +58,7 @@ import { AttemptChangeEmailHasExpiredException } from './exception/attempt-chang import { AttemptChangeEmailNotFoundException } from './exception/attempt-change-email-not-found.exception'; import { UserEmailAlreadyInUseException } from './exception/user-email-already-in-use.exception'; import { UserNotFoundException } from './exception/user-not-found.exception'; +import { getTokenDefaultIat } from '../../lib/jwt'; class ReferralsNotAvailableError extends Error { constructor() { @@ -533,6 +534,7 @@ export class UserUseCases { pass: userData.userId, }, }, + iat: getTokenDefaultIat(), }; } From a748760bbe9a3b8d20a68d0ed933b37f0ca6cd4a Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Tue, 9 Jan 2024 09:22:38 -0400 Subject: [PATCH 23/53] chore: modified migration timestamp and prevent conflicts --- ...0000-add-lastPasswordChangedAt-to-users.js | 16 ------------- ...1854-add-lastPasswordChangedAt-to-users.js | 23 +++++++++++++++++++ src/modules/user/user.attributes.ts | 2 +- 3 files changed, 24 insertions(+), 17 deletions(-) delete mode 100644 migrations/20230530160000-add-lastPasswordChangedAt-to-users.js create mode 100644 migrations/20240109071854-add-lastPasswordChangedAt-to-users.js diff --git a/migrations/20230530160000-add-lastPasswordChangedAt-to-users.js b/migrations/20230530160000-add-lastPasswordChangedAt-to-users.js deleted file mode 100644 index 6a06d6e51..000000000 --- a/migrations/20230530160000-add-lastPasswordChangedAt-to-users.js +++ /dev/null @@ -1,16 +0,0 @@ -'use strict'; - -/** @type {import('sequelize-cli').Migration} */ -module.exports = { - async up(queryInterface, Sequelize) { - await queryInterface.addColumn('users', 'last_password_changed_at', { - type: Sequelize.DATE, - allowNull: true, - defaultValue: null, - }); - }, - - async down(queryInterface) { - await queryInterface.removeColumn('users', 'last_password_changed_at'); - }, -}; diff --git a/migrations/20240109071854-add-lastPasswordChangedAt-to-users.js b/migrations/20240109071854-add-lastPasswordChangedAt-to-users.js new file mode 100644 index 000000000..dfd7464fe --- /dev/null +++ b/migrations/20240109071854-add-lastPasswordChangedAt-to-users.js @@ -0,0 +1,23 @@ +'use strict'; + +const tableName = 'users'; +const newColumn = 'last_password_changed_at'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + const tableDefinition = await queryInterface.describeTable(tableName); + // Only add column if is not created in the table + if (!tableDefinition[newColumn]) { + return queryInterface.addColumn(tableName, newColumn, { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }); + } + }, + + async down(queryInterface) { + await queryInterface.removeColumn(tableName, newColumn); + }, +}; diff --git a/src/modules/user/user.attributes.ts b/src/modules/user/user.attributes.ts index 03a972643..3f72dbcc3 100644 --- a/src/modules/user/user.attributes.ts +++ b/src/modules/user/user.attributes.ts @@ -26,5 +26,5 @@ export interface UserAttributes { sharedWorkspace: boolean; tempKey: string; avatar: string; - lastPasswordChangedAt: Date; + lastPasswordChangedAt?: Date; } From 234362944acf1ba79945edf9a4c55754ae1f1557 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Tue, 9 Jan 2024 11:20:04 -0400 Subject: [PATCH 24/53] chore: change test order and mremove migration previous column check --- ...1854-add-lastPasswordChangedAt-to-users.js | 14 ++++------- src/modules/auth/jwt.strategy.spec.ts | 24 +++++++++---------- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/migrations/20240109071854-add-lastPasswordChangedAt-to-users.js b/migrations/20240109071854-add-lastPasswordChangedAt-to-users.js index dfd7464fe..b37b06ca3 100644 --- a/migrations/20240109071854-add-lastPasswordChangedAt-to-users.js +++ b/migrations/20240109071854-add-lastPasswordChangedAt-to-users.js @@ -6,15 +6,11 @@ const newColumn = 'last_password_changed_at'; /** @type {import('sequelize-cli').Migration} */ module.exports = { async up(queryInterface, Sequelize) { - const tableDefinition = await queryInterface.describeTable(tableName); - // Only add column if is not created in the table - if (!tableDefinition[newColumn]) { - return queryInterface.addColumn(tableName, newColumn, { - type: Sequelize.DATE, - allowNull: true, - defaultValue: null, - }); - } + await queryInterface.addColumn(tableName, newColumn, { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }); }, async down(queryInterface) { diff --git a/src/modules/auth/jwt.strategy.spec.ts b/src/modules/auth/jwt.strategy.spec.ts index 1dcba419c..29bd486a9 100644 --- a/src/modules/auth/jwt.strategy.spec.ts +++ b/src/modules/auth/jwt.strategy.spec.ts @@ -17,7 +17,7 @@ describe('Jwt strategy', () => { strategy = moduleRef.get(JwtStrategy); }); - it('When token is old, then fail', async () => { + it('When token is old version, then fail', async () => { await expect(strategy.validate({ email: 'test@test.com' })).rejects.toThrow( new UnauthorizedException('Old token version detected'), ); @@ -31,12 +31,12 @@ describe('Jwt strategy', () => { ).rejects.toThrow(UnauthorizedException); }); - it('When token iat is older than lastPasswordChangedAt , then fail', async () => { + it('When token iat is less than lastPasswordChangedAt , then fail', async () => { const user = newUser(); - const greaterDate = new Date(); + const tokenIat = getTokenDefaultIat(); + const greaterDate = new Date(tokenIat * 1000); greaterDate.setMinutes(greaterDate.getMinutes() + 1); user.lastPasswordChangedAt = greaterDate; - const tokenIat = getTokenDefaultIat(); jest.spyOn(userUseCases, 'getUser').mockResolvedValue(user); @@ -45,10 +45,10 @@ describe('Jwt strategy', () => { ).rejects.toThrow(UnauthorizedException); }); - it('When user has lastPasswordChangedAt older than token iat, then return user', async () => { - const tokenIat = getTokenDefaultIat(); + it('When user iat is greater than lastPasswordChangedAt, then return user', async () => { const user = newUser(); - const olderDate = new Date(); + const tokenIat = getTokenDefaultIat(); + const olderDate = new Date(tokenIat * 1000); olderDate.setMinutes(olderDate.getMinutes() - 1); user.lastPasswordChangedAt = olderDate; @@ -71,16 +71,16 @@ describe('Jwt strategy', () => { ).resolves.toBe(user); }); - it('When user is guest on shared workspace, then return owner', async () => { + it('When user is guest on shared workspace and token is valid, then return owner', async () => { const guestUser = newUser(); const owner = newUser(); - const anyUuid = 'testUuid'; guestUser.bridgeUser = owner.username; - const olderDate = new Date(); - olderDate.setMinutes(olderDate.getMinutes() - 1); - guestUser.lastPasswordChangedAt = olderDate; + const anyUuid = 'testUuid'; const tokenIat = getTokenDefaultIat(); + const olderDate = new Date(tokenIat * 1000); + olderDate.setMinutes(olderDate.getMinutes() - 1); + guestUser.lastPasswordChangedAt = olderDate; const getUserSpy = jest .spyOn(userUseCases, 'getUser') From 960238b432f201e58aca0f9e59f979cabe337f5f Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Wed, 10 Jan 2024 12:02:35 -0400 Subject: [PATCH 25/53] feat: change account unblock logic according to lastPasswordChangeAt field --- src/modules/user/dto/account-unblock.dto.ts | 3 +- src/modules/user/user.controller.ts | 13 +- src/modules/user/user.usecase.spec.ts | 147 +++++++++++--------- src/modules/user/user.usecase.ts | 20 ++- 4 files changed, 106 insertions(+), 77 deletions(-) diff --git a/src/modules/user/dto/account-unblock.dto.ts b/src/modules/user/dto/account-unblock.dto.ts index 4a35d2b11..be6746c04 100644 --- a/src/modules/user/dto/account-unblock.dto.ts +++ b/src/modules/user/dto/account-unblock.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty } from 'class-validator'; +import { IsNotEmpty, IsEmail } from 'class-validator'; export class RequestAccountUnblock { @ApiProperty({ @@ -7,5 +7,6 @@ export class RequestAccountUnblock { description: 'User email', }) @IsNotEmpty() + @IsEmail() email: string; } diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index 34483070f..d2697e4c5 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -50,7 +50,7 @@ import { RequestRecoverAccountDto, ResetAccountDto, } from './dto/recover-account.dto'; -import { verifyToken } from '../../lib/jwt'; +import { verifyToken, verifyWithDefaultSecret } from '../../lib/jwt'; import getEnv from '../../config/configuration'; import { validate } from 'uuid'; import { CryptoService } from '../../externals/crypto/crypto.service'; @@ -468,25 +468,26 @@ export class UserController { async accountUnblock(@Query('token') token: string) { let decodedContent: { payload: { uuid: string; action: string; email: string }; + iat: number; }; - try { - const decoded = verifyToken(token, getEnv().secrets.jwt); - + const decoded = verifyWithDefaultSecret(token); if (typeof decoded === 'string') { throw new ForbiddenException(); } - decodedContent = decoded as { payload: { uuid: string; action: string; email: string }; + iat: number; }; } catch (err) { throw new ForbiddenException(); } const tokenPayload = decodedContent?.payload; + const tokenIat = decodedContent.iat; if ( + !tokenIat || !tokenPayload.action || !tokenPayload.uuid || !tokenPayload.email || @@ -499,7 +500,7 @@ export class UserController { const { uuid, email } = tokenPayload; try { - await this.userUseCases.unblockAccount(uuid, token); + await this.userUseCases.unblockAccount(uuid, tokenIat); } catch (err) { if ( err instanceof ForbiddenException || diff --git a/src/modules/user/user.usecase.spec.ts b/src/modules/user/user.usecase.spec.ts index 9638cd938..537f79414 100644 --- a/src/modules/user/user.usecase.spec.ts +++ b/src/modules/user/user.usecase.spec.ts @@ -30,6 +30,7 @@ import { NotFoundException, } from '@nestjs/common'; import { SignWithCustomDuration } from '../../middlewares/passport'; +import { getTokenDefaultIat } from '../../lib/jwt'; jest.mock('../../middlewares/passport', () => { const originalModule = jest.requireActual('../../middlewares/passport'); @@ -241,85 +242,101 @@ describe('User use cases', () => { }); }); - describe('Request account unblock', () => { - it('When user does not exist, then fail', async () => { - const userFindByEmailSpy = jest.spyOn(userRepository, 'findByEmail'); - const email = 'email@test.com'; - userFindByEmailSpy.mockReturnValueOnce(null); + describe('Unblocking user account', () => { + describe('Request Account unblock', () => { + const fixedSystemCurrentDate = new Date('2020-02-19'); - await expect(userUseCases.sendAccountUnblockEmail(email)).rejects.toThrow( - NotFoundException, - ); - }); + beforeAll(async () => { + jest.useFakeTimers(); + jest.setSystemTime(fixedSystemCurrentDate); + }); - it('When user user exists, then add unblockToken and send email', async () => { - const userFindByEmailSpy = jest.spyOn(userRepository, 'findByEmail'); - const userUpdateSpy = jest.spyOn(userRepository, 'updateByUuid'); - const emailSendSpy = jest.spyOn(mailerService, 'send'); - const configServiceGetSpy = jest.spyOn(configService, 'get'); - const email = 'email@test.com'; - userFindByEmailSpy.mockResolvedValueOnce(user); - userUpdateSpy.mockResolvedValueOnce(null); - emailSendSpy.mockResolvedValueOnce(null); - configServiceGetSpy.mockReturnValue('secret'); - - await userUseCases.sendAccountUnblockEmail(email); - - expect(SignWithCustomDuration).toHaveBeenCalledWith( - { - payload: { - uuid: user.uuid, - email: user.email, - action: AccountTokenAction.Unblock, - }, - }, - 'secret', - '48h', - ); - expect(userUpdateSpy).toHaveBeenCalledWith(user.uuid, { - unblockToken: 'anyToken', + afterAll(async () => { + jest.useRealTimers(); }); - }); - }); - describe('Unblock user account', () => { - it('When user does not exist, then fail', async () => { - const userFindByUuidSpy = jest.spyOn(userRepository, 'findByUuid'); - userFindByUuidSpy.mockReturnValueOnce(null); + it('When user does not exist, then fail', async () => { + const userFindByEmailSpy = jest.spyOn(userRepository, 'findByEmail'); + const email = 'email@test.com'; + userFindByEmailSpy.mockReturnValueOnce(null); - await expect(userUseCases.unblockAccount(user.uuid)).rejects.toThrow( - BadRequestException, - ); + await expect( + userUseCases.sendAccountUnblockEmail(email), + ).rejects.toThrow(NotFoundException); + }); + + it('When user user exists, then user lastPasswordChangedAt is updated', async () => { + const userFindByEmailSpy = jest.spyOn(userRepository, 'findByEmail'); + const userUpdateSpy = jest.spyOn(userRepository, 'updateByUuid'); + const configServiceGetSpy = jest.spyOn(configService, 'get'); + const email = 'email@test.com'; + userFindByEmailSpy.mockResolvedValueOnce(user); + configServiceGetSpy.mockReturnValue('secret'); + + await userUseCases.sendAccountUnblockEmail(email); + + expect(SignWithCustomDuration).toHaveBeenCalledWith( + { + payload: { + uuid: user.uuid, + email: user.email, + action: AccountTokenAction.Unblock, + }, + iat: getTokenDefaultIat(), + }, + 'secret', + '48h', + ); + expect(userUpdateSpy).toHaveBeenCalledWith(user.uuid, { + lastPasswordChangedAt: fixedSystemCurrentDate, + }); + }); }); - it('When token is not the last one requested, then fail', async () => { - const userFindByUuidSpy = jest.spyOn(userRepository, 'findByUuid'); - const unblockUser = User.build({ - ...user, - unblockToken: 'lastToken', + describe('Unblock account', () => { + it('When user does not exist, then fail', async () => { + const userFindByUuidSpy = jest.spyOn(userRepository, 'findByUuid'); + userFindByUuidSpy.mockReturnValueOnce(null); + + await expect(userUseCases.unblockAccount(user.uuid)).rejects.toThrow( + BadRequestException, + ); }); - userFindByUuidSpy.mockResolvedValueOnce(unblockUser); + it('When token is older than lastPasswordChangedAt, then fail', async () => { + const userFindByUuidSpy = jest.spyOn(userRepository, 'findByUuid'); + const olderIat = getTokenDefaultIat(); + const recentDate = new Date(olderIat * 1000); + recentDate.setMilliseconds(recentDate.getMilliseconds() + 1); + const unblockUser = User.build({ + ...user, + lastPasswordChangedAt: recentDate, + }); - await expect( - userUseCases.unblockAccount(unblockUser.uuid, 'notLastToken'), - ).rejects.toThrow(ForbiddenException); - }); + userFindByUuidSpy.mockResolvedValueOnce(unblockUser); - it('When user found and tokens are equal, then user unblockToken and errorLoginCount are reset', async () => { - const userFindByUuidSpy = jest.spyOn(userRepository, 'findByUuid'); - const userUpdateByUuidSpy = jest.spyOn(userRepository, 'updateByUuid'); - const unblockUser = User.build({ - ...user, - unblockToken: 'lastToken', + await expect( + userUseCases.unblockAccount(unblockUser.uuid, olderIat), + ).rejects.toThrow(ForbiddenException); }); - userFindByUuidSpy.mockResolvedValueOnce(unblockUser); - await userUseCases.unblockAccount(unblockUser.uuid, 'lastToken'); + it('When token is greater than lastPasswordChangedAt, then update user', async () => { + const userFindByUuidSpy = jest.spyOn(userRepository, 'findByUuid'); + const tokenIat = getTokenDefaultIat(); + const olderDate = new Date(tokenIat * 1000); + olderDate.setMilliseconds(olderDate.getMilliseconds() - 1); + const unblockUser = User.build({ + ...user, + lastPasswordChangedAt: olderDate, + }); + userFindByUuidSpy.mockResolvedValueOnce(unblockUser); + + await userUseCases.unblockAccount(unblockUser.uuid, tokenIat); - expect(userUpdateByUuidSpy).toHaveBeenCalledWith(unblockUser.uuid, { - errorLoginCount: 0, - unblockToken: null, + expect(userRepository.updateByUuid).toHaveBeenCalledWith(user.uuid, { + errorLoginCount: 0, + lastPasswordChangedAt: null, + }); }); }); }); diff --git a/src/modules/user/user.usecase.ts b/src/modules/user/user.usecase.ts index 8cb235fcf..e6aff8b85 100644 --- a/src/modules/user/user.usecase.ts +++ b/src/modules/user/user.usecase.ts @@ -60,7 +60,7 @@ import { AttemptChangeEmailHasExpiredException } from './exception/attempt-chang import { AttemptChangeEmailNotFoundException } from './exception/attempt-change-email-not-found.exception'; import { UserEmailAlreadyInUseException } from './exception/user-email-already-in-use.exception'; import { UserNotFoundException } from './exception/user-not-found.exception'; -import { getTokenDefaultIat } from '../../lib/jwt'; +import { getTokenDefaultIat, isTokenIatGreaterThanDate } from '../../lib/jwt'; class ReferralsNotAvailableError extends Error { constructor() { @@ -715,13 +715,14 @@ export class UserUseCases { email: user.email, action: AccountTokenAction.Unblock, }, + iat: getTokenDefaultIat(), }, secret, '48h', ); await this.userRepository.updateByUuid(user.uuid, { - unblockToken: unblockAccountToken, + lastPasswordChangedAt: new Date(), }); const driveWebUrl = this.configService.get('clients.drive.web'); @@ -737,20 +738,29 @@ export class UserUseCases { }); } - async unblockAccount(userUuid: User['uuid'], token?: string): Promise { + async unblockAccount( + userUuid: User['uuid'], + tokenIat?: number, + ): Promise { const user = await this.userRepository.findByUuid(userUuid); if (!user) { throw new BadRequestException(); } - if (token && user?.unblockToken !== token) { + if ( + tokenIat && + !isTokenIatGreaterThanDate( + new Date(user?.lastPasswordChangedAt), + tokenIat, + ) + ) { throw new ForbiddenException(); } await this.userRepository.updateByUuid(userUuid, { errorLoginCount: 0, - unblockToken: null, + lastPasswordChangedAt: null, }); } From 2b18d1184b8e0dadf659012518a34992ccd1823d Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Wed, 10 Jan 2024 12:05:18 -0400 Subject: [PATCH 26/53] chore: remove migration of unblock_token --- ...2070906-add-unblockToken-field-to-users.js | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 migrations/20240102070906-add-unblockToken-field-to-users.js diff --git a/migrations/20240102070906-add-unblockToken-field-to-users.js b/migrations/20240102070906-add-unblockToken-field-to-users.js deleted file mode 100644 index 612acb01d..000000000 --- a/migrations/20240102070906-add-unblockToken-field-to-users.js +++ /dev/null @@ -1,19 +0,0 @@ -'use strict'; - -const tableName = 'users'; -const newColumn = 'unblock_token'; - -/** @type {import('sequelize-cli').Migration} */ -module.exports = { - async up(queryInterface, Sequelize) { - await queryInterface.addColumn(tableName, newColumn, { - type: Sequelize.TEXT, - defaultValue: null, - allowNull: true, - }); - }, - - async down(queryInterface) { - await queryInterface.removeColumn(tableName, newColumn); - }, -}; From 6f44c2c57f30f3b0ff893c11af83308cbaff6e05 Mon Sep 17 00:00:00 2001 From: Edison J Padilla Date: Wed, 10 Jan 2024 22:08:49 -0400 Subject: [PATCH 27/53] fix: showing error message or complete error --- src/lib/http/http-exception.filter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/http/http-exception.filter.ts b/src/lib/http/http-exception.filter.ts index 3e8193a58..7c1b0ee83 100644 --- a/src/lib/http/http-exception.filter.ts +++ b/src/lib/http/http-exception.filter.ts @@ -38,7 +38,7 @@ export class HttpExceptionFilter implements ExceptionFilter { ` UNHANDLE ERROR: - ${JSON.stringify(exception)} + ${JSON.stringify(exception.message ?? exception)} `, ); From 07d90ec81b9f1c4f3da5213916ba833cc8ce8f06 Mon Sep 17 00:00:00 2001 From: Edison J Padilla Date: Thu, 11 Jan 2024 02:00:00 -0400 Subject: [PATCH 28/53] hotfix: Getting user by uuid because when I update the email and I try to get it by new email immediately, it doesn't return it updated --- src/modules/user/user.usecase.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/modules/user/user.usecase.ts b/src/modules/user/user.usecase.ts index 14606e77a..215b94259 100644 --- a/src/modules/user/user.usecase.ts +++ b/src/modules/user/user.usecase.ts @@ -934,7 +934,16 @@ export class UserUseCases { attemptChangeEmailId, ); - const user = await this.userRepository.findByEmail(emails.newEmail); + const user = await this.userRepository.findByUuid( + attemptChangeEmail.userUuid, + ); + + if (user.email !== emails.newEmail) { + user.email = emails.newEmail; + user.username = emails.newEmail; + user.bridgeUser = emails.newEmail; + } + const newTokenPayload = this.getNewTokenPayload(user); return { From 2bc68db588b2b2ee0d5a4c44150393f60a6d1562 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Sun, 14 Jan 2024 18:24:04 -0400 Subject: [PATCH 29/53] chore: remove unused variable from tests --- src/modules/user/user.usecase.spec.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/modules/user/user.usecase.spec.ts b/src/modules/user/user.usecase.spec.ts index 537f79414..1f7f17181 100644 --- a/src/modules/user/user.usecase.spec.ts +++ b/src/modules/user/user.usecase.spec.ts @@ -46,7 +46,6 @@ describe('User use cases', () => { let shareUseCases: ShareUseCases; let folderUseCases: FolderUseCases; let fileUseCases: FileUseCases; - let mailerService: MailerService; let userRepository: SequelizeUserRepository; let configService: ConfigService; @@ -87,7 +86,6 @@ describe('User use cases', () => { folderUseCases = moduleRef.get(FolderUseCases); fileUseCases = moduleRef.get(FileUseCases); userUseCases = moduleRef.get(UserUseCases); - mailerService = moduleRef.get(MailerService); configService = moduleRef.get(ConfigService); userRepository = moduleRef.get( SequelizeUserRepository, From 4039100a7bf16618def1abfdf7ff0ca471e4e919 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Sun, 14 Jan 2024 19:19:32 -0400 Subject: [PATCH 30/53] chore: change email URL blocked account --- src/modules/user/user.usecase.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/user/user.usecase.ts b/src/modules/user/user.usecase.ts index e6aff8b85..beef562d4 100644 --- a/src/modules/user/user.usecase.ts +++ b/src/modules/user/user.usecase.ts @@ -730,7 +730,7 @@ export class UserUseCases { 'mailer.templates.unblockAccountEmail', ); - const url = `${driveWebUrl}/unblock-account/${unblockAccountToken}`; + const url = `${driveWebUrl}/blocked-account/${unblockAccountToken}`; await this.mailerService.send(user.email, unblockAccountTemplateId, { email, From 86e5b2dfd4ba70c33365f148fa062a897bcbaeaa Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Sun, 14 Jan 2024 19:45:02 -0400 Subject: [PATCH 31/53] fix: use tokenDefaultIat as initial point for passwordChangedAt and consider as valid if date is equal --- src/lib/jwt.ts | 2 +- src/modules/user/user.controller.ts | 2 +- src/modules/user/user.usecase.spec.ts | 2 +- src/modules/user/user.usecase.ts | 12 +++++++----- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/lib/jwt.ts b/src/lib/jwt.ts index 193ec3491..57fc3c827 100644 --- a/src/lib/jwt.ts +++ b/src/lib/jwt.ts @@ -47,5 +47,5 @@ export function getTokenDefaultIat() { } export function isTokenIatGreaterThanDate(date: Date, iat: number) { - return Math.floor(date.getTime() / 1000) < iat; + return Math.floor(date.getTime() / 1000) <= iat; } diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index d2697e4c5..5ea63f7e7 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -465,7 +465,7 @@ export class UserController { summary: 'Resets user error login counter to unblock account', }) @Public() - async accountUnblock(@Query('token') token: string) { + async accountUnblock(@Body('token') token: string) { let decodedContent: { payload: { uuid: string; action: string; email: string }; iat: number; diff --git a/src/modules/user/user.usecase.spec.ts b/src/modules/user/user.usecase.spec.ts index 1f7f17181..dbd3d162d 100644 --- a/src/modules/user/user.usecase.spec.ts +++ b/src/modules/user/user.usecase.spec.ts @@ -305,7 +305,7 @@ describe('User use cases', () => { const userFindByUuidSpy = jest.spyOn(userRepository, 'findByUuid'); const olderIat = getTokenDefaultIat(); const recentDate = new Date(olderIat * 1000); - recentDate.setMilliseconds(recentDate.getMilliseconds() + 1); + recentDate.setSeconds(recentDate.getSeconds() + 1); const unblockUser = User.build({ ...user, lastPasswordChangedAt: recentDate, diff --git a/src/modules/user/user.usecase.ts b/src/modules/user/user.usecase.ts index beef562d4..d46bd8041 100644 --- a/src/modules/user/user.usecase.ts +++ b/src/modules/user/user.usecase.ts @@ -708,6 +708,12 @@ export class UserUseCases { throw new NotFoundException(); } + const defaultIat = getTokenDefaultIat(); + + await this.userRepository.updateByUuid(user.uuid, { + lastPasswordChangedAt: new Date(defaultIat * 1000), + }); + const unblockAccountToken = SignWithCustomDuration( { payload: { @@ -715,16 +721,12 @@ export class UserUseCases { email: user.email, action: AccountTokenAction.Unblock, }, - iat: getTokenDefaultIat(), + iat: defaultIat, }, secret, '48h', ); - await this.userRepository.updateByUuid(user.uuid, { - lastPasswordChangedAt: new Date(), - }); - const driveWebUrl = this.configService.get('clients.drive.web'); const unblockAccountTemplateId = this.configService.get( 'mailer.templates.unblockAccountEmail', From 22b599d0c958fec7baa0503fc7a4767236ac19ad Mon Sep 17 00:00:00 2001 From: Edison J Padilla Date: Fri, 12 Jan 2024 09:46:58 -0400 Subject: [PATCH 32/53] fix: update bridgeUser when is corresponding --- src/modules/user/user.repository.ts | 13 ++ src/modules/user/user.usecase.spec.ts | 248 ++++++++++++++++++++++++++ src/modules/user/user.usecase.ts | 27 ++- 3 files changed, 281 insertions(+), 7 deletions(-) diff --git a/src/modules/user/user.repository.ts b/src/modules/user/user.repository.ts index 93d583170..9cd0e2421 100644 --- a/src/modules/user/user.repository.ts +++ b/src/modules/user/user.repository.ts @@ -23,6 +23,11 @@ export interface UserRepository { ): Promise; toDomain(model: UserModel): User; toModel(domain: User): Partial; + updateBy( + where: Partial, + update: Partial, + transaction?: Transaction, + ): Promise; } @Injectable() @@ -105,6 +110,14 @@ export class SequelizeUserRepository implements UserRepository { await this.modelUser.update(update, { where: { id }, transaction }); } + async updateBy( + where: Partial, + update: Partial, + transaction?: Transaction, + ): Promise { + await this.modelUser.update(update, { where, transaction }); + } + async updateByUuid(uuid: User['uuid'], update: Partial): Promise { await this.modelUser.update(update, { where: { uuid } }); } diff --git a/src/modules/user/user.usecase.spec.ts b/src/modules/user/user.usecase.spec.ts index ff5135709..d2a87473d 100644 --- a/src/modules/user/user.usecase.spec.ts +++ b/src/modules/user/user.usecase.spec.ts @@ -1,5 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { createMock } from '@golevelup/ts-jest'; +import { AttemptChangeEmailModel } from './attempt-change-email.model'; +import { UserEmailAlreadyInUseException } from './exception/user-email-already-in-use.exception'; import { UserUseCases } from './user.usecase'; import { ShareUseCases } from '../share/share.usecase'; @@ -24,12 +26,27 @@ import { SequelizePreCreatedUsersRepository } from './pre-created-users.reposito import { SequelizeSharingRepository } from '../sharing/sharing.repository'; import { SequelizeAttemptChangeEmailRepository } from './attempt-change-email.repository'; import { MailerService } from '../../externals/mailer/mailer.service'; +import { UserNotFoundException } from './exception/user-not-found.exception'; +import { AttemptChangeEmailNotFoundException } from './exception/attempt-change-email-not-found.exception'; +import { AttemptChangeEmailHasExpiredException } from './exception/attempt-change-email-has-expired.exception'; +import { AttemptChangeEmailAlreadyVerifiedException } from './exception/attempt-change-email-already-verified.exception'; + +jest.mock('../../middlewares/passport', () => ({ + Sign: jest.fn(() => 'newToken'), + SignEmail: jest.fn(() => 'token'), +})); describe('User use cases', () => { let userUseCases: UserUseCases; let shareUseCases: ShareUseCases; let folderUseCases: FolderUseCases; let fileUseCases: FileUseCases; + let bridgeService: BridgeService; + let userRepository: SequelizeUserRepository; + let sharedWorkspaceRepository: SequelizeSharedWorkspaceRepository; + let cryptoService: CryptoService; + let attemptChangeEmailRepository: SequelizeAttemptChangeEmailRepository; + let configService: ConfigService; const user = User.build({ id: 1, @@ -68,6 +85,20 @@ describe('User use cases', () => { folderUseCases = moduleRef.get(FolderUseCases); fileUseCases = moduleRef.get(FileUseCases); userUseCases = moduleRef.get(UserUseCases); + bridgeService = moduleRef.get(BridgeService); + userRepository = moduleRef.get( + SequelizeUserRepository, + ); + sharedWorkspaceRepository = + moduleRef.get( + SequelizeSharedWorkspaceRepository, + ); + cryptoService = moduleRef.get(CryptoService); + attemptChangeEmailRepository = + moduleRef.get( + SequelizeAttemptChangeEmailRepository, + ); + configService = moduleRef.get(ConfigService); }); describe('Resetting a user', () => { @@ -217,6 +248,223 @@ describe('User use cases', () => { }); }); }); + + describe('changeUserEmailById', () => { + it('When changing the user email successfully, Then it should return the old and new email details', async () => { + jest.spyOn(userRepository, 'findByUsername').mockResolvedValue(undefined); + jest.spyOn(userRepository, 'findByUuid').mockResolvedValue(user); + + const result = await userUseCases.changeUserEmailById( + user.uuid, + 'newemail@example.com', + ); + + expect(result).toEqual({ + oldEmail: user.email, + newEmail: 'newemail@example.com', + }); + }); + + it('When the user is a guest on a shared workspace, Then it should not call bridgeService.updateUserEmail', async () => { + jest.spyOn(userRepository, 'findByUsername').mockResolvedValue(undefined); + jest.spyOn(userRepository, 'findByUuid').mockResolvedValue( + User.build({ + ...user, + bridgeUser: 'bridgeUser@inxt.com', + }), + ); + + await userUseCases.changeUserEmailById(user.uuid, 'newemail@example.com'); + + expect(bridgeService.updateUserEmail).not.toHaveBeenCalled(); + expect(userRepository.updateByUuid).toHaveBeenCalledWith(user.uuid, { + email: 'newemail@example.com', + username: 'newemail@example.com', + }); + }); + + it('When the user is a guest on a shared workspace, Then it should call sharedWorkspaceRepository.updateGuestEmail', async () => { + jest.spyOn(userRepository, 'findByUsername').mockResolvedValue(undefined); + jest.spyOn(userRepository, 'findByUuid').mockResolvedValue( + User.build({ + ...user, + bridgeUser: 'bridgeUser@inxt.com', + }), + ); + + await userUseCases.changeUserEmailById(user.uuid, 'newemail@example.com'); + + expect(sharedWorkspaceRepository.updateGuestEmail).toHaveBeenCalledWith( + user.email, + 'newemail@example.com', + ); + expect(userRepository.updateByUuid).toHaveBeenCalledWith(user.uuid, { + email: 'newemail@example.com', + username: 'newemail@example.com', + }); + }); + + it('When the user is not a guest on a shared workspace, Then it should update the bridgeUser property', async () => { + jest.spyOn(userRepository, 'findByUsername').mockResolvedValue(undefined); + jest.spyOn(userRepository, 'findByUuid').mockResolvedValue( + User.build({ + ...user, + bridgeUser: user.email, + }), + ); + + await userUseCases.changeUserEmailById(user.uuid, 'newemail@example.com'); + + expect(bridgeService.updateUserEmail).toHaveBeenCalledWith( + user.uuid, + 'newemail@example.com', + ); + expect(userRepository.updateByUuid).toHaveBeenCalledWith(user.uuid, { + email: 'newemail@example.com', + username: 'newemail@example.com', + bridgeUser: 'newemail@example.com', + }); + }); + + it('When an exception is thrown, Then it should call bridgeService.updateUserEmail', async () => { + jest.spyOn(userRepository, 'findByUsername').mockResolvedValue(undefined); + jest.spyOn(userRepository, 'findByUuid').mockResolvedValue(user); + + try { + await userUseCases.changeUserEmailById( + user.uuid, + 'newuseremail@inxt.com', + ); + } catch (e) { + expect(bridgeService.updateUserEmail).toHaveBeenCalledWith( + user.uuid, + user.email, + ); + } + }); + + it('When the user is not found, Then it should throw UserNotFoundException', async () => { + jest.spyOn(userRepository, 'findByUuid').mockResolvedValue(undefined); + + await expect( + userUseCases.changeUserEmailById( + 'nonexistentuuid', + 'newemail@example.com', + ), + ).rejects.toThrow(UserNotFoundException); + }); + + it('When the user email is already in use, Then it should throw UserEmailAlreadyInUseException', async () => { + jest.spyOn(userRepository, 'findByUuid').mockResolvedValue(user); + jest.spyOn(userRepository, 'findByUsername').mockResolvedValue(user); + + await expect( + userUseCases.changeUserEmailById( + 'nonexistentuuid', + 'newemail@example.com', + ), + ).rejects.toThrow(UserEmailAlreadyInUseException); + }); + }); + + describe('acceptAttemptChangeEmail', () => { + it('When accepting an attempt, Then it should return new email details with a new token', async () => { + const encryptedId = 'encryptedId'; + const decryptedId = '1'; + jest.spyOn(cryptoService, 'decryptText').mockReturnValue(decryptedId); + + const attemptChangeEmail = { + id: 1, + userUuid: user.uuid, + newEmail: 'newemail@example.com', + isExpiresAt: false, + isVerified: false, + }; + + jest + .spyOn(attemptChangeEmailRepository, 'getOneById') + .mockResolvedValue(attemptChangeEmail as any); + jest + .spyOn(attemptChangeEmailRepository, 'acceptAttemptChangeEmail') + .mockResolvedValue(undefined); + + jest.spyOn(userUseCases, 'changeUserEmailById').mockResolvedValue({ + oldEmail: user.email, + newEmail: 'newemail@example.com', + }); + + jest.spyOn(userUseCases, 'getNewTokenPayload').mockReturnValue({} as any); + jest.spyOn(configService, 'get').mockReturnValue('a-secret-key'); + + const result = await userUseCases.acceptAttemptChangeEmail(encryptedId); + + expect(result).toEqual({ + oldEmail: user.email, + newEmail: 'newemail@example.com', + newAuthentication: { + token: 'token', + newToken: 'newToken', + user, + }, + }); + }); + + it('When the attempt is not found, Then it should throw AttemptChangeEmailNotFoundException', async () => { + jest.spyOn(cryptoService, 'decryptText').mockReturnValue('1'); + jest + .spyOn(attemptChangeEmailRepository, 'getOneById') + .mockResolvedValue(undefined); + + await expect( + userUseCases.acceptAttemptChangeEmail('encryptedId'), + ).rejects.toThrow(AttemptChangeEmailNotFoundException); + }); + + it('When the attempt is expired, Then it should throw AttemptChangeEmailHasExpiredException', async () => { + jest.spyOn(cryptoService, 'decryptText').mockReturnValue('1'); + jest.spyOn(attemptChangeEmailRepository, 'getOneById').mockResolvedValue( + createMock({ + isExpired: true, + }), + ); + + await expect( + userUseCases.acceptAttemptChangeEmail('encryptedId'), + ).rejects.toThrow(AttemptChangeEmailHasExpiredException); + }); + + it('When the attempt is already verified, Then it should throw AttemptChangeEmailAlreadyVerifiedException', async () => { + jest.spyOn(cryptoService, 'decryptText').mockReturnValue('1'); + jest.spyOn(attemptChangeEmailRepository, 'getOneById').mockResolvedValue( + createMock({ + isExpired: false, + isVerified: true, + }), + ); + + await expect( + userUseCases.acceptAttemptChangeEmail('encryptedId'), + ).rejects.toThrow(AttemptChangeEmailAlreadyVerifiedException); + }); + + it('When changeUserEmailById fails, Then it should throw an error', async () => { + jest.spyOn(cryptoService, 'decryptText').mockReturnValue('1'); + jest.spyOn(attemptChangeEmailRepository, 'getOneById').mockResolvedValue( + createMock({ + isExpired: false, + isVerified: false, + }), + ); + + jest + .spyOn(userUseCases, 'changeUserEmailById') + .mockRejectedValue(new Error('Change email failed')); + + await expect( + userUseCases.acceptAttemptChangeEmail('encryptedId'), + ).rejects.toThrowError('Change email failed'); + }); + }); }); const createTestingModule = (): Promise => { diff --git a/src/modules/user/user.usecase.ts b/src/modules/user/user.usecase.ts index 215b94259..84cc91028 100644 --- a/src/modules/user/user.usecase.ts +++ b/src/modules/user/user.usecase.ts @@ -835,15 +835,29 @@ export class UserUseCases { const { uuid, email } = user; - await this.networkService.updateUserEmail(uuid, newEmail); - try { - await this.userRepository.updateById(user.id, { + const payload = { email: newEmail, username: newEmail, - bridgeUser: newEmail, - }); - await this.sharedWorkspaceRepository.updateGuestEmail(email, newEmail); + }; + + const isGuestOnSharedWorkspace = user.isGuestOnSharedWorkspace(); + + if (!isGuestOnSharedWorkspace) { + await this.networkService.updateUserEmail(uuid, newEmail); + payload['bridgeUser'] = newEmail; + } else { + await this.sharedWorkspaceRepository.updateGuestEmail(email, newEmail); + } + + if (user.sharedWorkspace) { + await this.userRepository.updateBy( + { bridgeUser: email }, + { bridgeUser: newEmail }, + ); + } + + await this.userRepository.updateByUuid(user.uuid, payload); } catch (error) { Logger.error(`[CHANGE-EMAIL/ERROR]: ${JSON.stringify(error)}.`); @@ -941,7 +955,6 @@ export class UserUseCases { if (user.email !== emails.newEmail) { user.email = emails.newEmail; user.username = emails.newEmail; - user.bridgeUser = emails.newEmail; } const newTokenPayload = this.getNewTokenPayload(user); From 330b56be0027c9932edc49e5ecbcc1bc5f22f2f3 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Wed, 17 Jan 2024 15:12:52 -0400 Subject: [PATCH 33/53] feat: add email limitt --- ...56-add-unblock-account-to-mailtype-enum.js | 20 ++++++ .../mailer/mail-limit/mail-limit.domain.ts | 44 +++++++++++++ .../mailer/mail-limit/mail-limit.model.ts | 64 +++++++++++++++++++ .../mail-limit/mail-limit.repository.ts | 44 +++++++++++++ src/externals/mailer/mailTypes.ts | 8 +++ src/externals/mailer/mailer.service.ts | 12 ++++ src/lib/time.ts | 14 ++++ src/modules/user/user.controller.ts | 8 ++- src/modules/user/user.module.ts | 4 ++ src/modules/user/user.usecase.spec.ts | 47 ++++++++------ src/modules/user/user.usecase.ts | 42 +++++++++--- test/fixtures.ts | 19 ++++++ 12 files changed, 296 insertions(+), 30 deletions(-) create mode 100644 migrations/20240117140256-add-unblock-account-to-mailtype-enum.js create mode 100644 src/externals/mailer/mail-limit/mail-limit.domain.ts create mode 100644 src/externals/mailer/mail-limit/mail-limit.model.ts create mode 100644 src/externals/mailer/mail-limit/mail-limit.repository.ts create mode 100644 src/externals/mailer/mailTypes.ts diff --git a/migrations/20240117140256-add-unblock-account-to-mailtype-enum.js b/migrations/20240117140256-add-unblock-account-to-mailtype-enum.js new file mode 100644 index 000000000..eb3db8537 --- /dev/null +++ b/migrations/20240117140256-add-unblock-account-to-mailtype-enum.js @@ -0,0 +1,20 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + up: async (queryInterface) => { + await queryInterface.sequelize.query( + `ALTER TYPE mail_type ADD VALUE 'unblock_account';`, + ); + }, + down: async (queryInterface) => { + await queryInterface.sequelize.query( + ` + ALTER TYPE mail_type RENAME TO mail_type_old; + CREATE TYPE mail_type AS ENUM('invite_friend', 'reset_password', 'remove_account', 'deactivate_account'); + ALTER TABLE mail_limits ALTER COLUMN mail_type TYPE mail_type USING mail_type::text::mail_type; + DROP TYPE mail_type_old; + `, + ); + }, +}; diff --git a/src/externals/mailer/mail-limit/mail-limit.domain.ts b/src/externals/mailer/mail-limit/mail-limit.domain.ts new file mode 100644 index 000000000..4c87bf846 --- /dev/null +++ b/src/externals/mailer/mail-limit/mail-limit.domain.ts @@ -0,0 +1,44 @@ +import { Time } from '../../../lib/time'; +import { MailTypes } from '../mailTypes'; +import { MailLimitModelAttributes } from './mail-limit.model'; + +export class MailLimit implements MailLimitModelAttributes { + id: number; + userId: number; + mailType: MailTypes; + attemptsCount: number; + attemptsLimit: number; + lastMailSent: Date; + constructor({ + id, + userId, + mailType, + attemptsCount, + attemptsLimit, + lastMailSent, + }: MailLimitModelAttributes) { + this.id = id; + this.userId = userId; + this.mailType = mailType; + this.attemptsCount = attemptsCount; + this.attemptsLimit = attemptsLimit; + this.lastMailSent = lastMailSent; + } + + static build(mailLimit: MailLimitModelAttributes): MailLimit { + return new MailLimit(mailLimit); + } + + isLimitForTodayReached() { + return ( + Time.isToday(this.lastMailSent) && + this.attemptsCount >= this.attemptsLimit + ); + } + + increaseAttemptsCountIfToday() { + this.attemptsCount = Time.isToday(this.lastMailSent) + ? this.attemptsCount + 1 + : 1; + } +} diff --git a/src/externals/mailer/mail-limit/mail-limit.model.ts b/src/externals/mailer/mail-limit/mail-limit.model.ts new file mode 100644 index 000000000..6aabd0f41 --- /dev/null +++ b/src/externals/mailer/mail-limit/mail-limit.model.ts @@ -0,0 +1,64 @@ +import { + Column, + Model, + Table, + PrimaryKey, + DataType, + AutoIncrement, + AllowNull, + Default, + ForeignKey, + BelongsTo, +} from 'sequelize-typescript'; +import { MailTypes } from '../mailTypes'; +import { UserModel } from '../../../modules/user/user.model'; + +export interface MailLimitModelAttributes { + id: number; + userId: number; + mailType: MailTypes; + attemptsCount: number; + attemptsLimit: number; + lastMailSent: Date; +} + +@Table({ + underscored: true, + timestamps: false, + tableName: 'mail_limits', +}) +export class MailLimitModel extends Model implements MailLimitModelAttributes { + @PrimaryKey + @AutoIncrement + @Column + id: number; + + @ForeignKey(() => UserModel) + @Column + userId: number; + + @BelongsTo(() => UserModel) + user: UserModel; + + @AllowNull(false) + @Column({ + type: DataType.ENUM, + values: Object.values(MailTypes), + }) + mailType: MailTypes; + + @AllowNull(false) + @Default(0) + @Column(DataType.INTEGER) + attemptsCount: number; + + @AllowNull(false) + @Default(0) + @Column(DataType.INTEGER) + attemptsLimit: number; + + @AllowNull(false) + @Default(new Date()) + @Column(DataType.DATE) + lastMailSent: Date; +} diff --git a/src/externals/mailer/mail-limit/mail-limit.repository.ts b/src/externals/mailer/mail-limit/mail-limit.repository.ts new file mode 100644 index 000000000..fcca74496 --- /dev/null +++ b/src/externals/mailer/mail-limit/mail-limit.repository.ts @@ -0,0 +1,44 @@ +import { InjectModel } from '@nestjs/sequelize'; +import { Injectable } from '@nestjs/common'; + +import { MailLimitModel, MailLimitModelAttributes } from './mail-limit.model'; +import { MailLimit } from './mail-limit.domain'; +import { MailTypes } from '../mailTypes'; + +@Injectable() +export class SequelizeMailLimitRepository { + constructor( + @InjectModel(MailLimitModel) + private mailLimitModel: typeof MailLimitModel, + ) {} + + async findOrCreate( + where: Partial, + defaults: Partial, + ): Promise<[MailLimit, boolean]> { + const [mailLimit, wasCreated] = await this.mailLimitModel.findOrCreate({ + where, + defaults, + }); + return [mailLimit ? this.toDomain(mailLimit) : null, wasCreated]; + } + + async updateByUserIdAndMailType( + userId: MailLimitModelAttributes['userId'], + mailType: MailTypes, + update: Partial, + ): Promise { + await this.mailLimitModel.update(update, { + where: { + userId, + mailType, + }, + }); + } + + private toDomain(model: MailLimitModel): MailLimit { + return MailLimit.build({ + ...model.toJSON(), + }); + } +} diff --git a/src/externals/mailer/mailTypes.ts b/src/externals/mailer/mailTypes.ts new file mode 100644 index 000000000..542148e50 --- /dev/null +++ b/src/externals/mailer/mailTypes.ts @@ -0,0 +1,8 @@ +export enum MailTypes { + InviteFriend = 'invite_friend', + ResetPassword = 'reset_password', + RemoveAccount = 'remove_account', + EmailVerification = 'email_verification', + DeactivateUser = 'deactivate_user', + UnblockAccount = 'unblock_account', +} diff --git a/src/externals/mailer/mailer.service.ts b/src/externals/mailer/mailer.service.ts index 237c175e5..38d672b7f 100644 --- a/src/externals/mailer/mailer.service.ts +++ b/src/externals/mailer/mailer.service.ts @@ -164,4 +164,16 @@ export class MailerService { }, ); } + + async sendAutoAccountUnblockEmail(email: User['email'], url: string) { + const context = { + email, + unblock_url: url, + }; + await this.send( + email, + this.configService.get('mailer.templates.unblockAccountEmail'), + context, + ); + } } diff --git a/src/lib/time.ts b/src/lib/time.ts index 5af34318d..a51a48171 100644 --- a/src/lib/time.ts +++ b/src/lib/time.ts @@ -31,4 +31,18 @@ export class Time { public static resumeTime(): void { Time.freeze = null; } + + public static isToday = (date: Date) => { + const todayDate = new Date(); + + if ( + date.getDate() === todayDate.getDate() && + date.getMonth() === todayDate.getMonth() && + date.getFullYear() === todayDate.getFullYear() + ) { + return true; + } else { + return false; + } + }; } diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index 5ea63f7e7..bf915302e 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -38,6 +38,7 @@ import { AccountTokenAction, User } from './user.domain'; import { InvalidReferralCodeError, KeyServerNotFoundError, + MailLimitReachedException, UserAlreadyRegisteredError, UserUseCases, } from './user.usecase'; @@ -439,9 +440,12 @@ export class UserController { @Public() async requestAccountUnblock(@Body() body: RequestAccountUnblock) { try { - return await this.userUseCases.sendAccountUnblockEmail(body.email); + const response = await this.userUseCases.sendAccountUnblockEmail( + body.email, + ); + return response; } catch (err) { - if (err instanceof NotFoundException) { + if (err instanceof NotFoundException || MailLimitReachedException) { throw err; } diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts index fe27af386..fd2d10c85 100644 --- a/src/modules/user/user.module.ts +++ b/src/modules/user/user.module.ts @@ -42,6 +42,8 @@ import { SharingService } from '../sharing/sharing.service'; import { SequelizeAttemptChangeEmailRepository } from './attempt-change-email.repository'; import { AttemptChangeEmailModel } from './attempt-change-email.model'; import { MailerService } from '../../externals/mailer/mailer.service'; +import { MailLimitModel } from '../../externals/mailer/mail-limit/mail-limit.model'; +import { SequelizeMailLimitRepository } from '../../externals/mailer/mail-limit/mail-limit.repository'; @Module({ imports: [ @@ -53,6 +55,7 @@ import { MailerService } from '../../externals/mailer/mailer.service'; FriendInvitationModel, KeyServerModel, AttemptChangeEmailModel, + MailLimitModel, ]), forwardRef(() => FolderModule), forwardRef(() => FileModule), @@ -75,6 +78,7 @@ import { MailerService } from '../../externals/mailer/mailer.service'; SequelizeKeyServerRepository, SequelizeUserReferralsRepository, SequelizeAttemptChangeEmailRepository, + SequelizeMailLimitRepository, UserUseCases, CryptoService, BridgeService, diff --git a/src/modules/user/user.usecase.spec.ts b/src/modules/user/user.usecase.spec.ts index 85fa9f9f3..2fcc5863c 100644 --- a/src/modules/user/user.usecase.spec.ts +++ b/src/modules/user/user.usecase.spec.ts @@ -26,17 +26,16 @@ import { SequelizePreCreatedUsersRepository } from './pre-created-users.reposito import { SequelizeSharingRepository } from '../sharing/sharing.repository'; import { SequelizeAttemptChangeEmailRepository } from './attempt-change-email.repository'; import { MailerService } from '../../externals/mailer/mailer.service'; -import { - BadRequestException, - ForbiddenException, - NotFoundException, -} from '@nestjs/common'; +import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { SignWithCustomDuration } from '../../middlewares/passport'; import { getTokenDefaultIat } from '../../lib/jwt'; import { UserNotFoundException } from './exception/user-not-found.exception'; import { AttemptChangeEmailNotFoundException } from './exception/attempt-change-email-not-found.exception'; import { AttemptChangeEmailHasExpiredException } from './exception/attempt-change-email-has-expired.exception'; import { AttemptChangeEmailAlreadyVerifiedException } from './exception/attempt-change-email-already-verified.exception'; +import { SequelizeMailLimitRepository } from '../../externals/mailer/mail-limit/mail-limit.repository'; +import { newMailLimit } from '../../../test/fixtures'; +import { MailTypes } from '../../externals/mailer/mailTypes'; jest.mock('../../middlewares/passport', () => { const originalModule = jest.requireActual('../../middlewares/passport'); @@ -60,6 +59,7 @@ describe('User use cases', () => { let cryptoService: CryptoService; let attemptChangeEmailRepository: SequelizeAttemptChangeEmailRepository; let configService: ConfigService; + let mailLimitRepository: SequelizeMailLimitRepository; const user = User.build({ id: 1, @@ -116,6 +116,9 @@ describe('User use cases', () => { SequelizeAttemptChangeEmailRepository, ); configService = moduleRef.get(ConfigService); + mailLimitRepository = moduleRef.get( + SequelizeMailLimitRepository, + ); }); describe('Resetting a user', () => { @@ -269,6 +272,12 @@ describe('User use cases', () => { describe('Unblocking user account', () => { describe('Request Account unblock', () => { const fixedSystemCurrentDate = new Date('2020-02-19'); + const mailLimit = newMailLimit({ + userId: user.id, + mailType: MailTypes.UnblockAccount, + limit: 5, + attemps: 0, + }); beforeAll(async () => { jest.useFakeTimers(); @@ -279,25 +288,24 @@ describe('User use cases', () => { jest.useRealTimers(); }); - it('When user does not exist, then fail', async () => { + it('When user does not exist, then do nothing', async () => { const userFindByEmailSpy = jest.spyOn(userRepository, 'findByEmail'); - const email = 'email@test.com'; userFindByEmailSpy.mockReturnValueOnce(null); await expect( - userUseCases.sendAccountUnblockEmail(email), - ).rejects.toThrow(NotFoundException); + userUseCases.sendAccountUnblockEmail(user.email), + ).resolves.toBeUndefined(); }); it('When user user exists, then user lastPasswordChangedAt is updated', async () => { - const userFindByEmailSpy = jest.spyOn(userRepository, 'findByEmail'); const userUpdateSpy = jest.spyOn(userRepository, 'updateByUuid'); - const configServiceGetSpy = jest.spyOn(configService, 'get'); - const email = 'email@test.com'; - userFindByEmailSpy.mockResolvedValueOnce(user); - configServiceGetSpy.mockReturnValue('secret'); + jest.spyOn(userRepository, 'findByEmail').mockResolvedValueOnce(user); + jest.spyOn(configService, 'get').mockReturnValue('secret'); + jest + .spyOn(mailLimitRepository, 'findOrCreate') + .mockResolvedValueOnce([mailLimit, false]); - await userUseCases.sendAccountUnblockEmail(email); + await userUseCases.sendAccountUnblockEmail(user.email); expect(SignWithCustomDuration).toHaveBeenCalledWith( { @@ -327,7 +335,7 @@ describe('User use cases', () => { ); }); - it('When token is older than lastPasswordChangedAt, then fail', async () => { + it('When token iat is previous to lastPasswordChangedAt, then fail', async () => { const userFindByUuidSpy = jest.spyOn(userRepository, 'findByUuid'); const olderIat = getTokenDefaultIat(); const recentDate = new Date(olderIat * 1000); @@ -344,7 +352,7 @@ describe('User use cases', () => { ).rejects.toThrow(ForbiddenException); }); - it('When token is greater than lastPasswordChangedAt, then update user', async () => { + it('When token iat is greater than lastPasswordChangedAt, then update user', async () => { const userFindByUuidSpy = jest.spyOn(userRepository, 'findByUuid'); const tokenIat = getTokenDefaultIat(); const olderDate = new Date(tokenIat * 1000); @@ -359,7 +367,6 @@ describe('User use cases', () => { expect(userRepository.updateByUuid).toHaveBeenCalledWith(user.uuid, { errorLoginCount: 0, - lastPasswordChangedAt: null, }); }); }); @@ -663,6 +670,10 @@ const createTestingModule = (): Promise => { provide: MailerService, useValue: createMock(), }, + { + provide: SequelizeMailLimitRepository, + useValue: createMock(), + }, UserUseCases, ], }).compile(); diff --git a/src/modules/user/user.usecase.ts b/src/modules/user/user.usecase.ts index 071ab2586..8ee31e7fe 100644 --- a/src/modules/user/user.usecase.ts +++ b/src/modules/user/user.usecase.ts @@ -1,6 +1,8 @@ import { BadRequestException, ForbiddenException, + HttpException, + HttpStatus, Injectable, Logger, NotFoundException, @@ -61,6 +63,8 @@ import { AttemptChangeEmailNotFoundException } from './exception/attempt-change- import { UserEmailAlreadyInUseException } from './exception/user-email-already-in-use.exception'; import { UserNotFoundException } from './exception/user-not-found.exception'; import { getTokenDefaultIat, isTokenIatGreaterThanDate } from '../../lib/jwt'; +import { SequelizeMailLimitRepository } from '../../externals/mailer/mail-limit/mail-limit.repository'; +import { MailTypes } from '../../externals/mailer/mailTypes'; class ReferralsNotAvailableError extends Error { constructor() { @@ -98,6 +102,12 @@ export class UserNotFoundError extends Error { } } +export class MailLimitReachedException extends HttpException { + constructor() { + super('Mail Limit reached', HttpStatus.TOO_MANY_REQUESTS); + } +} + type NewUser = Pick< UserAttributes, 'email' | 'name' | 'lastname' | 'mnemonic' | 'password' @@ -129,6 +139,7 @@ export class UserUseCases { private readonly keyServerRepository: SequelizeKeyServerRepository, private readonly avatarService: AvatarService, private readonly mailerService: MailerService, + private readonly mailLimitRepository: SequelizeMailLimitRepository, ) {} findByEmail(email: User['email']): Promise { @@ -705,7 +716,19 @@ export class UserUseCases { const user = await this.userRepository.findByEmail(email); if (!user) { - throw new NotFoundException(); + return; + } + + const [mailLimit] = await this.mailLimitRepository.findOrCreate( + { userId: user.id, mailType: MailTypes.UnblockAccount }, + { + attemptsCount: 0, + attemptsLimit: 5, + }, + ); + + if (mailLimit.isLimitForTodayReached()) { + throw new MailLimitReachedException(); } const defaultIat = getTokenDefaultIat(); @@ -728,16 +751,16 @@ export class UserUseCases { ); const driveWebUrl = this.configService.get('clients.drive.web'); - const unblockAccountTemplateId = this.configService.get( - 'mailer.templates.unblockAccountEmail', - ); - const url = `${driveWebUrl}/blocked-account/${unblockAccountToken}`; + await this.mailerService.sendAutoAccountUnblockEmail(user.email, url); - await this.mailerService.send(user.email, unblockAccountTemplateId, { - email, - unblock_url: url, - }); + mailLimit.increaseAttemptsCountIfToday(); + + await this.mailLimitRepository.updateByUserIdAndMailType( + user.id, + MailTypes.UnblockAccount, + mailLimit, + ); } async unblockAccount( @@ -762,7 +785,6 @@ export class UserUseCases { await this.userRepository.updateByUuid(userUuid, { errorLoginCount: 0, - lastPasswordChangedAt: null, }); } diff --git a/test/fixtures.ts b/test/fixtures.ts index 4f8384910..43bd50295 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -9,6 +9,8 @@ import { SharingType, } from '../src/modules/sharing/sharing.domain'; import { File, FileStatus } from '../src/modules/file/file.domain'; +import { MailTypes } from '../src/externals/mailer/mailTypes'; +import { MailLimit } from '../src/externals/mailer/mail-limit/mail-limit.domain'; export const constants = { BUCKET_ID_LENGTH: 24, @@ -206,3 +208,20 @@ export const newSharingRole = (bindTo?: { updatedAt: randomDataGenerator.date(), }); }; + +export const newMailLimit = (bindTo?: { + userId: number; + mailType?: MailTypes; + attemps?: number; + limit?: number; + lastMailSent?: Date; +}): MailLimit => { + return MailLimit.build({ + id: randomDataGenerator.integer(), + userId: bindTo?.userId, + mailType: bindTo?.mailType, + attemptsCount: bindTo?.attemps ?? 0, + attemptsLimit: bindTo?.limit ?? 5, + lastMailSent: bindTo?.lastMailSent ?? new Date(), + }); +}; From d5e0dcbae4efe3e4a86ddf27373492d87a619d67 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Wed, 17 Jan 2024 23:21:00 -0400 Subject: [PATCH 34/53] fix: test dependencies --- src/modules/share/share.usecase.spec.ts | 5 +++++ src/modules/user/user.controller.ts | 4 ++-- src/modules/user/user.module.ts | 1 + src/modules/user/user.usecase.ts | 1 - 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/modules/share/share.usecase.spec.ts b/src/modules/share/share.usecase.spec.ts index 03d17a0ad..997a0090b 100644 --- a/src/modules/share/share.usecase.spec.ts +++ b/src/modules/share/share.usecase.spec.ts @@ -68,6 +68,7 @@ import { PlanModel } from '../plan/plan.model'; import { SequelizeAttemptChangeEmailRepository } from '../user/attempt-change-email.repository'; import { createMock } from '@golevelup/ts-jest'; import { MailerService } from '../../externals/mailer/mailer.service'; +import { SequelizeMailLimitRepository } from '../../externals/mailer/mail-limit/mail-limit.repository'; describe('Share Use Cases', () => { let service: ShareUseCases; @@ -330,6 +331,10 @@ describe('Share Use Cases', () => { provide: SequelizeAttemptChangeEmailRepository, useValue: createMock(), }, + { + provide: SequelizeMailLimitRepository, + useValue: createMock(), + }, { provide: MailerService, useValue: createMock(), diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index bf915302e..c31ce79c3 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -20,6 +20,7 @@ import { BadRequestException, UseFilters, InternalServerErrorException, + HttpException, } from '@nestjs/common'; import { ApiBadRequestResponse, @@ -38,7 +39,6 @@ import { AccountTokenAction, User } from './user.domain'; import { InvalidReferralCodeError, KeyServerNotFoundError, - MailLimitReachedException, UserAlreadyRegisteredError, UserUseCases, } from './user.usecase'; @@ -445,7 +445,7 @@ export class UserController { ); return response; } catch (err) { - if (err instanceof NotFoundException || MailLimitReachedException) { + if (err instanceof HttpException) { throw err; } diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts index fd2d10c85..cf350fc48 100644 --- a/src/modules/user/user.module.ts +++ b/src/modules/user/user.module.ts @@ -98,6 +98,7 @@ import { SequelizeMailLimitRepository } from '../../externals/mailer/mail-limit/ SequelizeUserReferralsRepository, SequelizeReferralRepository, SequelizeAttemptChangeEmailRepository, + SequelizeMailLimitRepository, ], }) export class UserModule {} diff --git a/src/modules/user/user.usecase.ts b/src/modules/user/user.usecase.ts index 8ee31e7fe..5de9bd68a 100644 --- a/src/modules/user/user.usecase.ts +++ b/src/modules/user/user.usecase.ts @@ -755,7 +755,6 @@ export class UserUseCases { await this.mailerService.sendAutoAccountUnblockEmail(user.email, url); mailLimit.increaseAttemptsCountIfToday(); - await this.mailLimitRepository.updateByUserIdAndMailType( user.id, MailTypes.UnblockAccount, From c3804765bcaed7b9c12464d3a02113cf69ee263c Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Thu, 18 Jan 2024 00:33:21 -0400 Subject: [PATCH 35/53] chore: change test descriptions --- .../mailer/mail-limit/mail-limit.domain.ts | 3 +- src/modules/user/user.controller.spec.ts | 42 ++++++------------- src/modules/user/user.usecase.spec.ts | 2 +- src/modules/user/user.usecase.ts | 2 +- 4 files changed, 16 insertions(+), 33 deletions(-) diff --git a/src/externals/mailer/mail-limit/mail-limit.domain.ts b/src/externals/mailer/mail-limit/mail-limit.domain.ts index 4c87bf846..06a6fd6a6 100644 --- a/src/externals/mailer/mail-limit/mail-limit.domain.ts +++ b/src/externals/mailer/mail-limit/mail-limit.domain.ts @@ -36,9 +36,10 @@ export class MailLimit implements MailLimitModelAttributes { ); } - increaseAttemptsCountIfToday() { + increaseTodayAttemps() { this.attemptsCount = Time.isToday(this.lastMailSent) ? this.attemptsCount + 1 : 1; + this.lastMailSent = new Date(); } } diff --git a/src/modules/user/user.controller.spec.ts b/src/modules/user/user.controller.spec.ts index a73bc9fdc..e1eab1d0a 100644 --- a/src/modules/user/user.controller.spec.ts +++ b/src/modules/user/user.controller.spec.ts @@ -7,7 +7,7 @@ import { } from '@nestjs/common'; import getEnv from '../../config/configuration'; import { UserController } from './user.controller'; -import { UserUseCases } from './user.usecase'; +import { MailLimitReachedException, UserUseCases } from './user.usecase'; import { NotificationService } from '../../externals/notifications/notification.service'; import { KeyServerUseCases } from '../keyserver/key-server.usecase'; import { CryptoService } from '../../externals/crypto/crypto.service'; @@ -56,20 +56,21 @@ describe('User Controller', () => { }); describe('POST /unblock-account', () => { - it('When user is not found, then returns NotFoundException', async () => { - userUseCases.sendAccountUnblockEmail.mockRejectedValueOnce( - new NotFoundException(), - ); - await expect( - userController.requestAccountUnblock({ email: 'test@test.com' }), - ).rejects.toThrow(NotFoundException); - }); - it('When an unexpected error is throw, then returns InternalServerException', async () => { + it('When an unhandled error is returned, then returns 500', async () => { userUseCases.sendAccountUnblockEmail.mockRejectedValueOnce(new Error()); await expect( - userController.requestAccountUnblock({ email: 'test@test.com' }), + userController.requestAccountUnblock({ email: '' }), ).rejects.toThrow(InternalServerErrorException); }); + + it('When mail Limit is reached, then 429 error is shown', async () => { + userUseCases.sendAccountUnblockEmail.mockRejectedValueOnce( + new MailLimitReachedException(), + ); + await expect( + userController.requestAccountUnblock({ email: '' }), + ).rejects.toThrow(MailLimitReachedException); + }); }); describe('PUT /unblock-account', () => { @@ -128,25 +129,6 @@ describe('User Controller', () => { ); }); - it('When token is valid but useCase throws badRequest or Forbidden, then fails with respective error', async () => { - userUseCases.unblockAccount - .mockRejectedValueOnce(new BadRequestException()) - .mockRejectedValueOnce(new ForbiddenException()); - await expect(userController.accountUnblock(validToken)).rejects.toThrow( - BadRequestException, - ); - await expect(userController.accountUnblock(validToken)).rejects.toThrow( - ForbiddenException, - ); - }); - - it('When token is valid but useCase throws unexpected error, then fails with InternalServerError', async () => { - userUseCases.unblockAccount.mockRejectedValueOnce(new Error()); - await expect(userController.accountUnblock(validToken)).rejects.toThrow( - InternalServerErrorException, - ); - }); - it('When token and user are correct, then resolves', async () => { userUseCases.unblockAccount.mockResolvedValueOnce(); await expect( diff --git a/src/modules/user/user.usecase.spec.ts b/src/modules/user/user.usecase.spec.ts index 2fcc5863c..6587bb5fa 100644 --- a/src/modules/user/user.usecase.spec.ts +++ b/src/modules/user/user.usecase.spec.ts @@ -297,7 +297,7 @@ describe('User use cases', () => { ).resolves.toBeUndefined(); }); - it('When user user exists, then user lastPasswordChangedAt is updated', async () => { + it('When user exists, then user lastPasswordChangedAt is updated', async () => { const userUpdateSpy = jest.spyOn(userRepository, 'updateByUuid'); jest.spyOn(userRepository, 'findByEmail').mockResolvedValueOnce(user); jest.spyOn(configService, 'get').mockReturnValue('secret'); diff --git a/src/modules/user/user.usecase.ts b/src/modules/user/user.usecase.ts index 5de9bd68a..3eb82a77f 100644 --- a/src/modules/user/user.usecase.ts +++ b/src/modules/user/user.usecase.ts @@ -754,7 +754,7 @@ export class UserUseCases { const url = `${driveWebUrl}/blocked-account/${unblockAccountToken}`; await this.mailerService.sendAutoAccountUnblockEmail(user.email, url); - mailLimit.increaseAttemptsCountIfToday(); + mailLimit.increaseTodayAttemps(); await this.mailLimitRepository.updateByUserIdAndMailType( user.id, MailTypes.UnblockAccount, From c42fc76814775cbc9a7dc92ebb0db3a850ed3e56 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Thu, 18 Jan 2024 09:24:56 -0400 Subject: [PATCH 36/53] chore: add test unit for fixtures --- src/modules/user/user.controller.spec.ts | 4 +--- src/modules/user/user.usecase.spec.ts | 4 ++-- test/fixtures.spec.ts | 29 ++++++++++++++++++++++++ test/fixtures.ts | 16 ++++++------- 4 files changed, 40 insertions(+), 13 deletions(-) diff --git a/src/modules/user/user.controller.spec.ts b/src/modules/user/user.controller.spec.ts index e1eab1d0a..e2e24ef24 100644 --- a/src/modules/user/user.controller.spec.ts +++ b/src/modules/user/user.controller.spec.ts @@ -1,9 +1,7 @@ import { DeepMocked, createMock } from '@golevelup/ts-jest'; import { - BadRequestException, ForbiddenException, InternalServerErrorException, - NotFoundException, } from '@nestjs/common'; import getEnv from '../../config/configuration'; import { UserController } from './user.controller'; @@ -56,7 +54,7 @@ describe('User Controller', () => { }); describe('POST /unblock-account', () => { - it('When an unhandled error is returned, then returns 500', async () => { + it('When an unhandled error is returned, then error 500 is shown', async () => { userUseCases.sendAccountUnblockEmail.mockRejectedValueOnce(new Error()); await expect( userController.requestAccountUnblock({ email: '' }), diff --git a/src/modules/user/user.usecase.spec.ts b/src/modules/user/user.usecase.spec.ts index 6587bb5fa..54371f424 100644 --- a/src/modules/user/user.usecase.spec.ts +++ b/src/modules/user/user.usecase.spec.ts @@ -275,8 +275,8 @@ describe('User use cases', () => { const mailLimit = newMailLimit({ userId: user.id, mailType: MailTypes.UnblockAccount, - limit: 5, - attemps: 0, + attemptsLimit: 5, + attemptsCount: 0, }); beforeAll(async () => { diff --git a/test/fixtures.spec.ts b/test/fixtures.spec.ts index 19ef94d3c..aa465e6c5 100644 --- a/test/fixtures.spec.ts +++ b/test/fixtures.spec.ts @@ -204,4 +204,33 @@ describe('Testing fixtures tests', () => { expect(file.folderUuid).toEqual(folder.uuid); }); }); + + describe('Mail Limits fixture', () => { + it('When it generates a new mail limit, then the identifier should be random', () => { + const mailLimit = fixtures.newMailLimit(); + const otherMailLimit = fixtures.newMailLimit(); + + expect(mailLimit.id).toBeGreaterThan(0); + expect(otherMailLimit.id).not.toBe(mailLimit.id); + }); + + it('When it generates a new mail limit and a date is provided, then that date should be set', () => { + const date = new Date(); + const mailLimit = fixtures.newMailLimit({ lastMailSent: date }); + + expect(mailLimit.lastMailSent).toEqual(date); + }); + + it('When it generates a new mail limit and attemps count are provided, then those attemps should be set', () => { + const mailLimit = fixtures.newMailLimit({ attemptsCount: 5 }); + + expect(mailLimit.attemptsCount).toEqual(5); + }); + + it('When it generates a new mail limit and attempts limits are provided, then those limits should be set', () => { + const mailLimit = fixtures.newMailLimit({ attemptsLimit: 5 }); + + expect(mailLimit.attemptsLimit).toEqual(5); + }); + }); }); diff --git a/test/fixtures.ts b/test/fixtures.ts index 43bd50295..c6f6aab71 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -210,18 +210,18 @@ export const newSharingRole = (bindTo?: { }; export const newMailLimit = (bindTo?: { - userId: number; + userId?: number; mailType?: MailTypes; - attemps?: number; - limit?: number; + attemptsCount?: number; + attemptsLimit?: number; lastMailSent?: Date; }): MailLimit => { return MailLimit.build({ - id: randomDataGenerator.integer(), - userId: bindTo?.userId, - mailType: bindTo?.mailType, - attemptsCount: bindTo?.attemps ?? 0, - attemptsLimit: bindTo?.limit ?? 5, + id: randomDataGenerator.natural({ min: 1 }), + userId: bindTo?.userId ?? randomDataGenerator.natural({ min: 1 }), + mailType: bindTo?.mailType ?? MailTypes.UnblockAccount, + attemptsCount: bindTo?.attemptsCount ?? 0, + attemptsLimit: bindTo?.attemptsLimit ?? 5, lastMailSent: bindTo?.lastMailSent ?? new Date(), }); }; From 65ca2fda2cf0dcf03a26cf35e04ca5df1eeab3fb Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Thu, 18 Jan 2024 09:55:34 -0400 Subject: [PATCH 37/53] chore: moved mail-limits to security domain --- .../security}/mail-limit/mail-limit.domain.ts | 2 +- .../security}/mail-limit/mail-limit.model.ts | 2 +- .../security}/mail-limit/mail-limit.repository.ts | 2 +- .../security/mail-limit}/mailTypes.ts | 0 src/modules/security/security.module.ts | 12 ++++++++++++ src/modules/share/share.usecase.spec.ts | 2 +- src/modules/user/user.module.ts | 7 ++----- src/modules/user/user.usecase.spec.ts | 4 ++-- src/modules/user/user.usecase.ts | 4 ++-- test/fixtures.ts | 4 ++-- 10 files changed, 24 insertions(+), 15 deletions(-) rename src/{externals/mailer => modules/security}/mail-limit/mail-limit.domain.ts (96%) rename src/{externals/mailer => modules/security}/mail-limit/mail-limit.model.ts (96%) rename src/{externals/mailer => modules/security}/mail-limit/mail-limit.repository.ts (96%) rename src/{externals/mailer => modules/security/mail-limit}/mailTypes.ts (100%) create mode 100644 src/modules/security/security.module.ts diff --git a/src/externals/mailer/mail-limit/mail-limit.domain.ts b/src/modules/security/mail-limit/mail-limit.domain.ts similarity index 96% rename from src/externals/mailer/mail-limit/mail-limit.domain.ts rename to src/modules/security/mail-limit/mail-limit.domain.ts index 06a6fd6a6..00d6cbf4b 100644 --- a/src/externals/mailer/mail-limit/mail-limit.domain.ts +++ b/src/modules/security/mail-limit/mail-limit.domain.ts @@ -1,5 +1,5 @@ import { Time } from '../../../lib/time'; -import { MailTypes } from '../mailTypes'; +import { MailTypes } from './mailTypes'; import { MailLimitModelAttributes } from './mail-limit.model'; export class MailLimit implements MailLimitModelAttributes { diff --git a/src/externals/mailer/mail-limit/mail-limit.model.ts b/src/modules/security/mail-limit/mail-limit.model.ts similarity index 96% rename from src/externals/mailer/mail-limit/mail-limit.model.ts rename to src/modules/security/mail-limit/mail-limit.model.ts index 6aabd0f41..7c2b8003c 100644 --- a/src/externals/mailer/mail-limit/mail-limit.model.ts +++ b/src/modules/security/mail-limit/mail-limit.model.ts @@ -10,7 +10,7 @@ import { ForeignKey, BelongsTo, } from 'sequelize-typescript'; -import { MailTypes } from '../mailTypes'; +import { MailTypes } from './mailTypes'; import { UserModel } from '../../../modules/user/user.model'; export interface MailLimitModelAttributes { diff --git a/src/externals/mailer/mail-limit/mail-limit.repository.ts b/src/modules/security/mail-limit/mail-limit.repository.ts similarity index 96% rename from src/externals/mailer/mail-limit/mail-limit.repository.ts rename to src/modules/security/mail-limit/mail-limit.repository.ts index fcca74496..4182822c4 100644 --- a/src/externals/mailer/mail-limit/mail-limit.repository.ts +++ b/src/modules/security/mail-limit/mail-limit.repository.ts @@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common'; import { MailLimitModel, MailLimitModelAttributes } from './mail-limit.model'; import { MailLimit } from './mail-limit.domain'; -import { MailTypes } from '../mailTypes'; +import { MailTypes } from './mailTypes'; @Injectable() export class SequelizeMailLimitRepository { diff --git a/src/externals/mailer/mailTypes.ts b/src/modules/security/mail-limit/mailTypes.ts similarity index 100% rename from src/externals/mailer/mailTypes.ts rename to src/modules/security/mail-limit/mailTypes.ts diff --git a/src/modules/security/security.module.ts b/src/modules/security/security.module.ts new file mode 100644 index 000000000..909ae9003 --- /dev/null +++ b/src/modules/security/security.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { SequelizeMailLimitRepository } from './mail-limit/mail-limit.repository'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { MailLimitModel } from './mail-limit/mail-limit.model'; + +@Module({ + imports: [SequelizeModule.forFeature([MailLimitModel])], + controllers: [], + providers: [SequelizeMailLimitRepository], + exports: [SequelizeMailLimitRepository], +}) +export class SecurityModule {} diff --git a/src/modules/share/share.usecase.spec.ts b/src/modules/share/share.usecase.spec.ts index 997a0090b..ba711b95a 100644 --- a/src/modules/share/share.usecase.spec.ts +++ b/src/modules/share/share.usecase.spec.ts @@ -68,7 +68,7 @@ import { PlanModel } from '../plan/plan.model'; import { SequelizeAttemptChangeEmailRepository } from '../user/attempt-change-email.repository'; import { createMock } from '@golevelup/ts-jest'; import { MailerService } from '../../externals/mailer/mailer.service'; -import { SequelizeMailLimitRepository } from '../../externals/mailer/mail-limit/mail-limit.repository'; +import { SequelizeMailLimitRepository } from '../security/mail-limit/mail-limit.repository'; describe('Share Use Cases', () => { let service: ShareUseCases; diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts index cf350fc48..62bbbc3c8 100644 --- a/src/modules/user/user.module.ts +++ b/src/modules/user/user.module.ts @@ -42,8 +42,7 @@ import { SharingService } from '../sharing/sharing.service'; import { SequelizeAttemptChangeEmailRepository } from './attempt-change-email.repository'; import { AttemptChangeEmailModel } from './attempt-change-email.model'; import { MailerService } from '../../externals/mailer/mailer.service'; -import { MailLimitModel } from '../../externals/mailer/mail-limit/mail-limit.model'; -import { SequelizeMailLimitRepository } from '../../externals/mailer/mail-limit/mail-limit.repository'; +import { SecurityModule } from '../security/security.module'; @Module({ imports: [ @@ -55,7 +54,6 @@ import { SequelizeMailLimitRepository } from '../../externals/mailer/mail-limit/ FriendInvitationModel, KeyServerModel, AttemptChangeEmailModel, - MailLimitModel, ]), forwardRef(() => FolderModule), forwardRef(() => FileModule), @@ -68,6 +66,7 @@ import { SequelizeMailLimitRepository } from '../../externals/mailer/mail-limit/ AppSumoModule, PlanModule, forwardRef(() => SharingModule), + SecurityModule, ], controllers: [UserController], providers: [ @@ -78,7 +77,6 @@ import { SequelizeMailLimitRepository } from '../../externals/mailer/mail-limit/ SequelizeKeyServerRepository, SequelizeUserReferralsRepository, SequelizeAttemptChangeEmailRepository, - SequelizeMailLimitRepository, UserUseCases, CryptoService, BridgeService, @@ -98,7 +96,6 @@ import { SequelizeMailLimitRepository } from '../../externals/mailer/mail-limit/ SequelizeUserReferralsRepository, SequelizeReferralRepository, SequelizeAttemptChangeEmailRepository, - SequelizeMailLimitRepository, ], }) export class UserModule {} diff --git a/src/modules/user/user.usecase.spec.ts b/src/modules/user/user.usecase.spec.ts index 54371f424..c2bdcc26d 100644 --- a/src/modules/user/user.usecase.spec.ts +++ b/src/modules/user/user.usecase.spec.ts @@ -33,9 +33,9 @@ import { UserNotFoundException } from './exception/user-not-found.exception'; import { AttemptChangeEmailNotFoundException } from './exception/attempt-change-email-not-found.exception'; import { AttemptChangeEmailHasExpiredException } from './exception/attempt-change-email-has-expired.exception'; import { AttemptChangeEmailAlreadyVerifiedException } from './exception/attempt-change-email-already-verified.exception'; -import { SequelizeMailLimitRepository } from '../../externals/mailer/mail-limit/mail-limit.repository'; import { newMailLimit } from '../../../test/fixtures'; -import { MailTypes } from '../../externals/mailer/mailTypes'; +import { MailTypes } from '../security/mail-limit/mailTypes'; +import { SequelizeMailLimitRepository } from '../security/mail-limit/mail-limit.repository'; jest.mock('../../middlewares/passport', () => { const originalModule = jest.requireActual('../../middlewares/passport'); diff --git a/src/modules/user/user.usecase.ts b/src/modules/user/user.usecase.ts index 3eb82a77f..b555e1b48 100644 --- a/src/modules/user/user.usecase.ts +++ b/src/modules/user/user.usecase.ts @@ -63,8 +63,8 @@ import { AttemptChangeEmailNotFoundException } from './exception/attempt-change- import { UserEmailAlreadyInUseException } from './exception/user-email-already-in-use.exception'; import { UserNotFoundException } from './exception/user-not-found.exception'; import { getTokenDefaultIat, isTokenIatGreaterThanDate } from '../../lib/jwt'; -import { SequelizeMailLimitRepository } from '../../externals/mailer/mail-limit/mail-limit.repository'; -import { MailTypes } from '../../externals/mailer/mailTypes'; +import { MailTypes } from '../security/mail-limit/mailTypes'; +import { SequelizeMailLimitRepository } from '../security/mail-limit/mail-limit.repository'; class ReferralsNotAvailableError extends Error { constructor() { diff --git a/test/fixtures.ts b/test/fixtures.ts index c6f6aab71..698e2b9a2 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -9,8 +9,8 @@ import { SharingType, } from '../src/modules/sharing/sharing.domain'; import { File, FileStatus } from '../src/modules/file/file.domain'; -import { MailTypes } from '../src/externals/mailer/mailTypes'; -import { MailLimit } from '../src/externals/mailer/mail-limit/mail-limit.domain'; +import { MailTypes } from '../src/modules/security/mail-limit/mailTypes'; +import { MailLimit } from '../src/modules/security/mail-limit/mail-limit.domain'; export const constants = { BUCKET_ID_LENGTH: 24, From a566cb9ea787c43077aaeb0fb9c5cde9ca46f084 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Thu, 18 Jan 2024 10:36:08 -0400 Subject: [PATCH 38/53] chore: use automock for missing dependencies in unit tests --- src/modules/file/file.usecase.spec.ts | 51 +------ src/modules/folder/folder.usecase.spec.ts | 49 +------ src/modules/send/send.usecase.spec.ts | 5 +- src/modules/share/share.usecase.spec.ts | 167 +--------------------- src/modules/trash/trash.usecase.spec.ts | 56 +------- src/modules/user/user.usecase.spec.ts | 94 +----------- 6 files changed, 32 insertions(+), 390 deletions(-) diff --git a/src/modules/file/file.usecase.spec.ts b/src/modules/file/file.usecase.spec.ts index 80277f27d..b7ff9340d 100644 --- a/src/modules/file/file.usecase.spec.ts +++ b/src/modules/file/file.usecase.spec.ts @@ -1,4 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { createMock } from '@golevelup/ts-jest'; import { FileUseCases } from './file.usecase'; import { SequelizeFileRepository, FileRepository } from './file.repository'; import { @@ -6,26 +7,15 @@ import { NotFoundException, UnprocessableEntityException, } from '@nestjs/common'; -import { getModelToken } from '@nestjs/sequelize'; import { File, FileAttributes, FileStatus } from './file.domain'; import { User } from '../user/user.domain'; import { ShareUseCases } from '../share/share.usecase'; -import { FolderUseCases } from '../folder/folder.usecase'; -import { - SequelizeShareRepository, - ShareModel, -} from '../share/share.repository'; -import { - FolderModel, - SequelizeFolderRepository, -} from '../folder/folder.repository'; -import { SequelizeUserRepository, UserModel } from '../user/user.repository'; + import { BridgeModule } from '../../externals/bridge/bridge.module'; import { BridgeService } from '../../externals/bridge/bridge.service'; import { CryptoService } from '../../externals/crypto/crypto.service'; import { CryptoModule } from '../../externals/crypto/crypto.module'; -import { FileModel } from './file.model'; -import { ThumbnailModel } from '../thumbnail/thumbnail.model'; + const fileId = '6295c99a241bb000083f1c6a'; const userId = 1; const folderId = 4; @@ -69,37 +59,10 @@ describe('FileUseCases', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [BridgeModule, CryptoModule], - providers: [ - FileUseCases, - SequelizeFileRepository, - { - provide: getModelToken(FileModel), - useValue: jest.fn(), - }, - ShareUseCases, - SequelizeShareRepository, - { - provide: getModelToken(ShareModel), - useValue: jest.fn(), - }, - FolderUseCases, - SequelizeFolderRepository, - { - provide: getModelToken(FolderModel), - useValue: jest.fn(), - }, - SequelizeUserRepository, - { - provide: getModelToken(UserModel), - useValue: jest.fn(), - }, - { - provide: getModelToken(ThumbnailModel), - useValue: jest.fn(), - }, - CryptoService, - ], - }).compile(); + providers: [FileUseCases, CryptoService], + }) + .useMocker(() => createMock()) + .compile(); service = module.get(FileUseCases); // eslint-disable-next-line @typescript-eslint/ban-ts-comment diff --git a/src/modules/folder/folder.usecase.spec.ts b/src/modules/folder/folder.usecase.spec.ts index f6d46211d..def2299eb 100644 --- a/src/modules/folder/folder.usecase.spec.ts +++ b/src/modules/folder/folder.usecase.spec.ts @@ -1,4 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { createMock } from '@golevelup/ts-jest'; import { FolderUseCases } from './folder.usecase'; import { SequelizeFolderRepository, @@ -8,24 +9,11 @@ import { NotFoundException, UnprocessableEntityException, } from '@nestjs/common'; -import { getModelToken } from '@nestjs/sequelize'; import { Folder, FolderAttributes, FolderOptions } from './folder.domain'; -import { FileUseCases } from '../file/file.usecase'; -import { SequelizeFileRepository } from '../file/file.repository'; -import { - SequelizeShareRepository, - ShareModel, -} from '../share/share.repository'; -import { ShareUseCases } from '../share/share.usecase'; -import { SequelizeUserRepository, UserModel } from '../user/user.repository'; import { BridgeModule } from '../../externals/bridge/bridge.module'; import { CryptoModule } from '../../externals/crypto/crypto.module'; import { CryptoService } from '../../externals/crypto/crypto.service'; import { User } from '../user/user.domain'; -import { FolderModel } from './folder.model'; -import { FileModel } from '../file/file.model'; -import { SequelizeThumbnailRepository } from '../thumbnail/thumbnail.repository'; -import { ThumbnailModel } from '../thumbnail/thumbnail.model'; const folderId = 4; const userId = 1; @@ -37,37 +25,10 @@ describe('FolderUseCases', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [BridgeModule, CryptoModule], - providers: [ - FolderUseCases, - FileUseCases, - SequelizeFileRepository, - SequelizeFolderRepository, - { - provide: getModelToken(FolderModel), - useValue: jest.fn(), - }, - { - provide: getModelToken(FileModel), - useValue: jest.fn(), - }, - ShareUseCases, - SequelizeShareRepository, - SequelizeThumbnailRepository, - { - provide: getModelToken(ShareModel), - useValue: jest.fn(), - }, - SequelizeUserRepository, - { - provide: getModelToken(ThumbnailModel), - useValue: jest.fn(), - }, - { - provide: getModelToken(UserModel), - useValue: jest.fn(), - }, - ], - }).compile(); + providers: [FolderUseCases], + }) + .useMocker(() => createMock()) + .compile(); service = module.get(FolderUseCases); folderRepository = module.get(SequelizeFolderRepository); diff --git a/src/modules/send/send.usecase.spec.ts b/src/modules/send/send.usecase.spec.ts index 120b1de56..ac553dba8 100644 --- a/src/modules/send/send.usecase.spec.ts +++ b/src/modules/send/send.usecase.spec.ts @@ -16,6 +16,7 @@ import { SequelizeSendRepository, } from './send-link.repository'; import { SendUseCases } from './send.usecase'; +import { createMock } from '@golevelup/ts-jest'; describe('Send Use Cases', () => { let service: SendUseCases, notificationService, sendRepository; @@ -80,7 +81,9 @@ describe('Send Use Cases', () => { useValue: jest.fn(), }, ], - }).compile(); + }) + .useMocker(() => createMock()) + .compile(); service = module.get(SendUseCases); notificationService = module.get(NotificationService); diff --git a/src/modules/share/share.usecase.spec.ts b/src/modules/share/share.usecase.spec.ts index 03d17a0ad..79fe2d961 100644 --- a/src/modules/share/share.usecase.spec.ts +++ b/src/modules/share/share.usecase.spec.ts @@ -3,71 +3,23 @@ import { UnauthorizedException, NotFoundException, } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { getModelToken } from '@nestjs/sequelize'; import { Test, TestingModule } from '@nestjs/testing'; -import { CryptoModule } from '../../externals/crypto/crypto.module'; -import { BridgeModule } from '../../externals/bridge/bridge.module'; import { File, FileStatus } from '../file/file.domain'; import { FileRepository, SequelizeFileRepository, } from '../file/file.repository'; -import { FileUseCases } from '../file/file.usecase'; import { Folder } from '../folder/folder.domain'; import { - FolderModel, FolderRepository, SequelizeFolderRepository, } from '../folder/folder.repository'; -import { FolderUseCases } from '../folder/folder.usecase'; import { User } from '../user/user.domain'; -import { SequelizeUserRepository, UserModel } from '../user/user.repository'; -import { UserUseCases } from '../user/user.usecase'; import { Share, ShareAttributes } from './share.domain'; -import { SequelizeShareRepository, ShareModel } from './share.repository'; +import { SequelizeShareRepository } from './share.repository'; import { ShareUseCases } from './share.usecase'; import { CryptoService } from '../../externals/crypto/crypto.service'; -import { - FriendInvitationModel, - SequelizeSharedWorkspaceRepository, -} from '../../shared-workspace/shared-workspace.repository'; -import { - ReferralModel, - SequelizeReferralRepository, -} from '../user/referrals.repository'; -import { - SequelizeUserReferralsRepository, - UserReferralModel, -} from '../user/user-referrals.repository'; -import { PaymentsService } from '../../externals/payments/payments.service'; -import { EventEmitter2 } from '@nestjs/event-emitter'; -import { NotificationService } from '../../externals/notifications/notification.service'; -import { NewsletterService } from '../../externals/newsletter'; -import { HttpClient } from '../../externals/http/http.service'; -import { HttpModule } from '@nestjs/axios'; -import { FileModel } from '../file/file.model'; -import { ThumbnailModel } from '../thumbnail/thumbnail.model'; -import { SequelizeKeyServerRepository } from '../keyserver/key-server.repository'; -import { AvatarService } from '../../externals/avatar/avatar.service'; -import { SequelizePreCreatedUsersRepository } from '../user/pre-created-users.repository'; -import { PreCreatedUserModel } from '../user/pre-created-users.model'; -import { SequelizeSharingRepository } from '../sharing/sharing.repository'; -import { - PermissionModel, - RoleModel, - SharingInviteModel, - SharingModel, -} from '../sharing/models'; -import { SharingRolesModel } from '../sharing/models/sharing-roles.model'; -import { AppSumoUseCase } from '../app-sumo/app-sumo.usecase'; -import { AppSumoModel } from '../app-sumo/app-sumo.model'; -import { SequelizeAppSumoRepository } from '../app-sumo/app-sumo.repository'; -import { SequelizePlanRepository } from '../plan/plan.repository'; -import { PlanModel } from '../plan/plan.model'; -import { SequelizeAttemptChangeEmailRepository } from '../user/attempt-change-email.repository'; import { createMock } from '@golevelup/ts-jest'; -import { MailerService } from '../../externals/mailer/mailer.service'; describe('Share Use Cases', () => { let service: ShareUseCases; @@ -223,119 +175,10 @@ describe('Share Use Cases', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - imports: [BridgeModule, CryptoModule, HttpModule], - providers: [ - ShareUseCases, - FolderUseCases, - UserUseCases, - FileUseCases, - SequelizePreCreatedUsersRepository, - SequelizeSharingRepository, - SequelizeAppSumoRepository, - AppSumoUseCase, - SequelizePlanRepository, - SequelizeShareRepository, - SequelizeFileRepository, - SequelizeFolderRepository, - SequelizeUserRepository, - SequelizeSharedWorkspaceRepository, - SequelizeReferralRepository, - SequelizeUserReferralsRepository, - SequelizeKeyServerRepository, - PaymentsService, - EventEmitter2, - NotificationService, - ConfigService, - NewsletterService, - AvatarService, - { - provide: HttpClient, - useValue: { - post: jest.fn().mockResolvedValue({}), - }, - }, - { - provide: SequelizeKeyServerRepository, - useValue: { - findUserKeysOrCreate: () => { - return {}; - }, - }, - }, - { - provide: getModelToken(ShareModel), - useValue: jest.fn(), - }, - { - provide: getModelToken(FileModel), - useValue: jest.fn(), - }, - { - provide: getModelToken(FolderModel), - useValue: jest.fn(), - }, - { - provide: getModelToken(UserModel), - useValue: jest.fn(), - }, - { - provide: getModelToken(PreCreatedUserModel), - useValue: jest.fn(), - }, - { - provide: getModelToken(SharingModel), - useValue: jest.fn(), - }, - { - provide: getModelToken(PermissionModel), - useValue: jest.fn(), - }, - { - provide: getModelToken(RoleModel), - useValue: jest.fn(), - }, - { - provide: getModelToken(SharingRolesModel), - useValue: jest.fn(), - }, - { - provide: getModelToken(SharingInviteModel), - useValue: jest.fn(), - }, - { - provide: getModelToken(AppSumoModel), - useValue: jest.fn(), - }, - { - provide: getModelToken(PlanModel), - useValue: jest.fn(), - }, - { - provide: getModelToken(ReferralModel), - useValue: jest.fn(), - }, - { - provide: getModelToken(UserReferralModel), - useValue: jest.fn(), - }, - { - provide: getModelToken(FriendInvitationModel), - useValue: jest.fn(), - }, - { - provide: getModelToken(ThumbnailModel), - useValue: jest.fn(), - }, - { - provide: SequelizeAttemptChangeEmailRepository, - useValue: createMock(), - }, - { - provide: MailerService, - useValue: createMock(), - }, - ], - }).compile(); + providers: [ShareUseCases], + }) + .useMocker(() => createMock()) + .compile(); service = module.get(ShareUseCases); shareRepository = module.get( diff --git a/src/modules/trash/trash.usecase.spec.ts b/src/modules/trash/trash.usecase.spec.ts index 3b989c7c5..f94c75ee7 100644 --- a/src/modules/trash/trash.usecase.spec.ts +++ b/src/modules/trash/trash.usecase.spec.ts @@ -1,27 +1,13 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { createMock } from '@golevelup/ts-jest'; + import { TrashUseCases } from './trash.usecase'; -import { SequelizeFileRepository } from '../file/file.repository'; import { File, FileAttributes } from '../file/file.domain'; -import { - FolderModel, - SequelizeFolderRepository, -} from '../folder/folder.repository'; -import { getModelToken } from '@nestjs/sequelize'; import { User } from '../user/user.domain'; -import { SequelizeUserRepository, UserModel } from '../user/user.repository'; import { Folder, FolderAttributes } from '../folder/folder.domain'; import { FileUseCases } from '../file/file.usecase'; import { FolderUseCases } from '../folder/folder.usecase'; -import { ShareUseCases } from '../share/share.usecase'; -import { - SequelizeShareRepository, - ShareModel, -} from '../share/share.repository'; -import { BridgeModule } from '../../externals/bridge/bridge.module'; -import { CryptoModule } from '../..//externals/crypto/crypto.module'; -import { NotFoundException } from '@nestjs/common'; -import { FileModel } from '../file/file.model'; -import { ThumbnailModel } from '../thumbnail/thumbnail.model'; describe('Trash Use Cases', () => { let service: TrashUseCases, @@ -59,38 +45,10 @@ describe('Trash Use Cases', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - imports: [BridgeModule, CryptoModule], - providers: [ - TrashUseCases, - FileUseCases, - SequelizeFileRepository, - { - provide: getModelToken(FileModel), - useValue: jest.fn(), - }, - FolderUseCases, - SequelizeFolderRepository, - { - provide: getModelToken(FolderModel), - useValue: jest.fn(), - }, - ShareUseCases, - SequelizeShareRepository, - { - provide: getModelToken(ShareModel), - useValue: jest.fn(), - }, - SequelizeUserRepository, - { - provide: getModelToken(UserModel), - useValue: jest.fn(), - }, - { - provide: getModelToken(ThumbnailModel), - useValue: jest.fn(), - }, - ], - }).compile(); + providers: [TrashUseCases], + }) + .useMocker(() => createMock()) + .compile(); service = module.get(TrashUseCases); fileUseCases = module.get(FileUseCases); diff --git a/src/modules/user/user.usecase.spec.ts b/src/modules/user/user.usecase.spec.ts index d2a87473d..9131c9543 100644 --- a/src/modules/user/user.usecase.spec.ts +++ b/src/modules/user/user.usecase.spec.ts @@ -10,22 +10,12 @@ import { FileUseCases } from '../file/file.usecase'; import { User } from './user.domain'; import { SequelizeUserRepository } from './user.repository'; import { SequelizeSharedWorkspaceRepository } from '../../shared-workspace/shared-workspace.repository'; -import { SequelizeReferralRepository } from './referrals.repository'; -import { SequelizeUserReferralsRepository } from './user-referrals.repository'; import { CryptoService } from '../../externals/crypto/crypto.service'; import { BridgeService } from '../../externals/bridge/bridge.service'; -import { NotificationService } from '../../externals/notifications/notification.service'; -import { PaymentsService } from '../../externals/payments/payments.service'; -import { NewsletterService } from '../../externals/newsletter'; import { ConfigService } from '@nestjs/config'; -import { SequelizeKeyServerRepository } from '../keyserver/key-server.repository'; import { Folder, FolderAttributes } from '../folder/folder.domain'; import { File, FileAttributes } from '../file/file.domain'; -import { AvatarService } from '../../externals/avatar/avatar.service'; -import { SequelizePreCreatedUsersRepository } from './pre-created-users.repository'; -import { SequelizeSharingRepository } from '../sharing/sharing.repository'; import { SequelizeAttemptChangeEmailRepository } from './attempt-change-email.repository'; -import { MailerService } from '../../externals/mailer/mailer.service'; import { UserNotFoundException } from './exception/user-not-found.exception'; import { AttemptChangeEmailNotFoundException } from './exception/attempt-change-email-not-found.exception'; import { AttemptChangeEmailHasExpiredException } from './exception/attempt-change-email-has-expired.exception'; @@ -470,84 +460,8 @@ describe('User use cases', () => { const createTestingModule = (): Promise => { return Test.createTestingModule({ controllers: [], - providers: [ - { - provide: SequelizeUserRepository, - useValue: createMock(), - }, - { - provide: SequelizeSharedWorkspaceRepository, - useValue: createMock(), - }, - { - provide: SequelizePreCreatedUsersRepository, - useValue: createMock(), - }, - { - provide: SequelizeSharingRepository, - useValue: createMock(), - }, - { - provide: SequelizeReferralRepository, - useValue: createMock(), - }, - { - provide: SequelizeUserReferralsRepository, - useValue: createMock(), - }, - { - provide: FileUseCases, - useValue: createMock(), - }, - { - provide: FolderUseCases, - useValue: createMock(), - }, - { - provide: ShareUseCases, - useValue: createMock(), - }, - { - provide: ConfigService, - useValue: createMock(), - }, - { - provide: CryptoService, - useValue: createMock(), - }, - { - provide: BridgeService, - useValue: createMock(), - }, - { - provide: NotificationService, - useValue: createMock(), - }, - { - provide: PaymentsService, - useValue: createMock(), - }, - { - provide: NewsletterService, - useValue: createMock(), - }, - { - provide: SequelizeKeyServerRepository, - useValue: createMock(), - }, - { - provide: AvatarService, - useValue: createMock(), - }, - { - provide: SequelizeAttemptChangeEmailRepository, - useValue: createMock(), - }, - { - provide: MailerService, - useValue: createMock(), - }, - UserUseCases, - ], - }).compile(); + providers: [UserUseCases], + }) + .useMocker(() => createMock()) + .compile(); }; From 09050766004a5f43d67365fcd8cbffaba1f8a1e3 Mon Sep 17 00:00:00 2001 From: Edison J Padilla Date: Thu, 11 Jan 2024 01:36:11 -0400 Subject: [PATCH 39/53] PB-845: Calculate folder size and expose it via shared & Drive --- src/common/base-http.exception.ts | 20 +--- src/modules/file/file.usecase.spec.ts | 6 +- ...calculate-folder-size-timeout.exception.ts | 12 +++ src/modules/folder/folder.controller.spec.ts | 50 ++++++++++ src/modules/folder/folder.controller.ts | 12 ++- src/modules/folder/folder.repository.spec.ts | 97 +++++++++++++++++++ src/modules/folder/folder.repository.ts | 62 +++++++++++- src/modules/folder/folder.usecase.spec.ts | 32 ++++++ src/modules/folder/folder.usecase.ts | 11 ++- src/modules/send/send.usecase.spec.ts | 2 +- src/modules/share/share.usecase.spec.ts | 2 +- src/modules/trash/trash.usecase.spec.ts | 8 +- 12 files changed, 278 insertions(+), 36 deletions(-) create mode 100644 src/modules/folder/exception/calculate-folder-size-timeout.exception.ts create mode 100644 src/modules/folder/folder.controller.spec.ts create mode 100644 src/modules/folder/folder.repository.spec.ts diff --git a/src/common/base-http.exception.ts b/src/common/base-http.exception.ts index 39a858589..285cc42c7 100644 --- a/src/common/base-http.exception.ts +++ b/src/common/base-http.exception.ts @@ -1,25 +1,11 @@ import { HttpStatus } from '@nestjs/common'; export class BaseHttpException extends Error { - private readonly _statusCode: number; - private readonly _code: string; - constructor( - message: string, - statusCode = HttpStatus.INTERNAL_SERVER_ERROR, - code?: string, + public readonly message: string, + public readonly statusCode = HttpStatus.INTERNAL_SERVER_ERROR, + public readonly code?: string, ) { super(message); - this.message = message; - this._statusCode = statusCode; - this._code = code; - } - - get statusCode(): number { - return this._statusCode; - } - - get code(): string { - return this._code; } } diff --git a/src/modules/file/file.usecase.spec.ts b/src/modules/file/file.usecase.spec.ts index 80277f27d..76494bf1c 100644 --- a/src/modules/file/file.usecase.spec.ts +++ b/src/modules/file/file.usecase.spec.ts @@ -15,10 +15,8 @@ import { SequelizeShareRepository, ShareModel, } from '../share/share.repository'; -import { - FolderModel, - SequelizeFolderRepository, -} from '../folder/folder.repository'; +import { SequelizeFolderRepository } from '../folder/folder.repository'; +import { FolderModel } from '../folder/folder.model'; import { SequelizeUserRepository, UserModel } from '../user/user.repository'; import { BridgeModule } from '../../externals/bridge/bridge.module'; import { BridgeService } from '../../externals/bridge/bridge.service'; diff --git a/src/modules/folder/exception/calculate-folder-size-timeout.exception.ts b/src/modules/folder/exception/calculate-folder-size-timeout.exception.ts new file mode 100644 index 000000000..87a72ea97 --- /dev/null +++ b/src/modules/folder/exception/calculate-folder-size-timeout.exception.ts @@ -0,0 +1,12 @@ +import { HttpStatus } from '@nestjs/common'; +import { BaseHttpException } from '../../../common/base-http.exception'; + +export class CalculateFolderSizeTimeoutException extends BaseHttpException { + constructor( + message = 'Calculate folder size timeout', + code = 'CALCULATE_FOLDER_SIZE_TIMEOUT', + statusCode = HttpStatus.UNPROCESSABLE_ENTITY, + ) { + super(message, statusCode, code); + } +} diff --git a/src/modules/folder/folder.controller.spec.ts b/src/modules/folder/folder.controller.spec.ts new file mode 100644 index 000000000..7239801f1 --- /dev/null +++ b/src/modules/folder/folder.controller.spec.ts @@ -0,0 +1,50 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { newFolder } from '../../../test/fixtures'; +import { FileUseCases } from '../file/file.usecase'; +import { FolderController } from './folder.controller'; +import { Folder } from './folder.domain'; +import { FolderUseCases } from './folder.usecase'; +import { CalculateFolderSizeTimeoutException } from './exception/calculate-folder-size-timeout.exception'; + +describe('FolderController', () => { + let folderController: FolderController; + let folderUseCases: FolderUseCases; + let folder: Folder; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [FolderController], + providers: [ + { provide: FolderUseCases, useValue: createMock() }, + { provide: FileUseCases, useValue: createMock() }, + ], + }).compile(); + + folderController = module.get(FolderController); + folderUseCases = module.get(FolderUseCases); + folder = newFolder(); + }); + + describe('get folder size', () => { + it('When get folder size is requested, then return the folder size', async () => { + const expectedSize = 100; + jest + .spyOn(folderUseCases, 'getFolderSizeByUuid') + .mockResolvedValue(expectedSize); + + const result = await folderController.getFolderSize(folder.uuid); + expect(result).toEqual({ size: expectedSize }); + }); + + it('When get folder size times out, then throw an exception', async () => { + jest + .spyOn(folderUseCases, 'getFolderSizeByUuid') + .mockRejectedValue(new CalculateFolderSizeTimeoutException()); + + await expect(folderController.getFolderSize(folder.uuid)).rejects.toThrow( + CalculateFolderSizeTimeoutException, + ); + }); + }); +}); diff --git a/src/modules/folder/folder.controller.ts b/src/modules/folder/folder.controller.ts index 81d8ae4ac..6505c2a44 100644 --- a/src/modules/folder/folder.controller.ts +++ b/src/modules/folder/folder.controller.ts @@ -9,6 +9,7 @@ import { NotImplementedException, Param, Query, + UseFilters, } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { FolderUseCases } from './folder.usecase'; @@ -19,6 +20,7 @@ import { Folder, SortableFolderAttributes } from './folder.domain'; import { FileStatus, SortableFileAttributes } from '../file/file.domain'; import logger from '../../externals/logger'; import { validate } from 'uuid'; +import { HttpExceptionFilter } from '../../lib/http/http-exception.filter'; const foldersStatuses = ['ALL', 'EXISTS', 'TRASHED', 'DELETED'] as const; @@ -255,7 +257,7 @@ export class FolderController { @UserDecorator() user: User, @Query('limit') limit: number, @Query('offset') offset: number, - @Query('status') status: typeof foldersStatuses[number], + @Query('status') status: (typeof foldersStatuses)[number], @Query('updatedAt') updatedAt?: string, ) { if (!status) { @@ -418,4 +420,12 @@ export class FolderController { }); } } + + @UseFilters(new HttpExceptionFilter()) + @Get(':uuid/size') + async getFolderSize(@Param('uuid') folderUuid: Folder['uuid']) { + const size = await this.folderUseCases.getFolderSizeByUuid(folderUuid); + + return { size }; + } } diff --git a/src/modules/folder/folder.repository.spec.ts b/src/modules/folder/folder.repository.spec.ts new file mode 100644 index 000000000..6d663914a --- /dev/null +++ b/src/modules/folder/folder.repository.spec.ts @@ -0,0 +1,97 @@ +import { createMock } from '@golevelup/ts-jest'; +import { CalculateFolderSizeTimeoutException } from './exception/calculate-folder-size-timeout.exception'; +import { SequelizeFolderRepository } from './folder.repository'; +import { FolderModel } from './folder.model'; +import { Folder } from './folder.domain'; +import { newFolder } from '../../../test/fixtures'; + +jest.mock('./folder.model', () => ({ + FolderModel: { + sequelize: { + query: jest.fn(() => Promise.resolve([[{ totalsize: 100 }]])), + }, + }, +})); + +describe('SequelizeFolderRepository', () => { + const TIMEOUT_ERROR_CODE = '57014'; + + let repository: SequelizeFolderRepository; + let folderModel: typeof FolderModel; + let folder: Folder; + + beforeEach(async () => { + folderModel = createMock(); + + repository = new SequelizeFolderRepository(folderModel); + + folder = newFolder(); + }); + + describe('calculate folder size', () => { + it('When calculate folder size is requested, then it works', async () => { + const calculateSizeQuery = ` + WITH RECURSIVE folder_recursive AS ( + SELECT + fl1.uuid, + fl1.parent_uuid, + f1.size AS filesize, + 1 AS row_num, + fl1.user_id as owner_id + FROM folders fl1 + LEFT JOIN files f1 ON f1.folder_uuid = fl1.uuid + WHERE fl1.uuid = :folderUuid + AND fl1.removed = FALSE + AND fl1.deleted = FALSE + AND f1.status != 'DELETED' + + UNION ALL + + SELECT + fl2.uuid, + fl2.parent_uuid, + f2.size AS filesize, + fr.row_num + 1, + fr.owner_id + FROM folders fl2 + INNER JOIN files f2 ON f2.folder_uuid = fl2.uuid + INNER JOIN folder_recursive fr ON fr.uuid = fl2.parent_uuid + WHERE fr.row_num < 100000 + AND fl2.user_id = fr.owner_id + AND fl2.removed = FALSE + AND fl2.deleted = FALSE + AND f2.status != 'DELETED' + ) + SELECT COALESCE(SUM(filesize), 0) AS totalsize FROM folder_recursive; + `; + + jest + .spyOn(FolderModel.sequelize, 'query') + .mockResolvedValue([[{ totalsize: 100 }]] as any); + + const size = await repository.calculateFolderSize(folder.uuid); + + expect(size).toBeGreaterThanOrEqual(0); + expect(FolderModel.sequelize.query).toHaveBeenCalledWith( + calculateSizeQuery, + { + replacements: { + folderUuid: folder.uuid, + }, + }, + ); + }); + + it('When the folder size calculation times out, then throw an exception', async () => { + jest.spyOn(FolderModel.sequelize, 'query').mockRejectedValue({ + original: { + code: TIMEOUT_ERROR_CODE, + }, + }); + + await expect(repository.calculateFolderSize(folder.uuid)).rejects.toThrow( + CalculateFolderSizeTimeoutException, + ); + }); + }); +}); diff --git a/src/modules/folder/folder.repository.ts b/src/modules/folder/folder.repository.ts index 825a4d058..29d3fc01a 100644 --- a/src/modules/folder/folder.repository.ts +++ b/src/modules/folder/folder.repository.ts @@ -6,12 +6,12 @@ import { v4 } from 'uuid'; import { Folder } from './folder.domain'; import { FolderAttributes } from './folder.attributes'; -import { UserModel } from '../user/user.model'; import { User } from '../user/user.domain'; import { UserAttributes } from '../user/user.attributes'; import { Pagination } from '../../lib/pagination'; import { FolderModel } from './folder.model'; import { SharingModel } from '../sharing/models'; +import { CalculateFolderSizeTimeoutException } from './exception/calculate-folder-size-timeout.exception'; function mapSnakeCaseToCamelCase(data) { const camelCasedObject = {}; @@ -59,6 +59,7 @@ export interface FolderRepository { ): Promise; deleteById(folderId: FolderAttributes['id']): Promise; clearOrphansFolders(userId: FolderAttributes['userId']): Promise; + calculateFolderSize(folderUuid: string): Promise; } @Injectable() @@ -66,8 +67,6 @@ export class SequelizeFolderRepository implements FolderRepository { constructor( @InjectModel(FolderModel) private folderModel: typeof FolderModel, - @InjectModel(UserModel) - private userModel: typeof UserModel, ) {} async findAllCursor( @@ -447,6 +446,62 @@ export class SequelizeFolderRepository implements FolderRepository { return folders.map((folder) => this.toDomain(folder)); } + async calculateFolderSize(folderUuid: string): Promise { + try { + const calculateSizeQuery = ` + WITH RECURSIVE folder_recursive AS ( + SELECT + fl1.uuid, + fl1.parent_uuid, + f1.size AS filesize, + 1 AS row_num, + fl1.user_id as owner_id + FROM folders fl1 + LEFT JOIN files f1 ON f1.folder_uuid = fl1.uuid + WHERE fl1.uuid = :folderUuid + AND fl1.removed = FALSE + AND fl1.deleted = FALSE + AND f1.status != 'DELETED' + + UNION ALL + + SELECT + fl2.uuid, + fl2.parent_uuid, + f2.size AS filesize, + fr.row_num + 1, + fr.owner_id + FROM folders fl2 + INNER JOIN files f2 ON f2.folder_uuid = fl2.uuid + INNER JOIN folder_recursive fr ON fr.uuid = fl2.parent_uuid + WHERE fr.row_num < 100000 + AND fl2.user_id = fr.owner_id + AND fl2.removed = FALSE + AND fl2.deleted = FALSE + AND f2.status != 'DELETED' + ) + SELECT COALESCE(SUM(filesize), 0) AS totalsize FROM folder_recursive; + `; + + const [[{ totalsize }]]: any = await FolderModel.sequelize.query( + calculateSizeQuery, + { + replacements: { + folderUuid, + }, + }, + ); + + return +totalsize; + } catch (error) { + if (error.original?.code === '57014') { + throw new CalculateFolderSizeTimeoutException(); + } + + throw error; + } + } + private toDomain(model: FolderModel): Folder { return Folder.build({ ...model.toJSON(), @@ -459,4 +514,3 @@ export class SequelizeFolderRepository implements FolderRepository { return domain.toJSON(); } } -export { FolderModel }; diff --git a/src/modules/folder/folder.usecase.spec.ts b/src/modules/folder/folder.usecase.spec.ts index f6d46211d..829e7f36c 100644 --- a/src/modules/folder/folder.usecase.spec.ts +++ b/src/modules/folder/folder.usecase.spec.ts @@ -26,6 +26,8 @@ import { FolderModel } from './folder.model'; import { FileModel } from '../file/file.model'; import { SequelizeThumbnailRepository } from '../thumbnail/thumbnail.repository'; import { ThumbnailModel } from '../thumbnail/thumbnail.model'; +import { CalculateFolderSizeTimeoutException } from './exception/calculate-folder-size-timeout.exception'; +import { newFolder } from '../../../test/fixtures'; const folderId = 4; const userId = 1; @@ -492,4 +494,34 @@ describe('FolderUseCases', () => { } }); }); + + describe('get folder size', () => { + const folder = newFolder(); + + it('When the folder size is requested to be calculated, then it works', async () => { + const mockSize = 123456789; + + jest + .spyOn(folderRepository, 'calculateFolderSize') + .mockResolvedValueOnce(mockSize); + + const result = await service.getFolderSizeByUuid(folder.uuid); + + expect(result).toBe(mockSize); + expect(folderRepository.calculateFolderSize).toHaveBeenCalledTimes(1); + expect(folderRepository.calculateFolderSize).toHaveBeenCalledWith( + folder.uuid, + ); + }); + + it('When the folder size times out, then throw an exception', async () => { + jest + .spyOn(folderRepository, 'calculateFolderSize') + .mockRejectedValueOnce(new CalculateFolderSizeTimeoutException()); + + await expect(service.getFolderSizeByUuid(folder.uuid)).rejects.toThrow( + CalculateFolderSizeTimeoutException, + ); + }); + }); }); diff --git a/src/modules/folder/folder.usecase.ts b/src/modules/folder/folder.usecase.ts index fc5568b22..f34fad779 100644 --- a/src/modules/folder/folder.usecase.ts +++ b/src/modules/folder/folder.usecase.ts @@ -16,6 +16,7 @@ import { } from './folder.domain'; import { FolderAttributes } from './folder.attributes'; import { SequelizeFolderRepository } from './folder.repository'; +import { SequelizeFileRepository } from '../file/file.repository'; const invalidName = /[\\/]|^\s*$/; @@ -26,6 +27,7 @@ export class FolderUseCases { constructor( private folderRepository: SequelizeFolderRepository, private userRepository: SequelizeUserRepository, + private readonly fileRepository: SequelizeFileRepository, private readonly cryptoService: CryptoService, ) {} @@ -498,9 +500,8 @@ export class FolderUseCases { } async deleteOrphansFolders(userId: UserAttributes['id']): Promise { - let remainingFolders = await this.folderRepository.clearOrphansFolders( - userId, - ); + let remainingFolders = + await this.folderRepository.clearOrphansFolders(userId); if (remainingFolders > 0) { remainingFolders += await this.deleteOrphansFolders(userId); @@ -536,4 +537,8 @@ export class FolderUseCases { async deleteByUser(user: User, folders: Folder[]): Promise { await this.folderRepository.deleteByUser(user, folders); } + + getFolderSizeByUuid(folderUuid: Folder['uuid']): Promise { + return this.folderRepository.calculateFolderSize(folderUuid); + } } diff --git a/src/modules/send/send.usecase.spec.ts b/src/modules/send/send.usecase.spec.ts index 120b1de56..7e7054241 100644 --- a/src/modules/send/send.usecase.spec.ts +++ b/src/modules/send/send.usecase.spec.ts @@ -5,7 +5,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Sequelize } from 'sequelize-typescript'; import { CryptoModule } from '../../externals/crypto/crypto.module'; import { NotificationService } from '../../externals/notifications/notification.service'; -import { FolderModel } from '../folder/folder.repository'; +import { FolderModel } from '../folder/folder.model'; import { User } from '../user/user.domain'; import { UserModel } from '../user/user.repository'; import { SendLink } from './send-link.domain'; diff --git a/src/modules/share/share.usecase.spec.ts b/src/modules/share/share.usecase.spec.ts index 03d17a0ad..edbff6f0f 100644 --- a/src/modules/share/share.usecase.spec.ts +++ b/src/modules/share/share.usecase.spec.ts @@ -16,10 +16,10 @@ import { import { FileUseCases } from '../file/file.usecase'; import { Folder } from '../folder/folder.domain'; import { - FolderModel, FolderRepository, SequelizeFolderRepository, } from '../folder/folder.repository'; +import { FolderModel } from '../folder/folder.model'; import { FolderUseCases } from '../folder/folder.usecase'; import { User } from '../user/user.domain'; import { SequelizeUserRepository, UserModel } from '../user/user.repository'; diff --git a/src/modules/trash/trash.usecase.spec.ts b/src/modules/trash/trash.usecase.spec.ts index 3b989c7c5..01d52991f 100644 --- a/src/modules/trash/trash.usecase.spec.ts +++ b/src/modules/trash/trash.usecase.spec.ts @@ -2,10 +2,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { TrashUseCases } from './trash.usecase'; import { SequelizeFileRepository } from '../file/file.repository'; import { File, FileAttributes } from '../file/file.domain'; -import { - FolderModel, - SequelizeFolderRepository, -} from '../folder/folder.repository'; +import { SequelizeFolderRepository } from '../folder/folder.repository'; +import { FolderModel } from '../folder/folder.model'; import { getModelToken } from '@nestjs/sequelize'; import { User } from '../user/user.domain'; import { SequelizeUserRepository, UserModel } from '../user/user.repository'; @@ -18,7 +16,7 @@ import { ShareModel, } from '../share/share.repository'; import { BridgeModule } from '../../externals/bridge/bridge.module'; -import { CryptoModule } from '../..//externals/crypto/crypto.module'; +import { CryptoModule } from '../../externals/crypto/crypto.module'; import { NotFoundException } from '@nestjs/common'; import { FileModel } from '../file/file.model'; import { ThumbnailModel } from '../thumbnail/thumbnail.model'; From 54873793ba5e9c5ba5e8963a91fb004543bd9df1 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Thu, 18 Jan 2024 20:45:40 -0400 Subject: [PATCH 40/53] feat: expire tokens issued before the last mail sent --- src/lib/jwt.ts | 2 +- src/lib/time.spec.ts | 70 +++++++++++++++ src/lib/time.ts | 13 ++- .../security/mail-limit/mail-limit.domain.ts | 4 +- .../mail-limit/mail-limit.repository.ts | 10 +++ src/modules/user/user.controller.ts | 2 +- src/modules/user/user.usecase.spec.ts | 89 +++++++++++-------- src/modules/user/user.usecase.ts | 29 +++--- 8 files changed, 158 insertions(+), 61 deletions(-) create mode 100644 src/lib/time.spec.ts diff --git a/src/lib/jwt.ts b/src/lib/jwt.ts index 57fc3c827..193ec3491 100644 --- a/src/lib/jwt.ts +++ b/src/lib/jwt.ts @@ -47,5 +47,5 @@ export function getTokenDefaultIat() { } export function isTokenIatGreaterThanDate(date: Date, iat: number) { - return Math.floor(date.getTime() / 1000) <= iat; + return Math.floor(date.getTime() / 1000) < iat; } diff --git a/src/lib/time.spec.ts b/src/lib/time.spec.ts new file mode 100644 index 000000000..e6ecf8654 --- /dev/null +++ b/src/lib/time.spec.ts @@ -0,0 +1,70 @@ +import { Time } from './time'; + +describe('Time class', () => { + const fixedSystemCurrentDate = new Date('2022-01-01'); + + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(fixedSystemCurrentDate); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + describe('now()', () => { + it('When is called, then returns the current date', () => { + const currentDate = Time.now(); + expect(currentDate.getTime()).toBeGreaterThanOrEqual( + new Date('2022-01-01').getTime(), + ); + }); + + it('When is called and date is provided, then returns the date provided', () => { + const initialDate = new Date('2022-02-02'); + const currentDate = Time.now(initialDate); + expect(currentDate).toEqual(initialDate); + }); + }); + + describe('dateWithDaysAdded()', () => { + it('When days are added, then returns correct current date and days added', () => { + const systemFutureDate = new Date(fixedSystemCurrentDate); + systemFutureDate.setDate(systemFutureDate.getDate() + 5); + + const futureDate = Time.dateWithDaysAdded(5); + + expect(futureDate.getUTCDate()).toBe(systemFutureDate.getUTCDate()); + }); + }); + + describe('isToday()', () => { + it('When provided date is today, then returns true', () => { + const today = new Date(); + + const isToday = Time.isToday(today); + + expect(isToday).toBe(true); + }); + + it('When provided date is another day, then returns false', () => { + const notToday = new Date(); + notToday.setDate(notToday.getDate() + 1); + + const isToday = Time.isToday(notToday); + + expect(isToday).toBe(false); + }); + }); + + describe('convertTimestampToDate()', () => { + it('When a date in timestamp is provided, then same date should be returned', () => { + const timestamp = 1642531200; + + const timestampDate = Time.convertTimestampToDate(timestamp); + + expect(timestampDate).toBeInstanceOf(Date); + expect(timestampDate).toEqual(new Date(timestamp * 1000)); + }); + }); +}); diff --git a/src/lib/time.ts b/src/lib/time.ts index a51a48171..ebd9fa5bf 100644 --- a/src/lib/time.ts +++ b/src/lib/time.ts @@ -34,15 +34,14 @@ export class Time { public static isToday = (date: Date) => { const todayDate = new Date(); - - if ( + return ( date.getDate() === todayDate.getDate() && date.getMonth() === todayDate.getMonth() && date.getFullYear() === todayDate.getFullYear() - ) { - return true; - } else { - return false; - } + ); + }; + + public static convertTimestampToDate = (timestamp: number) => { + return new Date(timestamp * 1000); }; } diff --git a/src/modules/security/mail-limit/mail-limit.domain.ts b/src/modules/security/mail-limit/mail-limit.domain.ts index 00d6cbf4b..8157b77d9 100644 --- a/src/modules/security/mail-limit/mail-limit.domain.ts +++ b/src/modules/security/mail-limit/mail-limit.domain.ts @@ -36,10 +36,10 @@ export class MailLimit implements MailLimitModelAttributes { ); } - increaseTodayAttemps() { + increaseTodayAttemps(customSentDate?: Date) { this.attemptsCount = Time.isToday(this.lastMailSent) ? this.attemptsCount + 1 : 1; - this.lastMailSent = new Date(); + this.lastMailSent = customSentDate ?? new Date(); } } diff --git a/src/modules/security/mail-limit/mail-limit.repository.ts b/src/modules/security/mail-limit/mail-limit.repository.ts index 4182822c4..a7e79901a 100644 --- a/src/modules/security/mail-limit/mail-limit.repository.ts +++ b/src/modules/security/mail-limit/mail-limit.repository.ts @@ -23,6 +23,16 @@ export class SequelizeMailLimitRepository { return [mailLimit ? this.toDomain(mailLimit) : null, wasCreated]; } + async findByUserIdAndMailType( + userId: MailLimitModelAttributes['userId'], + mailType: MailTypes, + ): Promise { + const mailLimit = await this.mailLimitModel.findOne({ + where: { userId, mailType }, + }); + return mailLimit ? this.toDomain(mailLimit) : null; + } + async updateByUserIdAndMailType( userId: MailLimitModelAttributes['userId'], mailType: MailTypes, diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index c31ce79c3..c9194bd43 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -483,7 +483,7 @@ export class UserController { payload: { uuid: string; action: string; email: string }; iat: number; }; - } catch (err) { + } catch { throw new ForbiddenException(); } diff --git a/src/modules/user/user.usecase.spec.ts b/src/modules/user/user.usecase.spec.ts index 932f50a2a..344bac1cf 100644 --- a/src/modules/user/user.usecase.spec.ts +++ b/src/modules/user/user.usecase.spec.ts @@ -3,7 +3,7 @@ import { createMock } from '@golevelup/ts-jest'; import { AttemptChangeEmailModel } from './attempt-change-email.model'; import { UserEmailAlreadyInUseException } from './exception/user-email-already-in-use.exception'; -import { UserUseCases } from './user.usecase'; +import { MailLimitReachedException, UserUseCases } from './user.usecase'; import { ShareUseCases } from '../share/share.usecase'; import { FolderUseCases } from '../folder/folder.usecase'; import { FileUseCases } from '../file/file.usecase'; @@ -262,12 +262,6 @@ describe('User use cases', () => { describe('Unblocking user account', () => { describe('Request Account unblock', () => { const fixedSystemCurrentDate = new Date('2020-02-19'); - const mailLimit = newMailLimit({ - userId: user.id, - mailType: MailTypes.UnblockAccount, - attemptsLimit: 5, - attemptsCount: 0, - }); beforeAll(async () => { jest.useFakeTimers(); @@ -287,13 +281,30 @@ describe('User use cases', () => { ).resolves.toBeUndefined(); }); - it('When user exists, then user lastPasswordChangedAt is updated', async () => { - const userUpdateSpy = jest.spyOn(userRepository, 'updateByUuid'); + it('When user reached mails limit, then fail', async () => { + const limit = newMailLimit({ + userId: user.id, + attemptsCount: 5, + attemptsLimit: 5, + }); + jest.spyOn(userRepository, 'findByEmail').mockResolvedValueOnce(user); + jest + .spyOn(mailLimitRepository, 'findOrCreate') + .mockResolvedValueOnce([limit, false]); + + await expect( + userUseCases.sendAccountUnblockEmail(user.email), + ).rejects.toBeInstanceOf(MailLimitReachedException); + }); + + it('When user exists and email is sent, then mailLimit is updated', async () => { + const limit = newMailLimit({ userId: user.id }); + jest.spyOn(mailLimitRepository, 'updateByUserIdAndMailType'); jest.spyOn(userRepository, 'findByEmail').mockResolvedValueOnce(user); jest.spyOn(configService, 'get').mockReturnValue('secret'); jest .spyOn(mailLimitRepository, 'findOrCreate') - .mockResolvedValueOnce([mailLimit, false]); + .mockResolvedValueOnce([limit, false]); await userUseCases.sendAccountUnblockEmail(user.email); @@ -309,9 +320,9 @@ describe('User use cases', () => { 'secret', '48h', ); - expect(userUpdateSpy).toHaveBeenCalledWith(user.uuid, { - lastPasswordChangedAt: fixedSystemCurrentDate, - }); + expect( + mailLimitRepository.updateByUserIdAndMailType, + ).toHaveBeenCalledWith(user.id, MailTypes.UnblockAccount, limit); }); }); @@ -320,44 +331,48 @@ describe('User use cases', () => { const userFindByUuidSpy = jest.spyOn(userRepository, 'findByUuid'); userFindByUuidSpy.mockReturnValueOnce(null); - await expect(userUseCases.unblockAccount(user.uuid)).rejects.toThrow( + await expect(userUseCases.unblockAccount(user.uuid, 0)).rejects.toThrow( BadRequestException, ); }); - it('When token iat is previous to lastPasswordChangedAt, then fail', async () => { - const userFindByUuidSpy = jest.spyOn(userRepository, 'findByUuid'); - const olderIat = getTokenDefaultIat(); - const recentDate = new Date(olderIat * 1000); - recentDate.setSeconds(recentDate.getSeconds() + 1); - const unblockUser = User.build({ - ...user, - lastPasswordChangedAt: recentDate, - }); + it('When token was issued before lastMailSent date, then fail', async () => { + const tokenIat = getTokenDefaultIat(); + const futureDate = new Date(tokenIat * 1000); + futureDate.setSeconds(futureDate.getSeconds() + 1); + const mailLimit = newMailLimit({ lastMailSent: futureDate }); - userFindByUuidSpy.mockResolvedValueOnce(unblockUser); + jest.spyOn(userRepository, 'findByUuid').mockResolvedValueOnce(user); + jest + .spyOn(mailLimitRepository, 'findByUserIdAndMailType') + .mockResolvedValueOnce(mailLimit); await expect( - userUseCases.unblockAccount(unblockUser.uuid, olderIat), + userUseCases.unblockAccount(user.uuid, tokenIat), ).rejects.toThrow(ForbiddenException); }); - it('When token iat is greater than lastPasswordChangedAt, then update user', async () => { - const userFindByUuidSpy = jest.spyOn(userRepository, 'findByUuid'); + it('When token was issued before user lastPasswordChanged date, then fail', async () => { const tokenIat = getTokenDefaultIat(); - const olderDate = new Date(tokenIat * 1000); - olderDate.setMilliseconds(olderDate.getMilliseconds() - 1); - const unblockUser = User.build({ + const futureDate = new Date(tokenIat * 1000); + futureDate.setSeconds(futureDate.getSeconds() + 1); + const mailLimit = newMailLimit({ + lastMailSent: new Date(tokenIat * 1000), + }); + const userWithPasswordChanged = new User({ ...user, - lastPasswordChangedAt: olderDate, + lastPasswordChangedAt: futureDate, }); - userFindByUuidSpy.mockResolvedValueOnce(unblockUser); - - await userUseCases.unblockAccount(unblockUser.uuid, tokenIat); + jest + .spyOn(userRepository, 'findByUuid') + .mockResolvedValueOnce(userWithPasswordChanged); + jest + .spyOn(mailLimitRepository, 'findByUserIdAndMailType') + .mockResolvedValueOnce(mailLimit); - expect(userRepository.updateByUuid).toHaveBeenCalledWith(user.uuid, { - errorLoginCount: 0, - }); + await expect( + userUseCases.unblockAccount(user.uuid, tokenIat), + ).rejects.toThrow(ForbiddenException); }); }); }); diff --git a/src/modules/user/user.usecase.ts b/src/modules/user/user.usecase.ts index b555e1b48..c3e14b9af 100644 --- a/src/modules/user/user.usecase.ts +++ b/src/modules/user/user.usecase.ts @@ -62,9 +62,10 @@ import { AttemptChangeEmailHasExpiredException } from './exception/attempt-chang import { AttemptChangeEmailNotFoundException } from './exception/attempt-change-email-not-found.exception'; import { UserEmailAlreadyInUseException } from './exception/user-email-already-in-use.exception'; import { UserNotFoundException } from './exception/user-not-found.exception'; -import { getTokenDefaultIat, isTokenIatGreaterThanDate } from '../../lib/jwt'; +import { getTokenDefaultIat } from '../../lib/jwt'; import { MailTypes } from '../security/mail-limit/mailTypes'; import { SequelizeMailLimitRepository } from '../security/mail-limit/mail-limit.repository'; +import { Time } from '../../lib/time'; class ReferralsNotAvailableError extends Error { constructor() { @@ -732,11 +733,6 @@ export class UserUseCases { } const defaultIat = getTokenDefaultIat(); - - await this.userRepository.updateByUuid(user.uuid, { - lastPasswordChangedAt: new Date(defaultIat * 1000), - }); - const unblockAccountToken = SignWithCustomDuration( { payload: { @@ -754,7 +750,9 @@ export class UserUseCases { const url = `${driveWebUrl}/blocked-account/${unblockAccountToken}`; await this.mailerService.sendAutoAccountUnblockEmail(user.email, url); - mailLimit.increaseTodayAttemps(); + const lastMailSentDate = Time.convertTimestampToDate(defaultIat); + mailLimit.increaseTodayAttemps(lastMailSentDate); + await this.mailLimitRepository.updateByUserIdAndMailType( user.id, MailTypes.UnblockAccount, @@ -764,7 +762,7 @@ export class UserUseCases { async unblockAccount( userUuid: User['uuid'], - tokenIat?: number, + tokenIat: number, ): Promise { const user = await this.userRepository.findByUuid(userUuid); @@ -772,18 +770,23 @@ export class UserUseCases { throw new BadRequestException(); } + const mailLimit = await this.mailLimitRepository.findByUserIdAndMailType( + user.id, + MailTypes.UnblockAccount, + ); + + const tokenIssuedAtDate = Time.convertTimestampToDate(tokenIat); + if ( - tokenIat && - !isTokenIatGreaterThanDate( - new Date(user?.lastPasswordChangedAt), - tokenIat, - ) + mailLimit.lastMailSent > tokenIssuedAtDate || + user.lastPasswordChangedAt > tokenIssuedAtDate ) { throw new ForbiddenException(); } await this.userRepository.updateByUuid(userUuid, { errorLoginCount: 0, + lastPasswordChangedAt: new Date(), }); } From 5e4aee4dd109ac747521c3dd89ba9f9960192e93 Mon Sep 17 00:00:00 2001 From: Sergio Gutierrez Villalba Date: Tue, 23 Jan 2024 15:45:48 +0100 Subject: [PATCH 41/53] feat(files): drop files cascade deletion on user removal --- ...-remove-files-deletion-on-user-deletion.js | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 migrations/20240123143540-remove-files-deletion-on-user-deletion.js diff --git a/migrations/20240123143540-remove-files-deletion-on-user-deletion.js b/migrations/20240123143540-remove-files-deletion-on-user-deletion.js new file mode 100644 index 000000000..323ae5e29 --- /dev/null +++ b/migrations/20240123143540-remove-files-deletion-on-user-deletion.js @@ -0,0 +1,22 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.removeConstraint('files', 'files_user_id_fkey'); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.addConstraint('files', { + fields: ['user_id'], + type: 'foreign key', + name: 'files_user_id_fkey', + references: { + table: 'users', + field: 'id', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }); + }, +}; From a95f16b30c6f488caaf77908180d9c3668f3acf8 Mon Sep 17 00:00:00 2001 From: Edison J Padilla <=> Date: Wed, 24 Jan 2024 12:14:40 -0400 Subject: [PATCH 42/53] [PB-845]: feat/Calculate-sharing-folder-size --- src/common/uuid.dto.spec.ts | 33 +++++++++++++++++ src/common/uuid.dto.ts | 6 +++ .../exception/sharing-not-found.exception.ts | 11 ++++++ .../sharing/exception/sharing.exception.ts | 7 ++++ .../sharing/sharing.controller.spec.ts | 37 +++++++++++++++++++ src/modules/sharing/sharing.controller.ts | 13 +++++++ src/modules/sharing/sharing.service.spec.ts | 35 ++++++++++++++++++ src/modules/sharing/sharing.service.ts | 17 +++++++++ 8 files changed, 159 insertions(+) create mode 100644 src/common/uuid.dto.spec.ts create mode 100644 src/common/uuid.dto.ts create mode 100644 src/modules/sharing/exception/sharing-not-found.exception.ts create mode 100644 src/modules/sharing/exception/sharing.exception.ts create mode 100644 src/modules/sharing/sharing.controller.spec.ts diff --git a/src/common/uuid.dto.spec.ts b/src/common/uuid.dto.spec.ts new file mode 100644 index 000000000..163de9971 --- /dev/null +++ b/src/common/uuid.dto.spec.ts @@ -0,0 +1,33 @@ +import { validate } from 'class-validator'; +import { UuidDto } from './uuid.dto'; +import { newUser } from '../../test/fixtures'; + +describe('UuidDto Validation', () => { + const user = newUser(); + + it('When a valid UUID is passed, then pass', async () => { + const dto = new UuidDto(); + dto.id = user.uuid; + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('When an invalid UUID is passed, then fail', async () => { + const dto = new UuidDto(); + dto.id = 'invalid_uuid_string'; + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].constraints).toHaveProperty('isUuid'); + }); + + it('When an empty string is passed, then fail', async () => { + const dto = new UuidDto(); + dto.id = 'invalid-uuid'; + const errors = await validate(dto); + + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].constraints).toHaveProperty('isUuid'); + }); +}); diff --git a/src/common/uuid.dto.ts b/src/common/uuid.dto.ts new file mode 100644 index 000000000..e6a0764f8 --- /dev/null +++ b/src/common/uuid.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class UuidDto { + @IsUUID() + id: string; +} diff --git a/src/modules/sharing/exception/sharing-not-found.exception.ts b/src/modules/sharing/exception/sharing-not-found.exception.ts new file mode 100644 index 000000000..10bbdf17e --- /dev/null +++ b/src/modules/sharing/exception/sharing-not-found.exception.ts @@ -0,0 +1,11 @@ +import { SharingException } from './sharing.exception'; + +export class SharingNotFoundException extends SharingException { + constructor( + message = 'Sharing not found', + statusCode = 404, + code = 'SHARING_NOT_FOUND', + ) { + super(message, statusCode, code); + } +} diff --git a/src/modules/sharing/exception/sharing.exception.ts b/src/modules/sharing/exception/sharing.exception.ts new file mode 100644 index 000000000..81674562b --- /dev/null +++ b/src/modules/sharing/exception/sharing.exception.ts @@ -0,0 +1,7 @@ +import { BaseHttpException } from '../../../common/base-http.exception'; + +export class SharingException extends BaseHttpException { + constructor(message: string, statusCode: number, code: string) { + super(`Sharing -> ${message}`, statusCode, code); + } +} diff --git a/src/modules/sharing/sharing.controller.spec.ts b/src/modules/sharing/sharing.controller.spec.ts new file mode 100644 index 000000000..58de6f618 --- /dev/null +++ b/src/modules/sharing/sharing.controller.spec.ts @@ -0,0 +1,37 @@ +import { SharingController } from './sharing.controller'; +import { SharingService } from './sharing.service'; +import { UuidDto } from '../../common/uuid.dto'; +import { createMock } from '@golevelup/ts-jest'; +import { Sharing } from './sharing.domain' +import { newSharing } from '../../../test/fixtures'; + +describe('SharingController', () => { + let controller: SharingController; + let sharingService: SharingService; + let sharing: Sharing; + + beforeEach(async () => { + sharingService = createMock(); + controller = new SharingController(sharingService); + sharing = newSharing() + }); + + describe('get public sharing folder size', () => { + it('When request the get sharing size method, then it works', async () => { + const expectedResult = 100; + + jest + .spyOn(sharingService, 'getPublicSharingFolderSize') + .mockImplementation(async () => expectedResult); + + const result = await controller.getPublicSharingFolderSize({ + id: sharing.id, + } as UuidDto); + + expect(result).toStrictEqual({ size: expectedResult }); + expect(sharingService.getPublicSharingFolderSize).toHaveBeenCalledWith( + sharing.id, + ); + }); + }); +}); diff --git a/src/modules/sharing/sharing.controller.ts b/src/modules/sharing/sharing.controller.ts index 02fb10e5d..222b43a87 100644 --- a/src/modules/sharing/sharing.controller.ts +++ b/src/modules/sharing/sharing.controller.ts @@ -17,6 +17,7 @@ import { NotFoundException, Headers, Patch, + UseFilters, } from '@nestjs/common'; import { Response } from 'express'; import { @@ -55,6 +56,8 @@ import { CreateSharingDto } from './dto/create-sharing.dto'; import { ChangeSharingType } from './dto/change-sharing-type.dto'; import { ThrottlerGuard } from '../../guards/throttler.guard'; import { SetSharingPasswordDto } from './dto/set-sharing-password.dto'; +import { UuidDto } from '../../common/uuid.dto'; +import { HttpExceptionFilter } from '../../lib/http/http-exception.filter'; @ApiTags('Sharing') @Controller('sharings') @@ -1026,4 +1029,14 @@ export class SharingController { return { message: 'User removed from shared folder' }; } + + @UseFilters(new HttpExceptionFilter()) + @UseGuards(ThrottlerGuard) + @Public() + @Get('public/:id/folder/size') + async getPublicSharingFolderSize(@Param() param: UuidDto) { + const size = await this.sharingService.getPublicSharingFolderSize(param.id); + + return { size }; + } } diff --git a/src/modules/sharing/sharing.service.spec.ts b/src/modules/sharing/sharing.service.spec.ts index 395073096..49b84684e 100644 --- a/src/modules/sharing/sharing.service.spec.ts +++ b/src/modules/sharing/sharing.service.spec.ts @@ -24,6 +24,7 @@ import { UserUseCases } from '../user/user.usecase'; import { SequelizeUserReferralsRepository } from '../user/user-referrals.repository'; import { SharingType } from './sharing.domain'; import { FileStatus } from '../file/file.domain'; +import { SharingNotFoundException } from './exception/sharing-not-found.exception'; describe('Sharing Use Cases', () => { let sharingService: SharingService; @@ -491,4 +492,38 @@ describe('Sharing Use Cases', () => { ); }); }); + + describe('get sharing folder size', () => { + const owner = newUser(); + const otherUser = publicUser(); + const folder = newFolder({ owner }); + + it('When user tries to get sharing folder size with a public folder, then it works', async () => { + const sharing = newSharing({ + owner, + item: folder, + sharedWith: otherUser, + sharingType: SharingType.Public, + }); + + const expectedSize = 100; + + sharingRepository.findOneSharing.mockResolvedValue(sharing); + folderUseCases.getFolderSizeByUuid.mockResolvedValue(expectedSize); + + const publicSharingSize = await sharingService.getPublicSharingFolderSize( + sharing.id, + ); + + expect(publicSharingSize).toStrictEqual(expectedSize); + }); + + it('When user tries to get sharing folder size and folder is not found, then it fails', async () => { + sharingRepository.findOneSharing.mockResolvedValue(null); + + await expect( + sharingService.getPublicSharingFolderSize(''), + ).rejects.toThrow(SharingNotFoundException); + }); + }); }); diff --git a/src/modules/sharing/sharing.service.ts b/src/modules/sharing/sharing.service.ts index 665cbae97..4e2b3b060 100644 --- a/src/modules/sharing/sharing.service.ts +++ b/src/modules/sharing/sharing.service.ts @@ -47,6 +47,7 @@ import { CreateSharingDto } from './dto/create-sharing.dto'; import { aes } from '@internxt/lib'; import { Environment } from '@internxt/inxt-js'; import { SequelizeUserReferralsRepository } from '../user/user-referrals.repository'; +import { SharingNotFoundException } from './exception/sharing-not-found.exception'; export class InvalidOwnerError extends Error { constructor() { @@ -2057,4 +2058,20 @@ export class SharingService { return sharedItem; } + + async getPublicSharingFolderSize( + id: SharingAttributes['id'], + ): Promise { + const sharing = await this.sharingRepository.findOneSharing({ + id, + type: SharingType.Public, + itemType: 'folder', + }); + + if (!sharing) { + throw new SharingNotFoundException(); + } + + return this.folderUsecases.getFolderSizeByUuid(sharing.itemId); + } } From 037efa0d10a5eebc13dc6bd88206fdf41b137d98 Mon Sep 17 00:00:00 2001 From: edisonjpadilla <145517183+edisonjpadilla@users.noreply.github.com> Date: Fri, 26 Jan 2024 11:17:36 -0400 Subject: [PATCH 43/53] fix test --- src/modules/sharing/sharing.controller.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/sharing/sharing.controller.spec.ts b/src/modules/sharing/sharing.controller.spec.ts index 58de6f618..f82423336 100644 --- a/src/modules/sharing/sharing.controller.spec.ts +++ b/src/modules/sharing/sharing.controller.spec.ts @@ -13,7 +13,7 @@ describe('SharingController', () => { beforeEach(async () => { sharingService = createMock(); controller = new SharingController(sharingService); - sharing = newSharing() + sharing = newSharing({}) }); describe('get public sharing folder size', () => { From 6dd0dc6b8d0ce972b05369a8f2bbcdbdca0a79e1 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+masterprog-cmd@users.noreply.github.com> Date: Mon, 29 Jan 2024 16:46:40 +0100 Subject: [PATCH 44/53] feat(users): check if the user exists or has sub --- src/modules/user/user.controller.ts | 41 +++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index c9194bd43..488fc11a3 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -161,6 +161,47 @@ export class UserController { } } + @UseGuards(ThrottlerGuard) + @Throttle({ + long: { + ttl: 3600, + limit: 5, + }, + }) + @Get('/user-subscription') + @HttpCode(201) + @ApiOperation({ + summary: + 'Check if the user exists or not and if the user does not have a subscription', + }) + @ApiOkResponse({ + description: 'Check if the user exists or has subscription', + }) + @ApiBadRequestResponse({ description: 'Missing required fields' }) + @Public() + async UserExistsAndHasSubscription( + @Body() email: string, + @Res({ passthrough: true }) res: Response, + ) { + const user = await this.userUseCases.getUserByUsername(email); + if (!user) { + return res.status(200).json({ message: 'User allowed' }); + } + + const userHasSubscriptions = + await this.userUseCases.hasUserBeenSubscribedAnyTime( + email, + email, + user.password, + ); + + if (userHasSubscriptions) { + return res.status(404).json({ message: 'User not allowed' }); + } else { + return res.status(200).json({ message: 'User allowed' }); + } + } + @UseGuards(ThrottlerGuard) @Throttle({ long: { From 1a432ecd4558032564f06066d6f4519509a97e48 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+masterprog-cmd@users.noreply.github.com> Date: Mon, 29 Jan 2024 17:14:42 +0100 Subject: [PATCH 45/53] fix: change endpoint name --- src/modules/user/user.controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index 488fc11a3..40e9867ed 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -168,7 +168,7 @@ export class UserController { limit: 5, }, }) - @Get('/user-subscription') + @Get('/user-exists') @HttpCode(201) @ApiOperation({ summary: @@ -179,7 +179,7 @@ export class UserController { }) @ApiBadRequestResponse({ description: 'Missing required fields' }) @Public() - async UserExistsAndHasSubscription( + async UserExistsOrHasSubscription( @Body() email: string, @Res({ passthrough: true }) res: Response, ) { From 7689899cfb57af54cc8e63ba71a53d325e8464c7 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+masterprog-cmd@users.noreply.github.com> Date: Mon, 29 Jan 2024 17:39:33 +0100 Subject: [PATCH 46/53] fix: make the user-exists endpoint private --- src/modules/user/user.controller.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index 40e9867ed..b29b6543d 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -28,6 +28,7 @@ import { ApiOkResponse, ApiOperation, ApiParam, + ApiQuery, ApiTags, } from '@nestjs/swagger'; import { Public } from '../auth/decorators/public.decorator'; @@ -178,11 +179,15 @@ export class UserController { description: 'Check if the user exists or has subscription', }) @ApiBadRequestResponse({ description: 'Missing required fields' }) - @Public() + @ApiQuery({ + name: 'email', + type: String, + }) async UserExistsOrHasSubscription( - @Body() email: string, + @Query('email') email: string, @Res({ passthrough: true }) res: Response, ) { + console.log('email', email); const user = await this.userUseCases.getUserByUsername(email); if (!user) { return res.status(200).json({ message: 'User allowed' }); From 8e776cdef3a427d2005d75a8863259562a21c542 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+masterprog-cmd@users.noreply.github.com> Date: Mon, 29 Jan 2024 19:14:10 +0100 Subject: [PATCH 47/53] refactor (user/:email endpoint) --- src/modules/user/user.controller.ts | 58 ++++++++++++++++++----------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index b29b6543d..39cce8049 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -28,7 +28,6 @@ import { ApiOkResponse, ApiOperation, ApiParam, - ApiQuery, ApiTags, } from '@nestjs/swagger'; import { Public } from '../auth/decorators/public.decorator'; @@ -169,41 +168,56 @@ export class UserController { limit: 5, }, }) - @Get('/user-exists') + @Get('/user/:email') @HttpCode(201) @ApiOperation({ summary: - 'Check if the user exists or not and if the user does not have a subscription', + 'Get the user data by email and check if the user has subscription', }) @ApiOkResponse({ - description: 'Check if the user exists or has subscription', + description: 'Get the user data by email', }) @ApiBadRequestResponse({ description: 'Missing required fields' }) - @ApiQuery({ + @ApiParam({ name: 'email', type: String, }) - async UserExistsOrHasSubscription( - @Query('email') email: string, + async getUserByEmail( + @Param('email') email: User['email'], @Res({ passthrough: true }) res: Response, ) { - console.log('email', email); - const user = await this.userUseCases.getUserByUsername(email); - if (!user) { - return res.status(200).json({ message: 'User allowed' }); - } + try { + const user = await this.userUseCases.getUserByUsername(email); + if (!user) { + throw new NotFoundException('PRE_CREATED_USER_NOT_FOUND'); + } - const userHasSubscriptions = - await this.userUseCases.hasUserBeenSubscribedAnyTime( - email, - email, - user.password, - ); + const userHasSubscriptions = + await this.userUseCases.hasUserBeenSubscribedAnyTime( + email, + email, + user.password, + ); + + return res + .status(200) + .json({ user: user, hasSubscriptions: userHasSubscriptions }); + } catch (err) { + let errorMessage = err.message; - if (userHasSubscriptions) { - return res.status(404).json({ message: 'User not allowed' }); - } else { - return res.status(200).json({ message: 'User allowed' }); + if (err instanceof NotFoundException) { + res.status(HttpStatus.NOT_FOUND); + } else { + new Logger().error( + `[AUTH/GET-USER-BY-EMAIL] ERROR: ${(err as Error).message}, STACK: ${ + (err as Error).stack + }`, + ); + res.status(HttpStatus.INTERNAL_SERVER_ERROR); + errorMessage = 'Internal Server Error'; + } + + return { error: errorMessage }; } } From 98c2a136c20b4cea307b85ff3e3e24181f5ab4d6 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+masterprog-cmd@users.noreply.github.com> Date: Tue, 30 Jan 2024 12:40:08 +0100 Subject: [PATCH 48/53] fix: remove wrong error message --- src/modules/user/user.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index 39cce8049..0ae86c293 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -189,7 +189,7 @@ export class UserController { try { const user = await this.userUseCases.getUserByUsername(email); if (!user) { - throw new NotFoundException('PRE_CREATED_USER_NOT_FOUND'); + throw new NotFoundException(); } const userHasSubscriptions = From 015213ae82fc95d72776c3c25d870c3e70f03bdc Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+masterprog-cmd@users.noreply.github.com> Date: Wed, 31 Jan 2024 09:49:31 +0100 Subject: [PATCH 49/53] fix: send the correct data to get the user subscriptions --- src/modules/user/user.controller.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index 0ae86c293..5ffd2afde 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -194,9 +194,9 @@ export class UserController { const userHasSubscriptions = await this.userUseCases.hasUserBeenSubscribedAnyTime( - email, - email, - user.password, + user.email, + user.bridgeUser, + user.userId, ); return res From f5b9796a57d09d695ad50094e55ce68d97753862 Mon Sep 17 00:00:00 2001 From: Edison J Padilla <=> Date: Wed, 7 Feb 2024 08:34:35 -0400 Subject: [PATCH 50/53] [PB-1617] bug: add logs --- src/modules/trash/trash.controller.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/modules/trash/trash.controller.ts b/src/modules/trash/trash.controller.ts index 5b23e0de5..b01c3d1aa 100644 --- a/src/modules/trash/trash.controller.ts +++ b/src/modules/trash/trash.controller.ts @@ -12,6 +12,7 @@ import { Res, Logger, HttpStatus, + UseFilters, } from '@nestjs/common'; import { ApiBadRequestResponse, @@ -38,6 +39,7 @@ import { File, FileStatus } from '../file/file.domain'; import logger from '../../externals/logger'; import { v4 } from 'uuid'; import { Response } from 'express'; +import { HttpExceptionFilter } from '../../lib/http/http-exception.filter'; @ApiTags('Trash') @Controller('storage/trash') @@ -182,6 +184,7 @@ export class TrashController { } } + @UseFilters(new HttpExceptionFilter()) @Delete('/all') @HttpCode(200) @ApiOperation({ @@ -191,6 +194,7 @@ export class TrashController { await this.trashUseCases.emptyTrash(user); } + @UseFilters(new HttpExceptionFilter()) @Delete('/all/request') requestEmptyTrash(user: User) { this.trashUseCases.emptyTrash(user); From 4886bad9310fd859efbb602d573d17cd958c783a Mon Sep 17 00:00:00 2001 From: Edison J Padilla <=> Date: Wed, 7 Feb 2024 08:34:35 -0400 Subject: [PATCH 51/53] [PB-1617] bug: add logs --- src/modules/trash/trash.controller.ts | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/modules/trash/trash.controller.ts b/src/modules/trash/trash.controller.ts index b01c3d1aa..484e9feab 100644 --- a/src/modules/trash/trash.controller.ts +++ b/src/modules/trash/trash.controller.ts @@ -13,6 +13,7 @@ import { Logger, HttpStatus, UseFilters, + InternalServerErrorException, } from '@nestjs/common'; import { ApiBadRequestResponse, @@ -191,13 +192,33 @@ export class TrashController { summary: "Deletes all items from user's trash", }) async clearTrash(@UserDecorator() user: User) { - await this.trashUseCases.emptyTrash(user); + try { + await this.trashUseCases.emptyTrash(user); + } catch (error) { + new Logger().error( + `[TRASH/EMPTY_TRASH] ERROR: ${ + (error as Error).message + } USER: ${JSON.stringify(user)} STACK: ${(error as Error).stack}`, + ); + + throw new InternalServerErrorException(); + } } @UseFilters(new HttpExceptionFilter()) @Delete('/all/request') requestEmptyTrash(user: User) { - this.trashUseCases.emptyTrash(user); + try { + this.trashUseCases.emptyTrash(user); + } catch (error) { + new Logger().error( + `[TRASH/REQUEST_EMPTY_TRASH] ERROR: ${ + (error as Error).message + } USER: ${JSON.stringify(user)} STACK: ${(error as Error).stack}`, + ); + + throw new InternalServerErrorException(); + } } @Delete('/') From bcded89bc407041efe460d96b3b4cc9165bcd18a Mon Sep 17 00:00:00 2001 From: Sergio Gutierrez Villalba Date: Tue, 13 Feb 2024 14:26:36 +0100 Subject: [PATCH 52/53] feat(folders): index parent_uuid field [skip ci] --- ...213132333-create-index-parent-uuid-folders.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 migrations/20240213132333-create-index-parent-uuid-folders.js diff --git a/migrations/20240213132333-create-index-parent-uuid-folders.js b/migrations/20240213132333-create-index-parent-uuid-folders.js new file mode 100644 index 000000000..95b0743ac --- /dev/null +++ b/migrations/20240213132333-create-index-parent-uuid-folders.js @@ -0,0 +1,16 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.query( + `CREATE INDEX CONCURRENTLY folders_parent_uuid_index ON folders (parent_uuid)`, + ); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.sequelize.query( + `DROP INDEX CONCURRENTLY folders_parent_uuid_index`, + ); + }, +}; From 5dbecded6558a57930420b5ebc8019570c9ea2a9 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Thu, 15 Feb 2024 01:15:50 -0400 Subject: [PATCH 53/53] fix: files can not be trashed if status = deleted --- src/modules/file/file.repository.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/modules/file/file.repository.ts b/src/modules/file/file.repository.ts index a51607420..6ed859445 100644 --- a/src/modules/file/file.repository.ts +++ b/src/modules/file/file.repository.ts @@ -380,6 +380,9 @@ export class SequelizeFileRepository implements FileRepository { fileId: { [Op.in]: fileIds, }, + status: { + [Op.not]: FileStatus.DELETED, + }, }, }, );