diff --git a/apps/server/src/infra/rabbitmq/exchange/files-storage.ts b/apps/server/src/infra/rabbitmq/exchange/files-storage.ts index 86d2f710a57..0ef4fd76768 100644 --- a/apps/server/src/infra/rabbitmq/exchange/files-storage.ts +++ b/apps/server/src/infra/rabbitmq/exchange/files-storage.ts @@ -51,7 +51,7 @@ export interface FileDO { parentId: string; securityCheckStatus: ScanStatus; size: number; - creatorId: string; + creatorId?: string; mimeType: string; parentType: FileRecordParentType; deletedSince?: Date; diff --git a/apps/server/src/modules/files-storage/controller/dto/file-storage.response.ts b/apps/server/src/modules/files-storage/controller/dto/file-storage.response.ts index 439bddeef73..dc595d62d9a 100644 --- a/apps/server/src/modules/files-storage/controller/dto/file-storage.response.ts +++ b/apps/server/src/modules/files-storage/controller/dto/file-storage.response.ts @@ -38,7 +38,7 @@ export class FileRecordResponse { size: number; @ApiProperty() - creatorId: string; + creatorId?: string; @ApiProperty() mimeType: string; diff --git a/apps/server/src/modules/files-storage/entity/filerecord.entity.spec.ts b/apps/server/src/modules/files-storage/entity/filerecord.entity.spec.ts index 7be042c0640..13c17ddcad8 100644 --- a/apps/server/src/modules/files-storage/entity/filerecord.entity.spec.ts +++ b/apps/server/src/modules/files-storage/entity/filerecord.entity.spec.ts @@ -833,4 +833,23 @@ describe('FileRecord Entity', () => { }); }); }); + + describe('removeCreatorId is called', () => { + describe('WHEN creatorId exists', () => { + const setup = () => { + const creatorId = new ObjectId().toHexString(); + const fileRecord = fileRecordFactory.build({ creatorId }); + + return { fileRecord, creatorId }; + }; + + it('should set it to undefined', () => { + const { fileRecord } = setup(); + + const result = fileRecord.removeCreatorId(); + + expect(result).toBe(undefined); + }); + }); + }); }); diff --git a/apps/server/src/modules/files-storage/entity/filerecord.entity.ts b/apps/server/src/modules/files-storage/entity/filerecord.entity.ts index e0957b355bb..442fe1fa444 100644 --- a/apps/server/src/modules/files-storage/entity/filerecord.entity.ts +++ b/apps/server/src/modules/files-storage/entity/filerecord.entity.ts @@ -75,7 +75,7 @@ export interface FileRecordProperties { mimeType: string; parentType: FileRecordParentType; parentId: EntityId; - creatorId: EntityId; + creatorId?: EntityId; schoolId: EntityId; deletedSince?: Date; isCopyFrom?: EntityId; @@ -127,11 +127,15 @@ export class FileRecord extends BaseEntityWithTimestamps { return this._parentId.toHexString(); } - @Property({ fieldName: 'creator' }) - _creatorId: ObjectId; + @Property({ fieldName: 'creator', nullable: true }) + _creatorId?: ObjectId; - get creatorId(): EntityId { - return this._creatorId.toHexString(); + get creatorId(): EntityId | undefined { + return this._creatorId?.toHexString(); + } + + set creatorId(userId: EntityId | undefined) { + this._creatorId = userId !== undefined ? new ObjectId(userId) : undefined; } @Property({ fieldName: 'school' }) @@ -157,7 +161,9 @@ export class FileRecord extends BaseEntityWithTimestamps { this.mimeType = props.mimeType; this.parentType = props.parentType; this._parentId = new ObjectId(props.parentId); - this._creatorId = new ObjectId(props.creatorId); + if (props.creatorId !== undefined) { + this._creatorId = new ObjectId(props.creatorId); + } this._schoolId = new ObjectId(props.schoolId); if (props.isCopyFrom) { this._isCopyFrom = new ObjectId(props.isCopyFrom); @@ -300,4 +306,8 @@ export class FileRecord extends BaseEntityWithTimestamps { return filenameObj.name; } + + public removeCreatorId(): void { + this.creatorId = undefined; + } } diff --git a/apps/server/src/modules/files-storage/repo/filerecord-scope.ts b/apps/server/src/modules/files-storage/repo/filerecord-scope.ts index e335e99a300..aa655fc807d 100644 --- a/apps/server/src/modules/files-storage/repo/filerecord-scope.ts +++ b/apps/server/src/modules/files-storage/repo/filerecord-scope.ts @@ -34,4 +34,10 @@ export class FileRecordScope extends Scope { return this; } + + byCreatorId(creatorId: EntityId): FileRecordScope { + this.addQuery({ _creatorId: new ObjectId(creatorId) }); + + return this; + } } diff --git a/apps/server/src/modules/files-storage/repo/filerecord.repo.integration.spec.ts b/apps/server/src/modules/files-storage/repo/filerecord.repo.integration.spec.ts index d1dbe490c25..7fa1853bc05 100644 --- a/apps/server/src/modules/files-storage/repo/filerecord.repo.integration.spec.ts +++ b/apps/server/src/modules/files-storage/repo/filerecord.repo.integration.spec.ts @@ -372,4 +372,32 @@ describe('FileRecordRepo', () => { await expect(repo.findBySecurityCheckRequestToken(token)).rejects.toThrow(); }); }); + + describe('findByCreatorId', () => { + const setup = () => { + const creator1 = new ObjectId().toHexString(); + const creator2 = new ObjectId().toHexString(); + const fileRecords1 = fileRecordFactory.buildList(4, { + creatorId: creator1, + }); + const fileRecords2 = fileRecordFactory.buildList(3, { + creatorId: creator2, + }); + + return { fileRecords1, fileRecords2, creator1 }; + }; + + it('should only find searched creator', async () => { + const { fileRecords1, fileRecords2, creator1 } = setup(); + + await em.persistAndFlush([...fileRecords1, ...fileRecords2]); + em.clear(); + + const [results, count] = await repo.findByCreatorId(creator1); + + expect(count).toEqual(4); + expect(results).toHaveLength(4); + expect(results.map((o) => o.creatorId)).toEqual([creator1, creator1, creator1, creator1]); + }); + }); }); diff --git a/apps/server/src/modules/files-storage/repo/filerecord.repo.ts b/apps/server/src/modules/files-storage/repo/filerecord.repo.ts index 2b87fe61707..e5c001dee06 100644 --- a/apps/server/src/modules/files-storage/repo/filerecord.repo.ts +++ b/apps/server/src/modules/files-storage/repo/filerecord.repo.ts @@ -62,6 +62,13 @@ export class FileRecordRepo extends BaseRepo { return fileRecord; } + async findByCreatorId(creatorId: EntityId): Promise> { + const scope = new FileRecordScope().byCreatorId(creatorId); + const result = await this.findAndCount(scope); + + return result; + } + private async findAndCount( scope: FileRecordScope, options?: IFindOptions diff --git a/apps/server/src/modules/files-storage/service/files-storage-remove-creator.service.spec.ts b/apps/server/src/modules/files-storage/service/files-storage-remove-creator.service.spec.ts new file mode 100644 index 00000000000..55c0b48e4cf --- /dev/null +++ b/apps/server/src/modules/files-storage/service/files-storage-remove-creator.service.spec.ts @@ -0,0 +1,156 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AntivirusService } from '@infra/antivirus'; +import { S3ClientAdapter } from '@infra/s3-client'; +import { fileRecordFactory, setupEntities } from '@shared/testing'; +import { LegacyLogger } from '@src/core/logger'; +import { FileRecordParams } from '../controller/dto'; +import { FileRecord, FileRecordParentType } from '../entity'; +import { FILES_STORAGE_S3_CONNECTION } from '../files-storage.config'; +import { FileRecordRepo } from '../repo'; +import { FilesStorageService } from './files-storage.service'; + +const buildFileRecordsWithParams = () => { + const parentId = new ObjectId().toHexString(); + const parentSchoolId = new ObjectId().toHexString(); + const creatorId = new ObjectId().toHexString(); + + const fileRecords = [ + fileRecordFactory.buildWithId({ parentId, schoolId: parentSchoolId, name: 'text.txt', creatorId }), + fileRecordFactory.buildWithId({ parentId, schoolId: parentSchoolId, name: 'text-two.txt', creatorId }), + fileRecordFactory.buildWithId({ parentId, schoolId: parentSchoolId, name: 'text-tree.txt', creatorId }), + ]; + + const params: FileRecordParams = { + schoolId: parentSchoolId, + parentId, + parentType: FileRecordParentType.User, + }; + + return { params, fileRecords, parentId, creatorId }; +}; + +describe('FilesStorageService delete methods', () => { + let module: TestingModule; + let service: FilesStorageService; + let fileRecordRepo: DeepMocked; + let storageClient: DeepMocked; + + beforeAll(async () => { + await setupEntities([FileRecord]); + + module = await Test.createTestingModule({ + providers: [ + FilesStorageService, + { + provide: FILES_STORAGE_S3_CONNECTION, + useValue: createMock(), + }, + { + provide: FileRecordRepo, + useValue: createMock(), + }, + { + provide: LegacyLogger, + useValue: createMock(), + }, + { + provide: AntivirusService, + useValue: createMock(), + }, + { + provide: ConfigService, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(FilesStorageService); + storageClient = module.get(FILES_STORAGE_S3_CONNECTION); + fileRecordRepo = module.get(FileRecordRepo); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + it('service should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('removeCreatorIdFromFileRecord is called', () => { + describe('WHEN valid files does not exist', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + fileRecordRepo.findByCreatorId.mockResolvedValueOnce([[], 0]); + + return { userId }; + }; + + it('should not modify any filescall repo save with undefined creatorId', async () => { + const { userId } = setup(); + + const result = await service.removeCreatorIdFromFileRecord(userId); + + expect(result).toEqual(0); + + expect(fileRecordRepo.findByCreatorId).toBeCalledWith(userId); + expect(fileRecordRepo.save).not.toBeCalled(); + }); + }); + + describe('WHEN valid files exists', () => { + const setup = () => { + const { fileRecords, creatorId } = buildFileRecordsWithParams(); + + fileRecordRepo.findByCreatorId.mockResolvedValueOnce([fileRecords, fileRecords.length]); + + return { fileRecords, creatorId }; + }; + + it('should call repo save with undefined creatorId', async () => { + const { fileRecords, creatorId } = setup(); + + await service.removeCreatorIdFromFileRecord(creatorId); + + expect(fileRecordRepo.save).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ ...fileRecords[0], _creatorId: undefined }), + expect.objectContaining({ ...fileRecords[1], _creatorId: undefined }), + expect.objectContaining({ ...fileRecords[2], _creatorId: undefined }), + ]) + ); + }); + + it('should getnumber of updated fileRecords', async () => { + const { creatorId } = setup(); + + const result = await service.removeCreatorIdFromFileRecord(creatorId); + + expect(result).toEqual(3); + }); + }); + + describe('WHEN repository throw an error', () => { + const setup = () => { + const { fileRecords } = buildFileRecordsWithParams(); + + fileRecordRepo.save.mockRejectedValueOnce(new Error('bla')); + + return { fileRecords }; + }; + + it('should pass the error', async () => { + const { fileRecords } = setup(); + + await expect(service.delete(fileRecords)).rejects.toThrow(new Error('bla')); + }); + }); + }); +}); diff --git a/apps/server/src/modules/files-storage/service/files-storage.service.ts b/apps/server/src/modules/files-storage/service/files-storage.service.ts index c3c186d3db9..a2db42f68b9 100644 --- a/apps/server/src/modules/files-storage/service/files-storage.service.ts +++ b/apps/server/src/modules/files-storage/service/files-storage.service.ts @@ -311,6 +311,20 @@ export class FilesStorageService { } } + public async removeCreatorIdFromFileRecord(userId: EntityId): Promise { + const [fileRecords] = await this.fileRecordRepo.findByCreatorId(userId); + + if (fileRecords.length === 0) { + return 0; + } + + fileRecords.forEach((entity) => entity.removeCreatorId()); + + await this.fileRecordRepo.save(fileRecords); + + return fileRecords.length; + } + // restore private async restoreFilesInFileStorageClient(fileRecords: FileRecord[]) { const paths = getPaths(fileRecords);