From 3cc43cd7c6960baffb0c056aebd1e7a6d37acf74 Mon Sep 17 00:00:00 2001 From: tamarafinogina Date: Mon, 12 Jan 2026 10:57:13 +0100 Subject: [PATCH 01/71] remove encryptedName from createFile --- src/lib/newrelic.interceptor.ts | 22 +++++++++++++++++----- src/modules/file/file.repository.spec.ts | 2 +- src/modules/file/file.repository.ts | 6 ++++-- src/modules/file/file.usecase.ts | 6 ------ 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/lib/newrelic.interceptor.ts b/src/lib/newrelic.interceptor.ts index 3f668fb6e..cfa5a5779 100644 --- a/src/lib/newrelic.interceptor.ts +++ b/src/lib/newrelic.interceptor.ts @@ -1,5 +1,11 @@ -import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; -const newrelic = require('newrelic') +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const newrelic = require('newrelic'); /** * Only for the headers, the instrumentation is not done directly here @@ -15,18 +21,24 @@ export class NewRelicInterceptor implements NestInterceptor { if (rawClient) { newrelic.addCustomAttribute( 'internxtClient', - String(Array.isArray(rawClient) ? rawClient[0] : rawClient).slice(0, 50), + String(Array.isArray(rawClient) ? rawClient[0] : rawClient).slice( + 0, + 50, + ), ); } if (rawVersion) { newrelic.addCustomAttribute( 'internxtVersion', - String(Array.isArray(rawVersion) ? rawVersion[0] : rawVersion).slice(0, 15), + String(Array.isArray(rawVersion) ? rawVersion[0] : rawVersion).slice( + 0, + 15, + ), ); } - console.log(rawClient, rawVersion) + console.log(rawClient, rawVersion); return next.handle(); } diff --git a/src/modules/file/file.repository.spec.ts b/src/modules/file/file.repository.spec.ts index 6a05470cf..a4ef2a71b 100644 --- a/src/modules/file/file.repository.spec.ts +++ b/src/modules/file/file.repository.spec.ts @@ -604,7 +604,7 @@ describe('FileRepository', () => { }); it('When creation fails then it should return null', async () => { - const fileData = { name: v4() } as any; + const fileData = { plainName: v4() } as any; jest.spyOn(fileModel, 'create').mockResolvedValue(null); const result = await repository.create(fileData); diff --git a/src/modules/file/file.repository.ts b/src/modules/file/file.repository.ts index 3c36ee490..eacad28ff 100644 --- a/src/modules/file/file.repository.ts +++ b/src/modules/file/file.repository.ts @@ -25,7 +25,7 @@ import { WorkspaceItemUserModel } from '../workspaces/models/workspace-items-use import { WorkspaceAttributes } from '../workspaces/attributes/workspace.attributes'; export interface FileRepository { - create(file: Omit): Promise; + create(file: Omit): Promise; deleteByFileId(fileId: any): Promise; deleteFilesByUser(user: User, files: File[]): Promise; destroyFile(where: Partial): Promise; @@ -160,7 +160,9 @@ export class SequelizeFileRepository implements FileRepository { }); } - async create(file: Omit): Promise { + async create( + file: Omit, + ): Promise { const raw = await this.fileModel.create(file); return raw ? this.toDomain(raw) : null; diff --git a/src/modules/file/file.usecase.ts b/src/modules/file/file.usecase.ts index 06d0e2863..3e462ae49 100644 --- a/src/modules/file/file.usecase.ts +++ b/src/modules/file/file.usecase.ts @@ -384,11 +384,6 @@ export class FileUseCases { throw new ForbiddenException('Folder is not yours'); } - const cryptoFileName = this.cryptoService.encryptName( - newFileDto.plainName, - folder.id, - ); - const exists = await this.fileRepository.findByPlainNameAndFolderId( user.id, newFileDto.plainName, @@ -410,7 +405,6 @@ export class FileUseCases { const newFile = await this.fileRepository.create({ uuid: v4(), - name: cryptoFileName, plainName: newFileDto.plainName, type: newFileDto.type, size: newFileDto.size, From 54f6b6c944c0a8931b3162b08a884e802b9ec44e Mon Sep 17 00:00:00 2001 From: tamarafinogina Date: Mon, 12 Jan 2026 12:09:06 +0100 Subject: [PATCH 02/71] revert lint changes to src/lib/newrelic.interceptor.ts --- src/lib/newrelic.interceptor.ts | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/src/lib/newrelic.interceptor.ts b/src/lib/newrelic.interceptor.ts index cfa5a5779..3f668fb6e 100644 --- a/src/lib/newrelic.interceptor.ts +++ b/src/lib/newrelic.interceptor.ts @@ -1,11 +1,5 @@ -import { - CallHandler, - ExecutionContext, - Injectable, - NestInterceptor, -} from '@nestjs/common'; -// eslint-disable-next-line @typescript-eslint/no-var-requires -const newrelic = require('newrelic'); +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +const newrelic = require('newrelic') /** * Only for the headers, the instrumentation is not done directly here @@ -21,24 +15,18 @@ export class NewRelicInterceptor implements NestInterceptor { if (rawClient) { newrelic.addCustomAttribute( 'internxtClient', - String(Array.isArray(rawClient) ? rawClient[0] : rawClient).slice( - 0, - 50, - ), + String(Array.isArray(rawClient) ? rawClient[0] : rawClient).slice(0, 50), ); } if (rawVersion) { newrelic.addCustomAttribute( 'internxtVersion', - String(Array.isArray(rawVersion) ? rawVersion[0] : rawVersion).slice( - 0, - 15, - ), + String(Array.isArray(rawVersion) ? rawVersion[0] : rawVersion).slice(0, 15), ); } - console.log(rawClient, rawVersion); + console.log(rawClient, rawVersion) return next.handle(); } From 9b5b347717d660b7dac0a4ba0075d93e700eaa6b Mon Sep 17 00:00:00 2001 From: tamarafinogina Date: Mon, 12 Jan 2026 13:45:10 +0100 Subject: [PATCH 03/71] make name an opeional attribute --- src/modules/file/file.domain.ts | 2 +- src/modules/file/file.repository.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/file/file.domain.ts b/src/modules/file/file.domain.ts index 5a4246e3a..d83617c0d 100644 --- a/src/modules/file/file.domain.ts +++ b/src/modules/file/file.domain.ts @@ -20,7 +20,7 @@ export interface FileAttributes { id: number; uuid: string; fileId: string; - name: string; + name?: string; type: string; size: bigint; bucket: string; diff --git a/src/modules/file/file.repository.ts b/src/modules/file/file.repository.ts index eacad28ff..cc2e8da38 100644 --- a/src/modules/file/file.repository.ts +++ b/src/modules/file/file.repository.ts @@ -25,7 +25,7 @@ import { WorkspaceItemUserModel } from '../workspaces/models/workspace-items-use import { WorkspaceAttributes } from '../workspaces/attributes/workspace.attributes'; export interface FileRepository { - create(file: Omit): Promise; + create(file: Omit): Promise; deleteByFileId(fileId: any): Promise; deleteFilesByUser(user: User, files: File[]): Promise; destroyFile(where: Partial): Promise; From 7b89968a885cf865548ad16c68fbf3235e2ebb86 Mon Sep 17 00:00:00 2001 From: tamarafinogina Date: Mon, 12 Jan 2026 14:16:09 +0100 Subject: [PATCH 04/71] revert create file --- src/modules/file/file.repository.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/modules/file/file.repository.ts b/src/modules/file/file.repository.ts index cc2e8da38..3c36ee490 100644 --- a/src/modules/file/file.repository.ts +++ b/src/modules/file/file.repository.ts @@ -160,9 +160,7 @@ export class SequelizeFileRepository implements FileRepository { }); } - async create( - file: Omit, - ): Promise { + async create(file: Omit): Promise { const raw = await this.fileModel.create(file); return raw ? this.toDomain(raw) : null; From f61b33f1089aa045017bafb323ba05705f0cfcde Mon Sep 17 00:00:00 2001 From: tamarafinogina Date: Thu, 8 Jan 2026 18:32:10 +0100 Subject: [PATCH 05/71] remove encrypted name from findByNameAndParentUuid --- src/modules/folder/folder.repository.ts | 7 +------ src/modules/folder/folder.usecase.spec.ts | 12 ------------ src/modules/folder/folder.usecase.ts | 14 -------------- 3 files changed, 1 insertion(+), 32 deletions(-) diff --git a/src/modules/folder/folder.repository.ts b/src/modules/folder/folder.repository.ts index 6faf158c3..9152b8b01 100644 --- a/src/modules/folder/folder.repository.ts +++ b/src/modules/folder/folder.repository.ts @@ -74,7 +74,6 @@ export interface FolderRepository { }, ): Promise; findByNameAndParentUuid( - name: FolderAttributes['name'], plainName: FolderAttributes['plainName'], parentUuid: FolderAttributes['parentUuid'], deleted: FolderAttributes['deleted'], @@ -395,17 +394,13 @@ export class SequelizeFolderRepository implements FolderRepository { } async findByNameAndParentUuid( - name: FolderAttributes['name'], plainName: FolderAttributes['plainName'], parentUuid: FolderAttributes['parentUuid'], deleted: FolderAttributes['deleted'], ): Promise { const folder = await this.folderModel.findOne({ where: { - [Op.or]: [ - { name: { [Op.eq]: name } }, - { plainName: { [Op.eq]: plainName } }, - ], + plainName: { [Op.eq]: plainName }, parentUuid: { [Op.eq]: parentUuid }, deleted: { [Op.eq]: deleted }, }, diff --git a/src/modules/folder/folder.usecase.spec.ts b/src/modules/folder/folder.usecase.spec.ts index d71dbc979..b8669f92e 100644 --- a/src/modules/folder/folder.usecase.spec.ts +++ b/src/modules/folder/folder.usecase.spec.ts @@ -530,7 +530,6 @@ describe('FolderUseCases', () => { const expectedFolder = newFolder({ attributes: { ...folder, - name: 'newencrypted-' + folder.name, parentUuid: destinationFolder.uuid, parentId: destinationFolder.parentId, }, @@ -551,10 +550,6 @@ describe('FolderUseCases', () => { .spyOn(cryptoService, 'decryptName') .mockReturnValueOnce(folder.plainName); - jest - .spyOn(cryptoService, 'encryptName') - .mockReturnValueOnce(expectedFolder.name); - jest .spyOn(folderRepository, 'findByNameAndParentUuid') .mockResolvedValueOnce(null); @@ -574,7 +569,6 @@ describe('FolderUseCases', () => { { parentId: destinationFolder.id, parentUuid: destinationFolder.uuid, - name: expectedFolder.name, plainName: expectedFolder.plainName, deleted: false, deletedAt: null, @@ -759,7 +753,6 @@ describe('FolderUseCases', () => { const expectedFolder = newFolder({ attributes: { ...folder, - name: 'newencrypted-' + newName, plainName: newName, parentUuid: destinationFolder.uuid, parentId: destinationFolder.id, @@ -777,10 +770,6 @@ describe('FolderUseCases', () => { .spyOn(service, 'getFolderByUuid') .mockResolvedValueOnce(destinationFolder); - jest - .spyOn(cryptoService, 'encryptName') - .mockReturnValueOnce(expectedFolder.name); - jest .spyOn(folderRepository, 'findByNameAndParentUuid') .mockResolvedValueOnce(null); @@ -801,7 +790,6 @@ describe('FolderUseCases', () => { { parentId: destinationFolder.id, parentUuid: destinationFolder.uuid, - name: expectedFolder.name, plainName: newName, deleted: false, deletedAt: null, diff --git a/src/modules/folder/folder.usecase.ts b/src/modules/folder/folder.usecase.ts index faa256c90..6ad413659 100644 --- a/src/modules/folder/folder.usecase.ts +++ b/src/modules/folder/folder.usecase.ts @@ -885,13 +885,7 @@ export class FolderUseCases { const plainName = newName ?? this.cryptoService.decryptName(folder.name, folder.parentId); - const nameEncryptedWithDestination = this.cryptoService.encryptName( - plainName, - destinationFolder.id, - ); - const exists = await this.folderRepository.findByNameAndParentUuid( - nameEncryptedWithDestination, plainName, destinationFolder.uuid, false, @@ -911,7 +905,6 @@ export class FolderUseCases { const updateData: Partial = { parentId: destinationFolder.id, parentUuid: destinationFolder.uuid, - name: nameEncryptedWithDestination, plainName, deleted: false, deletedAt: null, @@ -939,13 +932,7 @@ export class FolderUseCases { throw new BadRequestException('Invalid folder name'); } - const newEncryptedName = this.cryptoService.encryptName( - newName, - folder.parentId, - ); - const exists = await this.folderRepository.findByNameAndParentUuid( - newEncryptedName, newName, folder.parentUuid, false, @@ -958,7 +945,6 @@ export class FolderUseCases { } return await this.folderRepository.updateByFolderId(folder.id, { - name: newEncryptedName, plainName: newName, }); } From 0653adc0ecb05e9a2db68833dfc908bc03a52afa Mon Sep 17 00:00:00 2001 From: tamarafinogina Date: Fri, 9 Jan 2026 13:50:18 +0100 Subject: [PATCH 06/71] add comments --- src/modules/folder/folder.usecase.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/modules/folder/folder.usecase.ts b/src/modules/folder/folder.usecase.ts index 6ad413659..4848aca9e 100644 --- a/src/modules/folder/folder.usecase.ts +++ b/src/modules/folder/folder.usecase.ts @@ -834,6 +834,9 @@ export class FolderUseCases { const { destinationFolder: destinationFolderUuid } = moveFolderDto; const newName = moveFolderDto.name; + console.log('PR: Moving folder to:', destinationFolderUuid); + console.log('PR: Moving folder new name:', newName); + if (newName === '' || invalidName.test(newName)) { throw new BadRequestException('Invalid folder name'); } @@ -932,6 +935,7 @@ export class FolderUseCases { throw new BadRequestException('Invalid folder name'); } + console.log('PR: Renaming folder to:', newName); const exists = await this.folderRepository.findByNameAndParentUuid( newName, folder.parentUuid, From 1cb6dcb56e3508c2b580414f707971d2b836b805 Mon Sep 17 00:00:00 2001 From: tamarafinogina Date: Fri, 9 Jan 2026 17:34:20 +0100 Subject: [PATCH 07/71] use folder.plainName if available --- src/modules/folder/folder.usecase.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/modules/folder/folder.usecase.ts b/src/modules/folder/folder.usecase.ts index 4848aca9e..96a5f8cc9 100644 --- a/src/modules/folder/folder.usecase.ts +++ b/src/modules/folder/folder.usecase.ts @@ -834,9 +834,6 @@ export class FolderUseCases { const { destinationFolder: destinationFolderUuid } = moveFolderDto; const newName = moveFolderDto.name; - console.log('PR: Moving folder to:', destinationFolderUuid); - console.log('PR: Moving folder new name:', newName); - if (newName === '' || invalidName.test(newName)) { throw new BadRequestException('Invalid folder name'); } @@ -886,7 +883,9 @@ export class FolderUseCases { } const plainName = - newName ?? this.cryptoService.decryptName(folder.name, folder.parentId); + newName ?? + folder.plainName ?? + this.cryptoService.decryptName(folder.name, folder.parentId); const exists = await this.folderRepository.findByNameAndParentUuid( plainName, @@ -935,7 +934,6 @@ export class FolderUseCases { throw new BadRequestException('Invalid folder name'); } - console.log('PR: Renaming folder to:', newName); const exists = await this.folderRepository.findByNameAndParentUuid( newName, folder.parentUuid, From 26eebcac915d9f1d6f26230433980c88af295941 Mon Sep 17 00:00:00 2001 From: tamarafinogina Date: Fri, 9 Jan 2026 17:38:45 +0100 Subject: [PATCH 08/71] decrypt only if no plainName --- src/modules/folder/folder.usecase.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/modules/folder/folder.usecase.ts b/src/modules/folder/folder.usecase.ts index 96a5f8cc9..71d778559 100644 --- a/src/modules/folder/folder.usecase.ts +++ b/src/modules/folder/folder.usecase.ts @@ -69,10 +69,11 @@ export class FolderUseCases { throw new NotFoundException('Folder not found'); } - folder.plainName = this.cryptoService.decryptName( - folder.name, - folder.parentId, - ); + if (!folder.plainName) + folder.plainName = this.cryptoService.decryptName( + folder.name, + folder.parentId, + ); return folder; } From 186bef5f67cc90cfe63bf30046d3f0cd3f56d3c8 Mon Sep 17 00:00:00 2001 From: tamarafinogina Date: Fri, 9 Jan 2026 18:16:22 +0100 Subject: [PATCH 09/71] add tests --- src/modules/folder/folder.usecase.spec.ts | 85 ++++++++++++++++++++++- src/modules/folder/folder.usecase.ts | 3 +- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/src/modules/folder/folder.usecase.spec.ts b/src/modules/folder/folder.usecase.spec.ts index b8669f92e..f7c906201 100644 --- a/src/modules/folder/folder.usecase.spec.ts +++ b/src/modules/folder/folder.usecase.spec.ts @@ -526,7 +526,7 @@ describe('FolderUseCases', () => { attributes: { userId: userMocked.id }, }); - it('When folder is moved, then the folder is returned with its updated properties', async () => { + it('When folder without plainName is moved, then the folder is returned with its updated properties', async () => { const expectedFolder = newFolder({ attributes: { ...folder, @@ -576,6 +576,65 @@ describe('FolderUseCases', () => { ); }); + it('When folder with plainName is moved, then the folder is returned with its updated properties', async () => { + const plainName = 'Folder Plain Name'; + const folderWithPlainName = newFolder({ + attributes: { userId: userMocked.id, plainName }, + }); + const expectedFolder = newFolder({ + attributes: { + ...folderWithPlainName, + parentUuid: destinationFolder.uuid, + parentId: destinationFolder.parentId, + plainName, + }, + }); + const mockParentFolder = newFolder({ + attributes: { userId: userMocked.id, removed: false }, + }); + + jest + .spyOn(folderRepository, 'findOne') + .mockResolvedValueOnce(folderWithPlainName); + jest + .spyOn(folderRepository, 'findOne') + .mockResolvedValueOnce(mockParentFolder); + jest + .spyOn(service, 'getFolderByUuid') + .mockResolvedValueOnce(destinationFolder); + jest.spyOn(cryptoService, 'decryptName'); + + jest + .spyOn(folderRepository, 'findByNameAndParentUuid') + .mockResolvedValueOnce(null); + + jest + .spyOn(folderRepository, 'updateByFolderId') + .mockResolvedValueOnce(expectedFolder); + + const result = await service.moveFolder( + userMocked, + folderWithPlainName.uuid, + { + destinationFolder: destinationFolder.uuid, + }, + ); + + expect(result).toEqual(expectedFolder); + expect(cryptoService.decryptName).not.toHaveBeenCalled(); + expect(folderRepository.updateByFolderId).toHaveBeenCalledTimes(1); + expect(folderRepository.updateByFolderId).toHaveBeenCalledWith( + folderWithPlainName.id, + { + parentId: destinationFolder.id, + parentUuid: destinationFolder.uuid, + plainName: expectedFolder.plainName, + deleted: false, + deletedAt: null, + }, + ); + }); + it('When folder is moved but it is removed, then an error is thrown', async () => { const mockFolder = newFolder({ attributes: { userId: userMocked.id, removed: true }, @@ -1665,9 +1724,11 @@ describe('FolderUseCases', () => { describe('getByUuid', () => { const folderUuid = v4(); - it('When folder exists, then it should decrypt and return the folder', async () => { - const folder = newFolder({ attributes: { uuid: folderUuid } }); + it('When folder exists and no plainName, then it should decrypt and return the folder', async () => { const decryptedName = 'Decrypted Name'; + const folder = newFolder({ + attributes: { uuid: folderUuid, plainName: undefined }, + }); jest.spyOn(folderRepository, 'findByUuid').mockResolvedValueOnce(folder); jest @@ -1687,6 +1748,24 @@ describe('FolderUseCases', () => { expect(result.plainName).toBe(decryptedName); }); + it('When folder exists and there is a plainName, then it should not decrypt and return the folder', async () => { + const plainName = 'Plain Name'; + const folder = newFolder({ + attributes: { uuid: folderUuid, plainName }, + }); + + jest.spyOn(cryptoService, 'decryptName'); + jest.spyOn(folderRepository, 'findByUuid').mockResolvedValueOnce(folder); + const result = await service.getByUuid(folderUuid); + + expect(folderRepository.findByUuid).toHaveBeenCalledWith( + folderUuid, + false, + ); + expect(cryptoService.decryptName).not.toHaveBeenCalled(); + expect(result.plainName).toBe(plainName); + }); + it('When folder does not exist, then it should throw NotFoundException', async () => { jest.spyOn(folderRepository, 'findByUuid').mockResolvedValueOnce(null); diff --git a/src/modules/folder/folder.usecase.ts b/src/modules/folder/folder.usecase.ts index 71d778559..937647c89 100644 --- a/src/modules/folder/folder.usecase.ts +++ b/src/modules/folder/folder.usecase.ts @@ -69,11 +69,12 @@ export class FolderUseCases { throw new NotFoundException('Folder not found'); } - if (!folder.plainName) + if (!folder.plainName) { folder.plainName = this.cryptoService.decryptName( folder.name, folder.parentId, ); + } return folder; } From fd6f3468250df4c779a7054736ee3f30dbf64a76 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Thu, 15 Jan 2026 11:41:16 -0600 Subject: [PATCH 10/71] fix: refactor name decryption logic to use plainName if available in backup, file, and folder use cases --- src/modules/backups/backup.usecase.ts | 4 +++- src/modules/file/file.usecase.ts | 7 +++---- src/modules/folder/folder.usecase.ts | 16 ++++++---------- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/modules/backups/backup.usecase.ts b/src/modules/backups/backup.usecase.ts index c1f6d6fc8..a2287acb8 100644 --- a/src/modules/backups/backup.usecase.ts +++ b/src/modules/backups/backup.usecase.ts @@ -144,7 +144,9 @@ export class BackupUseCase { return Promise.all( folders.map(async (folder) => ({ ...(await this.addFolderAsDeviceProperties(user, folder)), - plainName: this.cryptoService.decryptName(folder.name, folder.bucket), + plainName: + folder.plainName ?? + this.cryptoService.decryptName(folder.name, folder.bucket), })), ); } diff --git a/src/modules/file/file.usecase.ts b/src/modules/file/file.usecase.ts index 3e462ae49..4458cca15 100644 --- a/src/modules/file/file.usecase.ts +++ b/src/modules/file/file.usecase.ts @@ -1060,10 +1060,9 @@ export class FileUseCases { } decrypFileName(file: File): any { - const decryptedName = this.cryptoService.decryptName( - file.name, - file.folderId, - ); + const decryptedName = + file.plainName ?? + this.cryptoService.decryptName(file.name, file.folderId); if (decryptedName === '') { return File.build(file); diff --git a/src/modules/folder/folder.usecase.ts b/src/modules/folder/folder.usecase.ts index 937647c89..0081964d4 100644 --- a/src/modules/folder/folder.usecase.ts +++ b/src/modules/folder/folder.usecase.ts @@ -69,12 +69,9 @@ export class FolderUseCases { throw new NotFoundException('Folder not found'); } - if (!folder.plainName) { - folder.plainName = this.cryptoService.decryptName( - folder.name, - folder.parentId, - ); - } + folder.plainName = + folder.plainName ?? + this.cryptoService.decryptName(folder.name, folder.parentId); return folder; } @@ -954,10 +951,9 @@ export class FolderUseCases { } decryptFolderName(folder: Folder): Folder { - const decryptedName = this.cryptoService.decryptName( - folder.name, - folder.parentId, - ); + const decryptedName = + folder.plainName ?? + this.cryptoService.decryptName(folder.name, folder.parentId); if (decryptedName === '') { throw new Error('Unable to decrypt folder name'); From 384dab0288c891e8a28a7fb2cb014411e220016f Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Thu, 15 Jan 2026 12:05:32 -0600 Subject: [PATCH 11/71] test: add tests for handling plainName in file and folder use cases --- src/modules/file/file.usecase.spec.ts | 24 ++++++++++++++++++++++- src/modules/folder/folder.usecase.spec.ts | 19 +++++++++--------- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/modules/file/file.usecase.spec.ts b/src/modules/file/file.usecase.spec.ts index b05f76c93..791a052f4 100644 --- a/src/modules/file/file.usecase.spec.ts +++ b/src/modules/file/file.usecase.spec.ts @@ -371,6 +371,7 @@ describe('FileUseCases', () => { ...fileAttributes, name: encryptedName, folderId, + plainName: null, }; const decryptedName = 'decryptedName'; @@ -393,6 +394,22 @@ describe('FileUseCases', () => { ); }); + it('When the file has a plain name, then the plain name is returned', () => { + const file = File.build({ + ...fileAttributes, + plainName: 'plain name', + }); + + const result = service.decrypFileName(file); + expect(result).toEqual( + File.build({ + ...file, + name: 'plain name', + plainName: 'plain name', + }), + ); + }); + it('fails when name is not encrypted', () => { const decyptedName = 'not encrypted name'; @@ -1826,7 +1843,12 @@ describe('FileUseCases', () => { const result = await service.getFileMetadata(userMocked, mockFile.uuid); - expect(result).toEqual(mockFile); + expect(result).toEqual( + File.build({ + ...mockFile, + name: mockFile.plainName, + }), + ); expect(fileRepository.findByUuid).toHaveBeenCalledWith( mockFile.uuid, userMocked.id, diff --git a/src/modules/folder/folder.usecase.spec.ts b/src/modules/folder/folder.usecase.spec.ts index f7c906201..7a7610a20 100644 --- a/src/modules/folder/folder.usecase.spec.ts +++ b/src/modules/folder/folder.usecase.spec.ts @@ -461,6 +461,7 @@ describe('FolderUseCases', () => { const folder = newFolder({ attributes: { name: 'not encrypted name', + plainName: null, }, }); @@ -1724,11 +1725,11 @@ describe('FolderUseCases', () => { describe('getByUuid', () => { const folderUuid = v4(); - it('When folder exists and no plainName, then it should decrypt and return the folder', async () => { - const decryptedName = 'Decrypted Name'; + it('When folder exists, then it should decrypt and return the folder', async () => { const folder = newFolder({ - attributes: { uuid: folderUuid, plainName: undefined }, + attributes: { uuid: folderUuid, plainName: null }, }); + const decryptedName = 'Decrypted Name'; jest.spyOn(folderRepository, 'findByUuid').mockResolvedValueOnce(folder); jest @@ -1748,22 +1749,22 @@ describe('FolderUseCases', () => { expect(result.plainName).toBe(decryptedName); }); - it('When folder exists and there is a plainName, then it should not decrypt and return the folder', async () => { - const plainName = 'Plain Name'; + it('When the folder has a plain name, then the plain name is returned', async () => { const folder = newFolder({ - attributes: { uuid: folderUuid, plainName }, + attributes: { uuid: folderUuid, plainName: 'plain name' }, }); - jest.spyOn(cryptoService, 'decryptName'); jest.spyOn(folderRepository, 'findByUuid').mockResolvedValueOnce(folder); + const decryptSpy = jest.spyOn(cryptoService, 'decryptName'); + const result = await service.getByUuid(folderUuid); expect(folderRepository.findByUuid).toHaveBeenCalledWith( folderUuid, false, ); - expect(cryptoService.decryptName).not.toHaveBeenCalled(); - expect(result.plainName).toBe(plainName); + expect(decryptSpy).not.toHaveBeenCalled(); + expect(result.plainName).toBe('plain name'); }); it('When folder does not exist, then it should throw NotFoundException', async () => { From 519bca0493ef84fd5b010c8349f5b4c827170642 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Thu, 15 Jan 2026 07:25:03 -0600 Subject: [PATCH 12/71] fix(file): ensure proper BigInt comparison for file size --- src/modules/file/file.usecase.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/file/file.usecase.ts b/src/modules/file/file.usecase.ts index 4458cca15..849c8f67a 100644 --- a/src/modules/file/file.usecase.ts +++ b/src/modules/file/file.usecase.ts @@ -395,7 +395,7 @@ export class FileUseCases { throw new ConflictException('File already exists'); } - const isFileEmpty = newFileDto.size === BigInt(0); + const isFileEmpty = BigInt(newFileDto.size) === BigInt(0); if (isFileEmpty) { await this.checkEmptyFilesLimit(user); From 86295bc9a029c8e555d88eccc8ad5dfb7ca79db9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Fri, 16 Jan 2026 12:49:48 +0100 Subject: [PATCH 13/71] feat(cache): add getRecord and increment functions for the rate limiter usage --- .../cache-manager.service.spec.ts | 108 ++++++++++++++++++ .../cache-manager/cache-manager.service.ts | 51 ++++++++- 2 files changed, 158 insertions(+), 1 deletion(-) diff --git a/src/modules/cache-manager/cache-manager.service.spec.ts b/src/modules/cache-manager/cache-manager.service.spec.ts index c2fc92df3..a9aba91cb 100644 --- a/src/modules/cache-manager/cache-manager.service.spec.ts +++ b/src/modules/cache-manager/cache-manager.service.spec.ts @@ -307,4 +307,112 @@ describe('CacheManagerService', () => { }); }); }); + + describe('getRecord', () => { + const key = 'throttle:some:key'; + + it('When entry exists and not expired then returns a record succesfully', async () => { + const now = 1_600_000_000_000; + const expirationTTL = 5_000; + const expiresAt = now + expirationTTL; + const entry = { hits: 3, expiresAt }; + + jest.spyOn(Date, 'now').mockReturnValue(now); + jest.spyOn(cacheManager, 'get').mockResolvedValue(entry as any); + + const result = await cacheManagerService.getRecord(key); + + expect(cacheManager.get).toHaveBeenCalledWith(key); + expect(result).toEqual({ + totalHits: 3, + timeToExpire: expirationTTL, + isBlocked: false, + timeToBlockExpire: 0, + }); + }); + + it('When entry exists but expired then returns record with time to expire set to 0', async () => { + const now = 1_600_000_010_000; + const expiresAt = now - 1_000; + const entry = { hits: 2, expiresAt }; + + jest.spyOn(Date, 'now').mockReturnValue(now); + jest.spyOn(cacheManager, 'get').mockResolvedValue(entry as any); + + const result = await cacheManagerService.getRecord(key); + + expect(result).toEqual({ + totalHits: 2, + timeToExpire: 0, + isBlocked: false, + timeToBlockExpire: 0, + }); + }); + + it('When cache returns null then returns undefined', async () => { + jest.spyOn(cacheManager, 'get').mockResolvedValue(null); + + const result = await cacheManagerService.getRecord(key); + + expect(result).toBeUndefined(); + }); + }); + + describe('increment', () => { + const key = 'throttle:some:key'; + + it('When there is no existing entry then it sets hits=1 and ttl equals requested ttl (ms)', async () => { + const now = 1_600_000_020_000; + const ttlSeconds = 60; + const ttlMs = ttlSeconds * 1000; + + jest.spyOn(Date, 'now').mockReturnValue(now); + jest.spyOn(cacheManager, 'get').mockResolvedValue(null); + const setSpy = jest.spyOn(cacheManager, 'set').mockResolvedValue(undefined as any); + + const result = await cacheManagerService.increment(key, ttlSeconds); + + expect(cacheManager.get).toHaveBeenCalledWith(key); + expect(setSpy).toHaveBeenCalledWith(key, { hits: 1, expiresAt: now + ttlMs }, ttlMs); + expect(result.totalHits).toBe(1); + expect(result.timeToExpire).toBe(ttlMs); + }); + + it('When existing entry present and not expired then it increments hits and preserves the expiration time', async () => { + const now = 1_600_000_030_000; + const expiresAt = now + 3_000; + const existing = { hits: 2, expiresAt }; + const ttlSeconds = 10; + + jest.spyOn(Date, 'now').mockReturnValue(now); + jest.spyOn(cacheManager, 'get').mockResolvedValue(existing as any); + const setSpy = jest.spyOn(cacheManager, 'set').mockResolvedValue(undefined as any); + + const result = await cacheManagerService.increment(key, ttlSeconds); + const expectedNewHits = existing.hits + 1; + + expect(setSpy).toHaveBeenCalledWith(key, { hits: expectedNewHits, expiresAt }, expiresAt - now); + expect(result.totalHits).toBe(expectedNewHits); + expect(result.timeToExpire).toBe(expiresAt - now); + }); + + it('When existing entry expired then it still increments but ttl becomes 0', async () => { + const now = 1_600_000_040_000; + const expiresAt = now - 500; + const existing = { hits: 5, expiresAt }; + const ttlSeconds = 30; + + jest.spyOn(Date, 'now').mockReturnValue(now); + jest.spyOn(cacheManager, 'get').mockResolvedValue(existing as any); + const setSpy = jest.spyOn(cacheManager, 'set').mockResolvedValue(undefined as any); + + const result = await cacheManagerService.increment(key, ttlSeconds); + const expectedNewHits = existing.hits + 1; + const expectedTimeToExpire = 0; + + expect(setSpy).toHaveBeenCalledWith(key, { hits: expectedNewHits, expiresAt }, 0); + expect(result.totalHits).toBe(expectedNewHits); + expect(result.timeToExpire).toBe(expectedTimeToExpire); + }); + }); }); diff --git a/src/modules/cache-manager/cache-manager.service.ts b/src/modules/cache-manager/cache-manager.service.ts index d50622ca1..faa81b56f 100644 --- a/src/modules/cache-manager/cache-manager.service.ts +++ b/src/modules/cache-manager/cache-manager.service.ts @@ -1,6 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; +import { ThrottlerStorageRecord } from '@nestjs/throttler/dist/throttler-storage-record.interface'; @Injectable() export class CacheManagerService { @@ -8,7 +9,7 @@ export class CacheManagerService { private readonly LIMIT_KEY_PREFIX = 'limit:'; private readonly JWT_KEY_PREFIX = 'jwt:'; private readonly AVATAR_KEY_PREFIX = 'avatar:'; - private readonly TTL_10_MINUTES = 10000 * 60; + private readonly TTL_10_MINUTES = 10 * 60 * 1000; private readonly TTL_24_HOURS = 24 * 60 * 60 * 1000; constructor(@Inject(CACHE_MANAGER) private readonly cacheManager: Cache) {} @@ -100,4 +101,52 @@ export class CacheManagerService { async deleteUserAvatar(userUuid: string) { return this.cacheManager.del(`${this.AVATAR_KEY_PREFIX}${userUuid}`); } + + async getRecord(key: string): Promise { + const entry = await this.cacheManager.get<{ hits: number; expiresAt: number }>( + key, + ); + + if (entry && typeof entry.hits === 'number') { + const now = Date.now(); + const timeToExpire = entry.expiresAt > now ? entry.expiresAt - now : 0; + const record: ThrottlerStorageRecord = { + totalHits: entry.hits, + timeToExpire, + isBlocked: false, + timeToBlockExpire: 0, + }; + return record; + } + return undefined; + } + + async increment(key: string, ttlSeconds: number): Promise { + const ttlMs = ttlSeconds * 1000; + const now = Date.now(); + + const existing = await this.cacheManager.get<{ hits: number; expiresAt: number }>( + key, + ); + + let hits = 1; + let expiresAt = now + ttlMs; + + if (existing && typeof existing.hits === 'number' && existing.expiresAt) { + hits = existing.hits + 1; + expiresAt = existing.expiresAt; + } + + const remainingTtl = Math.max(0, expiresAt - now); + + await this.cacheManager.set(key, { hits, expiresAt }, remainingTtl); + + const record: ThrottlerStorageRecord = { + totalHits: hits, + timeToExpire: remainingTtl, + isBlocked: false, + timeToBlockExpire: 0, + }; + return record; + } } From 489e566452bbd4ed68f2ab6756e86d9295669a57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Fri, 16 Jan 2026 12:52:59 +0100 Subject: [PATCH 14/71] refactor(guards): simplify CustomThrottlerGuard significatively --- src/guards/throttler.guard.ts | 76 +++-------------------------------- 1 file changed, 5 insertions(+), 71 deletions(-) diff --git a/src/guards/throttler.guard.ts b/src/guards/throttler.guard.ts index b8f9b4159..012e25fb9 100644 --- a/src/guards/throttler.guard.ts +++ b/src/guards/throttler.guard.ts @@ -1,10 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { ThrottlerGuard as BaseThrottlerGuard, ThrottlerModuleOptions, ThrottlerRequest, ThrottlerStorageService } from '@nestjs/throttler'; -import { ConfigService } from '@nestjs/config'; -import { Reflector } from '@nestjs/core'; -import type { Request } from 'express'; -import { User } from '../modules/user/user.domain'; - +import { ThrottlerGuard as BaseThrottlerGuard } from '@nestjs/throttler'; @Injectable() export class ThrottlerGuard extends BaseThrottlerGuard { protected async getTracker(req: Record): Promise { @@ -15,70 +10,9 @@ export class ThrottlerGuard extends BaseThrottlerGuard { } @Injectable() -export class CustomThrottlerGuard extends BaseThrottlerGuard { - constructor( - options: ThrottlerModuleOptions, - storageService: ThrottlerStorageService, - reflector: Reflector, - private readonly config: ConfigService, - ) { - super(options, storageService, reflector); - } - - protected async getTracker(req: Record): Promise { - const user = req.user; - if (user && (user.id || user.uuid)) { - return `user:${user.id ?? user.uuid}`; - } - const auth = req.headers['authorization'] as string | undefined; - if (auth) return `token:${auth.slice(0, 200)}`; - const forwarded = (req.headers['x-forwarded-for'] as string) || ''; - const ip = forwarded ? forwarded.split(',')[0].trim() : req.ip || req.socket?.remoteAddress || 'unknown'; - return `ip:${ip}`; - } - - protected async handleRequest(requestProps: ThrottlerRequest): Promise { - const { context } = requestProps; - - const handlerContext = context.getHandler(); - const classContext = context.getClass(); - - const isPublic = this.reflector.get('isPublic', handlerContext); - const disableGlobalAuth = this.reflector.getAllAndOverride( - 'disableGlobalAuth', - [handlerContext, classContext], - ); - - const req = context.switchToHttp().getRequest(); - - if (isPublic || disableGlobalAuth || !req.user) { - const anonymousLimit = this.config.get('users.rateLimit.anonymous.limit'); - const anonymousTTL = this.config.get('users.rateLimit.anonymous.ttl'); - - requestProps.ttl = anonymousTTL; - requestProps.limit = anonymousLimit; - - return super.handleRequest(requestProps); - } - - const user = req.user as User; - const isFreeUser = user.tierId === this.config.get('users.freeTierId'); - - if (isFreeUser) { - const freeLimit = this.config.get('users.rateLimit.free.limit'); - const freeTTL = this.config.get('users.rateLimit.free.ttl'); - - requestProps.ttl = freeTTL; - requestProps.limit = freeLimit; - - return super.handleRequest(requestProps); - } - - const paidLimit = this.config.get('users.rateLimit.paid.limit'); - const paidTTL = this.config.get('users.rateLimit.paid.ttl'); - requestProps.ttl = paidTTL; - requestProps.limit = paidLimit; - - return super.handleRequest(requestProps); +export class CustomThrottlerGuard extends ThrottlerGuard { + protected async getTracker(req: any): Promise { + const userId = req.user?.uuid; + return userId ? `rl:${userId}` : `rl:${req.ip}`; } } \ No newline at end of file From 3e33020af74f9ec149e6c13a5f9154b3dbedc430 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Fri, 16 Jan 2026 12:53:50 +0100 Subject: [PATCH 15/71] feat(guards): add custom throttler --- .../custom-endpoint-throttle.decorator.ts | 20 ++++ .../custom-endpoint-throttle.guard.spec.ts | 103 ++++++++++++++++++ src/guards/custom-endpoint-throttle.guard.ts | 65 +++++++++++ 3 files changed, 188 insertions(+) create mode 100644 src/guards/custom-endpoint-throttle.decorator.ts create mode 100644 src/guards/custom-endpoint-throttle.guard.spec.ts create mode 100644 src/guards/custom-endpoint-throttle.guard.ts diff --git a/src/guards/custom-endpoint-throttle.decorator.ts b/src/guards/custom-endpoint-throttle.decorator.ts new file mode 100644 index 000000000..a34aba68e --- /dev/null +++ b/src/guards/custom-endpoint-throttle.decorator.ts @@ -0,0 +1,20 @@ +import { SetMetadata } from '@nestjs/common'; + +export const CUSTOM_ENDPOINT_THROTTLE_KEY = 'customEndpointThrottle'; + +export interface CustomThrottleOptions { + ttl: number; // seconds + limit: number; +} + +/** + * You can use two different shapes: + * - single policy: { ttl, limit } + * - named policies: { short: { ttl, limit }, long: { ttl, limit } } + */ +export type CustomThrottleArg = + | CustomThrottleOptions + | Record; + +export const CustomThrottle = (opts: CustomThrottleArg) => + SetMetadata(CUSTOM_ENDPOINT_THROTTLE_KEY, opts); diff --git a/src/guards/custom-endpoint-throttle.guard.spec.ts b/src/guards/custom-endpoint-throttle.guard.spec.ts new file mode 100644 index 000000000..9b864440d --- /dev/null +++ b/src/guards/custom-endpoint-throttle.guard.spec.ts @@ -0,0 +1,103 @@ +import * as tsjest from '@golevelup/ts-jest'; +import { ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { CustomEndpointThrottleGuard } from './custom-endpoint-throttle.guard'; +import { CacheManagerService } from '../modules/cache-manager/cache-manager.service'; +import { ThrottlerException } from '@nestjs/throttler'; + +describe('CustomThrottleGuard', () => { + let guard: CustomEndpointThrottleGuard; + let reflector: Reflector; + let cacheService: jest.Mocked; + + beforeEach(() => { + reflector = tsjest.createMock(); + cacheService = tsjest.createMock(); + cacheService.increment = jest.fn(); + guard = new CustomEndpointThrottleGuard(reflector, cacheService as any); + }); + + describe('canActivate', () => { + it('When reflector returns no metadata then the guard checks are skipped', async () => { + (reflector.get as jest.Mock).mockReturnValue(undefined); + const context = tsjest.createMock(); + + const result = await guard.canActivate(context); + + expect(result).toBe(true); + expect(cacheService.increment).not.toHaveBeenCalled(); + }); + + describe('Applying a single policy', () => { + const route = '/login'; + + it('When under limit then it allows the request to pass', async () => { + const policy = { ttl: 60, limit: 5 }; + (reflector.get as jest.Mock).mockReturnValue(policy); + + const request: any = { route: { path: route }, user: { uuid: 'user-1' }, ip: '1.2.3.4' }; + (cacheService.increment as jest.Mock).mockResolvedValue({ totalHits: 1, timeToExpire: 5000 }); + const context = tsjest.createMock(); + (context as any).switchToHttp = () => ({ getRequest: () => request }); + + const result = await guard.canActivate(context); + + expect(result).toBe(true); + expect(cacheService.increment).toHaveBeenCalledWith(`${request.route.path}:policy0:cet:uid:${request.user.uuid}`, 60); + }); + + it('When over the limit then the request is throttled', async () => { + const policy = { ttl: 60, limit: 1 }; + (reflector.get as jest.Mock).mockReturnValue(policy); + + const request: any = { route: { path: route }, user: { uuid: 'user-2' }, ip: '2.2.2.2' }; + (cacheService.increment as jest.Mock).mockResolvedValue({ totalHits: 2, timeToExpire: 1000 }); + const context = tsjest.createMock(); + (context as any).switchToHttp = () => ({ getRequest: () => request }); + + await expect(guard.canActivate(context)).rejects.toBeInstanceOf(ThrottlerException); + expect(cacheService.increment).toHaveBeenCalledWith(`${request.route.path}:policy0:cet:uid:${request.user.uuid}`, 60); + }); + }); + + describe('Applying multiple policies', () => { + const route = '/login'; + + it('When under limits then it allows the request to pass', async () => { + const named = { short: { ttl: 60, limit: 5 }, long: { ttl: 3600, limit: 30 } }; + (reflector.get as jest.Mock).mockReturnValue(named); + const request: any = { route: { path: route }, user: null, ip: '9.9.9.9' }; + + (cacheService.increment as jest.Mock) + .mockResolvedValueOnce({ totalHits: named.short.limit - 1, timeToExpire: 100 }) + .mockResolvedValueOnce({ totalHits: named.long.limit - 1, timeToExpire: 1000 }); + + const context = tsjest.createMock(); + (context as any).switchToHttp = () => ({ getRequest: () => request }); + + const result = await guard.canActivate(context); + + expect(result).toBe(true); + expect(cacheService.increment).toHaveBeenCalledWith(`${request.route.path}:short:cet:ip:${request.ip}`, named.short.ttl); + expect(cacheService.increment).toHaveBeenCalledWith(`${request.route.path}:long:cet:ip:${request.ip}`, named.long.ttl); + }); + + it('when over the limit then the request is throttled', async () => { + const named = { short: { ttl: 60, limit: 1 }, long: { ttl: 3600, limit: 30 } }; + (reflector.get as jest.Mock).mockReturnValue(named); + const request: any = { route: { path: route }, user: null, ip: '11.11.11.11' }; + + const shortOverTheLimit = named.short.limit + 1; + (cacheService.increment as jest.Mock) + .mockResolvedValueOnce({ totalHits: shortOverTheLimit, timeToExpire: 10 }) + .mockResolvedValueOnce({ totalHits: named.long.limit - 1, timeToExpire: 1000 }); + + const context = tsjest.createMock(); + (context as any).switchToHttp = () => ({ getRequest: () => request }); + + await expect(guard.canActivate(context)).rejects.toBeInstanceOf(ThrottlerException); + expect(cacheService.increment).toHaveBeenCalledWith(`${request.route.path}:short:cet:ip:${request.ip}`, 60); + }); + }); + }); +}); diff --git a/src/guards/custom-endpoint-throttle.guard.ts b/src/guards/custom-endpoint-throttle.guard.ts new file mode 100644 index 000000000..bbe7449c5 --- /dev/null +++ b/src/guards/custom-endpoint-throttle.guard.ts @@ -0,0 +1,65 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + Inject, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ThrottlerException } from '@nestjs/throttler'; +import { CacheManagerService } from '../modules/cache-manager/cache-manager.service'; +import { + CUSTOM_ENDPOINT_THROTTLE_KEY, + CustomThrottleOptions, +} from './custom-endpoint-throttle.decorator'; + +@Injectable() +export class CustomEndpointThrottleGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly cacheService: CacheManagerService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const raw = this.reflector.get(CUSTOM_ENDPOINT_THROTTLE_KEY, context.getHandler()); + + // If no custom throttle metadata, do not block (this guard should be applied + // only where needed). Returning true lets other guards run. + if (!raw) return true; + + const policies: Array = []; + + if (typeof raw === 'object' && (raw as any).ttl === undefined && (raw as any).limit === undefined) { + // named policies object: { short: { ttl, limit }, long: { ttl, limit } } + const entries = Object.entries(raw) as [string, CustomThrottleOptions][]; + for (const [name, val] of entries) { + policies.push({ ...(val as CustomThrottleOptions), key: name }); + } + } else { + policies.push({ ...(raw as CustomThrottleOptions), key: (raw as any).key ?? 'policy0' }); + } + + const request = context.switchToHttp().getRequest(); + const user = request.user; + + const identifierBase = user?.uuid ? `cet:uid:${user.uuid}` : `cet:ip:${request.ip}`; + const route = request.route?.path ?? request.originalUrl ?? 'unknown'; + + // Apply all policies. If any policy is violated, throw. + for (let i = 0; i < policies.length; i++) { + const p = policies[i]; + // Prefer an explicit stable key from the policy so the identity + // remains the same even if the array order changes. Fallback to + // index-based id when no key provided. + const policyId = p.key ? String(p.key) : `policy${i}`; + const sanitizedRoute = String(route).replace(/\s+/g, '_'); + const sanitizedPolicyId = policyId.replace(/\s+/g, '_'); + const key = `${sanitizedRoute}:${sanitizedPolicyId}:${identifierBase}`; + const record = await this.cacheService.increment(key, p.ttl); + if (record.totalHits > p.limit) { + throw new ThrottlerException(); + } + } + + return true; + } +} From ac984e089be56711da1ce5599756b2947285aecf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Fri, 16 Jan 2026 13:03:10 +0100 Subject: [PATCH 16/71] feat(guards): create custom interceptor for global throttling --- src/guards/throttler.interceptor.spec.ts | 89 ++++++++++++++++++++++++ src/guards/throttler.interceptor.ts | 65 +++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 src/guards/throttler.interceptor.spec.ts create mode 100644 src/guards/throttler.interceptor.ts diff --git a/src/guards/throttler.interceptor.spec.ts b/src/guards/throttler.interceptor.spec.ts new file mode 100644 index 000000000..ad12a2ee0 --- /dev/null +++ b/src/guards/throttler.interceptor.spec.ts @@ -0,0 +1,89 @@ +import * as tsjest from '@golevelup/ts-jest'; +import { CustomThrottlerInterceptor } from './throttler.interceptor'; +import { CacheManagerService } from '../modules/cache-manager/cache-manager.service'; +import { ConfigService } from '@nestjs/config'; +import { Reflector } from '@nestjs/core'; +import { ExecutionContext, CallHandler } from '@nestjs/common'; +import { ThrottlerException } from '@nestjs/throttler'; +import { of } from 'rxjs'; + +describe('CustomThrottlerInterceptor', () => { + let interceptor: CustomThrottlerInterceptor; + let configService: jest.Mocked; + let cacheService: jest.Mocked; + let reflector: Reflector; + + beforeEach(() => { + configService = tsjest.createMock(); + cacheService = tsjest.createMock(); + cacheService.increment = jest.fn(); + reflector = tsjest.createMock(); + + interceptor = new CustomThrottlerInterceptor( + configService as any, + cacheService as any, + reflector, + ); + }); + + describe('intercept', () => { + it('When handler or class has custom throttle metadata then bypasses global throttling', async () => { + (reflector.get as jest.Mock).mockReturnValue(true); + const context = tsjest.createMock(); + const next: Partial = { handle: jest.fn(() => of('ok')) }; + + await interceptor.intercept(context, next as CallHandler); + + expect((next.handle as jest.Mock).mock.calls.length).toBeGreaterThanOrEqual(1); + expect(cacheService.increment).not.toHaveBeenCalled(); + }); + + it('When anonymous request under limit then increments by ip and allows', async () => { + (reflector.get as jest.Mock).mockReturnValue(undefined); + configService.get = jest.fn((key: string) => { + if (key === 'users.rateLimit.anonymous.ttl') return 30; + if (key === 'users.rateLimit.anonymous.limit') return 10; + return undefined; + }) as any; + + const request: any = { ip: '10.0.0.1', user: null }; + const context = tsjest.createMock(); + (context as any).switchToHttp = () => ({ getRequest: () => request }); + + (cacheService.increment as jest.Mock).mockResolvedValue({ totalHits: 1, timeToExpire: 1000 }); + const next: Partial = { handle: jest.fn(() => of('ok')) }; + + await interceptor.intercept(context, next as CallHandler); + + expect(cacheService.increment).toHaveBeenCalledWith(`rl:${request.ip}`, 30); + expect((next.handle as jest.Mock).mock.calls.length).toBeGreaterThanOrEqual(1); + }); + + it('When authenticated free-tier user exceeds limit then the request is throttled', async () => { + (reflector.get as jest.Mock).mockReturnValue(undefined); + const freeTierId = 'free-tier'; + configService.get = jest.fn((key: string) => { + switch (key) { + case 'users.freeTierId': + return freeTierId; + case 'users.rateLimit.free.ttl': + return 20; + case 'users.rateLimit.free.limit': + return 1; + default: + return undefined; + } + }) as any; + + const request: any = { ip: '1.1.1.1', user: { uuid: 'u123', tierId: freeTierId } }; + const context = tsjest.createMock(); + (context as any).switchToHttp = () => ({ getRequest: () => request }); + + (cacheService.increment as jest.Mock).mockResolvedValue({ totalHits: 5, timeToExpire: 100 }); + const next: Partial = { handle: jest.fn(() => of('ok')) }; + + await expect(interceptor.intercept(context, next as CallHandler)).rejects.toBeInstanceOf(ThrottlerException); + expect(cacheService.increment).toHaveBeenCalledWith(`rl:${request.user.uuid}`, 20); + }); + }); +}); diff --git a/src/guards/throttler.interceptor.ts b/src/guards/throttler.interceptor.ts new file mode 100644 index 000000000..7a187911e --- /dev/null +++ b/src/guards/throttler.interceptor.ts @@ -0,0 +1,65 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { CacheManagerService } from "../modules/cache-manager/cache-manager.service"; +import { Observable } from "rxjs"; +import { ThrottlerException } from "@nestjs/throttler"; +import { User } from "src/modules/user/user.domain"; +import { Reflector } from '@nestjs/core'; +import { CUSTOM_ENDPOINT_THROTTLE_KEY } from './custom-endpoint-throttle.decorator'; + +@Injectable() +export class CustomThrottlerInterceptor implements NestInterceptor { + constructor( + private readonly configService: ConfigService, + private readonly cacheService: CacheManagerService, + private readonly reflector: Reflector, + ) {} + + private getRateLimit(user?: User): { ttl: number; limit: number } { + if (!user) { + return { + ttl: this.configService.get('users.rateLimit.anonymous.ttl'), + limit: this.configService.get('users.rateLimit.anonymous.limit') + }; + } + if (user.tierId === this.configService.get('users.freeTierId')) { + return { + ttl: this.configService.get('users.rateLimit.free.ttl'), + limit: this.configService.get('users.rateLimit.free.limit') + } + } + return { + ttl: this.configService.get('users.rateLimit.paid.ttl'), + limit: this.configService.get('users.rateLimit.paid.limit') + } + } + + async intercept(context: ExecutionContext, next: CallHandler): Promise> { + const request = context.switchToHttp().getRequest(); + + // Interceptors run before guards, so we must check metadata and + // bypass the global interceptor when custom throttle is present. + const hasCustom = + this.reflector.get(CUSTOM_ENDPOINT_THROTTLE_KEY, context.getHandler()) || + this.reflector.get(CUSTOM_ENDPOINT_THROTTLE_KEY, context.getClass()); + + if (hasCustom) { + return next.handle(); + } + const user = request.user as User | null; + let key = `rl:${request.ip}`; + if (user && user.uuid) { + key = `rl:${user.uuid}` + } + + const { ttl, limit } = this.getRateLimit(user); + + const record = await this.cacheService.increment(key, ttl); + + if (record.totalHits > limit) { + throw new ThrottlerException(); + } + + return next.handle(); + } +} \ No newline at end of file From 096f8420434f4805057f047f8acaeae2622fb705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Fri, 16 Jan 2026 13:04:30 +0100 Subject: [PATCH 17/71] feat(guards): wire the throttler guard and interceptor with the app --- src/app.module.ts | 18 ++--------------- src/guards/throttler.module.ts | 35 ++++++++++++++++++++++++++++++++++ src/main.ts | 2 ++ 3 files changed, 39 insertions(+), 16 deletions(-) create mode 100644 src/guards/throttler.module.ts diff --git a/src/app.module.ts b/src/app.module.ts index a884f3764..3fcbe64d4 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -30,9 +30,9 @@ import { HttpGlobalExceptionFilter } from './common/http-global-exception-filter import { JobsModule } from './modules/jobs/jobs.module'; import { v4 } from 'uuid'; import { getClientIdFromHeaders } from './common/decorators/client.decorator'; -import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis'; import { CustomThrottlerGuard } from './guards/throttler.guard'; import { AuthGuard } from './modules/auth/auth.guard'; +import { CustomThrottlerModule } from './guards/throttler.module'; @Module({ imports: [ @@ -125,21 +125,7 @@ import { AuthGuard } from './modules/auth/auth.guard'; }), }), EventEmitterModule.forRoot({ wildcard: true, delimiter: '.' }), - ThrottlerModule.forRootAsync({ - imports: [ConfigModule], - inject: [ConfigService], - useFactory: (config: ConfigService) => { - return ({ - throttlers: [ - { - ttl: seconds(config.get('users.rateLimit.default.ttl')), - limit: config.get('users.rateLimit.default.limit') - } - ], - storage: new ThrottlerStorageRedisService(config.get('cache.redisConnectionString')) - }) - }, - }), + CustomThrottlerModule, JobsModule, NotificationModule, NotificationsModule, diff --git a/src/guards/throttler.module.ts b/src/guards/throttler.module.ts new file mode 100644 index 000000000..fb71f8b65 --- /dev/null +++ b/src/guards/throttler.module.ts @@ -0,0 +1,35 @@ +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { seconds, ThrottlerModule } from "@nestjs/throttler"; +import { CacheManagerService } from "../modules/cache-manager/cache-manager.service"; +import { Module } from "@nestjs/common"; +import { CustomThrottlerInterceptor } from "./throttler.interceptor"; +import { CacheManagerModule } from "../modules/cache-manager/cache-manager.module"; + +@Module({ + imports: [ + CacheManagerModule, + ThrottlerModule.forRootAsync({ + imports: [ConfigModule, CacheManagerModule], + inject: [CacheManagerService, ConfigService], + useFactory: ( + customStorage: CacheManagerService, + configService: ConfigService, + ) => ({ + storage: customStorage, + throttlers: [ + { + ttl: seconds(configService.get('users.rateLimit.default.ttl')), + limit: configService.get('users.rateLimit.default.limit') + }, + ], + }), + }), + ], + providers: [ + CustomThrottlerInterceptor, + ], + exports: [ + CustomThrottlerInterceptor, + ], +}) +export class CustomThrottlerModule {} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 9af9f2674..a18e14a41 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,6 +18,7 @@ import configuration from './config/configuration'; import { TransformInterceptor } from './lib/transform.interceptor'; import { RequestLoggerInterceptor } from './middlewares/requests-logger.interceptor'; import { NewRelicInterceptor } from './lib/newrelic.interceptor'; +import { CustomThrottlerInterceptor } from './guards/throttler.interceptor'; const config = configuration(); const APP_PORT = config.port || 3000; @@ -57,6 +58,7 @@ async function bootstrap() { app.useGlobalPipes(new ValidationPipe({ transform: true })); app.useGlobalInterceptors(new TransformInterceptor()); app.useGlobalInterceptors(new NewRelicInterceptor()); + app.useGlobalInterceptors(app.get(CustomThrottlerInterceptor)); app.use(helmet()); app.use(apiMetrics()); From bafa749528f74f7231885b85925826ff4ef5e64b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Fri, 16 Jan 2026 14:24:10 +0100 Subject: [PATCH 18/71] fix(cache): dont keep the expiration time when the record is already expired --- .../cache-manager/cache-manager.service.spec.ts | 11 +++++++---- src/modules/cache-manager/cache-manager.service.ts | 11 ++++++----- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/modules/cache-manager/cache-manager.service.spec.ts b/src/modules/cache-manager/cache-manager.service.spec.ts index a9aba91cb..dcab743a0 100644 --- a/src/modules/cache-manager/cache-manager.service.spec.ts +++ b/src/modules/cache-manager/cache-manager.service.spec.ts @@ -396,7 +396,7 @@ describe('CacheManagerService', () => { expect(result.timeToExpire).toBe(expiresAt - now); }); - it('When existing entry expired then it still increments but ttl becomes 0', async () => { + it('When existing entry expired then it sets hits=1 and ttl equals requested ttl (ms)', async () => { const now = 1_600_000_040_000; const expiresAt = now - 500; const existing = { hits: 5, expiresAt }; @@ -407,10 +407,13 @@ describe('CacheManagerService', () => { const setSpy = jest.spyOn(cacheManager, 'set').mockResolvedValue(undefined as any); const result = await cacheManagerService.increment(key, ttlSeconds); - const expectedNewHits = existing.hits + 1; - const expectedTimeToExpire = 0; + const expectedNewHits = 1 + const expectedTimeToExpire = ttlSeconds * 1000; - expect(setSpy).toHaveBeenCalledWith(key, { hits: expectedNewHits, expiresAt }, 0); + expect(setSpy).toHaveBeenCalledWith(key, { + hits: expectedNewHits, + expiresAt: now + expectedTimeToExpire + }, expectedTimeToExpire); expect(result.totalHits).toBe(expectedNewHits); expect(result.timeToExpire).toBe(expectedTimeToExpire); }); diff --git a/src/modules/cache-manager/cache-manager.service.ts b/src/modules/cache-manager/cache-manager.service.ts index faa81b56f..e5ce58457 100644 --- a/src/modules/cache-manager/cache-manager.service.ts +++ b/src/modules/cache-manager/cache-manager.service.ts @@ -12,7 +12,9 @@ export class CacheManagerService { private readonly TTL_10_MINUTES = 10 * 60 * 1000; private readonly TTL_24_HOURS = 24 * 60 * 60 * 1000; - constructor(@Inject(CACHE_MANAGER) private readonly cacheManager: Cache) {} + constructor( + @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, + ) {} /** * Get user's storage usage @@ -125,14 +127,13 @@ export class CacheManagerService { const ttlMs = ttlSeconds * 1000; const now = Date.now(); - const existing = await this.cacheManager.get<{ hits: number; expiresAt: number }>( - key, - ); + const existing = await this.cacheManager.get<{ hits: number; expiresAt: number }>(key); let hits = 1; let expiresAt = now + ttlMs; + const existingAndNotExpired = existing && existing.expiresAt > now; - if (existing && typeof existing.hits === 'number' && existing.expiresAt) { + if (existingAndNotExpired) { hits = existing.hits + 1; expiresAt = existing.expiresAt; } From bc08bc39e19825d7be0d7549181ecf7be8f18ee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Fri, 16 Jan 2026 16:17:08 +0100 Subject: [PATCH 19/71] fix(folders): rate limit folder meta to 30 x min --- src/modules/folder/folder.controller.ts | 7 +++++++ src/modules/folder/folder.module.ts | 10 +++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/modules/folder/folder.controller.ts b/src/modules/folder/folder.controller.ts index a099d402a..8604c4b00 100644 --- a/src/modules/folder/folder.controller.ts +++ b/src/modules/folder/folder.controller.ts @@ -14,6 +14,7 @@ import { Post, Put, Query, + UseGuards, } from '@nestjs/common'; import { ApiBearerAuth, @@ -66,6 +67,8 @@ import { ValidateUUIDPipe } from '../../common/pipes/validate-uuid.pipe'; import { GetFilesInFoldersDto } from './dto/get-files-in-folder.dto'; import { GetFoldersInFoldersDto } from './dto/get-folders-in-folder.dto'; import { GetFoldersQueryDto } from './dto/get-folders.dto'; +import { CustomEndpointThrottleGuard } from '../../guards/custom-endpoint-throttle.guard'; +import { CustomThrottle } from '../../guards/custom-endpoint-throttle.decorator'; export class BadRequestWrongFolderIdException extends BadRequestException { constructor() { @@ -760,6 +763,10 @@ export class FolderController { return folderDto; } + @UseGuards(CustomEndpointThrottleGuard) + @CustomThrottle({ + short: { ttl: 60, limit: 30 }, + }) @Get('/meta') async getFolderMetaByPath( @UserDecorator() user: User, diff --git a/src/modules/folder/folder.module.ts b/src/modules/folder/folder.module.ts index be045868b..43d11d2bb 100644 --- a/src/modules/folder/folder.module.ts +++ b/src/modules/folder/folder.module.ts @@ -13,6 +13,8 @@ import { WorkspacesModule } from '../workspaces/workspaces.module'; import { NotificationModule } from '../../externals/notifications/notifications.module'; import { TrashModule } from '../trash/trash.module'; import { FeatureLimitModule } from '../feature-limit/feature-limit.module'; +import { CustomEndpointThrottleGuard } from '../../guards/custom-endpoint-throttle.guard'; +import { CacheManagerModule } from '../cache-manager/cache-manager.module'; @Module({ imports: [ @@ -25,9 +27,15 @@ import { FeatureLimitModule } from '../feature-limit/feature-limit.module'; NotificationModule, forwardRef(() => TrashModule), FeatureLimitModule, + CacheManagerModule, ], controllers: [FolderController], - providers: [SequelizeFolderRepository, CryptoService, FolderUseCases], + providers: [ + SequelizeFolderRepository, + CryptoService, + FolderUseCases, + CustomEndpointThrottleGuard + ], exports: [FolderUseCases, SequelizeFolderRepository], }) export class FolderModule {} From 54ebf2029f3de879c92cb426f6e5e85f587082d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Fri, 16 Jan 2026 16:25:49 +0100 Subject: [PATCH 20/71] fix(files): rate limit file meta requests x min --- src/modules/file/file.controller.ts | 6 ++++++ src/modules/file/file.module.ts | 2 ++ 2 files changed, 8 insertions(+) diff --git a/src/modules/file/file.controller.ts b/src/modules/file/file.controller.ts index 39e96970c..6c4c10917 100644 --- a/src/modules/file/file.controller.ts +++ b/src/modules/file/file.controller.ts @@ -56,6 +56,8 @@ import { CreateThumbnailDto } from '../thumbnail/dto/create-thumbnail.dto'; import { ThumbnailUseCases } from '../thumbnail/thumbnail.usecase'; import { RequestLoggerInterceptor } from '../../middlewares/requests-logger.interceptor'; import { Version } from '../../common/decorators/version.decorator'; +import { CustomEndpointThrottleGuard } from '../../guards/custom-endpoint-throttle.guard'; +import { CustomThrottle } from '../../guards/custom-endpoint-throttle.decorator'; @ApiTags('File') @Controller('files') @@ -449,6 +451,10 @@ export class FileController { return files; } + @UseGuards(CustomEndpointThrottleGuard) + @CustomThrottle({ + short: { ttl: 60, limit: 100 }, + }) @Get('/meta') @ApiOkResponse({ type: FileDto }) async getFileMetaByPath( diff --git a/src/modules/file/file.module.ts b/src/modules/file/file.module.ts index 22e02c93b..9496ca0a7 100644 --- a/src/modules/file/file.module.ts +++ b/src/modules/file/file.module.ts @@ -21,6 +21,7 @@ import { FeatureLimitModule } from '../feature-limit/feature-limit.module'; import { RedisService } from '../../externals/redis/redis.service'; import { TrashModule } from '../trash/trash.module'; import { CacheManagerModule } from '../cache-manager/cache-manager.module'; +import { CustomEndpointThrottleGuard } from '../../guards/custom-endpoint-throttle.guard'; @Module({ imports: [ @@ -45,6 +46,7 @@ import { CacheManagerModule } from '../cache-manager/cache-manager.module'; FileUseCases, MailerService, RedisService, + CustomEndpointThrottleGuard ], exports: [ FileUseCases, From be0940f6006621fb82108268ea40cc18bf5f771a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Fri, 16 Jan 2026 16:35:35 +0100 Subject: [PATCH 21/71] refactor: remove deprecated prometheus integration --- package.json | 2 -- src/main.ts | 4 +--- yarn.lock | 48 +----------------------------------------------- 3 files changed, 2 insertions(+), 52 deletions(-) diff --git a/package.json b/package.json index 5f148715d..4dae6ee76 100644 --- a/package.json +++ b/package.json @@ -80,8 +80,6 @@ "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", "prettysize": "^2.0.0", - "prom-client": "^15.0.0", - "prometheus-api-metrics": "^4.0.0", "qrcode": "^1.4.4", "redis": "^5.8.2", "reflect-metadata": "^0.2.2", diff --git a/src/main.ts b/src/main.ts index a18e14a41..7e837af2b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,10 +3,9 @@ import dotenv from 'dotenv'; dotenv.config({ path: `.env.${process.env.NODE_ENV}` }); import { ValidationPipe } from '@nestjs/common'; -import { NestFactory, Reflector } from '@nestjs/core'; +import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; import { Logger } from 'nestjs-pino'; -import apiMetrics from 'prometheus-api-metrics'; import helmet from 'helmet'; import { DocumentBuilder, @@ -61,7 +60,6 @@ async function bootstrap() { app.useGlobalInterceptors(app.get(CustomThrottlerInterceptor)); app.use(helmet()); - app.use(apiMetrics()); if (!config.isProduction) { app.useGlobalInterceptors(new RequestLoggerInterceptor()); diff --git a/yarn.lock b/yarn.lock index 8551bb56e..52529566b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1858,7 +1858,7 @@ dependencies: "@opentelemetry/api" "^1.3.0" -"@opentelemetry/api@^1.3.0", "@opentelemetry/api@^1.4.0", "@opentelemetry/api@^1.9.0": +"@opentelemetry/api@^1.3.0", "@opentelemetry/api@^1.9.0": version "1.9.0" resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe" integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg== @@ -3759,11 +3759,6 @@ bignumber.js@^9.0.0: resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.3.1.tgz#759c5aaddf2ffdc4f154f7b493e1c8770f88c4d7" integrity sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ== -bintrees@1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/bintrees/-/bintrees-1.0.2.tgz#49f896d6e858a4a499df85c38fb399b9aff840f8" - integrity sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw== - bip39@3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/bip39/-/bip39-3.1.0.tgz#c55a418deaf48826a6ceb34ac55b3ee1577e18a3" @@ -4353,13 +4348,6 @@ debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, d dependencies: ms "^2.1.3" -debug@^3.2.7: - version "3.2.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" - integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== - dependencies: - ms "^2.1.1" - decamelize@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" @@ -6389,11 +6377,6 @@ lodash.defaults@^4.2.0: resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" integrity sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ== -lodash.get@^4.4.2: - version "4.4.2" - resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" - integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== - lodash.includes@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" @@ -7319,11 +7302,6 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" -pkginfo@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/pkginfo/-/pkginfo-0.4.1.tgz#b5418ef0439de5425fc4995042dced14fb2a84ff" - integrity sha512-8xCNE/aT/EXKenuMDZ+xTVwkT8gsoHN2z/Q29l80u0ppGEXVvsKRzNMbtKhg8LS8k1tJLAHHylf6p4VFmP6XUQ== - pluralize@8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" @@ -7409,23 +7387,6 @@ process-warning@^5.0.0: resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-5.0.0.tgz#566e0bf79d1dff30a72d8bbbe9e8ecefe8d378d7" integrity sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA== -prom-client@^15.0.0: - version "15.1.3" - resolved "https://registry.yarnpkg.com/prom-client/-/prom-client-15.1.3.tgz#69fa8de93a88bc9783173db5f758dc1c69fa8fc2" - integrity sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g== - dependencies: - "@opentelemetry/api" "^1.4.0" - tdigest "^0.1.1" - -prometheus-api-metrics@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/prometheus-api-metrics/-/prometheus-api-metrics-4.0.0.tgz#f69b2ab5dffea5638d680b9287613d08cbf855e6" - integrity sha512-xZq/fFTCOfEFCWRCok5cF969Xs2qPOlRkO8Tn3rVijeGkVdMEwlsDUM3oDXs/VdzMgCg5NFGEWHlGq4E3JgKKw== - dependencies: - debug "^3.2.7" - lodash.get "^4.4.2" - pkginfo "^0.4.1" - prompts@^2.0.1: version "2.4.2" resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" @@ -8341,13 +8302,6 @@ tar-stream@^2.1.4: inherits "^2.0.3" readable-stream "^3.1.1" -tdigest@^0.1.1: - version "0.1.2" - resolved "https://registry.yarnpkg.com/tdigest/-/tdigest-0.1.2.tgz#96c64bac4ff10746b910b0e23b515794e12faced" - integrity sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA== - dependencies: - bintrees "1.0.2" - terser-webpack-plugin@^5.3.11: version "5.3.14" resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz#9031d48e57ab27567f02ace85c7d690db66c3e06" From 06a096d9085d5b28d7ca7927976841ce99f381d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Fri, 16 Jan 2026 16:39:22 +0100 Subject: [PATCH 22/71] fix(users): custom rate limit for usage requests --- src/modules/user/user.controller.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index da1b6e4ad..adbf07bf0 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -108,6 +108,8 @@ import { PaymentRequiredException } from '../feature-limit/exceptions/payment-re import { FeatureLimitService } from '../feature-limit/feature-limit.service'; import { KlaviyoTrackingService } from '../../externals/klaviyo/klaviyo-tracking.service'; import { CaptchaGuard } from '../auth/captcha.guard'; +import { CustomEndpointThrottleGuard } from '../../guards/custom-endpoint-throttle.guard'; +import { CustomThrottle } from '../../guards/custom-endpoint-throttle.decorator'; @ApiTags('User') @Controller('users') @@ -1257,6 +1259,10 @@ export class UserController { } } + @UseGuards(CustomEndpointThrottleGuard) + @CustomThrottle({ + short: { ttl: 60, limit: 100 }, + }) @Get('/usage') @ApiBearerAuth() @ApiOperation({ From 58271d49c3fa08b4f6af30dffc1051c88c774c70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Fri, 16 Jan 2026 16:46:24 +0100 Subject: [PATCH 23/71] fix(files): throttle listing requests x min --- src/modules/file/file.controller.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/modules/file/file.controller.ts b/src/modules/file/file.controller.ts index 6c4c10917..3936efa45 100644 --- a/src/modules/file/file.controller.ts +++ b/src/modules/file/file.controller.ts @@ -347,6 +347,10 @@ export class FileController { return result; } + @UseGuards(CustomEndpointThrottleGuard) + @CustomThrottle({ + short: { ttl: 60, limit: 100 }, + }) @Get('/') @ApiOkResponse({ isArray: true, type: FileDto }) async getFiles( From b4561cb0d7b084c1d37dcce7a00de7c45bc53ff6 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Fri, 16 Jan 2026 11:48:28 -0600 Subject: [PATCH 24/71] fix(folders): add custom throttle guard for folder listing requests --- src/modules/folder/folder.controller.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/modules/folder/folder.controller.ts b/src/modules/folder/folder.controller.ts index 8604c4b00..723bc965b 100644 --- a/src/modules/folder/folder.controller.ts +++ b/src/modules/folder/folder.controller.ts @@ -482,6 +482,10 @@ export class FolderController { }; } + @UseGuards(CustomEndpointThrottleGuard) + @CustomThrottle({ + short: { ttl: 60, limit: 100 }, + }) @Get('/') @ApiOkResponse({ isArray: true, type: FolderDto }) async getFolders( From 04f182eeec662ed5889d7b7011ee0a4e9f5a1880 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Mon, 19 Jan 2026 09:33:31 +0100 Subject: [PATCH 25/71] fix(users, folders): set custom throttle to refresh and folder creation --- src/modules/folder/folder.controller.ts | 4 ++++ src/modules/user/user.controller.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/modules/folder/folder.controller.ts b/src/modules/folder/folder.controller.ts index 723bc965b..51b01c9a6 100644 --- a/src/modules/folder/folder.controller.ts +++ b/src/modules/folder/folder.controller.ts @@ -87,6 +87,10 @@ export class FolderController { private readonly storageNotificationService: StorageNotificationService, ) {} + @UseGuards(CustomEndpointThrottleGuard) + @CustomThrottle({ + short: { ttl: 60, limit: 80 } + }) @Post('/') @ApiOperation({ summary: 'Create Folder', diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index adbf07bf0..7dc05203f 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -462,6 +462,10 @@ export class UserController { return userCredentials; } + @UseGuards(CustomEndpointThrottleGuard) + @CustomThrottle({ + short: { ttl: 60, limit: 5 }, + }) @Get('/refresh') @HttpCode(200) @ApiOperation({ summary: 'Refresh session token' }) From 41584815a68e4a37415a0d83506128689295995e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Mon, 19 Jan 2026 09:48:13 +0100 Subject: [PATCH 26/71] fix(folder): increase create folder throttler to 30k/h --- src/modules/folder/folder.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/folder/folder.controller.ts b/src/modules/folder/folder.controller.ts index 51b01c9a6..d1f76164b 100644 --- a/src/modules/folder/folder.controller.ts +++ b/src/modules/folder/folder.controller.ts @@ -89,7 +89,7 @@ export class FolderController { @UseGuards(CustomEndpointThrottleGuard) @CustomThrottle({ - short: { ttl: 60, limit: 80 } + long: { ttl: 3600, limit: 30000 } }) @Post('/') @ApiOperation({ From bc8d864ea9e8fe0c4da83cb79bec8822dff5a65a Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Tue, 16 Dec 2025 12:08:26 +0100 Subject: [PATCH 27/71] feat(migration): add user_id column to file_versions table --- ...1216104346-add-user-id-to-file-versions.js | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 migrations/20251216104346-add-user-id-to-file-versions.js diff --git a/migrations/20251216104346-add-user-id-to-file-versions.js b/migrations/20251216104346-add-user-id-to-file-versions.js new file mode 100644 index 000000000..fc88efc2e --- /dev/null +++ b/migrations/20251216104346-add-user-id-to-file-versions.js @@ -0,0 +1,28 @@ +'use strict'; + +const tableName = 'file_versions'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn(tableName, 'user_id', { + type: Sequelize.INTEGER, + allowNull: true, + references: { + model: 'users', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }); + + await queryInterface.addIndex(tableName, ['user_id', 'status'], { + name: 'file_versions_user_id_status_idx', + }); + }, + + async down(queryInterface) { + await queryInterface.removeIndex(tableName, 'file_versions_user_id_status_idx'); + await queryInterface.removeColumn(tableName, 'user_id'); + }, +}; From cb3ed94b54e6ad61387ba11e95775a7916d07d1d Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Tue, 30 Dec 2025 07:50:50 +0100 Subject: [PATCH 28/71] fix(migration): use user uuid instead of id for file_versions --- migrations/20251216104346-add-user-id-to-file-versions.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/migrations/20251216104346-add-user-id-to-file-versions.js b/migrations/20251216104346-add-user-id-to-file-versions.js index fc88efc2e..b939e53df 100644 --- a/migrations/20251216104346-add-user-id-to-file-versions.js +++ b/migrations/20251216104346-add-user-id-to-file-versions.js @@ -6,11 +6,11 @@ const tableName = 'file_versions'; module.exports = { async up(queryInterface, Sequelize) { await queryInterface.addColumn(tableName, 'user_id', { - type: Sequelize.INTEGER, + type: Sequelize.STRING(36), allowNull: true, references: { model: 'users', - key: 'id', + key: 'uuid', }, onUpdate: 'CASCADE', onDelete: 'CASCADE', From 6919cf0dff8cb3e057d5b87f39c522244eccb2ec Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Fri, 16 Jan 2026 16:03:51 +0100 Subject: [PATCH 29/71] chore(migration): update timestamp for add-user-id-to-file-versions migration --- ...versions.js => 20260116150327-add-user-id-to-file-versions.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename migrations/{20251216104346-add-user-id-to-file-versions.js => 20260116150327-add-user-id-to-file-versions.js} (100%) diff --git a/migrations/20251216104346-add-user-id-to-file-versions.js b/migrations/20260116150327-add-user-id-to-file-versions.js similarity index 100% rename from migrations/20251216104346-add-user-id-to-file-versions.js rename to migrations/20260116150327-add-user-id-to-file-versions.js From 46fd9a59066238faf0295b59dfb1299149fc222f Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Fri, 16 Jan 2026 16:08:32 +0100 Subject: [PATCH 30/71] fix(migration): make user_id NOT NULL in file_versions IMPORTANT: Delete all test records from file_versions table before running this migration: DELETE FROM file_versions WHERE 1=1; --- migrations/20260116150327-add-user-id-to-file-versions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/20260116150327-add-user-id-to-file-versions.js b/migrations/20260116150327-add-user-id-to-file-versions.js index b939e53df..228d1d561 100644 --- a/migrations/20260116150327-add-user-id-to-file-versions.js +++ b/migrations/20260116150327-add-user-id-to-file-versions.js @@ -7,7 +7,7 @@ module.exports = { async up(queryInterface, Sequelize) { await queryInterface.addColumn(tableName, 'user_id', { type: Sequelize.STRING(36), - allowNull: true, + allowNull: false, references: { model: 'users', key: 'uuid', From 6cfcf2f11fe4cfcb7473776af84809c9c2ae8c33 Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Mon, 19 Jan 2026 11:26:37 +0100 Subject: [PATCH 31/71] fix(migration): create file_versions user_id status index concurrently --- ...260116150327-add-user-id-to-file-versions.js | 5 ----- ...28-add-index-file-versions-user-id-status.js | 17 +++++++++++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 migrations/20260116150328-add-index-file-versions-user-id-status.js diff --git a/migrations/20260116150327-add-user-id-to-file-versions.js b/migrations/20260116150327-add-user-id-to-file-versions.js index 228d1d561..8360c74f0 100644 --- a/migrations/20260116150327-add-user-id-to-file-versions.js +++ b/migrations/20260116150327-add-user-id-to-file-versions.js @@ -15,14 +15,9 @@ module.exports = { onUpdate: 'CASCADE', onDelete: 'CASCADE', }); - - await queryInterface.addIndex(tableName, ['user_id', 'status'], { - name: 'file_versions_user_id_status_idx', - }); }, async down(queryInterface) { - await queryInterface.removeIndex(tableName, 'file_versions_user_id_status_idx'); await queryInterface.removeColumn(tableName, 'user_id'); }, }; diff --git a/migrations/20260116150328-add-index-file-versions-user-id-status.js b/migrations/20260116150328-add-index-file-versions-user-id-status.js new file mode 100644 index 000000000..67b2e2fa8 --- /dev/null +++ b/migrations/20260116150328-add-index-file-versions-user-id-status.js @@ -0,0 +1,17 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.query(` + CREATE INDEX CONCURRENTLY file_versions_user_id_status_idx + ON file_versions (user_id, status); + `); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.sequelize.query(` + DROP INDEX CONCURRENTLY file_versions_user_id_status_idx; + `); + } +}; From 288b6481552500e67febe35822561856a4e3f8df Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Mon, 19 Jan 2026 13:43:45 +0100 Subject: [PATCH 32/71] perf(migration): use partial index for file_versions user_id lookup --- .../20260116150327-add-user-id-to-file-versions.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/migrations/20260116150327-add-user-id-to-file-versions.js b/migrations/20260116150327-add-user-id-to-file-versions.js index 8360c74f0..63c56e972 100644 --- a/migrations/20260116150327-add-user-id-to-file-versions.js +++ b/migrations/20260116150327-add-user-id-to-file-versions.js @@ -15,9 +15,18 @@ module.exports = { onUpdate: 'CASCADE', onDelete: 'CASCADE', }); + + await queryInterface.sequelize.query(` + CREATE INDEX CONCURRENTLY file_versions_user_id_exists_idx + ON file_versions(user_id) + WHERE status = 'EXISTS'; + `); }, async down(queryInterface) { + await queryInterface.sequelize.query(` + DROP INDEX CONCURRENTLY IF EXISTS file_versions_user_id_exists_idx; + `); await queryInterface.removeColumn(tableName, 'user_id'); }, }; From b0d90407f27e96c2d373917e41502bd068a67bb9 Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Mon, 19 Jan 2026 13:53:40 +0100 Subject: [PATCH 33/71] perf(migration): use partial index for file_versions user_id lookup --- ...0260116150328-add-index-file-versions-user-id-status.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/migrations/20260116150328-add-index-file-versions-user-id-status.js b/migrations/20260116150328-add-index-file-versions-user-id-status.js index 67b2e2fa8..baf5eacb2 100644 --- a/migrations/20260116150328-add-index-file-versions-user-id-status.js +++ b/migrations/20260116150328-add-index-file-versions-user-id-status.js @@ -4,14 +4,15 @@ module.exports = { async up(queryInterface, Sequelize) { await queryInterface.sequelize.query(` - CREATE INDEX CONCURRENTLY file_versions_user_id_status_idx - ON file_versions (user_id, status); + CREATE INDEX CONCURRENTLY file_versions_user_id_exists_idx + ON file_versions (user_id) + WHERE status = 'EXISTS'; `); }, async down(queryInterface, Sequelize) { await queryInterface.sequelize.query(` - DROP INDEX CONCURRENTLY file_versions_user_id_status_idx; + DROP INDEX CONCURRENTLY IF EXISTS file_versions_user_id_exists_idx; `); } }; From e6ad566de45cbdc4c3c4125ad82d54adefb4ab0d Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Mon, 19 Jan 2026 13:55:57 +0100 Subject: [PATCH 34/71] fix(migration): remove duplicate index creation from user_id migration --- .../20260116150327-add-user-id-to-file-versions.js | 9 --------- 1 file changed, 9 deletions(-) diff --git a/migrations/20260116150327-add-user-id-to-file-versions.js b/migrations/20260116150327-add-user-id-to-file-versions.js index 63c56e972..8360c74f0 100644 --- a/migrations/20260116150327-add-user-id-to-file-versions.js +++ b/migrations/20260116150327-add-user-id-to-file-versions.js @@ -15,18 +15,9 @@ module.exports = { onUpdate: 'CASCADE', onDelete: 'CASCADE', }); - - await queryInterface.sequelize.query(` - CREATE INDEX CONCURRENTLY file_versions_user_id_exists_idx - ON file_versions(user_id) - WHERE status = 'EXISTS'; - `); }, async down(queryInterface) { - await queryInterface.sequelize.query(` - DROP INDEX CONCURRENTLY IF EXISTS file_versions_user_id_exists_idx; - `); await queryInterface.removeColumn(tableName, 'user_id'); }, }; From ac95601d817980302778c48c4647f95473a2b1ab Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Mon, 19 Jan 2026 16:09:13 +0100 Subject: [PATCH 35/71] feat(usage): include file versions in storage calculation --- src/modules/file/file-version.domain.spec.ts | 2 + src/modules/file/file-version.domain.ts | 4 + src/modules/file/file-version.model.ts | 8 ++ .../file/file-version.repository.spec.ts | 70 ++++++++++++++ src/modules/file/file-version.repository.ts | 17 ++++ src/modules/file/file.usecase.spec.ts | 92 +++++++++++++++++-- src/modules/file/file.usecase.ts | 8 +- test/fixtures.ts | 1 + 8 files changed, 193 insertions(+), 9 deletions(-) diff --git a/src/modules/file/file-version.domain.spec.ts b/src/modules/file/file-version.domain.spec.ts index fe4bee07a..423e2dc02 100644 --- a/src/modules/file/file-version.domain.spec.ts +++ b/src/modules/file/file-version.domain.spec.ts @@ -8,6 +8,7 @@ describe('FileVersion Domain', () => { const mockAttributes: FileVersionAttributes = { id: 'version-id-123', fileId: 'file-id-456', + userId: 'user-uuid-789', networkFileId: 'network-file-id-789', size: BigInt(1024), status: FileVersionStatus.EXISTS, @@ -63,6 +64,7 @@ describe('FileVersion Domain', () => { expect(json).toEqual({ id: mockAttributes.id, fileId: mockAttributes.fileId, + userId: mockAttributes.userId, networkFileId: mockAttributes.networkFileId, size: mockAttributes.size, status: mockAttributes.status, diff --git a/src/modules/file/file-version.domain.ts b/src/modules/file/file-version.domain.ts index 05b36bde2..e396d6539 100644 --- a/src/modules/file/file-version.domain.ts +++ b/src/modules/file/file-version.domain.ts @@ -6,6 +6,7 @@ export enum FileVersionStatus { export interface FileVersionAttributes { id: string; fileId: string; + userId: string; networkFileId: string; size: bigint; status: FileVersionStatus; @@ -16,6 +17,7 @@ export interface FileVersionAttributes { export class FileVersion implements FileVersionAttributes { id: string; fileId: string; + userId: string; networkFileId: string; size: bigint; status: FileVersionStatus; @@ -25,6 +27,7 @@ export class FileVersion implements FileVersionAttributes { private constructor(attributes: FileVersionAttributes) { this.id = attributes.id; this.fileId = attributes.fileId; + this.userId = attributes.userId; this.networkFileId = attributes.networkFileId; this.size = attributes.size; this.status = attributes.status; @@ -48,6 +51,7 @@ export class FileVersion implements FileVersionAttributes { return { id: this.id, fileId: this.fileId, + userId: this.userId, networkFileId: this.networkFileId, size: this.size, status: this.status, diff --git a/src/modules/file/file-version.model.ts b/src/modules/file/file-version.model.ts index 9aa400481..691b66c3f 100644 --- a/src/modules/file/file-version.model.ts +++ b/src/modules/file/file-version.model.ts @@ -9,6 +9,7 @@ import { Table, } from 'sequelize-typescript'; import { FileModel } from './file.model'; +import { UserModel } from '../user/user.model'; import { FileVersionAttributes, FileVersionStatus, @@ -33,6 +34,13 @@ export class FileVersionModel extends Model implements FileVersionAttributes { @BelongsTo(() => FileModel, 'fileId') file: FileModel; + @ForeignKey(() => UserModel) + @Column(DataType.STRING(36)) + userId: string; + + @BelongsTo(() => UserModel, 'userId') + user: UserModel; + @Column(DataType.STRING) networkFileId: string; diff --git a/src/modules/file/file-version.repository.spec.ts b/src/modules/file/file-version.repository.spec.ts index 6bbee7f4a..5566d1179 100644 --- a/src/modules/file/file-version.repository.spec.ts +++ b/src/modules/file/file-version.repository.spec.ts @@ -29,6 +29,7 @@ describe('SequelizeFileVersionRepository', () => { const result = await repository.create({ fileId: version.fileId, + userId: version.userId, networkFileId: version.networkFileId, size: version.size, status: version.status, @@ -37,6 +38,7 @@ describe('SequelizeFileVersionRepository', () => { expect(result).toBeInstanceOf(FileVersion); expect(fileVersionModel.create).toHaveBeenCalledWith({ fileId: version.fileId, + userId: version.userId, networkFileId: version.networkFileId, size: version.size, status: version.status, @@ -53,6 +55,7 @@ describe('SequelizeFileVersionRepository', () => { await repository.create({ fileId: version.fileId, + userId: version.userId, networkFileId: version.networkFileId, size: version.size, } as any); @@ -76,6 +79,7 @@ describe('SequelizeFileVersionRepository', () => { const result = await repository.create({ fileId: version.fileId, + userId: version.userId, networkFileId: version.networkFileId, size: version.size, status: version.status, @@ -95,6 +99,7 @@ describe('SequelizeFileVersionRepository', () => { const result = await repository.create({ fileId: version.fileId, + userId: version.userId, networkFileId: version.networkFileId, size: version.size, status: version.status, @@ -208,6 +213,7 @@ describe('SequelizeFileVersionRepository', () => { const result = await repository.upsert({ fileId: version.fileId, + userId: version.userId, networkFileId: version.networkFileId, size: version.size, status: version.status, @@ -217,6 +223,7 @@ describe('SequelizeFileVersionRepository', () => { expect(fileVersionModel.upsert).toHaveBeenCalledWith( expect.objectContaining({ fileId: version.fileId, + userId: version.userId, networkFileId: version.networkFileId, size: version.size, status: version.status, @@ -403,4 +410,67 @@ describe('SequelizeFileVersionRepository', () => { ); }); }); + + describe('sumExistingSizesByUser', () => { + it('When user has versions, then it returns the sum', async () => { + const userId = 'user-uuid-123'; + const totalSize = 5000; + + jest + .spyOn(fileVersionModel, 'findAll') + .mockResolvedValue([{ total: totalSize }] as any); + + const result = await repository.sumExistingSizesByUser(userId); + + expect(result).toBe(totalSize); + expect(fileVersionModel.findAll).toHaveBeenCalledWith({ + attributes: expect.any(Array), + where: { + userId, + status: FileVersionStatus.EXISTS, + }, + raw: true, + }); + }); + + it('When user has no versions, then it returns 0', async () => { + const userId = 'user-uuid-456'; + + jest + .spyOn(fileVersionModel, 'findAll') + .mockResolvedValue([{ total: null }] as any); + + const result = await repository.sumExistingSizesByUser(userId); + + expect(result).toBe(0); + }); + + it('When query returns empty array, then it returns 0', async () => { + const userId = 'user-uuid-789'; + + jest.spyOn(fileVersionModel, 'findAll').mockResolvedValue([] as any); + + const result = await repository.sumExistingSizesByUser(userId); + + expect(result).toBe(0); + }); + + it('When summing sizes, then it only counts EXISTS status versions', async () => { + const userId = 'user-uuid-abc'; + + jest + .spyOn(fileVersionModel, 'findAll') + .mockResolvedValue([{ total: 1000 }] as any); + + await repository.sumExistingSizesByUser(userId); + + expect(fileVersionModel.findAll).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: FileVersionStatus.EXISTS, + }), + }), + ); + }); + }); }); diff --git a/src/modules/file/file-version.repository.ts b/src/modules/file/file-version.repository.ts index 02e4a4d6e..1fa44a4ce 100644 --- a/src/modules/file/file-version.repository.ts +++ b/src/modules/file/file-version.repository.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/sequelize'; +import { Sequelize } from 'sequelize'; import { FileVersionModel } from './file-version.model'; import { FileVersion, @@ -20,6 +21,7 @@ export interface FileVersionRepository { updateStatus(id: string, status: FileVersionStatus): Promise; updateStatusBatch(ids: string[], status: FileVersionStatus): Promise; deleteAllByFileId(fileId: string): Promise; + sumExistingSizesByUser(userId: string): Promise; } @Injectable() @@ -32,6 +34,7 @@ export class SequelizeFileVersionRepository implements FileVersionRepository { async create(version: CreateFileVersionData): Promise { const createdVersion = await this.model.create({ fileId: version.fileId, + userId: version.userId, networkFileId: version.networkFileId, size: version.size, status: version.status || FileVersionStatus.EXISTS, @@ -44,6 +47,7 @@ export class SequelizeFileVersionRepository implements FileVersionRepository { const [instance] = await this.model.upsert( { fileId: version.fileId, + userId: version.userId, networkFileId: version.networkFileId, size: version.size, status: version.status || FileVersionStatus.EXISTS, @@ -108,4 +112,17 @@ export class SequelizeFileVersionRepository implements FileVersionRepository { }, ); } + + async sumExistingSizesByUser(userId: string): Promise { + const result = await this.model.findAll({ + attributes: [[Sequelize.fn('SUM', Sequelize.col('size')), 'total']], + where: { + userId, + status: FileVersionStatus.EXISTS, + }, + raw: true, + }); + + return Number(result[0]?.['total']) || 0; + } } diff --git a/src/modules/file/file.usecase.spec.ts b/src/modules/file/file.usecase.spec.ts index 791a052f4..4aa22fca4 100644 --- a/src/modules/file/file.usecase.spec.ts +++ b/src/modules/file/file.usecase.spec.ts @@ -1637,31 +1637,101 @@ describe('FileUseCases', () => { }); describe('getUserUsedStorage', () => { - it('When called, it should return the user total used space', async () => { - const totalUsage = 1000; + it('When called, it should return the sum of files and versions usage', async () => { + const filesUsage = 1000; + const versionsUsage = 500; + const expectedTotal = filesUsage + versionsUsage; + + jest + .spyOn(service, 'getUserUsedStorageIncrementally') + .mockResolvedValueOnce(filesUsage); + jest + .spyOn(fileVersionRepository, 'sumExistingSizesByUser') + .mockResolvedValueOnce(versionsUsage); + + const result = await service.getUserUsedStorage(userMocked); + + expect(result).toEqual(expectedTotal); + expect(service.getUserUsedStorageIncrementally).toHaveBeenCalledWith( + userMocked, + ); + expect(fileVersionRepository.sumExistingSizesByUser).toHaveBeenCalledWith( + userMocked.uuid, + ); + }); + + it('When user has only files usage, then it returns files usage', async () => { + const filesUsage = 1000; + const versionsUsage = 0; + + jest + .spyOn(service, 'getUserUsedStorageIncrementally') + .mockResolvedValueOnce(filesUsage); + jest + .spyOn(fileVersionRepository, 'sumExistingSizesByUser') + .mockResolvedValueOnce(versionsUsage); + + const result = await service.getUserUsedStorage(userMocked); + + expect(result).toEqual(filesUsage); + }); + + it('When user has only versions usage, then it returns versions usage', async () => { + const filesUsage = 0; + const versionsUsage = 500; + jest .spyOn(service, 'getUserUsedStorageIncrementally') - .mockResolvedValueOnce(totalUsage); + .mockResolvedValueOnce(filesUsage); + jest + .spyOn(fileVersionRepository, 'sumExistingSizesByUser') + .mockResolvedValueOnce(versionsUsage); const result = await service.getUserUsedStorage(userMocked); - expect(result).toEqual(totalUsage); + + expect(result).toEqual(versionsUsage); }); - it('When getUserUsedStorageIncrementally returns null, it should return 0', async () => { + it('When getUserUsedStorageIncrementally returns null, it should treat as 0', async () => { + const versionsUsage = 500; + jest .spyOn(service, 'getUserUsedStorageIncrementally') .mockResolvedValueOnce(null); + jest + .spyOn(fileVersionRepository, 'sumExistingSizesByUser') + .mockResolvedValueOnce(versionsUsage); const result = await service.getUserUsedStorage(userMocked); - expect(result).toEqual(0); + + expect(result).toEqual(versionsUsage); }); - it('When getUserUsedStorageIncrementally returns undefined, it should return 0', async () => { + it('When getUserUsedStorageIncrementally returns undefined, it should treat as 0', async () => { + const versionsUsage = 500; + jest .spyOn(service, 'getUserUsedStorageIncrementally') .mockResolvedValueOnce(undefined); + jest + .spyOn(fileVersionRepository, 'sumExistingSizesByUser') + .mockResolvedValueOnce(versionsUsage); const result = await service.getUserUsedStorage(userMocked); + + expect(result).toEqual(versionsUsage); + }); + + it('When both return null/undefined, it should return 0', async () => { + jest + .spyOn(service, 'getUserUsedStorageIncrementally') + .mockResolvedValueOnce(null); + jest + .spyOn(fileVersionRepository, 'sumExistingSizesByUser') + .mockResolvedValueOnce(0); + + const result = await service.getUserUsedStorage(userMocked); + expect(result).toEqual(0); }); }); @@ -1874,6 +1944,7 @@ describe('FileUseCases', () => { FileVersion.build({ id: v4(), fileId: mockFile.uuid, + userId: v4(), networkFileId: 'network-1', size: BigInt(100), status: FileVersionStatus.EXISTS, @@ -1925,6 +1996,7 @@ describe('FileUseCases', () => { const mockVersion = FileVersion.build({ id: versionId, fileId: mockFile.uuid, + userId: v4(), networkFileId: 'network-id', size: BigInt(100), status: FileVersionStatus.EXISTS, @@ -1969,6 +2041,7 @@ describe('FileUseCases', () => { const mockVersion = FileVersion.build({ id: v4(), fileId: 'different-file-uuid', + userId: v4(), networkFileId: 'network-id', size: BigInt(100), status: FileVersionStatus.EXISTS, @@ -1992,6 +2065,7 @@ describe('FileUseCases', () => { const mockVersion = FileVersion.build({ id: versionId, fileId: mockFile.uuid, + userId: v4(), networkFileId: 'old-network-id', size: BigInt(100), status: FileVersionStatus.EXISTS, @@ -2021,6 +2095,7 @@ describe('FileUseCases', () => { const mockVersion = FileVersion.build({ id: versionId, fileId: mockFile.uuid, + userId: v4(), networkFileId: 'old-network-id', size: BigInt(100), status: FileVersionStatus.EXISTS, @@ -2083,6 +2158,7 @@ describe('FileUseCases', () => { const mockVersion = FileVersion.build({ id: v4(), fileId: 'different-file-uuid', + userId: v4(), networkFileId: 'network-id', size: BigInt(100), status: FileVersionStatus.EXISTS, @@ -2105,6 +2181,7 @@ describe('FileUseCases', () => { const mockVersion = FileVersion.build({ id: v4(), fileId: mockFile.uuid, + userId: v4(), networkFileId: 'network-id', size: BigInt(100), status: FileVersionStatus.DELETED, @@ -2259,6 +2336,7 @@ describe('FileUseCases', () => { ); expect(upsertSpy).toHaveBeenCalledWith({ fileId: mockFile.uuid, + userId: userMocked.uuid, networkFileId: mockFile.fileId, size: mockFile.size, status: 'EXISTS', diff --git a/src/modules/file/file.usecase.ts b/src/modules/file/file.usecase.ts index 849c8f67a..6f4db6d51 100644 --- a/src/modules/file/file.usecase.ts +++ b/src/modules/file/file.usecase.ts @@ -107,9 +107,12 @@ export class FileUseCases { } async getUserUsedStorage(user: User): Promise { - const usageCalculation = await this.getUserUsedStorageIncrementally(user); + const [filesUsage, versionsUsage] = await Promise.all([ + this.getUserUsedStorageIncrementally(user), + this.fileVersionRepository.sumExistingSizesByUser(user.uuid), + ]); - return usageCalculation || 0; + return (filesUsage || 0) + versionsUsage; } async getUserUsedStorageIncrementally(user: User): Promise { @@ -900,6 +903,7 @@ export class FileUseCases { await Promise.all([ this.fileVersionRepository.upsert({ fileId: file.uuid, + userId: user.uuid, networkFileId: file.fileId, size: file.size, status: FileVersionStatus.EXISTS, diff --git a/test/fixtures.ts b/test/fixtures.ts index 3c8bb042f..544da6006 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -762,6 +762,7 @@ export const newFileVersion = (params?: { const fileVersion = FileVersion.build({ id: v4(), fileId: v4(), + userId: v4(), networkFileId: randomDataGenerator.hash({ length: constants.BUCKET_ID_LENGTH, }), From f9714a6a1dd9792b1811148a7d1bc22bd97c23d0 Mon Sep 17 00:00:00 2001 From: tamarafinogina Date: Mon, 12 Jan 2026 11:47:41 +0100 Subject: [PATCH 36/71] remove encryptedName from updateFileMetaData --- src/modules/file/file.usecase.spec.ts | 2 -- src/modules/file/file.usecase.ts | 5 +---- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/modules/file/file.usecase.spec.ts b/src/modules/file/file.usecase.spec.ts index 4aa22fca4..f18c37e39 100644 --- a/src/modules/file/file.usecase.spec.ts +++ b/src/modules/file/file.usecase.spec.ts @@ -1343,7 +1343,6 @@ describe('FileUseCases', () => { attributes: { ...mockFile, plainName: newFileMeta.plainName, - name: encryptedName, }, }); @@ -1376,7 +1375,6 @@ describe('FileUseCases', () => { userMocked.id, expect.objectContaining({ plainName: newFileMeta.plainName, - name: encryptedName, }), ); const { diff --git a/src/modules/file/file.usecase.ts b/src/modules/file/file.usecase.ts index 6f4db6d51..992881d80 100644 --- a/src/modules/file/file.usecase.ts +++ b/src/modules/file/file.usecase.ts @@ -509,14 +509,11 @@ export class FileUseCases { newFileMetadata.plainName ?? file.plainName ?? this.cryptoService.decryptName(file.name, file.folderId); - const cryptoFileName = newFileMetadata.plainName - ? this.cryptoService.encryptName(newFileMetadata.plainName, file.folderId) - : file.name; + const type = newFileMetadata.type ?? file.type; const updatedFile = File.build({ ...file, - name: cryptoFileName, plainName, type, }); From 1bde81ceb12973ee92fa4eed54a65f74803b037b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= <34506328+sg-gs@users.noreply.github.com> Date: Tue, 20 Jan 2026 13:27:46 +0100 Subject: [PATCH 37/71] Revert "[PB-5673] Remove encryptedName from createFile" --- src/modules/file/file.domain.ts | 2 +- src/modules/file/file.repository.spec.ts | 2 +- src/modules/file/file.usecase.ts | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/modules/file/file.domain.ts b/src/modules/file/file.domain.ts index d83617c0d..5a4246e3a 100644 --- a/src/modules/file/file.domain.ts +++ b/src/modules/file/file.domain.ts @@ -20,7 +20,7 @@ export interface FileAttributes { id: number; uuid: string; fileId: string; - name?: string; + name: string; type: string; size: bigint; bucket: string; diff --git a/src/modules/file/file.repository.spec.ts b/src/modules/file/file.repository.spec.ts index a4ef2a71b..6a05470cf 100644 --- a/src/modules/file/file.repository.spec.ts +++ b/src/modules/file/file.repository.spec.ts @@ -604,7 +604,7 @@ describe('FileRepository', () => { }); it('When creation fails then it should return null', async () => { - const fileData = { plainName: v4() } as any; + const fileData = { name: v4() } as any; jest.spyOn(fileModel, 'create').mockResolvedValue(null); const result = await repository.create(fileData); diff --git a/src/modules/file/file.usecase.ts b/src/modules/file/file.usecase.ts index 992881d80..876c2aade 100644 --- a/src/modules/file/file.usecase.ts +++ b/src/modules/file/file.usecase.ts @@ -387,6 +387,11 @@ export class FileUseCases { throw new ForbiddenException('Folder is not yours'); } + const cryptoFileName = this.cryptoService.encryptName( + newFileDto.plainName, + folder.id, + ); + const exists = await this.fileRepository.findByPlainNameAndFolderId( user.id, newFileDto.plainName, @@ -408,6 +413,7 @@ export class FileUseCases { const newFile = await this.fileRepository.create({ uuid: v4(), + name: cryptoFileName, plainName: newFileDto.plainName, type: newFileDto.type, size: newFileDto.size, From 4f8205efdab22df2edb942517e69c5b08ce01b75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Z=C3=BAniga?= <125698953+jzunigax2@users.noreply.github.com> Date: Tue, 20 Jan 2026 11:06:50 -0300 Subject: [PATCH 38/71] Revert "[PB-5673] Remove encryptedName from updateFileMetaData" --- src/modules/file/file.usecase.spec.ts | 2 ++ src/modules/file/file.usecase.ts | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/modules/file/file.usecase.spec.ts b/src/modules/file/file.usecase.spec.ts index f18c37e39..4aa22fca4 100644 --- a/src/modules/file/file.usecase.spec.ts +++ b/src/modules/file/file.usecase.spec.ts @@ -1343,6 +1343,7 @@ describe('FileUseCases', () => { attributes: { ...mockFile, plainName: newFileMeta.plainName, + name: encryptedName, }, }); @@ -1375,6 +1376,7 @@ describe('FileUseCases', () => { userMocked.id, expect.objectContaining({ plainName: newFileMeta.plainName, + name: encryptedName, }), ); const { diff --git a/src/modules/file/file.usecase.ts b/src/modules/file/file.usecase.ts index 876c2aade..ad7645be3 100644 --- a/src/modules/file/file.usecase.ts +++ b/src/modules/file/file.usecase.ts @@ -515,11 +515,14 @@ export class FileUseCases { newFileMetadata.plainName ?? file.plainName ?? this.cryptoService.decryptName(file.name, file.folderId); - + const cryptoFileName = newFileMetadata.plainName + ? this.cryptoService.encryptName(newFileMetadata.plainName, file.folderId) + : file.name; const type = newFileMetadata.type ?? file.type; const updatedFile = File.build({ ...file, + name: cryptoFileName, plainName, type, }); From 6d0a53a61e48b7fa22c485964863b05da27f7872 Mon Sep 17 00:00:00 2001 From: Tamara Finogina Date: Tue, 20 Jan 2026 15:35:06 +0100 Subject: [PATCH 39/71] Revert "[PB-5673] Remove encrypted name from findByNameAndParentUuid" --- src/modules/folder/folder.repository.ts | 7 ++- src/modules/folder/folder.usecase.spec.ts | 73 ++++------------------- src/modules/folder/folder.usecase.ts | 18 +++++- 3 files changed, 34 insertions(+), 64 deletions(-) diff --git a/src/modules/folder/folder.repository.ts b/src/modules/folder/folder.repository.ts index 9152b8b01..6faf158c3 100644 --- a/src/modules/folder/folder.repository.ts +++ b/src/modules/folder/folder.repository.ts @@ -74,6 +74,7 @@ export interface FolderRepository { }, ): Promise; findByNameAndParentUuid( + name: FolderAttributes['name'], plainName: FolderAttributes['plainName'], parentUuid: FolderAttributes['parentUuid'], deleted: FolderAttributes['deleted'], @@ -394,13 +395,17 @@ export class SequelizeFolderRepository implements FolderRepository { } async findByNameAndParentUuid( + name: FolderAttributes['name'], plainName: FolderAttributes['plainName'], parentUuid: FolderAttributes['parentUuid'], deleted: FolderAttributes['deleted'], ): Promise { const folder = await this.folderModel.findOne({ where: { - plainName: { [Op.eq]: plainName }, + [Op.or]: [ + { name: { [Op.eq]: name } }, + { plainName: { [Op.eq]: plainName } }, + ], parentUuid: { [Op.eq]: parentUuid }, deleted: { [Op.eq]: deleted }, }, diff --git a/src/modules/folder/folder.usecase.spec.ts b/src/modules/folder/folder.usecase.spec.ts index 7a7610a20..aa8b40715 100644 --- a/src/modules/folder/folder.usecase.spec.ts +++ b/src/modules/folder/folder.usecase.spec.ts @@ -527,10 +527,11 @@ describe('FolderUseCases', () => { attributes: { userId: userMocked.id }, }); - it('When folder without plainName is moved, then the folder is returned with its updated properties', async () => { + it('When folder is moved, then the folder is returned with its updated properties', async () => { const expectedFolder = newFolder({ attributes: { ...folder, + name: 'newencrypted-' + folder.name, parentUuid: destinationFolder.uuid, parentId: destinationFolder.parentId, }, @@ -551,6 +552,10 @@ describe('FolderUseCases', () => { .spyOn(cryptoService, 'decryptName') .mockReturnValueOnce(folder.plainName); + jest + .spyOn(cryptoService, 'encryptName') + .mockReturnValueOnce(expectedFolder.name); + jest .spyOn(folderRepository, 'findByNameAndParentUuid') .mockResolvedValueOnce(null); @@ -570,65 +575,7 @@ describe('FolderUseCases', () => { { parentId: destinationFolder.id, parentUuid: destinationFolder.uuid, - plainName: expectedFolder.plainName, - deleted: false, - deletedAt: null, - }, - ); - }); - - it('When folder with plainName is moved, then the folder is returned with its updated properties', async () => { - const plainName = 'Folder Plain Name'; - const folderWithPlainName = newFolder({ - attributes: { userId: userMocked.id, plainName }, - }); - const expectedFolder = newFolder({ - attributes: { - ...folderWithPlainName, - parentUuid: destinationFolder.uuid, - parentId: destinationFolder.parentId, - plainName, - }, - }); - const mockParentFolder = newFolder({ - attributes: { userId: userMocked.id, removed: false }, - }); - - jest - .spyOn(folderRepository, 'findOne') - .mockResolvedValueOnce(folderWithPlainName); - jest - .spyOn(folderRepository, 'findOne') - .mockResolvedValueOnce(mockParentFolder); - jest - .spyOn(service, 'getFolderByUuid') - .mockResolvedValueOnce(destinationFolder); - jest.spyOn(cryptoService, 'decryptName'); - - jest - .spyOn(folderRepository, 'findByNameAndParentUuid') - .mockResolvedValueOnce(null); - - jest - .spyOn(folderRepository, 'updateByFolderId') - .mockResolvedValueOnce(expectedFolder); - - const result = await service.moveFolder( - userMocked, - folderWithPlainName.uuid, - { - destinationFolder: destinationFolder.uuid, - }, - ); - - expect(result).toEqual(expectedFolder); - expect(cryptoService.decryptName).not.toHaveBeenCalled(); - expect(folderRepository.updateByFolderId).toHaveBeenCalledTimes(1); - expect(folderRepository.updateByFolderId).toHaveBeenCalledWith( - folderWithPlainName.id, - { - parentId: destinationFolder.id, - parentUuid: destinationFolder.uuid, + name: expectedFolder.name, plainName: expectedFolder.plainName, deleted: false, deletedAt: null, @@ -813,6 +760,7 @@ describe('FolderUseCases', () => { const expectedFolder = newFolder({ attributes: { ...folder, + name: 'newencrypted-' + newName, plainName: newName, parentUuid: destinationFolder.uuid, parentId: destinationFolder.id, @@ -830,6 +778,10 @@ describe('FolderUseCases', () => { .spyOn(service, 'getFolderByUuid') .mockResolvedValueOnce(destinationFolder); + jest + .spyOn(cryptoService, 'encryptName') + .mockReturnValueOnce(expectedFolder.name); + jest .spyOn(folderRepository, 'findByNameAndParentUuid') .mockResolvedValueOnce(null); @@ -850,6 +802,7 @@ describe('FolderUseCases', () => { { parentId: destinationFolder.id, parentUuid: destinationFolder.uuid, + name: expectedFolder.name, plainName: newName, deleted: false, deletedAt: null, diff --git a/src/modules/folder/folder.usecase.ts b/src/modules/folder/folder.usecase.ts index 0081964d4..7156f2240 100644 --- a/src/modules/folder/folder.usecase.ts +++ b/src/modules/folder/folder.usecase.ts @@ -882,11 +882,15 @@ export class FolderUseCases { } const plainName = - newName ?? - folder.plainName ?? - this.cryptoService.decryptName(folder.name, folder.parentId); + newName ?? this.cryptoService.decryptName(folder.name, folder.parentId); + + const nameEncryptedWithDestination = this.cryptoService.encryptName( + plainName, + destinationFolder.id, + ); const exists = await this.folderRepository.findByNameAndParentUuid( + nameEncryptedWithDestination, plainName, destinationFolder.uuid, false, @@ -906,6 +910,7 @@ export class FolderUseCases { const updateData: Partial = { parentId: destinationFolder.id, parentUuid: destinationFolder.uuid, + name: nameEncryptedWithDestination, plainName, deleted: false, deletedAt: null, @@ -933,7 +938,13 @@ export class FolderUseCases { throw new BadRequestException('Invalid folder name'); } + const newEncryptedName = this.cryptoService.encryptName( + newName, + folder.parentId, + ); + const exists = await this.folderRepository.findByNameAndParentUuid( + newEncryptedName, newName, folder.parentUuid, false, @@ -946,6 +957,7 @@ export class FolderUseCases { } return await this.folderRepository.updateByFolderId(folder.id, { + name: newEncryptedName, plainName: newName, }); } From af5b1752d446f7c8d2d30c1e719dd9d466803ae5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Tue, 20 Jan 2026 16:11:14 +0100 Subject: [PATCH 40/71] fix: reduce usage limit x min and folders listing x min --- src/modules/folder/folder.controller.ts | 2 +- src/modules/user/user.controller.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/folder/folder.controller.ts b/src/modules/folder/folder.controller.ts index d1f76164b..bb5733da7 100644 --- a/src/modules/folder/folder.controller.ts +++ b/src/modules/folder/folder.controller.ts @@ -488,7 +488,7 @@ export class FolderController { @UseGuards(CustomEndpointThrottleGuard) @CustomThrottle({ - short: { ttl: 60, limit: 100 }, + short: { ttl: 60, limit: 60 }, }) @Get('/') @ApiOkResponse({ isArray: true, type: FolderDto }) diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index 7dc05203f..cd51d0fd4 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -1265,7 +1265,7 @@ export class UserController { @UseGuards(CustomEndpointThrottleGuard) @CustomThrottle({ - short: { ttl: 60, limit: 100 }, + short: { ttl: 60, limit: 60 }, }) @Get('/usage') @ApiBearerAuth() From de859fbf9e672202d2d3d69d69d636ce51887961 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Tue, 20 Jan 2026 14:05:21 -0600 Subject: [PATCH 41/71] fix: update folder mapping to include plain_name, temporary hotfix for compatibility --- src/modules/backups/backup.usecase.spec.ts | 9 ++++++++- src/modules/backups/backup.usecase.ts | 15 ++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/modules/backups/backup.usecase.spec.ts b/src/modules/backups/backup.usecase.spec.ts index 919fe48de..bf84686b6 100644 --- a/src/modules/backups/backup.usecase.spec.ts +++ b/src/modules/backups/backup.usecase.spec.ts @@ -119,6 +119,7 @@ describe('BackupUseCase', () => { it('When backups are activated, then it should return all devices as folders', async () => { const mockFolder = newFolder(); + jest .spyOn(folderUseCases, 'getFoldersByUserId') .mockResolvedValue([mockFolder]); @@ -128,8 +129,14 @@ describe('BackupUseCase', () => { const result = await backupUseCase.getDevicesAsFolder(userMocked); + const mockFolderResponse = { + //TODO: temporary hotfix remove after mac newer version is released + ...newBackupFolder(mockFolder), + plain_name: mockFolder.plainName, + }; + result.forEach((folder) => { - expect(folder).toEqual({ ...newBackupFolder(mockFolder) }); + expect(folder).toEqual(mockFolderResponse); }); }); }); diff --git a/src/modules/backups/backup.usecase.ts b/src/modules/backups/backup.usecase.ts index a2287acb8..2e69877da 100644 --- a/src/modules/backups/backup.usecase.ts +++ b/src/modules/backups/backup.usecase.ts @@ -142,12 +142,17 @@ export class BackupUseCase { }); return Promise.all( - folders.map(async (folder) => ({ - ...(await this.addFolderAsDeviceProperties(user, folder)), - plainName: + folders.map(async (folder) => { + const plainName = folder.plainName ?? - this.cryptoService.decryptName(folder.name, folder.bucket), - })), + this.cryptoService.decryptName(folder.name, folder.bucket); + + return { + ...(await this.addFolderAsDeviceProperties(user, folder)), + plainName, + plain_name: plainName, //TODO: temporary hotfix remove after mac newer version is released + }; + }), ); } From 0e93da2cb5f61039e379beeee09ca4067571b7c8 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Tue, 20 Jan 2026 21:14:34 -0600 Subject: [PATCH 42/71] feat: add migration to update CLI access limits --- ...20260120204639-update-cli-access-limits.js | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 migrations/20260120204639-update-cli-access-limits.js diff --git a/migrations/20260120204639-update-cli-access-limits.js b/migrations/20260120204639-update-cli-access-limits.js new file mode 100644 index 000000000..62452f0b7 --- /dev/null +++ b/migrations/20260120204639-update-cli-access-limits.js @@ -0,0 +1,140 @@ +'use strict'; + +const CLI_LIMIT_LABEL = 'cli-access'; + +const CLI_DISABLED_TIER_LABELS = [ + // Legacy tiers + '200gb_individual', + '2tb_individual', + '5tb_individual', + '10tb_individual', + // Current tiers (except ultimate_individual and pro_business) + 'essential_individual', + 'premium_individual', + 'standard_business', + 'essential_lifetime_individual', + 'premium_lifetime_individual', + 'ultimate_lifetime_individual', +]; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const [limits] = await queryInterface.sequelize.query( + `SELECT id, value FROM limits WHERE label = :limitLabel`, + { replacements: { limitLabel: CLI_LIMIT_LABEL }, transaction }, + ); + + if (limits.length === 0) { + throw new Error('CLI access limits not found in database'); + } + + const disabledLimitId = limits.find((l) => l.value === 'false')?.id; + const enabledLimitId = limits.find((l) => l.value === 'true')?.id; + + if (!disabledLimitId || !enabledLimitId) { + throw new Error('CLI access enabled or disabled limit not found'); + } + + const [tiersToDisable] = await queryInterface.sequelize.query( + `SELECT id, label FROM tiers WHERE label IN (:labels)`, + { + replacements: { labels: CLI_DISABLED_TIER_LABELS }, + transaction, + }, + ); + + if (tiersToDisable.length === 0) { + console.log( + 'No tiers found to restrict, skipping CLI access restriction', + ); + await transaction.commit(); + return; + } + + const tierIds = tiersToDisable.map((t) => t.id); + + console.log( + `Restricting CLI access for ${tiersToDisable.length} tiers: ${tiersToDisable.map((t) => t.label).join(', ')}`, + ); + + await queryInterface.sequelize.query( + `UPDATE tiers_limits + SET limit_id = :disabledLimitId, updated_at = NOW() + WHERE tier_id IN (:tierIds) + AND limit_id = :enabledLimitId`, + { + replacements: { + disabledLimitId, + enabledLimitId, + tierIds, + }, + transaction, + }, + ); + + await transaction.commit(); + + console.log('Successfully restricted CLI access for specified tiers'); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const [limits] = await queryInterface.sequelize.query( + `SELECT id, value FROM limits WHERE label = :limitLabel`, + { replacements: { limitLabel: CLI_LIMIT_LABEL }, transaction }, + ); + + const disabledLimitId = limits.find((l) => l.value === 'false')?.id; + const enabledLimitId = limits.find((l) => l.value === 'true')?.id; + + if (!disabledLimitId || !enabledLimitId) { + throw new Error('CLI access limits not found'); + } + + const [tiers] = await queryInterface.sequelize.query( + `SELECT id FROM tiers WHERE label IN (:labels)`, + { + replacements: { labels: CLI_DISABLED_TIER_LABELS }, + transaction, + }, + ); + + if (tiers.length === 0) { + await transaction.commit(); + return; + } + + const tierIds = tiers.map((t) => t.id); + + await queryInterface.sequelize.query( + `UPDATE tiers_limits + SET limit_id = :enabledLimitId, updated_at = NOW() + WHERE tier_id IN (:tierIds) + AND limit_id = :disabledLimitId`, + { + replacements: { + disabledLimitId, + enabledLimitId, + tierIds, + }, + transaction, + }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; From 138d507f5a7318539c1c9347061386282d7889c3 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Wed, 21 Jan 2026 06:33:08 -0600 Subject: [PATCH 43/71] feat: add migration to grant CLI access for ultimate lifetime tier --- ...641-update-cli-access-ultimate-lifetime.js | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 migrations/20260121062641-update-cli-access-ultimate-lifetime.js diff --git a/migrations/20260121062641-update-cli-access-ultimate-lifetime.js b/migrations/20260121062641-update-cli-access-ultimate-lifetime.js new file mode 100644 index 000000000..dae440cfe --- /dev/null +++ b/migrations/20260121062641-update-cli-access-ultimate-lifetime.js @@ -0,0 +1,105 @@ +'use strict'; + +const CLI_LIMIT_LABEL = 'cli-access'; +const TIER_LABEL = 'ultimate_lifetime_individual'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const [limits] = await queryInterface.sequelize.query( + `SELECT id, value FROM limits WHERE label = :limitLabel`, + { replacements: { limitLabel: CLI_LIMIT_LABEL }, transaction }, + ); + + if (limits.length === 0) { + throw new Error('CLI access limits not found in database'); + } + + const enabledLimitId = limits.find((l) => l.value === 'true')?.id; + const disabledLimitId = limits.find((l) => l.value === 'false')?.id; + + if (!enabledLimitId || !disabledLimitId) { + throw new Error('CLI access enabled or disabled limit not found'); + } + + const [tiers] = await queryInterface.sequelize.query( + `SELECT id FROM tiers WHERE label = :label`, + { replacements: { label: TIER_LABEL }, transaction }, + ); + + if (tiers.length === 0) { + console.log(`Tier ${TIER_LABEL} not found, skipping`); + await transaction.commit(); + return; + } + + const tierId = tiers[0].id; + + await queryInterface.sequelize.query( + `UPDATE tiers_limits + SET limit_id = :enabledLimitId, updated_at = NOW() + WHERE tier_id = :tierId + AND limit_id = :disabledLimitId`, + { + replacements: { enabledLimitId, disabledLimitId, tierId }, + transaction, + }, + ); + + await transaction.commit(); + console.log(`Successfully granted CLI access to ${TIER_LABEL}`); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const [limits] = await queryInterface.sequelize.query( + `SELECT id, value FROM limits WHERE label = :limitLabel`, + { replacements: { limitLabel: CLI_LIMIT_LABEL }, transaction }, + ); + + const enabledLimitId = limits.find((l) => l.value === 'true')?.id; + const disabledLimitId = limits.find((l) => l.value === 'false')?.id; + + if (!enabledLimitId || !disabledLimitId) { + throw new Error('CLI access limits not found'); + } + + const [tiers] = await queryInterface.sequelize.query( + `SELECT id FROM tiers WHERE label = :label`, + { replacements: { label: TIER_LABEL }, transaction }, + ); + + if (tiers.length === 0) { + await transaction.commit(); + return; + } + + const tierId = tiers[0].id; + + await queryInterface.sequelize.query( + `UPDATE tiers_limits + SET limit_id = :disabledLimitId, updated_at = NOW() + WHERE tier_id = :tierId + AND limit_id = :enabledLimitId`, + { + replacements: { enabledLimitId, disabledLimitId, tierId }, + transaction, + }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; From 894e6edb78b0233b202b4aa187cd0e39c1f49585 Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Tue, 20 Jan 2026 18:23:20 +0100 Subject: [PATCH 44/71] refactor(file-versions): extract get file versions to action layer --- .../actions/get-file-versions.action.spec.ts | 117 ++++++++++++++++++ .../file/actions/get-file-versions.action.ts | 40 ++++++ src/modules/file/actions/index.ts | 1 + src/modules/file/file.module.ts | 4 +- src/modules/file/file.usecase.spec.ts | 53 +++----- src/modules/file/file.usecase.ts | 25 +--- 6 files changed, 178 insertions(+), 62 deletions(-) create mode 100644 src/modules/file/actions/get-file-versions.action.spec.ts create mode 100644 src/modules/file/actions/get-file-versions.action.ts create mode 100644 src/modules/file/actions/index.ts diff --git a/src/modules/file/actions/get-file-versions.action.spec.ts b/src/modules/file/actions/get-file-versions.action.spec.ts new file mode 100644 index 000000000..d2e11a62b --- /dev/null +++ b/src/modules/file/actions/get-file-versions.action.spec.ts @@ -0,0 +1,117 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock } from '@golevelup/ts-jest'; +import { GetFileVersionsAction } from './get-file-versions.action'; +import { SequelizeFileVersionRepository } from '../file-version.repository'; +import { SequelizeFileRepository } from '../file.repository'; +import { FeatureLimitService } from '../../feature-limit/feature-limit.service'; +import { FileVersion, FileVersionStatus } from '../file-version.domain'; +import { NotFoundException } from '@nestjs/common'; +import { v4 } from 'uuid'; +import { newFile, newUser, newVersioningLimits } from '../../../../test/fixtures'; + +describe('GetFileVersionsAction', () => { + let action: GetFileVersionsAction; + let fileRepository: SequelizeFileRepository; + let fileVersionRepository: SequelizeFileVersionRepository; + let featureLimitService: FeatureLimitService; + + const userMocked = newUser(); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GetFileVersionsAction, + { + provide: SequelizeFileRepository, + useValue: createMock(), + }, + { + provide: SequelizeFileVersionRepository, + useValue: createMock(), + }, + { + provide: FeatureLimitService, + useValue: createMock(), + }, + ], + }).compile(); + + action = module.get(GetFileVersionsAction); + fileRepository = module.get( + SequelizeFileRepository, + ); + fileVersionRepository = module.get( + SequelizeFileVersionRepository, + ); + featureLimitService = module.get(FeatureLimitService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(action).toBeDefined(); + }); + + describe('execute', () => { + const mockLimits = newVersioningLimits({ retentionDays: 30 }); + + it('When file exists, then it should return file versions', async () => { + const mockFile = newFile(); + const createdAt = new Date('2025-01-01'); + const mockVersions = [ + FileVersion.build({ + id: v4(), + fileId: mockFile.uuid, + userId: v4(), + networkFileId: 'network-1', + size: BigInt(100), + status: FileVersionStatus.EXISTS, + createdAt, + updatedAt: new Date(), + }), + ]; + + jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); + jest + .spyOn(fileVersionRepository, 'findAllByFileId') + .mockResolvedValue(mockVersions); + jest + .spyOn(featureLimitService, 'getFileVersioningLimits') + .mockResolvedValue(mockLimits); + + const result = await action.execute(userMocked, mockFile.uuid); + + const expectedExpiresAt = new Date(createdAt); + expectedExpiresAt.setDate( + expectedExpiresAt.getDate() + mockLimits.retentionDays, + ); + + expect(result[0].expiresAt).toEqual(expectedExpiresAt); + expect(result[0].id).toEqual(mockVersions[0].id); + expect(fileRepository.findByUuid).toHaveBeenCalledWith( + mockFile.uuid, + userMocked.id, + {}, + ); + expect(fileVersionRepository.findAllByFileId).toHaveBeenCalledWith( + mockFile.uuid, + ); + }); + + it('When file does not exist, then should fail', async () => { + jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(null); + + await expect( + action.execute(userMocked, 'non-existent-uuid'), + ).rejects.toThrow(NotFoundException); + + expect(fileRepository.findByUuid).toHaveBeenCalledWith( + 'non-existent-uuid', + userMocked.id, + {}, + ); + }); + }); +}); diff --git a/src/modules/file/actions/get-file-versions.action.ts b/src/modules/file/actions/get-file-versions.action.ts new file mode 100644 index 000000000..ffe7ec7a5 --- /dev/null +++ b/src/modules/file/actions/get-file-versions.action.ts @@ -0,0 +1,40 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { SequelizeFileVersionRepository } from '../file-version.repository'; +import { SequelizeFileRepository } from '../file.repository'; +import { FeatureLimitService } from '../../feature-limit/feature-limit.service'; +import { User } from '../../user/user.domain'; +import { FileVersionDto } from '../dto/responses/file-version.dto'; + +@Injectable() +export class GetFileVersionsAction { + constructor( + private readonly fileRepository: SequelizeFileRepository, + private readonly fileVersionRepository: SequelizeFileVersionRepository, + private readonly featureLimitService: FeatureLimitService, + ) {} + + async execute(user: User, fileUuid: string): Promise { + const file = await this.fileRepository.findByUuid(fileUuid, user.id, {}); + + if (!file) { + throw new NotFoundException('File not found'); + } + + const [versions, limits] = await Promise.all([ + this.fileVersionRepository.findAllByFileId(fileUuid), + this.featureLimitService.getFileVersioningLimits(user.uuid), + ]); + + const { retentionDays } = limits; + + return versions.map((version) => { + const expiresAt = new Date(version.createdAt); + expiresAt.setDate(expiresAt.getDate() + retentionDays); + + return { + ...version.toJSON(), + expiresAt, + }; + }); + } +} diff --git a/src/modules/file/actions/index.ts b/src/modules/file/actions/index.ts new file mode 100644 index 000000000..85191b474 --- /dev/null +++ b/src/modules/file/actions/index.ts @@ -0,0 +1 @@ +export * from './get-file-versions.action'; diff --git a/src/modules/file/file.module.ts b/src/modules/file/file.module.ts index 9496ca0a7..ad14a621e 100644 --- a/src/modules/file/file.module.ts +++ b/src/modules/file/file.module.ts @@ -22,6 +22,7 @@ import { RedisService } from '../../externals/redis/redis.service'; import { TrashModule } from '../trash/trash.module'; import { CacheManagerModule } from '../cache-manager/cache-manager.module'; import { CustomEndpointThrottleGuard } from '../../guards/custom-endpoint-throttle.guard'; +import { GetFileVersionsAction } from './actions'; @Module({ imports: [ @@ -46,7 +47,8 @@ import { CustomEndpointThrottleGuard } from '../../guards/custom-endpoint-thrott FileUseCases, MailerService, RedisService, - CustomEndpointThrottleGuard + CustomEndpointThrottleGuard, + GetFileVersionsAction, ], exports: [ FileUseCases, diff --git a/src/modules/file/file.usecase.spec.ts b/src/modules/file/file.usecase.spec.ts index 4aa22fca4..0b53e4ac2 100644 --- a/src/modules/file/file.usecase.spec.ts +++ b/src/modules/file/file.usecase.spec.ts @@ -48,6 +48,7 @@ import { UserUseCases } from '../user/user.usecase'; import { TrashUseCases } from '../trash/trash.usecase'; import { TrashItemType } from '../trash/trash.attributes'; import { CacheManagerService } from '../cache-manager/cache-manager.service'; +import { GetFileVersionsAction } from './actions'; const fileId = '6295c99a241bb000083f1c6a'; const userId = 1; @@ -68,6 +69,7 @@ describe('FileUseCases', () => { let redisService: RedisService; let trashUsecases: TrashUseCases; let cacheManagerService: CacheManagerService; + let getFileVersionsAction: GetFileVersionsAction; const userMocked = newUser({ attributes: { @@ -99,6 +101,9 @@ describe('FileUseCases', () => { redisService = module.get(RedisService); trashUsecases = module.get(TrashUseCases); cacheManagerService = module.get(CacheManagerService); + getFileVersionsAction = module.get( + GetFileVersionsAction, + ); }); afterEach(() => { @@ -1935,53 +1940,23 @@ describe('FileUseCases', () => { }); describe('getFileVersions', () => { - const mockLimits = newVersioningLimits({ retentionDays: 30 }); - - it('When file exists, then it should return versions with expiresAt', async () => { + it('When file exists, then it should return file versions', async () => { const mockFile = newFile(); - const createdAt = new Date('2025-01-01'); - const mockVersions = [ - FileVersion.build({ - id: v4(), - fileId: mockFile.uuid, - userId: v4(), - networkFileId: 'network-1', - size: BigInt(100), - status: FileVersionStatus.EXISTS, - createdAt, - updatedAt: new Date(), - }), - ]; - - jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); - jest - .spyOn(fileVersionRepository, 'findAllByFileId') - .mockResolvedValue(mockVersions); - jest - .spyOn(featureLimitService, 'getFileVersioningLimits') - .mockResolvedValue(mockLimits); - const result = await service.getFileVersions(userMocked, mockFile.uuid); + jest.spyOn(getFileVersionsAction, 'execute').mockResolvedValue([]); - const expectedExpiresAt = new Date(createdAt); - expectedExpiresAt.setDate( - expectedExpiresAt.getDate() + mockLimits.retentionDays, - ); + await service.getFileVersions(userMocked, mockFile.uuid); - expect(result[0].expiresAt).toEqual(expectedExpiresAt); - expect(result[0].id).toEqual(mockVersions[0].id); - expect(fileRepository.findByUuid).toHaveBeenCalledWith( - mockFile.uuid, - userMocked.id, - {}, - ); - expect(fileVersionRepository.findAllByFileId).toHaveBeenCalledWith( + expect(getFileVersionsAction.execute).toHaveBeenCalledWith( + userMocked, mockFile.uuid, ); }); - it('When file does not exist, then it should throw NotFoundException', async () => { - jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(null); + it('When file does not exist, then should fail', async () => { + const error = new NotFoundException('File not found'); + + jest.spyOn(getFileVersionsAction, 'execute').mockRejectedValue(error); await expect( service.getFileVersions(userMocked, 'non-existent-uuid'), diff --git a/src/modules/file/file.usecase.ts b/src/modules/file/file.usecase.ts index ad7645be3..945fa390d 100644 --- a/src/modules/file/file.usecase.ts +++ b/src/modules/file/file.usecase.ts @@ -59,6 +59,7 @@ import { TrashItemType } from '../trash/trash.attributes'; import { TrashUseCases } from '../trash/trash.usecase'; import { CacheManagerService } from '../cache-manager/cache-manager.service'; import { PaymentRequiredException } from '../feature-limit/exceptions/payment-required.exception'; +import { GetFileVersionsAction } from './actions'; export enum VersionableFileExtension { PDF = 'pdf', @@ -93,6 +94,7 @@ export class FileUseCases { private readonly userUsecases: UserUseCases, private readonly redisService: RedisService, private readonly cacheManagerService: CacheManagerService, + private readonly getFileVersionsAction: GetFileVersionsAction, ) {} getByUuid(uuid: FileAttributes['uuid']): Promise { @@ -250,28 +252,7 @@ export class FileUseCases { user: User, fileUuid: string, ): Promise { - const file = await this.fileRepository.findByUuid(fileUuid, user.id, {}); - - if (!file) { - throw new NotFoundException('File not found'); - } - - const [versions, limits] = await Promise.all([ - this.fileVersionRepository.findAllByFileId(fileUuid), - this.featureLimitService.getFileVersioningLimits(user.uuid), - ]); - - const { retentionDays } = limits; - - return versions.map((version) => { - const expiresAt = new Date(version.createdAt); - expiresAt.setDate(expiresAt.getDate() + retentionDays); - - return { - ...version.toJSON(), - expiresAt, - }; - }); + return this.getFileVersionsAction.execute(user, fileUuid); } async deleteFileVersion( From 4461c334755eeaf894cc3a6811e6832bf84f08c3 Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Wed, 21 Jan 2026 14:06:27 +0100 Subject: [PATCH 45/71] test(file-versions): cover empty versions case and add result assertions --- .../actions/get-file-versions.action.spec.ts | 24 +++++++++++++++++++ src/modules/file/file.usecase.spec.ts | 3 ++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/modules/file/actions/get-file-versions.action.spec.ts b/src/modules/file/actions/get-file-versions.action.spec.ts index d2e11a62b..3d0c32f7d 100644 --- a/src/modules/file/actions/get-file-versions.action.spec.ts +++ b/src/modules/file/actions/get-file-versions.action.spec.ts @@ -100,6 +100,30 @@ describe('GetFileVersionsAction', () => { ); }); + it('When file exists but has no versions, then it should return empty array', async () => { + const mockFile = newFile(); + + jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); + jest + .spyOn(fileVersionRepository, 'findAllByFileId') + .mockResolvedValue([]); + jest + .spyOn(featureLimitService, 'getFileVersioningLimits') + .mockResolvedValue(mockLimits); + + const result = await action.execute(userMocked, mockFile.uuid); + + expect(result).toEqual([]); + expect(fileRepository.findByUuid).toHaveBeenCalledWith( + mockFile.uuid, + userMocked.id, + {}, + ); + expect(fileVersionRepository.findAllByFileId).toHaveBeenCalledWith( + mockFile.uuid, + ); + }); + it('When file does not exist, then should fail', async () => { jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(null); diff --git a/src/modules/file/file.usecase.spec.ts b/src/modules/file/file.usecase.spec.ts index 0b53e4ac2..330c66621 100644 --- a/src/modules/file/file.usecase.spec.ts +++ b/src/modules/file/file.usecase.spec.ts @@ -1945,8 +1945,9 @@ describe('FileUseCases', () => { jest.spyOn(getFileVersionsAction, 'execute').mockResolvedValue([]); - await service.getFileVersions(userMocked, mockFile.uuid); + const result = await service.getFileVersions(userMocked, mockFile.uuid); + expect(result).toEqual([]); expect(getFileVersionsAction.execute).toHaveBeenCalledWith( userMocked, mockFile.uuid, From 52ff20df33ca27565075c183ed59cca8dbabf0fc Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Tue, 20 Jan 2026 21:13:05 -0600 Subject: [PATCH 46/71] feat(migrations): add rclone access limit migration with tier relations --- .../20260121124206-add-rclone-access-limit.js | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 migrations/20260121124206-add-rclone-access-limit.js diff --git a/migrations/20260121124206-add-rclone-access-limit.js b/migrations/20260121124206-add-rclone-access-limit.js new file mode 100644 index 000000000..6ee1f549e --- /dev/null +++ b/migrations/20260121124206-add-rclone-access-limit.js @@ -0,0 +1,108 @@ +'use strict'; + +const { v4 } = require('uuid'); + +const LIMIT_LABEL = 'rclone-access'; + +const RCLONE_ENABLED_TIER_LABELS = [ + 'ultimate_individual', + 'ultimate_lifetime_individual', + 'pro_business', +]; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const disabledLimitId = v4(); + const enabledLimitId = v4(); + + await queryInterface.bulkInsert( + 'limits', + [ + { + id: disabledLimitId, + label: LIMIT_LABEL, + name: 'Rclone access disabled', + type: 'boolean', + value: 'false', + created_at: new Date(), + updated_at: new Date(), + }, + { + id: enabledLimitId, + label: LIMIT_LABEL, + name: 'Rclone access enabled', + type: 'boolean', + value: 'true', + created_at: new Date(), + updated_at: new Date(), + }, + ], + { transaction }, + ); + + const [tiers] = await queryInterface.sequelize.query( + `SELECT id, label FROM tiers`, + { transaction }, + ); + + if (tiers.length === 0) { + throw new Error('No tiers found in database'); + } + + const tierLimitRelations = tiers.map((tier) => { + const isEnabled = RCLONE_ENABLED_TIER_LABELS.includes(tier.label); + + return { + id: v4(), + tier_id: tier.id, + limit_id: isEnabled ? enabledLimitId : disabledLimitId, + created_at: new Date(), + updated_at: new Date(), + }; + }); + + await queryInterface.bulkInsert('tiers_limits', tierLimitRelations, { + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const [limits] = await queryInterface.sequelize.query( + `SELECT id FROM limits WHERE label = :limitLabel`, + { replacements: { limitLabel: LIMIT_LABEL }, transaction }, + ); + + const limitIds = limits.map((l) => l.id); + + if (limitIds.length > 0) { + await queryInterface.sequelize.query( + `DELETE FROM tiers_limits WHERE limit_id IN (:limitIds)`, + { replacements: { limitIds }, transaction }, + ); + } + + await queryInterface.sequelize.query( + `DELETE FROM limits WHERE label = :limitLabel`, + { replacements: { limitLabel: LIMIT_LABEL }, transaction }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; From 4424f074f5c5c1ad5e7b1ba6f2de5824db544470 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Thu, 8 Jan 2026 12:31:21 -0600 Subject: [PATCH 47/71] feat(migrations): add migration to clean up orphaned devices linked to deleted folders --- ...20260109152500-cleanup-orphaned-devices.js | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 migrations/20260109152500-cleanup-orphaned-devices.js diff --git a/migrations/20260109152500-cleanup-orphaned-devices.js b/migrations/20260109152500-cleanup-orphaned-devices.js new file mode 100644 index 000000000..a3a97d089 --- /dev/null +++ b/migrations/20260109152500-cleanup-orphaned-devices.js @@ -0,0 +1,50 @@ +'use strict'; + +const MAX_ATTEMPTS = 10; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, _Sequelize) { + console.info( + 'Starting migration: cleaning up devices with deleted folders', + ); + + let attempts = 0; + let success = false; + + while (!success && attempts < MAX_ATTEMPTS) { + try { + const [results] = await queryInterface.sequelize.query(` + DELETE FROM devices + WHERE id IN ( + SELECT d.id + FROM devices d + INNER JOIN folders f ON d.folder_uuid = f.uuid + WHERE f.deleted = true + ) + RETURNING id; + `); + + console.info( + `Migration completed. Deleted ${results.length} orphaned devices.`, + ); + success = true; + } catch (err) { + attempts++; + console.error( + `[ERROR]: Error during deletion (attempt ${attempts}/${MAX_ATTEMPTS}): ${err.message}`, + ); + + if (attempts >= MAX_ATTEMPTS) { + console.error( + '[ERROR]: Maximum retry attempts reached, exiting migration.', + ); + throw err; + } + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + } + }, + + async down() {}, +}; From 06c330810d54bf0b9199bae3d5f7e3970ed9b33f Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Fri, 9 Jan 2026 10:12:03 -0600 Subject: [PATCH 48/71] fix(migrations): update orphaned devices cleanup to include removed folders --- ...ed-devices.js => 20260122025914-cleanup-orphaned-devices.js} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename migrations/{20260109152500-cleanup-orphaned-devices.js => 20260122025914-cleanup-orphaned-devices.js} (95%) diff --git a/migrations/20260109152500-cleanup-orphaned-devices.js b/migrations/20260122025914-cleanup-orphaned-devices.js similarity index 95% rename from migrations/20260109152500-cleanup-orphaned-devices.js rename to migrations/20260122025914-cleanup-orphaned-devices.js index a3a97d089..9e27f1cb1 100644 --- a/migrations/20260109152500-cleanup-orphaned-devices.js +++ b/migrations/20260122025914-cleanup-orphaned-devices.js @@ -20,7 +20,7 @@ module.exports = { SELECT d.id FROM devices d INNER JOIN folders f ON d.folder_uuid = f.uuid - WHERE f.deleted = true + WHERE f.deleted = true or f.removed = true ) RETURNING id; `); From 61e4626a137087e5e678d7d60512e83607438547 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Wed, 21 Jan 2026 19:44:07 -0600 Subject: [PATCH 49/71] feat(migration): modify trigger function that handles file deletion to prevent null file_id entries --- ...0-fix-file-deleted-trigger-null-file-id.js | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 migrations/20260121193200-fix-file-deleted-trigger-null-file-id.js diff --git a/migrations/20260121193200-fix-file-deleted-trigger-null-file-id.js b/migrations/20260121193200-fix-file-deleted-trigger-null-file-id.js new file mode 100644 index 000000000..ffd302ba0 --- /dev/null +++ b/migrations/20260121193200-fix-file-deleted-trigger-null-file-id.js @@ -0,0 +1,44 @@ +'use strict'; + +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + CREATE OR REPLACE FUNCTION public.file_deleted_trigger() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + BEGIN + IF OLD.status != 'DELETED' AND NEW.status = 'DELETED' THEN + -- Skip empty files (file_id is NULL) since there's no network file to clean up + IF OLD.file_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM deleted_files WHERE file_id = OLD.uuid) THEN + INSERT INTO deleted_files (file_id, network_file_id, processed, created_at, updated_at, processed_at) + VALUES (OLD.uuid, OLD.file_id, false, NOW(), NOW(), NULL); + END IF; + END IF; + RETURN NEW; + END; + $function$ + ; + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + CREATE OR REPLACE FUNCTION public.file_deleted_trigger() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + BEGIN + IF OLD.status != 'DELETED' AND NEW.status = 'DELETED' then + IF NOT EXISTS (SELECT 1 FROM deleted_files WHERE file_id = OLD.uuid) THEN + INSERT INTO deleted_files (file_id, network_file_id, processed, created_at, updated_at, processed_at) + VALUES (OLD.uuid, OLD.file_id, false, NOW(), NOW(), NULL); + END IF; + END IF; + RETURN NEW; + END; + $function$ + ; + `); + }, +}; From 9153957209017f57a52ba237341ec9457620a134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Thu, 22 Jan 2026 10:46:37 +0100 Subject: [PATCH 50/71] fix(migrations): update trigger to avoid adding empty files to the deletion queue --- ...122102300-fix-file-deleted-trigger-null-file-id.js} | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) rename migrations/{20260121193200-fix-file-deleted-trigger-null-file-id.js => 20260122102300-fix-file-deleted-trigger-null-file-id.js} (85%) diff --git a/migrations/20260121193200-fix-file-deleted-trigger-null-file-id.js b/migrations/20260122102300-fix-file-deleted-trigger-null-file-id.js similarity index 85% rename from migrations/20260121193200-fix-file-deleted-trigger-null-file-id.js rename to migrations/20260122102300-fix-file-deleted-trigger-null-file-id.js index ffd302ba0..602e0190d 100644 --- a/migrations/20260121193200-fix-file-deleted-trigger-null-file-id.js +++ b/migrations/20260122102300-fix-file-deleted-trigger-null-file-id.js @@ -8,9 +8,8 @@ module.exports = { LANGUAGE plpgsql AS $function$ BEGIN - IF OLD.status != 'DELETED' AND NEW.status = 'DELETED' THEN - -- Skip empty files (file_id is NULL) since there's no network file to clean up - IF OLD.file_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM deleted_files WHERE file_id = OLD.uuid) THEN + IF OLD.status != 'DELETED' AND NEW.status = 'DELETED' AND OLD.file_id IS NOT NULL THEN + IF NOT EXISTS (SELECT 1 FROM deleted_files WHERE file_id = OLD.uuid) THEN INSERT INTO deleted_files (file_id, network_file_id, processed, created_at, updated_at, processed_at) VALUES (OLD.uuid, OLD.file_id, false, NOW(), NOW(), NULL); END IF; @@ -23,13 +22,14 @@ module.exports = { }, async down(queryInterface) { + // Revert to the previous implementation (without checking NEW.file_id). await queryInterface.sequelize.query(` CREATE OR REPLACE FUNCTION public.file_deleted_trigger() RETURNS trigger LANGUAGE plpgsql AS $function$ BEGIN - IF OLD.status != 'DELETED' AND NEW.status = 'DELETED' then + IF OLD.status != 'DELETED' AND NEW.status = 'DELETED' THEN IF NOT EXISTS (SELECT 1 FROM deleted_files WHERE file_id = OLD.uuid) THEN INSERT INTO deleted_files (file_id, network_file_id, processed, created_at, updated_at, processed_at) VALUES (OLD.uuid, OLD.file_id, false, NOW(), NOW(), NULL); @@ -41,4 +41,4 @@ module.exports = { ; `); }, -}; +}; \ No newline at end of file From 4a2b609fc90546a3f1b8fc40c78cac21749b4a6b Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Tue, 20 Jan 2026 21:30:59 -0600 Subject: [PATCH 51/71] feat: enhance CLI and Rclone login access with new client mappings and error handling --- src/common/constants.ts | 9 ++ src/common/enums/platform.enum.ts | 3 + src/modules/auth/auth.controller.spec.ts | 142 +++++++++++++++++- src/modules/auth/auth.controller.ts | 37 +++-- .../feature-limit/feature-limit.service.ts | 1 + src/modules/feature-limit/limits.enum.ts | 1 + src/modules/gateway/constants.ts | 1 + 7 files changed, 177 insertions(+), 17 deletions(-) diff --git a/src/common/constants.ts b/src/common/constants.ts index 12f672a5b..60267fc3a 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -1,3 +1,12 @@ +import { ClientEnum } from './enums/platform.enum'; + export enum PlatformName { CLI = 'cli', + RCLONE = 'rclone', } + +export const ClientToPlatformMap: Partial> = { + [ClientEnum.Cli]: PlatformName.CLI, + [ClientEnum.CliLegacy]: PlatformName.CLI, + [ClientEnum.Rclone]: PlatformName.RCLONE, +}; diff --git a/src/common/enums/platform.enum.ts b/src/common/enums/platform.enum.ts index bf1001554..539d818d1 100644 --- a/src/common/enums/platform.enum.ts +++ b/src/common/enums/platform.enum.ts @@ -2,4 +2,7 @@ export enum ClientEnum { Web = 'drive-web', Mobile = 'drive-mobile', Desktop = 'drive-desktop', + Cli = 'internxt-cli', + CliLegacy = '@internxt/cli', + Rclone = 'rclone-adapter', } diff --git a/src/modules/auth/auth.controller.spec.ts b/src/modules/auth/auth.controller.spec.ts index 34e9966e5..e961ca9d8 100644 --- a/src/modules/auth/auth.controller.spec.ts +++ b/src/modules/auth/auth.controller.spec.ts @@ -23,6 +23,7 @@ import { Test } from '@nestjs/testing'; import { FeatureLimitService } from '../feature-limit/feature-limit.service'; import { PaymentRequiredException } from '../feature-limit/exceptions/payment-required.exception'; import { PlatformName } from '../../common/constants'; +import { ClientEnum } from '../../common/enums/platform.enum'; describe('AuthController', () => { let authController: AuthController; @@ -401,7 +402,7 @@ describe('AuthController', () => { loginAccessDto.publicKey = 'publicKey'; loginAccessDto.revocateKey = 'revocateKey'; - it('When valid CLI login access details are provided and user can access platform, then it should return successfully', async () => { + it('When valid CLI login with new header and user can access platform, then it should return successfully', async () => { const eccKey = newKeyServer({ ...loginAccessDto }); const mockUser = newUser({ attributes: { tierId: v4() } }); const mockLoginResult = { @@ -423,7 +424,56 @@ describe('AuthController', () => { .spyOn(featureLimitService, 'canUserAccessPlatform') .mockResolvedValueOnce(true); - const result = await authController.cliLoginAccess(loginAccessDto); + const result = await authController.cliLoginAccess( + loginAccessDto, + ClientEnum.Cli, + ); + + expect(userUseCases.loginAccess).toHaveBeenCalledWith({ + ...loginAccessDto, + keys: { + ecc: { + publicKey: eccKey.publicKey, + privateKey: eccKey.privateKey, + revocationKey: eccKey.revocationKey, + }, + kyber: null, + }, + platform: PlatformName.CLI, + }); + expect(featureLimitService.canUserAccessPlatform).toHaveBeenCalledWith( + PlatformName.CLI, + mockUser.uuid, + ); + expect(result).toEqual(mockLoginResult); + }); + + it('When valid CLI login with legacy header and user can access platform, then it should return successfully', async () => { + const eccKey = newKeyServer({ ...loginAccessDto }); + const mockUser = newUser({ attributes: { tierId: v4() } }); + const mockLoginResult = { + user: mockUser, + token: 'jwt-token', + newToken: 'new-jwt-token', + } as any; + + jest.spyOn(keyServerUseCases, 'parseKeysInput').mockReturnValueOnce({ + ecc: eccKey.toJSON(), + kyber: null, + }); + + jest + .spyOn(userUseCases, 'loginAccess') + .mockResolvedValueOnce(mockLoginResult); + + jest + .spyOn(featureLimitService, 'canUserAccessPlatform') + .mockResolvedValueOnce(true); + + const result = await authController.cliLoginAccess( + loginAccessDto, + ClientEnum.CliLegacy, + ); expect(userUseCases.loginAccess).toHaveBeenCalledWith({ ...loginAccessDto, @@ -444,6 +494,58 @@ describe('AuthController', () => { expect(result).toEqual(mockLoginResult); }); + it('When valid Rclone login and user can access platform, then it should return successfully', async () => { + const eccKey = newKeyServer({ ...loginAccessDto }); + const mockUser = newUser({ attributes: { tierId: v4() } }); + const mockLoginResult = { + user: mockUser, + token: 'jwt-token', + newToken: 'new-jwt-token', + } as any; + + jest.spyOn(keyServerUseCases, 'parseKeysInput').mockReturnValueOnce({ + ecc: eccKey.toJSON(), + kyber: null, + }); + + jest + .spyOn(userUseCases, 'loginAccess') + .mockResolvedValueOnce(mockLoginResult); + + jest + .spyOn(featureLimitService, 'canUserAccessPlatform') + .mockResolvedValueOnce(true); + + const result = await authController.cliLoginAccess( + loginAccessDto, + ClientEnum.Rclone, + ); + + expect(userUseCases.loginAccess).toHaveBeenCalledWith({ + ...loginAccessDto, + keys: { + ecc: { + publicKey: eccKey.publicKey, + privateKey: eccKey.privateKey, + revocationKey: eccKey.revocationKey, + }, + kyber: null, + }, + platform: PlatformName.RCLONE, + }); + expect(featureLimitService.canUserAccessPlatform).toHaveBeenCalledWith( + PlatformName.RCLONE, + mockUser.uuid, + ); + expect(result).toEqual(mockLoginResult); + }); + + it('When unknown client header is provided, then it should throw BadRequestException', async () => { + await expect( + authController.cliLoginAccess(loginAccessDto, 'unknown-client'), + ).rejects.toThrow(BadRequestException); + }); + it('When user cannot access CLI platform, then it should throw PaymentRequiredException', async () => { const eccKey = newKeyServer({ ...loginAccessDto }); const mockUser = newUser({ attributes: { tierId: 'free_id' } }); @@ -467,7 +569,7 @@ describe('AuthController', () => { .mockResolvedValueOnce(false); await expect( - authController.cliLoginAccess(loginAccessDto), + authController.cliLoginAccess(loginAccessDto, ClientEnum.Cli), ).rejects.toThrow(PaymentRequiredException); expect(featureLimitService.canUserAccessPlatform).toHaveBeenCalledWith( @@ -476,6 +578,38 @@ describe('AuthController', () => { ); }); + it('When user cannot access Rclone platform, then it should throw PaymentRequiredException', async () => { + const eccKey = newKeyServer({ ...loginAccessDto }); + const mockUser = newUser({ attributes: { tierId: 'free_id' } }); + const mockLoginResult = { + success: true, + user: mockUser, + token: 'jwt-token', + } as any; + + jest.spyOn(keyServerUseCases, 'parseKeysInput').mockReturnValueOnce({ + ecc: eccKey.toJSON(), + kyber: null, + }); + + jest + .spyOn(userUseCases, 'loginAccess') + .mockResolvedValueOnce(mockLoginResult); + + jest + .spyOn(featureLimitService, 'canUserAccessPlatform') + .mockResolvedValueOnce(false); + + await expect( + authController.cliLoginAccess(loginAccessDto, ClientEnum.Rclone), + ).rejects.toThrow(PaymentRequiredException); + + expect(featureLimitService.canUserAccessPlatform).toHaveBeenCalledWith( + PlatformName.RCLONE, + mockUser.uuid, + ); + }); + it('When CLI login access includes both ecc and kyber keys, then it should parse and pass them correctly', async () => { const eccKey = newKeyServer(); const kyberKey = newKeyServer({ @@ -511,7 +645,7 @@ describe('AuthController', () => { .spyOn(featureLimitService, 'canUserAccessPlatform') .mockResolvedValueOnce(true); - await authController.cliLoginAccess(inputWithKyberKeys); + await authController.cliLoginAccess(inputWithKyberKeys, ClientEnum.Cli); expect(userUseCases.loginAccess).toHaveBeenCalledWith({ ...inputWithKyberKeys, diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 944d2380e..6a91e22a9 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -44,9 +44,11 @@ import { LoginAccessResponseDto } from './dto/responses/login-access-response.dt import { LoginResponseDto } from './dto/responses/login-response.dto'; import { JwtToken } from './decorators/get-jwt.decorator'; import { AuthUsecases } from './auth.usecase'; -import { PlatformName } from '../../common/constants'; +import { ClientToPlatformMap } from '../../common/constants'; import { FeatureLimitService } from '../feature-limit/feature-limit.service'; import { PaymentRequiredException } from '../feature-limit/exceptions/payment-required.exception'; +import { Client } from '../../common/decorators/client.decorator'; +import { ClientEnum } from '../../common/enums/platform.enum'; @ApiTags('Auth') @Controller('auth') @@ -268,22 +270,31 @@ export class AuthController { @Post('/cli/login/access') @HttpCode(HttpStatus.OK) @ApiOperation({ - summary: 'CLI platform login access', + summary: 'CLI/Rclone platform login access', }) @ApiOkResponse({ - description: 'CLI user successfully accessed their account', + description: 'User successfully accessed their account via CLI or Rclone', type: LoginAccessResponseDto, }) @ApiPaymentRequiredResponse({ - description: 'This user current tier does not allow CLI access', + description: 'This user current tier does not allow CLI/Rclone access', }) @Public() async cliLoginAccess( @Body() body: LoginAccessDto, + @Client() client: string, ): Promise { + const platform = ClientToPlatformMap[client as ClientEnum]; + + if (!platform) { + throw new BadRequestException( + `Invalid client header '${client}' for this endpoint`, + ); + } + this.logger.log( - { email: body.email, category: 'CLI-LOGIN-ACCESS' }, - 'Attempting CLI login', + { email: body.email, category: 'CLI-LOGIN-ACCESS', client, platform }, + 'Attempting platform login', ); try { const { ecc, kyber } = this.keyServerUseCases.parseKeysInput(body.keys, { @@ -295,29 +306,29 @@ export class AuthController { const result = await this.userUseCases.loginAccess({ ...body, keys: { kyber, ecc }, - platform: PlatformName.CLI, + platform, }); const canUserAccess = await this.featureLimitService.canUserAccessPlatform( - PlatformName.CLI, + platform, result.user.uuid, ); if (!canUserAccess) throw new PaymentRequiredException( - 'CLI access not allowed for this user tier', + `${platform} access not allowed for this user tier`, ); this.logger.log( - { email: body.email, category: 'CLI-LOGIN-ACCESS' }, - 'Successful CLI login', + { email: body.email, category: 'CLI-LOGIN-ACCESS', platform }, + 'Successful platform login', ); return result; } catch (error) { this.logger.error( - { email: body.email, category: 'CLI-LOGIN-ACCESS', error }, - 'Failed CLI login attempt', + { email: body.email, category: 'CLI-LOGIN-ACCESS', client, error }, + 'Failed platform login attempt', ); throw error; } diff --git a/src/modules/feature-limit/feature-limit.service.ts b/src/modules/feature-limit/feature-limit.service.ts index a6972f0dc..8d1ee3064 100644 --- a/src/modules/feature-limit/feature-limit.service.ts +++ b/src/modules/feature-limit/feature-limit.service.ts @@ -23,6 +23,7 @@ export class FeatureLimitService { ): Promise { const platformLimitLabelsMap: Record = { [PlatformName.CLI]: LimitLabels.CliAccess, + [PlatformName.RCLONE]: LimitLabels.RcloneAccess, }; const limitLabel = platformLimitLabelsMap[platform]; diff --git a/src/modules/feature-limit/limits.enum.ts b/src/modules/feature-limit/limits.enum.ts index e5468a58f..4c74c7aa2 100644 --- a/src/modules/feature-limit/limits.enum.ts +++ b/src/modules/feature-limit/limits.enum.ts @@ -8,6 +8,7 @@ export enum LimitLabels { FileVersionRetentionDays = 'file-version-retention-days', FileVersionMaxNumber = 'file-version-max-number', MaxZeroSizeFiles = 'max-zero-size-files', + RcloneAccess = 'rclone-access', } export enum LimitTypes { diff --git a/src/modules/gateway/constants.ts b/src/modules/gateway/constants.ts index dfa62df1a..439a0ead2 100644 --- a/src/modules/gateway/constants.ts +++ b/src/modules/gateway/constants.ts @@ -2,4 +2,5 @@ import { LimitLabels } from '../feature-limit/limits.enum'; export const FeatureNameLimitMap: Record = { cli: LimitLabels.CliAccess, + rclone: LimitLabels.RcloneAccess, }; From 7a11659448ffa8a5c6d7d873eb0d153a2c2d18b3 Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Tue, 20 Jan 2026 19:06:57 +0100 Subject: [PATCH 52/71] refactor(file-versions): extract delete version to action layer --- .../delete-file-version.action.spec.ts | 138 ++++++++++++++++++ .../actions/delete-file-version.action.ts | 49 +++++++ src/modules/file/actions/index.ts | 1 + src/modules/file/file.module.ts | 3 +- src/modules/file/file.usecase.spec.ts | 102 +++++-------- src/modules/file/file.usecase.ts | 28 +--- 6 files changed, 227 insertions(+), 94 deletions(-) create mode 100644 src/modules/file/actions/delete-file-version.action.spec.ts create mode 100644 src/modules/file/actions/delete-file-version.action.ts diff --git a/src/modules/file/actions/delete-file-version.action.spec.ts b/src/modules/file/actions/delete-file-version.action.spec.ts new file mode 100644 index 000000000..d9e9f937b --- /dev/null +++ b/src/modules/file/actions/delete-file-version.action.spec.ts @@ -0,0 +1,138 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock } from '@golevelup/ts-jest'; +import { DeleteFileVersionAction } from './delete-file-version.action'; +import { SequelizeFileVersionRepository } from '../file-version.repository'; +import { SequelizeFileRepository } from '../file.repository'; +import { FileVersion, FileVersionStatus } from '../file-version.domain'; +import { + BadRequestException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { v4 } from 'uuid'; +import { newFile, newUser } from '../../../../test/fixtures'; + +describe('DeleteFileVersionAction', () => { + let action: DeleteFileVersionAction; + let fileRepository: SequelizeFileRepository; + let fileVersionRepository: SequelizeFileVersionRepository; + + const userMocked = newUser(); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DeleteFileVersionAction, + { + provide: SequelizeFileRepository, + useValue: createMock(), + }, + { + provide: SequelizeFileVersionRepository, + useValue: createMock(), + }, + ], + }).compile(); + + action = module.get(DeleteFileVersionAction); + fileRepository = module.get( + SequelizeFileRepository, + ); + fileVersionRepository = module.get( + SequelizeFileVersionRepository, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(action).toBeDefined(); + }); + + describe('execute', () => { + it('When file and version exist, then should delete version', async () => { + const mockFile = newFile({ attributes: { userId: userMocked.id } }); + const versionId = v4(); + const mockVersion = FileVersion.build({ + id: versionId, + fileId: mockFile.uuid, + userId: v4(), + networkFileId: 'network-id', + size: BigInt(100), + status: FileVersionStatus.EXISTS, + createdAt: new Date(), + updatedAt: new Date(), + }); + + jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); + jest.spyOn(fileVersionRepository, 'findById').mockResolvedValue(mockVersion); + jest.spyOn(fileVersionRepository, 'updateStatus').mockResolvedValue(undefined); + + await action.execute(userMocked, mockFile.uuid, versionId); + + expect(fileRepository.findByUuid).toHaveBeenCalledWith( + mockFile.uuid, + userMocked.id, + {}, + ); + expect(fileVersionRepository.findById).toHaveBeenCalledWith(versionId); + expect(fileVersionRepository.updateStatus).toHaveBeenCalledWith( + versionId, + FileVersionStatus.DELETED, + ); + }); + + it('When file does not exist, then should fail', async () => { + jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(null); + + await expect( + action.execute(userMocked, 'non-existent-uuid', v4()), + ).rejects.toThrow(NotFoundException); + }); + + it('When user does not own file, then should fail', async () => { + const mockFile = newFile({ attributes: { userId: 999 } }); + + jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); + + await expect( + action.execute(userMocked, mockFile.uuid, v4()), + ).rejects.toThrow(ForbiddenException); + }); + + it('When version does not exist, then should fail', async () => { + const mockFile = newFile({ attributes: { userId: userMocked.id } }); + + jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); + jest.spyOn(fileVersionRepository, 'findById').mockResolvedValue(null); + + await expect( + action.execute(userMocked, mockFile.uuid, v4()), + ).rejects.toThrow(NotFoundException); + }); + + it('When version does not belong to file, then should fail', async () => { + const mockFile = newFile({ attributes: { userId: userMocked.id } }); + const versionId = v4(); + const mockVersion = FileVersion.build({ + id: versionId, + fileId: 'different-file-uuid', + userId: v4(), + networkFileId: 'network-id', + size: BigInt(100), + status: FileVersionStatus.EXISTS, + createdAt: new Date(), + updatedAt: new Date(), + }); + + jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); + jest.spyOn(fileVersionRepository, 'findById').mockResolvedValue(mockVersion); + + await expect( + action.execute(userMocked, mockFile.uuid, versionId), + ).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/src/modules/file/actions/delete-file-version.action.ts b/src/modules/file/actions/delete-file-version.action.ts new file mode 100644 index 000000000..83d12f219 --- /dev/null +++ b/src/modules/file/actions/delete-file-version.action.ts @@ -0,0 +1,49 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { SequelizeFileVersionRepository } from '../file-version.repository'; +import { SequelizeFileRepository } from '../file.repository'; +import { User } from '../../user/user.domain'; +import { FileVersionStatus } from '../file-version.domain'; + +@Injectable() +export class DeleteFileVersionAction { + constructor( + private readonly fileRepository: SequelizeFileRepository, + private readonly fileVersionRepository: SequelizeFileVersionRepository, + ) {} + + async execute( + user: User, + fileUuid: string, + versionId: string, + ): Promise { + const file = await this.fileRepository.findByUuid(fileUuid, user.id, {}); + + if (!file) { + throw new NotFoundException('File not found'); + } + + if (!file.isOwnedBy(user)) { + throw new ForbiddenException('You do not own this file'); + } + + const version = await this.fileVersionRepository.findById(versionId); + + if (!version) { + throw new NotFoundException('Version not found'); + } + + if (version.fileId !== fileUuid) { + throw new BadRequestException('Version does not belong to this file'); + } + + await this.fileVersionRepository.updateStatus( + versionId, + FileVersionStatus.DELETED, + ); + } +} diff --git a/src/modules/file/actions/index.ts b/src/modules/file/actions/index.ts index 85191b474..1b9b8200e 100644 --- a/src/modules/file/actions/index.ts +++ b/src/modules/file/actions/index.ts @@ -1 +1,2 @@ export * from './get-file-versions.action'; +export * from './delete-file-version.action'; diff --git a/src/modules/file/file.module.ts b/src/modules/file/file.module.ts index ad14a621e..e779ccdc7 100644 --- a/src/modules/file/file.module.ts +++ b/src/modules/file/file.module.ts @@ -22,7 +22,7 @@ import { RedisService } from '../../externals/redis/redis.service'; import { TrashModule } from '../trash/trash.module'; import { CacheManagerModule } from '../cache-manager/cache-manager.module'; import { CustomEndpointThrottleGuard } from '../../guards/custom-endpoint-throttle.guard'; -import { GetFileVersionsAction } from './actions'; +import { DeleteFileVersionAction, GetFileVersionsAction } from './actions'; @Module({ imports: [ @@ -49,6 +49,7 @@ import { GetFileVersionsAction } from './actions'; RedisService, CustomEndpointThrottleGuard, GetFileVersionsAction, + DeleteFileVersionAction, ], exports: [ FileUseCases, diff --git a/src/modules/file/file.usecase.spec.ts b/src/modules/file/file.usecase.spec.ts index 330c66621..e5b6531c8 100644 --- a/src/modules/file/file.usecase.spec.ts +++ b/src/modules/file/file.usecase.spec.ts @@ -48,7 +48,7 @@ import { UserUseCases } from '../user/user.usecase'; import { TrashUseCases } from '../trash/trash.usecase'; import { TrashItemType } from '../trash/trash.attributes'; import { CacheManagerService } from '../cache-manager/cache-manager.service'; -import { GetFileVersionsAction } from './actions'; +import { DeleteFileVersionAction, GetFileVersionsAction } from './actions'; const fileId = '6295c99a241bb000083f1c6a'; const userId = 1; @@ -70,6 +70,7 @@ describe('FileUseCases', () => { let trashUsecases: TrashUseCases; let cacheManagerService: CacheManagerService; let getFileVersionsAction: GetFileVersionsAction; + let deleteFileVersionAction: DeleteFileVersionAction; const userMocked = newUser({ attributes: { @@ -104,6 +105,9 @@ describe('FileUseCases', () => { getFileVersionsAction = module.get( GetFileVersionsAction, ); + deleteFileVersionAction = module.get( + DeleteFileVersionAction, + ); }); afterEach(() => { @@ -1966,101 +1970,63 @@ describe('FileUseCases', () => { }); describe('deleteFileVersion', () => { - it('When file and version exist, then it should delete the version', async () => { + it('When file and version exist, then should delete version', async () => { const mockFile = newFile({ attributes: { userId: userMocked.id } }); const versionId = v4(); - const mockVersion = FileVersion.build({ - id: versionId, - fileId: mockFile.uuid, - userId: v4(), - networkFileId: 'network-id', - size: BigInt(100), - status: FileVersionStatus.EXISTS, - createdAt: new Date('2024-01-01'), - updatedAt: new Date(), - }); - jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); jest - .spyOn(fileVersionRepository, 'findById') - .mockResolvedValue(mockVersion); - jest.spyOn(fileVersionRepository, 'updateStatus').mockResolvedValue(); + .spyOn(deleteFileVersionAction, 'execute') + .mockResolvedValue(undefined); await service.deleteFileVersion(userMocked, mockFile.uuid, versionId); - expect(fileVersionRepository.updateStatus).toHaveBeenCalledWith( + expect(deleteFileVersionAction.execute).toHaveBeenCalledWith( + userMocked, + mockFile.uuid, versionId, - FileVersionStatus.DELETED, ); }); - it('When file does not exist, then it should throw NotFoundException', async () => { - jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(null); + it('When file does not exist, then should fail', async () => { + const error = new NotFoundException('File not found'); + + jest.spyOn(deleteFileVersionAction, 'execute').mockRejectedValue(error); await expect( service.deleteFileVersion(userMocked, 'non-existent-uuid', v4()), ).rejects.toThrow(NotFoundException); }); - it('When version does not exist, then it should throw NotFoundException', async () => { - const mockFile = newFile({ attributes: { userId: userMocked.id } }); - jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); - jest.spyOn(fileVersionRepository, 'findById').mockResolvedValue(null); + it('When user does not own file, then should fail', async () => { + const error = new ForbiddenException('You do not own this file'); + + jest.spyOn(deleteFileVersionAction, 'execute').mockRejectedValue(error); await expect( - service.deleteFileVersion(userMocked, mockFile.uuid, v4()), - ).rejects.toThrow(NotFoundException); + service.deleteFileVersion(userMocked, v4(), v4()), + ).rejects.toThrow(ForbiddenException); }); - it('When version does not belong to file, then it should throw BadRequestException', async () => { - const mockFile = newFile({ attributes: { userId: userMocked.id } }); - const mockVersion = FileVersion.build({ - id: v4(), - fileId: 'different-file-uuid', - userId: v4(), - networkFileId: 'network-id', - size: BigInt(100), - status: FileVersionStatus.EXISTS, - createdAt: new Date(), - updatedAt: new Date(), - }); + it('When version does not exist, then should fail', async () => { + const error = new NotFoundException('Version not found'); - jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); - jest - .spyOn(fileVersionRepository, 'findById') - .mockResolvedValue(mockVersion); + jest.spyOn(deleteFileVersionAction, 'execute').mockRejectedValue(error); await expect( - service.deleteFileVersion(userMocked, mockFile.uuid, mockVersion.id), - ).rejects.toThrow(BadRequestException); + service.deleteFileVersion(userMocked, v4(), v4()), + ).rejects.toThrow(NotFoundException); }); - it('When deleting a version, then file should not be modified', async () => { - const mockFile = newFile({ attributes: { userId: userMocked.id } }); - const versionId = v4(); - const mockVersion = FileVersion.build({ - id: versionId, - fileId: mockFile.uuid, - userId: v4(), - networkFileId: 'old-network-id', - size: BigInt(100), - status: FileVersionStatus.EXISTS, - createdAt: new Date(), - updatedAt: new Date(), - }); - - jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); - jest - .spyOn(fileVersionRepository, 'findById') - .mockResolvedValue(mockVersion); - jest.spyOn(fileVersionRepository, 'updateStatus').mockResolvedValue(); - const updateFileSpy = jest - .spyOn(fileRepository, 'updateByUuidAndUserId') - .mockResolvedValue(); + it('When version does not belong to file, then should fail', async () => { + const error = new BadRequestException( + 'Version does not belong to this file', + ); - await service.deleteFileVersion(userMocked, mockFile.uuid, versionId); + jest.spyOn(deleteFileVersionAction, 'execute').mockRejectedValue(error); - expect(updateFileSpy).not.toHaveBeenCalled(); + await expect( + service.deleteFileVersion(userMocked, v4(), v4()), + ).rejects.toThrow(BadRequestException); }); }); diff --git a/src/modules/file/file.usecase.ts b/src/modules/file/file.usecase.ts index 945fa390d..827fad9ba 100644 --- a/src/modules/file/file.usecase.ts +++ b/src/modules/file/file.usecase.ts @@ -59,7 +59,7 @@ import { TrashItemType } from '../trash/trash.attributes'; import { TrashUseCases } from '../trash/trash.usecase'; import { CacheManagerService } from '../cache-manager/cache-manager.service'; import { PaymentRequiredException } from '../feature-limit/exceptions/payment-required.exception'; -import { GetFileVersionsAction } from './actions'; +import { DeleteFileVersionAction, GetFileVersionsAction } from './actions'; export enum VersionableFileExtension { PDF = 'pdf', @@ -95,6 +95,7 @@ export class FileUseCases { private readonly redisService: RedisService, private readonly cacheManagerService: CacheManagerService, private readonly getFileVersionsAction: GetFileVersionsAction, + private readonly deleteFileVersionAction: DeleteFileVersionAction, ) {} getByUuid(uuid: FileAttributes['uuid']): Promise { @@ -260,30 +261,7 @@ export class FileUseCases { fileUuid: string, versionId: string, ): Promise { - const file = await this.fileRepository.findByUuid(fileUuid, user.id, {}); - - if (!file) { - throw new NotFoundException('File not found'); - } - - if (!file.isOwnedBy(user)) { - throw new ForbiddenException('You do not own this file'); - } - - const version = await this.fileVersionRepository.findById(versionId); - - if (!version) { - throw new NotFoundException('Version not found'); - } - - if (version.fileId !== fileUuid) { - throw new BadRequestException('Version does not belong to this file'); - } - - await this.fileVersionRepository.updateStatus( - versionId, - FileVersionStatus.DELETED, - ); + return this.deleteFileVersionAction.execute(user, fileUuid, versionId); } async restoreFileVersion( From bdc660bec9f80c1483bc9cf31123e4e12d11e61a Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Wed, 21 Jan 2026 14:29:00 +0100 Subject: [PATCH 53/71] simplify delete tests --- .../delete-file-version.action.spec.ts | 4 +- .../actions/delete-file-version.action.ts | 4 +- src/modules/file/file.usecase.spec.ts | 53 +++---------------- 3 files changed, 10 insertions(+), 51 deletions(-) diff --git a/src/modules/file/actions/delete-file-version.action.spec.ts b/src/modules/file/actions/delete-file-version.action.spec.ts index d9e9f937b..fabee7535 100644 --- a/src/modules/file/actions/delete-file-version.action.spec.ts +++ b/src/modules/file/actions/delete-file-version.action.spec.ts @@ -5,7 +5,7 @@ import { SequelizeFileVersionRepository } from '../file-version.repository'; import { SequelizeFileRepository } from '../file.repository'; import { FileVersion, FileVersionStatus } from '../file-version.domain'; import { - BadRequestException, + ConflictException, ForbiddenException, NotFoundException, } from '@nestjs/common'; @@ -132,7 +132,7 @@ describe('DeleteFileVersionAction', () => { await expect( action.execute(userMocked, mockFile.uuid, versionId), - ).rejects.toThrow(BadRequestException); + ).rejects.toThrow(ConflictException); }); }); }); diff --git a/src/modules/file/actions/delete-file-version.action.ts b/src/modules/file/actions/delete-file-version.action.ts index 83d12f219..4e089a7dd 100644 --- a/src/modules/file/actions/delete-file-version.action.ts +++ b/src/modules/file/actions/delete-file-version.action.ts @@ -1,5 +1,5 @@ import { - BadRequestException, + ConflictException, ForbiddenException, Injectable, NotFoundException, @@ -38,7 +38,7 @@ export class DeleteFileVersionAction { } if (version.fileId !== fileUuid) { - throw new BadRequestException('Version does not belong to this file'); + throw new ConflictException('Version does not belong to this file'); } await this.fileVersionRepository.updateStatus( diff --git a/src/modules/file/file.usecase.spec.ts b/src/modules/file/file.usecase.spec.ts index e5b6531c8..510831d18 100644 --- a/src/modules/file/file.usecase.spec.ts +++ b/src/modules/file/file.usecase.spec.ts @@ -1970,63 +1970,22 @@ describe('FileUseCases', () => { }); describe('deleteFileVersion', () => { - it('When file and version exist, then should delete version', async () => { + it('When deletion fails, then error is propagated', async () => { const mockFile = newFile({ attributes: { userId: userMocked.id } }); const versionId = v4(); - - jest - .spyOn(deleteFileVersionAction, 'execute') - .mockResolvedValue(undefined); - - await service.deleteFileVersion(userMocked, mockFile.uuid, versionId); - - expect(deleteFileVersionAction.execute).toHaveBeenCalledWith( - userMocked, - mockFile.uuid, - versionId, - ); - }); - - it('When file does not exist, then should fail', async () => { const error = new NotFoundException('File not found'); jest.spyOn(deleteFileVersionAction, 'execute').mockRejectedValue(error); await expect( - service.deleteFileVersion(userMocked, 'non-existent-uuid', v4()), + service.deleteFileVersion(userMocked, mockFile.uuid, versionId), ).rejects.toThrow(NotFoundException); - }); - - it('When user does not own file, then should fail', async () => { - const error = new ForbiddenException('You do not own this file'); - jest.spyOn(deleteFileVersionAction, 'execute').mockRejectedValue(error); - - await expect( - service.deleteFileVersion(userMocked, v4(), v4()), - ).rejects.toThrow(ForbiddenException); - }); - - it('When version does not exist, then should fail', async () => { - const error = new NotFoundException('Version not found'); - - jest.spyOn(deleteFileVersionAction, 'execute').mockRejectedValue(error); - - await expect( - service.deleteFileVersion(userMocked, v4(), v4()), - ).rejects.toThrow(NotFoundException); - }); - - it('When version does not belong to file, then should fail', async () => { - const error = new BadRequestException( - 'Version does not belong to this file', + expect(deleteFileVersionAction.execute).toHaveBeenCalledWith( + userMocked, + mockFile.uuid, + versionId, ); - - jest.spyOn(deleteFileVersionAction, 'execute').mockRejectedValue(error); - - await expect( - service.deleteFileVersion(userMocked, v4(), v4()), - ).rejects.toThrow(BadRequestException); }); }); From 55f2736461ff045d6b31ad7dd4258f6e7d106bbd Mon Sep 17 00:00:00 2001 From: tamarafinogina Date: Fri, 23 Jan 2026 10:48:35 +0100 Subject: [PATCH 54/71] update jest --- package.json | 8 +- yarn.lock | 1738 +++++++++++++++++++++++++++----------------------- 2 files changed, 953 insertions(+), 793 deletions(-) diff --git a/package.json b/package.json index 4dae6ee76..9e11b3739 100644 --- a/package.json +++ b/package.json @@ -95,14 +95,14 @@ "uuid": "^11.1.0" }, "devDependencies": { - "@golevelup/ts-jest": "^0.6.2", + "@golevelup/ts-jest": "^1.2.0", "@internxt/eslint-config-internxt": "^1.0.9", "@nestjs/schematics": "^11.0.7", "@nestjs/testing": "^11.1.6", "@types/chance": "^1.1.6", "@types/crypto-js": "^4.2.1", "@types/express": "^5.0.1", - "@types/jest": "29.5.14", + "@types/jest": "^30.0.0", "@types/jsonwebtoken": "9.0.2", "@types/multer": "^1.4.13", "@types/multer-s3": "^3.0.3", @@ -119,13 +119,13 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.1", "husky": "^8.0.3", - "jest": "29.7.0", + "jest": "^30.2.0", "lint-staged": "^15.1.0", "prettier": "^3.5.3", "sequelize-cli": "^6.6.3", "source-map-support": "^0.5.21", "supertest": "^7.1.4", - "ts-jest": "29.3.2", + "ts-jest": "^29.4.6", "ts-loader": "^9.5.2", "ts-node": "^10.9.2", "tsconfig-paths": "4.2.0", diff --git a/yarn.lock b/yarn.lock index 52529566b..b0773389a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -692,7 +692,16 @@ resolved "https://registry.yarnpkg.com/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.1.tgz#ceecff9ebe1f6199369e6911f38633fac3322811" integrity sha512-sIyFcoPZkTtNu9xFeEoynMef3bPJIAbOfUh+ueYcfhVl6xm2VRtMcMclSxmZCMnHHd4hlYKJeq/aggmBEWynww== -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.16.7", "@babel/code-frame@^7.27.1": +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.27.1", "@babel/code-frame@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.28.6.tgz#72499312ec58b1e2245ba4a4f550c132be4982f7" + integrity sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q== + dependencies: + "@babel/helper-validator-identifier" "^7.28.5" + js-tokens "^4.0.0" + picocolors "^1.1.1" + +"@babel/code-frame@^7.16.7": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== @@ -701,25 +710,25 @@ js-tokens "^4.0.0" picocolors "^1.1.1" -"@babel/compat-data@^7.27.2": - version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.28.5.tgz#a8a4962e1567121ac0b3b487f52107443b455c7f" - integrity sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA== - -"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.23.9": - version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.28.5.tgz#4c81b35e51e1b734f510c99b07dfbc7bbbb48f7e" - integrity sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw== - dependencies: - "@babel/code-frame" "^7.27.1" - "@babel/generator" "^7.28.5" - "@babel/helper-compilation-targets" "^7.27.2" - "@babel/helper-module-transforms" "^7.28.3" - "@babel/helpers" "^7.28.4" - "@babel/parser" "^7.28.5" - "@babel/template" "^7.27.2" - "@babel/traverse" "^7.28.5" - "@babel/types" "^7.28.5" +"@babel/compat-data@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.28.6.tgz#103f466803fa0f059e82ccac271475470570d74c" + integrity sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg== + +"@babel/core@^7.23.9", "@babel/core@^7.27.4": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.28.6.tgz#531bf883a1126e53501ba46eb3bb414047af507f" + integrity sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw== + dependencies: + "@babel/code-frame" "^7.28.6" + "@babel/generator" "^7.28.6" + "@babel/helper-compilation-targets" "^7.28.6" + "@babel/helper-module-transforms" "^7.28.6" + "@babel/helpers" "^7.28.6" + "@babel/parser" "^7.28.6" + "@babel/template" "^7.28.6" + "@babel/traverse" "^7.28.6" + "@babel/types" "^7.28.6" "@jridgewell/remapping" "^2.3.5" convert-source-map "^2.0.0" debug "^4.1.0" @@ -727,23 +736,23 @@ json5 "^2.2.3" semver "^6.3.1" -"@babel/generator@^7.28.5", "@babel/generator@^7.7.2": - version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.5.tgz#712722d5e50f44d07bc7ac9fe84438742dd61298" - integrity sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ== +"@babel/generator@^7.27.5", "@babel/generator@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.6.tgz#48dcc65d98fcc8626a48f72b62e263d25fc3c3f1" + integrity sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw== dependencies: - "@babel/parser" "^7.28.5" - "@babel/types" "^7.28.5" + "@babel/parser" "^7.28.6" + "@babel/types" "^7.28.6" "@jridgewell/gen-mapping" "^0.3.12" "@jridgewell/trace-mapping" "^0.3.28" jsesc "^3.0.2" -"@babel/helper-compilation-targets@^7.27.2": - version "7.27.2" - resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz#46a0f6efab808d51d29ce96858dd10ce8732733d" - integrity sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ== +"@babel/helper-compilation-targets@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz#32c4a3f41f12ed1532179b108a4d746e105c2b25" + integrity sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA== dependencies: - "@babel/compat-data" "^7.27.2" + "@babel/compat-data" "^7.28.6" "@babel/helper-validator-option" "^7.27.1" browserslist "^4.24.0" lru-cache "^5.1.1" @@ -754,27 +763,27 @@ resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz#b9430df2aa4e17bc28665eadeae8aa1d985e6674" integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw== -"@babel/helper-module-imports@^7.27.1": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz#7ef769a323e2655e126673bb6d2d6913bbead204" - integrity sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w== +"@babel/helper-module-imports@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz#60632cbd6ffb70b22823187201116762a03e2d5c" + integrity sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw== dependencies: - "@babel/traverse" "^7.27.1" - "@babel/types" "^7.27.1" + "@babel/traverse" "^7.28.6" + "@babel/types" "^7.28.6" -"@babel/helper-module-transforms@^7.28.3": - version "7.28.3" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz#a2b37d3da3b2344fe085dab234426f2b9a2fa5f6" - integrity sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw== +"@babel/helper-module-transforms@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz#9312d9d9e56edc35aeb6e95c25d4106b50b9eb1e" + integrity sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA== dependencies: - "@babel/helper-module-imports" "^7.27.1" - "@babel/helper-validator-identifier" "^7.27.1" - "@babel/traverse" "^7.28.3" + "@babel/helper-module-imports" "^7.28.6" + "@babel/helper-validator-identifier" "^7.28.5" + "@babel/traverse" "^7.28.6" -"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.27.1", "@babel/helper-plugin-utils@^7.8.0": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz#ddb2f876534ff8013e6c2b299bf4d39b3c51d44c" - integrity sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw== +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.28.6", "@babel/helper-plugin-utils@^7.8.0": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz#6f13ea251b68c8532e985fd532f28741a8af9ac8" + integrity sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug== "@babel/helper-string-parser@^7.27.1": version "7.27.1" @@ -791,20 +800,20 @@ resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f" integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== -"@babel/helpers@^7.28.4": - version "7.28.4" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.28.4.tgz#fe07274742e95bdf7cf1443593eeb8926ab63827" - integrity sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w== +"@babel/helpers@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.28.6.tgz#fca903a313ae675617936e8998b814c415cbf5d7" + integrity sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw== dependencies: - "@babel/template" "^7.27.2" - "@babel/types" "^7.28.4" + "@babel/template" "^7.28.6" + "@babel/types" "^7.28.6" -"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.23.9", "@babel/parser@^7.27.2", "@babel/parser@^7.28.5": - version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.5.tgz#0b0225ee90362f030efd644e8034c99468893b08" - integrity sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ== +"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.23.9", "@babel/parser@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.6.tgz#f01a8885b7fa1e56dd8a155130226cd698ef13fd" + integrity sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ== dependencies: - "@babel/types" "^7.28.5" + "@babel/types" "^7.28.6" "@babel/plugin-syntax-async-generators@^7.8.4": version "7.8.4" @@ -835,11 +844,11 @@ "@babel/helper-plugin-utils" "^7.14.5" "@babel/plugin-syntax-import-attributes@^7.24.7": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz#34c017d54496f9b11b61474e7ea3dfd5563ffe07" - integrity sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww== + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz#b71d5914665f60124e133696f17cd7669062c503" + integrity sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw== dependencies: - "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-plugin-utils" "^7.28.6" "@babel/plugin-syntax-import-meta@^7.10.4": version "7.10.4" @@ -855,12 +864,12 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-jsx@^7.7.2": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz#2f9beb5eff30fa507c5532d107daac7b888fa34c" - integrity sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w== +"@babel/plugin-syntax-jsx@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz#f8ca28bbd84883b5fea0e447c635b81ba73997ee" + integrity sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w== dependencies: - "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-plugin-utils" "^7.28.6" "@babel/plugin-syntax-logical-assignment-operators@^7.10.4": version "7.10.4" @@ -918,39 +927,39 @@ dependencies: "@babel/helper-plugin-utils" "^7.14.5" -"@babel/plugin-syntax-typescript@^7.7.2": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz#5147d29066a793450f220c63fa3a9431b7e6dd18" - integrity sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ== +"@babel/plugin-syntax-typescript@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz#c7b2ddf1d0a811145b1de800d1abd146af92e3a2" + integrity sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A== dependencies: - "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-plugin-utils" "^7.28.6" -"@babel/template@^7.27.2", "@babel/template@^7.3.3": - version "7.27.2" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d" - integrity sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw== +"@babel/template@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.28.6.tgz#0e7e56ecedb78aeef66ce7972b082fce76a23e57" + integrity sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ== dependencies: - "@babel/code-frame" "^7.27.1" - "@babel/parser" "^7.27.2" - "@babel/types" "^7.27.1" + "@babel/code-frame" "^7.28.6" + "@babel/parser" "^7.28.6" + "@babel/types" "^7.28.6" -"@babel/traverse@^7.27.1", "@babel/traverse@^7.28.3", "@babel/traverse@^7.28.5": - version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.5.tgz#450cab9135d21a7a2ca9d2d35aa05c20e68c360b" - integrity sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ== +"@babel/traverse@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.6.tgz#871ddc79a80599a5030c53b1cc48cbe3a5583c2e" + integrity sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg== dependencies: - "@babel/code-frame" "^7.27.1" - "@babel/generator" "^7.28.5" + "@babel/code-frame" "^7.28.6" + "@babel/generator" "^7.28.6" "@babel/helper-globals" "^7.28.0" - "@babel/parser" "^7.28.5" - "@babel/template" "^7.27.2" - "@babel/types" "^7.28.5" + "@babel/parser" "^7.28.6" + "@babel/template" "^7.28.6" + "@babel/types" "^7.28.6" debug "^4.3.1" -"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.27.1", "@babel/types@^7.28.2", "@babel/types@^7.28.4", "@babel/types@^7.28.5", "@babel/types@^7.3.3": - version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.5.tgz#10fc405f60897c35f07e85493c932c7b5ca0592b" - integrity sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA== +"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.27.3", "@babel/types@^7.28.2", "@babel/types@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.6.tgz#c3e9377f1b155005bcc4c46020e7e394e13089df" + integrity sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg== dependencies: "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" @@ -996,6 +1005,28 @@ resolved "https://registry.yarnpkg.com/@dashlane/pqc-kem-kyber512-node/-/pqc-kem-kyber512-node-1.0.0.tgz#0305f8a6c86595a1dc3b0d16184237c71e912d8c" integrity sha512-gVzQwP/1OqKLyYZ/oRI9uECSnYIcLUcZbnAA34Q2l8X1eXq5JWf304tDp1UTdYdJ+ZE58SmQ68VCa/WvpCviGw== +"@emnapi/core@^1.4.3": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.8.1.tgz#fd9efe721a616288345ffee17a1f26ac5dd01349" + integrity sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg== + dependencies: + "@emnapi/wasi-threads" "1.1.0" + tslib "^2.4.0" + +"@emnapi/runtime@^1.4.3": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.8.1.tgz#550fa7e3c0d49c5fb175a116e8cd70614f9a22a5" + integrity sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg== + dependencies: + tslib "^2.4.0" + +"@emnapi/wasi-threads@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz#60b2102fddc9ccb78607e4a3cf8403ea69be41bf" + integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ== + dependencies: + tslib "^2.4.0" + "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.9.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz#7308df158e064f0dd8b8fdb58aa14fa2a7f913b3" @@ -1033,10 +1064,10 @@ resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.1.1.tgz#b9da6a878a371829a0502c9b6c1c143ef6663f4d" integrity sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA== -"@golevelup/ts-jest@^0.6.2": - version "0.6.2" - resolved "https://registry.yarnpkg.com/@golevelup/ts-jest/-/ts-jest-0.6.2.tgz#483a482e1ab5a835cdd0f8669f76d1201c4a0f63" - integrity sha512-ks82vcWbnRuwHSKlrZTGCPPWXZEKlsn1VA2OiYfJ+tVMcMsI4y9ExWkf7FnmYypYJIRWKS9b9N5QVVrCOmaVlg== +"@golevelup/ts-jest@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@golevelup/ts-jest/-/ts-jest-1.2.0.tgz#986c514080bd9960e6a9ec19d2f0e46c48d1bdc4" + integrity sha512-plN26rWBmwOrtWBc46FKgTMWDlLK5F/vYP2i+0NXnoOLdcXrTNDftzW4dpa0fREqUdCRb3EJtuyr6g5AKNKTRA== "@grpc/grpc-js@^1.13.2": version "1.14.2" @@ -1333,197 +1364,225 @@ resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== -"@jest/console@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/console/-/console-29.7.0.tgz#cd4822dbdb84529265c5a2bdb529a3c9cc950ffc" - integrity sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg== +"@jest/console@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/console/-/console-30.2.0.tgz#c52fcd5b58fdd2e8eb66b2fd8ae56f2f64d05b28" + integrity sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ== dependencies: - "@jest/types" "^29.6.3" + "@jest/types" "30.2.0" "@types/node" "*" - chalk "^4.0.0" - jest-message-util "^29.7.0" - jest-util "^29.7.0" + chalk "^4.1.2" + jest-message-util "30.2.0" + jest-util "30.2.0" slash "^3.0.0" -"@jest/core@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/core/-/core-29.7.0.tgz#b6cccc239f30ff36609658c5a5e2291757ce448f" - integrity sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg== - dependencies: - "@jest/console" "^29.7.0" - "@jest/reporters" "^29.7.0" - "@jest/test-result" "^29.7.0" - "@jest/transform" "^29.7.0" - "@jest/types" "^29.6.3" +"@jest/core@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/core/-/core-30.2.0.tgz#813d59faa5abd5510964a8b3a7b17cc77b775275" + integrity sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ== + dependencies: + "@jest/console" "30.2.0" + "@jest/pattern" "30.0.1" + "@jest/reporters" "30.2.0" + "@jest/test-result" "30.2.0" + "@jest/transform" "30.2.0" + "@jest/types" "30.2.0" "@types/node" "*" - ansi-escapes "^4.2.1" - chalk "^4.0.0" - ci-info "^3.2.0" - exit "^0.1.2" - graceful-fs "^4.2.9" - jest-changed-files "^29.7.0" - jest-config "^29.7.0" - jest-haste-map "^29.7.0" - jest-message-util "^29.7.0" - jest-regex-util "^29.6.3" - jest-resolve "^29.7.0" - jest-resolve-dependencies "^29.7.0" - jest-runner "^29.7.0" - jest-runtime "^29.7.0" - jest-snapshot "^29.7.0" - jest-util "^29.7.0" - jest-validate "^29.7.0" - jest-watcher "^29.7.0" - micromatch "^4.0.4" - pretty-format "^29.7.0" + ansi-escapes "^4.3.2" + chalk "^4.1.2" + ci-info "^4.2.0" + exit-x "^0.2.2" + graceful-fs "^4.2.11" + jest-changed-files "30.2.0" + jest-config "30.2.0" + jest-haste-map "30.2.0" + jest-message-util "30.2.0" + jest-regex-util "30.0.1" + jest-resolve "30.2.0" + jest-resolve-dependencies "30.2.0" + jest-runner "30.2.0" + jest-runtime "30.2.0" + jest-snapshot "30.2.0" + jest-util "30.2.0" + jest-validate "30.2.0" + jest-watcher "30.2.0" + micromatch "^4.0.8" + pretty-format "30.2.0" slash "^3.0.0" - strip-ansi "^6.0.0" -"@jest/environment@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-29.7.0.tgz#24d61f54ff1f786f3cd4073b4b94416383baf2a7" - integrity sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw== +"@jest/diff-sequences@30.0.1": + version "30.0.1" + resolved "https://registry.yarnpkg.com/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz#0ededeae4d071f5c8ffe3678d15f3a1be09156be" + integrity sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw== + +"@jest/environment@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-30.2.0.tgz#1e673cdb8b93ded707cf6631b8353011460831fa" + integrity sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g== dependencies: - "@jest/fake-timers" "^29.7.0" - "@jest/types" "^29.6.3" + "@jest/fake-timers" "30.2.0" + "@jest/types" "30.2.0" "@types/node" "*" - jest-mock "^29.7.0" + jest-mock "30.2.0" -"@jest/expect-utils@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.7.0.tgz#023efe5d26a8a70f21677d0a1afc0f0a44e3a1c6" - integrity sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA== +"@jest/expect-utils@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-30.2.0.tgz#4f95413d4748454fdb17404bf1141827d15e6011" + integrity sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA== dependencies: - jest-get-type "^29.6.3" + "@jest/get-type" "30.1.0" -"@jest/expect@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/expect/-/expect-29.7.0.tgz#76a3edb0cb753b70dfbfe23283510d3d45432bf2" - integrity sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ== +"@jest/expect@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/expect/-/expect-30.2.0.tgz#9a5968499bb8add2bbb09136f69f7df5ddbf3185" + integrity sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA== dependencies: - expect "^29.7.0" - jest-snapshot "^29.7.0" + expect "30.2.0" + jest-snapshot "30.2.0" -"@jest/fake-timers@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-29.7.0.tgz#fd91bf1fffb16d7d0d24a426ab1a47a49881a565" - integrity sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ== +"@jest/fake-timers@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-30.2.0.tgz#0941ddc28a339b9819542495b5408622dc9e94ec" + integrity sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw== dependencies: - "@jest/types" "^29.6.3" - "@sinonjs/fake-timers" "^10.0.2" + "@jest/types" "30.2.0" + "@sinonjs/fake-timers" "^13.0.0" "@types/node" "*" - jest-message-util "^29.7.0" - jest-mock "^29.7.0" - jest-util "^29.7.0" - -"@jest/globals@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-29.7.0.tgz#8d9290f9ec47ff772607fa864ca1d5a2efae1d4d" - integrity sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ== + jest-message-util "30.2.0" + jest-mock "30.2.0" + jest-util "30.2.0" + +"@jest/get-type@30.1.0": + version "30.1.0" + resolved "https://registry.yarnpkg.com/@jest/get-type/-/get-type-30.1.0.tgz#4fcb4dc2ebcf0811be1c04fd1cb79c2dba431cbc" + integrity sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA== + +"@jest/globals@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-30.2.0.tgz#2f4b696d5862664b89c4ee2e49ae24d2bb7e0988" + integrity sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw== + dependencies: + "@jest/environment" "30.2.0" + "@jest/expect" "30.2.0" + "@jest/types" "30.2.0" + jest-mock "30.2.0" + +"@jest/pattern@30.0.1": + version "30.0.1" + resolved "https://registry.yarnpkg.com/@jest/pattern/-/pattern-30.0.1.tgz#d5304147f49a052900b4b853dedb111d080e199f" + integrity sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA== dependencies: - "@jest/environment" "^29.7.0" - "@jest/expect" "^29.7.0" - "@jest/types" "^29.6.3" - jest-mock "^29.7.0" + "@types/node" "*" + jest-regex-util "30.0.1" -"@jest/reporters@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-29.7.0.tgz#04b262ecb3b8faa83b0b3d321623972393e8f4c7" - integrity sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg== +"@jest/reporters@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-30.2.0.tgz#a36b28fcbaf0c4595250b108e6f20e363348fd91" + integrity sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ== dependencies: "@bcoe/v8-coverage" "^0.2.3" - "@jest/console" "^29.7.0" - "@jest/test-result" "^29.7.0" - "@jest/transform" "^29.7.0" - "@jest/types" "^29.6.3" - "@jridgewell/trace-mapping" "^0.3.18" + "@jest/console" "30.2.0" + "@jest/test-result" "30.2.0" + "@jest/transform" "30.2.0" + "@jest/types" "30.2.0" + "@jridgewell/trace-mapping" "^0.3.25" "@types/node" "*" - chalk "^4.0.0" - collect-v8-coverage "^1.0.0" - exit "^0.1.2" - glob "^7.1.3" - graceful-fs "^4.2.9" + chalk "^4.1.2" + collect-v8-coverage "^1.0.2" + exit-x "^0.2.2" + glob "^10.3.10" + graceful-fs "^4.2.11" istanbul-lib-coverage "^3.0.0" istanbul-lib-instrument "^6.0.0" istanbul-lib-report "^3.0.0" - istanbul-lib-source-maps "^4.0.0" + istanbul-lib-source-maps "^5.0.0" istanbul-reports "^3.1.3" - jest-message-util "^29.7.0" - jest-util "^29.7.0" - jest-worker "^29.7.0" + jest-message-util "30.2.0" + jest-util "30.2.0" + jest-worker "30.2.0" slash "^3.0.0" - string-length "^4.0.1" - strip-ansi "^6.0.0" + string-length "^4.0.2" v8-to-istanbul "^9.0.1" -"@jest/schemas@^29.6.3": - version "29.6.3" - resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.3.tgz#430b5ce8a4e0044a7e3819663305a7b3091c8e03" - integrity sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA== +"@jest/schemas@30.0.5": + version "30.0.5" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-30.0.5.tgz#7bdf69fc5a368a5abdb49fd91036c55225846473" + integrity sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA== dependencies: - "@sinclair/typebox" "^0.27.8" + "@sinclair/typebox" "^0.34.0" -"@jest/source-map@^29.6.3": - version "29.6.3" - resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-29.6.3.tgz#d90ba772095cf37a34a5eb9413f1b562a08554c4" - integrity sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw== +"@jest/snapshot-utils@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz#387858eb90c2f98f67bff327435a532ac5309fbe" + integrity sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug== dependencies: - "@jridgewell/trace-mapping" "^0.3.18" - callsites "^3.0.0" - graceful-fs "^4.2.9" - -"@jest/test-result@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-29.7.0.tgz#8db9a80aa1a097bb2262572686734baed9b1657c" - integrity sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA== - dependencies: - "@jest/console" "^29.7.0" - "@jest/types" "^29.6.3" - "@types/istanbul-lib-coverage" "^2.0.0" - collect-v8-coverage "^1.0.0" - -"@jest/test-sequencer@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz#6cef977ce1d39834a3aea887a1726628a6f072ce" - integrity sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw== - dependencies: - "@jest/test-result" "^29.7.0" - graceful-fs "^4.2.9" - jest-haste-map "^29.7.0" + "@jest/types" "30.2.0" + chalk "^4.1.2" + graceful-fs "^4.2.11" + natural-compare "^1.4.0" + +"@jest/source-map@30.0.1": + version "30.0.1" + resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-30.0.1.tgz#305ebec50468f13e658b3d5c26f85107a5620aaa" + integrity sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg== + dependencies: + "@jridgewell/trace-mapping" "^0.3.25" + callsites "^3.1.0" + graceful-fs "^4.2.11" + +"@jest/test-result@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-30.2.0.tgz#9c0124377fb7996cdffb86eda3dbc56eacab363d" + integrity sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg== + dependencies: + "@jest/console" "30.2.0" + "@jest/types" "30.2.0" + "@types/istanbul-lib-coverage" "^2.0.6" + collect-v8-coverage "^1.0.2" + +"@jest/test-sequencer@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz#bf0066bc72e176d58f5dfa7f212b6e7eee44f221" + integrity sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q== + dependencies: + "@jest/test-result" "30.2.0" + graceful-fs "^4.2.11" + jest-haste-map "30.2.0" slash "^3.0.0" -"@jest/transform@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-29.7.0.tgz#df2dd9c346c7d7768b8a06639994640c642e284c" - integrity sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw== +"@jest/transform@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-30.2.0.tgz#54bef1a4510dcbd58d5d4de4fe2980a63077ef2a" + integrity sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA== dependencies: - "@babel/core" "^7.11.6" - "@jest/types" "^29.6.3" - "@jridgewell/trace-mapping" "^0.3.18" - babel-plugin-istanbul "^6.1.1" - chalk "^4.0.0" + "@babel/core" "^7.27.4" + "@jest/types" "30.2.0" + "@jridgewell/trace-mapping" "^0.3.25" + babel-plugin-istanbul "^7.0.1" + chalk "^4.1.2" convert-source-map "^2.0.0" fast-json-stable-stringify "^2.1.0" - graceful-fs "^4.2.9" - jest-haste-map "^29.7.0" - jest-regex-util "^29.6.3" - jest-util "^29.7.0" - micromatch "^4.0.4" - pirates "^4.0.4" + graceful-fs "^4.2.11" + jest-haste-map "30.2.0" + jest-regex-util "30.0.1" + jest-util "30.2.0" + micromatch "^4.0.8" + pirates "^4.0.7" slash "^3.0.0" - write-file-atomic "^4.0.2" + write-file-atomic "^5.0.1" -"@jest/types@^29.6.3": - version "29.6.3" - resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.6.3.tgz#1131f8cf634e7e84c5e77bab12f052af585fba59" - integrity sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw== +"@jest/types@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-30.2.0.tgz#1c678a7924b8f59eafd4c77d56b6d0ba976d62b8" + integrity sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg== dependencies: - "@jest/schemas" "^29.6.3" - "@types/istanbul-lib-coverage" "^2.0.0" - "@types/istanbul-reports" "^3.0.0" + "@jest/pattern" "30.0.1" + "@jest/schemas" "30.0.5" + "@types/istanbul-lib-coverage" "^2.0.6" + "@types/istanbul-reports" "^3.0.4" "@types/node" "*" - "@types/yargs" "^17.0.8" - chalk "^4.0.0" + "@types/yargs" "^17.0.33" + chalk "^4.1.2" "@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.5": version "0.3.13" @@ -1567,7 +1626,7 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" -"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.28": +"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.23", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.28": version "0.3.31" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== @@ -1604,6 +1663,15 @@ resolved "https://registry.yarnpkg.com/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz#2249090633e04063176863a050c8f0808d2b6d2b" integrity sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA== +"@napi-rs/wasm-runtime@^0.2.11": + version "0.2.12" + resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz#3e78a8b96e6c33a6c517e1894efbd5385a7cb6f2" + integrity sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ== + dependencies: + "@emnapi/core" "^1.4.3" + "@emnapi/runtime" "^1.4.3" + "@tybys/wasm-util" "^0.10.0" + "@nest-lab/throttler-storage-redis@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@nest-lab/throttler-storage-redis/-/throttler-storage-redis-1.1.0.tgz#78f3dad83dbf6f890f27ce3f323b4a1e5f33e227" @@ -2138,10 +2206,10 @@ "@sendgrid/client" "^8.1.5" "@sendgrid/helpers" "^8.0.0" -"@sinclair/typebox@^0.27.8": - version "0.27.8" - resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" - integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== +"@sinclair/typebox@^0.34.0": + version "0.34.48" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.34.48.tgz#75b0ead87e59e1adbd6dccdc42bad4fddee73b59" + integrity sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA== "@sinonjs/commons@^3.0.0", "@sinonjs/commons@^3.0.1": version "3.0.1" @@ -2157,14 +2225,7 @@ dependencies: "@sinonjs/commons" "^3.0.0" -"@sinonjs/fake-timers@^10.0.2": - version "10.3.0" - resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz#55fdff1ecab9f354019129daf4df0dd4d923ea66" - integrity sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA== - dependencies: - "@sinonjs/commons" "^3.0.0" - -"@sinonjs/fake-timers@^13.0.1": +"@sinonjs/fake-timers@^13.0.0", "@sinonjs/fake-timers@^13.0.1": version "13.0.5" resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz#36b9dbc21ad5546486ea9173d6bea063eb1717d5" integrity sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw== @@ -2726,7 +2787,14 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9" integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== -"@types/babel__core@^7.1.14": +"@tybys/wasm-util@^0.10.0": + version "0.10.1" + resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414" + integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg== + dependencies: + tslib "^2.4.0" + +"@types/babel__core@^7.20.5": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA== @@ -2752,7 +2820,7 @@ "@babel/parser" "^7.1.0" "@babel/types" "^7.0.0" -"@types/babel__traverse@*", "@types/babel__traverse@^7.0.6": +"@types/babel__traverse@*": version "7.28.0" resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz#07d713d6cce0d265c9849db0cbe62d3f61f36f74" integrity sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q== @@ -2848,19 +2916,12 @@ "@types/express-serve-static-core" "^5.0.0" "@types/serve-static" "^2" -"@types/graceful-fs@^4.1.3": - version "4.1.9" - resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz#2a06bc0f68a20ab37b3e36aa238be6abdf49e8b4" - integrity sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ== - dependencies: - "@types/node" "*" - "@types/http-errors@*": version "2.0.5" resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.5.tgz#5b749ab2b16ba113423feb1a64a95dcd30398472" integrity sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg== -"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": +"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.1", "@types/istanbul-lib-coverage@^2.0.6": version "2.0.6" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" integrity sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w== @@ -2872,20 +2933,20 @@ dependencies: "@types/istanbul-lib-coverage" "*" -"@types/istanbul-reports@^3.0.0": +"@types/istanbul-reports@^3.0.4": version "3.0.4" resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz#0f03e3d2f670fbdac586e34b433783070cc16f54" integrity sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ== dependencies: "@types/istanbul-lib-report" "*" -"@types/jest@29.5.14": - version "29.5.14" - resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.14.tgz#2b910912fa1d6856cadcd0c1f95af7df1d6049e5" - integrity sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ== +"@types/jest@^30.0.0": + version "30.0.0" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-30.0.0.tgz#5e85ae568006712e4ad66f25433e9bdac8801f1d" + integrity sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA== dependencies: - expect "^29.0.0" - pretty-format "^29.0.0" + expect "^30.0.0" + pretty-format "^30.0.0" "@types/json-schema@*", "@types/json-schema@^7.0.12", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": version "7.0.15" @@ -2955,7 +3016,14 @@ dependencies: "@types/express" "*" -"@types/node@*", "@types/node@>=13.7.0", "@types/node@>=8.1.0": +"@types/node@*": + version "25.0.10" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.0.10.tgz#4864459c3c9459376b8b75fd051315071c8213e7" + integrity sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg== + dependencies: + undici-types "~7.16.0" + +"@types/node@>=13.7.0", "@types/node@>=8.1.0": version "24.10.1" resolved "https://registry.yarnpkg.com/@types/node/-/node-24.10.1.tgz#91e92182c93db8bd6224fca031e2370cef9a8f01" integrity sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ== @@ -3052,7 +3120,7 @@ dependencies: "@types/node" "*" -"@types/stack-utils@^2.0.0": +"@types/stack-utils@^2.0.3": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== @@ -3090,7 +3158,7 @@ resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" integrity sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ== -"@types/yargs@^17.0.8": +"@types/yargs@^17.0.33": version "17.0.35" resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.35.tgz#07013e46aa4d7d7d50a49e15604c1c5340d4eb24" integrity sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg== @@ -3272,11 +3340,108 @@ resolved "https://registry.yarnpkg.com/@tyriar/fibonacci-heap/-/fibonacci-heap-2.0.9.tgz#df3dcbdb1b9182168601f6318366157ee16666e9" integrity sha512-bYuSNomfn4hu2tPiDN+JZtnzCpSpbJ/PNeulmocDy3xN2X5OkJL65zo6rPZp65cPPhLF9vfT/dgE+RtFRCSxOA== -"@ungap/structured-clone@^1.2.0": +"@ungap/structured-clone@^1.2.0", "@ungap/structured-clone@^1.3.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== +"@unrs/resolver-binding-android-arm-eabi@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz#9f5b04503088e6a354295e8ea8fe3cb99e43af81" + integrity sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw== + +"@unrs/resolver-binding-android-arm64@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz#7414885431bd7178b989aedc4d25cccb3865bc9f" + integrity sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g== + +"@unrs/resolver-binding-darwin-arm64@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz#b4a8556f42171fb9c9f7bac8235045e82aa0cbdf" + integrity sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g== + +"@unrs/resolver-binding-darwin-x64@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz#fd4d81257b13f4d1a083890a6a17c00de571f0dc" + integrity sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ== + +"@unrs/resolver-binding-freebsd-x64@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz#d2513084d0f37c407757e22f32bd924a78cfd99b" + integrity sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw== + +"@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz#844d2605d057488d77fab09705f2866b86164e0a" + integrity sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw== + +"@unrs/resolver-binding-linux-arm-musleabihf@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz#204892995cefb6bd1d017d52d097193bc61ddad3" + integrity sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw== + +"@unrs/resolver-binding-linux-arm64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz#023eb0c3aac46066a10be7a3f362e7b34f3bdf9d" + integrity sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ== + +"@unrs/resolver-binding-linux-arm64-musl@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz#9e6f9abb06424e3140a60ac996139786f5d99be0" + integrity sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w== + +"@unrs/resolver-binding-linux-ppc64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz#b111417f17c9d1b02efbec8e08398f0c5527bb44" + integrity sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA== + +"@unrs/resolver-binding-linux-riscv64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz#92ffbf02748af3e99873945c9a8a5ead01d508a9" + integrity sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ== + +"@unrs/resolver-binding-linux-riscv64-musl@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz#0bec6f1258fc390e6b305e9ff44256cb207de165" + integrity sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew== + +"@unrs/resolver-binding-linux-s390x-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz#577843a084c5952f5906770633ccfb89dac9bc94" + integrity sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg== + +"@unrs/resolver-binding-linux-x64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz#36fb318eebdd690f6da32ac5e0499a76fa881935" + integrity sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w== + +"@unrs/resolver-binding-linux-x64-musl@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz#bfb9af75f783f98f6a22c4244214efe4df1853d6" + integrity sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA== + +"@unrs/resolver-binding-wasm32-wasi@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz#752c359dd875684b27429500d88226d7cc72f71d" + integrity sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ== + dependencies: + "@napi-rs/wasm-runtime" "^0.2.11" + +"@unrs/resolver-binding-win32-arm64-msvc@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz#ce5735e600e4c2fbb409cd051b3b7da4a399af35" + integrity sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw== + +"@unrs/resolver-binding-win32-ia32-msvc@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz#72fc57bc7c64ec5c3de0d64ee0d1810317bc60a6" + integrity sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ== + +"@unrs/resolver-binding-win32-x64-msvc@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777" + integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g== + "@webassemblyjs/ast@1.14.1", "@webassemblyjs/ast@^1.14.1": version "1.14.1" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.14.1.tgz#a9f6a07f2b03c95c8d38c4536a1fdfb521ff55b6" @@ -3511,7 +3676,7 @@ ansi-colors@4.1.3: resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" integrity sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw== -ansi-escapes@^4.2.1: +ansi-escapes@^4.3.2: version "4.3.2" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== @@ -3542,7 +3707,7 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" -ansi-styles@^5.0.0: +ansi-styles@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== @@ -3557,7 +3722,7 @@ ansis@4.2.0: resolved "https://registry.yarnpkg.com/ansis/-/ansis-4.2.0.tgz#2e6e61c46b11726ac67f78785385618b9e658780" integrity sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig== -anymatch@^3.0.3: +anymatch@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== @@ -3619,7 +3784,7 @@ asn1.js@^5.0.0: dependencies: lodash "^4.17.14" -async@3.2.6, async@^3.2.3, async@^3.2.6: +async@3.2.6, async@^3.2.3: version "3.2.6" resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce" integrity sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA== @@ -3666,41 +3831,38 @@ axios@^1.12.0, axios@^1.12.2: form-data "^4.0.4" proxy-from-env "^1.1.0" -babel-jest@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.7.0.tgz#f4369919225b684c56085998ac63dbd05be020d5" - integrity sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg== +babel-jest@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-30.2.0.tgz#fd44a1ec9552be35ead881f7381faa7d8f3b95ac" + integrity sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw== dependencies: - "@jest/transform" "^29.7.0" - "@types/babel__core" "^7.1.14" - babel-plugin-istanbul "^6.1.1" - babel-preset-jest "^29.6.3" - chalk "^4.0.0" - graceful-fs "^4.2.9" + "@jest/transform" "30.2.0" + "@types/babel__core" "^7.20.5" + babel-plugin-istanbul "^7.0.1" + babel-preset-jest "30.2.0" + chalk "^4.1.2" + graceful-fs "^4.2.11" slash "^3.0.0" -babel-plugin-istanbul@^6.1.1: - version "6.1.1" - resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz#fa88ec59232fd9b4e36dbbc540a8ec9a9b47da73" - integrity sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA== +babel-plugin-istanbul@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz#d8b518c8ea199364cf84ccc82de89740236daf92" + integrity sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA== dependencies: "@babel/helper-plugin-utils" "^7.0.0" "@istanbuljs/load-nyc-config" "^1.0.0" - "@istanbuljs/schema" "^0.1.2" - istanbul-lib-instrument "^5.0.4" + "@istanbuljs/schema" "^0.1.3" + istanbul-lib-instrument "^6.0.2" test-exclude "^6.0.0" -babel-plugin-jest-hoist@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz#aadbe943464182a8922c3c927c3067ff40d24626" - integrity sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg== +babel-plugin-jest-hoist@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz#94c250d36b43f95900f3a219241e0f4648191ce2" + integrity sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA== dependencies: - "@babel/template" "^7.3.3" - "@babel/types" "^7.3.3" - "@types/babel__core" "^7.1.14" - "@types/babel__traverse" "^7.0.6" + "@types/babel__core" "^7.20.5" -babel-preset-current-node-syntax@^1.0.0: +babel-preset-current-node-syntax@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz#20730d6cdc7dda5d89401cab10ac6a32067acde6" integrity sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg== @@ -3721,13 +3883,13 @@ babel-preset-current-node-syntax@^1.0.0: "@babel/plugin-syntax-private-property-in-object" "^7.14.5" "@babel/plugin-syntax-top-level-await" "^7.14.5" -babel-preset-jest@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz#fa05fa510e7d493896d7b0dd2033601c840f171c" - integrity sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA== +babel-preset-jest@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz#04717843e561347781d6d7f69c81e6bcc3ed11ce" + integrity sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ== dependencies: - babel-plugin-jest-hoist "^29.6.3" - babel-preset-current-node-syntax "^1.0.0" + babel-plugin-jest-hoist "30.2.0" + babel-preset-current-node-syntax "^1.2.0" balanced-match@^1.0.0: version "1.0.2" @@ -3744,10 +3906,10 @@ base64-js@^1.0.2, base64-js@^1.3.1: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== -baseline-browser-mapping@^2.8.25: - version "2.8.32" - resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz#5de72358cf363ac41e7d642af239f6ac5ed1270a" - integrity sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw== +baseline-browser-mapping@^2.8.25, baseline-browser-mapping@^2.9.0: + version "2.9.17" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.17.tgz#9d6019766cd7eba738cb5f32c84b9f937cc87780" + integrity sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ== bcryptjs@^2.4.3: version "2.4.3" @@ -3827,7 +3989,18 @@ braces@^3.0.3: dependencies: fill-range "^7.1.1" -browserslist@^4.24.0, browserslist@^4.26.3: +browserslist@^4.24.0: + version "4.28.1" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.1.tgz#7f534594628c53c63101079e27e40de490456a95" + integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA== + dependencies: + baseline-browser-mapping "^2.9.0" + caniuse-lite "^1.0.30001759" + electron-to-chromium "^1.5.263" + node-releases "^2.0.27" + update-browserslist-db "^1.2.0" + +browserslist@^4.26.3: version "4.28.0" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.0.tgz#9cefece0a386a17a3cd3d22ebf67b9deca1b5929" integrity sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ== @@ -3918,7 +4091,7 @@ call-bound@^1.0.2: call-bind-apply-helpers "^1.0.2" get-intrinsic "^1.3.0" -callsites@^3.0.0: +callsites@^3.0.0, callsites@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== @@ -3928,15 +4101,15 @@ camelcase@^5.0.0, camelcase@^5.3.1: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== -camelcase@^6.2.0: +camelcase@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== -caniuse-lite@^1.0.30001754: - version "1.0.30001759" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz#d569e7b010372c6b0ca3946e30dada0a2e9d5006" - integrity sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw== +caniuse-lite@^1.0.30001754, caniuse-lite@^1.0.30001759: + version "1.0.30001766" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz#b6f6b55cb25a2d888d9393104d14751c6a7d6f7a" + integrity sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA== "chalk@4.1 - 4.1.2", chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2: version "4.1.2" @@ -3988,16 +4161,21 @@ chrome-trace-event@^1.0.2: resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz#05bffd7ff928465093314708c93bdfa9bd1f0f5b" integrity sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ== -ci-info@^3.2.0: - version "3.9.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" - integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== +ci-info@^4.2.0: + version "4.3.1" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-4.3.1.tgz#355ad571920810b5623e11d40232f443f16f1daa" + integrity sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA== -cjs-module-lexer@^1.0.0, cjs-module-lexer@^1.2.2: +cjs-module-lexer@^1.2.2: version "1.4.3" resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz#0f79731eb8cfe1ec72acd4066efac9d61991b00d" integrity sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q== +cjs-module-lexer@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz#b3ca5101843389259ade7d88c77bd06ce55849ca" + integrity sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ== + class-transformer@^0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/class-transformer/-/class-transformer-0.5.1.tgz#24147d5dffd2a6cea930a3250a677addf96ab336" @@ -4095,7 +4273,7 @@ co@^4.6.0: resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ== -collect-v8-coverage@^1.0.0: +collect-v8-coverage@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz#cc1f01eb8d02298cbc9a437c74c70ab4e5210b80" integrity sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw== @@ -4266,19 +4444,6 @@ cosmiconfig@^8.2.0: parse-json "^5.2.0" path-type "^4.0.0" -create-jest@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/create-jest/-/create-jest-29.7.0.tgz#a355c5b3cb1e1af02ba177fe7afd7feee49a5320" - integrity sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q== - dependencies: - "@jest/types" "^29.6.3" - chalk "^4.0.0" - exit "^0.1.2" - graceful-fs "^4.2.9" - jest-config "^29.7.0" - jest-util "^29.7.0" - prompts "^2.0.1" - create-require@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" @@ -4353,17 +4518,17 @@ decamelize@^1.2.0: resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== -dedent@^1.0.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.7.0.tgz#c1f9445335f0175a96587be245a282ff451446ca" - integrity sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ== +dedent@^1.6.0: + version "1.7.1" + resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.7.1.tgz#364661eea3d73f3faba7089214420ec2f8f13e15" + integrity sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg== deep-is@^0.1.3: version "0.1.4" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== -deepmerge@^4.2.2: +deepmerge@^4.2.2, deepmerge@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== @@ -4390,7 +4555,7 @@ depd@^2.0.0, depd@~2.0.0: resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== -detect-newline@^3.0.0: +detect-newline@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== @@ -4403,11 +4568,6 @@ dezalgo@^1.0.4: asap "^2.0.0" wrappy "1" -diff-sequences@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" - integrity sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q== - diff@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" @@ -4500,17 +4660,10 @@ ee-first@1.1.1: resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== -ejs@^3.1.10: - version "3.1.10" - resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.10.tgz#69ab8358b14e896f80cc39e62087b88500c3ac3b" - integrity sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA== - dependencies: - jake "^10.8.5" - -electron-to-chromium@^1.5.249: - version "1.5.263" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.263.tgz#bec8f2887c30001dfacf415c136eae3b4386846a" - integrity sha512-DrqJ11Knd+lo+dv+lltvfMDLU27g14LMdH2b0O3Pio4uk0x+z7OR+JrmyacTPN2M8w3BrZ7/RTwG3R9B7irPlg== +electron-to-chromium@^1.5.249, electron-to-chromium@^1.5.263: + version "1.5.277" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.277.tgz#7164191a07bf32a7e646e68334f402dd60629821" + integrity sha512-wKXFZw4erWmmOz5N/grBoJ2XrNJGDFMu2+W5ACHza5rHtvsqrK4gb6rnLC7XxKB9WlJ+RmyQatuEXmtm86xbnw== emittery@^0.13.1: version "0.13.1" @@ -4767,7 +4920,7 @@ events@3.3.0, events@^3.2.0: resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== -execa@^5.0.0: +execa@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== @@ -4797,21 +4950,22 @@ execa@^8.0.1: signal-exit "^4.1.0" strip-final-newline "^3.0.0" -exit@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" - integrity sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ== +exit-x@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/exit-x/-/exit-x-0.2.2.tgz#1f9052de3b8d99a696b10dad5bced9bdd5c3aa64" + integrity sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ== -expect@^29.0.0, expect@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/expect/-/expect-29.7.0.tgz#578874590dcb3214514084c08115d8aee61e11bc" - integrity sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw== +expect@30.2.0, expect@^30.0.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/expect/-/expect-30.2.0.tgz#d4013bed267013c14bc1199cec8aa57cee9b5869" + integrity sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw== dependencies: - "@jest/expect-utils" "^29.7.0" - jest-get-type "^29.6.3" - jest-matcher-utils "^29.7.0" - jest-message-util "^29.7.0" - jest-util "^29.7.0" + "@jest/expect-utils" "30.2.0" + "@jest/get-type" "30.1.0" + jest-matcher-utils "30.2.0" + jest-message-util "30.2.0" + jest-mock "30.2.0" + jest-util "30.2.0" express@5.1.0: version "5.1.0" @@ -4913,7 +5067,7 @@ fastq@^1.6.0: dependencies: reusify "^1.0.4" -fb-watchman@^2.0.0: +fb-watchman@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.2.tgz#e9524ee6b5c77e9e5001af0f85f3adbb8623255c" integrity sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA== @@ -4959,13 +5113,6 @@ file-type@^3.3.0: resolved "https://registry.yarnpkg.com/file-type/-/file-type-3.9.0.tgz#257a078384d1db8087bc449d107d52a52672b9e9" integrity sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA== -filelist@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.4.tgz#f78978a1e944775ff9e62e744424f215e58352b5" - integrity sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q== - dependencies: - minimatch "^5.0.1" - fill-range@^7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" @@ -5129,7 +5276,7 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@^2.3.2: +fsevents@^2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== @@ -5256,7 +5403,7 @@ glob@7.2.0: once "^1.3.0" path-is-absolute "^1.0.0" -glob@^10.3.7, glob@^10.4.2: +glob@^10.3.10, glob@^10.3.7, glob@^10.4.2: version "10.5.0" resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== @@ -5304,7 +5451,7 @@ gopd@^1.2.0: resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== -graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.9: +graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.11, graceful-fs@^4.2.4: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== @@ -5314,6 +5461,18 @@ graphemer@^1.4.0: resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== +handlebars@^4.7.8: + version "4.7.8" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.8.tgz#41c42c18b1be2365439188c77c6afae71c0cd9e9" + integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ== + dependencies: + minimist "^1.2.5" + neo-async "^2.6.2" + source-map "^0.6.1" + wordwrap "^1.0.0" + optionalDependencies: + uglify-js "^3.1.4" + has-flag@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" @@ -5464,7 +5623,7 @@ import-in-the-middle@^1.13.0: cjs-module-lexer "^1.2.2" module-details-from-path "^1.0.3" -import-local@^3.0.2: +import-local@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.2.0.tgz#c3d5c745798c02a6f8b897726aba5100186ee260" integrity sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA== @@ -5568,7 +5727,7 @@ is-fullwidth-code-point@^5.0.0: dependencies: get-east-asian-width "^1.3.1" -is-generator-fn@^2.0.0: +is-generator-fn@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== @@ -5630,18 +5789,7 @@ istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== -istanbul-lib-instrument@^5.0.4: - version "5.2.1" - resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz#d10c8885c2125574e1c231cacadf955675e1ce3d" - integrity sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg== - dependencies: - "@babel/core" "^7.12.3" - "@babel/parser" "^7.14.7" - "@istanbuljs/schema" "^0.1.2" - istanbul-lib-coverage "^3.2.0" - semver "^6.3.0" - -istanbul-lib-instrument@^6.0.0: +istanbul-lib-instrument@^6.0.0, istanbul-lib-instrument@^6.0.2: version "6.0.3" resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz#fa15401df6c15874bcb2105f773325d78c666765" integrity sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q== @@ -5661,14 +5809,14 @@ istanbul-lib-report@^3.0.0: make-dir "^4.0.0" supports-color "^7.1.0" -istanbul-lib-source-maps@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz#895f3a709fcfba34c6de5a42939022f3e4358551" - integrity sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw== +istanbul-lib-source-maps@^5.0.0: + version "5.0.6" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz#acaef948df7747c8eb5fbf1265cb980f6353a441" + integrity sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A== dependencies: + "@jridgewell/trace-mapping" "^0.3.23" debug "^4.1.1" istanbul-lib-coverage "^3.0.0" - source-map "^0.6.1" istanbul-reports@^3.1.3: version "3.2.0" @@ -5692,381 +5840,370 @@ jackspeak@^3.1.2: optionalDependencies: "@pkgjs/parseargs" "^0.11.0" -jake@^10.8.5: - version "10.9.4" - resolved "https://registry.yarnpkg.com/jake/-/jake-10.9.4.tgz#d626da108c63d5cfb00ab5c25fadc7e0084af8e6" - integrity sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA== +jest-changed-files@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-30.2.0.tgz#602266e478ed554e1e1469944faa7efd37cee61c" + integrity sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ== dependencies: - async "^3.2.6" - filelist "^1.0.4" - picocolors "^1.1.1" - -jest-changed-files@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-29.7.0.tgz#1c06d07e77c78e1585d020424dedc10d6e17ac3a" - integrity sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w== - dependencies: - execa "^5.0.0" - jest-util "^29.7.0" + execa "^5.1.1" + jest-util "30.2.0" p-limit "^3.1.0" -jest-circus@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-29.7.0.tgz#b6817a45fcc835d8b16d5962d0c026473ee3668a" - integrity sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw== +jest-circus@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-30.2.0.tgz#98b8198b958748a2f322354311023d1d02e7603f" + integrity sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg== dependencies: - "@jest/environment" "^29.7.0" - "@jest/expect" "^29.7.0" - "@jest/test-result" "^29.7.0" - "@jest/types" "^29.6.3" + "@jest/environment" "30.2.0" + "@jest/expect" "30.2.0" + "@jest/test-result" "30.2.0" + "@jest/types" "30.2.0" "@types/node" "*" - chalk "^4.0.0" + chalk "^4.1.2" co "^4.6.0" - dedent "^1.0.0" - is-generator-fn "^2.0.0" - jest-each "^29.7.0" - jest-matcher-utils "^29.7.0" - jest-message-util "^29.7.0" - jest-runtime "^29.7.0" - jest-snapshot "^29.7.0" - jest-util "^29.7.0" + dedent "^1.6.0" + is-generator-fn "^2.1.0" + jest-each "30.2.0" + jest-matcher-utils "30.2.0" + jest-message-util "30.2.0" + jest-runtime "30.2.0" + jest-snapshot "30.2.0" + jest-util "30.2.0" p-limit "^3.1.0" - pretty-format "^29.7.0" - pure-rand "^6.0.0" + pretty-format "30.2.0" + pure-rand "^7.0.0" slash "^3.0.0" - stack-utils "^2.0.3" + stack-utils "^2.0.6" -jest-cli@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-29.7.0.tgz#5592c940798e0cae677eec169264f2d839a37995" - integrity sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg== +jest-cli@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-30.2.0.tgz#1780f8e9d66bf84a10b369aea60aeda7697dcc67" + integrity sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA== dependencies: - "@jest/core" "^29.7.0" - "@jest/test-result" "^29.7.0" - "@jest/types" "^29.6.3" - chalk "^4.0.0" - create-jest "^29.7.0" - exit "^0.1.2" - import-local "^3.0.2" - jest-config "^29.7.0" - jest-util "^29.7.0" - jest-validate "^29.7.0" - yargs "^17.3.1" - -jest-config@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-29.7.0.tgz#bcbda8806dbcc01b1e316a46bb74085a84b0245f" - integrity sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ== - dependencies: - "@babel/core" "^7.11.6" - "@jest/test-sequencer" "^29.7.0" - "@jest/types" "^29.6.3" - babel-jest "^29.7.0" - chalk "^4.0.0" - ci-info "^3.2.0" - deepmerge "^4.2.2" - glob "^7.1.3" - graceful-fs "^4.2.9" - jest-circus "^29.7.0" - jest-environment-node "^29.7.0" - jest-get-type "^29.6.3" - jest-regex-util "^29.6.3" - jest-resolve "^29.7.0" - jest-runner "^29.7.0" - jest-util "^29.7.0" - jest-validate "^29.7.0" - micromatch "^4.0.4" + "@jest/core" "30.2.0" + "@jest/test-result" "30.2.0" + "@jest/types" "30.2.0" + chalk "^4.1.2" + exit-x "^0.2.2" + import-local "^3.2.0" + jest-config "30.2.0" + jest-util "30.2.0" + jest-validate "30.2.0" + yargs "^17.7.2" + +jest-config@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-30.2.0.tgz#29df8c50e2ad801cc59c406b50176c18c362a90b" + integrity sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA== + dependencies: + "@babel/core" "^7.27.4" + "@jest/get-type" "30.1.0" + "@jest/pattern" "30.0.1" + "@jest/test-sequencer" "30.2.0" + "@jest/types" "30.2.0" + babel-jest "30.2.0" + chalk "^4.1.2" + ci-info "^4.2.0" + deepmerge "^4.3.1" + glob "^10.3.10" + graceful-fs "^4.2.11" + jest-circus "30.2.0" + jest-docblock "30.2.0" + jest-environment-node "30.2.0" + jest-regex-util "30.0.1" + jest-resolve "30.2.0" + jest-runner "30.2.0" + jest-util "30.2.0" + jest-validate "30.2.0" + micromatch "^4.0.8" parse-json "^5.2.0" - pretty-format "^29.7.0" + pretty-format "30.2.0" slash "^3.0.0" strip-json-comments "^3.1.1" -jest-diff@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.7.0.tgz#017934a66ebb7ecf6f205e84699be10afd70458a" - integrity sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw== +jest-diff@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-30.2.0.tgz#e3ec3a6ea5c5747f605c9e874f83d756cba36825" + integrity sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A== dependencies: - chalk "^4.0.0" - diff-sequences "^29.6.3" - jest-get-type "^29.6.3" - pretty-format "^29.7.0" + "@jest/diff-sequences" "30.0.1" + "@jest/get-type" "30.1.0" + chalk "^4.1.2" + pretty-format "30.2.0" -jest-docblock@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-29.7.0.tgz#8fddb6adc3cdc955c93e2a87f61cfd350d5d119a" - integrity sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g== +jest-docblock@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-30.2.0.tgz#42cd98d69f887e531c7352309542b1ce4ee10256" + integrity sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA== dependencies: - detect-newline "^3.0.0" + detect-newline "^3.1.0" -jest-each@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-29.7.0.tgz#162a9b3f2328bdd991beaabffbb74745e56577d1" - integrity sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ== +jest-each@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-30.2.0.tgz#39e623ae71641c2ac3ee69b3ba3d258fce8e768d" + integrity sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ== dependencies: - "@jest/types" "^29.6.3" - chalk "^4.0.0" - jest-get-type "^29.6.3" - jest-util "^29.7.0" - pretty-format "^29.7.0" - -jest-environment-node@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-29.7.0.tgz#0b93e111dda8ec120bc8300e6d1fb9576e164376" - integrity sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw== - dependencies: - "@jest/environment" "^29.7.0" - "@jest/fake-timers" "^29.7.0" - "@jest/types" "^29.6.3" - "@types/node" "*" - jest-mock "^29.7.0" - jest-util "^29.7.0" + "@jest/get-type" "30.1.0" + "@jest/types" "30.2.0" + chalk "^4.1.2" + jest-util "30.2.0" + pretty-format "30.2.0" -jest-get-type@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.6.3.tgz#36f499fdcea197c1045a127319c0481723908fd1" - integrity sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw== +jest-environment-node@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-30.2.0.tgz#3def7980ebd2fd86e74efd4d2e681f55ab38da0f" + integrity sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA== + dependencies: + "@jest/environment" "30.2.0" + "@jest/fake-timers" "30.2.0" + "@jest/types" "30.2.0" + "@types/node" "*" + jest-mock "30.2.0" + jest-util "30.2.0" + jest-validate "30.2.0" -jest-haste-map@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-29.7.0.tgz#3c2396524482f5a0506376e6c858c3bbcc17b104" - integrity sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA== +jest-haste-map@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-30.2.0.tgz#808e3889f288603ac70ff0ac047598345a66022e" + integrity sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw== dependencies: - "@jest/types" "^29.6.3" - "@types/graceful-fs" "^4.1.3" + "@jest/types" "30.2.0" "@types/node" "*" - anymatch "^3.0.3" - fb-watchman "^2.0.0" - graceful-fs "^4.2.9" - jest-regex-util "^29.6.3" - jest-util "^29.7.0" - jest-worker "^29.7.0" - micromatch "^4.0.4" + anymatch "^3.1.3" + fb-watchman "^2.0.2" + graceful-fs "^4.2.11" + jest-regex-util "30.0.1" + jest-util "30.2.0" + jest-worker "30.2.0" + micromatch "^4.0.8" walker "^1.0.8" optionalDependencies: - fsevents "^2.3.2" + fsevents "^2.3.3" -jest-leak-detector@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz#5b7ec0dadfdfec0ca383dc9aa016d36b5ea4c728" - integrity sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw== +jest-leak-detector@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz#292fdca7b7c9cf594e1e570ace140b01d8beb736" + integrity sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ== dependencies: - jest-get-type "^29.6.3" - pretty-format "^29.7.0" + "@jest/get-type" "30.1.0" + pretty-format "30.2.0" -jest-matcher-utils@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz#ae8fec79ff249fd592ce80e3ee474e83a6c44f12" - integrity sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g== +jest-matcher-utils@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz#69a0d4c271066559ec8b0d8174829adc3f23a783" + integrity sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg== dependencies: - chalk "^4.0.0" - jest-diff "^29.7.0" - jest-get-type "^29.6.3" - pretty-format "^29.7.0" - -jest-message-util@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.7.0.tgz#8bc392e204e95dfe7564abbe72a404e28e51f7f3" - integrity sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w== - dependencies: - "@babel/code-frame" "^7.12.13" - "@jest/types" "^29.6.3" - "@types/stack-utils" "^2.0.0" - chalk "^4.0.0" - graceful-fs "^4.2.9" - micromatch "^4.0.4" - pretty-format "^29.7.0" + "@jest/get-type" "30.1.0" + chalk "^4.1.2" + jest-diff "30.2.0" + pretty-format "30.2.0" + +jest-message-util@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-30.2.0.tgz#fc97bf90d11f118b31e6131e2b67fc4f39f92152" + integrity sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw== + dependencies: + "@babel/code-frame" "^7.27.1" + "@jest/types" "30.2.0" + "@types/stack-utils" "^2.0.3" + chalk "^4.1.2" + graceful-fs "^4.2.11" + micromatch "^4.0.8" + pretty-format "30.2.0" slash "^3.0.0" - stack-utils "^2.0.3" + stack-utils "^2.0.6" -jest-mock@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.7.0.tgz#4e836cf60e99c6fcfabe9f99d017f3fdd50a6347" - integrity sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw== +jest-mock@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-30.2.0.tgz#69f991614eeb4060189459d3584f710845bff45e" + integrity sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw== dependencies: - "@jest/types" "^29.6.3" + "@jest/types" "30.2.0" "@types/node" "*" - jest-util "^29.7.0" + jest-util "30.2.0" -jest-pnp-resolver@^1.2.2: +jest-pnp-resolver@^1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz#930b1546164d4ad5937d5540e711d4d38d4cad2e" integrity sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w== -jest-regex-util@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-29.6.3.tgz#4a556d9c776af68e1c5f48194f4d0327d24e8a52" - integrity sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg== +jest-regex-util@30.0.1: + version "30.0.1" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-30.0.1.tgz#f17c1de3958b67dfe485354f5a10093298f2a49b" + integrity sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA== -jest-resolve-dependencies@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz#1b04f2c095f37fc776ff40803dc92921b1e88428" - integrity sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA== +jest-resolve-dependencies@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz#3370e2c0b49cc560f6a7e8ec3a59dd99525e1a55" + integrity sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w== dependencies: - jest-regex-util "^29.6.3" - jest-snapshot "^29.7.0" + jest-regex-util "30.0.1" + jest-snapshot "30.2.0" -jest-resolve@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-29.7.0.tgz#64d6a8992dd26f635ab0c01e5eef4399c6bcbc30" - integrity sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA== +jest-resolve@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-30.2.0.tgz#2e2009cbd61e8f1f003355d5ec87225412cebcd7" + integrity sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A== dependencies: - chalk "^4.0.0" - graceful-fs "^4.2.9" - jest-haste-map "^29.7.0" - jest-pnp-resolver "^1.2.2" - jest-util "^29.7.0" - jest-validate "^29.7.0" - resolve "^1.20.0" - resolve.exports "^2.0.0" + chalk "^4.1.2" + graceful-fs "^4.2.11" + jest-haste-map "30.2.0" + jest-pnp-resolver "^1.2.3" + jest-util "30.2.0" + jest-validate "30.2.0" slash "^3.0.0" - -jest-runner@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-29.7.0.tgz#809af072d408a53dcfd2e849a4c976d3132f718e" - integrity sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ== - dependencies: - "@jest/console" "^29.7.0" - "@jest/environment" "^29.7.0" - "@jest/test-result" "^29.7.0" - "@jest/transform" "^29.7.0" - "@jest/types" "^29.6.3" + unrs-resolver "^1.7.11" + +jest-runner@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-30.2.0.tgz#c62b4c3130afa661789705e13a07bdbcec26a114" + integrity sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ== + dependencies: + "@jest/console" "30.2.0" + "@jest/environment" "30.2.0" + "@jest/test-result" "30.2.0" + "@jest/transform" "30.2.0" + "@jest/types" "30.2.0" "@types/node" "*" - chalk "^4.0.0" + chalk "^4.1.2" emittery "^0.13.1" - graceful-fs "^4.2.9" - jest-docblock "^29.7.0" - jest-environment-node "^29.7.0" - jest-haste-map "^29.7.0" - jest-leak-detector "^29.7.0" - jest-message-util "^29.7.0" - jest-resolve "^29.7.0" - jest-runtime "^29.7.0" - jest-util "^29.7.0" - jest-watcher "^29.7.0" - jest-worker "^29.7.0" + exit-x "^0.2.2" + graceful-fs "^4.2.11" + jest-docblock "30.2.0" + jest-environment-node "30.2.0" + jest-haste-map "30.2.0" + jest-leak-detector "30.2.0" + jest-message-util "30.2.0" + jest-resolve "30.2.0" + jest-runtime "30.2.0" + jest-util "30.2.0" + jest-watcher "30.2.0" + jest-worker "30.2.0" p-limit "^3.1.0" source-map-support "0.5.13" -jest-runtime@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-29.7.0.tgz#efecb3141cf7d3767a3a0cc8f7c9990587d3d817" - integrity sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ== - dependencies: - "@jest/environment" "^29.7.0" - "@jest/fake-timers" "^29.7.0" - "@jest/globals" "^29.7.0" - "@jest/source-map" "^29.6.3" - "@jest/test-result" "^29.7.0" - "@jest/transform" "^29.7.0" - "@jest/types" "^29.6.3" +jest-runtime@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-30.2.0.tgz#395ea792cde048db1b0cd1a92dc9cb9f1921bf8a" + integrity sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg== + dependencies: + "@jest/environment" "30.2.0" + "@jest/fake-timers" "30.2.0" + "@jest/globals" "30.2.0" + "@jest/source-map" "30.0.1" + "@jest/test-result" "30.2.0" + "@jest/transform" "30.2.0" + "@jest/types" "30.2.0" "@types/node" "*" - chalk "^4.0.0" - cjs-module-lexer "^1.0.0" - collect-v8-coverage "^1.0.0" - glob "^7.1.3" - graceful-fs "^4.2.9" - jest-haste-map "^29.7.0" - jest-message-util "^29.7.0" - jest-mock "^29.7.0" - jest-regex-util "^29.6.3" - jest-resolve "^29.7.0" - jest-snapshot "^29.7.0" - jest-util "^29.7.0" + chalk "^4.1.2" + cjs-module-lexer "^2.1.0" + collect-v8-coverage "^1.0.2" + glob "^10.3.10" + graceful-fs "^4.2.11" + jest-haste-map "30.2.0" + jest-message-util "30.2.0" + jest-mock "30.2.0" + jest-regex-util "30.0.1" + jest-resolve "30.2.0" + jest-snapshot "30.2.0" + jest-util "30.2.0" slash "^3.0.0" strip-bom "^4.0.0" -jest-snapshot@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-29.7.0.tgz#c2c574c3f51865da1bb329036778a69bf88a6be5" - integrity sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw== - dependencies: - "@babel/core" "^7.11.6" - "@babel/generator" "^7.7.2" - "@babel/plugin-syntax-jsx" "^7.7.2" - "@babel/plugin-syntax-typescript" "^7.7.2" - "@babel/types" "^7.3.3" - "@jest/expect-utils" "^29.7.0" - "@jest/transform" "^29.7.0" - "@jest/types" "^29.6.3" - babel-preset-current-node-syntax "^1.0.0" - chalk "^4.0.0" - expect "^29.7.0" - graceful-fs "^4.2.9" - jest-diff "^29.7.0" - jest-get-type "^29.6.3" - jest-matcher-utils "^29.7.0" - jest-message-util "^29.7.0" - jest-util "^29.7.0" - natural-compare "^1.4.0" - pretty-format "^29.7.0" - semver "^7.5.3" - -jest-util@^29.0.0, jest-util@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.7.0.tgz#23c2b62bfb22be82b44de98055802ff3710fc0bc" - integrity sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA== - dependencies: - "@jest/types" "^29.6.3" +jest-snapshot@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-30.2.0.tgz#266fbbb4b95fc4665ce6f32f1f38eeb39f4e26d0" + integrity sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA== + dependencies: + "@babel/core" "^7.27.4" + "@babel/generator" "^7.27.5" + "@babel/plugin-syntax-jsx" "^7.27.1" + "@babel/plugin-syntax-typescript" "^7.27.1" + "@babel/types" "^7.27.3" + "@jest/expect-utils" "30.2.0" + "@jest/get-type" "30.1.0" + "@jest/snapshot-utils" "30.2.0" + "@jest/transform" "30.2.0" + "@jest/types" "30.2.0" + babel-preset-current-node-syntax "^1.2.0" + chalk "^4.1.2" + expect "30.2.0" + graceful-fs "^4.2.11" + jest-diff "30.2.0" + jest-matcher-utils "30.2.0" + jest-message-util "30.2.0" + jest-util "30.2.0" + pretty-format "30.2.0" + semver "^7.7.2" + synckit "^0.11.8" + +jest-util@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-30.2.0.tgz#5142adbcad6f4e53c2776c067a4db3c14f913705" + integrity sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA== + dependencies: + "@jest/types" "30.2.0" "@types/node" "*" - chalk "^4.0.0" - ci-info "^3.2.0" - graceful-fs "^4.2.9" - picomatch "^2.2.3" + chalk "^4.1.2" + ci-info "^4.2.0" + graceful-fs "^4.2.11" + picomatch "^4.0.2" -jest-validate@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-29.7.0.tgz#7bf705511c64da591d46b15fce41400d52147d9c" - integrity sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw== +jest-validate@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-30.2.0.tgz#273eaaed4c0963b934b5b31e96289edda6e0a2ef" + integrity sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw== dependencies: - "@jest/types" "^29.6.3" - camelcase "^6.2.0" - chalk "^4.0.0" - jest-get-type "^29.6.3" + "@jest/get-type" "30.1.0" + "@jest/types" "30.2.0" + camelcase "^6.3.0" + chalk "^4.1.2" leven "^3.1.0" - pretty-format "^29.7.0" + pretty-format "30.2.0" -jest-watcher@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-29.7.0.tgz#7810d30d619c3a62093223ce6bb359ca1b28a2f2" - integrity sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g== +jest-watcher@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-30.2.0.tgz#f9c055de48e18c979e7756a3917e596e2d69b07b" + integrity sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg== dependencies: - "@jest/test-result" "^29.7.0" - "@jest/types" "^29.6.3" + "@jest/test-result" "30.2.0" + "@jest/types" "30.2.0" "@types/node" "*" - ansi-escapes "^4.2.1" - chalk "^4.0.0" + ansi-escapes "^4.3.2" + chalk "^4.1.2" emittery "^0.13.1" - jest-util "^29.7.0" - string-length "^4.0.1" + jest-util "30.2.0" + string-length "^4.0.2" -jest-worker@^27.4.5: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-27.5.1.tgz#8d146f0900e8973b106b6f73cc1e9a8cb86f8db0" - integrity sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg== +jest-worker@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-30.2.0.tgz#fd5c2a36ff6058ec8f74366ec89538cc99539d26" + integrity sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g== dependencies: "@types/node" "*" + "@ungap/structured-clone" "^1.3.0" + jest-util "30.2.0" merge-stream "^2.0.0" - supports-color "^8.0.0" + supports-color "^8.1.1" -jest-worker@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-29.7.0.tgz#acad073acbbaeb7262bd5389e1bcf43e10058d4a" - integrity sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw== +jest-worker@^27.4.5: + version "27.5.1" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-27.5.1.tgz#8d146f0900e8973b106b6f73cc1e9a8cb86f8db0" + integrity sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg== dependencies: "@types/node" "*" - jest-util "^29.7.0" merge-stream "^2.0.0" supports-color "^8.0.0" -jest@29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest/-/jest-29.7.0.tgz#994676fc24177f088f1c5e3737f5697204ff2613" - integrity sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw== +jest@^30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest/-/jest-30.2.0.tgz#9f0a71e734af968f26952b5ae4b724af82681630" + integrity sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A== dependencies: - "@jest/core" "^29.7.0" - "@jest/types" "^29.6.3" - import-local "^3.0.2" - jest-cli "^29.7.0" + "@jest/core" "30.2.0" + "@jest/types" "30.2.0" + import-local "^3.2.0" + jest-cli "30.2.0" joycon@^3.1.1: version "3.1.1" @@ -6272,11 +6409,6 @@ keyv@^5.3.3: dependencies: "@keyv/serialize" "^1.1.1" -kleur@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" - integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== - kuler@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3" @@ -6574,7 +6706,7 @@ methods@^1.1.2: resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== -micromatch@^4.0.0, micromatch@^4.0.4, micromatch@^4.0.8: +micromatch@^4.0.0, micromatch@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== @@ -6659,13 +6791,6 @@ minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" -minimatch@^5.0.1: - version "5.1.6" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" - integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== - dependencies: - brace-expansion "^2.0.1" - minimatch@^9.0.4: version "9.0.5" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" @@ -6755,6 +6880,11 @@ nan@^2.22.2: resolved "https://registry.yarnpkg.com/nan/-/nan-2.23.1.tgz#6f86a31dd87e3d1eb77512bf4b9e14c8aded3975" integrity sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw== +napi-postinstall@^0.3.0: + version "0.3.4" + resolved "https://registry.yarnpkg.com/napi-postinstall/-/napi-postinstall-0.3.4.tgz#7af256d6588b5f8e952b9190965d6b019653bbb9" + integrity sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ== + natural-compare-lite@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz#17b09581988979fddafe0201e931ba933c96cbb4" @@ -7215,11 +7345,16 @@ picomatch@4.0.2: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab" integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg== -picomatch@^2.0.4, picomatch@^2.2.3, picomatch@^2.3.1: +picomatch@^2.0.4, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== +picomatch@^4.0.2: + version "4.0.3" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" + integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== + pidtree@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/pidtree/-/pidtree-0.6.0.tgz#90ad7b6d42d5841e69e0a2419ef38f8883aa057c" @@ -7290,7 +7425,7 @@ pino@^10.0.0: sonic-boom "^4.0.1" thread-stream "^3.0.0" -pirates@^4.0.4: +pirates@^4.0.7: version "4.0.7" resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.7.tgz#643b4a18c4257c8a65104b73f3049ce9a0a15e22" integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA== @@ -7368,14 +7503,14 @@ pretty-bytes@^5.6.0: resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg== -pretty-format@^29.0.0, pretty-format@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" - integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== +pretty-format@30.2.0, pretty-format@^30.0.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-30.2.0.tgz#2d44fe6134529aed18506f6d11509d8a62775ebe" + integrity sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA== dependencies: - "@jest/schemas" "^29.6.3" - ansi-styles "^5.0.0" - react-is "^18.0.0" + "@jest/schemas" "30.0.5" + ansi-styles "^5.2.0" + react-is "^18.3.1" prettysize@^2.0.0: version "2.0.0" @@ -7387,14 +7522,6 @@ process-warning@^5.0.0: resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-5.0.0.tgz#566e0bf79d1dff30a72d8bbbe9e8ecefe8d378d7" integrity sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA== -prompts@^2.0.1: - version "2.4.2" - resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" - integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q== - dependencies: - kleur "^3.0.3" - sisteransi "^1.0.5" - proto-list@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" @@ -7444,10 +7571,10 @@ punycode@^2.1.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== -pure-rand@^6.0.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.1.0.tgz#d173cf23258231976ccbdb05247c9787957604f2" - integrity sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA== +pure-rand@^7.0.0: + version "7.0.1" + resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-7.0.1.tgz#6f53a5a9e3e4a47445822af96821ca509ed37566" + integrity sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ== qrcode@^1.4.4: version "1.5.4" @@ -7510,7 +7637,7 @@ raw-body@^3.0.1: iconv-lite "~0.7.0" unpipe "~1.0.0" -react-is@^18.0.0: +react-is@^18.3.1: version "18.3.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== @@ -7608,12 +7735,7 @@ resolve-from@^5.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== -resolve.exports@^2.0.0: - version "2.0.3" - resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-2.0.3.tgz#41955e6f1b4013b7586f873749a635dea07ebe3f" - integrity sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A== - -resolve@^1.20.0, resolve@^1.22.1, resolve@^1.22.8: +resolve@^1.22.1, resolve@^1.22.8: version "1.22.11" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.11.tgz#aad857ce1ffb8bfa9b0b1ac29f1156383f68c262" integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ== @@ -7755,12 +7877,12 @@ secure-json-parse@^4.0.0: resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-4.1.0.tgz#4f1ab41c67a13497ea1b9131bb4183a22865477c" integrity sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA== -semver@^6.3.0, semver@^6.3.1: +semver@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4, semver@^7.7.1, semver@^7.7.2: +semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4, semver@^7.7.2, semver@^7.7.3: version "7.7.3" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== @@ -7908,7 +8030,7 @@ side-channel@^1.1.0: side-channel-map "^1.0.1" side-channel-weakmap "^1.0.2" -signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7: +signal-exit@^3.0.2, signal-exit@^3.0.3: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== @@ -7930,11 +8052,6 @@ sinon@^18.0.1: nise "^6.0.0" supports-color "^7" -sisteransi@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" - integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== - slash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" @@ -8030,7 +8147,7 @@ stack-trace@0.0.x: resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" integrity sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg== -stack-utils@^2.0.3: +stack-utils@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" integrity sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== @@ -8074,7 +8191,7 @@ string-argv@^0.3.2: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== -string-length@^4.0.1: +string-length@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" integrity sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ== @@ -8231,7 +8348,7 @@ supports-color@^7, supports-color@^7.1.0: dependencies: has-flag "^4.0.0" -supports-color@^8.0.0: +supports-color@^8.0.0, supports-color@^8.1.1: version "8.1.1" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== @@ -8276,6 +8393,13 @@ synckit@^0.11.7: dependencies: "@pkgr/core" "^0.2.9" +synckit@^0.11.8: + version "0.11.12" + resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.12.tgz#abe74124264fbc00a48011b0d98bdc1cffb64a7b" + integrity sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ== + dependencies: + "@pkgr/core" "^0.2.9" + tapable@^2.2.0, tapable@^2.2.1, tapable@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.0.tgz#7e3ea6d5ca31ba8e078b560f0d83ce9a14aa8be6" @@ -8390,20 +8514,19 @@ ts-api-utils@^1.0.1: resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.4.3.tgz#bfc2215fe6528fecab2b0fba570a2e8a4263b064" integrity sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw== -ts-jest@29.3.2: - version "29.3.2" - resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.3.2.tgz#0576cdf0a507f811fe73dcd16d135ce89f8156cb" - integrity sha512-bJJkrWc6PjFVz5g2DGCNUo8z7oFEYaz1xP1NpeDU7KNLMWPpEyV8Chbpkn8xjzgRDpQhnGMyvyldoL7h8JXyug== +ts-jest@^29.4.6: + version "29.4.6" + resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.4.6.tgz#51cb7c133f227396818b71297ad7409bb77106e9" + integrity sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA== dependencies: bs-logger "^0.2.6" - ejs "^3.1.10" fast-json-stable-stringify "^2.1.0" - jest-util "^29.0.0" + handlebars "^4.7.8" json5 "^2.2.3" lodash.memoize "^4.1.2" make-error "^1.3.6" - semver "^7.7.1" - type-fest "^4.39.1" + semver "^7.7.3" + type-fest "^4.41.0" yargs-parser "^21.1.1" ts-loader@^9.5.2: @@ -8455,7 +8578,7 @@ tsconfig-paths@4.2.0, tsconfig-paths@^4.1.2: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@2.8.1, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.6.2: +tslib@2.8.1, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.4.0, tslib@^2.6.2: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -8499,7 +8622,7 @@ type-fest@^0.21.3: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== -type-fest@^4.39.1: +type-fest@^4.41.0: version "4.41.0" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.41.0.tgz#6ae1c8e5731273c2bf1f58ad39cbae2c91a46c58" integrity sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA== @@ -8531,6 +8654,11 @@ typescript@5.9.3, typescript@^5.8.3: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== +uglify-js@^3.1.4: + version "3.19.3" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.19.3.tgz#82315e9bbc6f2b25888858acd1fff8441035b77f" + integrity sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ== + uid@2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/uid/-/uid-2.0.2.tgz#4b5782abf0f2feeefc00fa88006b2b3b7af3e3b9" @@ -8596,10 +8724,37 @@ unpipe@~1.0.0: resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== -update-browserslist-db@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz#7802aa2ae91477f255b86e0e46dbc787a206ad4a" - integrity sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A== +unrs-resolver@^1.7.11: + version "1.11.1" + resolved "https://registry.yarnpkg.com/unrs-resolver/-/unrs-resolver-1.11.1.tgz#be9cd8686c99ef53ecb96df2a473c64d304048a9" + integrity sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg== + dependencies: + napi-postinstall "^0.3.0" + optionalDependencies: + "@unrs/resolver-binding-android-arm-eabi" "1.11.1" + "@unrs/resolver-binding-android-arm64" "1.11.1" + "@unrs/resolver-binding-darwin-arm64" "1.11.1" + "@unrs/resolver-binding-darwin-x64" "1.11.1" + "@unrs/resolver-binding-freebsd-x64" "1.11.1" + "@unrs/resolver-binding-linux-arm-gnueabihf" "1.11.1" + "@unrs/resolver-binding-linux-arm-musleabihf" "1.11.1" + "@unrs/resolver-binding-linux-arm64-gnu" "1.11.1" + "@unrs/resolver-binding-linux-arm64-musl" "1.11.1" + "@unrs/resolver-binding-linux-ppc64-gnu" "1.11.1" + "@unrs/resolver-binding-linux-riscv64-gnu" "1.11.1" + "@unrs/resolver-binding-linux-riscv64-musl" "1.11.1" + "@unrs/resolver-binding-linux-s390x-gnu" "1.11.1" + "@unrs/resolver-binding-linux-x64-gnu" "1.11.1" + "@unrs/resolver-binding-linux-x64-musl" "1.11.1" + "@unrs/resolver-binding-wasm32-wasi" "1.11.1" + "@unrs/resolver-binding-win32-arm64-msvc" "1.11.1" + "@unrs/resolver-binding-win32-ia32-msvc" "1.11.1" + "@unrs/resolver-binding-win32-x64-msvc" "1.11.1" + +update-browserslist-db@^1.1.4, update-browserslist-db@^1.2.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d" + integrity sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w== dependencies: escalade "^3.2.0" picocolors "^1.1.1" @@ -8773,6 +8928,11 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== +wordwrap@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== + "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" @@ -8823,13 +8983,13 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== -write-file-atomic@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-4.0.2.tgz#a9df01ae5b77858a027fd2e80768ee433555fcfd" - integrity sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg== +write-file-atomic@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-5.0.1.tgz#68df4717c55c6fa4281a7860b4c2ba0a6d2b11e7" + integrity sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw== dependencies: imurmurhash "^0.1.4" - signal-exit "^3.0.7" + signal-exit "^4.0.1" ws@^8.17.1: version "8.18.3" @@ -8914,7 +9074,7 @@ yargs@^16.2.0: y18n "^5.0.5" yargs-parser "^20.2.2" -yargs@^17.3.1, yargs@^17.7.2: +yargs@^17.7.2: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== From dc77aa99ad16b34dd9ba3fe31066f3f4fe0d4fd9 Mon Sep 17 00:00:00 2001 From: tamarafinogina Date: Fri, 23 Jan 2026 10:23:35 +0100 Subject: [PATCH 55/71] run yarn format --- src/app.module.ts | 6 +- src/config/configuration.ts | 12 +- .../custom-endpoint-throttle.guard.spec.ts | 185 ++++++++++++------ src/guards/custom-endpoint-throttle.guard.ts | 20 +- src/guards/throttler.guard.ts | 2 +- src/guards/throttler.interceptor.spec.ts | 37 +++- src/guards/throttler.interceptor.ts | 40 ++-- src/guards/throttler.module.ts | 24 +-- src/lib/newrelic.interceptor.ts | 21 +- .../cache-manager/cache-manager.module.ts | 4 +- .../cache-manager.service.spec.ts | 42 ++-- .../cache-manager/cache-manager.service.ts | 21 +- .../delete-file-version.action.spec.ts | 12 +- .../actions/get-file-versions.action.spec.ts | 6 +- src/modules/folder/folder.controller.ts | 2 +- src/modules/folder/folder.module.ts | 6 +- src/modules/sharing/sharing.module.ts | 2 +- src/modules/user/user.controller.ts | 4 +- src/modules/user/user.module.ts | 2 +- 19 files changed, 294 insertions(+), 154 deletions(-) diff --git a/src/app.module.ts b/src/app.module.ts index 3fcbe64d4..416a076a2 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -156,12 +156,12 @@ import { CustomThrottlerModule } from './guards/throttler.module'; }, { provide: APP_GUARD, - useClass: AuthGuard + useClass: AuthGuard, }, { provide: APP_GUARD, - useClass: CustomThrottlerGuard - } + useClass: CustomThrottlerGuard, + }, ], }) export class AppModule {} diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 48ffbe4a6..97d301414 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -146,21 +146,21 @@ export default () => ({ rateLimit: { default: { ttl: process.env.RATE_LIMIT_DEFAULT_TTL, - limit: process.env.RATE_LIMIT_DEFAULT_LIMIT + limit: process.env.RATE_LIMIT_DEFAULT_LIMIT, }, anonymous: { ttl: process.env.RATE_LIMIT_ANON_TTL, - limit: process.env.RATE_LIMIT_ANON_LIMIT + limit: process.env.RATE_LIMIT_ANON_LIMIT, }, free: { ttl: process.env.RATE_LIMIT_FREE_TTL, - limit: process.env.RATE_LIMIT_FREE_LIMIT + limit: process.env.RATE_LIMIT_FREE_LIMIT, }, paid: { ttl: process.env.RATE_LIMIT_PAID_TTL, - limit: process.env.RATE_LIMIT_PAID_LIMIT - } - } + limit: process.env.RATE_LIMIT_PAID_LIMIT, + }, + }, }, jitsi: { appId: process.env.JITSI_APP_ID, diff --git a/src/guards/custom-endpoint-throttle.guard.spec.ts b/src/guards/custom-endpoint-throttle.guard.spec.ts index 9b864440d..e1d0f6b15 100644 --- a/src/guards/custom-endpoint-throttle.guard.spec.ts +++ b/src/guards/custom-endpoint-throttle.guard.spec.ts @@ -19,85 +19,144 @@ describe('CustomThrottleGuard', () => { describe('canActivate', () => { it('When reflector returns no metadata then the guard checks are skipped', async () => { - (reflector.get as jest.Mock).mockReturnValue(undefined); - const context = tsjest.createMock(); + (reflector.get as jest.Mock).mockReturnValue(undefined); + const context = tsjest.createMock(); - const result = await guard.canActivate(context); + const result = await guard.canActivate(context); - expect(result).toBe(true); - expect(cacheService.increment).not.toHaveBeenCalled(); + expect(result).toBe(true); + expect(cacheService.increment).not.toHaveBeenCalled(); }); describe('Applying a single policy', () => { - const route = '/login'; - - it('When under limit then it allows the request to pass', async () => { - const policy = { ttl: 60, limit: 5 }; - (reflector.get as jest.Mock).mockReturnValue(policy); - - const request: any = { route: { path: route }, user: { uuid: 'user-1' }, ip: '1.2.3.4' }; - (cacheService.increment as jest.Mock).mockResolvedValue({ totalHits: 1, timeToExpire: 5000 }); - const context = tsjest.createMock(); - (context as any).switchToHttp = () => ({ getRequest: () => request }); - - const result = await guard.canActivate(context); - - expect(result).toBe(true); - expect(cacheService.increment).toHaveBeenCalledWith(`${request.route.path}:policy0:cet:uid:${request.user.uuid}`, 60); + const route = '/login'; + + it('When under limit then it allows the request to pass', async () => { + const policy = { ttl: 60, limit: 5 }; + (reflector.get as jest.Mock).mockReturnValue(policy); + + const request: any = { + route: { path: route }, + user: { uuid: 'user-1' }, + ip: '1.2.3.4', + }; + (cacheService.increment as jest.Mock).mockResolvedValue({ + totalHits: 1, + timeToExpire: 5000, }); + const context = tsjest.createMock(); + (context as any).switchToHttp = () => ({ getRequest: () => request }); - it('When over the limit then the request is throttled', async () => { - const policy = { ttl: 60, limit: 1 }; - (reflector.get as jest.Mock).mockReturnValue(policy); - - const request: any = { route: { path: route }, user: { uuid: 'user-2' }, ip: '2.2.2.2' }; - (cacheService.increment as jest.Mock).mockResolvedValue({ totalHits: 2, timeToExpire: 1000 }); - const context = tsjest.createMock(); - (context as any).switchToHttp = () => ({ getRequest: () => request }); + const result = await guard.canActivate(context); - await expect(guard.canActivate(context)).rejects.toBeInstanceOf(ThrottlerException); - expect(cacheService.increment).toHaveBeenCalledWith(`${request.route.path}:policy0:cet:uid:${request.user.uuid}`, 60); + expect(result).toBe(true); + expect(cacheService.increment).toHaveBeenCalledWith( + `${request.route.path}:policy0:cet:uid:${request.user.uuid}`, + 60, + ); + }); + + it('When over the limit then the request is throttled', async () => { + const policy = { ttl: 60, limit: 1 }; + (reflector.get as jest.Mock).mockReturnValue(policy); + + const request: any = { + route: { path: route }, + user: { uuid: 'user-2' }, + ip: '2.2.2.2', + }; + (cacheService.increment as jest.Mock).mockResolvedValue({ + totalHits: 2, + timeToExpire: 1000, }); + const context = tsjest.createMock(); + (context as any).switchToHttp = () => ({ getRequest: () => request }); + + await expect(guard.canActivate(context)).rejects.toBeInstanceOf( + ThrottlerException, + ); + expect(cacheService.increment).toHaveBeenCalledWith( + `${request.route.path}:policy0:cet:uid:${request.user.uuid}`, + 60, + ); + }); }); describe('Applying multiple policies', () => { - const route = '/login'; + const route = '/login'; + + it('When under limits then it allows the request to pass', async () => { + const named = { + short: { ttl: 60, limit: 5 }, + long: { ttl: 3600, limit: 30 }, + }; + (reflector.get as jest.Mock).mockReturnValue(named); + const request: any = { + route: { path: route }, + user: null, + ip: '9.9.9.9', + }; + + (cacheService.increment as jest.Mock) + .mockResolvedValueOnce({ + totalHits: named.short.limit - 1, + timeToExpire: 100, + }) + .mockResolvedValueOnce({ + totalHits: named.long.limit - 1, + timeToExpire: 1000, + }); - it('When under limits then it allows the request to pass', async () => { - const named = { short: { ttl: 60, limit: 5 }, long: { ttl: 3600, limit: 30 } }; - (reflector.get as jest.Mock).mockReturnValue(named); - const request: any = { route: { path: route }, user: null, ip: '9.9.9.9' }; - - (cacheService.increment as jest.Mock) - .mockResolvedValueOnce({ totalHits: named.short.limit - 1, timeToExpire: 100 }) - .mockResolvedValueOnce({ totalHits: named.long.limit - 1, timeToExpire: 1000 }); - - const context = tsjest.createMock(); - (context as any).switchToHttp = () => ({ getRequest: () => request }); - - const result = await guard.canActivate(context); - - expect(result).toBe(true); - expect(cacheService.increment).toHaveBeenCalledWith(`${request.route.path}:short:cet:ip:${request.ip}`, named.short.ttl); - expect(cacheService.increment).toHaveBeenCalledWith(`${request.route.path}:long:cet:ip:${request.ip}`, named.long.ttl); - }); - - it('when over the limit then the request is throttled', async () => { - const named = { short: { ttl: 60, limit: 1 }, long: { ttl: 3600, limit: 30 } }; - (reflector.get as jest.Mock).mockReturnValue(named); - const request: any = { route: { path: route }, user: null, ip: '11.11.11.11' }; + const context = tsjest.createMock(); + (context as any).switchToHttp = () => ({ getRequest: () => request }); - const shortOverTheLimit = named.short.limit + 1; - (cacheService.increment as jest.Mock) - .mockResolvedValueOnce({ totalHits: shortOverTheLimit, timeToExpire: 10 }) - .mockResolvedValueOnce({ totalHits: named.long.limit - 1, timeToExpire: 1000 }); + const result = await guard.canActivate(context); - const context = tsjest.createMock(); - (context as any).switchToHttp = () => ({ getRequest: () => request }); + expect(result).toBe(true); + expect(cacheService.increment).toHaveBeenCalledWith( + `${request.route.path}:short:cet:ip:${request.ip}`, + named.short.ttl, + ); + expect(cacheService.increment).toHaveBeenCalledWith( + `${request.route.path}:long:cet:ip:${request.ip}`, + named.long.ttl, + ); + }); + + it('when over the limit then the request is throttled', async () => { + const named = { + short: { ttl: 60, limit: 1 }, + long: { ttl: 3600, limit: 30 }, + }; + (reflector.get as jest.Mock).mockReturnValue(named); + const request: any = { + route: { path: route }, + user: null, + ip: '11.11.11.11', + }; + + const shortOverTheLimit = named.short.limit + 1; + (cacheService.increment as jest.Mock) + .mockResolvedValueOnce({ + totalHits: shortOverTheLimit, + timeToExpire: 10, + }) + .mockResolvedValueOnce({ + totalHits: named.long.limit - 1, + timeToExpire: 1000, + }); - await expect(guard.canActivate(context)).rejects.toBeInstanceOf(ThrottlerException); - expect(cacheService.increment).toHaveBeenCalledWith(`${request.route.path}:short:cet:ip:${request.ip}`, 60); - }); + const context = tsjest.createMock(); + (context as any).switchToHttp = () => ({ getRequest: () => request }); + + await expect(guard.canActivate(context)).rejects.toBeInstanceOf( + ThrottlerException, + ); + expect(cacheService.increment).toHaveBeenCalledWith( + `${request.route.path}:short:cet:ip:${request.ip}`, + 60, + ); + }); }); }); }); diff --git a/src/guards/custom-endpoint-throttle.guard.ts b/src/guards/custom-endpoint-throttle.guard.ts index bbe7449c5..7eef13166 100644 --- a/src/guards/custom-endpoint-throttle.guard.ts +++ b/src/guards/custom-endpoint-throttle.guard.ts @@ -20,7 +20,10 @@ export class CustomEndpointThrottleGuard implements CanActivate { ) {} async canActivate(context: ExecutionContext): Promise { - const raw = this.reflector.get(CUSTOM_ENDPOINT_THROTTLE_KEY, context.getHandler()); + const raw = this.reflector.get( + CUSTOM_ENDPOINT_THROTTLE_KEY, + context.getHandler(), + ); // If no custom throttle metadata, do not block (this guard should be applied // only where needed). Returning true lets other guards run. @@ -28,20 +31,29 @@ export class CustomEndpointThrottleGuard implements CanActivate { const policies: Array = []; - if (typeof raw === 'object' && (raw as any).ttl === undefined && (raw as any).limit === undefined) { + if ( + typeof raw === 'object' && + (raw as any).ttl === undefined && + (raw as any).limit === undefined + ) { // named policies object: { short: { ttl, limit }, long: { ttl, limit } } const entries = Object.entries(raw) as [string, CustomThrottleOptions][]; for (const [name, val] of entries) { policies.push({ ...(val as CustomThrottleOptions), key: name }); } } else { - policies.push({ ...(raw as CustomThrottleOptions), key: (raw as any).key ?? 'policy0' }); + policies.push({ + ...(raw as CustomThrottleOptions), + key: (raw as any).key ?? 'policy0', + }); } const request = context.switchToHttp().getRequest(); const user = request.user; - const identifierBase = user?.uuid ? `cet:uid:${user.uuid}` : `cet:ip:${request.ip}`; + const identifierBase = user?.uuid + ? `cet:uid:${user.uuid}` + : `cet:ip:${request.ip}`; const route = request.route?.path ?? request.originalUrl ?? 'unknown'; // Apply all policies. If any policy is violated, throw. diff --git a/src/guards/throttler.guard.ts b/src/guards/throttler.guard.ts index 012e25fb9..e95496bb5 100644 --- a/src/guards/throttler.guard.ts +++ b/src/guards/throttler.guard.ts @@ -15,4 +15,4 @@ export class CustomThrottlerGuard extends ThrottlerGuard { const userId = req.user?.uuid; return userId ? `rl:${userId}` : `rl:${req.ip}`; } -} \ No newline at end of file +} diff --git a/src/guards/throttler.interceptor.spec.ts b/src/guards/throttler.interceptor.spec.ts index ad12a2ee0..47bcc3e6e 100644 --- a/src/guards/throttler.interceptor.spec.ts +++ b/src/guards/throttler.interceptor.spec.ts @@ -34,7 +34,9 @@ describe('CustomThrottlerInterceptor', () => { await interceptor.intercept(context, next as CallHandler); - expect((next.handle as jest.Mock).mock.calls.length).toBeGreaterThanOrEqual(1); + expect( + (next.handle as jest.Mock).mock.calls.length, + ).toBeGreaterThanOrEqual(1); expect(cacheService.increment).not.toHaveBeenCalled(); }); @@ -50,13 +52,21 @@ describe('CustomThrottlerInterceptor', () => { const context = tsjest.createMock(); (context as any).switchToHttp = () => ({ getRequest: () => request }); - (cacheService.increment as jest.Mock).mockResolvedValue({ totalHits: 1, timeToExpire: 1000 }); + (cacheService.increment as jest.Mock).mockResolvedValue({ + totalHits: 1, + timeToExpire: 1000, + }); const next: Partial = { handle: jest.fn(() => of('ok')) }; await interceptor.intercept(context, next as CallHandler); - expect(cacheService.increment).toHaveBeenCalledWith(`rl:${request.ip}`, 30); - expect((next.handle as jest.Mock).mock.calls.length).toBeGreaterThanOrEqual(1); + expect(cacheService.increment).toHaveBeenCalledWith( + `rl:${request.ip}`, + 30, + ); + expect( + (next.handle as jest.Mock).mock.calls.length, + ).toBeGreaterThanOrEqual(1); }); it('When authenticated free-tier user exceeds limit then the request is throttled', async () => { @@ -75,15 +85,26 @@ describe('CustomThrottlerInterceptor', () => { } }) as any; - const request: any = { ip: '1.1.1.1', user: { uuid: 'u123', tierId: freeTierId } }; + const request: any = { + ip: '1.1.1.1', + user: { uuid: 'u123', tierId: freeTierId }, + }; const context = tsjest.createMock(); (context as any).switchToHttp = () => ({ getRequest: () => request }); - (cacheService.increment as jest.Mock).mockResolvedValue({ totalHits: 5, timeToExpire: 100 }); + (cacheService.increment as jest.Mock).mockResolvedValue({ + totalHits: 5, + timeToExpire: 100, + }); const next: Partial = { handle: jest.fn(() => of('ok')) }; - await expect(interceptor.intercept(context, next as CallHandler)).rejects.toBeInstanceOf(ThrottlerException); - expect(cacheService.increment).toHaveBeenCalledWith(`rl:${request.user.uuid}`, 20); + await expect( + interceptor.intercept(context, next as CallHandler), + ).rejects.toBeInstanceOf(ThrottlerException); + expect(cacheService.increment).toHaveBeenCalledWith( + `rl:${request.user.uuid}`, + 20, + ); }); }); }); diff --git a/src/guards/throttler.interceptor.ts b/src/guards/throttler.interceptor.ts index 7a187911e..f043dfc6f 100644 --- a/src/guards/throttler.interceptor.ts +++ b/src/guards/throttler.interceptor.ts @@ -1,9 +1,14 @@ -import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { CacheManagerService } from "../modules/cache-manager/cache-manager.service"; -import { Observable } from "rxjs"; -import { ThrottlerException } from "@nestjs/throttler"; -import { User } from "src/modules/user/user.domain"; +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { CacheManagerService } from '../modules/cache-manager/cache-manager.service'; +import { Observable } from 'rxjs'; +import { ThrottlerException } from '@nestjs/throttler'; +import { User } from 'src/modules/user/user.domain'; import { Reflector } from '@nestjs/core'; import { CUSTOM_ENDPOINT_THROTTLE_KEY } from './custom-endpoint-throttle.decorator'; @@ -19,25 +24,30 @@ export class CustomThrottlerInterceptor implements NestInterceptor { if (!user) { return { ttl: this.configService.get('users.rateLimit.anonymous.ttl'), - limit: this.configService.get('users.rateLimit.anonymous.limit') + limit: this.configService.get( + 'users.rateLimit.anonymous.limit', + ), }; } if (user.tierId === this.configService.get('users.freeTierId')) { return { ttl: this.configService.get('users.rateLimit.free.ttl'), - limit: this.configService.get('users.rateLimit.free.limit') - } + limit: this.configService.get('users.rateLimit.free.limit'), + }; } return { ttl: this.configService.get('users.rateLimit.paid.ttl'), - limit: this.configService.get('users.rateLimit.paid.limit') - } + limit: this.configService.get('users.rateLimit.paid.limit'), + }; } - async intercept(context: ExecutionContext, next: CallHandler): Promise> { + async intercept( + context: ExecutionContext, + next: CallHandler, + ): Promise> { const request = context.switchToHttp().getRequest(); - // Interceptors run before guards, so we must check metadata and + // Interceptors run before guards, so we must check metadata and // bypass the global interceptor when custom throttle is present. const hasCustom = this.reflector.get(CUSTOM_ENDPOINT_THROTTLE_KEY, context.getHandler()) || @@ -49,7 +59,7 @@ export class CustomThrottlerInterceptor implements NestInterceptor { const user = request.user as User | null; let key = `rl:${request.ip}`; if (user && user.uuid) { - key = `rl:${user.uuid}` + key = `rl:${user.uuid}`; } const { ttl, limit } = this.getRateLimit(user); @@ -62,4 +72,4 @@ export class CustomThrottlerInterceptor implements NestInterceptor { return next.handle(); } -} \ No newline at end of file +} diff --git a/src/guards/throttler.module.ts b/src/guards/throttler.module.ts index fb71f8b65..344a35cc5 100644 --- a/src/guards/throttler.module.ts +++ b/src/guards/throttler.module.ts @@ -1,9 +1,9 @@ -import { ConfigModule, ConfigService } from "@nestjs/config"; -import { seconds, ThrottlerModule } from "@nestjs/throttler"; -import { CacheManagerService } from "../modules/cache-manager/cache-manager.service"; -import { Module } from "@nestjs/common"; -import { CustomThrottlerInterceptor } from "./throttler.interceptor"; -import { CacheManagerModule } from "../modules/cache-manager/cache-manager.module"; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { seconds, ThrottlerModule } from '@nestjs/throttler'; +import { CacheManagerService } from '../modules/cache-manager/cache-manager.service'; +import { Module } from '@nestjs/common'; +import { CustomThrottlerInterceptor } from './throttler.interceptor'; +import { CacheManagerModule } from '../modules/cache-manager/cache-manager.module'; @Module({ imports: [ @@ -19,17 +19,13 @@ import { CacheManagerModule } from "../modules/cache-manager/cache-manager.modul throttlers: [ { ttl: seconds(configService.get('users.rateLimit.default.ttl')), - limit: configService.get('users.rateLimit.default.limit') + limit: configService.get('users.rateLimit.default.limit'), }, ], }), }), ], - providers: [ - CustomThrottlerInterceptor, - ], - exports: [ - CustomThrottlerInterceptor, - ], + providers: [CustomThrottlerInterceptor], + exports: [CustomThrottlerInterceptor], }) -export class CustomThrottlerModule {} \ No newline at end of file +export class CustomThrottlerModule {} diff --git a/src/lib/newrelic.interceptor.ts b/src/lib/newrelic.interceptor.ts index 3f668fb6e..20518e7fb 100644 --- a/src/lib/newrelic.interceptor.ts +++ b/src/lib/newrelic.interceptor.ts @@ -1,5 +1,10 @@ -import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; -const newrelic = require('newrelic') +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +const newrelic = require('newrelic'); /** * Only for the headers, the instrumentation is not done directly here @@ -15,18 +20,24 @@ export class NewRelicInterceptor implements NestInterceptor { if (rawClient) { newrelic.addCustomAttribute( 'internxtClient', - String(Array.isArray(rawClient) ? rawClient[0] : rawClient).slice(0, 50), + String(Array.isArray(rawClient) ? rawClient[0] : rawClient).slice( + 0, + 50, + ), ); } if (rawVersion) { newrelic.addCustomAttribute( 'internxtVersion', - String(Array.isArray(rawVersion) ? rawVersion[0] : rawVersion).slice(0, 15), + String(Array.isArray(rawVersion) ? rawVersion[0] : rawVersion).slice( + 0, + 15, + ), ); } - console.log(rawClient, rawVersion) + console.log(rawClient, rawVersion); return next.handle(); } diff --git a/src/modules/cache-manager/cache-manager.module.ts b/src/modules/cache-manager/cache-manager.module.ts index 0c4a22c81..1ba9a8f57 100644 --- a/src/modules/cache-manager/cache-manager.module.ts +++ b/src/modules/cache-manager/cache-manager.module.ts @@ -26,9 +26,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; // Error propagation should be stopped by adding event listener redisStore.on('error', (err) => - logger.error( - `Error on redis client: ${err}, url: ${redisUrl}`, - ), + logger.error(`Error on redis client: ${err}, url: ${redisUrl}`), ); return { diff --git a/src/modules/cache-manager/cache-manager.service.spec.ts b/src/modules/cache-manager/cache-manager.service.spec.ts index dcab743a0..ab2800c33 100644 --- a/src/modules/cache-manager/cache-manager.service.spec.ts +++ b/src/modules/cache-manager/cache-manager.service.spec.ts @@ -368,12 +368,18 @@ describe('CacheManagerService', () => { jest.spyOn(Date, 'now').mockReturnValue(now); jest.spyOn(cacheManager, 'get').mockResolvedValue(null); - const setSpy = jest.spyOn(cacheManager, 'set').mockResolvedValue(undefined as any); + const setSpy = jest + .spyOn(cacheManager, 'set') + .mockResolvedValue(undefined as any); const result = await cacheManagerService.increment(key, ttlSeconds); expect(cacheManager.get).toHaveBeenCalledWith(key); - expect(setSpy).toHaveBeenCalledWith(key, { hits: 1, expiresAt: now + ttlMs }, ttlMs); + expect(setSpy).toHaveBeenCalledWith( + key, + { hits: 1, expiresAt: now + ttlMs }, + ttlMs, + ); expect(result.totalHits).toBe(1); expect(result.timeToExpire).toBe(ttlMs); }); @@ -386,12 +392,18 @@ describe('CacheManagerService', () => { jest.spyOn(Date, 'now').mockReturnValue(now); jest.spyOn(cacheManager, 'get').mockResolvedValue(existing as any); - const setSpy = jest.spyOn(cacheManager, 'set').mockResolvedValue(undefined as any); + const setSpy = jest + .spyOn(cacheManager, 'set') + .mockResolvedValue(undefined as any); const result = await cacheManagerService.increment(key, ttlSeconds); const expectedNewHits = existing.hits + 1; - - expect(setSpy).toHaveBeenCalledWith(key, { hits: expectedNewHits, expiresAt }, expiresAt - now); + + expect(setSpy).toHaveBeenCalledWith( + key, + { hits: expectedNewHits, expiresAt }, + expiresAt - now, + ); expect(result.totalHits).toBe(expectedNewHits); expect(result.timeToExpire).toBe(expiresAt - now); }); @@ -404,16 +416,22 @@ describe('CacheManagerService', () => { jest.spyOn(Date, 'now').mockReturnValue(now); jest.spyOn(cacheManager, 'get').mockResolvedValue(existing as any); - const setSpy = jest.spyOn(cacheManager, 'set').mockResolvedValue(undefined as any); + const setSpy = jest + .spyOn(cacheManager, 'set') + .mockResolvedValue(undefined as any); const result = await cacheManagerService.increment(key, ttlSeconds); - const expectedNewHits = 1 + const expectedNewHits = 1; const expectedTimeToExpire = ttlSeconds * 1000; - - expect(setSpy).toHaveBeenCalledWith(key, { - hits: expectedNewHits, - expiresAt: now + expectedTimeToExpire - }, expectedTimeToExpire); + + expect(setSpy).toHaveBeenCalledWith( + key, + { + hits: expectedNewHits, + expiresAt: now + expectedTimeToExpire, + }, + expectedTimeToExpire, + ); expect(result.totalHits).toBe(expectedNewHits); expect(result.timeToExpire).toBe(expectedTimeToExpire); }); diff --git a/src/modules/cache-manager/cache-manager.service.ts b/src/modules/cache-manager/cache-manager.service.ts index e5ce58457..21acc70e2 100644 --- a/src/modules/cache-manager/cache-manager.service.ts +++ b/src/modules/cache-manager/cache-manager.service.ts @@ -12,9 +12,7 @@ export class CacheManagerService { private readonly TTL_10_MINUTES = 10 * 60 * 1000; private readonly TTL_24_HOURS = 24 * 60 * 60 * 1000; - constructor( - @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, - ) {} + constructor(@Inject(CACHE_MANAGER) private readonly cacheManager: Cache) {} /** * Get user's storage usage @@ -105,9 +103,10 @@ export class CacheManagerService { } async getRecord(key: string): Promise { - const entry = await this.cacheManager.get<{ hits: number; expiresAt: number }>( - key, - ); + const entry = await this.cacheManager.get<{ + hits: number; + expiresAt: number; + }>(key); if (entry && typeof entry.hits === 'number') { const now = Date.now(); @@ -123,11 +122,17 @@ export class CacheManagerService { return undefined; } - async increment(key: string, ttlSeconds: number): Promise { + async increment( + key: string, + ttlSeconds: number, + ): Promise { const ttlMs = ttlSeconds * 1000; const now = Date.now(); - const existing = await this.cacheManager.get<{ hits: number; expiresAt: number }>(key); + const existing = await this.cacheManager.get<{ + hits: number; + expiresAt: number; + }>(key); let hits = 1; let expiresAt = now + ttlMs; diff --git a/src/modules/file/actions/delete-file-version.action.spec.ts b/src/modules/file/actions/delete-file-version.action.spec.ts index fabee7535..4abc0add4 100644 --- a/src/modules/file/actions/delete-file-version.action.spec.ts +++ b/src/modules/file/actions/delete-file-version.action.spec.ts @@ -67,8 +67,12 @@ describe('DeleteFileVersionAction', () => { }); jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); - jest.spyOn(fileVersionRepository, 'findById').mockResolvedValue(mockVersion); - jest.spyOn(fileVersionRepository, 'updateStatus').mockResolvedValue(undefined); + jest + .spyOn(fileVersionRepository, 'findById') + .mockResolvedValue(mockVersion); + jest + .spyOn(fileVersionRepository, 'updateStatus') + .mockResolvedValue(undefined); await action.execute(userMocked, mockFile.uuid, versionId); @@ -128,7 +132,9 @@ describe('DeleteFileVersionAction', () => { }); jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); - jest.spyOn(fileVersionRepository, 'findById').mockResolvedValue(mockVersion); + jest + .spyOn(fileVersionRepository, 'findById') + .mockResolvedValue(mockVersion); await expect( action.execute(userMocked, mockFile.uuid, versionId), diff --git a/src/modules/file/actions/get-file-versions.action.spec.ts b/src/modules/file/actions/get-file-versions.action.spec.ts index 3d0c32f7d..39a4d50cf 100644 --- a/src/modules/file/actions/get-file-versions.action.spec.ts +++ b/src/modules/file/actions/get-file-versions.action.spec.ts @@ -7,7 +7,11 @@ import { FeatureLimitService } from '../../feature-limit/feature-limit.service'; import { FileVersion, FileVersionStatus } from '../file-version.domain'; import { NotFoundException } from '@nestjs/common'; import { v4 } from 'uuid'; -import { newFile, newUser, newVersioningLimits } from '../../../../test/fixtures'; +import { + newFile, + newUser, + newVersioningLimits, +} from '../../../../test/fixtures'; describe('GetFileVersionsAction', () => { let action: GetFileVersionsAction; diff --git a/src/modules/folder/folder.controller.ts b/src/modules/folder/folder.controller.ts index bb5733da7..aabc2a578 100644 --- a/src/modules/folder/folder.controller.ts +++ b/src/modules/folder/folder.controller.ts @@ -89,7 +89,7 @@ export class FolderController { @UseGuards(CustomEndpointThrottleGuard) @CustomThrottle({ - long: { ttl: 3600, limit: 30000 } + long: { ttl: 3600, limit: 30000 }, }) @Post('/') @ApiOperation({ diff --git a/src/modules/folder/folder.module.ts b/src/modules/folder/folder.module.ts index 43d11d2bb..54c9f0124 100644 --- a/src/modules/folder/folder.module.ts +++ b/src/modules/folder/folder.module.ts @@ -31,10 +31,10 @@ import { CacheManagerModule } from '../cache-manager/cache-manager.module'; ], controllers: [FolderController], providers: [ - SequelizeFolderRepository, - CryptoService, + SequelizeFolderRepository, + CryptoService, FolderUseCases, - CustomEndpointThrottleGuard + CustomEndpointThrottleGuard, ], exports: [FolderUseCases, SequelizeFolderRepository], }) diff --git a/src/modules/sharing/sharing.module.ts b/src/modules/sharing/sharing.module.ts index 869621b51..96e3dbf99 100644 --- a/src/modules/sharing/sharing.module.ts +++ b/src/modules/sharing/sharing.module.ts @@ -50,7 +50,7 @@ import { CaptchaService } from '../../externals/captcha/captcha.service'; SequelizeSharingRepository, SequelizeUserReferralsRepository, PaymentsService, - CaptchaService + CaptchaService, ], exports: [SharingService, SequelizeSharingRepository, SequelizeModule], }) diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index cd51d0fd4..bfe16a0d6 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -977,8 +977,8 @@ export class UserController { @Throttle({ default: { ttl: 60, - limit: 5 - } + limit: 5, + }, }) @Put('/public-key/:email') @UseGuards(CaptchaGuard) diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts index aa974a8b5..aa05bdae4 100644 --- a/src/modules/user/user.module.ts +++ b/src/modules/user/user.module.ts @@ -102,7 +102,7 @@ import { CaptchaService } from '../../externals/captcha/captcha.service'; NewsletterService, AvatarService, MailerService, - CaptchaService + CaptchaService, ], exports: [ UserUseCases, From 5cb0d5ee558078e7e5329caae917e934a5a9bda1 Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Tue, 13 Jan 2026 11:26:22 +0100 Subject: [PATCH 56/71] migration: disable file versioning for essential tier --- ...113120000-update-file-versioning-limits.js | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 migrations/20260113120000-update-file-versioning-limits.js diff --git a/migrations/20260113120000-update-file-versioning-limits.js b/migrations/20260113120000-update-file-versioning-limits.js new file mode 100644 index 000000000..bf3a279c0 --- /dev/null +++ b/migrations/20260113120000-update-file-versioning-limits.js @@ -0,0 +1,94 @@ +'use strict'; + +const FILE_VERSION_LABELS = { + ENABLED: 'file-version-enabled', + MAX_SIZE: 'file-version-max-size', + RETENTION_DAYS: 'file-version-retention-days', + MAX_NUMBER: 'file-version-max-number', +}; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query( + `UPDATE limits + SET value = 'false', updated_at = NOW() + FROM tiers_limits tl, tiers t + WHERE tl.limit_id = limits.id + AND t.id = tl.tier_id + AND t.label = 'essential_individual' + AND limits.label = :enabledLabel`, + { replacements: { enabledLabel: FILE_VERSION_LABELS.ENABLED } }, + ); + + await queryInterface.sequelize.query( + `UPDATE limits + SET value = '0', updated_at = NOW() + FROM tiers_limits tl, tiers t + WHERE tl.limit_id = limits.id + AND t.id = tl.tier_id + AND t.label = 'essential_individual' + AND limits.label IN (:counterLabels)`, + { + replacements: { + counterLabels: [ + FILE_VERSION_LABELS.MAX_SIZE, + FILE_VERSION_LABELS.RETENTION_DAYS, + FILE_VERSION_LABELS.MAX_NUMBER, + ], + }, + }, + ); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query( + `UPDATE limits + SET value = 'true', updated_at = NOW() + FROM tiers_limits tl, tiers t + WHERE tl.limit_id = limits.id + AND t.id = tl.tier_id + AND t.label = 'essential_individual' + AND limits.label = :enabledLabel`, + { replacements: { enabledLabel: FILE_VERSION_LABELS.ENABLED } }, + ); + + await queryInterface.sequelize.query( + `UPDATE limits + SET value = :maxSize, updated_at = NOW() + FROM tiers_limits tl, tiers t + WHERE tl.limit_id = limits.id + AND t.id = tl.tier_id + AND t.label = 'essential_individual' + AND limits.label = :label`, + { + replacements: { + maxSize: String(1 * 1024 * 1024), + label: FILE_VERSION_LABELS.MAX_SIZE, + }, + }, + ); + + await queryInterface.sequelize.query( + `UPDATE limits + SET value = '10', updated_at = NOW() + FROM tiers_limits tl, tiers t + WHERE tl.limit_id = limits.id + AND t.id = tl.tier_id + AND t.label = 'essential_individual' + AND limits.label = :label`, + { replacements: { label: FILE_VERSION_LABELS.RETENTION_DAYS } }, + ); + + await queryInterface.sequelize.query( + `UPDATE limits + SET value = '1', updated_at = NOW() + FROM tiers_limits tl, tiers t + WHERE tl.limit_id = limits.id + AND t.id = tl.tier_id + AND t.label = 'essential_individual' + AND limits.label = :label`, + { replacements: { label: FILE_VERSION_LABELS.MAX_NUMBER } }, + ); + }, +}; From add694e8959f61d5e3d09ccc58f4746c58256a4b Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Fri, 16 Jan 2026 16:44:55 +0100 Subject: [PATCH 57/71] chore(migration): update timestamp for update-file-versioning-limits migration --- ...-limits.js => 20260116154444-update-file-versioning-limits.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename migrations/{20260113120000-update-file-versioning-limits.js => 20260116154444-update-file-versioning-limits.js} (100%) diff --git a/migrations/20260113120000-update-file-versioning-limits.js b/migrations/20260116154444-update-file-versioning-limits.js similarity index 100% rename from migrations/20260113120000-update-file-versioning-limits.js rename to migrations/20260116154444-update-file-versioning-limits.js From b448bf8d951a3e504250b0a26ce36da1b5e3f668 Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Tue, 27 Jan 2026 12:59:15 +0100 Subject: [PATCH 58/71] refactor(migrations): apply review feedback on deleted_file_versions --- ...60116154627-create-deleted-file-versions-table.js | 12 ++++-------- .../20260116154629-deleted-file-versions-function.js | 7 ++++--- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/migrations/20260116154627-create-deleted-file-versions-table.js b/migrations/20260116154627-create-deleted-file-versions-table.js index 9617980f3..82c8ae4c4 100644 --- a/migrations/20260116154627-create-deleted-file-versions-table.js +++ b/migrations/20260116154627-create-deleted-file-versions-table.js @@ -10,21 +10,17 @@ module.exports = { allowNull: false, primaryKey: true, }, - file_id: { - type: Sequelize.UUID, - allowNull: true, - }, network_file_id: { type: Sequelize.STRING, - allowNull: true, + allowNull: false, }, size: { type: Sequelize.BIGINT, - allowNull: true, + allowNull: false, }, processed: { type: Sequelize.BOOLEAN, - allowNull: true, + allowNull: false, defaultValue: false, }, created_at: { @@ -41,7 +37,7 @@ module.exports = { }, enqueued: { type: Sequelize.BOOLEAN, - allowNull: true, + allowNull: false, defaultValue: false, }, enqueued_at: { diff --git a/migrations/20260116154629-deleted-file-versions-function.js b/migrations/20260116154629-deleted-file-versions-function.js index 3a6c25eed..c4a7a666f 100644 --- a/migrations/20260116154629-deleted-file-versions-function.js +++ b/migrations/20260116154629-deleted-file-versions-function.js @@ -9,11 +9,13 @@ module.exports = { LANGUAGE plpgsql AS $function$ BEGIN - IF OLD.status != 'DELETED' AND NEW.status = 'DELETED' THEN + IF OLD.status != 'DELETED' AND NEW.status = 'DELETED' + AND NEW.network_file_id IS NOT NULL + AND NEW.size IS NOT NULL + AND NEW.size > 0 THEN IF NOT EXISTS (SELECT 1 FROM deleted_file_versions WHERE file_version_id = NEW.id) THEN INSERT INTO deleted_file_versions ( file_version_id, - file_id, network_file_id, size, processed, @@ -22,7 +24,6 @@ module.exports = { updated_at ) VALUES ( NEW.id, - NEW.file_id, NEW.network_file_id, NEW.size, false, From 9df573174e9e6b8f42ab1fc632a1d09b9490469b Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Tue, 20 Jan 2026 20:46:36 +0100 Subject: [PATCH 59/71] refactor(file-versions): extract create file version to action layer --- .../create-file-version.action.spec.ts | 348 ++++++++++++++++++ .../actions/create-file-version.action.ts | 98 +++++ src/modules/file/actions/index.ts | 1 + src/modules/file/file.module.ts | 7 +- src/modules/file/file.usecase.spec.ts | 175 ++------- src/modules/file/file.usecase.ts | 90 +---- 6 files changed, 489 insertions(+), 230 deletions(-) create mode 100644 src/modules/file/actions/create-file-version.action.spec.ts create mode 100644 src/modules/file/actions/create-file-version.action.ts diff --git a/src/modules/file/actions/create-file-version.action.spec.ts b/src/modules/file/actions/create-file-version.action.spec.ts new file mode 100644 index 000000000..38810c76f --- /dev/null +++ b/src/modules/file/actions/create-file-version.action.spec.ts @@ -0,0 +1,348 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CreateFileVersionAction } from './create-file-version.action'; +import { SequelizeFileRepository } from '../file.repository'; +import { SequelizeFileVersionRepository } from '../file-version.repository'; +import { FeatureLimitService } from '../../feature-limit/feature-limit.service'; +import { newFile, newUser } from '../../../../test/fixtures'; +import { FileVersion, FileVersionStatus } from '../file-version.domain'; + +describe('CreateFileVersionAction', () => { + let action: CreateFileVersionAction; + let fileRepository: SequelizeFileRepository; + let fileVersionRepository: SequelizeFileVersionRepository; + let featureLimitService: FeatureLimitService; + + const userMocked = newUser(); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CreateFileVersionAction, + { + provide: SequelizeFileRepository, + useValue: { + updateByUuidAndUserId: jest.fn(), + }, + }, + { + provide: SequelizeFileVersionRepository, + useValue: { + create: jest.fn(), + findAllByFileId: jest.fn(), + updateStatusBatch: jest.fn(), + }, + }, + { + provide: FeatureLimitService, + useValue: { + getFileVersioningLimits: jest.fn(), + }, + }, + ], + }).compile(); + + action = module.get(CreateFileVersionAction); + fileRepository = module.get( + SequelizeFileRepository, + ); + fileVersionRepository = module.get( + SequelizeFileVersionRepository, + ); + featureLimitService = module.get(FeatureLimitService); + }); + + describe('When creating file version without modificationTime', () => { + it('then should create version and update file', async () => { + const mockFile = newFile({ + attributes: { + fileId: 'old-file-id', + bucket: 'test-bucket', + type: 'pdf', + size: BigInt(100), + }, + }); + + const newFileId = 'new-file-id'; + const newSize = BigInt(200); + + jest + .spyOn(featureLimitService, 'getFileVersioningLimits') + .mockResolvedValue({ + enabled: true, + maxFileSize: 1000000, + retentionDays: 15, + maxVersions: 10, + }); + jest + .spyOn(fileVersionRepository, 'findAllByFileId') + .mockResolvedValue([]); + jest.spyOn(fileVersionRepository, 'create').mockResolvedValue({} as any); + jest.spyOn(fileRepository, 'updateByUuidAndUserId').mockResolvedValue(); + + await action.execute(userMocked, mockFile, newFileId, newSize); + + expect(fileVersionRepository.create).toHaveBeenCalledWith({ + fileId: mockFile.uuid, + userId: userMocked.uuid, + networkFileId: mockFile.fileId, + size: mockFile.size, + status: FileVersionStatus.EXISTS, + }); + + expect(fileRepository.updateByUuidAndUserId).toHaveBeenCalledWith( + mockFile.uuid, + userMocked.id, + expect.objectContaining({ + fileId: newFileId, + size: newSize, + updatedAt: expect.any(Date), + }), + ); + }); + }); + + describe('When creating file version with modificationTime', () => { + it('then should create version and update file with modificationTime', async () => { + const mockFile = newFile({ + attributes: { + fileId: 'old-file-id', + bucket: 'test-bucket', + type: 'pdf', + size: BigInt(100), + }, + }); + + const newFileId = 'new-file-id'; + const newSize = BigInt(200); + const modificationTime = new Date(); + + jest + .spyOn(featureLimitService, 'getFileVersioningLimits') + .mockResolvedValue({ + enabled: true, + maxFileSize: 1000000, + retentionDays: 15, + maxVersions: 10, + }); + jest + .spyOn(fileVersionRepository, 'findAllByFileId') + .mockResolvedValue([]); + jest.spyOn(fileVersionRepository, 'create').mockResolvedValue({} as any); + jest.spyOn(fileRepository, 'updateByUuidAndUserId').mockResolvedValue(); + + await action.execute( + userMocked, + mockFile, + newFileId, + newSize, + modificationTime, + ); + + expect(fileRepository.updateByUuidAndUserId).toHaveBeenCalledWith( + mockFile.uuid, + userMocked.id, + expect.objectContaining({ + fileId: newFileId, + size: newSize, + modificationTime, + updatedAt: expect.any(Date), + }), + ); + }); + }); + + describe('When retention policy needs to delete old versions', () => { + it('then should delete versions older than retention period', async () => { + const mockFile = newFile({ + attributes: { + fileId: 'old-file-id', + bucket: 'test-bucket', + type: 'pdf', + size: BigInt(100), + }, + }); + + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 20); + + const existingVersions = [ + FileVersion.build({ + id: 'version-1', + fileId: mockFile.uuid, + userId: userMocked.uuid, + networkFileId: 'network-1', + size: BigInt(50), + status: FileVersionStatus.EXISTS, + createdAt: oldDate, + updatedAt: oldDate, + }), + ]; + + jest + .spyOn(featureLimitService, 'getFileVersioningLimits') + .mockResolvedValue({ + enabled: true, + maxFileSize: 1000000, + retentionDays: 15, + maxVersions: 10, + }); + jest + .spyOn(fileVersionRepository, 'findAllByFileId') + .mockResolvedValue(existingVersions); + jest + .spyOn(fileVersionRepository, 'updateStatusBatch') + .mockResolvedValue(); + jest.spyOn(fileVersionRepository, 'create').mockResolvedValue({} as any); + jest.spyOn(fileRepository, 'updateByUuidAndUserId').mockResolvedValue(); + + await action.execute(userMocked, mockFile, 'new-file-id', BigInt(200)); + + expect(fileVersionRepository.updateStatusBatch).toHaveBeenCalledWith( + ['version-1'], + FileVersionStatus.DELETED, + ); + }); + }); + + describe('When retention policy is disabled', () => { + it('then should create version without applying retention', async () => { + const mockFile = newFile({ + attributes: { + fileId: 'old-file-id', + bucket: 'test-bucket', + type: 'pdf', + size: BigInt(100), + }, + }); + + jest + .spyOn(featureLimitService, 'getFileVersioningLimits') + .mockResolvedValue({ + enabled: false, + maxFileSize: 1000000, + retentionDays: 15, + maxVersions: 10, + }); + const findAllSpy = jest.spyOn(fileVersionRepository, 'findAllByFileId'); + jest.spyOn(fileVersionRepository, 'create').mockResolvedValue({} as any); + jest.spyOn(fileRepository, 'updateByUuidAndUserId').mockResolvedValue(); + + await action.execute(userMocked, mockFile, 'new-file-id', BigInt(200)); + + expect(findAllSpy).not.toHaveBeenCalled(); + expect(fileVersionRepository.create).toHaveBeenCalled(); + }); + }); + + describe('When versions exist within retention period and under limit', () => { + it('then should not delete any versions', async () => { + const mockFile = newFile({ + attributes: { + fileId: 'old-file-id', + bucket: 'test-bucket', + type: 'pdf', + size: BigInt(100), + }, + }); + + const now = new Date(); + const existingVersions = [ + FileVersion.build({ + id: 'version-1', + fileId: mockFile.uuid, + userId: userMocked.uuid, + networkFileId: 'network-1', + size: BigInt(50), + status: FileVersionStatus.EXISTS, + createdAt: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), + updatedAt: now, + }), + FileVersion.build({ + id: 'version-2', + fileId: mockFile.uuid, + userId: userMocked.uuid, + networkFileId: 'network-2', + size: BigInt(50), + status: FileVersionStatus.EXISTS, + createdAt: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), + updatedAt: now, + }), + ]; + + jest + .spyOn(featureLimitService, 'getFileVersioningLimits') + .mockResolvedValue({ + enabled: true, + maxFileSize: 1000000, + retentionDays: 15, + maxVersions: 10, + }); + jest + .spyOn(fileVersionRepository, 'findAllByFileId') + .mockResolvedValue(existingVersions); + const updateStatusBatchSpy = jest + .spyOn(fileVersionRepository, 'updateStatusBatch') + .mockResolvedValue(); + jest.spyOn(fileVersionRepository, 'create').mockResolvedValue({} as any); + jest.spyOn(fileRepository, 'updateByUuidAndUserId').mockResolvedValue(); + + await action.execute(userMocked, mockFile, 'new-file-id', BigInt(200)); + + expect(updateStatusBatchSpy).not.toHaveBeenCalled(); + expect(fileVersionRepository.create).toHaveBeenCalled(); + }); + }); + + describe('When max versions limit is reached', () => { + it('then should delete oldest versions exceeding the limit', async () => { + const mockFile = newFile({ + attributes: { + fileId: 'old-file-id', + bucket: 'test-bucket', + type: 'pdf', + size: BigInt(100), + }, + }); + + const now = new Date(); + const existingVersions = Array.from({ length: 12 }, (_, i) => { + const date = new Date(now); + date.setHours(date.getHours() - i); + return FileVersion.build({ + id: `version-${i}`, + fileId: mockFile.uuid, + userId: userMocked.uuid, + networkFileId: `network-${i}`, + size: BigInt(50), + status: FileVersionStatus.EXISTS, + createdAt: date, + updatedAt: date, + }); + }); + + jest + .spyOn(featureLimitService, 'getFileVersioningLimits') + .mockResolvedValue({ + enabled: true, + maxFileSize: 1000000, + retentionDays: 15, + maxVersions: 10, + }); + jest + .spyOn(fileVersionRepository, 'findAllByFileId') + .mockResolvedValue(existingVersions); + jest + .spyOn(fileVersionRepository, 'updateStatusBatch') + .mockResolvedValue(); + jest.spyOn(fileVersionRepository, 'create').mockResolvedValue({} as any); + jest.spyOn(fileRepository, 'updateByUuidAndUserId').mockResolvedValue(); + + await action.execute(userMocked, mockFile, 'new-file-id', BigInt(200)); + + expect(fileVersionRepository.updateStatusBatch).toHaveBeenCalledWith( + expect.arrayContaining(['version-10', 'version-11']), + FileVersionStatus.DELETED, + ); + }); + }); +}); diff --git a/src/modules/file/actions/create-file-version.action.ts b/src/modules/file/actions/create-file-version.action.ts new file mode 100644 index 000000000..b67a1ba81 --- /dev/null +++ b/src/modules/file/actions/create-file-version.action.ts @@ -0,0 +1,98 @@ +import { Injectable } from '@nestjs/common'; +import { User } from '../../user/user.domain'; +import { File } from '../file.domain'; +import { SequelizeFileRepository } from '../file.repository'; +import { SequelizeFileVersionRepository } from '../file-version.repository'; +import { FileVersionStatus } from '../file-version.domain'; +import { FeatureLimitService } from '../../feature-limit/feature-limit.service'; +import { Time } from '../../../lib/time'; + +@Injectable() +export class CreateFileVersionAction { + constructor( + private readonly fileRepository: SequelizeFileRepository, + private readonly fileVersionRepository: SequelizeFileVersionRepository, + private readonly featureLimitService: FeatureLimitService, + ) {} + + async execute( + user: User, + file: File, + newFileId: string | null, + newSize: bigint, + modificationTime?: Date, + ): Promise { + await this.applyRetentionPolicy(file.uuid, user.uuid); + + await Promise.all([ + this.fileVersionRepository.create({ + fileId: file.uuid, + userId: user.uuid, + networkFileId: file.fileId, + size: file.size, + status: FileVersionStatus.EXISTS, + }), + this.fileRepository.updateByUuidAndUserId(file.uuid, user.id, { + fileId: newFileId, + size: newSize, + updatedAt: new Date(), + ...(modificationTime ? { modificationTime } : null), + }), + ]); + } + + private async applyRetentionPolicy( + fileUuid: string, + userUuid: string, + ): Promise { + const limits = + await this.featureLimitService.getFileVersioningLimits(userUuid); + + if (!limits.enabled) { + return; + } + + const { retentionDays, maxVersions } = limits; + + const cutoffDate = Time.daysAgo(retentionDays); + + const versions = await this.fileVersionRepository.findAllByFileId(fileUuid); + + const versionsToDeleteByAge = versions.filter( + (version) => version.createdAt < cutoffDate, + ); + + const remainingVersions = versions + .filter((version) => version.createdAt >= cutoffDate) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + + const versionsToDeleteByCount = remainingVersions.slice(maxVersions); + + const versionsToDelete = [ + ...versionsToDeleteByAge, + ...versionsToDeleteByCount, + ]; + + const remainingCount = versions.length - versionsToDelete.length; + if (remainingCount >= maxVersions) { + const versionsNotDeleted = versions.filter( + (v) => !versionsToDelete.some((vd) => vd.id === v.id), + ); + const oldestVersion = versionsNotDeleted.sort( + (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), + )[0]; + + if (oldestVersion) { + versionsToDelete.push(oldestVersion); + } + } + + if (versionsToDelete.length > 0) { + const idsToDelete = versionsToDelete.map((v) => v.id); + await this.fileVersionRepository.updateStatusBatch( + idsToDelete, + FileVersionStatus.DELETED, + ); + } + } +} diff --git a/src/modules/file/actions/index.ts b/src/modules/file/actions/index.ts index 1b9b8200e..a78ed9562 100644 --- a/src/modules/file/actions/index.ts +++ b/src/modules/file/actions/index.ts @@ -1,2 +1,3 @@ export * from './get-file-versions.action'; export * from './delete-file-version.action'; +export * from './create-file-version.action'; diff --git a/src/modules/file/file.module.ts b/src/modules/file/file.module.ts index e779ccdc7..b419202fb 100644 --- a/src/modules/file/file.module.ts +++ b/src/modules/file/file.module.ts @@ -22,7 +22,11 @@ import { RedisService } from '../../externals/redis/redis.service'; import { TrashModule } from '../trash/trash.module'; import { CacheManagerModule } from '../cache-manager/cache-manager.module'; import { CustomEndpointThrottleGuard } from '../../guards/custom-endpoint-throttle.guard'; -import { DeleteFileVersionAction, GetFileVersionsAction } from './actions'; +import { + DeleteFileVersionAction, + GetFileVersionsAction, + CreateFileVersionAction, +} from './actions'; @Module({ imports: [ @@ -50,6 +54,7 @@ import { DeleteFileVersionAction, GetFileVersionsAction } from './actions'; CustomEndpointThrottleGuard, GetFileVersionsAction, DeleteFileVersionAction, + CreateFileVersionAction, ], exports: [ FileUseCases, diff --git a/src/modules/file/file.usecase.spec.ts b/src/modules/file/file.usecase.spec.ts index 510831d18..47758234b 100644 --- a/src/modules/file/file.usecase.spec.ts +++ b/src/modules/file/file.usecase.spec.ts @@ -48,7 +48,11 @@ import { UserUseCases } from '../user/user.usecase'; import { TrashUseCases } from '../trash/trash.usecase'; import { TrashItemType } from '../trash/trash.attributes'; import { CacheManagerService } from '../cache-manager/cache-manager.service'; -import { DeleteFileVersionAction, GetFileVersionsAction } from './actions'; +import { + DeleteFileVersionAction, + GetFileVersionsAction, + CreateFileVersionAction, +} from './actions'; const fileId = '6295c99a241bb000083f1c6a'; const userId = 1; @@ -71,6 +75,7 @@ describe('FileUseCases', () => { let cacheManagerService: CacheManagerService; let getFileVersionsAction: GetFileVersionsAction; let deleteFileVersionAction: DeleteFileVersionAction; + let createFileVersionAction: CreateFileVersionAction; const userMocked = newUser({ attributes: { @@ -108,6 +113,9 @@ describe('FileUseCases', () => { deleteFileVersionAction = module.get( DeleteFileVersionAction, ); + createFileVersionAction = module.get( + CreateFileVersionAction, + ); }); afterEach(() => { @@ -2216,13 +2224,9 @@ describe('FileUseCases', () => { jest .spyOn(service, 'isFileVersionable') .mockResolvedValue({ versionable: true, limits: null }); - const applyRetentionSpy = jest - .spyOn(service as any, 'applyRetentionPolicy') + const createVersionSpy = jest + .spyOn(createFileVersionAction, 'execute') .mockResolvedValue(undefined); - const upsertSpy = jest - .spyOn(fileVersionRepository, 'upsert') - .mockResolvedValue({} as any); - jest.spyOn(fileRepository, 'updateByUuidAndUserId').mockResolvedValue(); const deleteFileSpy = jest.spyOn(bridgeService, 'deleteFile'); const result = await service.replaceFile( @@ -2231,18 +2235,13 @@ describe('FileUseCases', () => { replaceData, ); - expect(applyRetentionSpy).toHaveBeenCalledWith( - mockFile.uuid, - userMocked.uuid, + expect(createVersionSpy).toHaveBeenCalledWith( + userMocked, + mockFile, + replaceData.fileId, + replaceData.size, + undefined, ); - expect(upsertSpy).toHaveBeenCalledWith({ - fileId: mockFile.uuid, - userId: userMocked.uuid, - networkFileId: mockFile.fileId, - size: mockFile.size, - status: 'EXISTS', - }); - expect(fileRepository.updateByUuidAndUserId).toHaveBeenCalled(); expect(deleteFileSpy).not.toHaveBeenCalled(); expect(result).toEqual({ ...mockFile.toJSON(), @@ -2268,18 +2267,13 @@ describe('FileUseCases', () => { jest .spyOn(service, 'isFileVersionable') .mockResolvedValue({ versionable: false, limits: null }); - const applyRetentionSpy = jest.spyOn( - service as any, - 'applyRetentionPolicy', - ); - const upsertSpy = jest.spyOn(fileVersionRepository, 'upsert'); + const createVersionSpy = jest.spyOn(createFileVersionAction, 'execute'); jest.spyOn(fileRepository, 'updateByUuidAndUserId').mockResolvedValue(); jest.spyOn(bridgeService, 'deleteFile').mockResolvedValue(); await service.replaceFile(userMocked, mockFile.uuid, replaceData); - expect(applyRetentionSpy).not.toHaveBeenCalled(); - expect(upsertSpy).not.toHaveBeenCalled(); + expect(createVersionSpy).not.toHaveBeenCalled(); expect(bridgeService.deleteFile).toHaveBeenCalledWith( userMocked, mockFile.bucket, @@ -2304,18 +2298,13 @@ describe('FileUseCases', () => { jest .spyOn(service, 'isFileVersionable') .mockResolvedValue({ versionable: false, limits: null }); - const applyRetentionSpy = jest.spyOn( - service as any, - 'applyRetentionPolicy', - ); - const upsertSpy = jest.spyOn(fileVersionRepository, 'upsert'); + const createVersionSpy = jest.spyOn(createFileVersionAction, 'execute'); jest.spyOn(fileRepository, 'updateByUuidAndUserId').mockResolvedValue(); jest.spyOn(bridgeService, 'deleteFile').mockResolvedValue(); await service.replaceFile(userMocked, mockFile.uuid, replaceData); - expect(applyRetentionSpy).not.toHaveBeenCalled(); - expect(upsertSpy).not.toHaveBeenCalled(); + expect(createVersionSpy).not.toHaveBeenCalled(); expect(bridgeService.deleteFile).toHaveBeenCalledWith( userMocked, mockFile.bucket, @@ -2996,128 +2985,6 @@ describe('FileUseCases', () => { }); }); - describe('applyRetentionPolicy', () => { - const userUuid = 'user-uuid'; - const premiumLimits = { - enabled: true, - maxFileSize: 100 * 1024 * 1024, - retentionDays: 30, - maxVersions: 10, - }; - - it('When versioning is disabled, then it returns early', async () => { - jest - .spyOn(featureLimitService, 'getFileVersioningLimits') - .mockResolvedValue({ ...premiumLimits, enabled: false }); - - const findAllByFileIdSpy = jest.spyOn( - fileVersionRepository, - 'findAllByFileId', - ); - - await service['applyRetentionPolicy']('file-uuid', userUuid); - - expect(findAllByFileIdSpy).not.toHaveBeenCalled(); - }); - - it('When no versions exist, then no versions are deleted', async () => { - jest - .spyOn(featureLimitService, 'getFileVersioningLimits') - .mockResolvedValue(premiumLimits); - - jest - .spyOn(fileVersionRepository, 'findAllByFileId') - .mockResolvedValue([]); - - const updateStatusBatchSpy = jest - .spyOn(fileVersionRepository, 'updateStatusBatch') - .mockResolvedValue(undefined); - - await service['applyRetentionPolicy']('file-uuid', userUuid); - - expect(updateStatusBatchSpy).not.toHaveBeenCalled(); - }); - - it('When versions exist within retention period and under limit, then no versions are deleted', async () => { - jest - .spyOn(featureLimitService, 'getFileVersioningLimits') - .mockResolvedValue(premiumLimits); - - const mockVersions = [ - { - id: '1', - createdAt: new Date(), - status: 'EXISTS', - }, - { - id: '2', - createdAt: new Date(), - status: 'EXISTS', - }, - ]; - - jest - .spyOn(fileVersionRepository, 'findAllByFileId') - .mockResolvedValue(mockVersions as any); - - const updateStatusBatchSpy = jest - .spyOn(fileVersionRepository, 'updateStatusBatch') - .mockResolvedValue(undefined); - - await service['applyRetentionPolicy']('file-uuid', userUuid); - - expect(updateStatusBatchSpy).not.toHaveBeenCalled(); - }); - - it('When limit is reached with recent versions, then oldest is deleted', async () => { - jest - .spyOn(featureLimitService, 'getFileVersioningLimits') - .mockResolvedValue(premiumLimits); - - const mockVersions = Array.from({ length: 10 }, (_, i) => ({ - id: `${i + 1}`, - createdAt: new Date(Date.now() - i * 1000), - status: 'EXISTS', - })); - - jest - .spyOn(fileVersionRepository, 'findAllByFileId') - .mockResolvedValue(mockVersions as any); - - const updateStatusBatchSpy = jest - .spyOn(fileVersionRepository, 'updateStatusBatch') - .mockResolvedValue(undefined); - - await service['applyRetentionPolicy']('file-uuid', userUuid); - - expect(updateStatusBatchSpy).toHaveBeenCalledWith(['10'], 'DELETED'); - }); - - it('When versions exceed limit, then excess versions are deleted', async () => { - jest - .spyOn(featureLimitService, 'getFileVersioningLimits') - .mockResolvedValue(premiumLimits); - - const now = new Date(); - const mockVersions = Array.from({ length: 12 }, (_, i) => ({ - id: `${i + 1}`, - createdAt: new Date(now.getTime() - i * 24 * 60 * 60 * 1000), - status: 'EXISTS', - })); - - jest - .spyOn(fileVersionRepository, 'findAllByFileId') - .mockResolvedValue(mockVersions as any); - const updateStatusBatchSpy = jest - .spyOn(fileVersionRepository, 'updateStatusBatch') - .mockResolvedValue(undefined); - - await service['applyRetentionPolicy']('file-uuid', userUuid); - - expect(updateStatusBatchSpy).toHaveBeenCalled(); - }); - }); - describe('getVersioningLimits', () => { it('When called with a valid user id, then the versioning limits are returned', async () => { const userUuid = v4(); diff --git a/src/modules/file/file.usecase.ts b/src/modules/file/file.usecase.ts index 827fad9ba..e6c321603 100644 --- a/src/modules/file/file.usecase.ts +++ b/src/modules/file/file.usecase.ts @@ -59,7 +59,11 @@ import { TrashItemType } from '../trash/trash.attributes'; import { TrashUseCases } from '../trash/trash.usecase'; import { CacheManagerService } from '../cache-manager/cache-manager.service'; import { PaymentRequiredException } from '../feature-limit/exceptions/payment-required.exception'; -import { DeleteFileVersionAction, GetFileVersionsAction } from './actions'; +import { + DeleteFileVersionAction, + GetFileVersionsAction, + CreateFileVersionAction, +} from './actions'; export enum VersionableFileExtension { PDF = 'pdf', @@ -96,6 +100,7 @@ export class FileUseCases { private readonly cacheManagerService: CacheManagerService, private readonly getFileVersionsAction: GetFileVersionsAction, private readonly deleteFileVersionAction: DeleteFileVersionAction, + private readonly createFileVersionAction: CreateFileVersionAction, ) {} getByUuid(uuid: FileAttributes['uuid']): Promise { @@ -861,25 +866,15 @@ export class FileUseCases { ); if (shouldVersion) { - await this.applyRetentionPolicy(fileUuid, user.uuid); - - const { fileId, size, modificationTime } = newFileData; - - await Promise.all([ - this.fileVersionRepository.upsert({ - fileId: file.uuid, - userId: user.uuid, - networkFileId: file.fileId, - size: file.size, - status: FileVersionStatus.EXISTS, - }), - this.fileRepository.updateByUuidAndUserId(fileUuid, user.id, { - fileId: newFileId, - size, - updatedAt: new Date(), - ...(modificationTime ? { modificationTime } : null), - }), - ]); + const { size, modificationTime } = newFileData; + + await this.createFileVersionAction.execute( + user, + file, + newFileId, + size, + modificationTime, + ); return { ...file.toJSON(), @@ -1194,61 +1189,6 @@ export class FileUseCases { return { versionable: true, limits }; } - private async applyRetentionPolicy( - fileUuid: string, - userUuid: string, - ): Promise { - const limits = - await this.featureLimitService.getFileVersioningLimits(userUuid); - - if (!limits.enabled) { - return; - } - - const { retentionDays, maxVersions } = limits; - - const cutoffDate = Time.daysAgo(retentionDays); - - const versions = await this.fileVersionRepository.findAllByFileId(fileUuid); - - const versionsToDeleteByAge = versions.filter( - (version) => version.createdAt < cutoffDate, - ); - - const remainingVersions = versions - .filter((version) => version.createdAt >= cutoffDate) - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); - - const versionsToDeleteByCount = remainingVersions.slice(maxVersions); - - const versionsToDelete = [ - ...versionsToDeleteByAge, - ...versionsToDeleteByCount, - ]; - - const remainingCount = versions.length - versionsToDelete.length; - if (remainingCount >= maxVersions) { - const versionsNotDeleted = versions.filter( - (v) => !versionsToDelete.some((vd) => vd.id === v.id), - ); - const oldestVersion = versionsNotDeleted.sort( - (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), - )[0]; - - if (oldestVersion) { - versionsToDelete.push(oldestVersion); - } - } - - if (versionsToDelete.length > 0) { - const idsToDelete = versionsToDelete.map((v) => v.id); - await this.fileVersionRepository.updateStatusBatch( - idsToDelete, - FileVersionStatus.DELETED, - ); - } - } - async getVersioningLimits(userUuid: string): Promise<{ enabled: boolean; maxFileSize: number; From 79e6d8239db3d3a6fcc187277f681690f8d84646 Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Wed, 21 Jan 2026 07:19:48 +0100 Subject: [PATCH 60/71] fix(file-versions): remove unique constraint on file_id and network_file_id --- ...6-remove-file-versions-unique-constraint.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 migrations/20260121065836-remove-file-versions-unique-constraint.js diff --git a/migrations/20260121065836-remove-file-versions-unique-constraint.js b/migrations/20260121065836-remove-file-versions-unique-constraint.js new file mode 100644 index 000000000..361172942 --- /dev/null +++ b/migrations/20260121065836-remove-file-versions-unique-constraint.js @@ -0,0 +1,18 @@ +'use strict'; + +const tableName = 'file_versions'; +const indexName = 'file_versions_file_id_network_file_id_unique'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.removeIndex(tableName, indexName); + }, + + async down(queryInterface) { + await queryInterface.addIndex(tableName, ['file_id', 'network_file_id'], { + unique: true, + name: indexName, + }); + }, +}; From a74907af7aa34801c7c76bf887e2cfbf28ef1517 Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Wed, 21 Jan 2026 16:54:49 +0100 Subject: [PATCH 61/71] refactor(file-versions): simplify retention policy logic --- .../create-file-version.action.spec.ts | 30 ------------------- .../actions/create-file-version.action.ts | 18 ++--------- 2 files changed, 3 insertions(+), 45 deletions(-) diff --git a/src/modules/file/actions/create-file-version.action.spec.ts b/src/modules/file/actions/create-file-version.action.spec.ts index 38810c76f..5f1017fab 100644 --- a/src/modules/file/actions/create-file-version.action.spec.ts +++ b/src/modules/file/actions/create-file-version.action.spec.ts @@ -204,36 +204,6 @@ describe('CreateFileVersionAction', () => { }); }); - describe('When retention policy is disabled', () => { - it('then should create version without applying retention', async () => { - const mockFile = newFile({ - attributes: { - fileId: 'old-file-id', - bucket: 'test-bucket', - type: 'pdf', - size: BigInt(100), - }, - }); - - jest - .spyOn(featureLimitService, 'getFileVersioningLimits') - .mockResolvedValue({ - enabled: false, - maxFileSize: 1000000, - retentionDays: 15, - maxVersions: 10, - }); - const findAllSpy = jest.spyOn(fileVersionRepository, 'findAllByFileId'); - jest.spyOn(fileVersionRepository, 'create').mockResolvedValue({} as any); - jest.spyOn(fileRepository, 'updateByUuidAndUserId').mockResolvedValue(); - - await action.execute(userMocked, mockFile, 'new-file-id', BigInt(200)); - - expect(findAllSpy).not.toHaveBeenCalled(); - expect(fileVersionRepository.create).toHaveBeenCalled(); - }); - }); - describe('When versions exist within retention period and under limit', () => { it('then should not delete any versions', async () => { const mockFile = newFile({ diff --git a/src/modules/file/actions/create-file-version.action.ts b/src/modules/file/actions/create-file-version.action.ts index b67a1ba81..3b6775ba2 100644 --- a/src/modules/file/actions/create-file-version.action.ts +++ b/src/modules/file/actions/create-file-version.action.ts @@ -48,10 +48,6 @@ export class CreateFileVersionAction { const limits = await this.featureLimitService.getFileVersioningLimits(userUuid); - if (!limits.enabled) { - return; - } - const { retentionDays, maxVersions } = limits; const cutoffDate = Time.daysAgo(retentionDays); @@ -74,17 +70,9 @@ export class CreateFileVersionAction { ]; const remainingCount = versions.length - versionsToDelete.length; - if (remainingCount >= maxVersions) { - const versionsNotDeleted = versions.filter( - (v) => !versionsToDelete.some((vd) => vd.id === v.id), - ); - const oldestVersion = versionsNotDeleted.sort( - (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), - )[0]; - - if (oldestVersion) { - versionsToDelete.push(oldestVersion); - } + if (remainingCount === maxVersions) { + const oldestVersion = remainingVersions[remainingVersions.length - 1]; + versionsToDelete.push(oldestVersion); } if (versionsToDelete.length > 0) { From 007dc7c3c4b04fc70e043d2ae4e3e9a217c7e4c7 Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Fri, 23 Jan 2026 09:01:56 +0100 Subject: [PATCH 62/71] refactor(fv-action): improve test readability --- .../create-file-version.action.spec.ts | 55 ++++++++++--------- .../actions/create-file-version.action.ts | 2 +- 2 files changed, 31 insertions(+), 26 deletions(-) diff --git a/src/modules/file/actions/create-file-version.action.spec.ts b/src/modules/file/actions/create-file-version.action.spec.ts index 5f1017fab..5644ba41f 100644 --- a/src/modules/file/actions/create-file-version.action.spec.ts +++ b/src/modules/file/actions/create-file-version.action.spec.ts @@ -5,6 +5,7 @@ import { SequelizeFileVersionRepository } from '../file-version.repository'; import { FeatureLimitService } from '../../feature-limit/feature-limit.service'; import { newFile, newUser } from '../../../../test/fixtures'; import { FileVersion, FileVersionStatus } from '../file-version.domain'; +import dayjs from 'dayjs'; describe('CreateFileVersionAction', () => { let action: CreateFileVersionAction; @@ -162,8 +163,10 @@ describe('CreateFileVersionAction', () => { }, }); - const oldDate = new Date(); - oldDate.setDate(oldDate.getDate() - 20); + const retentionDays = 15; + const oldVersionDate = dayjs() + .subtract(retentionDays + 1, 'day') + .toDate(); const existingVersions = [ FileVersion.build({ @@ -173,8 +176,8 @@ describe('CreateFileVersionAction', () => { networkFileId: 'network-1', size: BigInt(50), status: FileVersionStatus.EXISTS, - createdAt: oldDate, - updatedAt: oldDate, + createdAt: oldVersionDate, + updatedAt: oldVersionDate, }), ]; @@ -183,7 +186,7 @@ describe('CreateFileVersionAction', () => { .mockResolvedValue({ enabled: true, maxFileSize: 1000000, - retentionDays: 15, + retentionDays, maxVersions: 10, }); jest @@ -198,7 +201,7 @@ describe('CreateFileVersionAction', () => { await action.execute(userMocked, mockFile, 'new-file-id', BigInt(200)); expect(fileVersionRepository.updateStatusBatch).toHaveBeenCalledWith( - ['version-1'], + [existingVersions[0].id], FileVersionStatus.DELETED, ); }); @@ -215,7 +218,7 @@ describe('CreateFileVersionAction', () => { }, }); - const now = new Date(); + const now = dayjs().toDate(); const existingVersions = [ FileVersion.build({ id: 'version-1', @@ -224,7 +227,7 @@ describe('CreateFileVersionAction', () => { networkFileId: 'network-1', size: BigInt(50), status: FileVersionStatus.EXISTS, - createdAt: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), + createdAt: dayjs().subtract(5, 'day').toDate(), updatedAt: now, }), FileVersion.build({ @@ -234,7 +237,7 @@ describe('CreateFileVersionAction', () => { networkFileId: 'network-2', size: BigInt(50), status: FileVersionStatus.EXISTS, - createdAt: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), + createdAt: dayjs().subtract(10, 'day').toDate(), updatedAt: now, }), ]; @@ -274,21 +277,23 @@ describe('CreateFileVersionAction', () => { }, }); - const now = new Date(); - const existingVersions = Array.from({ length: 12 }, (_, i) => { - const date = new Date(now); - date.setHours(date.getHours() - i); - return FileVersion.build({ - id: `version-${i}`, - fileId: mockFile.uuid, - userId: userMocked.uuid, - networkFileId: `network-${i}`, - size: BigInt(50), - status: FileVersionStatus.EXISTS, - createdAt: date, - updatedAt: date, - }); - }); + const maxVersions = 10; + const existingVersions = Array.from( + { length: maxVersions + 2 }, + (_, i) => { + const date = dayjs().subtract(i, 'hour').toDate(); + return FileVersion.build({ + id: `version-${i}`, + fileId: mockFile.uuid, + userId: userMocked.uuid, + networkFileId: `network-${i}`, + size: BigInt(50), + status: FileVersionStatus.EXISTS, + createdAt: date, + updatedAt: date, + }); + }, + ); jest .spyOn(featureLimitService, 'getFileVersioningLimits') @@ -296,7 +301,7 @@ describe('CreateFileVersionAction', () => { enabled: true, maxFileSize: 1000000, retentionDays: 15, - maxVersions: 10, + maxVersions, }); jest .spyOn(fileVersionRepository, 'findAllByFileId') diff --git a/src/modules/file/actions/create-file-version.action.ts b/src/modules/file/actions/create-file-version.action.ts index 3b6775ba2..0f6a81e48 100644 --- a/src/modules/file/actions/create-file-version.action.ts +++ b/src/modules/file/actions/create-file-version.action.ts @@ -71,7 +71,7 @@ export class CreateFileVersionAction { const remainingCount = versions.length - versionsToDelete.length; if (remainingCount === maxVersions) { - const oldestVersion = remainingVersions[remainingVersions.length - 1]; + const oldestVersion = remainingVersions.at(-1); versionsToDelete.push(oldestVersion); } From 770b25404c5cd5e7f2d3171f205894077dc3bfda Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Mon, 26 Jan 2026 11:51:53 +0100 Subject: [PATCH 63/71] refactor(fv): remove unnecessary unique constraint removal migration --- ...6-remove-file-versions-unique-constraint.js | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 migrations/20260121065836-remove-file-versions-unique-constraint.js diff --git a/migrations/20260121065836-remove-file-versions-unique-constraint.js b/migrations/20260121065836-remove-file-versions-unique-constraint.js deleted file mode 100644 index 361172942..000000000 --- a/migrations/20260121065836-remove-file-versions-unique-constraint.js +++ /dev/null @@ -1,18 +0,0 @@ -'use strict'; - -const tableName = 'file_versions'; -const indexName = 'file_versions_file_id_network_file_id_unique'; - -/** @type {import('sequelize-cli').Migration} */ -module.exports = { - async up(queryInterface) { - await queryInterface.removeIndex(tableName, indexName); - }, - - async down(queryInterface) { - await queryInterface.addIndex(tableName, ['file_id', 'network_file_id'], { - unique: true, - name: indexName, - }); - }, -}; From 8663f8e5997973eed2bb6077f385001d009bb757 Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Tue, 27 Jan 2026 11:03:16 +0100 Subject: [PATCH 64/71] test(file-versions): complete assertions and improve code clarity --- .../create-file-version.action.spec.ts | 59 ++++++++++++++++++- .../actions/create-file-version.action.ts | 3 +- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/modules/file/actions/create-file-version.action.spec.ts b/src/modules/file/actions/create-file-version.action.spec.ts index 5644ba41f..b8deee942 100644 --- a/src/modules/file/actions/create-file-version.action.spec.ts +++ b/src/modules/file/actions/create-file-version.action.spec.ts @@ -200,10 +200,30 @@ describe('CreateFileVersionAction', () => { await action.execute(userMocked, mockFile, 'new-file-id', BigInt(200)); + const oldestVersionId = existingVersions[0].id; + expect(fileVersionRepository.updateStatusBatch).toHaveBeenCalledWith( - [existingVersions[0].id], + [oldestVersionId], FileVersionStatus.DELETED, ); + + expect(fileVersionRepository.create).toHaveBeenCalledWith({ + fileId: mockFile.uuid, + userId: userMocked.uuid, + networkFileId: mockFile.fileId, + size: mockFile.size, + status: FileVersionStatus.EXISTS, + }); + + expect(fileRepository.updateByUuidAndUserId).toHaveBeenCalledWith( + mockFile.uuid, + userMocked.id, + expect.objectContaining({ + fileId: 'new-file-id', + size: BigInt(200), + updatedAt: expect.any(Date), + }), + ); }); }); @@ -262,7 +282,24 @@ describe('CreateFileVersionAction', () => { await action.execute(userMocked, mockFile, 'new-file-id', BigInt(200)); expect(updateStatusBatchSpy).not.toHaveBeenCalled(); - expect(fileVersionRepository.create).toHaveBeenCalled(); + + expect(fileVersionRepository.create).toHaveBeenCalledWith({ + fileId: mockFile.uuid, + userId: userMocked.uuid, + networkFileId: mockFile.fileId, + size: mockFile.size, + status: FileVersionStatus.EXISTS, + }); + + expect(fileRepository.updateByUuidAndUserId).toHaveBeenCalledWith( + mockFile.uuid, + userMocked.id, + expect.objectContaining({ + fileId: 'new-file-id', + size: BigInt(200), + updatedAt: expect.any(Date), + }), + ); }); }); @@ -318,6 +355,24 @@ describe('CreateFileVersionAction', () => { expect.arrayContaining(['version-10', 'version-11']), FileVersionStatus.DELETED, ); + + expect(fileVersionRepository.create).toHaveBeenCalledWith({ + fileId: mockFile.uuid, + userId: userMocked.uuid, + networkFileId: mockFile.fileId, + size: mockFile.size, + status: FileVersionStatus.EXISTS, + }); + + expect(fileRepository.updateByUuidAndUserId).toHaveBeenCalledWith( + mockFile.uuid, + userMocked.id, + expect.objectContaining({ + fileId: 'new-file-id', + size: BigInt(200), + updatedAt: expect.any(Date), + }), + ); }); }); }); diff --git a/src/modules/file/actions/create-file-version.action.ts b/src/modules/file/actions/create-file-version.action.ts index 0f6a81e48..4002a74ef 100644 --- a/src/modules/file/actions/create-file-version.action.ts +++ b/src/modules/file/actions/create-file-version.action.ts @@ -69,8 +69,7 @@ export class CreateFileVersionAction { ...versionsToDeleteByCount, ]; - const remainingCount = versions.length - versionsToDelete.length; - if (remainingCount === maxVersions) { + if (remainingVersions.length === maxVersions) { const oldestVersion = remainingVersions.at(-1); versionsToDelete.push(oldestVersion); } From 6b8bc976ffd88c6e052da82246fe6e78fc55d052 Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Tue, 27 Jan 2026 17:05:23 +0100 Subject: [PATCH 65/71] fix(file-versions): correct retention policy to make space for new version --- src/modules/file/actions/create-file-version.action.spec.ts | 2 +- src/modules/file/actions/create-file-version.action.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/modules/file/actions/create-file-version.action.spec.ts b/src/modules/file/actions/create-file-version.action.spec.ts index b8deee942..b07b51130 100644 --- a/src/modules/file/actions/create-file-version.action.spec.ts +++ b/src/modules/file/actions/create-file-version.action.spec.ts @@ -352,7 +352,7 @@ describe('CreateFileVersionAction', () => { await action.execute(userMocked, mockFile, 'new-file-id', BigInt(200)); expect(fileVersionRepository.updateStatusBatch).toHaveBeenCalledWith( - expect.arrayContaining(['version-10', 'version-11']), + expect.arrayContaining(['version-9', 'version-10', 'version-11']), FileVersionStatus.DELETED, ); diff --git a/src/modules/file/actions/create-file-version.action.ts b/src/modules/file/actions/create-file-version.action.ts index 4002a74ef..ae5efaa6b 100644 --- a/src/modules/file/actions/create-file-version.action.ts +++ b/src/modules/file/actions/create-file-version.action.ts @@ -62,6 +62,7 @@ export class CreateFileVersionAction { .filter((version) => version.createdAt >= cutoffDate) .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + const versionsToKeep = remainingVersions.slice(0, maxVersions); const versionsToDeleteByCount = remainingVersions.slice(maxVersions); const versionsToDelete = [ @@ -69,8 +70,8 @@ export class CreateFileVersionAction { ...versionsToDeleteByCount, ]; - if (remainingVersions.length === maxVersions) { - const oldestVersion = remainingVersions.at(-1); + if (versionsToKeep.length === maxVersions) { + const oldestVersion = versionsToKeep.at(-1); versionsToDelete.push(oldestVersion); } From f7405088bfb59c2b87b30ced5a33bc256eef9495 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:21:32 -0600 Subject: [PATCH 66/71] feat(exception-filter): handle database connection errors with specific logging and response --- .../http-global-exception-filter.exception.ts | 42 +++++++++++++++++++ src/modules/auth/jwt.strategy.ts | 5 +++ 2 files changed, 47 insertions(+) diff --git a/src/common/http-global-exception-filter.exception.ts b/src/common/http-global-exception-filter.exception.ts index 2e7df2924..4d1fcf206 100644 --- a/src/common/http-global-exception-filter.exception.ts +++ b/src/common/http-global-exception-filter.exception.ts @@ -39,6 +39,20 @@ export class HttpGlobalExceptionFilter extends BaseExceptionFilter { const requestId = request.id; + if (this.isDatabaseConnectionError(exception)) { + this.logDatabaseConnectionError(exception, request); + + return httpAdapter.reply( + response, + { + statusCode: HttpStatus.SERVICE_UNAVAILABLE, + message: 'Service temporarily unavailable', + requestId, + }, + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + this.logUnexpectedError(exception, request); return httpAdapter.reply( @@ -83,6 +97,34 @@ export class HttpGlobalExceptionFilter extends BaseExceptionFilter { ); } + private isDatabaseConnectionError(exception: any): boolean { + const connectionErrorNames = [ + 'SequelizeConnectionAcquireTimeoutError', + 'SequelizeConnectionError', + 'SequelizeConnectionRefusedError', + 'SequelizeConnectionTimedOutError', + ]; + + return connectionErrorNames.includes(exception?.name); + } + + private logDatabaseConnectionError(exception: any, request) { + const errorResponse = { + name: exception.name, + path: request.url, + errorType: 'DATABASE_CONNECTION_ERROR', + method: request.method, + user: { + uuid: request?.user?.uuid, + }, + error: { + message: exception.message, + }, + }; + + this.logger.error(errorResponse, 'DATABASE_CONNECTION_ERROR'); + } + logUnexpectedError(exception: any, request) { let errorSubtype = ''; if (exception instanceof SequelizeError) { diff --git a/src/modules/auth/jwt.strategy.ts b/src/modules/auth/jwt.strategy.ts index 865b97908..c02510a0c 100644 --- a/src/modules/auth/jwt.strategy.ts +++ b/src/modules/auth/jwt.strategy.ts @@ -2,6 +2,7 @@ import { Inject, UnauthorizedException, Logger, + HttpException, Injectable, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -93,6 +94,10 @@ export class JwtStrategy extends PassportStrategy(Strategy, strategyId) { throw err; } + if (!(err instanceof HttpException)) { + throw err; + } + Logger.error( `[AUTH/MIDDLEWARE] ERROR validating authorization ${ err.message From 8c1ec5e00c6ddfd35cbbb9d624022c365ea7a13d Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Tue, 27 Jan 2026 10:56:41 -0600 Subject: [PATCH 67/71] feat(folder): implement request timeout handling for folder metadata retrieval --- src/modules/folder/folder.controller.ts | 1 + src/modules/folder/folder.repository.ts | 31 ++++++++++++++++++----- src/modules/folder/folder.usecase.spec.ts | 24 ++++++++++++++++++ src/modules/folder/folder.usecase.ts | 18 +++++++++---- 4 files changed, 62 insertions(+), 12 deletions(-) diff --git a/src/modules/folder/folder.controller.ts b/src/modules/folder/folder.controller.ts index aabc2a578..286e9190d 100644 --- a/src/modules/folder/folder.controller.ts +++ b/src/modules/folder/folder.controller.ts @@ -792,6 +792,7 @@ export class FolderController { user, folderPath, ); + if (!folder) { throw new NotFoundException('Folder not found'); } diff --git a/src/modules/folder/folder.repository.ts b/src/modules/folder/folder.repository.ts index 6faf158c3..c58fc0226 100644 --- a/src/modules/folder/folder.repository.ts +++ b/src/modules/folder/folder.repository.ts @@ -1179,14 +1179,31 @@ export class SequelizeFolderRepository implements FolderRepository { path: string, rootFolderUuid: Folder['uuid'], ): Promise { - const [[folder]] = await this.folderModel.sequelize.query( - 'SELECT * FROM get_folder_by_path (:userId, :path, :rootFolderUuid)', - { - replacements: { userId, path, rootFolderUuid }, - }, - ); + try { + return await this.folderModel.sequelize.transaction( + async (transaction) => { + await this.folderModel.sequelize.query( + "SET LOCAL statement_timeout = '8s'", + { transaction }, + ); + + const [[folder]] = await this.folderModel.sequelize.query( + 'SELECT * FROM get_folder_by_path (:userId, :path, :rootFolderUuid)', + { + replacements: { userId, path, rootFolderUuid }, + transaction, + }, + ); - return (folder as Folder) ?? null; + return (folder as Folder) ?? null; + }, + ); + } catch (error) { + if (error.original?.code === '57014') { + throw new Error('Query timed out'); + } + throw error; + } } private toDomain(model: FolderModel): Folder { diff --git a/src/modules/folder/folder.usecase.spec.ts b/src/modules/folder/folder.usecase.spec.ts index aa8b40715..9b9cf3867 100644 --- a/src/modules/folder/folder.usecase.spec.ts +++ b/src/modules/folder/folder.usecase.spec.ts @@ -9,6 +9,7 @@ import { NotAcceptableException, NotFoundException, UnprocessableEntityException, + RequestTimeoutException, } from '@nestjs/common'; import { v4 } from 'uuid'; import { Folder, FolderOptions } from './folder.domain'; @@ -1408,6 +1409,29 @@ describe('FolderUseCases', () => { service.getFolderMetadataByPath(userMocked, folderPath), ).rejects.toThrow(NotFoundException); }); + + it('When get folder metadata by path times out, then it should throw RequestTimeoutException', async () => { + const folderPath = '/folder1/folder2'; + jest.spyOn(service, 'getFolderByUserId').mockResolvedValue(newFolder()); + jest + .spyOn(folderRepository, 'getFolderByPath') + .mockRejectedValue(new Error('Query timed out')); + + await expect( + service.getFolderMetadataByPath(userMocked, folderPath), + ).rejects.toThrow(RequestTimeoutException); + }); + + it('When get folder metadata by path throws generic error, then it should rethrow it', async () => { + const folderPath = '/folder1/folder2'; + const error = new Error('Generic error'); + jest.spyOn(service, 'getFolderByUserId').mockResolvedValue(newFolder()); + jest.spyOn(folderRepository, 'getFolderByPath').mockRejectedValue(error); + + await expect( + service.getFolderMetadataByPath(userMocked, folderPath), + ).rejects.toThrow(error); + }); }); describe('getWorkspacesFoldersUpdatedAfter', () => { diff --git a/src/modules/folder/folder.usecase.ts b/src/modules/folder/folder.usecase.ts index 7156f2240..9e0c2023f 100644 --- a/src/modules/folder/folder.usecase.ts +++ b/src/modules/folder/folder.usecase.ts @@ -7,6 +7,7 @@ import { Logger, NotAcceptableException, NotFoundException, + RequestTimeoutException, UnprocessableEntityException, forwardRef, } from '@nestjs/common'; @@ -1027,11 +1028,18 @@ export class FolderUseCases { throw new NotFoundException('Root Folder not found'); } - return this.folderRepository.getFolderByPath( - user.id, - path, - rootFolder.uuid, - ); + try { + return await this.folderRepository.getFolderByPath( + user.id, + path, + rootFolder.uuid, + ); + } catch (error) { + if (error.message === 'Query timed out') { + throw new RequestTimeoutException('Folder metadata search timed out'); + } + throw error; + } } async updateByFolderIdAndForceUpdatedAt( From 1174c548aa69249bcf435e87fdcc26c4fc64359d Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Thu, 18 Dec 2025 12:56:14 +0100 Subject: [PATCH 68/71] feat: allow empty files in workspace --- src/modules/file/file.controller.spec.ts | 1 + src/modules/file/file.controller.ts | 9 + src/modules/file/file.repository.ts | 30 +++ src/modules/file/file.usecase.spec.ts | 226 ++++++++++++++++++ src/modules/file/file.usecase.ts | 55 ++++- src/modules/workspaces/workspaces.module.ts | 2 + .../workspaces/workspaces.usecase.spec.ts | 126 ++++++++++ src/modules/workspaces/workspaces.usecase.ts | 12 + 8 files changed, 457 insertions(+), 4 deletions(-) diff --git a/src/modules/file/file.controller.spec.ts b/src/modules/file/file.controller.spec.ts index 5bd3cc3b7..f5e1e2de8 100644 --- a/src/modules/file/file.controller.spec.ts +++ b/src/modules/file/file.controller.spec.ts @@ -625,6 +625,7 @@ describe('FileController', () => { userMocked, validUuid, replaceFileDto, + null, ); expect(storageNotificationService.fileUpdated).toHaveBeenCalledWith({ payload: replacedFile, diff --git a/src/modules/file/file.controller.ts b/src/modules/file/file.controller.ts index 3936efa45..889cd36e8 100644 --- a/src/modules/file/file.controller.ts +++ b/src/modules/file/file.controller.ts @@ -58,6 +58,8 @@ import { RequestLoggerInterceptor } from '../../middlewares/requests-logger.inte import { Version } from '../../common/decorators/version.decorator'; import { CustomEndpointThrottleGuard } from '../../guards/custom-endpoint-throttle.guard'; import { CustomThrottle } from '../../guards/custom-endpoint-throttle.decorator'; +import { Workspace as WorkspaceDecorator } from '../auth/decorators/workspace.decorator'; +import { Workspace } from '../workspaces/domains/workspaces.domain'; @ApiTags('File') @Controller('files') @@ -263,12 +265,19 @@ export class FileController { @Body() fileData: ReplaceFileDto, @Client() clientId: string, @Requester() requester: User, + @WorkspaceDecorator() workspace?: Workspace, ): Promise { try { const file = await this.fileUseCases.replaceFile( user, fileUuid, fileData, + workspace + ? { + workspace, + memberId: requester.uuid, + } + : null, ); this.storageNotificationService.fileUpdated({ diff --git a/src/modules/file/file.repository.ts b/src/modules/file/file.repository.ts index 3c36ee490..1961dc71b 100644 --- a/src/modules/file/file.repository.ts +++ b/src/modules/file/file.repository.ts @@ -104,6 +104,10 @@ export interface FileRepository { getFilesWhoseFolderIdDoesNotExist(userId: File['userId']): Promise; getFilesCountWhere(where: Partial): Promise; getZeroSizeFilesCountByUser(userId: User['id']): Promise; + getZeroSizeFilesCountInWorkspaceByMember( + createdBy: WorkspaceItemUserAttributes['createdBy'], + workspaceId: WorkspaceAttributes['id'], + ): Promise; updateFilesStatusToTrashed( user: User, fileIds: File['fileId'][], @@ -469,6 +473,32 @@ export class SequelizeFileRepository implements FileRepository { return files.map(this.toDomain.bind(this)); } + async getZeroSizeFilesCountInWorkspaceByMember( + createdBy: WorkspaceItemUserAttributes['createdBy'], + workspaceId: WorkspaceAttributes['id'], + ): Promise { + const { count } = await this.fileModel.findAndCountAll({ + where: { + size: 0, + status: { + [Op.not]: FileStatus.DELETED, + }, + }, + include: [ + { + model: WorkspaceItemUserModel, + where: { + createdBy, + workspaceId, + itemType: WorkspaceItemType.File, + }, + }, + ], + }); + + return count; + } + async getSumSizeOfFilesInWorkspaceByStatuses( createdBy: WorkspaceItemUserAttributes['createdBy'], workspaceId: WorkspaceAttributes['id'], diff --git a/src/modules/file/file.usecase.spec.ts b/src/modules/file/file.usecase.spec.ts index 47758234b..365f454e7 100644 --- a/src/modules/file/file.usecase.spec.ts +++ b/src/modules/file/file.usecase.spec.ts @@ -76,6 +76,7 @@ describe('FileUseCases', () => { let getFileVersionsAction: GetFileVersionsAction; let deleteFileVersionAction: DeleteFileVersionAction; let createFileVersionAction: CreateFileVersionAction; + let userUsecases: UserUseCases; const userMocked = newUser({ attributes: { @@ -116,6 +117,7 @@ describe('FileUseCases', () => { createFileVersionAction = module.get( CreateFileVersionAction, ); + userUsecases = module.get(UserUseCases); }); afterEach(() => { @@ -1250,6 +1252,73 @@ describe('FileUseCases', () => { }); }); + describe('checkWorkspaceEmptyFilesLimit', () => { + it('When workspace owner limit is enforced, then it should throw', async () => { + const workspaceNetworkUser = newUser(); + const member = newUser(); + const workspace = newWorkspace({ + attributes: { workspaceUserId: workspaceNetworkUser.uuid }, + }); + const mockLimit = newFeatureLimit({ + value: '10', + label: LimitLabels.MaxZeroSizeFiles, + type: LimitTypes.Counter, + }); + + jest + .spyOn(userUsecases, 'findByUuid') + .mockResolvedValue(workspaceNetworkUser); + jest + .spyOn(featureLimitService, 'getUserLimitByLabel') + .mockResolvedValue(mockLimit); + jest + .spyOn(fileRepository, 'getZeroSizeFilesCountInWorkspaceByMember') + .mockResolvedValue(10); + + await expect( + service.checkWorkspaceEmptyFilesLimit(member.uuid, workspace), + ).rejects.toThrow(BadRequestException); + }); + + it('When workspace owner limit is NOT enforced, then it should not throw', async () => { + const workspaceNetworkUser = newUser(); + const member = newUser(); + const workspace = newWorkspace({ + attributes: { ownerId: workspaceNetworkUser.uuid }, + }); + const mockLimit = newFeatureLimit({ + value: '1000', + label: LimitLabels.MaxZeroSizeFiles, + type: LimitTypes.Counter, + }); + + jest + .spyOn(userUsecases, 'findByUuid') + .mockResolvedValue(workspaceNetworkUser); + jest + .spyOn(featureLimitService, 'getUserLimitByLabel') + .mockResolvedValue(mockLimit); + jest + .spyOn(fileRepository, 'getZeroSizeFilesCountInWorkspaceByMember') + .mockResolvedValue(5); + + await expect( + service.checkWorkspaceEmptyFilesLimit(member.uuid, workspace), + ).resolves.not.toThrow(); + + expect(userUsecases.findByUuid).toHaveBeenCalledWith( + workspace.workspaceUserId, + ); + expect(featureLimitService.getUserLimitByLabel).toHaveBeenCalledWith( + LimitLabels.MaxZeroSizeFiles, + workspaceNetworkUser, + ); + expect( + fileRepository.getZeroSizeFilesCountInWorkspaceByMember, + ).toHaveBeenCalledWith(member.uuid, workspace.id); + }); + }); + describe('updateFileMetaData', () => { it('When a file with the same name already exists in the folder, then it should fail', async () => { const newFileMeta: UpdateFileMetaDto = { plainName: 'new-name' }; @@ -2448,6 +2517,163 @@ describe('FileUseCases', () => { ); }); }); + + describe('Empty file replacement in workspace', () => { + it('When replacing with empty file with workspace options, then it should check workspace limit', async () => { + const workspaceOwner = newUser(); + const member = newUser(); + const requester = newUser(); + const workspace = newWorkspace({ + attributes: { ownerId: workspaceOwner.uuid }, + }); + const mockFile = newFile({ + owner: member, + attributes: { + fileId: 'old-file-id', + size: BigInt(100), + }, + }); + const replaceData = { + size: BigInt(0), + }; + const workspaceOptions = { + workspace, + memberId: requester.uuid, + }; + const mockLimit = newFeatureLimit({ + value: '1000', + label: LimitLabels.MaxZeroSizeFiles, + type: LimitTypes.Counter, + }); + + jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); + jest + .spyOn(userUsecases, 'findByUuid') + .mockResolvedValue(workspaceOwner); + jest + .spyOn(featureLimitService, 'getUserLimitByLabel') + .mockResolvedValue(mockLimit); + jest + .spyOn(fileRepository, 'getZeroSizeFilesCountInWorkspaceByMember') + .mockResolvedValue(0); + jest + .spyOn(service, 'isFileVersionable') + .mockResolvedValue({ versionable: false, limits: null }); + const updateSpy = jest + .spyOn(fileRepository, 'updateByUuidAndUserId') + .mockResolvedValue(); + jest.spyOn(bridgeService, 'deleteFile').mockResolvedValue(); + + const result = await service.replaceFile( + member, + mockFile.uuid, + replaceData, + workspaceOptions, + ); + + expect( + fileRepository.getZeroSizeFilesCountInWorkspaceByMember, + ).toHaveBeenCalledWith(requester.uuid, workspace.id); + expect(updateSpy).toHaveBeenCalledWith( + mockFile.uuid, + member.id, + expect.objectContaining({ + fileId: null, + size: BigInt(0), + }), + ); + expect(result.fileId).toBeNull(); + expect(result.size).toBe(BigInt(0)); + }); + + it('When replacing with empty file without workspace options, then it should check individual limit', async () => { + const mockFile = newFile({ + attributes: { + fileId: 'old-file-id', + size: BigInt(100), + }, + }); + const replaceData = { + size: BigInt(0), + }; + const mockLimit = newFeatureLimit({ + value: '1000', + label: LimitLabels.MaxZeroSizeFiles, + type: LimitTypes.Counter, + }); + + jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); + jest + .spyOn(featureLimitService, 'getUserLimitByLabel') + .mockResolvedValue(mockLimit); + jest + .spyOn(fileRepository, 'getZeroSizeFilesCountByUser') + .mockResolvedValue(0); + jest + .spyOn(service, 'isFileVersionable') + .mockResolvedValue({ versionable: false, limits: null }); + jest.spyOn(fileRepository, 'updateByUuidAndUserId').mockResolvedValue(); + jest.spyOn(bridgeService, 'deleteFile').mockResolvedValue(); + + await service.replaceFile(userMocked, mockFile.uuid, replaceData); + + expect(featureLimitService.getUserLimitByLabel).toHaveBeenCalledWith( + LimitLabels.MaxZeroSizeFiles, + userMocked, + ); + expect(fileRepository.getZeroSizeFilesCountByUser).toHaveBeenCalledWith( + userMocked.id, + ); + expect( + fileRepository.getZeroSizeFilesCountInWorkspaceByMember, + ).not.toHaveBeenCalled(); + }); + + it('When replacing with non-empty file with workspace options, then it should not check limits', async () => { + const workspaceOwner = newUser(); + const member = newUser(); + const requester = newUser(); + const workspace = newWorkspace({ + attributes: { ownerId: workspaceOwner.uuid }, + }); + const mockFile = newFile({ + owner: member, + attributes: { + fileId: 'old-file-id', + size: BigInt(100), + }, + }); + const replaceData = { + fileId: 'new-file-id', + size: BigInt(1024), + }; + const workspaceOptions = { + workspace, + memberId: requester.uuid, + }; + + jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); + jest + .spyOn(service, 'isFileVersionable') + .mockResolvedValue({ versionable: false, limits: null }); + jest.spyOn(fileRepository, 'updateByUuidAndUserId').mockResolvedValue(); + jest.spyOn(bridgeService, 'deleteFile').mockResolvedValue(); + + await service.replaceFile( + member, + mockFile.uuid, + replaceData, + workspaceOptions, + ); + + expect( + fileRepository.getZeroSizeFilesCountInWorkspaceByMember, + ).not.toHaveBeenCalled(); + expect( + fileRepository.getZeroSizeFilesCountByUser, + ).not.toHaveBeenCalled(); + }); + }); }); describe('addOldAttributes', () => { diff --git a/src/modules/file/file.usecase.ts b/src/modules/file/file.usecase.ts index e6c321603..60d02c790 100644 --- a/src/modules/file/file.usecase.ts +++ b/src/modules/file/file.usecase.ts @@ -48,9 +48,8 @@ import { PLAN_FREE_INDIVIDUAL_TIER_LABEL, LimitLabels, } from '../feature-limit/limits.enum'; -import { FeatureLimitUsecases } from '../feature-limit/feature-limit.usecase'; import { SequelizeFileVersionRepository } from './file-version.repository'; -import { FileVersion, FileVersionStatus } from './file-version.domain'; +import { FileVersionStatus } from './file-version.domain'; import { FileVersionDto } from './dto/responses/file-version.dto'; import { UserUseCases } from '../user/user.usecase'; import { RedisService } from '../../externals/redis/redis.service'; @@ -64,6 +63,7 @@ import { GetFileVersionsAction, CreateFileVersionAction, } from './actions'; +import { Workspace } from '../workspaces/domains/workspaces.domain'; export enum VersionableFileExtension { PDF = 'pdf', @@ -93,7 +93,6 @@ export class FileUseCases { private readonly usageService: UsageService, private readonly mailerService: MailerService, private readonly featureLimitService: FeatureLimitService, - private readonly featureLimitUsecases: FeatureLimitUsecases, @Inject(forwardRef(() => UserUseCases)) private readonly userUsecases: UserUseCases, private readonly redisService: RedisService, @@ -451,6 +450,16 @@ export class FileUseCases { } } + async getZeroSizeFilesInWorkspaceByMember( + memberId: string, + workspaceId: string, + ) { + return this.fileRepository.getZeroSizeFilesCountInWorkspaceByMember( + memberId, + workspaceId, + ); + } + async updateFileMetaData( user: User, fileUuid: File['uuid'], @@ -840,6 +849,10 @@ export class FileUseCases { user: User, fileUuid: File['fileId'], newFileData: ReplaceFileDto, + workspaceOptions?: { + workspace: Workspace; + memberId: string; + }, ): Promise { const file = await this.fileRepository.findByUuid(fileUuid, user.id); @@ -854,7 +867,14 @@ export class FileUseCases { const isFileEmpty = newFileData.size === BigInt(0); if (isFileEmpty) { - await this.checkEmptyFilesLimit(user); + if (!workspaceOptions) { + await this.checkEmptyFilesLimit(user); + } else { + await this.checkWorkspaceEmptyFilesLimit( + workspaceOptions.memberId, + workspaceOptions.workspace, + ); + } } const newFileId = isFileEmpty ? null : newFileData.fileId; @@ -929,6 +949,33 @@ export class FileUseCases { }; } + async checkWorkspaceEmptyFilesLimit(memberId: string, workspace: Workspace) { + const workspaceNetworkUser = await this.userUsecases.findByUuid( + workspace.workspaceUserId, + ); + + const [maxZeroSizeFilesLimit, zeroSizeFilesCount] = await Promise.all([ + this.featureLimitService.getUserLimitByLabel( + LimitLabels.MaxZeroSizeFiles, + workspaceNetworkUser, + ), + this.fileRepository.getZeroSizeFilesCountInWorkspaceByMember( + memberId, + workspace.id, + ), + ]); + + if ( + maxZeroSizeFilesLimit.shouldLimitBeEnforced({ + currentCount: zeroSizeFilesCount, + }) + ) { + throw new BadRequestException( + 'You can not have more empty files in this workspace', + ); + } + } + async deleteUserTrashedFilesBatch( user: User, limit: number, diff --git a/src/modules/workspaces/workspaces.module.ts b/src/modules/workspaces/workspaces.module.ts index 3fc790b9c..1603a4fe5 100644 --- a/src/modules/workspaces/workspaces.module.ts +++ b/src/modules/workspaces/workspaces.module.ts @@ -25,6 +25,7 @@ import { FuzzySearchUseCases } from '../fuzzy-search/fuzzy-search.usecase'; import { FuzzySearchModule } from '../fuzzy-search/fuzzy-search.module'; import { NotificationModule } from '../../externals/notifications/notifications.module'; import { WorkspaceLogModel } from './models/workspace-logs.model'; +import { FeatureLimitModule } from '../feature-limit/feature-limit.module'; @Module({ imports: [ @@ -47,6 +48,7 @@ import { WorkspaceLogModel } from './models/workspace-logs.model'; HttpClientModule, FuzzySearchModule, NotificationModule, + forwardRef(() => FeatureLimitModule), ], controllers: [WorkspacesController], providers: [ diff --git a/src/modules/workspaces/workspaces.usecase.spec.ts b/src/modules/workspaces/workspaces.usecase.spec.ts index 1a36471db..65e8a07c4 100644 --- a/src/modules/workspaces/workspaces.usecase.spec.ts +++ b/src/modules/workspaces/workspaces.usecase.spec.ts @@ -2688,6 +2688,132 @@ describe('WorkspacesUsecases', () => { expect(result).toEqual({ ...createdFile, item: createdItemFile }); }); + + it('When creating empty file in workspace and limit not reached, then it should succeed', async () => { + const user = newUser(); + const workspace = newWorkspace(); + const workspaceUser = newWorkspaceUser({ + attributes: { + spaceLimit: 10240, + driveUsage: 0, + rootFolderId: 'root-folder-uuid', + }, + workspaceId: workspace.id, + member: user, + }); + const folderItem = newWorkspaceItemUser({ createdBy: user.uuid }); + const createdFile = newFile({ owner: user }); + const createdItemFile = newWorkspaceItemUser({ + createdBy: user.uuid, + itemType: WorkspaceItemType.File, + }); + const emptyFileDto = { + ...createFileDto, + size: BigInt(0), + }; + + jest + .spyOn(workspaceRepository, 'findWorkspaceUser') + .mockResolvedValueOnce(workspaceUser); + jest.spyOn(workspaceRepository, 'findById').mockResolvedValue(workspace); + jest + .spyOn(fileUseCases, 'checkWorkspaceEmptyFilesLimit') + .mockResolvedValue(undefined); + jest + .spyOn(workspaceRepository, 'getItemBy') + .mockResolvedValue(folderItem); + jest.spyOn(folderUseCases, 'getByUuid').mockResolvedValue({} as any); + jest.spyOn(fileUseCases, 'createFile').mockResolvedValue(createdFile); + jest + .spyOn(workspaceRepository, 'createItem') + .mockResolvedValue(createdItemFile); + + const result = await service.createFile(user, workspace.id, emptyFileDto); + + expect(fileUseCases.checkWorkspaceEmptyFilesLimit).toHaveBeenCalledWith( + workspaceUser.memberId, + workspace, + ); + expect(fileUseCases.createFile).toHaveBeenCalled(); + expect(result).toEqual({ ...createdFile, item: createdItemFile }); + }); + + it('When creating empty file in workspace and limit reached, then it should throw', async () => { + const user = newUser(); + const workspace = newWorkspace(); + const workspaceUser = newWorkspaceUser({ + attributes: { + spaceLimit: 10240, + driveUsage: 0, + }, + workspaceId: workspace.id, + member: user, + }); + const emptyFileDto = { + ...createFileDto, + size: BigInt(0), + }; + + jest + .spyOn(workspaceRepository, 'findWorkspaceUser') + .mockResolvedValueOnce(workspaceUser); + jest.spyOn(workspaceRepository, 'findById').mockResolvedValue(workspace); + jest + .spyOn(fileUseCases, 'checkWorkspaceEmptyFilesLimit') + .mockRejectedValue( + new BadRequestException( + 'You can not have more empty files in this workspace', + ), + ); + + await expect( + service.createFile(user, workspace.id, emptyFileDto), + ).rejects.toThrow(BadRequestException); + }); + + it('When creating non-empty file in workspace, then it should NOT call empty file check', async () => { + const user = newUser(); + const fileSize = 2000; + const workspace = newWorkspace(); + const workspaceUser = newWorkspaceUser({ + attributes: { + spaceLimit: fileSize + 1, + rootFolderId: createFileDto.folderUuid, + }, + workspaceId: workspace.id, + member: user, + }); + const folderItem = newWorkspaceItemUser({ createdBy: user.uuid }); + const createdFile = newFile({ owner: user }); + const createdItemFile = newWorkspaceItemUser({ + createdBy: user.uuid, + itemType: WorkspaceItemType.File, + }); + + jest + .spyOn(workspaceRepository, 'findWorkspaceUser') + .mockResolvedValueOnce(workspaceUser); + jest.spyOn(workspaceRepository, 'findById').mockResolvedValue(workspace); + jest + .spyOn(workspaceRepository, 'getItemBy') + .mockResolvedValue(folderItem); + jest.spyOn(folderUseCases, 'getByUuid').mockResolvedValue({} as any); + jest.spyOn(fileUseCases, 'createFile').mockResolvedValue(createdFile); + jest + .spyOn(workspaceRepository, 'createItem') + .mockResolvedValue(createdItemFile); + const checkWorkspaceEmptyFilesLimitSpy = jest.spyOn( + fileUseCases, + 'checkWorkspaceEmptyFilesLimit', + ); + + await service.createFile(user, workspace.id, { + ...createFileDto, + size: BigInt(fileSize), + }); + + expect(checkWorkspaceEmptyFilesLimitSpy).not.toHaveBeenCalled(); + }); }); describe('getPersonalWorkspaceFoldersInFolder', () => { diff --git a/src/modules/workspaces/workspaces.usecase.ts b/src/modules/workspaces/workspaces.usecase.ts index df902baed..30ef0daec 100644 --- a/src/modules/workspaces/workspaces.usecase.ts +++ b/src/modules/workspaces/workspaces.usecase.ts @@ -77,6 +77,7 @@ import { SharingAccessTokenData } from '../sharing/guards/sharings-token.interfa import { FuzzySearchUseCases } from '../fuzzy-search/fuzzy-search.usecase'; import { WorkspaceLog } from './domains/workspace-log.domain'; import { TrashItem } from './interceptors/workspaces-logs.interceptor'; +import { FeatureLimitService } from '../feature-limit/feature-limit.service'; @Injectable() export class WorkspacesUsecases { @@ -98,6 +99,7 @@ export class WorkspacesUsecases { private readonly folderUseCases: FolderUseCases, private readonly avatarService: AvatarService, private readonly fuzzySearchUseCases: FuzzySearchUseCases, + private readonly featureLimitsService: FeatureLimitService, ) {} async initiateWorkspace( @@ -879,6 +881,15 @@ export class WorkspacesUsecases { const workspace = await this.workspaceRepository.findById(workspaceId); + const isFileEmpty = BigInt(createFileDto.size) === BigInt(0); + + if (isFileEmpty) { + await this.fileUseCases.checkWorkspaceEmptyFilesLimit( + workspaceUser.memberId, + workspace, + ); + } + const parentFolder = await this.workspaceRepository.getItemBy({ workspaceId, itemId: createFileDto.folderUuid, @@ -908,6 +919,7 @@ export class WorkspacesUsecases { networkUser, { ...createFileDto, + fileId: isFileEmpty ? null : createFileDto.fileId, }, tier, ); From ad54b53160a33ccf57c42b50b6f619ff38379dbe Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Wed, 28 Jan 2026 19:14:28 -0600 Subject: [PATCH 69/71] fix: remove double empty file check, skipping flawed check on workspace network user empty file counts --- src/modules/file/file.usecase.spec.ts | 218 ++++++++++++++++++ src/modules/file/file.usecase.ts | 19 +- .../workspaces/workspaces.usecase.spec.ts | 93 ++++++-- src/modules/workspaces/workspaces.usecase.ts | 14 +- 4 files changed, 317 insertions(+), 27 deletions(-) diff --git a/src/modules/file/file.usecase.spec.ts b/src/modules/file/file.usecase.spec.ts index 365f454e7..bb618a759 100644 --- a/src/modules/file/file.usecase.spec.ts +++ b/src/modules/file/file.usecase.spec.ts @@ -1044,6 +1044,224 @@ describe('FileUseCases', () => { }); }); + describe('Empty files creation in workspace', () => { + it('When creating empty file with workspace options, then it should check workspace limit', async () => { + const folder = newFolder({ attributes: { userId: userMocked.id } }); + const workspaceOwner = newUser(); + const workspace = newWorkspace({ + attributes: { ownerId: workspaceOwner.uuid }, + }); + const emptyFileDto: CreateFileDto = { + ...newFileDto, + size: BigInt(0), + }; + const workspaceOptions = { + workspace, + memberId: userMocked.uuid, + }; + + const mockLimit = newFeatureLimit({ + label: LimitLabels.MaxZeroSizeFiles, + type: LimitTypes.Counter, + value: '1000', + }); + + jest.spyOn(folderUseCases, 'getByUuid').mockResolvedValueOnce(folder); + jest + .spyOn(fileRepository, 'findByPlainNameAndFolderId') + .mockResolvedValueOnce(null); + jest + .spyOn(userUsecases, 'findByUuid') + .mockResolvedValue(workspaceOwner); + jest + .spyOn(featureLimitService, 'getUserLimitByLabel') + .mockResolvedValue(mockLimit); + jest + .spyOn(fileRepository, 'getZeroSizeFilesCountInWorkspaceByMember') + .mockResolvedValue(5); + + const createdFile = newFile({ + attributes: { + ...emptyFileDto, + id: 1, + folderId: folder.id, + folderUuid: folder.uuid, + userId: userMocked.id, + uuid: v4(), + status: FileStatus.EXISTS, + }, + }); + + jest.spyOn(fileRepository, 'create').mockResolvedValueOnce(createdFile); + + const result = await service.createFile( + userMocked, + emptyFileDto, + undefined, + workspaceOptions, + ); + + expect(result).toEqual(createdFile); + expect( + fileRepository.getZeroSizeFilesCountInWorkspaceByMember, + ).toHaveBeenCalledWith(userMocked.uuid, workspace.id); + expect( + fileRepository.getZeroSizeFilesCountByUser, + ).not.toHaveBeenCalled(); + }); + + it('When creating empty file with workspace options and limit is reached, then it should throw', async () => { + const folder = newFolder({ attributes: { userId: userMocked.id } }); + const workspaceOwner = newUser(); + const workspace = newWorkspace({ + attributes: { ownerId: workspaceOwner.uuid }, + }); + const emptyFileDto: CreateFileDto = { + ...newFileDto, + size: BigInt(0), + }; + const workspaceOptions = { + workspace, + memberId: userMocked.uuid, + }; + + const mockLimit = newFeatureLimit({ + label: LimitLabels.MaxZeroSizeFiles, + type: LimitTypes.Counter, + value: '1000', + }); + + jest.spyOn(folderUseCases, 'getByUuid').mockResolvedValueOnce(folder); + jest + .spyOn(fileRepository, 'findByPlainNameAndFolderId') + .mockResolvedValueOnce(null); + jest + .spyOn(userUsecases, 'findByUuid') + .mockResolvedValue(workspaceOwner); + jest + .spyOn(featureLimitService, 'getUserLimitByLabel') + .mockResolvedValue(mockLimit); + jest + .spyOn(fileRepository, 'getZeroSizeFilesCountInWorkspaceByMember') + .mockResolvedValue(1000); + + await expect( + service.createFile( + userMocked, + emptyFileDto, + undefined, + workspaceOptions, + ), + ).rejects.toThrow(BadRequestException); + + expect( + fileRepository.getZeroSizeFilesCountInWorkspaceByMember, + ).toHaveBeenCalledWith(userMocked.uuid, workspace.id); + expect( + fileRepository.getZeroSizeFilesCountByUser, + ).not.toHaveBeenCalled(); + }); + + it('When creating empty file without workspace options, then it should check individual limit', async () => { + const folder = newFolder({ attributes: { userId: userMocked.id } }); + const emptyFileDto: CreateFileDto = { + ...newFileDto, + size: BigInt(0), + }; + + const mockLimit = newFeatureLimit({ + label: LimitLabels.MaxZeroSizeFiles, + type: LimitTypes.Counter, + value: '1000', + }); + + jest.spyOn(folderUseCases, 'getByUuid').mockResolvedValueOnce(folder); + jest + .spyOn(fileRepository, 'findByPlainNameAndFolderId') + .mockResolvedValueOnce(null); + jest + .spyOn(featureLimitService, 'getUserLimitByLabel') + .mockResolvedValue(mockLimit); + jest + .spyOn(fileRepository, 'getZeroSizeFilesCountByUser') + .mockResolvedValue(5); + + const createdFile = newFile({ + attributes: { + ...emptyFileDto, + id: 1, + folderId: folder.id, + folderUuid: folder.uuid, + userId: userMocked.id, + uuid: v4(), + status: FileStatus.EXISTS, + }, + }); + + jest.spyOn(fileRepository, 'create').mockResolvedValueOnce(createdFile); + + const result = await service.createFile(userMocked, emptyFileDto); + + expect(result).toEqual(createdFile); + expect(featureLimitService.getUserLimitByLabel).toHaveBeenCalledWith( + LimitLabels.MaxZeroSizeFiles, + userMocked, + ); + expect(fileRepository.getZeroSizeFilesCountByUser).toHaveBeenCalledWith( + userMocked.id, + ); + expect( + fileRepository.getZeroSizeFilesCountInWorkspaceByMember, + ).not.toHaveBeenCalled(); + }); + + it('When creating non-empty file with workspace options, then it should not check limits', async () => { + const folder = newFolder({ attributes: { userId: userMocked.id } }); + const workspace = newWorkspace(); + const fileDto: CreateFileDto = { + ...newFileDto, + size: BigInt(1024), + }; + const workspaceOptions = { + workspace, + memberId: userMocked.uuid, + }; + + jest.spyOn(folderUseCases, 'getByUuid').mockResolvedValueOnce(folder); + jest + .spyOn(fileRepository, 'findByPlainNameAndFolderId') + .mockResolvedValueOnce(null); + + const createdFile = newFile({ + attributes: { + ...fileDto, + id: 1, + folderId: folder.id, + folderUuid: folder.uuid, + userId: userMocked.id, + uuid: v4(), + status: FileStatus.EXISTS, + }, + }); + + jest.spyOn(fileRepository, 'create').mockResolvedValueOnce(createdFile); + + await service.createFile( + userMocked, + fileDto, + undefined, + workspaceOptions, + ); + + expect( + fileRepository.getZeroSizeFilesCountInWorkspaceByMember, + ).not.toHaveBeenCalled(); + expect( + fileRepository.getZeroSizeFilesCountByUser, + ).not.toHaveBeenCalled(); + }); + }); + describe('first upload email functionality', () => { it('When user has no previous files, then should send first upload email', async () => { const folder = newFolder({ attributes: { userId: userMocked.id } }); diff --git a/src/modules/file/file.usecase.ts b/src/modules/file/file.usecase.ts index 60d02c790..11206d300 100644 --- a/src/modules/file/file.usecase.ts +++ b/src/modules/file/file.usecase.ts @@ -334,7 +334,15 @@ export class FileUseCases { return this.fileRepository.findByUuids(uuids); } - async createFile(user: User, newFileDto: CreateFileDto, tier?) { + async createFile( + user: User, + newFileDto: CreateFileDto, + tier?, + workspaceOptions?: { + workspace: Workspace; + memberId: string; + }, + ) { const [hadFilesBeforeUpload, folder] = await Promise.all([ this.hasUploadedFiles(user), this.folderUsecases.getByUuid(newFileDto.folderUuid), @@ -369,7 +377,14 @@ export class FileUseCases { const isFileEmpty = BigInt(newFileDto.size) === BigInt(0); if (isFileEmpty) { - await this.checkEmptyFilesLimit(user); + if (workspaceOptions) { + await this.checkWorkspaceEmptyFilesLimit( + workspaceOptions.memberId, + workspaceOptions.workspace, + ); + } else { + await this.checkEmptyFilesLimit(user); + } } const newFileId = isFileEmpty ? null : newFileDto.fileId; diff --git a/src/modules/workspaces/workspaces.usecase.spec.ts b/src/modules/workspaces/workspaces.usecase.spec.ts index 65e8a07c4..612b32da1 100644 --- a/src/modules/workspaces/workspaces.usecase.spec.ts +++ b/src/modules/workspaces/workspaces.usecase.spec.ts @@ -2716,9 +2716,6 @@ describe('WorkspacesUsecases', () => { .spyOn(workspaceRepository, 'findWorkspaceUser') .mockResolvedValueOnce(workspaceUser); jest.spyOn(workspaceRepository, 'findById').mockResolvedValue(workspace); - jest - .spyOn(fileUseCases, 'checkWorkspaceEmptyFilesLimit') - .mockResolvedValue(undefined); jest .spyOn(workspaceRepository, 'getItemBy') .mockResolvedValue(folderItem); @@ -2730,11 +2727,15 @@ describe('WorkspacesUsecases', () => { const result = await service.createFile(user, workspace.id, emptyFileDto); - expect(fileUseCases.checkWorkspaceEmptyFilesLimit).toHaveBeenCalledWith( - workspaceUser.memberId, - workspace, + expect(fileUseCases.createFile).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ size: BigInt(0) }), + undefined, + { + workspace, + memberId: workspaceUser.memberId, + }, ); - expect(fileUseCases.createFile).toHaveBeenCalled(); expect(result).toEqual({ ...createdFile, item: createdItemFile }); }); @@ -2749,6 +2750,7 @@ describe('WorkspacesUsecases', () => { workspaceId: workspace.id, member: user, }); + const folderItem = newWorkspaceItemUser({ createdBy: user.uuid }); const emptyFileDto = { ...createFileDto, size: BigInt(0), @@ -2759,7 +2761,11 @@ describe('WorkspacesUsecases', () => { .mockResolvedValueOnce(workspaceUser); jest.spyOn(workspaceRepository, 'findById').mockResolvedValue(workspace); jest - .spyOn(fileUseCases, 'checkWorkspaceEmptyFilesLimit') + .spyOn(workspaceRepository, 'getItemBy') + .mockResolvedValue(folderItem); + jest.spyOn(folderUseCases, 'getByUuid').mockResolvedValue({} as any); + jest + .spyOn(fileUseCases, 'createFile') .mockRejectedValue( new BadRequestException( 'You can not have more empty files in this workspace', @@ -2771,7 +2777,58 @@ describe('WorkspacesUsecases', () => { ).rejects.toThrow(BadRequestException); }); - it('When creating non-empty file in workspace, then it should NOT call empty file check', async () => { + it('When creating empty file in workspace, then it should pass workspace options to fileUseCases.createFile', async () => { + const user = newUser(); + const fileSize = 0; + const workspace = newWorkspace(); + const workspaceUser = newWorkspaceUser({ + attributes: { + spaceLimit: 10240, + driveUsage: 0, + rootFolderId: createFileDto.folderUuid, + }, + workspaceId: workspace.id, + member: user, + }); + const folderItem = newWorkspaceItemUser({ createdBy: user.uuid }); + const createdFile = newFile({ owner: user }); + const createdItemFile = newWorkspaceItemUser({ + createdBy: user.uuid, + itemType: WorkspaceItemType.File, + }); + + jest + .spyOn(workspaceRepository, 'findWorkspaceUser') + .mockResolvedValueOnce(workspaceUser); + jest.spyOn(workspaceRepository, 'findById').mockResolvedValue(workspace); + jest + .spyOn(workspaceRepository, 'getItemBy') + .mockResolvedValue(folderItem); + jest.spyOn(folderUseCases, 'getByUuid').mockResolvedValue({} as any); + const createFileSpy = jest + .spyOn(fileUseCases, 'createFile') + .mockResolvedValue(createdFile); + jest + .spyOn(workspaceRepository, 'createItem') + .mockResolvedValue(createdItemFile); + + await service.createFile(user, workspace.id, { + ...createFileDto, + size: BigInt(fileSize), + }); + + expect(createFileSpy).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ size: BigInt(fileSize) }), + undefined, + { + workspace, + memberId: workspaceUser.memberId, + }, + ); + }); + + it('When creating non-empty file in workspace, then it should pass workspace options to fileUseCases.createFile', async () => { const user = newUser(); const fileSize = 2000; const workspace = newWorkspace(); @@ -2798,21 +2855,27 @@ describe('WorkspacesUsecases', () => { .spyOn(workspaceRepository, 'getItemBy') .mockResolvedValue(folderItem); jest.spyOn(folderUseCases, 'getByUuid').mockResolvedValue({} as any); - jest.spyOn(fileUseCases, 'createFile').mockResolvedValue(createdFile); + const createFileSpy = jest + .spyOn(fileUseCases, 'createFile') + .mockResolvedValue(createdFile); jest .spyOn(workspaceRepository, 'createItem') .mockResolvedValue(createdItemFile); - const checkWorkspaceEmptyFilesLimitSpy = jest.spyOn( - fileUseCases, - 'checkWorkspaceEmptyFilesLimit', - ); await service.createFile(user, workspace.id, { ...createFileDto, size: BigInt(fileSize), }); - expect(checkWorkspaceEmptyFilesLimitSpy).not.toHaveBeenCalled(); + expect(createFileSpy).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ size: BigInt(fileSize) }), + undefined, + { + workspace, + memberId: workspaceUser.memberId, + }, + ); }); }); diff --git a/src/modules/workspaces/workspaces.usecase.ts b/src/modules/workspaces/workspaces.usecase.ts index 30ef0daec..6120decb1 100644 --- a/src/modules/workspaces/workspaces.usecase.ts +++ b/src/modules/workspaces/workspaces.usecase.ts @@ -881,15 +881,6 @@ export class WorkspacesUsecases { const workspace = await this.workspaceRepository.findById(workspaceId); - const isFileEmpty = BigInt(createFileDto.size) === BigInt(0); - - if (isFileEmpty) { - await this.fileUseCases.checkWorkspaceEmptyFilesLimit( - workspaceUser.memberId, - workspace, - ); - } - const parentFolder = await this.workspaceRepository.getItemBy({ workspaceId, itemId: createFileDto.folderUuid, @@ -919,9 +910,12 @@ export class WorkspacesUsecases { networkUser, { ...createFileDto, - fileId: isFileEmpty ? null : createFileDto.fileId, }, tier, + { + workspace, + memberId: workspaceUser.memberId, + }, ); const createdItemFile = await this.workspaceRepository.createItem({ From c1c0798de67b7ffac8b62dab8bf3198fdbe5f073 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Wed, 28 Jan 2026 21:07:04 -0600 Subject: [PATCH 70/71] fix: update workspace empty file check to use owner ID --- src/modules/file/file.usecase.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/modules/file/file.usecase.ts b/src/modules/file/file.usecase.ts index 11206d300..51503d847 100644 --- a/src/modules/file/file.usecase.ts +++ b/src/modules/file/file.usecase.ts @@ -965,14 +965,14 @@ export class FileUseCases { } async checkWorkspaceEmptyFilesLimit(memberId: string, workspace: Workspace) { - const workspaceNetworkUser = await this.userUsecases.findByUuid( - workspace.workspaceUserId, + const workspaceOwner = await this.userUsecases.findByUuid( + workspace.ownerId, ); const [maxZeroSizeFilesLimit, zeroSizeFilesCount] = await Promise.all([ this.featureLimitService.getUserLimitByLabel( LimitLabels.MaxZeroSizeFiles, - workspaceNetworkUser, + workspaceOwner, ), this.fileRepository.getZeroSizeFilesCountInWorkspaceByMember( memberId, From fe03a532e5c9183715982624e21153ee54082b76 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Thu, 29 Jan 2026 14:38:44 -0600 Subject: [PATCH 71/71] fix: update tests to use workspace owner ID for empty file limit checks --- src/modules/file/file.usecase.spec.ts | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/modules/file/file.usecase.spec.ts b/src/modules/file/file.usecase.spec.ts index bb618a759..9f2471823 100644 --- a/src/modules/file/file.usecase.spec.ts +++ b/src/modules/file/file.usecase.spec.ts @@ -1472,10 +1472,10 @@ describe('FileUseCases', () => { describe('checkWorkspaceEmptyFilesLimit', () => { it('When workspace owner limit is enforced, then it should throw', async () => { - const workspaceNetworkUser = newUser(); + const workspaceOwner = newUser(); const member = newUser(); const workspace = newWorkspace({ - attributes: { workspaceUserId: workspaceNetworkUser.uuid }, + attributes: { ownerId: workspaceOwner.uuid }, }); const mockLimit = newFeatureLimit({ value: '10', @@ -1483,9 +1483,7 @@ describe('FileUseCases', () => { type: LimitTypes.Counter, }); - jest - .spyOn(userUsecases, 'findByUuid') - .mockResolvedValue(workspaceNetworkUser); + jest.spyOn(userUsecases, 'findByUuid').mockResolvedValue(workspaceOwner); jest .spyOn(featureLimitService, 'getUserLimitByLabel') .mockResolvedValue(mockLimit); @@ -1499,10 +1497,10 @@ describe('FileUseCases', () => { }); it('When workspace owner limit is NOT enforced, then it should not throw', async () => { - const workspaceNetworkUser = newUser(); + const workspaceOwner = newUser(); const member = newUser(); const workspace = newWorkspace({ - attributes: { ownerId: workspaceNetworkUser.uuid }, + attributes: { ownerId: workspaceOwner.uuid }, }); const mockLimit = newFeatureLimit({ value: '1000', @@ -1510,9 +1508,7 @@ describe('FileUseCases', () => { type: LimitTypes.Counter, }); - jest - .spyOn(userUsecases, 'findByUuid') - .mockResolvedValue(workspaceNetworkUser); + jest.spyOn(userUsecases, 'findByUuid').mockResolvedValue(workspaceOwner); jest .spyOn(featureLimitService, 'getUserLimitByLabel') .mockResolvedValue(mockLimit); @@ -1524,12 +1520,10 @@ describe('FileUseCases', () => { service.checkWorkspaceEmptyFilesLimit(member.uuid, workspace), ).resolves.not.toThrow(); - expect(userUsecases.findByUuid).toHaveBeenCalledWith( - workspace.workspaceUserId, - ); + expect(userUsecases.findByUuid).toHaveBeenCalledWith(workspace.ownerId); expect(featureLimitService.getUserLimitByLabel).toHaveBeenCalledWith( LimitLabels.MaxZeroSizeFiles, - workspaceNetworkUser, + workspaceOwner, ); expect( fileRepository.getZeroSizeFilesCountInWorkspaceByMember,