From 560418efcc6e51c2a08e016c0e394153811c84ff Mon Sep 17 00:00:00 2001 From: Bartosz Nowicki <116367402+bn-pass@users.noreply.github.com> Date: Tue, 19 Sep 2023 19:05:23 +0200 Subject: [PATCH] BC-4573-rewrite-files-user-data-deletion (#4303) * move files repo to a dedicated files module * add barrel file for the files deletion console job * remove unused factory function * add ownerId and refOwnerModel (required) fields to the file model in new (NestJS-based) server part * add more File model fields from the legacy model * add parent field in the new Field model * reorder some fields in the new file model * remove some unused imports, rename file properties interface to match current code style guide * add share tokens to the file props * reorder some fields * add lockId field to the new File entity * add embedded security check object * modify some File entity fields to not trigger unnecessary entities loading on the ORM * add model for the files permission * add method for finding all the files with given permissionRefId * add method to find all files by owner userId, refactor findByPermissionRefId to properly return entities mapped from the raw documents * add File entity method that removes permissions with given refId from the entity object * add file entity method that marks file for deletion * add file entity method that verify if file is already marked for deletion * add test cases for saving the file entity after removing some file permissions or marking the file as deleted * rename File entity class to FileEntity to make it compliant with the current convention * rename storage provider entity class to StorgeProviderEntity * rename file security check entity class * rename some classes * add complete domain layer for the files module * replace use of the files-related enums located in the shared entities module before with the new enums defined in the files module domain layer * add getProps method for all the new domain objects of the files module * add mappers for all the domain objects * rename entity key * remove unused user file factory reference * remove unnecessary use of the user file factory as the FileEntity is not really needed there * remove unnecessary use of the user file factory * reorganize some code, move the user file factory to the files repo as its only used there now * remove file factory export as it was moved inside of the files module * rename file record security check to avoid naming collision, move the file entities to the files module * add required injections for the FileEntity (as it was moved from the ALL_ENTITIES), split the files-related entities to a separate files * add complete unit tests for the files-related entities * modify one comment * fix imports order * modify a few imports * move user file factory to a separate submodule * add files service with the markFilesOwnedByUserForDeletion() method * add more unit tests for the markFilesOwnedByUserForDeletion() method * fix one import * replace filter with forEach * add method to remove user permissions to any files with just a single test case as for now * rename filesRepo var to just repo * add more unit tests for the removeUserPermissionsToAnyFiles() method * add files use case with the user data deletion method * modify method to copy file entity in unit tests to make it more generic * replace explicit file entity constructor calls with the file factory calls (wherever possible) * rollback not currently used code to not make the PR too big * replace plain string class name with the callback type * add unit test for creating directory with the complete props object * rollback adding new UC for the files module as it is not yet used and it'll be added in the other ticket once all the authorization-level requirements will be defined * fix some imports * move all consts definition to the top of the test suite * remove all the empty lines in the files module files imports * rename userFileFactory to fileEntityFactory * replace explicit file entity constructor calls with the factory calls * fix imports * change name of the file that contains the FileEntity factory * replace all the FilesPermissionEntity constructor calls with the new factory calls * fix DeleteFilesUC unit tests * change the way of describing unit tests for the file-security-check.entity.spec.ts file * add local factory for the legacy file mock * move the legacy file entity mock factory to the @shared/testing module as it's also needed in the task copy service unit tests * change DeleteFilesUC to DeleteFilesUc to keep it consistent with the majority of the modules in the project * remove some unnecessary setup function calls * move consts inside of the setup function, add some internal setup functions (rest of the "global" setup function calls will also be moved into the specific "describe" blocks) * move setup functions inside of the respective describe blocks * reformat some of the unit tests * modify the way the other files in the files.repo.spec.ts are described and organized * reformat file-permission.entity.spec.ts file unit tests * reformat file.entity.spec.ts file unit tests * remove unused import * fix exports from the shared testing factory submodule index.ts file * remove redeclared var * remove unused var --- apps/server/src/console/console.module.ts | 3 +- .../service/copy-files.service.spec.ts | 18 +- .../entity/filerecord.entity.spec.ts | 10 +- .../files-storage/entity/filerecord.entity.ts | 14 +- .../files-storage/files-storage.module.ts | 4 +- apps/server/src/modules/files/domain/index.ts | 1 + .../domain/types/file-owner-model.enum.ts | 5 + .../file-permission-reference-model.enum.ts | 4 + .../types/file-security-check-status.enum.ts | 6 + .../src/modules/files/domain/types/index.ts | 3 + .../entity/file-permission.entity.spec.ts | 62 ++ .../files/entity/file-permission.entity.ts | 55 ++ .../entity/file-security-check.entity.spec.ts | 61 ++ .../entity/file-security-check.entity.ts | 41 ++ .../modules/files/entity/file.entity.spec.ts | 293 ++++++++++ .../src/modules/files/entity/file.entity.ts | 203 +++++++ apps/server/src/modules/files/entity/index.ts | 3 + .../testing/factory/file-entity.factory.ts | 24 + .../factory/file-permission-entity.factory.ts | 14 + .../files/entity/testing/factory/index.ts | 2 + .../src/modules/files/entity/testing/index.ts | 1 + apps/server/src/modules/files/files.module.ts | 6 +- .../files/job/delete-files.console.spec.ts | 3 +- .../modules/files/job/delete-files.console.ts | 2 +- apps/server/src/modules/files/job/index.ts | 1 + .../src/modules/files/repo/files.repo.spec.ts | 548 ++++++++++++++++++ .../src/modules/files/repo/files.repo.ts | 65 +++ .../files => modules/files/repo}/index.ts | 0 .../files/service/files.service.spec.ts | 171 ++++++ .../modules/files/service/files.service.ts | 36 ++ .../server/src/modules/files/service/index.ts | 1 + .../modules/files/uc/delete-files.uc.spec.ts | 49 +- .../src/modules/files/uc/delete-files.uc.ts | 19 +- .../uc/database-management.uc.spec.ts | 4 +- .../management/uc/database-management.uc.ts | 6 +- .../task/service/task-copy.service.spec.ts | 6 +- .../src/shared/domain/entity/all-entities.ts | 6 +- .../shared/domain/entity/file.entity.spec.ts | 34 -- .../src/shared/domain/entity/file.entity.ts | 62 -- apps/server/src/shared/domain/entity/index.ts | 1 - .../domain/entity/storageprovider.entity.ts | 2 +- .../domain/entity/submission.entity.spec.ts | 4 +- .../src/shared/repo/files/files.repo.spec.ts | 82 --- .../src/shared/repo/files/files.repo.ts | 23 - apps/server/src/shared/repo/index.ts | 1 - .../src/shared/repo/storageprovider/index.ts | 1 + .../storageprovider.repo.spec.ts | 4 +- .../storageprovider/storageprovider.repo.ts | 10 +- .../shared/testing/factory/file.factory.ts | 23 - .../testing/factory/filerecord.factory.ts | 8 +- .../src/shared/testing/factory/index.ts | 2 +- .../legacy-file-entity-mock.factory.ts | 9 + .../factory/storageprovider.factory.ts | 6 +- 53 files changed, 1702 insertions(+), 320 deletions(-) create mode 100644 apps/server/src/modules/files/domain/index.ts create mode 100644 apps/server/src/modules/files/domain/types/file-owner-model.enum.ts create mode 100644 apps/server/src/modules/files/domain/types/file-permission-reference-model.enum.ts create mode 100644 apps/server/src/modules/files/domain/types/file-security-check-status.enum.ts create mode 100644 apps/server/src/modules/files/domain/types/index.ts create mode 100644 apps/server/src/modules/files/entity/file-permission.entity.spec.ts create mode 100644 apps/server/src/modules/files/entity/file-permission.entity.ts create mode 100644 apps/server/src/modules/files/entity/file-security-check.entity.spec.ts create mode 100644 apps/server/src/modules/files/entity/file-security-check.entity.ts create mode 100644 apps/server/src/modules/files/entity/file.entity.spec.ts create mode 100644 apps/server/src/modules/files/entity/file.entity.ts create mode 100644 apps/server/src/modules/files/entity/index.ts create mode 100644 apps/server/src/modules/files/entity/testing/factory/file-entity.factory.ts create mode 100644 apps/server/src/modules/files/entity/testing/factory/file-permission-entity.factory.ts create mode 100644 apps/server/src/modules/files/entity/testing/factory/index.ts create mode 100644 apps/server/src/modules/files/entity/testing/index.ts create mode 100644 apps/server/src/modules/files/job/index.ts create mode 100644 apps/server/src/modules/files/repo/files.repo.spec.ts create mode 100644 apps/server/src/modules/files/repo/files.repo.ts rename apps/server/src/{shared/repo/files => modules/files/repo}/index.ts (100%) create mode 100644 apps/server/src/modules/files/service/files.service.spec.ts create mode 100644 apps/server/src/modules/files/service/files.service.ts create mode 100644 apps/server/src/modules/files/service/index.ts delete mode 100644 apps/server/src/shared/domain/entity/file.entity.spec.ts delete mode 100644 apps/server/src/shared/domain/entity/file.entity.ts delete mode 100644 apps/server/src/shared/repo/files/files.repo.spec.ts delete mode 100644 apps/server/src/shared/repo/files/files.repo.ts create mode 100644 apps/server/src/shared/repo/storageprovider/index.ts delete mode 100644 apps/server/src/shared/testing/factory/file.factory.ts create mode 100644 apps/server/src/shared/testing/factory/legacy-file-entity-mock.factory.ts diff --git a/apps/server/src/console/console.module.ts b/apps/server/src/console/console.module.ts index 547f3f4308b..2aed716e719 100644 --- a/apps/server/src/console/console.module.ts +++ b/apps/server/src/console/console.module.ts @@ -8,6 +8,7 @@ import { ConsoleWriterModule } from '@shared/infra/console/console-writer/consol import { KeycloakModule } from '@shared/infra/identity-management/keycloak/keycloak.module'; import { DB_PASSWORD, DB_URL, DB_USERNAME, createConfigModuleOptions } from '@src/config'; import { FilesModule } from '@src/modules/files'; +import { FileEntity } from '@src/modules/files/entity'; import { FileRecord } from '@src/modules/files-storage/entity'; import { ManagementModule } from '@src/modules/management/management.module'; import { serverConfig } from '@src/modules/server'; @@ -28,7 +29,7 @@ import { ServerConsole } from './server.console'; clientUrl: DB_URL, password: DB_PASSWORD, user: DB_USERNAME, - entities: [...ALL_ENTITIES, FileRecord], + entities: [...ALL_ENTITIES, FileEntity, FileRecord], allowGlobalContext: true, findOneOrFailHandler: (entityName: string, where: Dictionary | IPrimaryKey) => new NotFoundException(`The requested ${entityName}: ${JSON.stringify(where)} has not been found.`), diff --git a/apps/server/src/modules/files-storage-client/service/copy-files.service.spec.ts b/apps/server/src/modules/files-storage-client/service/copy-files.service.spec.ts index 3fef84be76a..0dadb1a87e1 100644 --- a/apps/server/src/modules/files-storage-client/service/copy-files.service.spec.ts +++ b/apps/server/src/modules/files-storage-client/service/copy-files.service.spec.ts @@ -1,7 +1,13 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { ComponentType, IComponentProperties } from '@shared/domain'; -import { courseFactory, fileFactory, lessonFactory, schoolFactory, setupEntities } from '@shared/testing'; +import { + courseFactory, + lessonFactory, + schoolFactory, + legacyFileEntityMockFactory, + setupEntities, +} from '@shared/testing'; import { CopyElementType, CopyHelperService } from '@src/modules/copy-helper'; import { CopyFilesService } from './copy-files.service'; import { FilesStorageClientAdapterService } from './files-storage-client.service'; @@ -52,16 +58,16 @@ describe('copy files service', () => { describe('copy files of entity', () => { const setup = () => { const school = schoolFactory.build(); - const file1 = fileFactory.buildWithId({ name: 'file.jpg' }); - const file2 = fileFactory.buildWithId({ name: 'file.jpg' }); + const file1 = legacyFileEntityMockFactory.build(); + const file2 = legacyFileEntityMockFactory.build(); const imageHTML1 = getImageHTML(file1.id, file1.name); const imageHTML2 = getImageHTML(file2.id, file2.name); - return { file1, file2, school, imageHTML1, imageHTML2 }; + return { school, imageHTML1, imageHTML2 }; }; describe('copy files of lesson', () => { const lessonSetup = () => { - const { file1, file2, school, imageHTML1, imageHTML2 } = setup(); + const { school, imageHTML1, imageHTML2 } = setup(); const originalCourse = courseFactory.build({ school }); const textContent: IComponentProperties = { title: '', @@ -86,7 +92,7 @@ describe('copy files service', () => { const mockedFileDto = { id: 'mockedFileId', sourceId: 'mockedSourceId', name: 'mockedName' }; filesStorageClientAdapterService.copyFilesOfParent.mockResolvedValue([mockedFileDto]); - return { originalLesson, copyLesson, file1, file2, schoolId: school.id, userId, mockedFileDto }; + return { originalLesson, copyLesson, schoolId: school.id, userId, mockedFileDto }; }; it('should return fileUrlReplacements', async () => { 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 df092c0a952..f07d88d85fd 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 @@ -6,7 +6,7 @@ import { PreviewInputMimeTypes } from '../interface'; import { FileRecord, FileRecordParentType, - FileSecurityCheck, + FileRecordSecurityCheck, IFileRecordProperties, PreviewStatus, ScanStatus, @@ -95,21 +95,21 @@ describe('FileRecord Entity', () => { }); }); - describe('FileSecurityCheck', () => { + describe('FileRecordSecurityCheck', () => { it('should set the requestToken via the constructor', () => { - const securityCheck = new FileSecurityCheck({ requestToken: '08154711' }); + const securityCheck = new FileRecordSecurityCheck({ requestToken: '08154711' }); expect(securityCheck.requestToken).toEqual('08154711'); expect(securityCheck.status).toEqual(securityCheck.status); expect(securityCheck.reason).toEqual(securityCheck.reason); }); it('should set the status via the constructor', () => { - const securityCheck = new FileSecurityCheck({ status: ScanStatus.PENDING }); + const securityCheck = new FileRecordSecurityCheck({ status: ScanStatus.PENDING }); expect(securityCheck.status).toEqual(ScanStatus.PENDING); expect(securityCheck.requestToken).toEqual(securityCheck.requestToken); expect(securityCheck.reason).toEqual(securityCheck.reason); }); it('should set the reason via the constructor', () => { - const securityCheck = new FileSecurityCheck({ reason: 'test-reason' }); + const securityCheck = new FileRecordSecurityCheck({ reason: 'test-reason' }); expect(securityCheck.reason).toEqual('test-reason'); expect(securityCheck.status).toEqual(securityCheck.status); expect(securityCheck.requestToken).toEqual(securityCheck.requestToken); 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 799acbc0ab1..81fe5b34ba6 100644 --- a/apps/server/src/modules/files-storage/entity/filerecord.entity.ts +++ b/apps/server/src/modules/files-storage/entity/filerecord.entity.ts @@ -1,7 +1,7 @@ import { Embeddable, Embedded, Entity, Enum, Index, Property } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; import { BadRequestException } from '@nestjs/common'; -import { BaseEntityWithTimestamps, type EntityId } from '@shared/domain'; +import { BaseEntityWithTimestamps, EntityId } from '@shared/domain'; import { v4 as uuid } from 'uuid'; import { ErrorType } from '../error'; import { PreviewInputMimeTypes } from '../interface/preview-input-mime-types.enum'; @@ -33,13 +33,13 @@ export enum PreviewStatus { PREVIEW_NOT_POSSIBLE_WRONG_MIME_TYPE = 'preview_not_possible_wrong_mime_type', } -export interface IFileSecurityCheckProperties { +export interface IFileRecordSecurityCheckProperties { status?: ScanStatus; reason?: string; requestToken?: string; } @Embeddable() -export class FileSecurityCheck { +export class FileRecordSecurityCheck { @Enum() status: ScanStatus = ScanStatus.PENDING; @@ -55,7 +55,7 @@ export class FileSecurityCheck { @Property() updatedAt = new Date(); - constructor(props: IFileSecurityCheckProperties) { + constructor(props: IFileRecordSecurityCheckProperties) { if (props.status !== undefined) { this.status = props.status; } @@ -111,8 +111,8 @@ export class FileRecord extends BaseEntityWithTimestamps { @Property() mimeType: string; // TODO mime-type enum? - @Embedded(() => FileSecurityCheck, { object: true, nullable: false }) - securityCheck: FileSecurityCheck; + @Embedded(() => FileRecordSecurityCheck, { object: true, nullable: false }) + securityCheck: FileRecordSecurityCheck; @Index() @Enum() @@ -161,7 +161,7 @@ export class FileRecord extends BaseEntityWithTimestamps { if (props.isCopyFrom) { this._isCopyFrom = new ObjectId(props.isCopyFrom); } - this.securityCheck = new FileSecurityCheck({}); + this.securityCheck = new FileRecordSecurityCheck({}); this.deletedSince = props.deletedSince; } diff --git a/apps/server/src/modules/files-storage/files-storage.module.ts b/apps/server/src/modules/files-storage/files-storage.module.ts index b33abd62b91..29caa14e8c8 100644 --- a/apps/server/src/modules/files-storage/files-storage.module.ts +++ b/apps/server/src/modules/files-storage/files-storage.module.ts @@ -9,7 +9,7 @@ import { RabbitMQWrapperModule } from '@shared/infra/rabbitmq/rabbitmq.module'; import { S3ClientModule } from '@shared/infra/s3-client'; import { DB_PASSWORD, DB_URL, DB_USERNAME, createConfigModuleOptions } from '@src/config'; import { LoggerModule } from '@src/core/logger'; -import { FileRecord, FileSecurityCheck } from './entity'; +import { FileRecord, FileRecordSecurityCheck } from './entity'; import { config, s3Config } from './files-storage.config'; import { FileRecordRepo } from './repo'; import { FilesStorageService } from './service/files-storage.service'; @@ -45,7 +45,7 @@ const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { clientUrl: DB_URL, password: DB_PASSWORD, user: DB_USERNAME, - entities: [...ALL_ENTITIES, FileRecord, FileSecurityCheck], + entities: [...ALL_ENTITIES, FileRecord, FileRecordSecurityCheck], // debug: true, // use it for locally debugging of querys }), diff --git a/apps/server/src/modules/files/domain/index.ts b/apps/server/src/modules/files/domain/index.ts new file mode 100644 index 00000000000..fcb073fefcd --- /dev/null +++ b/apps/server/src/modules/files/domain/index.ts @@ -0,0 +1 @@ +export * from './types'; diff --git a/apps/server/src/modules/files/domain/types/file-owner-model.enum.ts b/apps/server/src/modules/files/domain/types/file-owner-model.enum.ts new file mode 100644 index 00000000000..56a6cedcf42 --- /dev/null +++ b/apps/server/src/modules/files/domain/types/file-owner-model.enum.ts @@ -0,0 +1,5 @@ +export const enum FileOwnerModel { + USER = 'user', + COURSE = 'course', + TEAMS = 'teams', +} diff --git a/apps/server/src/modules/files/domain/types/file-permission-reference-model.enum.ts b/apps/server/src/modules/files/domain/types/file-permission-reference-model.enum.ts new file mode 100644 index 00000000000..eced0e48af5 --- /dev/null +++ b/apps/server/src/modules/files/domain/types/file-permission-reference-model.enum.ts @@ -0,0 +1,4 @@ +export const enum FilePermissionReferenceModel { + USER = 'user', + ROLE = 'role', +} diff --git a/apps/server/src/modules/files/domain/types/file-security-check-status.enum.ts b/apps/server/src/modules/files/domain/types/file-security-check-status.enum.ts new file mode 100644 index 00000000000..b0e36760099 --- /dev/null +++ b/apps/server/src/modules/files/domain/types/file-security-check-status.enum.ts @@ -0,0 +1,6 @@ +export const enum FileSecurityCheckStatus { + PENDING = 'pending', + VERIFIED = 'verified', + BLOCKED = 'blocked', + WONT_CHECK = 'wont-check', +} diff --git a/apps/server/src/modules/files/domain/types/index.ts b/apps/server/src/modules/files/domain/types/index.ts new file mode 100644 index 00000000000..92847521f18 --- /dev/null +++ b/apps/server/src/modules/files/domain/types/index.ts @@ -0,0 +1,3 @@ +export * from './file-security-check-status.enum'; +export * from './file-permission-reference-model.enum'; +export * from './file-owner-model.enum'; diff --git a/apps/server/src/modules/files/entity/file-permission.entity.spec.ts b/apps/server/src/modules/files/entity/file-permission.entity.spec.ts new file mode 100644 index 00000000000..c2d61405838 --- /dev/null +++ b/apps/server/src/modules/files/entity/file-permission.entity.spec.ts @@ -0,0 +1,62 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { FilePermissionReferenceModel } from '../domain'; +import { FilePermissionEntity } from './file-permission.entity'; + +describe(FilePermissionEntity.name, () => { + describe('constructor', () => { + const setup = () => { + const refId = new ObjectId(); + const refPermModel = FilePermissionReferenceModel.USER; + + return { refId, refPermModel }; + }; + + describe('when passed a minimal valid props object', () => { + it(`should return a valid ${FilePermissionEntity.name} object with proper default fields values and with the values taken from the passed props object`, () => { + const { refId, refPermModel } = setup(); + + const entity = new FilePermissionEntity({ + refId: refId.toHexString(), + refPermModel, + }); + + expect(entity).toEqual( + expect.objectContaining({ + refId, + refPermModel, + write: true, + read: true, + create: true, + delete: true, + }) + ); + }); + }); + + describe('when passed a complete (fully filled) props object', () => { + it(`should return a valid ${FilePermissionEntity.name} object with proper fields values taken from the passed props object`, () => { + const { refId, refPermModel } = setup(); + + const entity = new FilePermissionEntity({ + refId: refId.toHexString(), + refPermModel, + write: false, + read: false, + create: false, + delete: false, + }); + + expect(entity).toEqual( + expect.objectContaining({ + refId, + refPermModel, + write: false, + read: false, + create: false, + delete: false, + }) + ); + }); + }); + }); +}); diff --git a/apps/server/src/modules/files/entity/file-permission.entity.ts b/apps/server/src/modules/files/entity/file-permission.entity.ts new file mode 100644 index 00000000000..3bd7ed40ced --- /dev/null +++ b/apps/server/src/modules/files/entity/file-permission.entity.ts @@ -0,0 +1,55 @@ +import { Embeddable, Enum, Property } from '@mikro-orm/core'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { EntityId } from '@shared/domain'; +import { FilePermissionReferenceModel } from '../domain'; + +export interface FilePermissionEntityProps { + refId: EntityId; + refPermModel: FilePermissionReferenceModel; + write?: boolean; + read?: boolean; + create?: boolean; + delete?: boolean; +} + +@Embeddable() +export class FilePermissionEntity { + @Property({ nullable: false }) + refId: ObjectId; + + @Enum({ nullable: false }) + refPermModel: FilePermissionReferenceModel; + + @Property() + write = true; + + @Property() + read = true; + + @Property() + create = true; + + @Property() + delete = true; + + constructor(props: FilePermissionEntityProps) { + this.refId = new ObjectId(props.refId); + this.refPermModel = props.refPermModel; + + if (props.write !== undefined) { + this.write = props.write; + } + + if (props.read !== undefined) { + this.read = props.read; + } + + if (props.create !== undefined) { + this.create = props.create; + } + + if (props.delete !== undefined) { + this.delete = props.delete; + } + } +} diff --git a/apps/server/src/modules/files/entity/file-security-check.entity.spec.ts b/apps/server/src/modules/files/entity/file-security-check.entity.spec.ts new file mode 100644 index 00000000000..1546b063d53 --- /dev/null +++ b/apps/server/src/modules/files/entity/file-security-check.entity.spec.ts @@ -0,0 +1,61 @@ +import { validate as validateUUID } from 'uuid'; +import { FileSecurityCheckStatus } from '../domain'; +import { FileSecurityCheckEntity } from './file-security-check.entity'; + +describe(FileSecurityCheckEntity.name, () => { + describe('constructor', () => { + const verifyTimestamps = (entity: FileSecurityCheckEntity) => { + const currentTime = new Date().getTime(); + + const createdAtTime = entity.createdAt.getTime(); + + expect(createdAtTime).toBeGreaterThan(0); + expect(createdAtTime).toBeLessThanOrEqual(currentTime); + + const updatedAtTime = entity.updatedAt.getTime(); + + expect(updatedAtTime).toBeGreaterThan(0); + expect(updatedAtTime).toBeLessThanOrEqual(currentTime); + }; + + describe('when passed an empty props object', () => { + it(`should return a valid ${FileSecurityCheckEntity.name} object with proper default fields values`, () => { + const entity = new FileSecurityCheckEntity({}); + + verifyTimestamps(entity); + expect(entity).toEqual( + expect.objectContaining({ + status: FileSecurityCheckStatus.PENDING, + reason: 'not yet scanned', + }) + ); + expect(entity.requestToken).toBeDefined(); + expect(entity.requestToken?.length).toBeGreaterThan(0); + expect(validateUUID(entity.requestToken as string)).toEqual(true); + }); + }); + + describe('when passed a complete (fully filled) props object', () => { + it(`should return a valid ${FileSecurityCheckEntity.name} object with fields values taken from the passed props object`, () => { + const status = FileSecurityCheckStatus.VERIFIED; + const reason = 'AV scanning done'; + const requestToken = 'b9ebf8d9-6029-4d6c-bd93-4cace483df3c'; + + const entity = new FileSecurityCheckEntity({ + status, + reason, + requestToken, + }); + + verifyTimestamps(entity); + expect(entity).toEqual( + expect.objectContaining({ + status, + reason, + }) + ); + expect(entity.requestToken).toEqual(requestToken); + }); + }); + }); +}); diff --git a/apps/server/src/modules/files/entity/file-security-check.entity.ts b/apps/server/src/modules/files/entity/file-security-check.entity.ts new file mode 100644 index 00000000000..a3fb5dd0fb3 --- /dev/null +++ b/apps/server/src/modules/files/entity/file-security-check.entity.ts @@ -0,0 +1,41 @@ +import { Embeddable, Enum, Property } from '@mikro-orm/core'; +import { v4 as uuid } from 'uuid'; +import { FileSecurityCheckStatus } from '../domain'; + +export interface FileSecurityCheckEntityProps { + status?: FileSecurityCheckStatus; + reason?: string; + requestToken?: string; +} + +@Embeddable() +export class FileSecurityCheckEntity { + @Enum() + status: FileSecurityCheckStatus = FileSecurityCheckStatus.PENDING; + + @Property() + reason = 'not yet scanned'; + + @Property() + requestToken?: string = uuid(); + + @Property() + createdAt = new Date(); + + @Property() + updatedAt = new Date(); + + constructor(props: FileSecurityCheckEntityProps) { + if (props.status !== undefined) { + this.status = props.status; + } + + if (props.reason !== undefined) { + this.reason = props.reason; + } + + if (props.requestToken !== undefined) { + this.requestToken = props.requestToken; + } + } +} diff --git a/apps/server/src/modules/files/entity/file.entity.spec.ts b/apps/server/src/modules/files/entity/file.entity.spec.ts new file mode 100644 index 00000000000..1f6150b12c5 --- /dev/null +++ b/apps/server/src/modules/files/entity/file.entity.spec.ts @@ -0,0 +1,293 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { setupEntities, storageProviderFactory } from '@shared/testing'; +import { FileOwnerModel } from '@src/modules/files/domain'; +import { fileEntityFactory, filePermissionEntityFactory } from './testing'; +import { FileEntity } from './file.entity'; +import { FileSecurityCheckEntity } from './file-security-check.entity'; + +describe(FileEntity.name, () => { + const storageProvider = storageProviderFactory.buildWithId(); + const mainUserId = new ObjectId().toHexString(); + const anotherUserId = new ObjectId().toHexString(); + const yetAnotherUserId = new ObjectId().toHexString(); + + const copyFile = (file: FileEntity) => + new FileEntity({ + createdAt: file.createdAt, + updatedAt: file.updatedAt, + deletedAt: file.deletedAt, + deleted: file.deleted, + name: file.name, + size: file.size, + type: file.type, + storageFileName: file.storageFileName, + bucket: file.bucket, + storageProvider: file.storageProvider, + thumbnail: file.thumbnail, + thumbnailRequestToken: file.thumbnailRequestToken, + securityCheck: file.securityCheck, + shareTokens: file.shareTokens, + ownerId: file.ownerId, + refOwnerModel: file.refOwnerModel, + creatorId: file.creatorId, + permissions: file.permissions, + versionKey: file.versionKey, + }); + + beforeAll(async () => { + await setupEntities(); + }); + + describe('removePermissionsByRefId', () => { + describe('when called on a file that contains some permission with given refId', () => { + it('should properly remove this permission', () => { + const anotherUsersPermissions = [ + filePermissionEntityFactory.build({ refId: anotherUserId }), + filePermissionEntityFactory.build({ refId: yetAnotherUserId }), + ]; + const file = fileEntityFactory.build({ + ownerId: mainUserId, + creatorId: mainUserId, + permissions: [filePermissionEntityFactory.build({ refId: mainUserId }), ...anotherUsersPermissions], + }); + + const expectedFile = copyFile(file); + expectedFile.permissions = anotherUsersPermissions; + + file.removePermissionsByRefId(mainUserId); + + expect(file).toEqual(expectedFile); + }); + }); + + describe("when called on a file that doesn't have any permission with given refId", () => { + it('should not modify the file in any way (including the other present permissions)', () => { + const file = fileEntityFactory.build({ + ownerId: mainUserId, + creatorId: mainUserId, + permissions: [ + filePermissionEntityFactory.build({ refId: mainUserId }), + filePermissionEntityFactory.build({ refId: anotherUserId }), + ], + }); + + const originalFile = copyFile(file); + + const randomUserId = new ObjectId().toHexString(); + + file.removePermissionsByRefId(randomUserId); + + expect(file).toEqual(originalFile); + }); + }); + + describe("when called on a file that doesn't have any permissions at all", () => { + it('should not modify the file in any way', () => { + const file = fileEntityFactory.build({ permissions: [] }); + + const originalFile = copyFile(file); + + file.removePermissionsByRefId(mainUserId); + + expect(file).toEqual(originalFile); + }); + }); + }); + + describe('markForDeletion', () => { + describe('when called on some typical file', () => { + it('should properly mark the file for deletion', () => { + const file = fileEntityFactory.build(); + + const expectedFile = copyFile(file); + + const fakeCurrentDate = new Date('2023-01-01'); + + expectedFile.deletedAt = fakeCurrentDate; + expectedFile.deleted = true; + + jest.useFakeTimers().setSystemTime(fakeCurrentDate); + + file.markForDeletion(); + + expect(file).toEqual(expectedFile); + }); + }); + }); + + describe('isMarkedForDeletion', () => { + describe('when called on a file marked for deletion', () => { + it('should return "true"', () => { + const file = fileEntityFactory.build(); + + file.markForDeletion(); + + expect(file.isMarkedForDeletion()).toEqual(true); + }); + }); + + describe('when called on a file not marked for deletion (missing all the fields required for the proper marking)', () => { + it('should return "false"', () => { + const file = fileEntityFactory.build(); + + expect(file.isMarkedForDeletion()).toEqual(false); + }); + }); + + describe('when called on a file not marked for deletion (missing "deleted" flag)', () => { + it('should return "false"', () => { + const file = fileEntityFactory.build(); + + file.deleted = false; + + expect(file.isMarkedForDeletion()).toEqual(false); + }); + }); + + describe('when called on a file not marked for deletion (missing "deletedAt" timestamp)', () => { + it('should return "false"', () => { + const file = fileEntityFactory.build(); + + file.deleted = true; + + expect(file.isMarkedForDeletion()).toEqual(false); + }); + }); + + describe('when called on a file not marked for deletion (invalid "deletedAt" timestamp)', () => { + it('should return "false"', () => { + const file = fileEntityFactory.build(); + + file.deletedAt = new Date(0); + + expect(file.isMarkedForDeletion()).toEqual(false); + }); + }); + }); + + describe('constructor', () => { + describe('when creating a directory', () => { + it(`should return a valid ${FileEntity.name} object with fields values set from the provided complete props object`, () => { + const userId = new ObjectId().toHexString(); + const props = { + createdAt: new Date(2023, 8, 1), + updatedAt: new Date(2023, 9, 1), + deletedAt: new Date(2023, 10, 1), + deleted: true, + isDirectory: true, + name: 'test-files', + size: 1, + type: 'dir', + storageFileName: '000-test-files', + bucket: '000-bucket', + storageProvider: storageProviderFactory.buildWithId(), + thumbnail: 'https://example.com/directory-thumbnail.png', + thumbnailRequestToken: '9d96ca2e-bc14-4fde-9a8b-948cca0bd723', + securityCheck: new FileSecurityCheckEntity({}), + shareTokens: [ + '1c2ef176-cc1e-4e2e-bc64-0c84ad12ecb8', + '27ede1ff-90c1-4423-8884-a1910dc383e0', + '8786d3a3-7b66-431e-a19e-84a2a2f29f26', + ], + parentId: new ObjectId().toHexString(), + ownerId: userId, + refOwnerModel: FileOwnerModel.USER, + creatorId: userId, + permissions: [filePermissionEntityFactory.build({ refId: userId })], + lockId: new ObjectId().toHexString(), + versionKey: 0, + }; + + const entity = new FileEntity(props); + + const entityProps = { + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + deletedAt: entity.deletedAt, + deleted: entity.deleted, + isDirectory: entity.isDirectory, + name: entity.name, + size: entity.size, + type: entity.type, + storageFileName: entity.storageFileName, + bucket: entity.bucket, + storageProvider: entity.storageProvider, + thumbnail: entity.thumbnail, + thumbnailRequestToken: entity.thumbnailRequestToken, + securityCheck: entity.securityCheck, + shareTokens: entity.shareTokens, + parentId: entity.parentId, + ownerId: entity.ownerId, + refOwnerModel: entity.refOwnerModel, + creatorId: entity.creatorId, + permissions: entity.permissions, + lockId: entity.lockId, + versionKey: entity.versionKey, + }; + + expect(entityProps).toEqual(props); + }); + }); + + describe('when creating a file (non-directory)', () => { + const userId = new ObjectId().toHexString(); + + it(`should create an object of the ${FileEntity.name} class`, () => { + const file = fileEntityFactory.build(); + + expect(file).toBeInstanceOf(FileEntity); + }); + + describe('when there is no bucket set in the provided props object', () => { + it('should throw an exception', () => { + const call = () => + new FileEntity({ + name: 'name', + size: 42, + storageFileName: 'name', + storageProvider, + ownerId: userId, + refOwnerModel: FileOwnerModel.USER, + creatorId: userId, + permissions: [filePermissionEntityFactory.build({ refId: userId })], + }); + expect(call).toThrow(); + }); + }); + + describe('when there is no storageFileName set in the provided props object', () => { + it('should throw an exception', () => { + const call = () => + new FileEntity({ + name: 'name', + size: 42, + bucket: 'bucket', + storageProvider, + ownerId: userId, + refOwnerModel: FileOwnerModel.USER, + creatorId: userId, + permissions: [filePermissionEntityFactory.build({ refId: userId })], + }); + expect(call).toThrow(); + }); + }); + + describe('when there is no storageProvider set in the provided props object', () => { + it('should throw an exception', () => { + const call = () => + new FileEntity({ + name: 'name', + size: 42, + bucket: 'bucket', + storageFileName: 'name', + ownerId: userId, + refOwnerModel: FileOwnerModel.USER, + creatorId: userId, + permissions: [filePermissionEntityFactory.build({ refId: userId })], + }); + expect(call).toThrow(); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/files/entity/file.entity.ts b/apps/server/src/modules/files/entity/file.entity.ts new file mode 100644 index 00000000000..ea702ffc968 --- /dev/null +++ b/apps/server/src/modules/files/entity/file.entity.ts @@ -0,0 +1,203 @@ +import { Embedded, Entity, Enum, Index, ManyToOne, Property } from '@mikro-orm/core'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { v4 as uuid } from 'uuid'; +import { EntityId, StorageProviderEntity } from '@shared/domain'; +import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; +import { FileOwnerModel } from '../domain'; +import { FileSecurityCheckEntity } from './file-security-check.entity'; +import { FilePermissionEntity } from './file-permission.entity'; + +export interface FileEntityProps { + createdAt?: Date; + updatedAt?: Date; + deletedAt?: Date; + deleted?: boolean; + isDirectory?: boolean; + name: string; + size?: number; + type?: string; + storageFileName?: string; + bucket?: string; + storageProvider?: StorageProviderEntity; + thumbnail?: string; + thumbnailRequestToken?: string; + securityCheck?: FileSecurityCheckEntity; + shareTokens?: string[]; + parentId?: EntityId; + ownerId: EntityId; + refOwnerModel: FileOwnerModel; + creatorId: EntityId; + permissions: FilePermissionEntity[]; + lockId?: EntityId; + versionKey?: number; +} + +@Entity({ collection: 'files' }) +@Index({ options: { 'permissions.refId': 1 } }) +export class FileEntity extends BaseEntityWithTimestamps { + @Property({ nullable: true }) + deletedAt?: Date; + + @Property() + deleted = false; + + @Property() + isDirectory = false; + + @Property() + name: string; + + @Property({ nullable: true }) + size?: number; // not for directories + + @Property({ nullable: true }) + type?: string; + + @Property({ nullable: true }) + storageFileName?: string; // not for directories + + @Property({ nullable: true }) + bucket?: string; // not for directories + + @ManyToOne(() => StorageProviderEntity, { fieldName: 'storageProviderId', nullable: true }) + storageProvider?: StorageProviderEntity; // not for directories + + @Property({ nullable: true }) + thumbnail?: string; + + @Property({ nullable: true }) + thumbnailRequestToken?: string = uuid(); + + @Embedded(() => FileSecurityCheckEntity, { object: true, nullable: false }) + securityCheck: FileSecurityCheckEntity = new FileSecurityCheckEntity({}); + + @Property({ nullable: true }) + @Index() + shareTokens: string[] = []; + + @Property({ fieldName: 'parent', nullable: true }) + @Index() + _parentId?: ObjectId; + + get parentId(): EntityId | undefined { + return this._parentId?.toHexString(); + } + + @Property({ fieldName: 'owner', nullable: false }) + @Index() + _ownerId: ObjectId; + + get ownerId(): EntityId { + return this._ownerId.toHexString(); + } + + @Enum({ nullable: false }) + refOwnerModel: FileOwnerModel; + + @Property({ fieldName: 'creator' }) + @Index() + _creatorId: ObjectId; + + get creatorId(): EntityId { + return this._creatorId.toHexString(); + } + + @Embedded(() => FilePermissionEntity, { array: true, nullable: false }) + permissions: FilePermissionEntity[]; + + @Property({ fieldName: 'lockId', nullable: true }) + _lockId?: ObjectId; + + get lockId(): EntityId | undefined { + return this._lockId?.toHexString(); + } + + @Property({ fieldName: '__v', nullable: true }) + versionKey?: number; // mongoose model version key + + private validate(props: FileEntityProps) { + if (props.isDirectory) return; + + if (!props.size || !props.storageFileName || !props.bucket || !props.storageProvider) { + throw new Error( + 'files that are not directories always need a size, a storage file name, a bucket, and a storage provider.' + ); + } + } + + public removePermissionsByRefId(refId: EntityId): void { + const refObjectId = new ObjectId(refId); + + this.permissions = this.permissions.filter((permission) => !permission.refId.equals(refObjectId)); + } + + public markForDeletion(): void { + this.deletedAt = new Date(); + this.deleted = true; + } + + public isMarkedForDeletion(): boolean { + return this.deleted && this.deletedAt !== undefined && !Number.isNaN(this.deletedAt.getTime()); + } + + constructor(props: FileEntityProps) { + super(); + + this.validate(props); + + if (props.createdAt !== undefined) { + this.createdAt = props.createdAt; + } + + if (props.updatedAt !== undefined) { + this.updatedAt = props.updatedAt; + } + + this.deletedAt = props.deletedAt; + + if (props.deleted !== undefined) { + this.deleted = props.deleted; + } + + if (props.isDirectory !== undefined) { + this.isDirectory = props.isDirectory; + } + + this.name = props.name; + this.size = props.size; + this.type = props.type; + this.storageFileName = props.storageFileName; + this.bucket = props.bucket; + this.storageProvider = props.storageProvider; + this.thumbnail = props.thumbnail; + + if (props.thumbnailRequestToken !== undefined) { + this.thumbnailRequestToken = props.thumbnailRequestToken; + } + + if (props.securityCheck !== undefined) { + this.securityCheck = props.securityCheck; + } + + if (props.shareTokens !== undefined) { + this.shareTokens = props.shareTokens; + } + + if (props.parentId !== undefined) { + this._parentId = new ObjectId(props.parentId); + } + + this._ownerId = new ObjectId(props.ownerId); + this.refOwnerModel = props.refOwnerModel; + this._creatorId = new ObjectId(props.creatorId); + this.permissions = props.permissions; + + if (props.lockId !== undefined) { + this._lockId = new ObjectId(props.lockId); + } + + if (props.versionKey !== undefined) { + this.versionKey = props.versionKey; + } + } +} diff --git a/apps/server/src/modules/files/entity/index.ts b/apps/server/src/modules/files/entity/index.ts new file mode 100644 index 00000000000..4c2d152c9ac --- /dev/null +++ b/apps/server/src/modules/files/entity/index.ts @@ -0,0 +1,3 @@ +export * from './file-security-check.entity'; +export * from './file-permission.entity'; +export * from './file.entity'; diff --git a/apps/server/src/modules/files/entity/testing/factory/file-entity.factory.ts b/apps/server/src/modules/files/entity/testing/factory/file-entity.factory.ts new file mode 100644 index 00000000000..00f07dd04c7 --- /dev/null +++ b/apps/server/src/modules/files/entity/testing/factory/file-entity.factory.ts @@ -0,0 +1,24 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BaseFactory, storageProviderFactory } from '@shared/testing'; +import { FileEntity, FileEntityProps } from '../..'; +import { FileOwnerModel } from '../../../domain'; +import { filePermissionEntityFactory } from './file-permission-entity.factory'; + +export const fileEntityFactory = BaseFactory.define(FileEntity, ({ sequence }) => { + const userId = new ObjectId().toHexString(); + + return { + name: `test-file-${sequence}.txt`, + size: Math.floor(Math.random() * 4200) + 1, + type: 'plain/text', + storageFileName: `00${sequence}-test-file-${sequence}.txt`, + bucket: `bucket-00${sequence}`, + storageProvider: storageProviderFactory.buildWithId(), + thumbnail: 'https://example.com/thumbnail.png', + ownerId: userId, + refOwnerModel: FileOwnerModel.USER, + creatorId: userId, + permissions: [filePermissionEntityFactory.build({ refId: userId })], + versionKey: 0, + }; +}); diff --git a/apps/server/src/modules/files/entity/testing/factory/file-permission-entity.factory.ts b/apps/server/src/modules/files/entity/testing/factory/file-permission-entity.factory.ts new file mode 100644 index 00000000000..e0eebb815ff --- /dev/null +++ b/apps/server/src/modules/files/entity/testing/factory/file-permission-entity.factory.ts @@ -0,0 +1,14 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BaseFactory } from '@shared/testing'; +import { FilePermissionEntity, FilePermissionEntityProps } from '../..'; +import { FilePermissionReferenceModel } from '../../../domain'; + +export const filePermissionEntityFactory = BaseFactory.define( + FilePermissionEntity, + () => { + return { + refId: new ObjectId().toHexString(), + refPermModel: FilePermissionReferenceModel.USER, + }; + } +); diff --git a/apps/server/src/modules/files/entity/testing/factory/index.ts b/apps/server/src/modules/files/entity/testing/factory/index.ts new file mode 100644 index 00000000000..191b3e37f56 --- /dev/null +++ b/apps/server/src/modules/files/entity/testing/factory/index.ts @@ -0,0 +1,2 @@ +export * from './file-entity.factory'; +export * from './file-permission-entity.factory'; diff --git a/apps/server/src/modules/files/entity/testing/index.ts b/apps/server/src/modules/files/entity/testing/index.ts new file mode 100644 index 00000000000..d847d7abce6 --- /dev/null +++ b/apps/server/src/modules/files/entity/testing/index.ts @@ -0,0 +1 @@ +export * from './factory'; diff --git a/apps/server/src/modules/files/files.module.ts b/apps/server/src/modules/files/files.module.ts index bdc8009fd8f..a62815eb69f 100644 --- a/apps/server/src/modules/files/files.module.ts +++ b/apps/server/src/modules/files/files.module.ts @@ -1,9 +1,9 @@ import { Module } from '@nestjs/common'; -import { FilesRepo } from '@shared/repo'; -import { StorageProviderRepo } from '@shared/repo/storageprovider/storageprovider.repo'; +import { StorageProviderRepo } from '@shared/repo/storageprovider'; import { LoggerModule } from '@src/core/logger'; -import { DeleteFilesConsole } from './job/delete-files.console'; +import { DeleteFilesConsole } from './job'; import { DeleteFilesUc } from './uc'; +import { FilesRepo } from './repo'; @Module({ imports: [LoggerModule], diff --git a/apps/server/src/modules/files/job/delete-files.console.spec.ts b/apps/server/src/modules/files/job/delete-files.console.spec.ts index 56030ab914e..f760e49e03f 100644 --- a/apps/server/src/modules/files/job/delete-files.console.spec.ts +++ b/apps/server/src/modules/files/job/delete-files.console.spec.ts @@ -26,7 +26,8 @@ describe('DeleteFilesConsole', () => { console = module.get(DeleteFilesConsole); deleteFilesUc = module.get(DeleteFilesUc); - // Set fake system time. Otherwise dates constructed in the test and the console can differ because of the short time elapsing between the calls. + // Set fake system time. Otherwise, dates constructed in the test and the + // console can differ because of the short time elapsing between the calls. jest.useFakeTimers(); jest.setSystemTime(new Date(2022, 1, 22)); }); diff --git a/apps/server/src/modules/files/job/delete-files.console.ts b/apps/server/src/modules/files/job/delete-files.console.ts index a8a19033c15..ea06c4e8094 100644 --- a/apps/server/src/modules/files/job/delete-files.console.ts +++ b/apps/server/src/modules/files/job/delete-files.console.ts @@ -1,5 +1,5 @@ -import { LegacyLogger } from '@src/core/logger'; import { Command, Console } from 'nestjs-console'; +import { LegacyLogger } from '@src/core/logger'; import { DeleteFilesUc } from '../uc'; @Console({ command: 'files', description: 'file deletion console' }) diff --git a/apps/server/src/modules/files/job/index.ts b/apps/server/src/modules/files/job/index.ts new file mode 100644 index 00000000000..10c1686e988 --- /dev/null +++ b/apps/server/src/modules/files/job/index.ts @@ -0,0 +1 @@ +export * from './delete-files.console'; diff --git a/apps/server/src/modules/files/repo/files.repo.spec.ts b/apps/server/src/modules/files/repo/files.repo.spec.ts new file mode 100644 index 00000000000..ea33ae7917a --- /dev/null +++ b/apps/server/src/modules/files/repo/files.repo.spec.ts @@ -0,0 +1,548 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { StorageProviderEntity } from '@shared/domain'; +import { FileEntity } from '../entity'; +import { fileEntityFactory, filePermissionEntityFactory } from '../entity/testing'; +import { FilesRepo } from './files.repo'; + +describe(FilesRepo.name, () => { + let repo: FilesRepo; + let em: EntityManager; + let module: TestingModule; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + MongoMemoryDatabaseModule.forRoot({ + entities: [FileEntity, StorageProviderEntity], + }), + ], + providers: [FilesRepo], + }).compile(); + + repo = module.get(FilesRepo); + em = module.get(EntityManager); + }); + + beforeEach(async () => { + await em.nativeDelete(FileEntity, {}); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('defined', () => { + it('repo should be defined', () => { + expect(repo).toBeDefined(); + }); + + it('entity manager should be defined', () => { + expect(em).toBeDefined(); + }); + + it('should implement entityName getter', () => { + expect(repo.entityName).toBe(FileEntity); + }); + }); + + describe('findForCleanup', () => { + it('should return files marked for deletion according to given params', async () => { + const file: FileEntity = fileEntityFactory.build({ deletedAt: new Date() }); + + await em.persistAndFlush(file); + em.clear(); + + const thresholdDate = new Date(); + + const result = await repo.findForCleanup(thresholdDate, 3, 0); + + expect(result.length).toEqual(1); + expect(result[0].id).toEqual(file.id); + }); + + it('should not return files which are not marked for deletion', async () => { + const file = fileEntityFactory.build({ deletedAt: undefined }); + + await em.persistAndFlush(file); + em.clear(); + + const thresholdDate = new Date(); + const result = await repo.findForCleanup(thresholdDate, 3, 0); + + expect(result.length).toEqual(0); + }); + + it('should not return files where deletedAt is after threshold', async () => { + const thresholdDate = new Date(); + const file = fileEntityFactory.build({ deletedAt: new Date(thresholdDate.getTime() + 10) }); + + await em.persistAndFlush(file); + em.clear(); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(file.deletedAt!.getTime()).toBeGreaterThan(thresholdDate.getTime()); + + const result = await repo.findForCleanup(thresholdDate, 3, 0); + + expect(result.length).toEqual(0); + }); + }); + + describe('findByOwnerUserId', () => { + describe('when searching for a files owned by the user with given userId', () => { + const setup = async () => { + const mainUserId = new ObjectId().toHexString(); + const otherUserId = new ObjectId().toHexString(); + + // Test file created, owned and accessible only by the main user. + const mainUserFile = fileEntityFactory.build({ + ownerId: mainUserId, + creatorId: mainUserId, + permissions: [filePermissionEntityFactory.build({ refId: mainUserId })], + }); + + // Test file created and owned by the main user, but also accessible by the other user. + const mainUserSharedFile = fileEntityFactory.build({ + ownerId: mainUserId, + creatorId: mainUserId, + permissions: [ + filePermissionEntityFactory.build({ refId: mainUserId }), + filePermissionEntityFactory.build({ refId: otherUserId }), + ], + }); + + await em.persistAndFlush([mainUserSharedFile, mainUserFile]); + em.clear(); + + const expectedMainUserFileProps = { + id: mainUserFile.id, + createdAt: mainUserFile.createdAt, + updatedAt: mainUserFile.updatedAt, + deleted: false, + isDirectory: false, + name: mainUserFile.name, + size: mainUserFile.size, + type: mainUserFile.type, + storageFileName: mainUserFile.storageFileName, + bucket: mainUserFile.bucket, + thumbnail: mainUserFile.thumbnail, + thumbnailRequestToken: mainUserFile.thumbnailRequestToken, + securityCheck: mainUserFile.securityCheck, + shareTokens: [], + refOwnerModel: mainUserFile.refOwnerModel, + permissions: mainUserFile.permissions, + versionKey: 0, + }; + + const expectedMainUserSharedFileProps = { + id: mainUserSharedFile.id, + createdAt: mainUserSharedFile.createdAt, + updatedAt: mainUserSharedFile.updatedAt, + deleted: false, + isDirectory: false, + name: mainUserSharedFile.name, + size: mainUserSharedFile.size, + type: mainUserSharedFile.type, + storageFileName: mainUserSharedFile.storageFileName, + bucket: mainUserSharedFile.bucket, + thumbnail: mainUserSharedFile.thumbnail, + thumbnailRequestToken: mainUserSharedFile.thumbnailRequestToken, + securityCheck: mainUserSharedFile.securityCheck, + shareTokens: [], + refOwnerModel: mainUserSharedFile.refOwnerModel, + permissions: mainUserSharedFile.permissions, + versionKey: 0, + }; + + return { + mainUserIdd: mainUserId, + mainUserFile, + mainUserSharedFile, + expectedMainUserFileProps, + expectedMainUserSharedFileProps, + }; + }; + + describe('when there are some files that match this criteria', () => { + it('should return proper files', async () => { + const { + mainUserIdd, + mainUserSharedFile, + mainUserFile, + expectedMainUserSharedFileProps, + expectedMainUserFileProps, + } = await setup(); + + const results = await repo.findByOwnerUserId(mainUserIdd); + + expect(results).toHaveLength(2); + + // Verify explicit fields. + expect(results).toEqual( + expect.arrayContaining([ + expect.objectContaining(expectedMainUserSharedFileProps), + expect.objectContaining(expectedMainUserFileProps), + ]) + ); + + // Verify storage provider id. + expect(results.map((result) => result.storageProvider?.id)).toEqual( + expect.arrayContaining([mainUserSharedFile.storageProvider?.id, mainUserFile.storageProvider?.id]) + ); + + // Verify implicit ownerId field. + expect(results.map((result) => result.ownerId)).toEqual( + expect.arrayContaining([mainUserSharedFile.ownerId, mainUserFile.ownerId]) + ); + + // Verify implicit creatorId field. + expect(results.map((result) => result.creatorId)).toEqual( + expect.arrayContaining([mainUserSharedFile.creatorId, mainUserFile.creatorId]) + ); + }); + }); + + describe('when there are no files that match this criteria', () => { + it('should return an empty array', async () => { + await em.persistAndFlush([fileEntityFactory.build(), fileEntityFactory.build(), fileEntityFactory.build()]); + + em.clear(); + + const results = await repo.findByOwnerUserId(new ObjectId().toHexString()); + + expect(results).toHaveLength(0); + }); + }); + + describe('when there are no files in the database at all', () => { + it('should return an empty array', async () => { + const testPermissionRefId = new ObjectId().toHexString(); + + const results = await repo.findByOwnerUserId(testPermissionRefId); + + expect(results).toHaveLength(0); + }); + }); + }); + }); + + describe('findByPermissionRefId', () => { + describe('when searching for a files to which the user with given userId has access', () => { + const setup = async () => { + const mainUserId = new ObjectId().toHexString(); + const otherUserId = new ObjectId().toHexString(); + + // Test files created, owned and accessible only by the other user. + const otherUserFile = fileEntityFactory.build({ + ownerId: otherUserId, + creatorId: otherUserId, + permissions: [filePermissionEntityFactory.build({ refId: otherUserId })], + }); + + // Test file created and owned by the main user, but also accessible by the other user. + const mainUserSharedFile = fileEntityFactory.build({ + ownerId: mainUserId, + creatorId: mainUserId, + permissions: [ + filePermissionEntityFactory.build({ refId: mainUserId }), + filePermissionEntityFactory.build({ refId: otherUserId }), + ], + }); + + // Test file created and owned by the other user, but also accessible by the main user. + const otherUserSharedFile = fileEntityFactory.build({ + ownerId: otherUserId, + creatorId: otherUserId, + permissions: [ + filePermissionEntityFactory.build({ refId: otherUserId }), + filePermissionEntityFactory.build({ refId: mainUserId }), + ], + }); + + // Test file created, owned and accessible only by the main user. + const mainUserFile = fileEntityFactory.build({ + ownerId: mainUserId, + creatorId: mainUserId, + permissions: [filePermissionEntityFactory.build({ refId: mainUserId })], + }); + + await em.persistAndFlush([otherUserFile, mainUserSharedFile, otherUserSharedFile, mainUserFile]); + em.clear(); + + const expectedMainUserSharedFileProps = { + id: mainUserSharedFile.id, + createdAt: mainUserSharedFile.createdAt, + updatedAt: mainUserSharedFile.updatedAt, + deleted: false, + isDirectory: false, + name: mainUserSharedFile.name, + size: mainUserSharedFile.size, + type: mainUserSharedFile.type, + storageFileName: mainUserSharedFile.storageFileName, + bucket: mainUserSharedFile.bucket, + thumbnail: mainUserSharedFile.thumbnail, + thumbnailRequestToken: mainUserSharedFile.thumbnailRequestToken, + securityCheck: mainUserSharedFile.securityCheck, + shareTokens: [], + refOwnerModel: mainUserSharedFile.refOwnerModel, + permissions: mainUserSharedFile.permissions, + versionKey: 0, + }; + + const expectedOtherUserSharedFileProps = { + id: otherUserSharedFile.id, + createdAt: otherUserSharedFile.createdAt, + updatedAt: otherUserSharedFile.updatedAt, + deleted: false, + isDirectory: false, + name: otherUserSharedFile.name, + size: otherUserSharedFile.size, + type: otherUserSharedFile.type, + storageFileName: otherUserSharedFile.storageFileName, + bucket: otherUserSharedFile.bucket, + thumbnail: otherUserSharedFile.thumbnail, + thumbnailRequestToken: otherUserSharedFile.thumbnailRequestToken, + securityCheck: otherUserSharedFile.securityCheck, + shareTokens: [], + refOwnerModel: otherUserSharedFile.refOwnerModel, + permissions: otherUserSharedFile.permissions, + versionKey: 0, + }; + + const expectedMainUserFileProps = { + id: mainUserFile.id, + createdAt: mainUserFile.createdAt, + updatedAt: mainUserFile.updatedAt, + deleted: false, + isDirectory: false, + name: mainUserFile.name, + size: mainUserFile.size, + type: mainUserFile.type, + storageFileName: mainUserFile.storageFileName, + bucket: mainUserFile.bucket, + thumbnail: mainUserFile.thumbnail, + thumbnailRequestToken: mainUserFile.thumbnailRequestToken, + securityCheck: mainUserFile.securityCheck, + shareTokens: [], + refOwnerModel: mainUserFile.refOwnerModel, + permissions: mainUserFile.permissions, + versionKey: 0, + }; + + return { + mainUserId, + mainUserSharedFile, + otherUserSharedFile, + mainUserFile, + expectedMainUserSharedFileProps, + expectedOtherUserSharedFileProps, + expectedMainUserFileProps, + }; + }; + + describe('when there are some files that match this criteria', () => { + it('should return proper files', async () => { + const { + mainUserId, + mainUserSharedFile, + otherUserSharedFile, + mainUserFile, + expectedMainUserSharedFileProps, + expectedOtherUserSharedFileProps, + expectedMainUserFileProps, + } = await setup(); + + const results = await repo.findByPermissionRefId(mainUserId); + + expect(results).toHaveLength(3); + + // Verify explicit fields. + expect(results).toEqual( + expect.arrayContaining([ + expect.objectContaining(expectedMainUserSharedFileProps), + expect.objectContaining(expectedOtherUserSharedFileProps), + expect.objectContaining(expectedMainUserFileProps), + ]) + ); + + // Verify storage provider id. + expect(results.map((result) => result.storageProvider?.id)).toEqual( + expect.arrayContaining([ + mainUserSharedFile.storageProvider?.id, + otherUserSharedFile.storageProvider?.id, + mainUserFile.storageProvider?.id, + ]) + ); + + // Verify implicit ownerId field. + expect(results.map((result) => result.ownerId)).toEqual( + expect.arrayContaining([mainUserSharedFile.ownerId, otherUserSharedFile.ownerId, mainUserFile.ownerId]) + ); + + // Verify implicit creatorId field. + expect(results.map((result) => result.creatorId)).toEqual( + expect.arrayContaining([ + mainUserSharedFile.creatorId, + otherUserSharedFile.creatorId, + mainUserFile.creatorId, + ]) + ); + }); + }); + + describe('when there are no files that match this criteria', () => { + it('should return an empty array', async () => { + await em.persistAndFlush([fileEntityFactory.build(), fileEntityFactory.build(), fileEntityFactory.build()]); + em.clear(); + + const results = await repo.findByPermissionRefId(new ObjectId().toHexString()); + + expect(results).toHaveLength(0); + }); + }); + + describe('when there are no files in the database at all', () => { + it('should return an empty array', async () => { + const testPermissionRefId = new ObjectId().toHexString(); + + const results = await repo.findByPermissionRefId(testPermissionRefId); + + expect(results).toHaveLength(0); + }); + }); + }); + }); + + describe('save', () => { + describe('when modifying given file permissions', () => { + const setup = async () => { + const mainUserId = new ObjectId().toHexString(); + const otherUserId = new ObjectId().toHexString(); + + // Test file created and owned by the other user, but also accessible by the main user. + const otherUserSharedFile = fileEntityFactory.build({ + ownerId: otherUserId, + creatorId: otherUserId, + permissions: [ + filePermissionEntityFactory.build({ refId: otherUserId }), + filePermissionEntityFactory.build({ refId: mainUserId }), + ], + }); + + await em.persistAndFlush([otherUserSharedFile]); + em.clear(); + + const expectedOtherUserSharedFileProps = { + id: otherUserSharedFile.id, + createdAt: otherUserSharedFile.createdAt, + updatedAt: otherUserSharedFile.updatedAt, + deleted: false, + isDirectory: false, + name: otherUserSharedFile.name, + size: otherUserSharedFile.size, + type: otherUserSharedFile.type, + storageFileName: otherUserSharedFile.storageFileName, + bucket: otherUserSharedFile.bucket, + thumbnail: otherUserSharedFile.thumbnail, + thumbnailRequestToken: otherUserSharedFile.thumbnailRequestToken, + securityCheck: otherUserSharedFile.securityCheck, + shareTokens: [], + refOwnerModel: otherUserSharedFile.refOwnerModel, + permissions: otherUserSharedFile.permissions, + versionKey: 0, + }; + + return { + mainUserId, + otherUserSharedFile, + expectedOtherUserSharedFileProps, + }; + }; + + it('should properly update stored file', async () => { + const initialFiles = await setup(); + let { otherUserSharedFile } = initialFiles; + const { mainUserId, expectedOtherUserSharedFileProps } = initialFiles; + + // Pre-check to make sure the main user has access to the file right now. + expect(otherUserSharedFile.permissions).toEqual( + expect.arrayContaining([filePermissionEntityFactory.build({ refId: mainUserId })]) + ); + + otherUserSharedFile.removePermissionsByRefId(mainUserId); + + await repo.save(otherUserSharedFile); + + otherUserSharedFile = await repo.findById(otherUserSharedFile.id); + + // Verify if the main user has for sure lost the permission to given file. + expect(otherUserSharedFile.permissions).not.toEqual( + expect.arrayContaining([filePermissionEntityFactory.build({ refId: mainUserId })]) + ); + + expectedOtherUserSharedFileProps.permissions = expectedOtherUserSharedFileProps.permissions.filter( + (permission) => !permission.refId.equals(new ObjectId(mainUserId)) + ); + + // Verify if other file fields are still untouched after the update, + // except the updatedAt field which is expected to change. + expect(expectedOtherUserSharedFileProps.updatedAt.getTime()).toBeLessThanOrEqual( + otherUserSharedFile.updatedAt.getTime() + ); + + expectedOtherUserSharedFileProps.updatedAt = otherUserSharedFile.updatedAt; + + expect(otherUserSharedFile).toEqual(expect.objectContaining(expectedOtherUserSharedFileProps)); + }); + }); + + describe('when marking given file for deletion', () => { + it('should properly update stored file', async () => { + let file = fileEntityFactory.build(); + + const originalUpdatedAt = file.updatedAt; + + // Pre-check to make sure the file is not marked as deleted yet. + expect(file.isMarkedForDeletion()).toEqual(false); + + file.markForDeletion(); + + await repo.save(file); + + file = await repo.findById(file.id); + + // Verify if the file has for sure been marked as deleted. + expect(file.isMarkedForDeletion()).toEqual(true); + + // Verify if other file fields are still untouched after the update, + // except the updatedAt field which is expected to change. + expect(originalUpdatedAt.getTime()).toBeLessThanOrEqual(originalUpdatedAt.getTime()); + + const expectedFileProps = { + id: file.id, + createdAt: file.createdAt, + updatedAt: file.updatedAt, + deletedAt: file.deletedAt, + deleted: true, + isDirectory: false, + name: file.name, + size: file.size, + type: file.type, + storageFileName: file.storageFileName, + bucket: file.bucket, + thumbnail: file.thumbnail, + thumbnailRequestToken: file.thumbnailRequestToken, + securityCheck: file.securityCheck, + shareTokens: [], + refOwnerModel: file.refOwnerModel, + permissions: file.permissions, + versionKey: 0, + }; + + expect(file).toEqual(expect.objectContaining(expectedFileProps)); + }); + }); + }); +}); diff --git a/apps/server/src/modules/files/repo/files.repo.ts b/apps/server/src/modules/files/repo/files.repo.ts new file mode 100644 index 00000000000..0fe05662e34 --- /dev/null +++ b/apps/server/src/modules/files/repo/files.repo.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@nestjs/common'; +import { EntityDictionary } from '@mikro-orm/core'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { EntityId } from '@shared/domain'; +import { BaseRepo } from '@shared/repo/base.repo'; +import { FileOwnerModel } from '../domain'; +import { FileEntity } from '../entity'; + +@Injectable() +export class FilesRepo extends BaseRepo { + constructor(protected readonly _em: EntityManager) { + super(_em); + } + + get entityName() { + return FileEntity; + } + + public async findForCleanup(thresholdDate: Date, batchSize: number, offset: number): Promise { + const filter = { deletedAt: { $lte: thresholdDate } }; + const options = { + orderBy: { id: 'asc' }, + limit: batchSize, + offset, + populate: ['storageProvider'] as never[], + }; + + const files = await this._em.find(FileEntity, filter, options); + + return files as FileEntity[]; + } + + public async findByOwnerUserId(ownerUserId: EntityId): Promise { + const filter = { + owner: new ObjectId(ownerUserId), + refOwnerModel: FileOwnerModel.USER, + }; + + const files = await this._em.find(FileEntity, filter); + + return files as FileEntity[]; + } + + public async findByPermissionRefId(permissionRefId: EntityId): Promise { + const pipeline = [ + { + $match: { + permissions: { + $elemMatch: { + refId: new ObjectId(permissionRefId), + }, + }, + }, + }, + ]; + + const rawFilesDocuments = await this._em.aggregate(FileEntity, pipeline); + + const files = rawFilesDocuments.map((rawFileDocument) => + this._em.map(FileEntity, rawFileDocument as EntityDictionary) + ); + + return files; + } +} diff --git a/apps/server/src/shared/repo/files/index.ts b/apps/server/src/modules/files/repo/index.ts similarity index 100% rename from apps/server/src/shared/repo/files/index.ts rename to apps/server/src/modules/files/repo/index.ts diff --git a/apps/server/src/modules/files/service/files.service.spec.ts b/apps/server/src/modules/files/service/files.service.spec.ts new file mode 100644 index 00000000000..5ee07897565 --- /dev/null +++ b/apps/server/src/modules/files/service/files.service.spec.ts @@ -0,0 +1,171 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { setupEntities } from '@shared/testing'; +import { FilesService } from './files.service'; +import { FilesRepo } from '../repo'; +import { fileEntityFactory, filePermissionEntityFactory } from '../entity/testing'; +import { FileEntity } from '../entity'; + +describe(FilesService.name, () => { + let module: TestingModule; + let service: FilesService; + let repo: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + FilesService, + { + provide: FilesRepo, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(FilesService); + repo = module.get(FilesRepo); + + await setupEntities(); + }); + + afterEach(() => { + repo.findByPermissionRefId.mockClear(); + repo.findByOwnerUserId.mockClear(); + repo.save.mockClear(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('removeUserPermissionsToAnyFiles', () => { + it('should not modify any files if there are none that user has permission to access', async () => { + const userId = new ObjectId().toHexString(); + repo.findByPermissionRefId.mockResolvedValueOnce([]); + + const result = await service.removeUserPermissionsToAnyFiles(userId); + + expect(result).toEqual(0); + + expect(repo.findByPermissionRefId).toBeCalledWith(userId); + expect(repo.save).not.toBeCalled(); + }); + + describe('should properly remove user permissions', () => { + it('in case of just a single file accessible by given user', async () => { + const userId = new ObjectId().toHexString(); + const userPermission = filePermissionEntityFactory.build({ refId: userId }); + const entity = fileEntityFactory.buildWithId({ permissions: [userPermission] }); + + repo.findByPermissionRefId.mockResolvedValueOnce([entity]); + + const result = await service.removeUserPermissionsToAnyFiles(userId); + + expect(result).toEqual(1); + expect(entity.permissions).not.toContain(userPermission); + + expect(repo.findByPermissionRefId).toBeCalledWith(userId); + expect(repo.save).toBeCalledWith([entity]); + }); + + it('in case of many files accessible by given user', async () => { + const userId = new ObjectId().toHexString(); + const userPermission = filePermissionEntityFactory.build({ refId: userId }); + const anotherUserPermission = filePermissionEntityFactory.build(); + const yetAnotherUserPermission = filePermissionEntityFactory.build(); + const entities = [ + fileEntityFactory.buildWithId({ + permissions: [userPermission, anotherUserPermission, yetAnotherUserPermission], + }), + fileEntityFactory.buildWithId({ + permissions: [yetAnotherUserPermission, userPermission, anotherUserPermission], + }), + fileEntityFactory.buildWithId({ + permissions: [anotherUserPermission, yetAnotherUserPermission, userPermission], + }), + fileEntityFactory.buildWithId({ + permissions: [userPermission, yetAnotherUserPermission, anotherUserPermission], + }), + fileEntityFactory.buildWithId({ + permissions: [yetAnotherUserPermission, anotherUserPermission, userPermission], + }), + ]; + + repo.findByPermissionRefId.mockResolvedValueOnce(entities); + + const result = await service.removeUserPermissionsToAnyFiles(userId); + + expect(result).toEqual(5); + + entities.forEach((entity) => { + expect(entity.permissions).not.toContain(userPermission); + expect(entity.permissions).toContain(anotherUserPermission); + expect(entity.permissions).toContain(yetAnotherUserPermission); + }); + + expect(repo.findByPermissionRefId).toBeCalledWith(userId); + expect(repo.save).toBeCalledWith(entities); + }); + }); + }); + + describe('markFilesOwnedByUserForDeletion', () => { + const verifyEntityChanges = (entity: FileEntity) => { + expect(entity.deleted).toEqual(true); + + const deletedAtTime = entity.deletedAt?.getTime(); + + expect(deletedAtTime).toBeGreaterThan(0); + expect(deletedAtTime).toBeLessThanOrEqual(new Date().getTime()); + }; + + it('should not mark any files for deletion if there are none owned by given user', async () => { + const userId = new ObjectId().toHexString(); + repo.findByOwnerUserId.mockResolvedValueOnce([]); + + const result = await service.markFilesOwnedByUserForDeletion(userId); + + expect(result).toEqual(0); + + expect(repo.findByOwnerUserId).toBeCalledWith(userId); + expect(repo.save).not.toBeCalled(); + }); + + describe('should properly mark files for deletion', () => { + it('in case of just a single file owned by given user', async () => { + const entity = fileEntityFactory.buildWithId(); + const userId = entity.ownerId; + repo.findByOwnerUserId.mockResolvedValueOnce([entity]); + + const result = await service.markFilesOwnedByUserForDeletion(userId); + + expect(result).toEqual(1); + verifyEntityChanges(entity); + + expect(repo.findByOwnerUserId).toBeCalledWith(userId); + expect(repo.save).toBeCalledWith([entity]); + }); + + it('in case of many files owned by the user', async () => { + const userId = new ObjectId().toHexString(); + const entities = [ + fileEntityFactory.buildWithId({ ownerId: userId }), + fileEntityFactory.buildWithId({ ownerId: userId }), + fileEntityFactory.buildWithId({ ownerId: userId }), + fileEntityFactory.buildWithId({ ownerId: userId }), + fileEntityFactory.buildWithId({ ownerId: userId }), + ]; + repo.findByOwnerUserId.mockResolvedValueOnce(entities); + + const result = await service.markFilesOwnedByUserForDeletion(userId); + + expect(result).toEqual(5); + entities.forEach((entity) => verifyEntityChanges(entity)); + + expect(repo.findByOwnerUserId).toBeCalledWith(userId); + expect(repo.save).toBeCalledWith(entities); + }); + }); + }); +}); diff --git a/apps/server/src/modules/files/service/files.service.ts b/apps/server/src/modules/files/service/files.service.ts new file mode 100644 index 00000000000..b3d3bd352cc --- /dev/null +++ b/apps/server/src/modules/files/service/files.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain'; +import { FilesRepo } from '../repo'; + +@Injectable() +export class FilesService { + constructor(private readonly repo: FilesRepo) {} + + async removeUserPermissionsToAnyFiles(userId: EntityId): Promise { + const entities = await this.repo.findByPermissionRefId(userId); + + if (entities.length === 0) { + return 0; + } + + entities.forEach((entity) => entity.removePermissionsByRefId(userId)); + + await this.repo.save(entities); + + return entities.length; + } + + async markFilesOwnedByUserForDeletion(userId: EntityId): Promise { + const entities = await this.repo.findByOwnerUserId(userId); + + if (entities.length === 0) { + return 0; + } + + entities.forEach((entity) => entity.markForDeletion()); + + await this.repo.save(entities); + + return entities.length; + } +} diff --git a/apps/server/src/modules/files/service/index.ts b/apps/server/src/modules/files/service/index.ts new file mode 100644 index 00000000000..63b518b7997 --- /dev/null +++ b/apps/server/src/modules/files/service/index.ts @@ -0,0 +1 @@ +export * from './files.service'; diff --git a/apps/server/src/modules/files/uc/delete-files.uc.spec.ts b/apps/server/src/modules/files/uc/delete-files.uc.spec.ts index fd3ada9f397..ec0187a4cc4 100644 --- a/apps/server/src/modules/files/uc/delete-files.uc.spec.ts +++ b/apps/server/src/modules/files/uc/delete-files.uc.spec.ts @@ -1,39 +1,38 @@ import { S3Client } from '@aws-sdk/client-s3'; +import { AwsClientStub, mockClient } from 'aws-sdk-client-mock'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { File, StorageProvider } from '@shared/domain/entity'; -import { FilesRepo } from '@shared/repo'; -import { StorageProviderRepo } from '@shared/repo/storageprovider/storageprovider.repo'; +import { ObjectId } from 'bson'; +import { StorageProviderRepo } from '@shared/repo/storageprovider'; import { storageProviderFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { AwsClientStub, mockClient } from 'aws-sdk-client-mock'; import { DeleteFilesUc } from './delete-files.uc'; +import { FilesRepo } from '../repo'; +import { fileEntityFactory, filePermissionEntityFactory } from '../entity/testing'; -describe('DeleteFileUC', () => { +describe(DeleteFilesUc.name, () => { let service: DeleteFilesUc; let filesRepo: DeepMocked; let storageProviderRepo: DeepMocked; let s3Mock: AwsClientStub; let logger: DeepMocked; - const exampleStorageProvider = new StorageProvider({ - endpointUrl: 'endpointUrl', - accessKeyId: 'accessKey', - secretAccessKey: 'secret', - }); + const userId = new ObjectId().toHexString(); + + const storageProvider = storageProviderFactory.build(); const exampleFiles = [ - new File({ - storageProvider: exampleStorageProvider, - storageFileName: 'file1', - bucket: 'bucket', - name: 'filename1', + fileEntityFactory.build({ + storageProvider, + ownerId: userId, + creatorId: userId, + permissions: [filePermissionEntityFactory.build({ refId: userId })], }), - new File({ - storageProvider: exampleStorageProvider, - storageFileName: 'file2', - bucket: 'bucket', - name: 'filename2', + fileEntityFactory.build({ + storageProvider, + ownerId: userId, + creatorId: userId, + permissions: [filePermissionEntityFactory.build({ refId: userId })], }), ]; @@ -82,10 +81,9 @@ describe('DeleteFileUC', () => { const setup = () => { const thresholdDate = new Date(); const batchSize = 3; - filesRepo.findFilesForCleanup.mockResolvedValueOnce(exampleFiles); - filesRepo.findFilesForCleanup.mockResolvedValueOnce([]); + filesRepo.findForCleanup.mockResolvedValueOnce(exampleFiles); + filesRepo.findForCleanup.mockResolvedValueOnce([]); - const storageProvider = storageProviderFactory.build(); storageProviderRepo.findAll.mockResolvedValueOnce([storageProvider]); return { thresholdDate, batchSize }; @@ -116,10 +114,9 @@ describe('DeleteFileUC', () => { const batchSize = 3; const error = new Error(); - filesRepo.findFilesForCleanup.mockResolvedValueOnce(exampleFiles); - filesRepo.findFilesForCleanup.mockResolvedValueOnce([]); + filesRepo.findForCleanup.mockResolvedValueOnce(exampleFiles); + filesRepo.findForCleanup.mockResolvedValueOnce([]); - const storageProvider = storageProviderFactory.build(); storageProviderRepo.findAll.mockResolvedValueOnce([storageProvider]); const spy = jest.spyOn(DeleteFilesUc.prototype as any, 'deleteFileInStorage'); diff --git a/apps/server/src/modules/files/uc/delete-files.uc.ts b/apps/server/src/modules/files/uc/delete-files.uc.ts index cbad851f46a..7c349dc1d22 100644 --- a/apps/server/src/modules/files/uc/delete-files.uc.ts +++ b/apps/server/src/modules/files/uc/delete-files.uc.ts @@ -1,10 +1,11 @@ /* eslint-disable no-await-in-loop */ import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { Injectable } from '@nestjs/common'; -import { File, StorageProvider } from '@shared/domain'; -import { FilesRepo } from '@shared/repo'; -import { StorageProviderRepo } from '@shared/repo/storageprovider/storageprovider.repo'; -import { LegacyLogger } from '@src/core/logger/legacy-logger.service'; +import { StorageProviderEntity } from '@shared/domain'; +import { StorageProviderRepo } from '@shared/repo/storageprovider'; +import { LegacyLogger } from '@src/core/logger'; +import { FilesRepo } from '../repo'; +import { FileEntity } from '../entity'; @Injectable() export class DeleteFilesUc { @@ -28,7 +29,7 @@ export class DeleteFilesUc { do { const offset = failingFileIds.length; - const files = await this.filesRepo.findFilesForCleanup(thresholdDate, batchSize, offset); + const files = await this.filesRepo.findForCleanup(thresholdDate, batchSize, offset); const promises = files.map((file) => this.deleteFile(file)); const results = await Promise.all(promises); @@ -72,7 +73,7 @@ export class DeleteFilesUc { this.logger.log(`Initialized s3ClientMap with ${this.s3ClientMap.size} clients.`); } - private createClient(storageProvider: StorageProvider): S3Client { + private createClient(storageProvider: StorageProviderEntity): S3Client { const client = new S3Client({ endpoint: storageProvider.endpointUrl, forcePathStyle: true, @@ -87,7 +88,7 @@ export class DeleteFilesUc { return client; } - private async deleteFile(file: File): Promise<{ fileId: string; success: boolean }> { + private async deleteFile(file: FileEntity): Promise<{ fileId: string; success: boolean }> { try { if (!file.isDirectory) { await this.deleteFileInStorage(file); @@ -102,12 +103,12 @@ export class DeleteFilesUc { } } - private async deleteFileInStorage(file: File) { + private async deleteFileInStorage(file: FileEntity) { const bucket = file.bucket as string; const storageFileName = file.storageFileName as string; const deletionCommand = new DeleteObjectCommand({ Bucket: bucket, Key: storageFileName }); - const storageProvider = file.storageProvider as StorageProvider; + const storageProvider = file.storageProvider as StorageProviderEntity; const client = this.s3ClientMap.get(storageProvider.id) as S3Client; await client.send(deletionCommand); diff --git a/apps/server/src/modules/management/uc/database-management.uc.spec.ts b/apps/server/src/modules/management/uc/database-management.uc.spec.ts index d20e577a9bb..713b97d2dcb 100644 --- a/apps/server/src/modules/management/uc/database-management.uc.spec.ts +++ b/apps/server/src/modules/management/uc/database-management.uc.spec.ts @@ -3,7 +3,7 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { EntityManager } from '@mikro-orm/mongodb'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { StorageProvider, System } from '@shared/domain'; +import { StorageProviderEntity, System } from '@shared/domain'; import { DatabaseManagementService } from '@shared/infra/database'; import { DefaultEncryptionService, @@ -124,7 +124,7 @@ describe('DatabaseManagementService', () => { }, }; - const storageProviderParsed: StorageProvider[] = [ + const storageProviderParsed: StorageProviderEntity[] = [ { id: '62d6ca7e769952e3f6e67925', _id: new ObjectId('62d6ca7e769952e3f6e67925'), diff --git a/apps/server/src/modules/management/uc/database-management.uc.ts b/apps/server/src/modules/management/uc/database-management.uc.ts index 00244e86e89..8505f6b87d5 100644 --- a/apps/server/src/modules/management/uc/database-management.uc.ts +++ b/apps/server/src/modules/management/uc/database-management.uc.ts @@ -2,7 +2,7 @@ import { Configuration } from '@hpi-schul-cloud/commons'; import { EntityManager } from '@mikro-orm/mongodb'; import { Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { StorageProvider, System } from '@shared/domain'; +import { StorageProviderEntity, System } from '@shared/domain'; import { DatabaseManagementService } from '@shared/infra/database'; import { DefaultEncryptionService, IEncryptionService, LdapEncryptionService } from '@shared/infra/encryption'; import { FileSystemAdapter } from '@shared/infra/file-system'; @@ -379,11 +379,11 @@ export class DatabaseManagementUc { this.removeSecretsFromSystems(jsonDocuments as System[]); } if (collectionName === storageprovidersCollectionName) { - this.removeSecretsFromStorageproviders(jsonDocuments as StorageProvider[]); + this.removeSecretsFromStorageproviders(jsonDocuments as StorageProviderEntity[]); } } - private removeSecretsFromStorageproviders(storageProviders: StorageProvider[]) { + private removeSecretsFromStorageproviders(storageProviders: StorageProviderEntity[]) { storageProviders.forEach((storageProvider) => { storageProvider.accessKeyId = defaultSecretReplacementHintText; storageProvider.secretAccessKey = defaultSecretReplacementHintText; diff --git a/apps/server/src/modules/task/service/task-copy.service.spec.ts b/apps/server/src/modules/task/service/task-copy.service.spec.ts index 13cea99731d..a54ac5ca002 100644 --- a/apps/server/src/modules/task/service/task-copy.service.spec.ts +++ b/apps/server/src/modules/task/service/task-copy.service.spec.ts @@ -4,12 +4,12 @@ import { Task } from '@shared/domain/entity'; import { TaskRepo } from '@shared/repo'; import { courseFactory, - fileFactory, lessonFactory, schoolFactory, setupEntities, taskFactory, userFactory, + legacyFileEntityMockFactory, } from '@shared/testing'; import { CopyElementType, CopyHelperService, CopyStatusEnum } from '@src/modules/copy-helper'; import { CopyFilesService } from '@src/modules/files-storage-client'; @@ -410,8 +410,8 @@ describe('task copy service', () => { const setupWithFiles = () => { const school = schoolFactory.build(); - const file1 = fileFactory.buildWithId({ name: 'file1.jpg' }); - const file2 = fileFactory.buildWithId({ name: 'file2.jpg' }); + const file1 = legacyFileEntityMockFactory.build(); + const file2 = legacyFileEntityMockFactory.build(); const imageHTML1 = getImageHTML(file1.id, file1.name); const imageHTML2 = getImageHTML(file2.id, file2.name); diff --git a/apps/server/src/shared/domain/entity/all-entities.ts b/apps/server/src/shared/domain/entity/all-entities.ts index d025133f6b0..97069d23f8e 100644 --- a/apps/server/src/shared/domain/entity/all-entities.ts +++ b/apps/server/src/shared/domain/entity/all-entities.ts @@ -20,7 +20,6 @@ import { Course } from './course.entity'; import { CourseGroup } from './coursegroup.entity'; import { DashboardGridElementModel, DashboardModelEntity } from './dashboard.model.entity'; import { FederalState } from './federal-state.entity'; -import { File } from './file.entity'; import { ImportUser } from './import-user.entity'; import { Board, @@ -37,7 +36,7 @@ import { CourseNews, News, SchoolNews, TeamNews } from './news.entity'; import { Role } from './role.entity'; import { School, SchoolRolePermission, SchoolRoles } from './school.entity'; import { SchoolYear } from './schoolyear.entity'; -import { StorageProvider } from './storageprovider.entity'; +import { StorageProviderEntity } from './storageprovider.entity'; import { Submission } from './submission.entity'; import { System } from './system.entity'; import { Task } from './task.entity'; @@ -69,7 +68,6 @@ export const ALL_ENTITIES = [ DashboardModelEntity, ExternalToolEntity, FederalState, - File, ImportUser, Lesson, LessonBoardElement, @@ -86,7 +84,7 @@ export const ALL_ENTITIES = [ SchoolRoles, SchoolYear, ShareToken, - StorageProvider, + StorageProviderEntity, Submission, System, Task, diff --git a/apps/server/src/shared/domain/entity/file.entity.spec.ts b/apps/server/src/shared/domain/entity/file.entity.spec.ts deleted file mode 100644 index 1a884b9a3fa..00000000000 --- a/apps/server/src/shared/domain/entity/file.entity.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { setupEntities, storageProviderFactory } from '@shared/testing'; -import { File } from './file.entity'; - -describe('file entity', () => { - beforeAll(async () => { - await setupEntities(); - }); - - describe('constructor', () => { - describe('when creating a file (non-directory)', () => { - it('should create file', () => { - const storageProvider = storageProviderFactory.build(); - const file = new File({ name: 'name', storageProvider, bucket: 'bucket', storageFileName: 'name' }); - expect(file).toBeInstanceOf(File); - }); - it('should throw without bucket', () => { - const storageProvider = storageProviderFactory.build(); - const call = () => new File({ name: 'name', storageProvider, storageFileName: 'name' }); - expect(call).toThrow(); - }); - - it('should throw without storageFileName', () => { - const storageProvider = storageProviderFactory.build(); - const call = () => new File({ name: 'name', storageProvider, bucket: 'bucket' }); - expect(call).toThrow(); - }); - - it('should throw without storageProvider', () => { - const call = () => new File({ name: 'name', bucket: 'bucket', storageFileName: 'name' }); - expect(call).toThrow(); - }); - }); - }); -}); diff --git a/apps/server/src/shared/domain/entity/file.entity.ts b/apps/server/src/shared/domain/entity/file.entity.ts deleted file mode 100644 index ca8b84a4ccb..00000000000 --- a/apps/server/src/shared/domain/entity/file.entity.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Entity, Index, ManyToOne, Property } from '@mikro-orm/core'; -import { BaseEntityWithTimestamps } from './base.entity'; -import { StorageProvider } from './storageprovider.entity'; -import { User } from './user.entity'; - -export interface IFileProperties { - deletedAt?: Date; - storageFileName?: string; - bucket?: string; - storageProvider?: StorageProvider; - creator?: User; - name: string; - isDirectory?: boolean; -} - -@Entity({ collection: 'files' }) -@Index({ properties: ['shareTokens'] }) -export class File extends BaseEntityWithTimestamps { - constructor(props: IFileProperties) { - super(); - this.validate(props); - - this.isDirectory = props.isDirectory || false; - this.deletedAt = props.deletedAt; - this.storageFileName = props.storageFileName; - this.bucket = props.bucket; - this.storageProvider = props.storageProvider; - this.creator = props.creator; - this.name = props.name; - } - - private validate(props: IFileProperties) { - if (props.isDirectory) return; - if (!props.bucket || !props.storageFileName || !props.storageProvider) { - throw new Error('files that are not directories always need a bucket, a storageFilename, and a storageProvider.'); - } - } - - @Property({ nullable: true }) - deletedAt?: Date; - - @Property() - isDirectory: boolean; - - @Property() - name: string; - - @Property({ nullable: true }) - shareTokens?: string[]; - - @ManyToOne('User', { nullable: true }) - creator?: User; - - @Property({ nullable: true }) - storageFileName?: string; // not for directories - - @Property({ nullable: true }) - bucket?: string; // not for directories - - @ManyToOne('StorageProvider', { fieldName: 'storageProviderId', nullable: true }) - storageProvider?: StorageProvider; // not for directories -} diff --git a/apps/server/src/shared/domain/entity/index.ts b/apps/server/src/shared/domain/entity/index.ts index 5c8ff64a9de..91acc0ecb77 100644 --- a/apps/server/src/shared/domain/entity/index.ts +++ b/apps/server/src/shared/domain/entity/index.ts @@ -7,7 +7,6 @@ export * from './coursegroup.entity'; export * from './dashboard.entity'; export * from './dashboard.model.entity'; export * from './federal-state.entity'; -export * from './file.entity'; export * from './import-user.entity'; export * from './legacy-board'; export * from './lesson.entity'; diff --git a/apps/server/src/shared/domain/entity/storageprovider.entity.ts b/apps/server/src/shared/domain/entity/storageprovider.entity.ts index fe978389425..7bac9b3380a 100644 --- a/apps/server/src/shared/domain/entity/storageprovider.entity.ts +++ b/apps/server/src/shared/domain/entity/storageprovider.entity.ts @@ -10,7 +10,7 @@ export interface IStorageProviderProperties { } @Entity({ tableName: 'storageproviders' }) -export class StorageProvider extends BaseEntityWithTimestamps { +export class StorageProviderEntity extends BaseEntityWithTimestamps { @Property() endpointUrl: string; diff --git a/apps/server/src/shared/domain/entity/submission.entity.spec.ts b/apps/server/src/shared/domain/entity/submission.entity.spec.ts index 78eff9a393b..f65eea0b2c9 100644 --- a/apps/server/src/shared/domain/entity/submission.entity.spec.ts +++ b/apps/server/src/shared/domain/entity/submission.entity.spec.ts @@ -2,7 +2,6 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { InternalServerErrorException } from '@nestjs/common'; import { courseGroupFactory, - fileFactory, schoolFactory, setupEntities, submissionFactory, @@ -25,10 +24,9 @@ describe('Submission entity', () => { const school = schoolFactory.build(); const student = userFactory.build(); const task = taskFactory.buildWithId(); - const file = fileFactory.buildWithId(); const teamMember = userFactory.build(); - return { school, student, task, file, teamMember }; + return { school, student, task, teamMember }; }; describe('when required pros are set', () => { diff --git a/apps/server/src/shared/repo/files/files.repo.spec.ts b/apps/server/src/shared/repo/files/files.repo.spec.ts deleted file mode 100644 index 77782da7fae..00000000000 --- a/apps/server/src/shared/repo/files/files.repo.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-expressions */ -import { EntityManager } from '@mikro-orm/mongodb'; -import { Test, TestingModule } from '@nestjs/testing'; -import { File } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; -import { fileFactory } from '@shared/testing'; -import { FilesRepo } from './files.repo'; - -describe('FilesRepo', () => { - let repo: FilesRepo; - let em: EntityManager; - let module: TestingModule; - - beforeAll(async () => { - module = await Test.createTestingModule({ - imports: [MongoMemoryDatabaseModule.forRoot()], - providers: [FilesRepo], - }).compile(); - - repo = module.get(FilesRepo); - em = module.get(EntityManager); - }); - - beforeEach(async () => { - await em.nativeDelete(File, {}); - }); - - afterAll(async () => { - await module.close(); - }); - - describe('defined', () => { - it('repo should be defined', () => { - expect(repo).toBeDefined(); - }); - - it('entity manager should be defined', () => { - expect(em).toBeDefined(); - }); - - it('should implement entityName getter', () => { - expect(repo.entityName).toBe(File); - }); - }); - - describe('findAllFilesForCleanup', () => { - it('should return files marked for deletion', async () => { - const file = fileFactory.build({ deletedAt: new Date() }); - await em.persistAndFlush(file); - em.clear(); - - const thresholdDate = new Date(); - - const result = await repo.findFilesForCleanup(thresholdDate, 3, 0); - - expect(result.length).toEqual(1); - expect(result[0].id).toEqual(file.id); - }); - - it('should not return files which are not marked for deletion', async () => { - const file = fileFactory.build({ deletedAt: undefined }); - await em.persistAndFlush(file); - const thresholdDate = new Date(); - em.clear(); - - const result = await repo.findFilesForCleanup(thresholdDate, 3, 0); - expect(result.length).toEqual(0); - }); - - it('should not return files where deletedAt is after threshold', async () => { - const thresholdDate = new Date(); - const file = fileFactory.build({ deletedAt: new Date(thresholdDate.getTime() + 10) }); - await em.persistAndFlush(file); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - expect(file.deletedAt!.getTime()).toBeGreaterThan(thresholdDate.getTime()); - em.clear(); - - const result = await repo.findFilesForCleanup(thresholdDate, 3, 0); - expect(result.length).toEqual(0); - }); - }); -}); diff --git a/apps/server/src/shared/repo/files/files.repo.ts b/apps/server/src/shared/repo/files/files.repo.ts deleted file mode 100644 index ce0a8dd76dd..00000000000 --- a/apps/server/src/shared/repo/files/files.repo.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { EntityManager } from '@mikro-orm/mongodb'; -import { Injectable } from '@nestjs/common'; -import { File } from '@shared/domain'; -import { BaseRepo } from '../base.repo'; - -@Injectable() -export class FilesRepo extends BaseRepo { - constructor(protected readonly _em: EntityManager) { - super(_em); - } - - get entityName() { - return File; - } - - public async findFilesForCleanup(thresholdDate: Date, batchSize: number, offset: number): Promise { - const query = { deletedAt: { $lte: thresholdDate } }; - const options = { orderBy: { id: 'asc' }, limit: batchSize, offset, populate: ['storageProvider'] as never[] }; - const files = await this._em.find(File, query, options); - - return files; - } -} diff --git a/apps/server/src/shared/repo/index.ts b/apps/server/src/shared/repo/index.ts index 059ab24bd18..715a78ee420 100644 --- a/apps/server/src/shared/repo/index.ts +++ b/apps/server/src/shared/repo/index.ts @@ -11,7 +11,6 @@ export * from './course'; export * from './coursegroup'; export * from './dashboard'; export * from './federalstate'; -export * from './files'; export * from './importuser'; export * from './lesson'; export * from './ltitool'; diff --git a/apps/server/src/shared/repo/storageprovider/index.ts b/apps/server/src/shared/repo/storageprovider/index.ts new file mode 100644 index 00000000000..55860cab08d --- /dev/null +++ b/apps/server/src/shared/repo/storageprovider/index.ts @@ -0,0 +1 @@ +export * from './storageprovider.repo'; diff --git a/apps/server/src/shared/repo/storageprovider/storageprovider.repo.spec.ts b/apps/server/src/shared/repo/storageprovider/storageprovider.repo.spec.ts index 6941e73fcf1..707ff1b778d 100644 --- a/apps/server/src/shared/repo/storageprovider/storageprovider.repo.spec.ts +++ b/apps/server/src/shared/repo/storageprovider/storageprovider.repo.spec.ts @@ -1,6 +1,6 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { StorageProvider } from '@shared/domain'; +import { StorageProviderEntity } from '@shared/domain'; import { MongoMemoryDatabaseModule } from '@shared/infra/database'; import { cleanupCollections, storageProviderFactory } from '@shared/testing'; import { StorageProviderRepo } from './storageprovider.repo'; @@ -30,7 +30,7 @@ describe('StorageProviderRepo', () => { }); it('should implement entityName getter', () => { - expect(repo.entityName).toBe(StorageProvider); + expect(repo.entityName).toBe(StorageProviderEntity); }); describe('findAll', () => { diff --git a/apps/server/src/shared/repo/storageprovider/storageprovider.repo.ts b/apps/server/src/shared/repo/storageprovider/storageprovider.repo.ts index 4f8f151aade..010c2603a9f 100644 --- a/apps/server/src/shared/repo/storageprovider/storageprovider.repo.ts +++ b/apps/server/src/shared/repo/storageprovider/storageprovider.repo.ts @@ -1,20 +1,20 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; -import { StorageProvider } from '@shared/domain'; +import { StorageProviderEntity } from '@shared/domain'; import { BaseRepo } from '../base.repo'; @Injectable() -export class StorageProviderRepo extends BaseRepo { +export class StorageProviderRepo extends BaseRepo { constructor(protected readonly _em: EntityManager) { super(_em); } get entityName() { - return StorageProvider; + return StorageProviderEntity; } - async findAll(): Promise { - const providers = this._em.find(StorageProvider, {}); + async findAll(): Promise { + const providers = this._em.find(StorageProviderEntity, {}); return providers; } diff --git a/apps/server/src/shared/testing/factory/file.factory.ts b/apps/server/src/shared/testing/factory/file.factory.ts deleted file mode 100644 index dd3179fd9b8..00000000000 --- a/apps/server/src/shared/testing/factory/file.factory.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { File, IFileProperties } from '@shared/domain'; -import { BaseFactory } from './base.factory'; -import { storageProviderFactory } from './storageprovider.factory'; -import { userFactory } from './user.factory'; - -export const fileFactory = BaseFactory.define(File, ({ sequence }) => { - return { - storageFileName: `file-${sequence}`, - bucket: 'test-bucket', - storageProvider: storageProviderFactory.build(), - isDirectory: false, - creator: userFactory.build(), - name: `file-${sequence}`, - }; -}); - -export const directoryFactory = BaseFactory.define(File, ({ sequence }) => { - return { - isDirectory: true, - creator: userFactory.build(), - name: `directory-${sequence}`, - }; -}); diff --git a/apps/server/src/shared/testing/factory/filerecord.factory.ts b/apps/server/src/shared/testing/factory/filerecord.factory.ts index 110fa217b6d..4a0c73966fd 100644 --- a/apps/server/src/shared/testing/factory/filerecord.factory.ts +++ b/apps/server/src/shared/testing/factory/filerecord.factory.ts @@ -1,9 +1,5 @@ import { FileRecordParentType } from '@shared/infra/rabbitmq'; -import { - FileRecord, - FileSecurityCheck, - IFileRecordProperties, -} from '@src/modules/files-storage/entity/filerecord.entity'; +import { FileRecord, FileRecordSecurityCheck, IFileRecordProperties } from '@src/modules/files-storage/entity'; import { ObjectId } from 'bson'; import { DeepPartial } from 'fishery'; import { BaseFactory } from './base.factory'; @@ -22,7 +18,7 @@ export const fileRecordFactory = FileRecordFactory.define(FileRecord, ({ sequenc size: Math.round(Math.random() * 100000), name: `file-record #${sequence}`, mimeType: 'application/octet-stream', - securityCheck: new FileSecurityCheck({}), + securityCheck: new FileRecordSecurityCheck({}), parentType: FileRecordParentType.Course, parentId: new ObjectId().toHexString(), creatorId: new ObjectId().toHexString(), diff --git a/apps/server/src/shared/testing/factory/index.ts b/apps/server/src/shared/testing/factory/index.ts index 70c87fd46fb..d981b4ca29c 100644 --- a/apps/server/src/shared/testing/factory/index.ts +++ b/apps/server/src/shared/testing/factory/index.ts @@ -13,7 +13,6 @@ export * from './external-group-dto.factory'; export * from './external-tool-entity.factory'; export * from './external-tool-pseudonym.factory'; export * from './federal-state.factory'; -export * from './file.factory'; export * from './filerecord.factory'; export * from './group-entity.factory'; export * from './import-user.factory'; @@ -36,3 +35,4 @@ export * from './user-and-account.test.factory'; export * from './user-login-migration.factory'; export * from './user.do.factory'; export * from './user.factory'; +export * from './legacy-file-entity-mock.factory'; diff --git a/apps/server/src/shared/testing/factory/legacy-file-entity-mock.factory.ts b/apps/server/src/shared/testing/factory/legacy-file-entity-mock.factory.ts new file mode 100644 index 00000000000..9b28db96b03 --- /dev/null +++ b/apps/server/src/shared/testing/factory/legacy-file-entity-mock.factory.ts @@ -0,0 +1,9 @@ +import { Factory } from 'fishery'; +import { ObjectId } from '@mikro-orm/mongodb'; + +export const legacyFileEntityMockFactory = Factory.define<{ id: string; name: string }>(({ sequence }) => { + return { + id: new ObjectId().toHexString(), + name: `file-${sequence}.jpg`, + }; +}); diff --git a/apps/server/src/shared/testing/factory/storageprovider.factory.ts b/apps/server/src/shared/testing/factory/storageprovider.factory.ts index 7cbe270bab3..d0ad53be107 100644 --- a/apps/server/src/shared/testing/factory/storageprovider.factory.ts +++ b/apps/server/src/shared/testing/factory/storageprovider.factory.ts @@ -1,8 +1,8 @@ -import { StorageProvider, IStorageProviderProperties } from '@shared/domain'; +import { StorageProviderEntity, IStorageProviderProperties } from '@shared/domain'; import { BaseFactory } from './base.factory'; -export const storageProviderFactory = BaseFactory.define( - StorageProvider, +export const storageProviderFactory = BaseFactory.define( + StorageProviderEntity, () => { return { endpointUrl: 'http://localhost',