From 6f7f09cc8f637552c42ca49b2b53891c6c830740 Mon Sep 17 00:00:00 2001 From: Igor Richter <93926487+IgorCapCoder@users.noreply.github.com> Date: Fri, 6 Oct 2023 07:45:32 +0200 Subject: [PATCH 01/10] N21 1309 fix empty class sync (#4443) * handle empty, null and undefined lists * change scope of unique members assertion to fix current classes --- .../strategies/consumerActions/ClassAction.js | 14 ++-- .../sync/repo/user.repo.integration.test.js | 72 +++++++++++++------ .../consumerActions/ClassAction.test.js | 48 +++++++++++++ 3 files changed, 105 insertions(+), 29 deletions(-) diff --git a/src/services/sync/strategies/consumerActions/ClassAction.js b/src/services/sync/strategies/consumerActions/ClassAction.js index 3bf1b1329f6..c688600375e 100644 --- a/src/services/sync/strategies/consumerActions/ClassAction.js +++ b/src/services/sync/strategies/consumerActions/ClassAction.js @@ -90,14 +90,16 @@ class ClassAction extends BaseConsumerAction { const teachers = []; const ldapDns = !Array.isArray(uniqueMembers) ? [uniqueMembers] : uniqueMembers; - const users = await UserRepo.findByLdapDnsAndSchool(ldapDns, schoolId); + if (ldapDns[0]) { + const users = await UserRepo.findByLdapDnsAndSchool(ldapDns, schoolId); - users.forEach((user) => { - user.roles.forEach((role) => { - if (role.name === 'student') students.push(user._id); - if (role.name === 'teacher') teachers.push(user._id); + users.forEach((user) => { + user.roles.forEach((role) => { + if (role.name === 'student') students.push(user._id); + if (role.name === 'teacher') teachers.push(user._id); + }); }); - }); + } await ClassRepo.updateClassStudents(classId, students); await ClassRepo.updateClassTeachers(classId, teachers); diff --git a/test/services/sync/repo/user.repo.integration.test.js b/test/services/sync/repo/user.repo.integration.test.js index eecb30e9e3c..199a114de6a 100644 --- a/test/services/sync/repo/user.repo.integration.test.js +++ b/test/services/sync/repo/user.repo.integration.test.js @@ -188,45 +188,71 @@ describe('user repo', () => { }); describe('findByLdapDnsAndSchool', () => { - it('should return empty list if not found', async () => { - const testSchool = await testObjects.createTestSchool(); - const res = await UserRepo.findByLdapDnsAndSchool('Not existed dn', testSchool._id); + const setup = async () => { + const ldapDn = 'TEST_LDAP_DN'; + const ldapDn2 = 'TEST_LDAP_DN2'; + const previousLdapDn = 'PREVIOUS_LDAP_DN'; + const notExistingLdapDn = 'NOT_EXISTING_LDAP_DN'; + const ldapDns = [ldapDn, ldapDn2]; + + const school = await testObjects.createTestSchool(); + + const migratedUser = await testObjects.createTestUser({ + previousExternalId: previousLdapDn, + schoolId: school._id, + ldapDn: 'NEW_ID', + }); + const createdUsers = [ + await testObjects.createTestUser({ ldapDn, schoolId: school._id }), + await testObjects.createTestUser({ ldapDn2, schoolId: school._id }), + ]; + + return { + ldapDns, + notExistingLdapDn, + previousLdapDn, + migratedUser, + createdUsers, + school, + }; + }; + + it('should return empty list if user with ldapDn does not exist', async () => { + const { school, notExistingLdapDn } = await setup(); + + const res = await UserRepo.findByLdapDnsAndSchool([notExistingLdapDn], school._id); + + expect(res).to.eql([]); + }); + + it('should return empty list if ldapDns are empty', async () => { + const { school } = await setup(); + + const res = await UserRepo.findByLdapDnsAndSchool([], school._id); + expect(res).to.eql([]); }); it('should find user by ldap dn and school', async () => { - const ldapDns = ['TEST_LDAP_DN', 'TEST_LDAP_DN2']; - const school = await testObjects.createTestSchool(); - const createdUsers = await Promise.all( - ldapDns.map((ldapDn) => testObjects.createTestUser({ ldapDn, schoolId: school._id })) - ); + const { school, ldapDns, createdUsers } = await setup(); + const res = await UserRepo.findByLdapDnsAndSchool(ldapDns, school._id); + const user1 = res.filter((user) => createdUsers[0]._id.toString() === user._id.toString()); const user2 = res.filter((user) => createdUsers[1]._id.toString() === user._id.toString()); + expect(user1).not.to.be.undefined; expect(user2).not.to.be.undefined; }); describe('when the user has migrated', () => { - const setup = async () => { - const ldapDn = 'TEST_LDAP_DN'; - const school = await testObjects.createTestSchool(); - const user = await testObjects.createTestUser({ previousExternalId: ldapDn, schoolId: school._id }); - - return { - ldapDn, - user, - school, - }; - }; - it('should find the user by its old ldap dn and school', async () => { - const { ldapDn, school, user } = await setup(); + const { previousLdapDn, school, migratedUser } = await setup(); - const res = await UserRepo.findByLdapDnsAndSchool([ldapDn], school._id); + const res = await UserRepo.findByLdapDnsAndSchool([previousLdapDn], school._id); expect(res.length).to.equal(1); - expect(res[0]._id.toString()).to.equal(user._id.toString()); + expect(res[0]._id.toString()).to.equal(migratedUser._id.toString()); }); }); }); diff --git a/test/services/sync/strategies/consumerActions/ClassAction.test.js b/test/services/sync/strategies/consumerActions/ClassAction.test.js index 4b450df5622..efbd48da37b 100644 --- a/test/services/sync/strategies/consumerActions/ClassAction.test.js +++ b/test/services/sync/strategies/consumerActions/ClassAction.test.js @@ -342,5 +342,53 @@ describe('Class Actions', () => { expect(updateClassTeachersStub.getCall(0).firstArg.toString()).to.be.equal(mockClass._id.toString()); expect(updateClassTeachersStub.getCall(0).lastArg).to.eql(['user2', 'user3']); }); + + it('should not add any user to the class, when uniqueMembers are []', async () => { + const uniqueMembers = []; + const schoolObj = { _id: new ObjectId(), currentYear: new ObjectId() }; + const findByLdapDnsAndSchoolStub = sinon.stub(UserRepo, 'findByLdapDnsAndSchool'); + + await classAction.addUsersToClass(schoolObj._id, mockClass._id, uniqueMembers); + + expect(findByLdapDnsAndSchoolStub.notCalled).to.be.true; + + expect(updateClassStudentsStub.getCall(0).firstArg.toString()).to.be.equal(mockClass._id.toString()); + expect(updateClassStudentsStub.getCall(0).lastArg).to.eql([]); + + expect(updateClassTeachersStub.getCall(0).firstArg.toString()).to.be.equal(mockClass._id.toString()); + expect(updateClassTeachersStub.getCall(0).lastArg).to.eql([]); + }); + + it('should not add any user to the class, when uniqueMembers are [undefined]', async () => { + const uniqueMembers = [undefined]; + const schoolObj = { _id: new ObjectId(), currentYear: new ObjectId() }; + const findByLdapDnsAndSchoolStub = sinon.stub(UserRepo, 'findByLdapDnsAndSchool'); + + await classAction.addUsersToClass(schoolObj._id, mockClass._id, uniqueMembers); + + expect(findByLdapDnsAndSchoolStub.notCalled).to.be.true; + + expect(updateClassStudentsStub.getCall(0).firstArg.toString()).to.be.equal(mockClass._id.toString()); + expect(updateClassStudentsStub.getCall(0).lastArg).to.eql([]); + + expect(updateClassTeachersStub.getCall(0).firstArg.toString()).to.be.equal(mockClass._id.toString()); + expect(updateClassTeachersStub.getCall(0).lastArg).to.eql([]); + }); + + it('should not add any user to the class, when uniqueMembers are [null]', async () => { + const uniqueMembers = [null]; + const schoolObj = { _id: new ObjectId(), currentYear: new ObjectId() }; + const findByLdapDnsAndSchoolStub = sinon.stub(UserRepo, 'findByLdapDnsAndSchool'); + + await classAction.addUsersToClass(schoolObj._id, mockClass._id, uniqueMembers); + + expect(findByLdapDnsAndSchoolStub.notCalled).to.be.true; + + expect(updateClassStudentsStub.getCall(0).firstArg.toString()).to.be.equal(mockClass._id.toString()); + expect(updateClassStudentsStub.getCall(0).lastArg).to.eql([]); + + expect(updateClassTeachersStub.getCall(0).firstArg.toString()).to.be.equal(mockClass._id.toString()); + expect(updateClassTeachersStub.getCall(0).lastArg).to.eql([]); + }); }); }); From d3a0975f93081e0b9346bd6b045e6352b3911144 Mon Sep 17 00:00:00 2001 From: Igor Richter <93926487+IgorCapCoder@users.noreply.github.com> Date: Fri, 6 Oct 2023 09:27:28 +0200 Subject: [PATCH 02/10] N21 1212 remove user from group (#4454) * remove user from group: * remove emptx groups --- .../src/modules/group/domain/group.spec.ts | 138 ++++++++++++ apps/server/src/modules/group/domain/group.ts | 10 +- .../src/modules/group/repo/group.repo.spec.ts | 68 +++++- .../src/modules/group/repo/group.repo.ts | 17 +- .../group/service/group.service.spec.ts | 47 +++- .../modules/group/service/group.service.ts | 8 +- .../strategy/oidc/oidc.strategy.spec.ts | 28 ++- .../strategy/oidc/oidc.strategy.ts | 6 +- .../service/oidc-provisioning.service.spec.ts | 202 +++++++++++++++++- .../oidc/service/oidc-provisioning.service.ts | 40 ++++ .../not-found.loggable-exception.ts | 3 +- 11 files changed, 553 insertions(+), 14 deletions(-) create mode 100644 apps/server/src/modules/group/domain/group.spec.ts diff --git a/apps/server/src/modules/group/domain/group.spec.ts b/apps/server/src/modules/group/domain/group.spec.ts new file mode 100644 index 00000000000..b5ae8a03321 --- /dev/null +++ b/apps/server/src/modules/group/domain/group.spec.ts @@ -0,0 +1,138 @@ +import { groupFactory, roleFactory, userDoFactory } from '@shared/testing'; + +import { ObjectId } from 'bson'; +import { RoleReference, UserDO } from '@shared/domain'; +import { Group } from './group'; +import { GroupUser } from './group-user'; + +describe('Group (Domain Object)', () => { + describe('removeUser', () => { + describe('when the user is in the group', () => { + const setup = () => { + const user: UserDO = userDoFactory.buildWithId(); + const groupUser1 = new GroupUser({ + userId: user.id as string, + roleId: new ObjectId().toHexString(), + }); + const groupUser2 = new GroupUser({ + userId: new ObjectId().toHexString(), + roleId: new ObjectId().toHexString(), + }); + const group: Group = groupFactory.build({ + users: [groupUser1, groupUser2], + }); + + return { + user, + groupUser1, + groupUser2, + group, + }; + }; + + it('should remove the user', () => { + const { user, group, groupUser1 } = setup(); + + group.removeUser(user); + + expect(group.users).not.toContain(groupUser1); + }); + + it('should keep all other users', () => { + const { user, group, groupUser2 } = setup(); + + group.removeUser(user); + + expect(group.users).toContain(groupUser2); + }); + }); + + describe('when the user is not in the group', () => { + const setup = () => { + const user: UserDO = userDoFactory.buildWithId(); + const groupUser2 = new GroupUser({ + userId: new ObjectId().toHexString(), + roleId: new ObjectId().toHexString(), + }); + const group: Group = groupFactory.build({ + users: [groupUser2], + }); + + return { + user, + groupUser2, + group, + }; + }; + + it('should do nothing', () => { + const { user, group, groupUser2 } = setup(); + + group.removeUser(user); + + expect(group.users).toEqual([groupUser2]); + }); + }); + + describe('when the group is empty', () => { + const setup = () => { + const user: UserDO = userDoFactory.buildWithId(); + const group: Group = groupFactory.build({ users: [] }); + + return { + user, + group, + }; + }; + + it('should stay empty', () => { + const { user, group } = setup(); + + group.removeUser(user); + + expect(group.users).toEqual([]); + }); + }); + }); + + describe('isEmpty', () => { + describe('when no users in group exist', () => { + const setup = () => { + const group: Group = groupFactory.build({ users: [] }); + + return { + group, + }; + }; + + it('should return true', () => { + const { group } = setup(); + + const isEmpty = group.isEmpty(); + + expect(isEmpty).toEqual(true); + }); + }); + + describe('when users in group exist', () => { + const setup = () => { + const externalUserId = 'externalUserId'; + const role: RoleReference = roleFactory.buildWithId(); + const user: UserDO = userDoFactory.buildWithId({ roles: [role], externalId: externalUserId }); + const group: Group = groupFactory.build({ users: [{ userId: user.id as string, roleId: role.id }] }); + + return { + group, + }; + }; + + it('should return false', () => { + const { group } = setup(); + + const isEmpty = group.isEmpty(); + + expect(isEmpty).toEqual(false); + }); + }); + }); +}); diff --git a/apps/server/src/modules/group/domain/group.ts b/apps/server/src/modules/group/domain/group.ts index cbc5a416ffe..049043618ac 100644 --- a/apps/server/src/modules/group/domain/group.ts +++ b/apps/server/src/modules/group/domain/group.ts @@ -1,4 +1,4 @@ -import { EntityId, ExternalSource } from '@shared/domain'; +import { EntityId, ExternalSource, type UserDO } from '@shared/domain'; import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; import { GroupTypes } from './group-types'; import { GroupUser } from './group-user'; @@ -37,4 +37,12 @@ export class Group extends DomainObject { get organizationId(): string | undefined { return this.props.organizationId; } + + removeUser(user: UserDO): void { + this.props.users = this.props.users.filter((groupUser: GroupUser): boolean => groupUser.userId !== user.id); + } + + isEmpty(): boolean { + return this.props.users.length === 0; + } } diff --git a/apps/server/src/modules/group/repo/group.repo.spec.ts b/apps/server/src/modules/group/repo/group.repo.spec.ts index 6b7c9daf741..358b3c13983 100644 --- a/apps/server/src/modules/group/repo/group.repo.spec.ts +++ b/apps/server/src/modules/group/repo/group.repo.spec.ts @@ -1,8 +1,16 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { ExternalSource, SchoolEntity } from '@shared/domain'; +import { ExternalSource, SchoolEntity, UserDO, User } from '@shared/domain'; import { MongoMemoryDatabaseModule } from '@shared/infra/database'; -import { cleanupCollections, groupEntityFactory, groupFactory, schoolFactory } from '@shared/testing'; +import { + cleanupCollections, + groupEntityFactory, + groupFactory, + roleFactory, + schoolFactory, + userDoFactory, + userFactory, +} from '@shared/testing'; import { Group, GroupProps, GroupTypes, GroupUser } from '../domain'; import { GroupEntity, GroupEntityTypes } from '../entity'; import { GroupRepo } from './group.repo'; @@ -82,6 +90,62 @@ describe('GroupRepo', () => { }); }); + describe('findByUser', () => { + describe('when the user has groups', () => { + const setup = async () => { + const userEntity: User = userFactory.buildWithId(); + const user: UserDO = userDoFactory.build({ id: userEntity.id }); + const groups: GroupEntity[] = groupEntityFactory.buildListWithId(3, { + users: [{ user: userEntity, role: roleFactory.buildWithId() }], + }); + + const otherGroups: GroupEntity[] = groupEntityFactory.buildListWithId(2); + + await em.persistAndFlush([userEntity, ...groups, ...otherGroups]); + em.clear(); + + return { + user, + groups, + }; + }; + + it('should return the groups', async () => { + const { user, groups } = await setup(); + + const result: Group[] = await repo.findByUser(user); + + expect(result.map((group) => group.id).sort((a, b) => a.localeCompare(b))).toEqual( + groups.map((group) => group.id).sort((a, b) => a.localeCompare(b)) + ); + }); + }); + + describe('when the user has no groups exists', () => { + const setup = async () => { + const userEntity: User = userFactory.buildWithId(); + const user: UserDO = userDoFactory.build({ id: userEntity.id }); + + const otherGroups: GroupEntity[] = groupEntityFactory.buildListWithId(2); + + await em.persistAndFlush([userEntity, ...otherGroups]); + em.clear(); + + return { + user, + }; + }; + + it('should return an empty array', async () => { + const { user } = await setup(); + + const result: Group[] = await repo.findByUser(user); + + expect(result).toHaveLength(0); + }); + }); + }); + describe('findClassesForSchool', () => { describe('when groups of type class for the school exist', () => { const setup = async () => { diff --git a/apps/server/src/modules/group/repo/group.repo.ts b/apps/server/src/modules/group/repo/group.repo.ts index 2c920b9a39d..920647ee7e8 100644 --- a/apps/server/src/modules/group/repo/group.repo.ts +++ b/apps/server/src/modules/group/repo/group.repo.ts @@ -1,6 +1,7 @@ -import { EntityManager } from '@mikro-orm/mongodb'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; +import { type UserDO } from '@shared/domain'; import { Group, GroupProps } from '../domain'; import { GroupEntity, GroupEntityProps, GroupEntityTypes } from '../entity'; import { GroupDomainMapper } from './group-domain.mapper'; @@ -42,6 +43,20 @@ export class GroupRepo { return domainObject; } + public async findByUser(user: UserDO): Promise { + const entities: GroupEntity[] = await this.em.find(GroupEntity, { + users: { user: new ObjectId(user.id) }, + }); + + const domainObjects = entities.map((entity) => { + const props: GroupProps = GroupDomainMapper.mapEntityToDomainObjectProperties(entity); + + return new Group(props); + }); + + return domainObjects; + } + public async findClassesForSchool(schoolId: EntityId): Promise { const entities: GroupEntity[] = await this.em.find(GroupEntity, { type: GroupEntityTypes.CLASS, diff --git a/apps/server/src/modules/group/service/group.service.spec.ts b/apps/server/src/modules/group/service/group.service.spec.ts index 71cc9eaeb6a..19c66f266ee 100644 --- a/apps/server/src/modules/group/service/group.service.spec.ts +++ b/apps/server/src/modules/group/service/group.service.spec.ts @@ -2,7 +2,8 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; -import { groupFactory } from '@shared/testing'; +import { groupFactory, userDoFactory } from '@shared/testing'; +import { UserDO } from '@shared/domain'; import { Group } from '../domain'; import { GroupRepo } from '../repo'; import { GroupService } from './group.service'; @@ -120,6 +121,50 @@ describe('GroupService', () => { }); }); + describe('findByUser', () => { + describe('when groups with the user exists', () => { + const setup = () => { + const user: UserDO = userDoFactory.buildWithId(); + const groups: Group[] = groupFactory.buildList(2); + + groupRepo.findByUser.mockResolvedValue(groups); + + return { + user, + groups, + }; + }; + + it('should return the groups', async () => { + const { user, groups } = setup(); + + const result: Group[] = await service.findByUser(user); + + expect(result).toEqual(groups); + }); + }); + + describe('when no groups with the user exists', () => { + const setup = () => { + const user: UserDO = userDoFactory.buildWithId(); + + groupRepo.findByUser.mockResolvedValue([]); + + return { + user, + }; + }; + + it('should return empty array', async () => { + const { user } = setup(); + + const result: Group[] = await service.findByUser(user); + + expect(result).toEqual([]); + }); + }); + }); + describe('findClassesForSchool', () => { describe('when the school has groups of type class', () => { const setup = () => { diff --git a/apps/server/src/modules/group/service/group.service.ts b/apps/server/src/modules/group/service/group.service.ts index dcba9377de3..f3ce6a287e8 100644 --- a/apps/server/src/modules/group/service/group.service.ts +++ b/apps/server/src/modules/group/service/group.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; -import { EntityId } from '@shared/domain'; +import { EntityId, type UserDO } from '@shared/domain'; import { AuthorizationLoaderServiceGeneric } from '@src/modules/authorization'; import { Group } from '../domain'; import { GroupRepo } from '../repo'; @@ -31,6 +31,12 @@ export class GroupService implements AuthorizationLoaderServiceGeneric { return group; } + public async findByUser(user: UserDO): Promise { + const groups: Group[] = await this.groupRepo.findByUser(user); + + return groups; + } + public async findClassesForSchool(schoolId: EntityId): Promise { const group: Group[] = await this.groupRepo.findClassesForSchool(schoolId); diff --git a/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.spec.ts index 80bdfdf4c88..2b838159524 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.spec.ts @@ -98,7 +98,7 @@ describe('OidcStrategy', () => { }; }; - it('should call the OidcProvisioningService.provisionExternalSchool', async () => { + it('should provision school', async () => { const { oauthData } = setup(); await strategy.apply(oauthData); @@ -150,7 +150,7 @@ describe('OidcStrategy', () => { }; }; - it('should call the OidcProvisioningService.provisionExternalUser', async () => { + it('should provision external user', async () => { const { oauthData, schoolId } = setup(); await strategy.apply(oauthData); @@ -198,7 +198,19 @@ describe('OidcStrategy', () => { }; }; - it('should call the OidcProvisioningService.provisionExternalGroup for each group', async () => { + it('should remove external groups and affiliation', async () => { + const { oauthData } = setup(); + + await strategy.apply(oauthData); + + expect(oidcProvisioningService.removeExternalGroupsAndAffiliation).toHaveBeenCalledWith( + oauthData.externalUser.externalId, + oauthData.externalGroups, + oauthData.system.systemId + ); + }); + + it('should provision every external group', async () => { const { oauthData } = setup(); await strategy.apply(oauthData); @@ -241,7 +253,15 @@ describe('OidcStrategy', () => { }; }; - it('should not call the OidcProvisioningService.provisionExternalGroup', async () => { + it('should not remove external groups and affiliation', async () => { + const { oauthData } = setup(); + + await strategy.apply(oauthData); + + expect(oidcProvisioningService.removeExternalGroupsAndAffiliation).not.toHaveBeenCalled(); + }); + + it('should not provision groups', async () => { const { oauthData } = setup(); await strategy.apply(oauthData); diff --git a/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.ts b/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.ts index f51fc49abed..7804f2190f9 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.ts @@ -24,7 +24,11 @@ export abstract class OidcProvisioningStrategy extends ProvisioningStrategy { ); if (Configuration.get('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED') && data.externalGroups) { - // TODO: N21-1212 remove user from groups + await this.oidcProvisioningService.removeExternalGroupsAndAffiliation( + data.externalUser.externalId, + data.externalGroups, + data.system.systemId + ); await Promise.all( data.externalGroups.map((externalGroup) => diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.spec.ts index 1084f21bbf4..c4b27cac34b 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.spec.ts @@ -1,7 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { LegacySchoolDo, RoleName, SchoolFeatures } from '@shared/domain'; +import { ExternalSource, LegacySchoolDo, RoleName, RoleReference, SchoolFeatures } from '@shared/domain'; import { UserDO } from '@shared/domain/domainobject/user.do'; import { externalGroupDtoFactory, @@ -11,6 +11,7 @@ import { legacySchoolDoFactory, schoolYearFactory, userDoFactory, + roleFactory, } from '@shared/testing'; import { Logger } from '@src/core/logger'; import { AccountService } from '@src/modules/account/services/account.service'; @@ -21,6 +22,7 @@ import { RoleDto } from '@src/modules/role/service/dto/role.dto'; import { FederalStateService, LegacySchoolService, SchoolYearService } from '@src/modules/legacy-school'; import { UserService } from '@src/modules/user'; import CryptoJS from 'crypto-js'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { ExternalGroupDto, ExternalSchoolDto, ExternalUserDto } from '../../../dto'; import { SchoolForGroupNotFoundLoggable, UserForGroupNotFoundLoggable } from '../../../loggable'; import { OidcProvisioningService } from './oidc-provisioning.service'; @@ -342,6 +344,204 @@ describe('OidcProvisioningService', () => { }); describe('provisionExternalGroup', () => { + describe('when group membership of user has not changed', () => { + const setup = () => { + const systemId = 'systemId'; + const externalUserId = 'externalUserId'; + const role: RoleReference = roleFactory.buildWithId(); + const user: UserDO = userDoFactory.buildWithId({ roles: [role], externalId: externalUserId }); + + const existingGroups: Group[] = groupFactory.buildList(2, { + users: [{ userId: user.id as string, roleId: role.id }], + }); + + const firstExternalGroup: ExternalGroupDto = externalGroupDtoFactory.build({ + externalId: existingGroups[0].externalSource?.externalId, + users: [{ externalUserId, roleName: role.name }], + }); + const secondExternalGroup: ExternalGroupDto = externalGroupDtoFactory.build({ + externalId: existingGroups[1].externalSource?.externalId, + users: [{ externalUserId, roleName: role.name }], + }); + const externalGroups: ExternalGroupDto[] = [firstExternalGroup, secondExternalGroup]; + + userService.findByExternalId.mockResolvedValue(user); + groupService.findByUser.mockResolvedValue(existingGroups); + + return { + externalGroups, + systemId, + externalUserId, + }; + }; + + it('should not save the group', async () => { + const { externalGroups, systemId, externalUserId } = setup(); + + await service.removeExternalGroupsAndAffiliation(externalUserId, externalGroups, systemId); + + expect(groupService.save).not.toHaveBeenCalled(); + }); + + it('should not delete the group', async () => { + const { externalGroups, systemId, externalUserId } = setup(); + + await service.removeExternalGroupsAndAffiliation(externalUserId, externalGroups, systemId); + + expect(groupService.delete).not.toHaveBeenCalled(); + }); + }); + + describe('when user is not part of a group anymore', () => { + describe('when group is empty after removal of the User', () => { + const setup = () => { + const systemId = 'systemId'; + const externalUserId = 'externalUserId'; + const role: RoleReference = roleFactory.buildWithId(); + const user: UserDO = userDoFactory.buildWithId({ roles: [role], externalId: externalUserId }); + + const firstExistingGroup: Group = groupFactory.build({ + users: [{ userId: user.id as string, roleId: role.id }], + externalSource: new ExternalSource({ + externalId: 'externalId-1', + systemId, + }), + }); + const secondExistingGroup: Group = groupFactory.build({ + users: [{ userId: user.id as string, roleId: role.id }], + externalSource: new ExternalSource({ + externalId: 'externalId-2', + systemId, + }), + }); + const existingGroups = [firstExistingGroup, secondExistingGroup]; + + const firstExternalGroup: ExternalGroupDto = externalGroupDtoFactory.build({ + externalId: existingGroups[0].externalSource?.externalId, + users: [{ externalUserId, roleName: role.name }], + }); + const externalGroups: ExternalGroupDto[] = [firstExternalGroup]; + + userService.findByExternalId.mockResolvedValue(user); + groupService.findByUser.mockResolvedValue(existingGroups); + + return { + externalGroups, + systemId, + externalUserId, + existingGroups, + }; + }; + + it('should delete the group', async () => { + const { externalGroups, systemId, externalUserId, existingGroups } = setup(); + + await service.removeExternalGroupsAndAffiliation(externalUserId, externalGroups, systemId); + + expect(groupService.delete).toHaveBeenCalledWith(existingGroups[1]); + }); + + it('should not save the group', async () => { + const { externalGroups, systemId, externalUserId } = setup(); + + await service.removeExternalGroupsAndAffiliation(externalUserId, externalGroups, systemId); + + expect(groupService.save).not.toHaveBeenCalled(); + }); + }); + + describe('when group is not empty after removal of the User', () => { + const setup = () => { + const systemId = 'systemId'; + const externalUserId = 'externalUserId'; + const anotherExternalUserId = 'anotherExternalUserId'; + const role: RoleReference = roleFactory.buildWithId(); + const user: UserDO = userDoFactory.buildWithId({ roles: [role], externalId: externalUserId }); + const anotherUser: UserDO = userDoFactory.buildWithId({ roles: [role], externalId: anotherExternalUserId }); + + const firstExistingGroup: Group = groupFactory.build({ + users: [ + { userId: user.id as string, roleId: role.id }, + { userId: anotherUser.id as string, roleId: role.id }, + ], + externalSource: new ExternalSource({ + externalId: `externalId-1`, + systemId, + }), + }); + + const secondExistingGroup: Group = groupFactory.build({ + users: [ + { userId: user.id as string, roleId: role.id }, + { userId: anotherUser.id as string, roleId: role.id }, + ], + externalSource: new ExternalSource({ + externalId: `externalId-2`, + systemId, + }), + }); + + const existingGroups: Group[] = [firstExistingGroup, secondExistingGroup]; + + const firstExternalGroup: ExternalGroupDto = externalGroupDtoFactory.build({ + externalId: existingGroups[0].externalSource?.externalId, + users: [{ externalUserId, roleName: role.name }], + }); + const externalGroups: ExternalGroupDto[] = [firstExternalGroup]; + + userService.findByExternalId.mockResolvedValue(user); + groupService.findByUser.mockResolvedValue(existingGroups); + + return { + externalGroups, + systemId, + externalUserId, + existingGroups, + }; + }; + + it('should save the group', async () => { + const { externalGroups, systemId, externalUserId, existingGroups } = setup(); + + await service.removeExternalGroupsAndAffiliation(externalUserId, externalGroups, systemId); + + expect(groupService.save).toHaveBeenCalledWith(existingGroups[1]); + }); + + it('should not delete the group', async () => { + const { externalGroups, systemId, externalUserId } = setup(); + + await service.removeExternalGroupsAndAffiliation(externalUserId, externalGroups, systemId); + + expect(groupService.delete).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when user could not be found', () => { + const setup = () => { + const systemId = 'systemId'; + const externalUserId = 'externalUserId'; + const externalGroups: ExternalGroupDto[] = [externalGroupDtoFactory.build()]; + + userService.findByExternalId.mockResolvedValue(null); + + return { + systemId, + externalUserId, + externalGroups, + }; + }; + + it('should throw NotFoundLoggableException', async () => { + const { externalGroups, systemId, externalUserId } = setup(); + + const func = async () => service.removeExternalGroupsAndAffiliation(externalUserId, externalGroups, systemId); + + await expect(func).rejects.toThrow(new NotFoundLoggableException('User', 'externalId', externalUserId)); + }); + }); + describe('when the group has no users', () => { const setup = () => { const externalGroupDto: ExternalGroupDto = externalGroupDtoFactory.build({ users: [] }); diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.ts index 1a16b8578c9..0aef3fdecb9 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.ts @@ -12,6 +12,7 @@ import { RoleDto } from '@src/modules/role/service/dto/role.dto'; import { UserService } from '@src/modules/user'; import { ObjectId } from 'bson'; import CryptoJS from 'crypto-js'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { ExternalGroupDto, ExternalGroupUserDto, ExternalSchoolDto, ExternalUserDto } from '../../../dto'; import { SchoolForGroupNotFoundLoggable, UserForGroupNotFoundLoggable } from '../../../loggable'; @@ -185,4 +186,43 @@ export class OidcProvisioningService { return filteredUsers; } + + async removeExternalGroupsAndAffiliation( + externalUserId: EntityId, + externalGroups: ExternalGroupDto[], + systemId: EntityId + ): Promise { + const user: UserDO | null = await this.userService.findByExternalId(externalUserId, systemId); + + if (!user) { + throw new NotFoundLoggableException(UserDO.name, 'externalId', externalUserId); + } + + const existingGroupsOfUser: Group[] = await this.groupService.findByUser(user); + + const groupsFromSystem: Group[] = existingGroupsOfUser.filter( + (existingGroup: Group) => existingGroup.externalSource?.systemId === systemId + ); + + const groupsWithoutUser: Group[] = groupsFromSystem.filter((existingGroupFromSystem: Group) => { + const isUserInGroup = externalGroups.some( + (externalGroup: ExternalGroupDto) => + externalGroup.externalId === existingGroupFromSystem.externalSource?.externalId + ); + + return !isUserInGroup; + }); + + await Promise.all( + groupsWithoutUser.map(async (group: Group) => { + group.removeUser(user); + + if (group.isEmpty()) { + await this.groupService.delete(group); + } else { + await this.groupService.save(group); + } + }) + ); + } } diff --git a/apps/server/src/shared/common/loggable-exception/not-found.loggable-exception.ts b/apps/server/src/shared/common/loggable-exception/not-found.loggable-exception.ts index 261f4161a30..4ffd8e5b70e 100644 --- a/apps/server/src/shared/common/loggable-exception/not-found.loggable-exception.ts +++ b/apps/server/src/shared/common/loggable-exception/not-found.loggable-exception.ts @@ -1,5 +1,4 @@ import { NotFoundException } from '@nestjs/common'; -import { EntityId } from '@shared/domain'; import { Loggable } from '@src/core/logger/interfaces'; import { ErrorLogMessage } from '@src/core/logger/types'; @@ -7,7 +6,7 @@ export class NotFoundLoggableException extends NotFoundException implements Logg constructor( private readonly resourceName: string, private readonly identifierName: string, - private readonly resourceId: EntityId + private readonly resourceId: string ) { super(); } From c66e1221953a1adf65db328a876ee9610f1bc56a Mon Sep 17 00:00:00 2001 From: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> Date: Fri, 6 Oct 2023 10:41:23 +0200 Subject: [PATCH 03/10] BC-5461 - Fixed check for undefined props by file permissions (#4453) --- src/services/fileStorage/proxy-service.js | 14 +++++++------- .../fileStorage/utils/filePermissionHelper.js | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/services/fileStorage/proxy-service.js b/src/services/fileStorage/proxy-service.js index 81a798be5c5..a33f261e540 100644 --- a/src/services/fileStorage/proxy-service.js +++ b/src/services/fileStorage/proxy-service.js @@ -422,10 +422,10 @@ const signedUrlService = { throw new NotFound('File seems not to be there.'); } - // deprecated: author check via file.permissions[0].refId is deprecated and will be removed in the next release + // deprecated: author check via file.permissions[0]?.refId is deprecated and will be removed in the next release const creatorId = fileObject.creator || - (fileObject.permissions[0].refPermModel !== 'user' ? userId : fileObject.permissions[0].refId); + (fileObject.permissions[0]?.refPermModel !== 'user' ? userId : fileObject.permissions[0]?.refId); if (download && fileObject.securityCheck && fileObject.securityCheck.status === SecurityCheckStatusTypes.BLOCKED) { throw new Forbidden('File access blocked by security check.'); @@ -456,11 +456,11 @@ const signedUrlService = { throw new NotFound('File seems not to be there.'); } - // deprecated: author check via file.permissions[0].refId is deprecated and will be removed in the next release + // deprecated: author check via file.permissions[0]?.refId is deprecated and will be removed in the next release const creatorId = - fileObject.creator || fileObject.permissions[0].refPermModel !== 'user' + fileObject.creator || fileObject.permissions[0]?.refPermModel !== 'user' ? userId - : fileObject.permissions[0].refId; + : fileObject.permissions[0]?.refId; return canRead(userId, id) .then(() => @@ -899,8 +899,8 @@ const filePermissionService = { const { refOwnerModel, owner } = fileObj; const rolePermissions = fileObj.permissions.filter(({ refPermModel }) => refPermModel === 'role'); const rolePromises = rolePermissions.map(({ refId }) => RoleModel.findOne({ _id: refId }).lean().exec()); - // deprecated: author check via file.permissions[0].refId is deprecated and will be removed in the next release - const isFileCreator = equalIds(fileObj.creator || fileObj.permissions[0].refId, userId); + // deprecated: author check via file.permissions[0]?.refId is deprecated and will be removed in the next release + const isFileCreator = equalIds(fileObj.creator || fileObj.permissions[0]?.refId, userId); const actionMap = { user: () => { diff --git a/src/services/fileStorage/utils/filePermissionHelper.js b/src/services/fileStorage/utils/filePermissionHelper.js index 5993fa25cec..332409445ff 100644 --- a/src/services/fileStorage/utils/filePermissionHelper.js +++ b/src/services/fileStorage/utils/filePermissionHelper.js @@ -34,9 +34,9 @@ const checkTeamPermission = async ({ user, file, permission }) => { rolesToTest = rolesToTest.concat(roleIndex[roleId].roles || []); } - // deprecated: author check via file.permissions[0].refId is deprecated and will be removed in the next release + // deprecated: author check via file.permissions[0]?.refId is deprecated and will be removed in the next release const { role: creatorRole } = file.owner.userIds.find((_) => - equalIds(_.userId, file.creator || file.permissions[0].refId) + equalIds(_.userId, file.creator || file.permissions[0]?.refId) ); const findRole = (roleId) => (roles) => roles.findIndex((r) => equalIds(r._id, roleId)) > -1; From a4364ecd01056abea5cdae1832f4293f32fa285c Mon Sep 17 00:00:00 2001 From: Majed Mak <132336669+MajedAlaitwniCap@users.noreply.github.com> Date: Fri, 6 Oct 2023 12:12:15 +0200 Subject: [PATCH 04/10] EW-619 DataPort review testing part (#4442) * EW-619 modified test structure related to Data Port Review --- .../controller/account.controller.spec.ts | 24 - .../account/controller/account.controller.ts | 4 +- .../controller/api-test/account.api.spec.ts | 775 +++- .../controller/dto/password-pattern.ts | 1 - .../account-entity-to-dto.mapper.spec.ts | 139 +- .../mapper/account-entity-to-dto.mapper.ts | 2 + .../account-idm-to-dto.mapper.db.spec.ts | 62 +- .../account-idm-to-dto.mapper.idm.spec.ts | 75 +- .../mapper/account-response.mapper.spec.ts | 81 +- .../account/mapper/account-response.mapper.ts | 1 + .../repo/account.repo.integration.spec.ts | 412 +- .../src/modules/account/repo/account.repo.ts | 5 + .../src/modules/account/review-comments.md | 12 + .../services/account-db.service.spec.ts | 995 ++-- .../account/services/account-db.service.ts | 21 +- .../account-idm.service.integration.spec.ts | 172 +- .../services/account-idm.service.spec.ts | 315 +- .../account/services/account-idm.service.ts | 9 +- .../services/account.service.abstract.ts | 3 + .../account.service.integration.spec.ts | 201 +- .../account/services/account.service.spec.ts | 710 +-- .../account/services/account.service.ts | 10 +- .../account.validation.service.spec.ts | 586 ++- .../services/account.validation.service.ts | 6 +- .../account/services/dto/account.dto.ts | 1 + .../src/modules/account/uc/account.uc.spec.ts | 4048 ++++++++++++----- .../src/modules/account/uc/account.uc.ts | 18 +- .../shared/testing/factory/account.factory.ts | 32 +- 28 files changed, 5962 insertions(+), 2758 deletions(-) delete mode 100644 apps/server/src/modules/account/controller/account.controller.spec.ts create mode 100644 apps/server/src/modules/account/review-comments.md diff --git a/apps/server/src/modules/account/controller/account.controller.spec.ts b/apps/server/src/modules/account/controller/account.controller.spec.ts deleted file mode 100644 index ef15672edc5..00000000000 --- a/apps/server/src/modules/account/controller/account.controller.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AccountController } from './account.controller'; -import { AccountUc } from '../uc/account.uc'; - -describe('account.controller', () => { - let module: TestingModule; - let controller: AccountController; - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [ - AccountController, - { - provide: AccountUc, - useValue: {}, - }, - ], - }).compile(); - controller = module.get(AccountController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/apps/server/src/modules/account/controller/account.controller.ts b/apps/server/src/modules/account/controller/account.controller.ts index 2256cb9fc90..23c07326d82 100644 --- a/apps/server/src/modules/account/controller/account.controller.ts +++ b/apps/server/src/modules/account/controller/account.controller.ts @@ -1,8 +1,8 @@ import { Body, Controller, Delete, Get, Param, Patch, Query } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { Authenticate, CurrentUser } from '@src/modules/authentication/decorator/auth.decorator'; import { EntityNotFoundError, ForbiddenOperationError, ValidationError } from '@shared/common'; import { ICurrentUser } from '@src/modules/authentication'; +import { Authenticate, CurrentUser } from '@src/modules/authentication/decorator/auth.decorator'; import { AccountUc } from '../uc/account.uc'; import { AccountByIdBodyParams, @@ -33,6 +33,8 @@ export class AccountController { @Query() query: AccountSearchQueryParams ): Promise { return this.accountUc.searchAccounts(currentUser, query); + + // TODO: mapping from domain to api dto should be a responsability of the controller (also every other function here) } @Get(':id') diff --git a/apps/server/src/modules/account/controller/api-test/account.api.spec.ts b/apps/server/src/modules/account/controller/api-test/account.api.spec.ts index fefdb006bf8..9e7dd1a7d18 100644 --- a/apps/server/src/modules/account/controller/api-test/account.api.spec.ts +++ b/apps/server/src/modules/account/controller/api-test/account.api.spec.ts @@ -1,9 +1,15 @@ -import { EntityManager } from '@mikro-orm/core'; -import { ExecutionContext, INestApplication } from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Account, Permission, RoleName, User } from '@shared/domain'; -import { ICurrentUser } from '@src/modules/authentication'; -import { accountFactory, mapUserToCurrentUser, roleFactory, schoolFactory, userFactory } from '@shared/testing'; +import { + accountFactory, + roleFactory, + schoolFactory, + userFactory, + TestApiClient, + cleanupCollections, +} from '@shared/testing'; import { AccountByIdBodyParams, AccountSearchQueryParams, @@ -11,305 +17,626 @@ import { PatchMyAccountParams, PatchMyPasswordParams, } from '@src/modules/account/controller/dto'; -import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; import { ServerTestModule } from '@src/modules/server/server.module'; -import { Request } from 'express'; -import request from 'supertest'; describe('Account Controller (API)', () => { const basePath = '/account'; let app: INestApplication; let em: EntityManager; - - let adminAccount: Account; - let teacherAccount: Account; - let studentAccount: Account; - let superheroAccount: Account; - - let adminUser: User; - let teacherUser: User; - let studentUser: User; - let superheroUser: User; - - let currentUser: ICurrentUser; + let testApiClient: TestApiClient; const defaultPassword = 'DummyPasswd!1'; const defaultPasswordHash = '$2a$10$/DsztV5o6P5piW2eWJsxw.4nHovmJGBA.QNwiTmuZ/uvUc40b.Uhu'; - const setup = async () => { - const school = schoolFactory.buildWithId(); - - const adminRoles = roleFactory.build({ - name: RoleName.ADMINISTRATOR, - permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + const mapUserToAccount = (user: User): Account => + accountFactory.buildWithId({ + userId: user.id, + username: user.email, + password: defaultPasswordHash, }); - const teacherRoles = roleFactory.build({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] }); - const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); - const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); - - adminUser = userFactory.buildWithId({ school, roles: [adminRoles] }); - teacherUser = userFactory.buildWithId({ school, roles: [teacherRoles] }); - studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); - superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); - - const mapUserToAccount = (user: User): Account => - accountFactory.buildWithId({ - userId: user.id, - username: user.email, - password: defaultPasswordHash, - }); - adminAccount = mapUserToAccount(adminUser); - teacherAccount = mapUserToAccount(teacherUser); - studentAccount = mapUserToAccount(studentUser); - superheroAccount = mapUserToAccount(superheroUser); - - em.persist(school); - em.persist([adminRoles, teacherRoles, studentRoles, superheroRoles]); - em.persist([adminUser, teacherUser, studentUser, superheroUser]); - em.persist([adminAccount, teacherAccount, studentAccount, superheroAccount]); - await em.flush(); - }; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [ServerTestModule], - }) - .overrideGuard(JwtAuthGuard) - .useValue({ - canActivate(context: ExecutionContext) { - const req: Request = context.switchToHttp().getRequest(); - req.user = currentUser; - return true; - }, - }) - .compile(); + }).compile(); app = moduleFixture.createNestApplication(); await app.init(); em = app.get(EntityManager); + testApiClient = new TestApiClient(app, basePath); }); beforeEach(async () => { - await setup(); + await cleanupCollections(em); }); afterAll(async () => { - // await cleanupCollections(em); + await cleanupCollections(em); await app.close(); }); describe('[PATCH] me/password', () => { - it(`should update the current user's (temporary) password`, async () => { - currentUser = mapUserToCurrentUser(studentUser, studentAccount); - const params: PatchMyPasswordParams = { - password: 'Valid12$', - confirmPassword: 'Valid12$', + describe('When patching with a valid password', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const studentAccount = mapUserToAccount(studentUser); + + em.persist([school, studentRoles, studentUser, studentAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(studentAccount); + + const passwordPatchParams: PatchMyPasswordParams = { + password: 'Valid12$', + confirmPassword: 'Valid12$', + }; + + return { passwordPatchParams, loggedInClient, studentAccount }; }; - await request(app.getHttpServer()) // - .patch(`${basePath}/me/password`) - .send(params) - .expect(200); - const updatedAccount = await em.findOneOrFail(Account, studentAccount.id); - expect(updatedAccount.password).not.toEqual(defaultPasswordHash); + it(`should update the current user's (temporary) password`, async () => { + const { passwordPatchParams, loggedInClient, studentAccount } = await setup(); + + await loggedInClient.patch('/me/password', passwordPatchParams).expect(200); + + const updatedAccount = await em.findOneOrFail(Account, studentAccount.id); + expect(updatedAccount.password).not.toEqual(defaultPasswordHash); + }); }); - it('should reject if new password is weak', async () => { - currentUser = mapUserToCurrentUser(studentUser, studentAccount); - const params: PatchMyPasswordParams = { - password: 'weak', - confirmPassword: 'weak', + + describe('When using a weak password', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const studentAccount = mapUserToAccount(studentUser); + + em.persist([school, studentRoles, studentUser, studentAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(studentAccount); + + const passwordPatchParams: PatchMyPasswordParams = { + password: 'weak', + confirmPassword: 'weak', + }; + + return { passwordPatchParams, loggedInClient }; }; - await request(app.getHttpServer()) // - .patch(`${basePath}/me/password`) - .send(params) - .expect(400); + + it('should reject the password change', async () => { + const { passwordPatchParams, loggedInClient } = await setup(); + + await loggedInClient.patch('/me/password', passwordPatchParams).expect(400); + }); }); }); describe('[PATCH] me', () => { - it(`should update a users account`, async () => { - const newEmailValue = 'new@mail.com'; - currentUser = mapUserToCurrentUser(studentUser, studentAccount); - const params: PatchMyAccountParams = { - passwordOld: defaultPassword, - email: newEmailValue, + describe('When patching the account with account info', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const studentAccount = mapUserToAccount(studentUser); + + em.persist([school, studentRoles, studentUser, studentAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(studentAccount); + + const newEmailValue = 'new@mail.com'; + + const patchMyAccountParams: PatchMyAccountParams = { + passwordOld: defaultPassword, + email: newEmailValue, + }; + return { newEmailValue, patchMyAccountParams, loggedInClient, studentAccount }; }; - await request(app.getHttpServer()) // - .patch(`${basePath}/me`) - .send(params) - .expect(200); - const updatedAccount = await em.findOneOrFail(Account, studentAccount.id); - expect(updatedAccount.username).toEqual(newEmailValue); + it(`should update a users account`, async () => { + const { newEmailValue, patchMyAccountParams, loggedInClient, studentAccount } = await setup(); + + await loggedInClient.patch('/me', patchMyAccountParams).expect(200); + + const updatedAccount = await em.findOneOrFail(Account, studentAccount.id); + expect(updatedAccount.username).toEqual(newEmailValue); + }); }); - it('should reject if new email is not valid', async () => { - currentUser = mapUserToCurrentUser(studentUser, studentAccount); - const params: PatchMyAccountParams = { - passwordOld: defaultPassword, - email: 'invalid', + describe('When patching with a not valid email', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const studentAccount = mapUserToAccount(studentUser); + + em.persist([school, studentRoles, studentUser, studentAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(studentAccount); + + const newEmailValue = 'new@mail.com'; + + const patchMyAccountParams: PatchMyAccountParams = { + passwordOld: defaultPassword, + email: 'invalid', + }; + return { newEmailValue, patchMyAccountParams, loggedInClient }; }; - await request(app.getHttpServer()) // - .patch(`${basePath}/me`) - .send(params) - .expect(400); + + it('should reject patch request', async () => { + const { patchMyAccountParams, loggedInClient } = await setup(); + + await loggedInClient.patch('/me', patchMyAccountParams).expect(400); + }); }); }); describe('[GET]', () => { - it('should search for user id', async () => { - currentUser = mapUserToCurrentUser(superheroUser, superheroAccount); - const query: AccountSearchQueryParams = { - type: AccountSearchType.USER_ID, - value: studentUser.id, - skip: 5, - limit: 5, + describe('When searching with a superhero user', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); + + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + + const studentAccount = mapUserToAccount(studentUser); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist(school); + em.persist([studentRoles, superheroRoles]); + em.persist([studentUser, superheroUser]); + em.persist([studentAccount, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + const query: AccountSearchQueryParams = { + type: AccountSearchType.USER_ID, + value: studentUser.id, + skip: 5, + limit: 5, + }; + + return { query, loggedInClient }; }; - await request(app.getHttpServer()) // - .get(`${basePath}`) - .query(query) - .send() - .expect(200); + it('should successfully search for user id', async () => { + const { query, loggedInClient } = await setup(); + + await loggedInClient.get().query(query).send().expect(200); + }); }); + // If skip is too big, just return an empty list. // We testing it here, because we are mocking the database in the use case unit tests // and for realistic behavior we need database. - it('should search for user id with large skip', async () => { - currentUser = mapUserToCurrentUser(superheroUser); - const query: AccountSearchQueryParams = { - type: AccountSearchType.USER_ID, - value: studentUser.id, - skip: 50000, - limit: 5, + describe('When searching with a superhero user with large skip', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); + + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + + const studentAccount = mapUserToAccount(studentUser); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist(school); + em.persist([studentRoles, superheroRoles]); + em.persist([studentUser, superheroUser]); + em.persist([studentAccount, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + const query: AccountSearchQueryParams = { + type: AccountSearchType.USER_ID, + value: studentUser.id, + skip: 50000, + limit: 5, + }; + + return { query, loggedInClient }; }; - await request(app.getHttpServer()) // - .get(`${basePath}`) - .query(query) - .send() - .expect(200); + it('should search for user id', async () => { + const { query, loggedInClient } = await setup(); + + await loggedInClient.get().query(query).send().expect(200); + }); }); - it('should search for user name', async () => { - currentUser = mapUserToCurrentUser(superheroUser, superheroAccount); - const query: AccountSearchQueryParams = { - type: AccountSearchType.USERNAME, - value: '', - skip: 5, - limit: 5, + + describe('When searching with a superhero user', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); + + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + + const studentAccount = mapUserToAccount(studentUser); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist(school); + em.persist([studentRoles, superheroRoles]); + em.persist([studentUser, superheroUser]); + em.persist([studentAccount, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + const query: AccountSearchQueryParams = { + type: AccountSearchType.USERNAME, + value: '', + skip: 5, + limit: 5, + }; + + return { query, loggedInClient }; }; - await request(app.getHttpServer()) // - .get(`${basePath}`) - .query(query) - .send() - .expect(200); + it('should search for username', async () => { + const { query, loggedInClient } = await setup(); + + await loggedInClient.get().query(query).send().expect(200); + }); }); - it('should reject if type is unknown', async () => { - currentUser = mapUserToCurrentUser(superheroUser, superheroAccount); - const query: AccountSearchQueryParams = { - type: '' as AccountSearchType, - value: '', - skip: 5, - limit: 5, + + describe('When searching with a superhero user', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); + + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + + const studentAccount = mapUserToAccount(studentUser); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist(school); + em.persist([studentRoles, superheroRoles]); + em.persist([studentUser, superheroUser]); + em.persist([studentAccount, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + const query: AccountSearchQueryParams = { + type: '' as AccountSearchType, + value: '', + skip: 5, + limit: 5, + }; + + return { query, loggedInClient }; }; - await request(app.getHttpServer()) // - .get(`${basePath}`) - .query(query) - .send() - .expect(400); + + it('should reject if type is unknown', async () => { + const { query, loggedInClient } = await setup(); + + await loggedInClient.get().query(query).send().expect(400); + }); }); - it('should reject if user is not authorized', async () => { - currentUser = mapUserToCurrentUser(adminUser, adminAccount); - const query: AccountSearchQueryParams = { - type: AccountSearchType.USERNAME, - value: '', - skip: 5, - limit: 5, + describe('When searching with an admin user (not authorized)', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + + const adminRoles = roleFactory.build({ + name: RoleName.ADMINISTRATOR, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }); + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + + const adminUser = userFactory.buildWithId({ school, roles: [adminRoles] }); + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + + const adminAccount = mapUserToAccount(adminUser); + const studentAccount = mapUserToAccount(studentUser); + + em.persist(school); + em.persist([adminRoles, studentRoles]); + em.persist([adminUser, studentUser]); + em.persist([adminAccount, studentAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(adminAccount); + + const query: AccountSearchQueryParams = { + type: AccountSearchType.USERNAME, + value: '', + skip: 5, + limit: 5, + }; + + return { query, loggedInClient, studentAccount }; }; - await request(app.getHttpServer()) // - .get(`${basePath}`) - .query(query) - .send() - .expect(403); + + it('should reject search for user', async () => { + const { query, loggedInClient } = await setup(); + + await loggedInClient.get().query(query).send().expect(403); + }); }); }); describe('[GET] :id', () => { - it('should return account for account id', async () => { - currentUser = mapUserToCurrentUser(superheroUser, superheroAccount); - await request(app.getHttpServer()) // - .get(`${basePath}/${studentAccount.id}`) - .expect(200); + describe('When searching with a superhero user', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); + + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + + const studentAccount = mapUserToAccount(studentUser); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist(school); + em.persist([studentRoles, superheroRoles]); + em.persist([studentUser, superheroUser]); + em.persist([studentAccount, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + return { loggedInClient, studentAccount }; + }; + it('should return account for account id', async () => { + const { loggedInClient, studentAccount } = await setup(); + await loggedInClient.get(`/${studentAccount.id}`).expect(200); + }); }); - it('should reject if user is not a authorized', async () => { - currentUser = mapUserToCurrentUser(adminUser, adminAccount); - await request(app.getHttpServer()) // - .get(`${basePath}/${studentAccount.id}`) - .expect(403); + + describe('When searching with a not authorized user', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + + const adminRoles = roleFactory.build({ + name: RoleName.ADMINISTRATOR, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }); + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + + const adminUser = userFactory.buildWithId({ school, roles: [adminRoles] }); + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + + const adminAccount = mapUserToAccount(adminUser); + const studentAccount = mapUserToAccount(studentUser); + + em.persist(school); + em.persist([adminRoles, studentRoles]); + em.persist([adminUser, studentUser]); + em.persist([adminAccount, studentAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(adminAccount); + + return { loggedInClient, studentAccount }; + }; + it('should reject request', async () => { + const { loggedInClient, studentAccount } = await setup(); + await loggedInClient.get(`/${studentAccount.id}`).expect(403); + }); }); - it('should reject not existing account id', async () => { - currentUser = mapUserToCurrentUser(superheroUser, superheroAccount); - await request(app.getHttpServer()) // - .get(`${basePath}/000000000000000000000000`) - .expect(404); + + describe('When searching with a superhero user', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist([school, superheroRoles, superheroUser, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + return { loggedInClient }; + }; + + it('should reject not existing account id', async () => { + const { loggedInClient } = await setup(); + await loggedInClient.get(`/000000000000000000000000`).expect(404); + }); }); }); describe('[PATCH] :id', () => { - it('should update account', async () => { - currentUser = mapUserToCurrentUser(superheroUser, superheroAccount); - const body: AccountByIdBodyParams = { - password: defaultPassword, - username: studentAccount.username, - activated: true, + describe('When using a superhero user', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); + + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + + const studentAccount = mapUserToAccount(studentUser); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist(school); + em.persist([studentRoles, superheroRoles]); + em.persist([studentUser, superheroUser]); + em.persist([studentAccount, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + const body: AccountByIdBodyParams = { + password: defaultPassword, + username: studentAccount.username, + activated: true, + }; + + return { body, loggedInClient, studentAccount }; }; - await request(app.getHttpServer()) // - .patch(`${basePath}/${studentAccount.id}`) - .send(body) - .expect(200); + + it('should update account', async () => { + const { body, loggedInClient, studentAccount } = await setup(); + + await loggedInClient.patch(`/${studentAccount.id}`, body).expect(200); + }); }); - it('should reject if user is not authorized', async () => { - currentUser = mapUserToCurrentUser(studentUser, studentAccount); - const body: AccountByIdBodyParams = { - password: defaultPassword, - username: studentAccount.username, - activated: true, + + describe('When the user is not authorized', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const studentAccount = mapUserToAccount(studentUser); + + em.persist([school, studentRoles, studentUser, studentAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(studentAccount); + + const body: AccountByIdBodyParams = { + password: defaultPassword, + username: studentAccount.username, + activated: true, + }; + + return { body, loggedInClient, studentAccount }; }; - await request(app.getHttpServer()) // - .patch(`${basePath}/${studentAccount.id}`) - .send(body) - .expect(403); + it('should reject update request', async () => { + const { body, loggedInClient, studentAccount } = await setup(); + + await loggedInClient.patch(`/${studentAccount.id}`, body).expect(403); + }); }); - it('should reject not existing account id', async () => { - currentUser = mapUserToCurrentUser(superheroUser, studentAccount); - const body: AccountByIdBodyParams = { - password: defaultPassword, - username: studentAccount.username, - activated: true, + + describe('When updating with a superhero user', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); + + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + + const studentAccount = mapUserToAccount(studentUser); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist(school); + em.persist([studentRoles, superheroRoles]); + em.persist([studentUser, superheroUser]); + em.persist([studentAccount, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + const body: AccountByIdBodyParams = { + password: defaultPassword, + username: studentAccount.username, + activated: true, + }; + + return { body, loggedInClient }; }; - await request(app.getHttpServer()) // - .patch(`${basePath}/000000000000000000000000`) - .send(body) - .expect(404); + it('should reject not existing account id', async () => { + const { body, loggedInClient } = await setup(); + await loggedInClient.patch('/000000000000000000000000', body).expect(404); + }); }); }); describe('[DELETE] :id', () => { - it('should delete account', async () => { - currentUser = mapUserToCurrentUser(superheroUser, studentAccount); - await request(app.getHttpServer()) // - .delete(`${basePath}/${studentAccount.id}`) - .expect(200); + describe('When using a superhero user', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); + + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + + const studentAccount = mapUserToAccount(studentUser); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist(school); + em.persist([studentRoles, superheroRoles]); + em.persist([studentUser, superheroUser]); + em.persist([studentAccount, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + return { loggedInClient, studentAccount }; + }; + it('should delete account', async () => { + const { loggedInClient, studentAccount } = await setup(); + await loggedInClient.delete(`/${studentAccount.id}`).expect(200); + }); }); - it('should reject if user is not a authorized', async () => { - currentUser = mapUserToCurrentUser(adminUser, adminAccount); - await request(app.getHttpServer()) // - .delete(`${basePath}/${studentAccount.id}`) - .expect(403); + + describe('When using a not authorized (admin) user', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + + const adminRoles = roleFactory.build({ + name: RoleName.ADMINISTRATOR, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }); + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + + const adminUser = userFactory.buildWithId({ school, roles: [adminRoles] }); + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + + const adminAccount = mapUserToAccount(adminUser); + const studentAccount = mapUserToAccount(studentUser); + + em.persist(school); + em.persist([adminRoles, studentRoles]); + em.persist([adminUser, studentUser]); + em.persist([adminAccount, studentAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(adminAccount); + + return { loggedInClient, studentAccount }; + }; + + it('should reject delete request', async () => { + const { loggedInClient, studentAccount } = await setup(); + await loggedInClient.delete(`/${studentAccount.id}`).expect(403); + }); }); - it('should reject not existing account id', async () => { - currentUser = mapUserToCurrentUser(superheroUser, studentAccount); - await request(app.getHttpServer()) // - .delete(`${basePath}/000000000000000000000000`) - .expect(404); + + describe('When using a superhero user', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist([school, superheroRoles, superheroUser, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + return { loggedInClient }; + }; + + it('should reject not existing account id', async () => { + const { loggedInClient } = await setup(); + await loggedInClient.delete('/000000000000000000000000').expect(404); + }); }); }); }); diff --git a/apps/server/src/modules/account/controller/dto/password-pattern.ts b/apps/server/src/modules/account/controller/dto/password-pattern.ts index 6ca2fd9fab2..d849b8db235 100644 --- a/apps/server/src/modules/account/controller/dto/password-pattern.ts +++ b/apps/server/src/modules/account/controller/dto/password-pattern.ts @@ -1,2 +1 @@ -// TODO Compare with client export const passwordPattern = /^(?=.*[A-Z])(?=.*[0-9])(?=.*[a-z])(?=.*[-_!<>§$%&/()=?\\;:,.#+*~'])\S.{6,253}\S$/; diff --git a/apps/server/src/modules/account/mapper/account-entity-to-dto.mapper.spec.ts b/apps/server/src/modules/account/mapper/account-entity-to-dto.mapper.spec.ts index 8e9522434eb..4de5040113f 100644 --- a/apps/server/src/modules/account/mapper/account-entity-to-dto.mapper.spec.ts +++ b/apps/server/src/modules/account/mapper/account-entity-to-dto.mapper.spec.ts @@ -1,5 +1,5 @@ import { Account } from '@shared/domain'; -import { ObjectId } from 'bson'; +import { accountFactory } from '@shared/testing'; import { AccountEntityToDtoMapper } from './account-entity-to-dto.mapper'; describe('AccountEntityToDtoMapper', () => { @@ -14,101 +14,80 @@ describe('AccountEntityToDtoMapper', () => { }); describe('mapToDto', () => { - it('should map all fields', () => { - const testEntity: Account = { - _id: new ObjectId(), - id: 'id', - createdAt: new Date(), - updatedAt: new Date(), - userId: new ObjectId(), - username: 'username', - activated: true, - credentialHash: 'credentialHash', - expiresAt: new Date(), - lasttriedFailedLogin: new Date(), - password: 'password', - systemId: new ObjectId(), - token: 'token', - }; - const ret = AccountEntityToDtoMapper.mapToDto(testEntity); - - expect(ret.id).toBe(testEntity.id); - expect(ret.createdAt).toEqual(testEntity.createdAt); - expect(ret.updatedAt).toEqual(testEntity.createdAt); - expect(ret.userId).toBe(testEntity.userId?.toString()); - expect(ret.username).toBe(testEntity.username); - expect(ret.activated).toBe(testEntity.activated); - expect(ret.credentialHash).toBe(testEntity.credentialHash); - expect(ret.expiresAt).toBe(testEntity.expiresAt); - expect(ret.lasttriedFailedLogin).toBe(testEntity.lasttriedFailedLogin); - expect(ret.password).toBe(testEntity.password); - expect(ret.systemId).toBe(testEntity.systemId?.toString()); - expect(ret.token).toBe(testEntity.token); - }); + describe('When mapping AccountEntity to AccountDto', () => { + const setup = () => { + const accountEntity = accountFactory.withAllProperties().buildWithId({}, '000000000000000000000001'); + + const missingSystemUserIdEntity: Account = accountFactory.withoutSystemAndUserId().build(); - it('should ignore missing ids', () => { - const testEntity: Account = { - _id: new ObjectId(), - id: 'id', - username: 'username', - createdAt: new Date(), - updatedAt: new Date(), + return { accountEntity, missingSystemUserIdEntity }; }; - const ret = AccountEntityToDtoMapper.mapToDto(testEntity); - expect(ret.userId).toBeUndefined(); - expect(ret.systemId).toBeUndefined(); + it('should map all fields', () => { + const { accountEntity } = setup(); + + const ret = AccountEntityToDtoMapper.mapToDto(accountEntity); + + expect({ ...ret, _id: accountEntity._id }).toMatchObject(accountEntity); + }); + + it('should ignore missing ids', () => { + const { missingSystemUserIdEntity } = setup(); + + const ret = AccountEntityToDtoMapper.mapToDto(missingSystemUserIdEntity); + + expect(ret.userId).toBeUndefined(); + expect(ret.systemId).toBeUndefined(); + }); }); }); describe('mapSearchResult', () => { - it('should use actual date if date is', () => { - const testEntity1: Account = { - _id: new ObjectId(), - id: '1', - username: '1', - createdAt: new Date(), - updatedAt: new Date(), - }; - const testEntity2: Account = { - _id: new ObjectId(), - id: '2', - username: '2', - createdAt: new Date(), - updatedAt: new Date(), + describe('When mapping multiple Account entities', () => { + const setup = () => { + const testEntity1: Account = accountFactory.buildWithId({}, '000000000000000000000001'); + const testEntity2: Account = accountFactory.buildWithId({}, '000000000000000000000002'); + + const testAmount = 10; + + const testEntities = [testEntity1, testEntity2]; + + return { testEntities, testAmount }; }; - const testAmount = 10; - const [accounts, total] = AccountEntityToDtoMapper.mapSearchResult([[testEntity1, testEntity2], testAmount]); + it('should map exact same amount of entities', () => { + const { testEntities, testAmount } = setup(); - expect(total).toBe(testAmount); - expect(accounts).toHaveLength(2); - expect(accounts).toContainEqual(expect.objectContaining({ id: '1' })); - expect(accounts).toContainEqual(expect.objectContaining({ id: '2' })); + const [accounts, total] = AccountEntityToDtoMapper.mapSearchResult([testEntities, testAmount]); + + expect(total).toBe(testAmount); + expect(accounts).toHaveLength(2); + expect(accounts).toContainEqual(expect.objectContaining({ id: '000000000000000000000001' })); + expect(accounts).toContainEqual(expect.objectContaining({ id: '000000000000000000000002' })); + }); }); }); describe('mapAccountsToDto', () => { - it('should use actual date if date is', () => { - const testEntity1: Account = { - _id: new ObjectId(), - username: '1', - id: '1', - createdAt: new Date(), - updatedAt: new Date(), - }; - const testEntity2: Account = { - _id: new ObjectId(), - username: '2', - id: '2', - createdAt: new Date(), - updatedAt: new Date(), + describe('When mapping multiple Account entities', () => { + const setup = () => { + const testEntity1: Account = accountFactory.buildWithId({}, '000000000000000000000001'); + const testEntity2: Account = accountFactory.buildWithId({}, '000000000000000000000002'); + + const testEntities = [testEntity1, testEntity2]; + + return testEntities; }; - const ret = AccountEntityToDtoMapper.mapAccountsToDto([testEntity1, testEntity2]); - expect(ret).toHaveLength(2); - expect(ret).toContainEqual(expect.objectContaining({ id: '1' })); - expect(ret).toContainEqual(expect.objectContaining({ id: '2' })); + it('should map all entities', () => { + const testEntities = setup(); + + const ret = AccountEntityToDtoMapper.mapAccountsToDto(testEntities); + + expect(ret).toHaveLength(2); + expect(ret).toContainEqual(expect.objectContaining({ id: '000000000000000000000001' })); + expect(ret).toContainEqual(expect.objectContaining({ id: '000000000000000000000002' })); + }); }); }); }); diff --git a/apps/server/src/modules/account/mapper/account-entity-to-dto.mapper.ts b/apps/server/src/modules/account/mapper/account-entity-to-dto.mapper.ts index 417497b3218..d8af59e6716 100644 --- a/apps/server/src/modules/account/mapper/account-entity-to-dto.mapper.ts +++ b/apps/server/src/modules/account/mapper/account-entity-to-dto.mapper.ts @@ -19,6 +19,8 @@ export class AccountEntityToDtoMapper { }); } + // TODO: use Counted instead of [Account[], number] + // TODO: adjust naming of accountEntities static mapSearchResult(accountEntities: [Account[], number]): Counted { const foundAccounts = accountEntities[0]; const accountDtos: AccountDto[] = AccountEntityToDtoMapper.mapAccountsToDto(foundAccounts); diff --git a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.db.spec.ts b/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.db.spec.ts index 2430afe6081..ee7d1644c94 100644 --- a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.db.spec.ts +++ b/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.db.spec.ts @@ -24,9 +24,9 @@ describe('AccountIdmToDtoMapperDb', () => { afterAll(async () => { await module.close(); }); - describe('when mapping from entity to dto', () => { - describe('mapToDto', () => { - it('should map all fields', () => { + describe('mapToDto', () => { + describe('when mapping from entity to dto', () => { + const setup = () => { const testIdmEntity: IdmAccount = { id: 'id', username: 'username', @@ -38,6 +38,12 @@ describe('AccountIdmToDtoMapperDb', () => { attDbcUserId: 'attDbcUserId', attDbcSystemId: 'attDbcSystemId', }; + return testIdmEntity; + }; + + it('should map all fields', () => { + const testIdmEntity = setup(); + const ret = mapper.mapToDto(testIdmEntity); expect(ret).toEqual( @@ -52,30 +58,42 @@ describe('AccountIdmToDtoMapperDb', () => { }) ); }); + }); + + describe('when date is undefined', () => { + const setup = () => { + const testIdmEntity: IdmAccount = { + id: 'id', + }; + return testIdmEntity; + }; - describe('when date is undefined', () => { - it('should use actual date', () => { - const testIdmEntity: IdmAccount = { - id: 'id', - }; - const ret = mapper.mapToDto(testIdmEntity); + it('should use actual date', () => { + const testIdmEntity = setup(); - const now = new Date(); - expect(ret.createdAt).toEqual(now); - expect(ret.updatedAt).toEqual(now); - }); + const ret = mapper.mapToDto(testIdmEntity); + + const now = new Date(); + expect(ret.createdAt).toEqual(now); + expect(ret.updatedAt).toEqual(now); }); + }); - describe('when a fields value is missing', () => { - it('should fill with empty string', () => { - const testIdmEntity: IdmAccount = { - id: 'id', - }; - const ret = mapper.mapToDto(testIdmEntity); + describe('when a fields value is missing', () => { + const setup = () => { + const testIdmEntity: IdmAccount = { + id: 'id', + }; + return testIdmEntity; + }; + + it('should fill with empty string', () => { + const testIdmEntity = setup(); + + const ret = mapper.mapToDto(testIdmEntity); - expect(ret.id).toBe(''); - expect(ret.username).toBe(''); - }); + expect(ret.id).toBe(''); + expect(ret.username).toBe(''); }); }); }); diff --git a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.idm.spec.ts b/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.idm.spec.ts index 0d60a2cc57f..554e2d3025a 100644 --- a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.idm.spec.ts +++ b/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.idm.spec.ts @@ -30,39 +30,52 @@ describe('AccountIdmToDtoMapperIdm', () => { await module.close(); }); - describe('when mapping from entity to dto', () => { - it('should map all fields', () => { - const testIdmEntity: IdmAccount = { - id: 'id', - username: 'username', - email: 'email', - firstName: 'firstName', - lastName: 'lastName', - createdDate: new Date(), - attDbcAccountId: 'attDbcAccountId', - attDbcUserId: 'attDbcUserId', - attDbcSystemId: 'attDbcSystemId', + describe('mapToDto', () => { + describe('when mapping from entity to dto', () => { + const setup = () => { + const testIdmEntity: IdmAccount = { + id: 'id', + username: 'username', + email: 'email', + firstName: 'firstName', + lastName: 'lastName', + createdDate: new Date(), + attDbcAccountId: 'attDbcAccountId', + attDbcUserId: 'attDbcUserId', + attDbcSystemId: 'attDbcSystemId', + }; + return testIdmEntity; }; - const ret = mapper.mapToDto(testIdmEntity); - - expect(ret).toEqual( - expect.objectContaining>({ - id: testIdmEntity.id, - idmReferenceId: undefined, - userId: testIdmEntity.attDbcUserId, - systemId: testIdmEntity.attDbcSystemId, - createdAt: testIdmEntity.createdDate, - updatedAt: testIdmEntity.createdDate, - username: testIdmEntity.username, - }) - ); - }); + it('should map all fields', () => { + const testIdmEntity = setup(); + + const ret = mapper.mapToDto(testIdmEntity); + + expect(ret).toEqual( + expect.objectContaining>({ + id: testIdmEntity.id, + idmReferenceId: undefined, + userId: testIdmEntity.attDbcUserId, + systemId: testIdmEntity.attDbcSystemId, + createdAt: testIdmEntity.createdDate, + updatedAt: testIdmEntity.createdDate, + username: testIdmEntity.username, + }) + ); + }); + }); describe('when date is undefined', () => { - it('should use actual date', () => { + const setup = () => { const testIdmEntity: IdmAccount = { id: 'id', }; + return testIdmEntity; + }; + + it('should use actual date', () => { + const testIdmEntity = setup(); + const ret = mapper.mapToDto(testIdmEntity); expect(ret.createdAt).toEqual(now); @@ -71,10 +84,16 @@ describe('AccountIdmToDtoMapperIdm', () => { }); describe('when a fields value is missing', () => { - it('should fill with empty string', () => { + const setup = () => { const testIdmEntity: IdmAccount = { id: 'id', }; + return testIdmEntity; + }; + + it('should fill with empty string', () => { + const testIdmEntity = setup(); + const ret = mapper.mapToDto(testIdmEntity); expect(ret.username).toBe(''); diff --git a/apps/server/src/modules/account/mapper/account-response.mapper.spec.ts b/apps/server/src/modules/account/mapper/account-response.mapper.spec.ts index e3aa1d06c03..05c345f166b 100644 --- a/apps/server/src/modules/account/mapper/account-response.mapper.spec.ts +++ b/apps/server/src/modules/account/mapper/account-response.mapper.spec.ts @@ -1,62 +1,57 @@ import { Account } from '@shared/domain'; import { AccountDto } from '@src/modules/account/services/dto/account.dto'; -import { ObjectId } from '@mikro-orm/mongodb'; +import { accountDtoFactory, accountFactory } from '@shared/testing'; import { AccountResponseMapper } from '.'; describe('AccountResponseMapper', () => { describe('mapToResponseFromEntity', () => { - it('should map all fields', () => { - const testEntity: Account = { - _id: new ObjectId(), - id: new ObjectId().toString(), - userId: new ObjectId(), - activated: true, - username: 'username', - createdAt: new Date(), - updatedAt: new Date(), - }; - const ret = AccountResponseMapper.mapToResponseFromEntity(testEntity); + describe('When mapping AccountEntity to AccountResponse', () => { + const setup = () => { + const testEntityAllFields: Account = accountFactory.withAllProperties().buildWithId(); - expect(ret.id).toBe(testEntity.id); - expect(ret.userId).toBe(testEntity.userId?.toString()); - expect(ret.activated).toBe(testEntity.activated); - expect(ret.username).toBe(testEntity.username); - expect(ret.updatedAt).toBe(testEntity.updatedAt); - }); + const testEntityMissingUserId: Account = accountFactory.withoutSystemAndUserId().build(); - it('should ignore missing userId', () => { - const testEntity: Account = { - _id: new ObjectId(), - id: new ObjectId().toString(), - userId: undefined, - activated: true, - username: 'username', - createdAt: new Date(), - updatedAt: new Date(), + return { testEntityAllFields, testEntityMissingUserId }; }; - const ret = AccountResponseMapper.mapToResponseFromEntity(testEntity); - expect(ret.userId).toBeUndefined(); + it('should map all fields', () => { + const { testEntityAllFields } = setup(); + + const ret = AccountResponseMapper.mapToResponseFromEntity(testEntityAllFields); + + expect(ret.id).toBe(testEntityAllFields.id); + expect(ret.userId).toBe(testEntityAllFields.userId?.toString()); + expect(ret.activated).toBe(testEntityAllFields.activated); + expect(ret.username).toBe(testEntityAllFields.username); + }); + + it('should ignore missing userId', () => { + const { testEntityMissingUserId } = setup(); + + const ret = AccountResponseMapper.mapToResponseFromEntity(testEntityMissingUserId); + + expect(ret.userId).toBeUndefined(); + }); }); }); describe('mapToResponse', () => { - it('should map all fields', () => { - const testDto: AccountDto = { - id: new ObjectId().toString(), - userId: new ObjectId().toString(), - activated: true, - username: 'username', - createdAt: new Date(), - updatedAt: new Date(), + describe('When mapping AccountDto to AccountResponse', () => { + const setup = () => { + const testDto: AccountDto = accountDtoFactory.buildWithId(); + return testDto; }; - const ret = AccountResponseMapper.mapToResponse(testDto); - expect(ret.id).toBe(testDto.id); - expect(ret.userId).toBe(testDto.userId?.toString()); - expect(ret.activated).toBe(testDto.activated); - expect(ret.username).toBe(testDto.username); - expect(ret.updatedAt).toBe(testDto.updatedAt); + it('should map all fields', () => { + const testDto = setup(); + + const ret = AccountResponseMapper.mapToResponse(testDto); + + expect(ret.id).toBe(testDto.id); + expect(ret.userId).toBe(testDto.userId?.toString()); + expect(ret.activated).toBe(testDto.activated); + expect(ret.username).toBe(testDto.username); + }); }); }); }); diff --git a/apps/server/src/modules/account/mapper/account-response.mapper.ts b/apps/server/src/modules/account/mapper/account-response.mapper.ts index 12d9227163b..94437737df9 100644 --- a/apps/server/src/modules/account/mapper/account-response.mapper.ts +++ b/apps/server/src/modules/account/mapper/account-response.mapper.ts @@ -3,6 +3,7 @@ import { AccountDto } from '@src/modules/account/services/dto/account.dto'; import { AccountResponse } from '../controller/dto'; export class AccountResponseMapper { + // TODO: remove this one static mapToResponseFromEntity(account: Account): AccountResponse { return new AccountResponse({ id: account.id, diff --git a/apps/server/src/modules/account/repo/account.repo.integration.spec.ts b/apps/server/src/modules/account/repo/account.repo.integration.spec.ts index bf4a44119fe..1b1193cf8a4 100644 --- a/apps/server/src/modules/account/repo/account.repo.integration.spec.ts +++ b/apps/server/src/modules/account/repo/account.repo.integration.spec.ts @@ -10,7 +10,6 @@ describe('account repo', () => { let module: TestingModule; let em: EntityManager; let repo: AccountRepo; - let mockAccounts: Account[]; beforeAll(async () => { module = await Test.createTestingModule({ @@ -25,16 +24,6 @@ describe('account repo', () => { await module.close(); }); - beforeEach(async () => { - mockAccounts = [ - accountFactory.build({ username: 'John Doe' }), - accountFactory.build({ username: 'Marry Doe' }), - accountFactory.build({ username: 'Susi Doe' }), - accountFactory.build({ username: 'Tim Doe' }), - ]; - await em.persistAndFlush(mockAccounts); - }); - afterEach(async () => { await cleanupCollections(em); }); @@ -44,183 +33,340 @@ describe('account repo', () => { }); describe('findByUserId', () => { - it('should findByUserId', async () => { - const accountToFind = accountFactory.build(); - await em.persistAndFlush(accountToFind); - em.clear(); - const account = await repo.findByUserId(accountToFind.userId ?? ''); - expect(account?.id).toEqual(accountToFind.id); + describe('When calling findByUserId with id', () => { + const setup = async () => { + const accountToFind = accountFactory.build(); + await em.persistAndFlush(accountToFind); + em.clear(); + return accountToFind; + }; + + it('should find user with id', async () => { + const accountToFind = await setup(); + const account = await repo.findByUserId(accountToFind.userId ?? ''); + expect(account?.id).toEqual(accountToFind.id); + }); }); }); describe('findByUsernameAndSystemId', () => { - it('should return account', async () => { - const accountToFind = accountFactory.withSystemId(new ObjectId(10)).build(); - await em.persistAndFlush(accountToFind); - em.clear(); - const account = await repo.findByUsernameAndSystemId(accountToFind.username ?? '', accountToFind.systemId ?? ''); - expect(account?.username).toEqual(accountToFind.username); + describe('When username and systemId are given', () => { + const setup = async () => { + const accountToFind = accountFactory.withSystemId(new ObjectId(10)).build(); + await em.persistAndFlush(accountToFind); + em.clear(); + return accountToFind; + }; + + it('should return account', async () => { + const accountToFind = await setup(); + const account = await repo.findByUsernameAndSystemId( + accountToFind.username ?? '', + accountToFind.systemId ?? '' + ); + expect(account?.username).toEqual(accountToFind.username); + }); }); - it('should return null', async () => { - const account = await repo.findByUsernameAndSystemId('', new ObjectId(undefined)); - expect(account).toBeNull(); + + describe('When username and systemId are not given', () => { + it('should return null', async () => { + const account = await repo.findByUsernameAndSystemId('', new ObjectId(undefined)); + expect(account).toBeNull(); + }); }); }); describe('findMultipleByUserId', () => { - it('should find multiple user by id', async () => { - const anAccountToFind = accountFactory.build(); - const anotherAccountToFind = accountFactory.build(); - await em.persistAndFlush(anAccountToFind); - await em.persistAndFlush(anotherAccountToFind); - em.clear(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const accounts = await repo.findMultipleByUserId([anAccountToFind.userId!, anotherAccountToFind.userId!]); - expect(accounts).toContainEqual(anAccountToFind); - expect(accounts).toContainEqual(anotherAccountToFind); - expect(accounts).toHaveLength(2); + describe('When multiple user ids are given', () => { + const setup = async () => { + const anAccountToFind = accountFactory.build(); + const anotherAccountToFind = accountFactory.build(); + await em.persistAndFlush(anAccountToFind); + await em.persistAndFlush(anotherAccountToFind); + em.clear(); + + return { anAccountToFind, anotherAccountToFind }; + }; + + it('should find multiple users', async () => { + const { anAccountToFind, anotherAccountToFind } = await setup(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const accounts = await repo.findMultipleByUserId([anAccountToFind.userId!, anotherAccountToFind.userId!]); + expect(accounts).toContainEqual(anAccountToFind); + expect(accounts).toContainEqual(anotherAccountToFind); + expect(accounts).toHaveLength(2); + }); }); - it('should return empty list if no results', async () => { - const accountToFind = accountFactory.build(); - await em.persistAndFlush(accountToFind); - em.clear(); - const accounts = await repo.findMultipleByUserId(['123456789012', '098765432101']); - expect(accounts).toHaveLength(0); + describe('When not existing user ids are given', () => { + it('should return empty list', async () => { + const accounts = await repo.findMultipleByUserId(['123456789012', '098765432101']); + expect(accounts).toHaveLength(0); + }); }); }); describe('findByUserIdOrFail', () => { - it('should find a user by id', async () => { - const accountToFind = accountFactory.build(); - await em.persistAndFlush(accountToFind); - em.clear(); - const account = await repo.findByUserIdOrFail(accountToFind.userId ?? ''); - expect(account.id).toEqual(accountToFind.id); + describe('When existing id is given', () => { + const setup = async () => { + const accountToFind = accountFactory.build(); + await em.persistAndFlush(accountToFind); + em.clear(); + return accountToFind; + }; + it('should find a user', async () => { + const accountToFind = await setup(); + const account = await repo.findByUserIdOrFail(accountToFind.userId ?? ''); + expect(account.id).toEqual(accountToFind.id); + }); }); - it('should throw if id does not exist', async () => { - const accountToFind = accountFactory.build(); - await em.persistAndFlush(accountToFind); - em.clear(); - await expect(repo.findByUserIdOrFail('123456789012')).rejects.toThrow(NotFoundError); + describe('When id does not exist', () => { + it('should throw not found error', async () => { + await expect(repo.findByUserIdOrFail('123456789012')).rejects.toThrow(NotFoundError); + }); }); }); describe('getObjectReference', () => { - it('should return a valid reference', async () => { - const user = userFactory.buildWithId(); - const account = accountFactory.build({ userId: user.id }); - await em.persistAndFlush([user, account]); - - const reference = repo.getObjectReference(User, account.userId ?? ''); - - expect(reference).toBe(user); + describe('When a user id is given', () => { + const setup = async () => { + const user = userFactory.buildWithId(); + const account = accountFactory.build({ userId: user.id }); + await em.persistAndFlush([user, account]); + return { user, account }; + }; + it('should return a valid reference', async () => { + const { user, account } = await setup(); + + const reference = repo.getObjectReference(User, account.userId ?? ''); + + expect(reference).toBe(user); + }); }); }); describe('saveWithoutFlush', () => { - it('should add an account to the persist stack', () => { - const account = accountFactory.build(); - - repo.saveWithoutFlush(account); - expect(em.getUnitOfWork().getPersistStack().size).toBe(1); + describe('When calling saveWithoutFlush', () => { + const setup = () => { + const account = accountFactory.build(); + return account; + }; + it('should add an account to the persist stack', () => { + const account = setup(); + + repo.saveWithoutFlush(account); + expect(em.getUnitOfWork().getPersistStack().size).toBe(1); + }); }); }); describe('flush', () => { - it('should flush after save', async () => { - const account = accountFactory.build(); - em.persist(account); + describe('When repo is flushed', () => { + const setup = () => { + const account = accountFactory.build(); + em.persist(account); + return account; + }; + + it('should save account', async () => { + const account = setup(); - expect(account.id).toBeNull(); + expect(account.id).toBeNull(); - await repo.flush(); + await repo.flush(); - expect(account.id).not.toBeNull(); + expect(account.id).not.toBeNull(); + }); }); }); - describe('findByUsername', () => { - it('should find account by user name', async () => { - const originalUsername = 'USER@EXAMPLE.COM'; - const account = accountFactory.build({ username: originalUsername }); - await em.persistAndFlush([account]); - em.clear(); - - const [result] = await repo.searchByUsernameExactMatch('USER@EXAMPLE.COM'); - expect(result).toHaveLength(1); - expect(result[0]).toEqual(expect.objectContaining({ username: originalUsername })); - - const [result2] = await repo.searchByUsernamePartialMatch('user'); - expect(result2).toHaveLength(1); - expect(result2[0]).toEqual(expect.objectContaining({ username: originalUsername })); + describe('searchByUsernamePartialMatch', () => { + describe('When searching with a partial user name', () => { + const setup = async () => { + const originalUsername = 'USER@EXAMPLE.COM'; + const partialUsername = 'user'; + const account = accountFactory.build({ username: originalUsername }); + await em.persistAndFlush([account]); + em.clear(); + return { originalUsername, partialUsername, account }; + }; + + it('should find exact one user', async () => { + const { originalUsername, partialUsername } = await setup(); + const [result] = await repo.searchByUsernamePartialMatch(partialUsername); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(expect.objectContaining({ username: originalUsername })); + }); }); - it('should find account by user name, ignoring case', async () => { - const originalUsername = 'USER@EXAMPLE.COM'; - const account = accountFactory.build({ username: originalUsername }); - await em.persistAndFlush([account]); - em.clear(); - - let [accounts] = await repo.searchByUsernameExactMatch('USER@example.COM'); - expect(accounts).toHaveLength(1); - expect(accounts[0]).toEqual(expect.objectContaining({ username: originalUsername })); + }); - [accounts] = await repo.searchByUsernameExactMatch('user@example.com'); - expect(accounts).toHaveLength(1); - expect(accounts[0]).toEqual(expect.objectContaining({ username: originalUsername })); + describe('searchByUsernameExactMatch', () => { + describe('When searching for an exact match', () => { + const setup = async () => { + const originalUsername = 'USER@EXAMPLE.COM'; + const account = accountFactory.build({ username: originalUsername }); + await em.persistAndFlush([account]); + em.clear(); + return { originalUsername, account }; + }; + + it('should find exact one account', async () => { + const { originalUsername } = await setup(); + + const [result] = await repo.searchByUsernameExactMatch(originalUsername); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(expect.objectContaining({ username: originalUsername })); + }); }); - it('should not find by wildcard', async () => { - const originalUsername = 'USER@EXAMPLE.COM'; - const account = accountFactory.build({ username: originalUsername }); - await em.persistAndFlush([account]); - em.clear(); - let [accounts] = await repo.searchByUsernameExactMatch('USER@EXAMPLECCOM'); - expect(accounts).toHaveLength(0); + describe('When searching by username', () => { + const setup = async () => { + const originalUsername = 'USER@EXAMPLE.COM'; + const partialLowerCaseUsername = 'USER@example.COM'; + const lowercaseUsername = 'user@example.com'; + const account = accountFactory.build({ username: originalUsername }); + await em.persistAndFlush([account]); + em.clear(); + return { originalUsername, partialLowerCaseUsername, lowercaseUsername, account }; + }; + + it('should find account by user name, ignoring case', async () => { + const { originalUsername, partialLowerCaseUsername, lowercaseUsername } = await setup(); + + let [accounts] = await repo.searchByUsernameExactMatch(partialLowerCaseUsername); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toEqual(expect.objectContaining({ username: originalUsername })); + + [accounts] = await repo.searchByUsernameExactMatch(lowercaseUsername); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toEqual(expect.objectContaining({ username: originalUsername })); + }); + }); - [accounts] = await repo.searchByUsernameExactMatch('.*'); - expect(accounts).toHaveLength(0); + describe('When using wildcard', () => { + const setup = async () => { + const originalUsername = 'USER@EXAMPLE.COM'; + const missingDotUserName = 'USER@EXAMPLECCOM'; + const wildcard = '.*'; + const account = accountFactory.build({ username: originalUsername }); + await em.persistAndFlush([account]); + em.clear(); + return { originalUsername, missingDotUserName, wildcard, account }; + }; + + it('should not find account', async () => { + const { missingDotUserName, wildcard } = await setup(); + + let [accounts] = await repo.searchByUsernameExactMatch(missingDotUserName); + expect(accounts).toHaveLength(0); + + [accounts] = await repo.searchByUsernameExactMatch(wildcard); + expect(accounts).toHaveLength(0); + }); }); }); - describe('deleteId', () => { - it('should delete an account by id', async () => { - const account = accountFactory.buildWithId(); - await em.persistAndFlush([account]); + describe('deleteById', () => { + describe('When an id is given', () => { + const setup = async () => { + const account = accountFactory.buildWithId(); + await em.persistAndFlush([account]); + + return account; + }; - await expect(repo.deleteById(account.id)).resolves.not.toThrow(); + it('should delete an account by id', async () => { + const account = await setup(); - await expect(repo.findById(account.id)).rejects.toThrow(NotFoundError); + await expect(repo.deleteById(account.id)).resolves.not.toThrow(); + + await expect(repo.findById(account.id)).rejects.toThrow(NotFoundError); + }); }); }); describe('deleteByUserId', () => { - it('should delete an account by user id', async () => { - const user = userFactory.buildWithId(); - const account = accountFactory.build({ userId: user.id }); - await em.persistAndFlush([user, account]); + describe('When an user id is given', () => { + const setup = async () => { + const user = userFactory.buildWithId(); + const account = accountFactory.build({ userId: user.id }); + await em.persistAndFlush([user, account]); + + return { user, account }; + }; - await expect(repo.deleteByUserId(user.id)).resolves.not.toThrow(); + it('should delete an account by user id', async () => { + const { user, account } = await setup(); - await expect(repo.findById(account.id)).rejects.toThrow(NotFoundError); + await expect(repo.deleteByUserId(user.id)).resolves.not.toThrow(); + + await expect(repo.findById(account.id)).rejects.toThrow(NotFoundError); + }); }); }); describe('findMany', () => { - it('should find all accounts', async () => { - const foundAccounts = await repo.findMany(); - expect(foundAccounts).toEqual(mockAccounts); - }); - it('limit the result set ', async () => { - const limit = 1; - const foundAccounts = await repo.findMany(0, limit); - expect(foundAccounts).toHaveLength(limit); - }); - it('skip n entries ', async () => { - const offset = 2; - const foundAccounts = await repo.findMany(offset); - expect(foundAccounts).toHaveLength(mockAccounts.length - offset); + describe('When no limit and offset are given', () => { + const setup = async () => { + const mockAccounts = [ + accountFactory.build({ username: 'John Doe' }), + accountFactory.build({ username: 'Marry Doe' }), + accountFactory.build({ username: 'Susi Doe' }), + accountFactory.build({ username: 'Tim Doe' }), + ]; + await em.persistAndFlush(mockAccounts); + return mockAccounts; + }; + + it('should find all accounts', async () => { + const mockAccounts = await setup(); + const foundAccounts = await repo.findMany(); + expect(foundAccounts).toEqual(mockAccounts); + }); + }); + + describe('When limit is given', () => { + const setup = async () => { + const limit = 1; + + const mockAccounts = [ + accountFactory.build({ username: 'John Doe' }), + accountFactory.build({ username: 'Marry Doe' }), + accountFactory.build({ username: 'Susi Doe' }), + accountFactory.build({ username: 'Tim Doe' }), + ]; + await em.persistAndFlush(mockAccounts); + return { limit, mockAccounts }; + }; + + it('should limit the result set', async () => { + const { limit } = await setup(); + const foundAccounts = await repo.findMany(0, limit); + expect(foundAccounts).toHaveLength(limit); + }); + }); + + describe('When offset is given', () => { + const setup = async () => { + const offset = 2; + + const mockAccounts = [ + accountFactory.build({ username: 'John Doe' }), + accountFactory.build({ username: 'Marry Doe' }), + accountFactory.build({ username: 'Susi Doe' }), + accountFactory.build({ username: 'Tim Doe' }), + ]; + await em.persistAndFlush(mockAccounts); + return { offset, mockAccounts }; + }; + + it('should skip n entries', async () => { + const { offset, mockAccounts } = await setup(); + + const foundAccounts = await repo.findMany(offset); + expect(foundAccounts).toHaveLength(mockAccounts.length - offset); + }); }); }); }); diff --git a/apps/server/src/modules/account/repo/account.repo.ts b/apps/server/src/modules/account/repo/account.repo.ts index fb68f0a759b..e848973c5c6 100644 --- a/apps/server/src/modules/account/repo/account.repo.ts +++ b/apps/server/src/modules/account/repo/account.repo.ts @@ -15,7 +15,9 @@ export class AccountRepo extends BaseRepo { * Finds an account by user id. * @param userId the user id */ + // TODO: here only EntityIds should arrive async findByUserId(userId: EntityId | ObjectId): Promise { + // TODO: you can use userId directly, without constructing an objectId return this._em.findOne(Account, { userId: new ObjectId(userId) }); } @@ -47,6 +49,8 @@ export class AccountRepo extends BaseRepo { await this._em.flush(); } + // TODO: the default values for skip and limit, are they required and/or correct here? + // TODO: use counted for the return type async searchByUsernameExactMatch(username: string, skip = 0, limit = 1): Promise<[Account[], number]> { return this.searchByUsername(username, skip, limit, true); } @@ -80,6 +84,7 @@ export class AccountRepo extends BaseRepo { limit: number, exactMatch: boolean ): Promise<[Account[], number]> { + // TODO: check that injections are not possible, eg make sure sanitizeHTML has been called at some point (for username) // escapes every character, that's not a unicode letter or number const escapedUsername = username.replace(/[^(\p{L}\p{N})]/gu, '\\$&'); const searchUsername = exactMatch ? `^${escapedUsername}$` : escapedUsername; diff --git a/apps/server/src/modules/account/review-comments.md b/apps/server/src/modules/account/review-comments.md new file mode 100644 index 00000000000..fc636019cdd --- /dev/null +++ b/apps/server/src/modules/account/review-comments.md @@ -0,0 +1,12 @@ +# Review Comments 14.7.23 + +- move mapper into repo folder +- write an md file or flow diagram describing how things work +- in what layer do the services belong? + +- naming of DO vs Entity (DO is the leading, "Account", entity is just the datalayer representation "AccountEntity") + +- new decisions for loggables + + +looked at this module only. \ No newline at end of file diff --git a/apps/server/src/modules/account/services/account-db.service.spec.ts b/apps/server/src/modules/account/services/account-db.service.spec.ts index 777ab61a1ad..8cc6a33cbb6 100644 --- a/apps/server/src/modules/account/services/account-db.service.spec.ts +++ b/apps/server/src/modules/account/services/account-db.service.spec.ts @@ -3,9 +3,9 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityNotFoundError } from '@shared/common'; -import { Account, EntityId, Permission, Role, RoleName, SchoolEntity, User } from '@shared/domain'; +import { Account, EntityId } from '@shared/domain'; import { IdentityManagementService } from '@shared/infra/identity-management/identity-management.service'; -import { accountFactory, schoolFactory, setupEntities, userFactory } from '@shared/testing'; +import { accountFactory, setupEntities, userFactory } from '@shared/testing'; import { AccountEntityToDtoMapper } from '@src/modules/account/mapper'; import { AccountDto } from '@src/modules/account/services/dto'; import { IServerConfig } from '@src/modules/server'; @@ -19,23 +19,11 @@ import { AbstractAccountService } from './account.service.abstract'; describe('AccountDbService', () => { let module: TestingModule; let accountService: AbstractAccountService; - let mockAccounts: Account[]; - let accountRepo: AccountRepo; + let accountRepo: DeepMocked; let accountLookupServiceMock: DeepMocked; const defaultPassword = 'DummyPasswd!1'; - let mockSchool: SchoolEntity; - - let mockTeacherUser: User; - let mockStudentUser: User; - let mockUserWithoutAccount: User; - - let mockTeacherAccount: Account; - let mockStudentAccount: Account; - - let mockAccountWithSystemId: Account; - afterAll(async () => { await module.close(); }); @@ -47,69 +35,7 @@ describe('AccountDbService', () => { AccountLookupService, { provide: AccountRepo, - useValue: { - save: jest.fn().mockImplementation((account: Account): Promise => { - if (account.username === 'fail@to.update') { - return Promise.reject(); - } - const accountEntity = mockAccounts.find((tempAccount) => tempAccount.userId === account.userId); - if (accountEntity) { - Object.assign(accountEntity, account); - } - - return Promise.resolve(); - }), - deleteById: jest.fn().mockImplementation((): Promise => Promise.resolve()), - findMultipleByUserId: (userIds: EntityId[]): Promise => { - const accounts = mockAccounts.filter((tempAccount) => - userIds.find((userId) => tempAccount.userId?.toString() === userId) - ); - return Promise.resolve(accounts); - }, - findByUserId: (userId: EntityId): Promise => { - const account = mockAccounts.find((tempAccount) => tempAccount.userId?.toString() === userId); - if (account) { - return Promise.resolve(account); - } - return Promise.resolve(null); - }, - findByUserIdOrFail: (userId: EntityId): Promise => { - const account = mockAccounts.find((tempAccount) => tempAccount.userId?.toString() === userId); - - if (account) { - return Promise.resolve(account); - } - throw new EntityNotFoundError(Account.name); - }, - findByUsernameAndSystemId: (username: string, systemId: EntityId | ObjectId): Promise => { - const account = mockAccounts.find( - (tempAccount) => tempAccount.username === username && tempAccount.systemId === systemId - ); - if (account) { - return Promise.resolve(account); - } - return Promise.resolve(null); - }, - - findById: jest.fn().mockImplementation((accountId: EntityId | ObjectId): Promise => { - const account = mockAccounts.find((tempAccount) => tempAccount.id === accountId.toString()); - - if (account) { - return Promise.resolve(account); - } - throw new EntityNotFoundError(Account.name); - }), - searchByUsernameExactMatch: jest - .fn() - .mockImplementation((): Promise<[Account[], number]> => Promise.resolve([[mockTeacherAccount], 1])), - searchByUsernamePartialMatch: jest - .fn() - .mockImplementation( - (): Promise<[Account[], number]> => Promise.resolve([mockAccounts, mockAccounts.length]) - ), - deleteByUserId: jest.fn().mockImplementation((): Promise => Promise.resolve()), - findMany: jest.fn().mockImplementation((): Promise => Promise.resolve(mockAccounts)), - }, + useValue: createMock(), }, { provide: LegacyLogger, @@ -125,14 +51,7 @@ describe('AccountDbService', () => { }, { provide: AccountLookupService, - useValue: createMock({ - getInternalId: (id: EntityId | ObjectId): Promise => { - if (ObjectId.isValid(id)) { - return Promise.resolve(new ObjectId(id)); - } - return Promise.resolve(null); - }, - }), + useValue: createMock(), }, ], }).compile(); @@ -143,28 +62,9 @@ describe('AccountDbService', () => { }); beforeEach(() => { + jest.resetAllMocks(); jest.useFakeTimers(); jest.setSystemTime(new Date(2020, 1, 1)); - - mockSchool = schoolFactory.buildWithId(); - - mockTeacherUser = userFactory.buildWithId({ - school: mockSchool, - roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], - }); - mockStudentUser = userFactory.buildWithId({ - school: mockSchool, - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - mockUserWithoutAccount = userFactory.buildWithId({ - school: mockSchool, - roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], - }); - mockTeacherAccount = accountFactory.buildWithId({ userId: mockTeacherUser.id, password: defaultPassword }); - mockStudentAccount = accountFactory.buildWithId({ userId: mockStudentUser.id, password: defaultPassword }); - - mockAccountWithSystemId = accountFactory.withSystemId(new ObjectId()).build(); - mockAccounts = [mockTeacherAccount, mockStudentAccount, mockAccountWithSystemId]; }); afterEach(() => { @@ -173,294 +73,615 @@ describe('AccountDbService', () => { }); describe('findById', () => { - it( - 'should return accountDto', - async () => { - const resultAccount = await accountService.findById(mockTeacherAccount.id); - expect(resultAccount).toEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); - }, - 10 * 60 * 1000 - ); + describe('when searching by Id', () => { + const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); + + mockTeacherAccountDto.username = 'changedUsername@example.org'; + mockTeacherAccountDto.activated = false; + + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + + return { mockTeacherAccount }; + }; + it( + 'should return accountDto', + async () => { + const { mockTeacherAccount } = setup(); + + const resultAccount = await accountService.findById(mockTeacherAccount.id); + expect(resultAccount).toEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + }, + 10 * 60 * 1000 + ); + }); }); describe('findByUserId', () => { - it('should return accountDto', async () => { - const resultAccount = await accountService.findByUserId(mockTeacherUser.id); - expect(resultAccount).toEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + describe('when user id exists', () => { + const setup = () => { + const mockTeacherUser = userFactory.buildWithId(); + + const mockTeacherAccount = accountFactory.buildWithId(); + + accountRepo.findByUserId.mockImplementation((userId: EntityId | ObjectId): Promise => { + if (userId === mockTeacherUser.id) { + return Promise.resolve(mockTeacherAccount); + } + return Promise.resolve(null); + }); + + return { mockTeacherUser, mockTeacherAccount }; + }; + it('should return accountDto', async () => { + const { mockTeacherUser, mockTeacherAccount } = setup(); + const resultAccount = await accountService.findByUserId(mockTeacherUser.id); + expect(resultAccount).toEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + }); }); - it('should return null', async () => { - const resultAccount = await accountService.findByUserId('nonExistentId'); - expect(resultAccount).toBeNull(); + + describe('when user id not exists', () => { + it('should return null', async () => { + const resultAccount = await accountService.findByUserId('nonExistentId'); + expect(resultAccount).toBeNull(); + }); }); }); describe('findByUsernameAndSystemId', () => { - it('should return accountDto', async () => { - const resultAccount = await accountService.findByUsernameAndSystemId( - mockAccountWithSystemId.username, - mockAccountWithSystemId.systemId ?? '' - ); - expect(resultAccount).not.toBe(undefined); + describe('when user name and system id exists', () => { + const setup = () => { + const mockAccountWithSystemId = accountFactory.withSystemId(new ObjectId()).build(); + accountRepo.findByUsernameAndSystemId.mockResolvedValue(mockAccountWithSystemId); + return { mockAccountWithSystemId }; + }; + it('should return accountDto', async () => { + const { mockAccountWithSystemId } = setup(); + const resultAccount = await accountService.findByUsernameAndSystemId( + mockAccountWithSystemId.username, + mockAccountWithSystemId.systemId ?? '' + ); + expect(resultAccount).not.toBe(undefined); + }); }); - it('should return null if username does not exist', async () => { - const resultAccount = await accountService.findByUsernameAndSystemId( - 'nonExistentUsername', - mockAccountWithSystemId.systemId ?? '' - ); - expect(resultAccount).toBeNull(); + + describe('when only system id exists', () => { + const setup = () => { + const mockAccountWithSystemId = accountFactory.withSystemId(new ObjectId()).build(); + accountRepo.findByUsernameAndSystemId.mockImplementation( + (username: string, systemId: EntityId | ObjectId): Promise => { + if (mockAccountWithSystemId.username === username && mockAccountWithSystemId.systemId === systemId) { + return Promise.resolve(mockAccountWithSystemId); + } + return Promise.resolve(null); + } + ); + return { mockAccountWithSystemId }; + }; + it('should return null if username does not exist', async () => { + const { mockAccountWithSystemId } = setup(); + const resultAccount = await accountService.findByUsernameAndSystemId( + 'nonExistentUsername', + mockAccountWithSystemId.systemId ?? '' + ); + expect(resultAccount).toBeNull(); + }); }); - it('should return null if system id does not exist', async () => { - const resultAccount = await accountService.findByUsernameAndSystemId( - mockAccountWithSystemId.username, - 'nonExistentSystemId' ?? '' - ); - expect(resultAccount).toBeNull(); + + describe('when only user name exists', () => { + const setup = () => { + const mockAccountWithSystemId = accountFactory.withSystemId(new ObjectId()).build(); + accountRepo.findByUsernameAndSystemId.mockImplementation( + (username: string, systemId: EntityId | ObjectId): Promise => { + if (mockAccountWithSystemId.username === username && mockAccountWithSystemId.systemId === systemId) { + return Promise.resolve(mockAccountWithSystemId); + } + return Promise.resolve(null); + } + ); + return { mockAccountWithSystemId }; + }; + it('should return null if system id does not exist', async () => { + const { mockAccountWithSystemId } = setup(); + const resultAccount = await accountService.findByUsernameAndSystemId( + mockAccountWithSystemId.username, + 'nonExistentSystemId' ?? '' + ); + expect(resultAccount).toBeNull(); + }); }); }); describe('findMultipleByUserId', () => { - it('should return multiple accountDtos', async () => { - const resultAccounts = await accountService.findMultipleByUserId([mockTeacherUser.id, mockStudentUser.id]); - expect(resultAccounts).toContainEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); - expect(resultAccounts).toContainEqual(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); - expect(resultAccounts).toHaveLength(2); + describe('when searching for multiple existing ids', () => { + const setup = () => { + const mockTeacherUser = userFactory.buildWithId(); + const mockStudentUser = userFactory.buildWithId(); + + const mockTeacherAccount = accountFactory.buildWithId({ + userId: mockTeacherUser.id, + password: defaultPassword, + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPassword, + }); + + accountRepo.findMultipleByUserId.mockImplementation((userIds: (EntityId | ObjectId)[]): Promise => { + const accounts = [mockStudentAccount, mockTeacherAccount].filter((tempAccount) => + userIds.find((userId) => tempAccount.userId?.toString() === userId) + ); + return Promise.resolve(accounts); + }); + return { mockStudentUser, mockStudentAccount, mockTeacherUser, mockTeacherAccount }; + }; + it('should return multiple accountDtos', async () => { + const { mockStudentUser, mockStudentAccount, mockTeacherUser, mockTeacherAccount } = setup(); + const resultAccounts = await accountService.findMultipleByUserId([mockTeacherUser.id, mockStudentUser.id]); + expect(resultAccounts).toContainEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + expect(resultAccounts).toContainEqual(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + expect(resultAccounts).toHaveLength(2); + }); }); - it('should return empty array on mismatch', async () => { - const resultAccount = await accountService.findMultipleByUserId(['nonExistentId1']); - expect(resultAccount).toHaveLength(0); + + describe('when only user name exists', () => { + const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + const mockStudentAccount = accountFactory.buildWithId(); + + accountRepo.findMultipleByUserId.mockImplementation((userIds: (EntityId | ObjectId)[]): Promise => { + const accounts = [mockStudentAccount, mockTeacherAccount].filter((tempAccount) => + userIds.find((userId) => tempAccount.userId?.toString() === userId) + ); + return Promise.resolve(accounts); + }); + return {}; + }; + it('should return empty array on mismatch', async () => { + setup(); + const resultAccount = await accountService.findMultipleByUserId(['nonExistentId1']); + expect(resultAccount).toHaveLength(0); + }); }); }); describe('findByUserIdOrFail', () => { - it('should return accountDto', async () => { - const resultAccount = await accountService.findByUserIdOrFail(mockTeacherUser.id); - expect(resultAccount).toEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + describe('when user exists', () => { + const setup = () => { + const mockTeacherUser = userFactory.buildWithId(); + const mockTeacherAccount = accountFactory.buildWithId({ + userId: mockTeacherUser.id, + password: defaultPassword, + }); + + accountRepo.findByUserIdOrFail.mockResolvedValue(mockTeacherAccount); + + return { mockTeacherUser, mockTeacherAccount }; + }; + + it('should return accountDto', async () => { + const { mockTeacherUser, mockTeacherAccount } = setup(); + const resultAccount = await accountService.findByUserIdOrFail(mockTeacherUser.id); + expect(resultAccount).toEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + }); }); - it('should throw EntityNotFoundError', async () => { - await expect(accountService.findByUserIdOrFail('nonExistentId')).rejects.toThrow(EntityNotFoundError); + + describe('when user does not exist', () => { + const setup = () => { + const mockTeacherUser = userFactory.buildWithId(); + const mockTeacherAccount = accountFactory.buildWithId({ + userId: mockTeacherUser.id, + password: defaultPassword, + }); + accountRepo.findByUserIdOrFail.mockImplementation((userId: EntityId | ObjectId): Promise => { + if (mockTeacherUser.id === userId) { + return Promise.resolve(mockTeacherAccount); + } + throw new EntityNotFoundError(Account.name); + }); + return {}; + }; + it('should throw EntityNotFoundError', async () => { + setup(); + await expect(accountService.findByUserIdOrFail('nonExistentId')).rejects.toThrow(EntityNotFoundError); + }); }); }); describe('save', () => { - it('should update an existing account', async () => { - const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); - mockTeacherAccountDto.username = 'changedUsername@example.org'; - mockTeacherAccountDto.activated = false; - const ret = await accountService.save(mockTeacherAccountDto); - - expect(ret).toBeDefined(); - expect(ret).toMatchObject({ - id: mockTeacherAccount.id, - username: mockTeacherAccountDto.username, - activated: mockTeacherAccountDto.activated, - systemId: mockTeacherAccount.systemId, - userId: mockTeacherAccount.userId, + describe('when update an existing account', () => { + const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); + + mockTeacherAccountDto.username = 'changedUsername@example.org'; + mockTeacherAccountDto.activated = false; + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountLookupServiceMock.getInternalId.mockResolvedValue(mockTeacherAccount._id); + accountRepo.save.mockResolvedValue(); + + return { mockTeacherAccountDto, mockTeacherAccount }; + }; + + it('should update account', async () => { + const { mockTeacherAccountDto, mockTeacherAccount } = setup(); + const ret = await accountService.save(mockTeacherAccountDto); + + expect(accountRepo.save).toBeCalledTimes(1); + expect(ret).toBeDefined(); + expect(ret).toMatchObject({ + id: mockTeacherAccount.id, + username: mockTeacherAccountDto.username, + activated: mockTeacherAccountDto.activated, + systemId: mockTeacherAccount.systemId, + userId: mockTeacherAccount.userId, + }); }); }); - it("should update an existing account's system", async () => { - const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); - mockTeacherAccountDto.username = 'changedUsername@example.org'; - mockTeacherAccountDto.systemId = '123456789012'; - const ret = await accountService.save(mockTeacherAccountDto); - expect(ret).toBeDefined(); - expect(ret).toMatchObject({ - id: mockTeacherAccount.id, - username: mockTeacherAccountDto.username, - activated: mockTeacherAccount.activated, - systemId: new ObjectId(mockTeacherAccountDto.systemId), - userId: mockTeacherAccount.userId, + describe("when update an existing account's system", () => { + const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); + + mockTeacherAccountDto.username = 'changedUsername@example.org'; + mockTeacherAccountDto.systemId = '123456789012'; + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountLookupServiceMock.getInternalId.mockResolvedValue(mockTeacherAccount._id); + accountRepo.save.mockResolvedValue(); + + return { mockTeacherAccountDto, mockTeacherAccount }; + }; + it("should update an existing account's system", async () => { + const { mockTeacherAccountDto, mockTeacherAccount } = setup(); + + const ret = await accountService.save(mockTeacherAccountDto); + expect(ret).toBeDefined(); + expect(ret).toMatchObject({ + id: mockTeacherAccount.id, + username: mockTeacherAccountDto.username, + activated: mockTeacherAccount.activated, + systemId: new ObjectId(mockTeacherAccountDto.systemId), + userId: mockTeacherAccount.userId, + }); }); }); - it("should update an existing account's user", async () => { - const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); - mockTeacherAccountDto.username = 'changedUsername@example.org'; - mockTeacherAccountDto.userId = mockStudentUser.id; - const ret = await accountService.save(mockTeacherAccountDto); - expect(ret).toBeDefined(); - expect(ret).toMatchObject({ - id: mockTeacherAccount.id, - username: mockTeacherAccountDto.username, - activated: mockTeacherAccount.activated, - systemId: mockTeacherAccount.systemId, - userId: new ObjectId(mockStudentUser.id), + + describe("when update an existing account's user", () => { + const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + const mockStudentUser = accountFactory.buildWithId(); + const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); + + mockTeacherAccountDto.username = 'changedUsername@example.org'; + mockTeacherAccountDto.userId = mockStudentUser.id; + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountLookupServiceMock.getInternalId.mockResolvedValue(mockTeacherAccount._id); + accountRepo.save.mockResolvedValue(); + + return { mockStudentUser, mockTeacherAccountDto, mockTeacherAccount }; + }; + it('should update account', async () => { + const { mockStudentUser, mockTeacherAccountDto, mockTeacherAccount } = setup(); + + const ret = await accountService.save(mockTeacherAccountDto); + expect(ret).toBeDefined(); + expect(ret).toMatchObject({ + id: mockTeacherAccount.id, + username: mockTeacherAccountDto.username, + activated: mockTeacherAccount.activated, + systemId: mockTeacherAccount.systemId, + userId: new ObjectId(mockStudentUser.id), + }); }); }); - it("should keep existing account's system undefined on update", async () => { - const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); - mockTeacherAccountDto.username = 'changedUsername@example.org'; - mockTeacherAccountDto.systemId = undefined; - const ret = await accountService.save(mockTeacherAccountDto); - expect(ret).toBeDefined(); - expect(ret).toMatchObject({ - id: mockTeacherAccount.id, - username: mockTeacherAccountDto.username, - activated: mockTeacherAccount.activated, - systemId: mockTeacherAccountDto.systemId, - userId: mockTeacherAccount.userId, + describe("when existing account's system is undefined", () => { + const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); + + mockTeacherAccountDto.username = 'changedUsername@example.org'; + mockTeacherAccountDto.systemId = undefined; + + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountLookupServiceMock.getInternalId.mockResolvedValue(mockTeacherAccount._id); + accountRepo.save.mockResolvedValue(); + + return { mockTeacherAccountDto, mockTeacherAccount }; + }; + it('should keep undefined on update', async () => { + const { mockTeacherAccountDto, mockTeacherAccount } = setup(); + + const ret = await accountService.save(mockTeacherAccountDto); + expect(ret).toBeDefined(); + expect(ret).toMatchObject({ + id: mockTeacherAccount.id, + username: mockTeacherAccountDto.username, + activated: mockTeacherAccount.activated, + systemId: mockTeacherAccountDto.systemId, + userId: mockTeacherAccount.userId, + }); + }); + }); + + describe('when account does not exists', () => { + const setup = () => { + const mockUserWithoutAccount = userFactory.buildWithId(); + + const accountToSave: AccountDto = { + createdAt: new Date(), + updatedAt: new Date(), + username: 'asdf@asdf.de', + userId: mockUserWithoutAccount.id, + systemId: '012345678912', + password: defaultPassword, + } as AccountDto; + (accountRepo.findById as jest.Mock).mockClear(); + (accountRepo.save as jest.Mock).mockClear(); + + return { accountToSave }; + }; + it('should save a new account', async () => { + const { accountToSave } = setup(); + + const ret = await accountService.save(accountToSave); + expect(ret).toBeDefined(); + expect(ret).toMatchObject({ + username: accountToSave.username, + userId: new ObjectId(accountToSave.userId), + systemId: new ObjectId(accountToSave.systemId), + createdAt: accountToSave.createdAt, + updatedAt: accountToSave.updatedAt, + }); }); }); - it('should save a new account', async () => { - const accountToSave: AccountDto = { - createdAt: new Date(), - updatedAt: new Date(), - username: 'asdf@asdf.de', - userId: mockUserWithoutAccount.id, - systemId: '012345678912', - password: defaultPassword, - } as AccountDto; - (accountRepo.findById as jest.Mock).mockClear(); - (accountRepo.save as jest.Mock).mockClear(); - const ret = await accountService.save(accountToSave); - expect(ret).toBeDefined(); - expect(ret).toMatchObject({ - username: accountToSave.username, - userId: new ObjectId(accountToSave.userId), - systemId: new ObjectId(accountToSave.systemId), - createdAt: accountToSave.createdAt, - updatedAt: accountToSave.updatedAt, + + describe("when account's system undefined", () => { + const setup = () => { + const mockUserWithoutAccount = userFactory.buildWithId(); + + const accountToSave: AccountDto = { + createdAt: new Date(), + updatedAt: new Date(), + username: 'asdf@asdf.de', + userId: mockUserWithoutAccount.id, + password: defaultPassword, + } as AccountDto; + (accountRepo.findById as jest.Mock).mockClear(); + (accountRepo.save as jest.Mock).mockClear(); + + return { accountToSave }; + }; + it('should keep undefined on save', async () => { + const { accountToSave } = setup(); + + const ret = await accountService.save(accountToSave); + expect(ret).toBeDefined(); + expect(ret).toMatchObject({ + systemId: undefined, + }); }); }); - it("should keep account's system undefined on save", async () => { - const accountToSave: AccountDto = { - createdAt: new Date(), - updatedAt: new Date(), - username: 'asdf@asdf.de', - userId: mockUserWithoutAccount.id, - password: defaultPassword, - } as AccountDto; - (accountRepo.findById as jest.Mock).mockClear(); - (accountRepo.save as jest.Mock).mockClear(); - const ret = await accountService.save(accountToSave); - expect(ret).toBeDefined(); - expect(ret).toMatchObject({ - systemId: undefined, + describe('when save account', () => { + const setup = () => { + const mockUserWithoutAccount = userFactory.buildWithId(); + + const accountToSave = { + createdAt: new Date(), + updatedAt: new Date(), + username: 'asdf@asdf.de', + userId: mockUserWithoutAccount.id, + systemId: '012345678912', + password: defaultPassword, + } as AccountDto; + (accountRepo.findById as jest.Mock).mockClear(); + (accountRepo.save as jest.Mock).mockClear(); + + return { accountToSave }; + }; + it('should encrypt password', async () => { + const { accountToSave } = setup(); + + await accountService.save(accountToSave); + const ret = await accountService.save(accountToSave); + expect(ret).toBeDefined(); + expect(ret).not.toMatchObject({ + password: defaultPassword, + }); }); }); - it('should encrypt password', async () => { - const accountToSave = { - createdAt: new Date(), - updatedAt: new Date(), - username: 'asdf@asdf.de', - userId: mockUserWithoutAccount.id, - systemId: '012345678912', - password: defaultPassword, - } as AccountDto; - (accountRepo.findById as jest.Mock).mockClear(); - (accountRepo.save as jest.Mock).mockClear(); - await accountService.save(accountToSave); - const ret = await accountService.save(accountToSave); - expect(ret).toBeDefined(); - expect(ret).not.toMatchObject({ - password: defaultPassword, + describe('when creating a new account', () => { + const setup = () => { + const spy = jest.spyOn(accountRepo, 'save'); + const dto = { + username: 'john.doe@domain.tld', + password: '', + } as AccountDto; + (accountRepo.findById as jest.Mock).mockClear(); + (accountRepo.save as jest.Mock).mockClear(); + + return { spy, dto }; + }; + it('should set password to undefined if password is empty', async () => { + const { spy, dto } = setup(); + + await expect(accountService.save(dto)).resolves.not.toThrow(); + expect(accountRepo.findById).not.toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + password: undefined, + }) + ); }); }); - it('should set password to undefined if password is empty while creating a new account', async () => { - const spy = jest.spyOn(accountRepo, 'save'); - const dto = { - username: 'john.doe@domain.tld', - password: '', - } as AccountDto; - (accountRepo.findById as jest.Mock).mockClear(); - (accountRepo.save as jest.Mock).mockClear(); - await expect(accountService.save(dto)).resolves.not.toThrow(); - expect(accountRepo.findById).not.toHaveBeenCalled(); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ + describe('when password is empty while editing an existing account', () => { + const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + + const spy = jest.spyOn(accountRepo, 'save'); + const dto = { + id: mockTeacherAccount.id, password: undefined, - }) - ); - }); + } as AccountDto; - it('should not change password if password is empty while editing an existing account', async () => { - const spy = jest.spyOn(accountRepo, 'save'); - const dto = { - id: mockTeacherAccount.id, - // username: 'john.doe@domain.tld', - password: undefined, - } as AccountDto; - (accountRepo.findById as jest.Mock).mockClear(); - (accountRepo.save as jest.Mock).mockClear(); - await expect(accountService.save(dto)).resolves.not.toThrow(); - expect(accountRepo.findById).toHaveBeenCalled(); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - password: defaultPassword, - }) - ); + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountLookupServiceMock.getInternalId.mockResolvedValue(mockTeacherAccount._id); + accountRepo.save.mockResolvedValue(); + + return { mockTeacherAccount, spy, dto }; + }; + it('should not change password', async () => { + const { mockTeacherAccount, spy, dto } = setup(); + await expect(accountService.save(dto)).resolves.not.toThrow(); + expect(accountRepo.findById).toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + password: mockTeacherAccount.password, + }) + ); + }); }); }); describe('updateUsername', () => { - it('should update an existing account but no other information', async () => { - const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); - const newUsername = 'newUsername'; - const ret = await accountService.updateUsername(mockTeacherAccount.id, newUsername); - - expect(ret).toBeDefined(); - expect(ret).toMatchObject({ - ...mockTeacherAccountDto, - username: newUsername, + describe('when updating username', () => { + const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); + const newUsername = 'newUsername'; + + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountLookupServiceMock.getInternalId.mockResolvedValue(mockTeacherAccount._id); + + return { mockTeacherAccount, mockTeacherAccountDto, newUsername }; + }; + it('should update only user name', async () => { + const { mockTeacherAccount, mockTeacherAccountDto, newUsername } = setup(); + const ret = await accountService.updateUsername(mockTeacherAccount.id, newUsername); + expect(ret).toBeDefined(); + expect(ret).toMatchObject({ + ...mockTeacherAccountDto, + username: newUsername, + }); }); }); }); describe('updateLastTriedFailedLogin', () => { - it('should update last tried failed login', async () => { - const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); - const theNewDate = new Date(); - const ret = await accountService.updateLastTriedFailedLogin(mockTeacherAccount.id, theNewDate); - - expect(ret).toBeDefined(); - expect(ret).toMatchObject({ - ...mockTeacherAccountDto, - lasttriedFailedLogin: theNewDate, + describe('when update last failed Login', () => { + const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); + const theNewDate = new Date(); + + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountLookupServiceMock.getInternalId.mockResolvedValue(mockTeacherAccount._id); + + return { mockTeacherAccount, mockTeacherAccountDto, theNewDate }; + }; + it('should update last tried failed login', async () => { + const { mockTeacherAccount, mockTeacherAccountDto, theNewDate } = setup(); + const ret = await accountService.updateLastTriedFailedLogin(mockTeacherAccount.id, theNewDate); + + expect(ret).toBeDefined(); + expect(ret).toMatchObject({ + ...mockTeacherAccountDto, + lasttriedFailedLogin: theNewDate, + }); }); }); }); describe('validatePassword', () => { - it('should validate password', async () => { - const ret = await accountService.validatePassword( - { password: await bcrypt.hash(defaultPassword, 10) } as unknown as AccountDto, - defaultPassword - ); - expect(ret).toBe(true); + describe('when accepted Password', () => { + const setup = async () => { + const ret = await accountService.validatePassword( + { password: await bcrypt.hash(defaultPassword, 10) } as unknown as AccountDto, + defaultPassword + ); + + return { ret }; + }; + it('should validate password', async () => { + const { ret } = await setup(); + + expect(ret).toBe(true); + }); }); - it('should report wrong password', async () => { - const ret = await accountService.validatePassword( - { password: await bcrypt.hash(defaultPassword, 10) } as unknown as AccountDto, - 'incorrectPwd' - ); - expect(ret).toBe(false); + + describe('when wrong Password', () => { + const setup = async () => { + const ret = await accountService.validatePassword( + { password: await bcrypt.hash(defaultPassword, 10) } as unknown as AccountDto, + 'incorrectPwd' + ); + + return { ret }; + }; + it('should report', async () => { + const { ret } = await setup(); + + expect(ret).toBe(false); + }); }); - it('should report missing account password', async () => { - const ret = await accountService.validatePassword({ password: undefined } as AccountDto, 'incorrectPwd'); - expect(ret).toBe(false); + + describe('when missing account password', () => { + const setup = async () => { + const ret = await accountService.validatePassword({ password: undefined } as AccountDto, 'incorrectPwd'); + + return { ret }; + }; + it('should report', async () => { + const { ret } = await setup(); + + expect(ret).toBe(false); + }); }); }); describe('updatePassword', () => { - it('should update password', async () => { - const newPassword = 'newPassword'; - const ret = await accountService.updatePassword(mockTeacherAccount.id, newPassword); + describe('when update Password', () => { + const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + const newPassword = 'newPassword'; + + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountLookupServiceMock.getInternalId.mockResolvedValue(mockTeacherAccount._id); + + return { mockTeacherAccount, newPassword }; + }; + it('should update password', async () => { + const { mockTeacherAccount, newPassword } = setup(); - expect(ret).toBeDefined(); - if (ret.password) { - await expect(bcrypt.compare(newPassword, ret.password)).resolves.toBe(true); - } else { - fail('return password is undefined'); - } + const ret = await accountService.updatePassword(mockTeacherAccount.id, newPassword); + + expect(ret).toBeDefined(); + if (ret.password) { + await expect(bcrypt.compare(newPassword, ret.password)).resolves.toBe(true); + } else { + fail('return password is undefined'); + } + }); }); }); describe('delete', () => { - describe('when deleting existing account', () => { + describe('when delete an existing account', () => { + const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountLookupServiceMock.getInternalId.mockResolvedValue(mockTeacherAccount._id); + + return { mockTeacherAccount }; + }; it('should delete account via repo', async () => { + const { mockTeacherAccount } = setup(); await accountService.delete(mockTeacherAccount.id); expect(accountRepo.deleteById).toHaveBeenCalledWith(new ObjectId(mockTeacherAccount.id)); }); @@ -468,55 +689,125 @@ describe('AccountDbService', () => { describe('when deleting non existing account', () => { const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + + accountRepo.findById.mockResolvedValue(mockTeacherAccount); accountLookupServiceMock.getInternalId.mockResolvedValueOnce(null); + + return { mockTeacherAccount }; }; - it('should throw', async () => { - setup(); + it('should throw account not found', async () => { + const { mockTeacherAccount } = setup(); await expect(accountService.delete(mockTeacherAccount.id)).rejects.toThrow(); }); }); }); describe('deleteByUserId', () => { - it('should delete the account with given user id via repo', async () => { - await accountService.deleteByUserId(mockTeacherAccount.userId?.toString() ?? ''); - expect(accountRepo.deleteByUserId).toHaveBeenCalledWith(mockTeacherAccount.userId); + describe('when delete account with given user id', () => { + const setup = () => { + const mockTeacherUser = userFactory.buildWithId(); + + const mockTeacherAccount = accountFactory.buildWithId({ + userId: mockTeacherUser.id, + password: defaultPassword, + }); + + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountLookupServiceMock.getInternalId.mockResolvedValue(mockTeacherAccount._id); + + return { mockTeacherUser, mockTeacherAccount }; + }; + it('should delete via repo', async () => { + const { mockTeacherUser, mockTeacherAccount } = setup(); + + await accountService.deleteByUserId(mockTeacherAccount.userId?.toString() ?? ''); + expect(accountRepo.deleteByUserId).toHaveBeenCalledWith(mockTeacherUser.id); + }); }); }); describe('searchByUsernamePartialMatch', () => { - it('should call repo', async () => { - const partialUserName = 'admin'; - const skip = 2; - const limit = 10; - const [accounts, total] = await accountService.searchByUsernamePartialMatch(partialUserName, skip, limit); - expect(accountRepo.searchByUsernamePartialMatch).toHaveBeenCalledWith(partialUserName, skip, limit); - expect(total).toBe(mockAccounts.length); + describe('when searching by part of username', () => { + const setup = () => { + const partialUserName = 'admin'; + const skip = 2; + const limit = 10; + const mockTeacherAccount = accountFactory.buildWithId(); + const mockStudentAccount = accountFactory.buildWithId(); + const mockAccountWithSystemId = accountFactory.withSystemId(new ObjectId()).build(); + const mockAccounts = [mockTeacherAccount, mockStudentAccount, mockAccountWithSystemId]; + + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountRepo.searchByUsernamePartialMatch.mockResolvedValue([ + [mockTeacherAccount, mockStudentAccount, mockAccountWithSystemId], + 3, + ]); + accountLookupServiceMock.getInternalId.mockResolvedValue(mockTeacherAccount._id); + + return { partialUserName, skip, limit, mockTeacherAccount, mockAccounts }; + }; + it('should call repo', async () => { + const { partialUserName, skip, limit, mockTeacherAccount, mockAccounts } = setup(); + const [accounts, total] = await accountService.searchByUsernamePartialMatch(partialUserName, skip, limit); + expect(accountRepo.searchByUsernamePartialMatch).toHaveBeenCalledWith(partialUserName, skip, limit); + expect(total).toBe(mockAccounts.length); - expect(accounts[0]).toEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + expect(accounts[0]).toEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + }); }); }); + describe('searchByUsernameExactMatch', () => { - it('should call repo', async () => { - const partialUserName = 'admin'; - const [accounts, total] = await accountService.searchByUsernameExactMatch(partialUserName); - expect(accountRepo.searchByUsernameExactMatch).toHaveBeenCalledWith(partialUserName); - expect(total).toBe(1); - expect(accounts[0]).toEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + describe('when searching by username', () => { + const setup = () => { + const partialUserName = 'admin'; + const mockTeacherAccount = accountFactory.buildWithId(); + + accountRepo.searchByUsernameExactMatch.mockResolvedValue([[mockTeacherAccount], 1]); + + return { partialUserName, mockTeacherAccount }; + }; + it('should call repo', async () => { + const { partialUserName, mockTeacherAccount } = setup(); + const [accounts, total] = await accountService.searchByUsernameExactMatch(partialUserName); + expect(accountRepo.searchByUsernameExactMatch).toHaveBeenCalledWith(partialUserName); + expect(total).toBe(1); + expect(accounts[0]).toEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + }); }); }); - describe('findMany', () => { - it('should call repo', async () => { - const foundAccounts = await accountService.findMany(1, 1); - expect(accountRepo.findMany).toHaveBeenCalledWith(1, 1); - expect(foundAccounts).toBeDefined(); - }); - it('should call repo', async () => { - const foundAccounts = await accountService.findMany(); - expect(accountRepo.findMany).toHaveBeenCalledWith(0, 100); - expect(foundAccounts).toBeDefined(); + describe('when find many one time', () => { + const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + + accountRepo.findMany.mockResolvedValue([mockTeacherAccount]); + + return {}; + }; + it('should call repo', async () => { + setup(); + const foundAccounts = await accountService.findMany(1, 1); + expect(accountRepo.findMany).toHaveBeenCalledWith(1, 1); + expect(foundAccounts).toBeDefined(); + }); + }); + describe('when call find many more than one time', () => { + const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + + accountRepo.findMany.mockResolvedValue([mockTeacherAccount]); + + return {}; + }; + it('should call repo each time', async () => { + setup(); + const foundAccounts = await accountService.findMany(); + expect(accountRepo.findMany).toHaveBeenCalledWith(0, 100); + expect(foundAccounts).toBeDefined(); + }); }); }); }); diff --git a/apps/server/src/modules/account/services/account-db.service.ts b/apps/server/src/modules/account/services/account-db.service.ts index 1209ed86744..2ea02eeb3c4 100644 --- a/apps/server/src/modules/account/services/account-db.service.ts +++ b/apps/server/src/modules/account/services/account-db.service.ts @@ -1,13 +1,15 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; -import bcrypt from 'bcryptjs'; import { EntityNotFoundError } from '@shared/common'; import { Account, Counted, EntityId } from '@shared/domain'; -import { AccountRepo } from '../repo/account.repo'; +import bcrypt from 'bcryptjs'; import { AccountEntityToDtoMapper } from '../mapper'; -import { AccountDto, AccountSaveDto } from './dto'; -import { AbstractAccountService } from './account.service.abstract'; +import { AccountRepo } from '../repo/account.repo'; import { AccountLookupService } from './account-lookup.service'; +import { AbstractAccountService } from './account.service.abstract'; +import { AccountDto, AccountSaveDto } from './dto'; + +// HINT: do more empty lines :) @Injectable() export class AccountServiceDb extends AbstractAccountService { @@ -32,10 +34,7 @@ export class AccountServiceDb extends AbstractAccountService { } async findByUserIdOrFail(userId: EntityId): Promise { - const accountEntity = await this.accountRepo.findByUserId(userId); - if (!accountEntity) { - throw new EntityNotFoundError('Account'); - } + const accountEntity = await this.accountRepo.findByUserIdOrFail(userId); return AccountEntityToDtoMapper.mapToDto(accountEntity); } @@ -46,6 +45,8 @@ export class AccountServiceDb extends AbstractAccountService { async save(accountDto: AccountSaveDto): Promise { let account: Account; + // HINT: mapping could be done by a mapper (though this whole file is subject to be removed in the future) + // HINT: today we have logic to map back into unit work in the baseDO if (accountDto.id) { const internalId = await this.getInternalId(accountDto.id); account = await this.accountRepo.findById(internalId); @@ -75,7 +76,7 @@ export class AccountServiceDb extends AbstractAccountService { credentialHash: accountDto.credentialHash, }); - await this.accountRepo.save(account); + await this.accountRepo.save(account); // HINT: this can be done once in the end } return AccountEntityToDtoMapper.mapToDto(account); } @@ -128,7 +129,7 @@ export class AccountServiceDb extends AbstractAccountService { if (!account.password) { return Promise.resolve(false); } - return bcrypt.compare(comparePassword, account.password); + return bcrypt.compare(comparePassword, account.password); // hint: first get result, then return seperately } private async getInternalId(id: EntityId | ObjectId): Promise { diff --git a/apps/server/src/modules/account/services/account-idm.service.integration.spec.ts b/apps/server/src/modules/account/services/account-idm.service.integration.spec.ts index 82f54c60fa1..f50e8fcc07e 100644 --- a/apps/server/src/modules/account/services/account-idm.service.integration.spec.ts +++ b/apps/server/src/modules/account/services/account-idm.service.integration.spec.ts @@ -94,79 +94,125 @@ describe('AccountIdmService Integration', () => { } }); - it('save should create a new account', async () => { - if (!isIdmReachable) return; - const createdAccount = await accountIdmService.save(testAccount); - const foundAccount = await identityManagementService.findAccountById(createdAccount.idmReferenceId ?? ''); - - expect(foundAccount).toEqual( - expect.objectContaining({ - id: createdAccount.idmReferenceId ?? '', - username: createdAccount.username, - attDbcAccountId: testDbcAccountId, - attDbcUserId: createdAccount.userId, - attDbcSystemId: createdAccount.systemId, - }) - ); + describe('save', () => { + describe('when account does not exists', () => { + it('should create a new account', async () => { + if (!isIdmReachable) return; + const createdAccount = await accountIdmService.save(testAccount); + const foundAccount = await identityManagementService.findAccountById(createdAccount.idmReferenceId ?? ''); + + expect(foundAccount).toEqual( + expect.objectContaining({ + id: createdAccount.idmReferenceId ?? '', + username: createdAccount.username, + attDbcAccountId: createdAccount.id, + attDbcUserId: createdAccount.userId, + attDbcSystemId: createdAccount.systemId, + }) + ); + }); + }); }); - it('save should update existing account', async () => { - if (!isIdmReachable) return; - const newUsername = 'jane.doe@mail.tld'; - const idmId = await createAccount(); - - await accountIdmService.save({ - id: testDbcAccountId, - username: newUsername, + describe('save', () => { + describe('when account exists', () => { + const setup = async () => { + const newUserName = 'jane.doe@mail.tld'; + const idmId = await createAccount(); + + return { idmId, newUserName }; + }; + it('should update account', async () => { + if (!isIdmReachable) return; + const { idmId, newUserName } = await setup(); + + await accountIdmService.save({ + id: testDbcAccountId, + username: newUserName, + }); + + const foundAccount = await identityManagementService.findAccountById(idmId); + + expect(foundAccount).toEqual( + expect.objectContaining({ + id: idmId, + username: newUserName, + }) + ); + }); }); - const foundAccount = await identityManagementService.findAccountById(idmId); - - expect(foundAccount).toEqual( - expect.objectContaining({ - id: idmId, - username: newUsername, - }) - ); }); - it('updateUsername should update username', async () => { - if (!isIdmReachable) return; - const newUserName = 'jane.doe@mail.tld'; - const idmId = await createAccount(); - await accountIdmService.updateUsername(testDbcAccountId, newUserName); - - const foundAccount = await identityManagementService.findAccountById(idmId); - - expect(foundAccount).toEqual( - expect.objectContaining>({ - username: newUserName, - }) - ); + describe('updateUsername', () => { + describe('when updating username', () => { + const setup = async () => { + const newUserName = 'jane.doe@mail.tld'; + const idmId = await createAccount(); + + return { newUserName, idmId }; + }; + it('should update only username', async () => { + if (!isIdmReachable) return; + const { newUserName, idmId } = await setup(); + + await accountIdmService.updateUsername(testDbcAccountId, newUserName); + const foundAccount = await identityManagementService.findAccountById(idmId); + + expect(foundAccount).toEqual( + expect.objectContaining>({ + username: newUserName, + }) + ); + }); + }); }); - it('updatePassword should update password', async () => { - if (!isIdmReachable) return; - await createAccount(); - await expect(accountIdmService.updatePassword(testDbcAccountId, 'newPassword')).resolves.not.toThrow(); + describe('updatePassword', () => { + describe('when updating with permitted password', () => { + const setup = async () => { + await createAccount(); + }; + it('should update password', async () => { + if (!isIdmReachable) return; + await setup(); + await expect(accountIdmService.updatePassword(testDbcAccountId, 'newPassword')).resolves.not.toThrow(); + }); + }); }); - it('delete should remove account', async () => { - if (!isIdmReachable) return; - const idmId = await createAccount(); - const foundAccount = await identityManagementService.findAccountById(idmId); - expect(foundAccount).toBeDefined(); - - await accountIdmService.delete(testDbcAccountId); - await expect(identityManagementService.findAccountById(idmId)).rejects.toThrow(); + describe('delete', () => { + describe('when delete account', () => { + const setup = async () => { + const idmId = await createAccount(); + const foundAccount = await identityManagementService.findAccountById(idmId); + return { idmId, foundAccount }; + }; + it('should remove account', async () => { + if (!isIdmReachable) return; + const { idmId, foundAccount } = await setup(); + expect(foundAccount).toBeDefined(); + + await accountIdmService.delete(testDbcAccountId); + await expect(identityManagementService.findAccountById(idmId)).rejects.toThrow(); + }); + }); }); - it('deleteByUserId should remove account', async () => { - if (!isIdmReachable) return; - const idmId = await createAccount(); - const foundAccount = await identityManagementService.findAccountById(idmId); - expect(foundAccount).toBeDefined(); - - await accountIdmService.deleteByUserId(testAccount.userId ?? ''); - await expect(identityManagementService.findAccountById(idmId)).rejects.toThrow(); + describe('deleteByUserId', () => { + describe('when deleting by UserId', () => { + const setup = async () => { + const idmId = await createAccount(); + const foundAccount = await identityManagementService.findAccountById(idmId); + return { idmId, foundAccount }; + }; + it('should remove account', async () => { + if (!isIdmReachable) return; + const { idmId, foundAccount } = await setup(); + expect(foundAccount).toBeDefined(); + + await accountIdmService.deleteByUserId(testAccount.userId ?? ''); + await expect(identityManagementService.findAccountById(idmId)).rejects.toThrow(); + }); + }); }); }); diff --git a/apps/server/src/modules/account/services/account-idm.service.spec.ts b/apps/server/src/modules/account/services/account-idm.service.spec.ts index 4b997d1b3fe..1669b4ca4c4 100644 --- a/apps/server/src/modules/account/services/account-idm.service.spec.ts +++ b/apps/server/src/modules/account/services/account-idm.service.spec.ts @@ -76,155 +76,203 @@ describe('AccountIdmService', () => { }); describe('save', () => { - const setup = () => { - idmServiceMock.createAccount.mockResolvedValue(mockIdmAccount.id); - idmServiceMock.updateAccount.mockResolvedValue(mockIdmAccount.id); - idmServiceMock.updateAccountPassword.mockResolvedValue(mockIdmAccount.id); - idmServiceMock.findAccountById.mockResolvedValue(mockIdmAccount); - }; - - it('should update an existing account', async () => { - setup(); - const updateSpy = jest.spyOn(idmServiceMock, 'updateAccount'); - const createSpy = jest.spyOn(idmServiceMock, 'createAccount'); - - const mockAccountDto = { - id: mockIdmAccountRefId, - username: 'testUserName', - userId: 'userId', - systemId: 'systemId', + describe('when save an existing account', () => { + const setup = () => { + idmServiceMock.createAccount.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.updateAccount.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.updateAccountPassword.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.findAccountById.mockResolvedValue(mockIdmAccount); + const updateSpy = jest.spyOn(idmServiceMock, 'updateAccount'); + const createSpy = jest.spyOn(idmServiceMock, 'createAccount'); + + const mockAccountDto = { + id: mockIdmAccountRefId, + username: 'testUserName', + userId: 'userId', + systemId: 'systemId', + }; + return { updateSpy, createSpy, mockAccountDto }; }; - const ret = await accountIdmService.save(mockAccountDto); - - expect(updateSpy).toHaveBeenCalled(); - expect(createSpy).not.toHaveBeenCalled(); - - expect(ret).toBeDefined(); - expect(ret).toMatchObject>({ - id: mockIdmAccount.attDbcAccountId, - idmReferenceId: mockIdmAccount.id, - createdAt: mockIdmAccount.createdDate, - updatedAt: mockIdmAccount.createdDate, - username: mockIdmAccount.username, + + it('should update account information', async () => { + const { updateSpy, createSpy, mockAccountDto } = setup(); + + const ret = await accountIdmService.save(mockAccountDto); + + expect(updateSpy).toHaveBeenCalled(); + expect(createSpy).not.toHaveBeenCalled(); + + expect(ret).toBeDefined(); + expect(ret).toMatchObject>({ + id: mockIdmAccount.attDbcAccountId, + idmReferenceId: mockIdmAccount.id, + createdAt: mockIdmAccount.createdDate, + updatedAt: mockIdmAccount.createdDate, + username: mockIdmAccount.username, + }); }); }); - it('should update an existing accounts password', async () => { - setup(); - const updateSpy = jest.spyOn(idmServiceMock, 'updateAccount'); - const updatePasswordSpy = jest.spyOn(idmServiceMock, 'updateAccountPassword'); - - const mockAccountDto: AccountSaveDto = { - id: mockIdmAccountRefId, - username: 'testUserName', - userId: 'userId', - systemId: 'systemId', - password: 'password', + describe('when save an existing account', () => { + const setup = () => { + idmServiceMock.createAccount.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.updateAccount.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.updateAccountPassword.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.findAccountById.mockResolvedValue(mockIdmAccount); + const updateSpy = jest.spyOn(idmServiceMock, 'updateAccount'); + const updatePasswordSpy = jest.spyOn(idmServiceMock, 'updateAccountPassword'); + + const mockAccountDto: AccountSaveDto = { + id: mockIdmAccountRefId, + username: 'testUserName', + userId: 'userId', + systemId: 'systemId', + password: 'password', + }; + return { updateSpy, updatePasswordSpy, mockAccountDto }; }; - const ret = await accountIdmService.save(mockAccountDto); + it('should update account password', async () => { + const { updateSpy, updatePasswordSpy, mockAccountDto } = setup(); - expect(updateSpy).toHaveBeenCalled(); - expect(updatePasswordSpy).toHaveBeenCalled(); - expect(ret).toBeDefined(); + const ret = await accountIdmService.save(mockAccountDto); + + expect(updateSpy).toHaveBeenCalled(); + expect(updatePasswordSpy).toHaveBeenCalled(); + expect(ret).toBeDefined(); + }); }); - it('should create a new account', async () => { - setup(); - const updateSpy = jest.spyOn(idmServiceMock, 'updateAccount'); - const createSpy = jest.spyOn(idmServiceMock, 'createAccount'); + describe('when save not existing account', () => { + const setup = () => { + idmServiceMock.createAccount.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.updateAccount.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.updateAccountPassword.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.findAccountById.mockResolvedValue(mockIdmAccount); + const updateSpy = jest.spyOn(idmServiceMock, 'updateAccount'); + const createSpy = jest.spyOn(idmServiceMock, 'createAccount'); + + const mockAccountDto = { username: 'testUserName', id: undefined, userId: 'userId', systemId: 'systemId' }; + + return { updateSpy, createSpy, mockAccountDto }; + }; + it('should create a new account', async () => { + const { updateSpy, createSpy, mockAccountDto } = setup(); - const mockAccountDto = { username: 'testUserName', id: undefined, userId: 'userId', systemId: 'systemId' }; - const ret = await accountIdmService.save(mockAccountDto); + const ret = await accountIdmService.save(mockAccountDto); - expect(updateSpy).not.toHaveBeenCalled(); - expect(createSpy).toHaveBeenCalled(); + expect(updateSpy).not.toHaveBeenCalled(); + expect(createSpy).toHaveBeenCalled(); - expect(ret).toBeDefined(); - expect(ret).toMatchObject>({ - id: mockIdmAccount.attDbcAccountId, - idmReferenceId: mockIdmAccount.id, - createdAt: mockIdmAccount.createdDate, - updatedAt: mockIdmAccount.createdDate, - username: mockIdmAccount.username, + expect(ret).toBeDefined(); + expect(ret).toMatchObject>({ + id: mockIdmAccount.attDbcAccountId, + idmReferenceId: mockIdmAccount.id, + createdAt: mockIdmAccount.createdDate, + updatedAt: mockIdmAccount.createdDate, + username: mockIdmAccount.username, + }); }); }); - it('should create a new account on update error', async () => { - setup(); - accountLookupServiceMock.getExternalId.mockResolvedValue(null); - const mockAccountDto = { - id: mockIdmAccountRefId, - username: 'testUserName', - userId: 'userId', - systemId: 'systemId', - }; - - const ret = await accountIdmService.save(mockAccountDto); - expect(idmServiceMock.createAccount).toHaveBeenCalled(); - expect(ret).toBeDefined(); - expect(ret).toMatchObject>({ - id: mockIdmAccount.attDbcAccountId, - idmReferenceId: mockIdmAccount.id, - createdAt: mockIdmAccount.createdDate, - updatedAt: mockIdmAccount.createdDate, - username: mockIdmAccount.username, + describe('when save not existing account', () => { + const setup = () => { + idmServiceMock.createAccount.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.updateAccount.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.updateAccountPassword.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.findAccountById.mockResolvedValue(mockIdmAccount); + accountLookupServiceMock.getExternalId.mockResolvedValue(null); + const mockAccountDto = { + id: mockIdmAccountRefId, + username: 'testUserName', + userId: 'userId', + systemId: 'systemId', + }; + + return { mockAccountDto }; + }; + it('should create a new account on update error', async () => { + const { mockAccountDto } = setup(); + + const ret = await accountIdmService.save(mockAccountDto); + + expect(idmServiceMock.createAccount).toHaveBeenCalled(); + expect(ret).toBeDefined(); + expect(ret).toMatchObject>({ + id: mockIdmAccount.attDbcAccountId, + idmReferenceId: mockIdmAccount.id, + createdAt: mockIdmAccount.createdDate, + updatedAt: mockIdmAccount.createdDate, + username: mockIdmAccount.username, + }); }); }); }); describe('updateUsername', () => { - it('should map result correctly', async () => { - accountLookupServiceMock.getExternalId.mockResolvedValue(mockIdmAccount.id); - const ret = await accountIdmService.updateUsername(mockIdmAccountRefId, 'any'); - - expect(ret).toBeDefined(); - expect(ret).toMatchObject>({ - id: mockIdmAccount.attDbcAccountId, - idmReferenceId: mockIdmAccount.id, - createdAt: mockIdmAccount.createdDate, - updatedAt: mockIdmAccount.createdDate, - username: mockIdmAccount.username, + describe('when update Username', () => { + const setup = () => { + accountLookupServiceMock.getExternalId.mockResolvedValue(mockIdmAccount.id); + }; + it('should map result correctly', async () => { + setup(); + const ret = await accountIdmService.updateUsername(mockIdmAccountRefId, 'any'); + + expect(ret).toBeDefined(); + expect(ret).toMatchObject>({ + id: mockIdmAccount.attDbcAccountId, + idmReferenceId: mockIdmAccount.id, + createdAt: mockIdmAccount.createdDate, + updatedAt: mockIdmAccount.createdDate, + username: mockIdmAccount.username, + }); }); }); }); describe('updatePassword', () => { - it('should map result correctly', async () => { - accountLookupServiceMock.getExternalId.mockResolvedValue(mockIdmAccount.id); - const ret = await accountIdmService.updatePassword(mockIdmAccountRefId, 'any'); - - expect(ret).toBeDefined(); - expect(ret).toMatchObject>({ - id: mockIdmAccount.attDbcAccountId, - idmReferenceId: mockIdmAccount.id, - createdAt: mockIdmAccount.createdDate, - updatedAt: mockIdmAccount.createdDate, - username: mockIdmAccount.username, + describe('when update password', () => { + const setup = () => { + accountLookupServiceMock.getExternalId.mockResolvedValue(mockIdmAccount.id); + }; + it('should map result correctly', async () => { + setup(); + const ret = await accountIdmService.updatePassword(mockIdmAccountRefId, 'any'); + + expect(ret).toBeDefined(); + expect(ret).toMatchObject>({ + id: mockIdmAccount.attDbcAccountId, + idmReferenceId: mockIdmAccount.id, + createdAt: mockIdmAccount.createdDate, + updatedAt: mockIdmAccount.createdDate, + username: mockIdmAccount.username, + }); }); }); }); describe('validatePassword', () => { - const setup = (acceptPassword: boolean) => { - idmOauthServiceMock.resourceOwnerPasswordGrant.mockResolvedValue( - acceptPassword ? '{ "alg": "HS256", "typ": "JWT" }' : undefined - ); - }; - it('should validate password by checking JWT', async () => { - setup(true); - const ret = await accountIdmService.validatePassword( - { username: 'username' } as unknown as AccountDto, - 'password' - ); - expect(ret).toBe(true); - }); - it('should report wrong password, i. e. non successful JWT creation', async () => { - setup(false); - const ret = await accountIdmService.validatePassword( - { username: 'username' } as unknown as AccountDto, - 'password' - ); - expect(ret).toBe(false); + describe('when validate password', () => { + const setup = (acceptPassword: boolean) => { + idmOauthServiceMock.resourceOwnerPasswordGrant.mockResolvedValue( + acceptPassword ? '{ "alg": "HS256", "typ": "JWT" }' : undefined + ); + }; + it('should validate password by checking JWT', async () => { + setup(true); + const ret = await accountIdmService.validatePassword( + { username: 'username' } as unknown as AccountDto, + 'password' + ); + expect(ret).toBe(true); + }); + it('should report wrong password, i. e. non successful JWT creation', async () => { + setup(false); + const ret = await accountIdmService.validatePassword( + { username: 'username' } as unknown as AccountDto, + 'password' + ); + expect(ret).toBe(false); + }); }); }); @@ -248,7 +296,7 @@ describe('AccountIdmService', () => { accountLookupServiceMock.getExternalId.mockResolvedValue(null); }; - it('should throw error', async () => { + it('should throw account not found error', async () => { setup(); await expect(accountIdmService.delete(mockIdmAccountRefId)).rejects.toThrow(); }); @@ -256,16 +304,19 @@ describe('AccountIdmService', () => { }); describe('deleteByUserId', () => { - const setup = () => { - idmServiceMock.findAccountByDbcUserId.mockResolvedValue(mockIdmAccount); - }; + describe('when deleting an account by user id', () => { + const setup = () => { + idmServiceMock.findAccountByDbcUserId.mockResolvedValue(mockIdmAccount); + const deleteSpy = jest.spyOn(idmServiceMock, 'deleteAccountById'); + return { deleteSpy }; + }; - it('should delete the account with given user id via repo', async () => { - setup(); - const deleteSpy = jest.spyOn(idmServiceMock, 'deleteAccountById'); + it('should delete the account with given user id via repo', async () => { + const { deleteSpy } = setup(); - await accountIdmService.deleteByUserId(mockIdmAccount.attDbcUserId ?? ''); - expect(deleteSpy).toHaveBeenCalledWith(mockIdmAccount.id); + await accountIdmService.deleteByUserId(mockIdmAccount.attDbcUserId ?? ''); + expect(deleteSpy).toHaveBeenCalledWith(mockIdmAccount.id); + }); }); }); @@ -287,7 +338,7 @@ describe('AccountIdmService', () => { idmServiceMock.findAccountById.mockRejectedValue(new Error()); }; - it('should throw', async () => { + it('should throw account not found', async () => { setup(); await expect(accountIdmService.findById('notExistingId')).rejects.toThrow(); }); @@ -359,7 +410,7 @@ describe('AccountIdmService', () => { idmServiceMock.findAccountByDbcUserId.mockResolvedValue(undefined as unknown as IdmAccount); }; - it('should throw', async () => { + it('should throw account not found', async () => { setup(); await expect(accountIdmService.findByUserIdOrFail('notExistingId')).rejects.toThrow(EntityNotFoundError); }); @@ -465,7 +516,7 @@ describe('AccountIdmService', () => { }); }); - it('findMany should throw', async () => { + it('findMany should throw not implemented Exception', async () => { await expect(accountIdmService.findMany(0, 0)).rejects.toThrow(NotImplementedException); }); }); diff --git a/apps/server/src/modules/account/services/account-idm.service.ts b/apps/server/src/modules/account/services/account-idm.service.ts index 68bcfb42bae..039db80eddf 100644 --- a/apps/server/src/modules/account/services/account-idm.service.ts +++ b/apps/server/src/modules/account/services/account-idm.service.ts @@ -1,13 +1,13 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { Injectable, NotImplementedException } from '@nestjs/common'; import { EntityNotFoundError } from '@shared/common'; +import { IdentityManagementOauthService, IdentityManagementService } from '@shared/infra/identity-management'; import { Counted, EntityId, IdmAccount, IdmAccountUpdate } from '@shared/domain'; -import { IdentityManagementService, IdentityManagementOauthService } from '@shared/infra/identity-management'; import { LegacyLogger } from '@src/core/logger'; import { AccountIdmToDtoMapper } from '../mapper'; +import { AccountLookupService } from './account-lookup.service'; import { AbstractAccountService } from './account.service.abstract'; import { AccountDto, AccountSaveDto } from './dto'; -import { AccountLookupService } from './account-lookup.service'; @Injectable() export class AccountServiceIdm extends AbstractAccountService { @@ -27,6 +27,7 @@ export class AccountServiceIdm extends AbstractAccountService { return account; } + // TODO: this needs a better solution. probably needs followup meeting to come up with something async findMultipleByUserId(userIds: EntityId[]): Promise { const results = new Array(); for (const userId of userIds) { @@ -34,6 +35,7 @@ export class AccountServiceIdm extends AbstractAccountService { // eslint-disable-next-line no-await-in-loop results.push(await this.identityManager.findAccountByDbcUserId(userId)); } catch { + // TODO: dont simply forget errors. maybe use a filter instead? // ignore entry } } @@ -46,6 +48,7 @@ export class AccountServiceIdm extends AbstractAccountService { const result = await this.identityManager.findAccountByDbcUserId(userId); return this.accountIdmToDtoMapper.mapToDto(result); } catch { + // TODO: dont simply forget errors return null; } } @@ -93,8 +96,10 @@ export class AccountServiceIdm extends AbstractAccountService { attDbcUserId: accountDto.userId, attDbcSystemId: accountDto.systemId, }; + // TODO: probably do some method extraction here if (accountDto.id) { let idmId: string | undefined; + // TODO: extract into a method that hides the trycatch try { idmId = await this.getIdmAccountId(accountDto.id); } catch { diff --git a/apps/server/src/modules/account/services/account.service.abstract.ts b/apps/server/src/modules/account/services/account.service.abstract.ts index b2e198f6a86..d25dbc0ac4a 100644 --- a/apps/server/src/modules/account/services/account.service.abstract.ts +++ b/apps/server/src/modules/account/services/account.service.abstract.ts @@ -2,6 +2,8 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { Counted, EntityId } from '@shared/domain'; import { AccountDto, AccountSaveDto } from './dto'; +// TODO: split functions which are only needed for feathers + export abstract class AbstractAccountService { abstract findById(id: EntityId): Promise; @@ -11,6 +13,7 @@ export abstract class AbstractAccountService { abstract findByUserIdOrFail(userId: EntityId): Promise; + // HINT: it would be preferable to use entityId here. Needs to be checked if this is blocked by lecacy code abstract findByUsernameAndSystemId(username: string, systemId: EntityId | ObjectId): Promise; abstract save(accountDto: AccountSaveDto): Promise; diff --git a/apps/server/src/modules/account/services/account.service.integration.spec.ts b/apps/server/src/modules/account/services/account.service.integration.spec.ts index d001925000b..5d5caa24263 100644 --- a/apps/server/src/modules/account/services/account.service.integration.spec.ts +++ b/apps/server/src/modules/account/services/account.service.integration.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { createMock } from '@golevelup/ts-jest'; import KeycloakAdminClient from '@keycloak/keycloak-admin-client-cjs/keycloak-admin-client-cjs-index'; import { EntityManager } from '@mikro-orm/mongodb'; @@ -158,95 +159,151 @@ describe('AccountService Integration', () => { ); }; - it('save should create a new account', async () => { - if (!isIdmReachable) return; - const account = await accountService.save(testAccount); - await compareDbAccount(account.id, account); - await compareIdmAccount(account.idmReferenceId ?? '', account); - }); + describe('save', () => { + describe('when account not exists', () => { + it('should create a new account', async () => { + if (!isIdmReachable) return; + const account = await accountService.save(testAccount); + await compareDbAccount(account.id, account); + await compareIdmAccount(account.idmReferenceId ?? '', account); + }); + }); - it('save should update existing account', async () => { - if (!isIdmReachable) return; - const newUsername = 'jane.doe@mail.tld'; - const [dbId, idmId] = await createAccount(); - const originalAccount = await accountService.findById(dbId); - const updatedAccount = await accountService.save({ - ...originalAccount, - username: newUsername, + describe('when account exists', () => { + const setup = async () => { + const newUsername = 'jane.doe@mail.tld'; + const [dbId, idmId] = await createAccount(); + const originalAccount = await accountService.findById(dbId); + return { newUsername, dbId, idmId, originalAccount }; + }; + it('save should update existing account', async () => { + if (!isIdmReachable) return; + const { newUsername, dbId, idmId, originalAccount } = await setup(); + const updatedAccount = await accountService.save({ + ...originalAccount, + username: newUsername, + }); + await compareDbAccount(dbId, updatedAccount); + await compareIdmAccount(idmId, updatedAccount); + }); }); - await compareDbAccount(dbId, updatedAccount); - await compareIdmAccount(idmId, updatedAccount); - }); - it('save should create idm account for existing db account', async () => { - if (!isIdmReachable) return; - const newUsername = 'jane.doe@mail.tld'; - const dbId = await createDbAccount(); - const originalAccount = await accountService.findById(dbId); - const updatedAccount = await accountService.save({ - ...originalAccount, - username: newUsername, + describe('when only db account exists', () => { + const setup = async () => { + const newUsername = 'jane.doe@mail.tld'; + const dbId = await createDbAccount(); + const originalAccount = await accountService.findById(dbId); + return { newUsername, dbId, originalAccount }; + }; + it('should create idm account for existing db account', async () => { + if (!isIdmReachable) return; + const { newUsername, dbId, originalAccount } = await setup(); + + const updatedAccount = await accountService.save({ + ...originalAccount, + username: newUsername, + }); + await compareDbAccount(dbId, updatedAccount); + await compareIdmAccount(updatedAccount.idmReferenceId ?? '', updatedAccount); + }); }); - await compareDbAccount(dbId, updatedAccount); - await compareIdmAccount(updatedAccount.idmReferenceId ?? '', updatedAccount); }); - it('updateUsername should update username', async () => { - if (!isIdmReachable) return; - const newUserName = 'jane.doe@mail.tld'; - const [dbId, idmId] = await createAccount(); - await accountService.updateUsername(dbId, newUserName); + describe('updateUsername', () => { + describe('when updating Username', () => { + const setup = async () => { + const newUsername = 'jane.doe@mail.tld'; + const [dbId, idmId] = await createAccount(); - const foundAccount = await identityManagementService.findAccountById(idmId); - expect(foundAccount).toEqual( - expect.objectContaining>({ - username: newUserName, - }) - ); - const foundDbAccount = await accountRepo.findById(dbId); - expect(foundDbAccount).toEqual( - expect.objectContaining>({ - username: newUserName, - }) - ); + return { newUsername, dbId, idmId }; + }; + it('should update username', async () => { + if (!isIdmReachable) return; + const { newUsername, dbId, idmId } = await setup(); + + await accountService.updateUsername(dbId, newUsername); + const foundAccount = await identityManagementService.findAccountById(idmId); + const foundDbAccount = await accountRepo.findById(dbId); + + expect(foundAccount).toEqual( + expect.objectContaining>({ + username: newUsername, + }) + ); + expect(foundDbAccount).toEqual( + expect.objectContaining>({ + username: newUsername, + }) + ); + }); + }); }); - it('updatePassword should update password', async () => { - if (!isIdmReachable) return; - const [dbId] = await createAccount(); + describe('updatePassword', () => { + describe('when updating password', () => { + const setup = async () => { + const [dbId] = await createAccount(); + + const foundDbAccountBefore = await accountRepo.findById(dbId); + const previousPasswordHash = foundDbAccountBefore.password; + const foundDbAccountAfter = await accountRepo.findById(dbId); - const foundDbAccountBefore = await accountRepo.findById(dbId); - const previousPasswordHash = foundDbAccountBefore.password; + return { dbId, previousPasswordHash, foundDbAccountAfter }; + }; + it('should update password', async () => { + if (!isIdmReachable) return; + const { dbId, previousPasswordHash, foundDbAccountAfter } = await setup(); - await expect(accountService.updatePassword(dbId, 'newPassword')).resolves.not.toThrow(); + await expect(accountService.updatePassword(dbId, 'newPassword')).resolves.not.toThrow(); - const foundDbAccountAfter = await accountRepo.findById(dbId); - expect(foundDbAccountAfter.password).not.toEqual(previousPasswordHash); + expect(foundDbAccountAfter.password).not.toEqual(previousPasswordHash); + }); + }); }); - it('delete should remove account', async () => { - if (!isIdmReachable) return; - const [dbId, idmId] = await createAccount(); - const foundIdmAccount = await identityManagementService.findAccountById(idmId); - expect(foundIdmAccount).toBeDefined(); - const foundDbAccount = await accountRepo.findById(dbId); - expect(foundDbAccount).toBeDefined(); + describe('delete', () => { + describe('when delete an account', () => { + const setup = async () => { + const [dbId, idmId] = await createAccount(); + const foundIdmAccount = await identityManagementService.findAccountById(idmId); + const foundDbAccount = await accountRepo.findById(dbId); + + return { dbId, idmId, foundIdmAccount, foundDbAccount }; + }; + it('should remove account', async () => { + if (!isIdmReachable) return; + const { dbId, idmId, foundIdmAccount, foundDbAccount } = await setup(); - await accountService.delete(dbId); - await expect(identityManagementService.findAccountById(idmId)).rejects.toThrow(); - await expect(accountRepo.findById(dbId)).rejects.toThrow(); + expect(foundIdmAccount).toBeDefined(); + expect(foundDbAccount).toBeDefined(); + + await accountService.delete(dbId); + await expect(identityManagementService.findAccountById(idmId)).rejects.toThrow(); + await expect(accountRepo.findById(dbId)).rejects.toThrow(); + }); + }); }); - it('deleteByUserId should remove account', async () => { - if (!isIdmReachable) return; - const [dbId, idmId] = await createAccount(); - const foundAccount = await identityManagementService.findAccountById(idmId); - expect(foundAccount).toBeDefined(); - const foundDbAccount = await accountRepo.findById(dbId); - expect(foundDbAccount).toBeDefined(); + describe('deleteByUserId', () => { + describe('when delete an account by User Id', () => { + const setup = async () => { + const [dbId, idmId] = await createAccount(); + const foundIdmAccount = await identityManagementService.findAccountById(idmId); + const foundDbAccount = await accountRepo.findById(dbId); - await accountService.deleteByUserId(testAccount.userId ?? ''); - await expect(identityManagementService.findAccountById(idmId)).rejects.toThrow(); - await expect(accountRepo.findById(dbId)).rejects.toThrow(); + return { dbId, idmId, foundIdmAccount, foundDbAccount }; + }; + it('should remove account', async () => { + if (!isIdmReachable) return; + const { dbId, idmId, foundIdmAccount, foundDbAccount } = await setup(); + + expect(foundIdmAccount).toBeDefined(); + expect(foundDbAccount).toBeDefined(); + + await accountService.deleteByUserId(testAccount.userId ?? ''); + await expect(identityManagementService.findAccountById(idmId)).rejects.toThrow(); + await expect(accountRepo.findById(dbId)).rejects.toThrow(); + }); + }); }); }); diff --git a/apps/server/src/modules/account/services/account.service.spec.ts b/apps/server/src/modules/account/services/account.service.spec.ts index 33dc783d4eb..834bc5b0f89 100644 --- a/apps/server/src/modules/account/services/account.service.spec.ts +++ b/apps/server/src/modules/account/services/account.service.spec.ts @@ -63,402 +63,568 @@ describe('AccountService', () => { }); describe('findById', () => { - it('should call findById in accountServiceDb', async () => { - await expect(accountService.findById('id')).resolves.not.toThrow(); - expect(accountServiceDb.findById).toHaveBeenCalledTimes(1); + describe('When calling findById in accountService', () => { + it('should call findById in accountServiceDb', async () => { + await expect(accountService.findById('id')).resolves.not.toThrow(); + expect(accountServiceDb.findById).toHaveBeenCalledTimes(1); + }); + }); + + describe('When identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; + + it('should call idm implementation', async () => { + const service = setup(); + await expect(service.findById('accountId')).resolves.not.toThrow(); + expect(accountServiceIdm.findById).toHaveBeenCalledTimes(1); + }); }); }); describe('findByUserId', () => { - it('should call findByUserId in accountServiceDb', async () => { - await expect(accountService.findByUserId('userId')).resolves.not.toThrow(); - expect(accountServiceDb.findByUserId).toHaveBeenCalledTimes(1); + describe('When calling findByUserId in accountService', () => { + it('should call findByUserId in accountServiceDb', async () => { + await expect(accountService.findByUserId('userId')).resolves.not.toThrow(); + expect(accountServiceDb.findByUserId).toHaveBeenCalledTimes(1); + }); + }); + describe('When identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; + + it('should call idm implementation', async () => { + const service = setup(); + await expect(service.findByUserId('userId')).resolves.not.toThrow(); + expect(accountServiceIdm.findByUserId).toHaveBeenCalledTimes(1); + }); }); }); describe('findByUsernameAndSystemId', () => { - it('should call findByUsernameAndSystemId in accountServiceDb', async () => { - await expect(accountService.findByUsernameAndSystemId('username', 'systemId')).resolves.not.toThrow(); - expect(accountServiceDb.findByUsernameAndSystemId).toHaveBeenCalledTimes(1); + describe('When calling findByUsernameAndSystemId in accountService', () => { + it('should call findByUsernameAndSystemId in accountServiceDb', async () => { + await expect(accountService.findByUsernameAndSystemId('username', 'systemId')).resolves.not.toThrow(); + expect(accountServiceDb.findByUsernameAndSystemId).toHaveBeenCalledTimes(1); + }); }); - }); + describe('when identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; - describe('findMultipleByUserId', () => { - it('should call findMultipleByUserId in accountServiceDb', async () => { - await expect(accountService.findMultipleByUserId(['userId1, userId2'])).resolves.not.toThrow(); - expect(accountServiceDb.findMultipleByUserId).toHaveBeenCalledTimes(1); + it('should call idm implementation', async () => { + const service = setup(); + await expect(service.findByUsernameAndSystemId('username', 'systemId')).resolves.not.toThrow(); + expect(accountServiceIdm.findByUsernameAndSystemId).toHaveBeenCalledTimes(1); + }); }); }); - describe('findByUserIdOrFail', () => { - it('should call findByUserIdOrFail in accountServiceDb', async () => { - await expect(accountService.findByUserIdOrFail('userId')).resolves.not.toThrow(); - expect(accountServiceDb.findByUserIdOrFail).toHaveBeenCalledTimes(1); + describe('findMultipleByUserId', () => { + describe('When calling findMultipleByUserId in accountService', () => { + it('should call findMultipleByUserId in accountServiceDb', async () => { + await expect(accountService.findMultipleByUserId(['userId1, userId2'])).resolves.not.toThrow(); + expect(accountServiceDb.findMultipleByUserId).toHaveBeenCalledTimes(1); + }); }); - }); + describe('When identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; - describe('save', () => { - it('should call save in accountServiceDb', async () => { - await expect(accountService.save({} as AccountSaveDto)).resolves.not.toThrow(); - expect(accountServiceDb.save).toHaveBeenCalledTimes(1); + it('should call idm implementation', async () => { + const service = setup(); + await expect(service.findMultipleByUserId(['userId'])).resolves.not.toThrow(); + expect(accountServiceIdm.findMultipleByUserId).toHaveBeenCalledTimes(1); + }); }); - it('should call save in accountServiceIdm if feature is enabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(true); + }); - await expect(accountService.save({} as AccountSaveDto)).resolves.not.toThrow(); - expect(accountServiceIdm.save).toHaveBeenCalledTimes(1); + describe('findByUserIdOrFail', () => { + describe('When calling findByUserIdOrFail in accountService', () => { + it('should call findByUserIdOrFail in accountServiceDb', async () => { + await expect(accountService.findByUserIdOrFail('userId')).resolves.not.toThrow(); + expect(accountServiceDb.findByUserIdOrFail).toHaveBeenCalledTimes(1); + }); }); - it('should not call save in accountServiceIdm if feature is disabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(false); + describe('When identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; - await expect(accountService.save({} as AccountSaveDto)).resolves.not.toThrow(); - expect(accountServiceIdm.save).not.toHaveBeenCalled(); + it('should call idm implementation', async () => { + const service = setup(); + await expect(service.findByUserIdOrFail('userId')).resolves.not.toThrow(); + expect(accountServiceIdm.findByUserIdOrFail).toHaveBeenCalledTimes(1); + }); }); }); - describe('saveWithValidation', () => { - it('should not sanitize username for external user', async () => { - const spy = jest.spyOn(accountService, 'save'); - const params: AccountSaveDto = { - username: ' John.Doe@domain.tld ', - systemId: 'ABC123', - }; - await accountService.saveWithValidation(params); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - username: ' John.Doe@domain.tld ', - }) - ); - spy.mockRestore(); - }); - it('should throw if username for a local user is not an email', async () => { - const params: AccountSaveDto = { - username: 'John Doe', - password: 'JohnsPassword', - }; - await expect(accountService.saveWithValidation(params)).rejects.toThrow('Username is not an email'); - }); - it('should not throw if username for an external user is not an email', async () => { - const params: AccountSaveDto = { - username: 'John Doe', - systemId: 'ABC123', - }; - await expect(accountService.saveWithValidation(params)).resolves.not.toThrow(); + describe('save', () => { + describe('When calling save in accountService', () => { + it('should call save in accountServiceDb', async () => { + await expect(accountService.save({} as AccountSaveDto)).resolves.not.toThrow(); + expect(accountServiceDb.save).toHaveBeenCalledTimes(1); + }); }); - it('should not throw if username for an external user is a ldap search string', async () => { - const params: AccountSaveDto = { - username: 'dc=schul-cloud,dc=org/fake.ldap', - systemId: 'ABC123', + describe('When calling save in accountService if feature is enabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(true); }; - await expect(accountService.saveWithValidation(params)).resolves.not.toThrow(); + it('should call save in accountServiceIdm', async () => { + setup(); + + await expect(accountService.save({} as AccountSaveDto)).resolves.not.toThrow(); + expect(accountServiceIdm.save).toHaveBeenCalledTimes(1); + }); }); - it('should throw if no password is provided for an internal user', async () => { - const params: AccountSaveDto = { - username: 'john.doe@mail.tld', + describe('When calling save in accountService if feature is disabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(false); }; - await expect(accountService.saveWithValidation(params)).rejects.toThrow('No password provided'); + it('should not call save in accountServiceIdm', async () => { + setup(); + + await expect(accountService.save({} as AccountSaveDto)).resolves.not.toThrow(); + expect(accountServiceIdm.save).not.toHaveBeenCalled(); + }); }); - it('should throw if account already exists', async () => { - const params: AccountSaveDto = { - username: 'john.doe@mail.tld', - password: 'JohnsPassword', - userId: 'userId123', + describe('when identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); }; - accountServiceDb.findByUserId.mockResolvedValueOnce({ id: 'foundAccount123' } as AccountDto); - await expect(accountService.saveWithValidation(params)).rejects.toThrow('Account already exists'); - }); - it('should throw if username already exists', async () => { - const accountIsUniqueEmailSpy = jest.spyOn(accountValidationService, 'isUniqueEmail'); - accountIsUniqueEmailSpy.mockResolvedValueOnce(false); - const params: AccountSaveDto = { - username: 'john.doe@mail.tld', - password: 'JohnsPassword', - }; - await expect(accountService.saveWithValidation(params)).rejects.toThrow('Username already exists'); + it('should call idm implementation', async () => { + setup(); + await expect(accountService.save({ username: 'username' })).resolves.not.toThrow(); + expect(accountServiceIdm.save).toHaveBeenCalledTimes(1); + }); }); }); - describe('updateUsername', () => { - it('should call updateUsername in accountServiceDb', async () => { - await expect(accountService.updateUsername('accountId', 'username')).resolves.not.toThrow(); - expect(accountServiceDb.updateUsername).toHaveBeenCalledTimes(1); + describe('saveWithValidation', () => { + describe('When calling saveWithValidation on accountService', () => { + const setup = () => { + const spy = jest.spyOn(accountService, 'save'); + return spy; + }; + it('should not sanitize username for external user', async () => { + const spy = setup(); + + const params: AccountSaveDto = { + username: ' John.Doe@domain.tld ', + systemId: 'ABC123', + }; + await accountService.saveWithValidation(params); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + username: ' John.Doe@domain.tld ', + }) + ); + spy.mockRestore(); + }); }); - it('should call updateUsername in accountServiceIdm if feature is enabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(true); - await expect(accountService.updateUsername('accountId', 'username')).resolves.not.toThrow(); - expect(accountServiceIdm.updateUsername).toHaveBeenCalledTimes(1); + describe('When username for a local user is not an email', () => { + it('should throw username is not an email error', async () => { + const params: AccountSaveDto = { + username: 'John Doe', + password: 'JohnsPassword', + }; + await expect(accountService.saveWithValidation(params)).rejects.toThrow('Username is not an email'); + }); }); - it('should not call updateUsername in accountServiceIdm if feature is disabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(false); - await expect(accountService.updateUsername('accountId', 'username')).resolves.not.toThrow(); - expect(accountServiceIdm.updateUsername).not.toHaveBeenCalled(); + describe('When username for an external user is not an email', () => { + it('should not throw an error', async () => { + const params: AccountSaveDto = { + username: 'John Doe', + systemId: 'ABC123', + }; + await expect(accountService.saveWithValidation(params)).resolves.not.toThrow(); + }); }); - }); - describe('updateLastTriedFailedLogin', () => { - it('should call updateLastTriedFailedLogin in accountServiceDb', async () => { - await expect(accountService.updateLastTriedFailedLogin('accountId', {} as Date)).resolves.not.toThrow(); - expect(accountServiceDb.updateLastTriedFailedLogin).toHaveBeenCalledTimes(1); + describe('When username for an external user is a ldap search string', () => { + it('should not throw an error', async () => { + const params: AccountSaveDto = { + username: 'dc=schul-cloud,dc=org/fake.ldap', + systemId: 'ABC123', + }; + await expect(accountService.saveWithValidation(params)).resolves.not.toThrow(); + }); }); - }); - describe('updatePassword', () => { - it('should call updatePassword in accountServiceDb', async () => { - await expect(accountService.updatePassword('accountId', 'password')).resolves.not.toThrow(); - expect(accountServiceDb.updatePassword).toHaveBeenCalledTimes(1); + describe('When no password is provided for an internal user', () => { + it('should throw no password provided error', async () => { + const params: AccountSaveDto = { + username: 'john.doe@mail.tld', + }; + await expect(accountService.saveWithValidation(params)).rejects.toThrow('No password provided'); + }); }); - it('should call updatePassword in accountServiceIdm if feature is enabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(true); - await expect(accountService.updatePassword('accountId', 'password')).resolves.not.toThrow(); - expect(accountServiceIdm.updatePassword).toHaveBeenCalledTimes(1); + describe('When account already exists', () => { + it('should throw account already exists', async () => { + const params: AccountSaveDto = { + username: 'john.doe@mail.tld', + password: 'JohnsPassword', + userId: 'userId123', + }; + accountServiceDb.findByUserId.mockResolvedValueOnce({ id: 'foundAccount123' } as AccountDto); + await expect(accountService.saveWithValidation(params)).rejects.toThrow('Account already exists'); + }); }); - it('should not call updatePassword in accountServiceIdm if feature is disabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(false); - await expect(accountService.updatePassword('accountId', 'password')).resolves.not.toThrow(); - expect(accountServiceIdm.updatePassword).not.toHaveBeenCalled(); + describe('When username already exists', () => { + const setup = () => { + accountValidationService.isUniqueEmail.mockResolvedValueOnce(false); + }; + it('should throw username already exists', async () => { + setup(); + const params: AccountSaveDto = { + username: 'john.doe@mail.tld', + password: 'JohnsPassword', + }; + await expect(accountService.saveWithValidation(params)).rejects.toThrow('Username already exists'); + }); }); - }); + describe('When identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; - describe('validatePassword', () => { - const setup = () => { - configService.get.mockReturnValue(true); - return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); - }; - it('should call validatePassword in accountServiceDb', async () => { - await expect(accountService.validatePassword({} as AccountDto, 'password')).resolves.not.toThrow(); - expect(accountServiceIdm.validatePassword).toHaveBeenCalledTimes(0); - expect(accountServiceDb.validatePassword).toHaveBeenCalledTimes(1); - }); - it('should call validatePassword in accountServiceIdm if feature is enabled', async () => { - const service = setup(); - await expect(service.validatePassword({} as AccountDto, 'password')).resolves.not.toThrow(); - expect(accountServiceDb.validatePassword).toHaveBeenCalledTimes(0); - expect(accountServiceIdm.validatePassword).toHaveBeenCalledTimes(1); + it('should call idm implementation', async () => { + setup(); + await expect( + accountService.saveWithValidation({ username: 'username@mail.tld', password: 'password' }) + ).resolves.not.toThrow(); + expect(accountServiceIdm.save).toHaveBeenCalledTimes(1); + }); }); }); - describe('delete', () => { - it('should call delete in accountServiceDb', async () => { - await expect(accountService.delete('accountId')).resolves.not.toThrow(); - expect(accountServiceDb.delete).toHaveBeenCalledTimes(1); + describe('updateUsername', () => { + describe('When calling updateUsername in accountService', () => { + it('should call updateUsername in accountServiceDb', async () => { + await expect(accountService.updateUsername('accountId', 'username')).resolves.not.toThrow(); + expect(accountServiceDb.updateUsername).toHaveBeenCalledTimes(1); + }); }); - it('should call delete in accountServiceIdm if feature is enabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(true); - await expect(accountService.delete('accountId')).resolves.not.toThrow(); - expect(accountServiceIdm.delete).toHaveBeenCalledTimes(1); - }); - it('should not call delete in accountServiceIdm if feature is disabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(false); + describe('When calling updateUsername in accountService if idm feature is enabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(true); + }; + it('should call updateUsername in accountServiceIdm', async () => { + setup(); - await expect(accountService.delete('accountId')).resolves.not.toThrow(); - expect(accountServiceIdm.delete).not.toHaveBeenCalled(); + await expect(accountService.updateUsername('accountId', 'username')).resolves.not.toThrow(); + expect(accountServiceIdm.updateUsername).toHaveBeenCalledTimes(1); + }); }); - }); - describe('deleteByUserId', () => { - it('should call deleteByUserId in accountServiceDb', async () => { - await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); - expect(accountServiceDb.deleteByUserId).toHaveBeenCalledTimes(1); - }); - it('should call deleteByUserId in accountServiceIdm if feature is enabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(true); + describe('When calling updateUsername in accountService if idm feature is disabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(false); + }; + it('should not call updateUsername in accountServiceIdm', async () => { + setup(); - await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); - expect(accountServiceIdm.deleteByUserId).toHaveBeenCalledTimes(1); + await expect(accountService.updateUsername('accountId', 'username')).resolves.not.toThrow(); + expect(accountServiceIdm.updateUsername).not.toHaveBeenCalled(); + }); }); - it('should not call deleteByUserId in accountServiceIdm if feature is disabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(false); + describe('When identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; - await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); - expect(accountServiceIdm.deleteByUserId).not.toHaveBeenCalled(); + it('should call idm implementation', async () => { + setup(); + await expect(accountService.updateUsername('accountId', 'username')).resolves.not.toThrow(); + expect(accountServiceIdm.updateUsername).toHaveBeenCalledTimes(1); + }); }); }); - describe('findMany', () => { - it('should call findMany in accountServiceDb', async () => { - await expect(accountService.findMany()).resolves.not.toThrow(); - expect(accountServiceDb.findMany).toHaveBeenCalledTimes(1); + describe('updateLastTriedFailedLogin', () => { + describe('When calling updateLastTriedFailedLogin in accountService', () => { + it('should call updateLastTriedFailedLogin in accountServiceDb', async () => { + await expect(accountService.updateLastTriedFailedLogin('accountId', {} as Date)).resolves.not.toThrow(); + expect(accountServiceDb.updateLastTriedFailedLogin).toHaveBeenCalledTimes(1); + }); }); - }); + describe('when identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; - describe('searchByUsernamePartialMatch', () => { - it('should call searchByUsernamePartialMatch in accountServiceDb', async () => { - await expect(accountService.searchByUsernamePartialMatch('username', 1, 1)).resolves.not.toThrow(); - expect(accountServiceDb.searchByUsernamePartialMatch).toHaveBeenCalledTimes(1); + it('should call idm implementation', async () => { + setup(); + await expect(accountService.updateLastTriedFailedLogin('accountId', new Date())).resolves.not.toThrow(); + expect(accountServiceIdm.updateLastTriedFailedLogin).toHaveBeenCalledTimes(1); + }); }); }); - describe('searchByUsernameExactMatch', () => { - it('should call searchByUsernameExactMatch in accountServiceDb', async () => { - await expect(accountService.searchByUsernameExactMatch('username')).resolves.not.toThrow(); - expect(accountServiceDb.searchByUsernameExactMatch).toHaveBeenCalledTimes(1); + describe('updatePassword', () => { + describe('When calling updatePassword in accountService', () => { + it('should call updatePassword in accountServiceDb', async () => { + await expect(accountService.updatePassword('accountId', 'password')).resolves.not.toThrow(); + expect(accountServiceDb.updatePassword).toHaveBeenCalledTimes(1); + }); }); - }); - describe('executeIdmMethod', () => { - it('should throw an error object', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(true); - const spyLogger = jest.spyOn(logger, 'error'); - const testError = new Error('error'); + describe('When calling updatePassword in accountService if feature is enabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(true); + }; + it('should call updatePassword in accountServiceIdm', async () => { + setup(); - const deleteByUserIdMock = jest.spyOn(accountServiceIdm, 'deleteByUserId'); - deleteByUserIdMock.mockImplementationOnce(() => { - throw testError; + await expect(accountService.updatePassword('accountId', 'password')).resolves.not.toThrow(); + expect(accountServiceIdm.updatePassword).toHaveBeenCalledTimes(1); }); - - await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); - expect(spyLogger).toHaveBeenCalledWith(testError, expect.anything()); }); - it('should throw an non error object', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(true); - const spyLogger = jest.spyOn(logger, 'error'); + describe('When calling updatePassword in accountService if feature is disabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(false); + }; + it('should not call updatePassword in accountServiceIdm', async () => { + setup(); - const deleteByUserIdMock = jest.spyOn(accountServiceIdm, 'deleteByUserId'); - deleteByUserIdMock.mockImplementationOnce(() => { - // eslint-disable-next-line @typescript-eslint/no-throw-literal - throw 'a non error object'; + await expect(accountService.updatePassword('accountId', 'password')).resolves.not.toThrow(); + expect(accountServiceIdm.updatePassword).not.toHaveBeenCalled(); }); + }); + describe('When identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; - await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); - expect(spyLogger).toHaveBeenCalledWith('a non error object'); + it('should call idm implementation', async () => { + setup(); + await expect(accountService.updatePassword('accountId', 'password')).resolves.not.toThrow(); + expect(accountServiceIdm.updatePassword).toHaveBeenCalledTimes(1); + }); }); }); - describe('when identity management is primary', () => { - const setup = () => { - configService.get.mockReturnValue(true); - return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); - }; - - describe('findById', () => { - it('should call idm implementation', async () => { - const service = setup(); - await expect(service.findById('accountId')).resolves.not.toThrow(); - expect(accountServiceIdm.findById).toHaveBeenCalledTimes(1); + describe('validatePassword', () => { + describe('When calling validatePassword in accountService', () => { + it('should call validatePassword in accountServiceDb', async () => { + await expect(accountService.validatePassword({} as AccountDto, 'password')).resolves.not.toThrow(); + expect(accountServiceIdm.validatePassword).toHaveBeenCalledTimes(0); + expect(accountServiceDb.validatePassword).toHaveBeenCalledTimes(1); }); }); - describe('findMultipleByUserId', () => { - it('should call idm implementation', async () => { + describe('When calling validatePassword in accountService if feature is enabled', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; + it('should call validatePassword in accountServiceIdm', async () => { const service = setup(); - await expect(service.findMultipleByUserId(['userId'])).resolves.not.toThrow(); - expect(accountServiceIdm.findMultipleByUserId).toHaveBeenCalledTimes(1); + await expect(service.validatePassword({} as AccountDto, 'password')).resolves.not.toThrow(); + expect(accountServiceDb.validatePassword).toHaveBeenCalledTimes(0); + expect(accountServiceIdm.validatePassword).toHaveBeenCalledTimes(1); }); }); + }); - describe('findByUserId', () => { - it('should call idm implementation', async () => { - const service = setup(); - await expect(service.findByUserId('userId')).resolves.not.toThrow(); - expect(accountServiceIdm.findByUserId).toHaveBeenCalledTimes(1); + describe('delete', () => { + describe('When calling delete in accountService', () => { + it('should call delete in accountServiceDb', async () => { + await expect(accountService.delete('accountId')).resolves.not.toThrow(); + expect(accountServiceDb.delete).toHaveBeenCalledTimes(1); }); }); - describe('findByUserIdOrFail', () => { - it('should call idm implementation', async () => { - const service = setup(); - await expect(service.findByUserIdOrFail('userId')).resolves.not.toThrow(); - expect(accountServiceIdm.findByUserIdOrFail).toHaveBeenCalledTimes(1); + describe('When calling delete in accountService if feature is enabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(true); + }; + it('should call delete in accountServiceIdm', async () => { + setup(); + + await expect(accountService.delete('accountId')).resolves.not.toThrow(); + expect(accountServiceIdm.delete).toHaveBeenCalledTimes(1); }); }); - describe('findByUsernameAndSystemId', () => { - it('should call idm implementation', async () => { - const service = setup(); - await expect(service.findByUsernameAndSystemId('username', 'systemId')).resolves.not.toThrow(); - expect(accountServiceIdm.findByUsernameAndSystemId).toHaveBeenCalledTimes(1); + describe('When calling delete in accountService if feature is disabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(false); + }; + it('should not call delete in accountServiceIdm', async () => { + setup(); + + await expect(accountService.delete('accountId')).resolves.not.toThrow(); + expect(accountServiceIdm.delete).not.toHaveBeenCalled(); }); }); + describe('When identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; - describe('searchByUsernamePartialMatch', () => { it('should call idm implementation', async () => { - const service = setup(); - await expect(service.searchByUsernamePartialMatch('username', 0, 1)).resolves.not.toThrow(); - expect(accountServiceIdm.searchByUsernamePartialMatch).toHaveBeenCalledTimes(1); + setup(); + await expect(accountService.delete('accountId')).resolves.not.toThrow(); + expect(accountServiceIdm.delete).toHaveBeenCalledTimes(1); }); }); + }); - describe('searchByUsernameExactMatch', () => { - it('should call idm implementation', async () => { - const service = setup(); - await expect(service.searchByUsernameExactMatch('username')).resolves.not.toThrow(); - expect(accountServiceIdm.searchByUsernameExactMatch).toHaveBeenCalledTimes(1); + describe('deleteByUserId', () => { + describe('When calling deleteByUserId in accountService', () => { + it('should call deleteByUserId in accountServiceDb', async () => { + await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); + expect(accountServiceDb.deleteByUserId).toHaveBeenCalledTimes(1); }); }); - describe('save', () => { - it('should call idm implementation', async () => { + describe('When calling deleteByUserId in accountService if feature is enabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(true); + }; + it('should call deleteByUserId in accountServiceIdm', async () => { setup(); - await expect(accountService.save({ username: 'username' })).resolves.not.toThrow(); - expect(accountServiceIdm.save).toHaveBeenCalledTimes(1); + + await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); + expect(accountServiceIdm.deleteByUserId).toHaveBeenCalledTimes(1); }); }); - describe('saveWithValidation', () => { - it('should call idm implementation', async () => { + describe('When calling deleteByUserId in accountService if feature is disabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(false); + }; + it('should not call deleteByUserId in accountServiceIdm', async () => { setup(); - await expect( - accountService.saveWithValidation({ username: 'username@mail.tld', password: 'password' }) - ).resolves.not.toThrow(); - expect(accountServiceIdm.save).toHaveBeenCalledTimes(1); + + await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); + expect(accountServiceIdm.deleteByUserId).not.toHaveBeenCalled(); }); }); + describe('When identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; - describe('updateUsername', () => { it('should call idm implementation', async () => { setup(); - await expect(accountService.updateUsername('accountId', 'username')).resolves.not.toThrow(); - expect(accountServiceIdm.updateUsername).toHaveBeenCalledTimes(1); + await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); + expect(accountServiceIdm.deleteByUserId).toHaveBeenCalledTimes(1); }); }); + }); - describe('updateLastTriedFailedLogin', () => { - it('should call idm implementation', async () => { - setup(); - await expect(accountService.updateLastTriedFailedLogin('accountId', new Date())).resolves.not.toThrow(); - expect(accountServiceIdm.updateLastTriedFailedLogin).toHaveBeenCalledTimes(1); + describe('findMany', () => { + describe('When calling findMany in accountService', () => { + it('should call findMany in accountServiceDb', async () => { + await expect(accountService.findMany()).resolves.not.toThrow(); + expect(accountServiceDb.findMany).toHaveBeenCalledTimes(1); }); }); + }); - describe('updatePassword', () => { - it('should call idm implementation', async () => { - setup(); - await expect(accountService.updatePassword('accountId', 'password')).resolves.not.toThrow(); - expect(accountServiceIdm.updatePassword).toHaveBeenCalledTimes(1); + describe('searchByUsernamePartialMatch', () => { + describe('When calling searchByUsernamePartialMatch in accountService', () => { + it('should call searchByUsernamePartialMatch in accountServiceDb', async () => { + await expect(accountService.searchByUsernamePartialMatch('username', 1, 1)).resolves.not.toThrow(); + expect(accountServiceDb.searchByUsernamePartialMatch).toHaveBeenCalledTimes(1); }); }); + describe('when identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; - describe('delete', () => { it('should call idm implementation', async () => { - setup(); - await expect(accountService.delete('accountId')).resolves.not.toThrow(); - expect(accountServiceIdm.delete).toHaveBeenCalledTimes(1); + const service = setup(); + await expect(service.searchByUsernamePartialMatch('username', 0, 1)).resolves.not.toThrow(); + expect(accountServiceIdm.searchByUsernamePartialMatch).toHaveBeenCalledTimes(1); }); }); + }); + + describe('searchByUsernameExactMatch', () => { + describe('When calling searchByUsernameExactMatch in accountService', () => { + it('should call searchByUsernameExactMatch in accountServiceDb', async () => { + await expect(accountService.searchByUsernameExactMatch('username')).resolves.not.toThrow(); + expect(accountServiceDb.searchByUsernameExactMatch).toHaveBeenCalledTimes(1); + }); + }); + describe('when identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; - describe('deleteByUserId', () => { it('should call idm implementation', async () => { - setup(); + const service = setup(); + await expect(service.searchByUsernameExactMatch('username')).resolves.not.toThrow(); + expect(accountServiceIdm.searchByUsernameExactMatch).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('executeIdmMethod', () => { + describe('When idm feature is enabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(true); + const testError = new Error('error'); + accountServiceIdm.deleteByUserId.mockImplementationOnce(() => { + throw testError; + }); + + const spyLogger = jest.spyOn(logger, 'error'); + + return { testError, spyLogger }; + }; + it('should call executeIdmMethod and throw an error object', async () => { + const { testError, spyLogger } = setup(); + await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); - expect(accountServiceIdm.deleteByUserId).toHaveBeenCalledTimes(1); + expect(spyLogger).toHaveBeenCalledWith(testError, expect.anything()); + }); + }); + + describe('When idm feature is enabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(true); + const spyLogger = jest.spyOn(logger, 'error'); + const deleteByUserIdMock = jest.spyOn(accountServiceIdm, 'deleteByUserId'); + deleteByUserIdMock.mockImplementationOnce(() => { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw 'a non error object'; + }); + return { spyLogger }; + }; + it('should call executeIdmMethod and throw an error object', async () => { + const { spyLogger } = setup(); + + await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); + expect(spyLogger).toHaveBeenCalledWith('a non error object'); }); }); }); diff --git a/apps/server/src/modules/account/services/account.service.ts b/apps/server/src/modules/account/services/account.service.ts index 6c2070550ab..3c8a5ff2058 100644 --- a/apps/server/src/modules/account/services/account.service.ts +++ b/apps/server/src/modules/account/services/account.service.ts @@ -4,7 +4,8 @@ import { ConfigService } from '@nestjs/config'; import { ValidationError } from '@shared/common'; import { Counted } from '@shared/domain'; import { isEmail, validateOrReject } from 'class-validator'; -import { LegacyLogger } from '../../../core/logger'; +import { LegacyLogger } from '../../../core/logger'; // TODO: use path alias +// TODO: account needs to define its own config, which is made available for the server import { IServerConfig } from '../../server/server.config'; import { AccountServiceDb } from './account-db.service'; import { AccountServiceIdm } from './account-idm.service'; @@ -12,6 +13,11 @@ import { AbstractAccountService } from './account.service.abstract'; import { AccountValidationService } from './account.validation.service'; import { AccountDto, AccountSaveDto } from './dto'; +/* TODO: extract a service that contains all things required by feathers, +which is responsible for the additionally required validation + +it should be clearly visible which functions are only needed for feathers, and easy to remove them */ + @Injectable() export class AccountService extends AbstractAccountService { private readonly accountImpl: AbstractAccountService; @@ -78,6 +84,7 @@ export class AccountService extends AbstractAccountService { } async saveWithValidation(dto: AccountSaveDto): Promise { + // TODO: move as much as possible into the class validator await validateOrReject(dto); // sanatizeUsername ✔ if (!dto.systemId) { @@ -108,6 +115,7 @@ export class AccountService extends AbstractAccountService { // dto.password = undefined; // } + // TODO: split validation from saving, so it can be used independently await this.save(dto); } diff --git a/apps/server/src/modules/account/services/account.validation.service.spec.ts b/apps/server/src/modules/account/services/account.validation.service.spec.ts index dba1e2bf02a..c152f01a59b 100644 --- a/apps/server/src/modules/account/services/account.validation.service.spec.ts +++ b/apps/server/src/modules/account/services/account.validation.service.spec.ts @@ -1,9 +1,9 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { EntityNotFoundError } from '@shared/common'; -import { Account, EntityId, Permission, Role, RoleName, User } from '@shared/domain'; +import { Permission, Role, RoleName } from '@shared/domain'; import { UserRepo } from '@shared/repo'; import { accountFactory, setupEntities, systemFactory, userFactory } from '@shared/testing'; import { ObjectId } from 'bson'; +import { DeepMocked, createMock } from '@golevelup/ts-jest'; import { AccountRepo } from '../repo/account.repo'; import { AccountValidationService } from './account.validation.service'; @@ -11,26 +11,8 @@ describe('AccountValidationService', () => { let module: TestingModule; let accountValidationService: AccountValidationService; - let mockTeacherUser: User; - let mockTeacherAccount: Account; - - let mockStudentUser: User; - let mockStudentAccount: Account; - - let mockOtherTeacherUser: User; - let mockOtherTeacherAccount: Account; - - let mockAdminUser: User; - - let mockExternalUser: User; - let mockExternalUserAccount: Account; - let mockOtherExternalUser: User; - let mockOtherExternalUserAccount: Account; - - let oprhanAccount: Account; - - let mockUsers: User[]; - let mockAccounts: Account[]; + let userRepo: DeepMocked; + let accountRepo: DeepMocked; afterAll(async () => { await module.close(); @@ -42,237 +24,405 @@ describe('AccountValidationService', () => { AccountValidationService, { provide: AccountRepo, - useValue: { - findById: jest.fn().mockImplementation((accountId: EntityId): Promise => { - const account = mockAccounts.find((tempAccount) => tempAccount.id === accountId); - - if (account) { - return Promise.resolve(account); - } - throw new EntityNotFoundError(Account.name); - }), - searchByUsernameExactMatch: jest - .fn() - .mockImplementation((username: string): Promise<[Account[], number]> => { - const account = mockAccounts.find((tempAccount) => tempAccount.username === username); - - if (account) { - return Promise.resolve([[account], 1]); - } - if (username === 'not@available.username') { - return Promise.resolve([[mockOtherTeacherAccount], 1]); - } - if (username === 'multiple@account.username') { - return Promise.resolve([mockAccounts, mockAccounts.length]); - } - return Promise.resolve([[], 0]); - }), - findByUserId: (userId: EntityId): Promise => { - const account = mockAccounts.find((tempAccount) => tempAccount.userId?.toString() === userId); - if (account) { - return Promise.resolve(account); - } - return Promise.resolve(null); - }, - }, + useValue: createMock(), }, { provide: UserRepo, - useValue: { - findById: jest.fn().mockImplementation((userId: EntityId): Promise => { - const user = mockUsers.find((tempUser) => tempUser.id === userId); - if (user) { - return Promise.resolve(user); - } - throw new EntityNotFoundError(User.name); - }), - findByEmail: jest.fn().mockImplementation((email: string): Promise => { - const user = mockUsers.find((tempUser) => tempUser.email === email); - - if (user) { - return Promise.resolve([user]); - } - if (email === 'multiple@user.email') { - return Promise.resolve(mockUsers); - } - return Promise.resolve([]); - }), - }, + useValue: createMock(), }, ], }).compile(); accountValidationService = module.get(AccountValidationService); + + userRepo = module.get(UserRepo); + accountRepo = module.get(AccountRepo); + await setupEntities(); }); beforeEach(() => { - mockTeacherUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], - }); - mockStudentUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - mockOtherTeacherUser = userFactory.buildWithId({ - roles: [ - new Role({ - name: RoleName.TEACHER, - permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], - }), - ], - }); - mockExternalUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - mockOtherExternalUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); + jest.resetAllMocks(); + }); - mockTeacherAccount = accountFactory.buildWithId({ userId: mockTeacherUser.id }); - mockStudentAccount = accountFactory.buildWithId({ userId: mockStudentUser.id }); - mockOtherTeacherAccount = accountFactory.buildWithId({ - userId: mockOtherTeacherUser.id, - }); - const externalSystemA = systemFactory.buildWithId(); - const externalSystemB = systemFactory.buildWithId(); - mockExternalUserAccount = accountFactory.buildWithId({ - userId: mockExternalUser.id, - username: 'unique.within@system', - systemId: externalSystemA.id, + describe('isUniqueEmail', () => { + describe('When new email is available', () => { + const setup = () => { + userRepo.findByEmail.mockResolvedValueOnce([]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[], 0]); + }; + it('should return true', async () => { + setup(); + + const res = await accountValidationService.isUniqueEmail('an@available.email'); + expect(res).toBe(true); + }); }); - mockOtherExternalUserAccount = accountFactory.buildWithId({ - userId: mockOtherExternalUser.id, - username: 'unique.within@system', - systemId: externalSystemB.id, + + describe('When new email is available', () => { + const setup = () => { + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[], 0]); + + return { mockStudentUser }; + }; + it('should return true and ignore current user', async () => { + const { mockStudentUser } = setup(); + const res = await accountValidationService.isUniqueEmail(mockStudentUser.email, mockStudentUser.id); + expect(res).toBe(true); + }); }); - oprhanAccount = accountFactory.buildWithId({ - username: 'orphan@account', - userId: undefined, - systemId: new ObjectId(), + describe('When new email is available', () => { + const setup = () => { + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockStudentAccount = accountFactory.buildWithId({ userId: mockStudentUser.id }); + + userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockStudentAccount], 1]); + + return { mockStudentUser, mockStudentAccount }; + }; + it('should return true and ignore current users account', async () => { + const { mockStudentUser, mockStudentAccount } = setup(); + const res = await accountValidationService.isUniqueEmail( + mockStudentAccount.username, + mockStudentUser.id, + mockStudentAccount.id + ); + expect(res).toBe(true); + }); }); - mockAccounts = [ - mockTeacherAccount, - mockStudentAccount, - mockOtherTeacherAccount, - mockExternalUserAccount, - mockOtherExternalUserAccount, - oprhanAccount, - ]; - mockAdminUser = userFactory.buildWithId({ - roles: [ - new Role({ - name: RoleName.ADMINISTRATOR, - permissions: [ - Permission.TEACHER_EDIT, - Permission.STUDENT_EDIT, - Permission.STUDENT_LIST, - Permission.TEACHER_LIST, - Permission.TEACHER_CREATE, - Permission.STUDENT_CREATE, - Permission.TEACHER_DELETE, - Permission.STUDENT_DELETE, + describe('When new email already in use by another user', () => { + const setup = () => { + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockAdminUser = userFactory.buildWithId({ + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), ], - }), - ], - }); - mockUsers = [ - mockTeacherUser, - mockStudentUser, - mockOtherTeacherUser, - mockAdminUser, - mockExternalUser, - mockOtherExternalUser, - ]; - }); + }); + const mockAdminAccount = accountFactory.buildWithId({ userId: mockAdminUser.id }); + const mockStudentAccount = accountFactory.buildWithId({ userId: mockStudentUser.id }); - describe('isUniqueEmail', () => { - it('should return true if new email is available', async () => { - const res = await accountValidationService.isUniqueEmail('an@available.email'); - expect(res).toBe(true); - }); - it('should return true if new email is available and ignore current user', async () => { - const res = await accountValidationService.isUniqueEmail(mockStudentUser.email, mockStudentUser.id); - expect(res).toBe(true); - }); - it('should return true if new email is available and ignore current users account', async () => { - const res = await accountValidationService.isUniqueEmail( - mockStudentAccount.username, - mockStudentUser.id, - mockStudentAccount.id - ); - expect(res).toBe(true); - }); - it('should return false if new email already in use by another user', async () => { - const res = await accountValidationService.isUniqueEmail( - mockAdminUser.email, - mockStudentUser.id, - mockStudentAccount.id - ); - expect(res).toBe(false); + userRepo.findByEmail.mockResolvedValueOnce([mockAdminUser]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockAdminAccount], 1]); + + return { mockAdminUser, mockStudentUser, mockStudentAccount }; + }; + it('should return false', async () => { + const { mockAdminUser, mockStudentUser, mockStudentAccount } = setup(); + const res = await accountValidationService.isUniqueEmail( + mockAdminUser.email, + mockStudentUser.id, + mockStudentAccount.id + ); + expect(res).toBe(false); + }); }); - it('should return false if new email is already in use by any user, system id is given', async () => { - const res = await accountValidationService.isUniqueEmail( - mockTeacherAccount.username, - mockStudentUser.id, - mockStudentAccount.id, - mockStudentAccount.systemId?.toString() - ); - expect(res).toBe(false); + + describe('When new email already in use by any user and system id is given', () => { + const setup = () => { + const mockTeacherUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], + }); + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockTeacherAccount = accountFactory.buildWithId({ userId: mockTeacherUser.id }); + const mockStudentAccount = accountFactory.buildWithId({ userId: mockStudentUser.id }); + + userRepo.findByEmail.mockResolvedValueOnce([mockTeacherUser]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockTeacherAccount], 1]); + + return { mockTeacherAccount, mockStudentUser, mockStudentAccount }; + }; + it('should return false', async () => { + const { mockTeacherAccount, mockStudentUser, mockStudentAccount } = setup(); + const res = await accountValidationService.isUniqueEmail( + mockTeacherAccount.username, + mockStudentUser.id, + mockStudentAccount.id, + mockStudentAccount.systemId?.toString() + ); + expect(res).toBe(false); + }); }); - it('should return false if new email already in use by multiple users', async () => { - const res = await accountValidationService.isUniqueEmail( - 'multiple@user.email', - mockStudentUser.id, - mockStudentAccount.id, - mockStudentAccount.systemId?.toString() - ); - expect(res).toBe(false); + + describe('When new email already in use by multiple users', () => { + const setup = () => { + const mockTeacherUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], + }); + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockOtherTeacherUser = userFactory.buildWithId({ + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentAccount = accountFactory.buildWithId({ userId: mockStudentUser.id }); + + const mockUsers = [mockTeacherUser, mockStudentUser, mockOtherTeacherUser]; + + userRepo.findByEmail.mockResolvedValueOnce(mockUsers); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[], 0]); + + return { mockStudentUser, mockStudentAccount }; + }; + it('should return false', async () => { + const { mockStudentUser, mockStudentAccount } = setup(); + const res = await accountValidationService.isUniqueEmail( + 'multiple@user.email', + mockStudentUser.id, + mockStudentAccount.id, + mockStudentAccount.systemId?.toString() + ); + expect(res).toBe(false); + }); }); - it('should return false if new email already in use by multiple accounts', async () => { - const res = await accountValidationService.isUniqueEmail( - 'multiple@account.username', - mockStudentUser.id, - mockStudentAccount.id, - mockStudentAccount.systemId?.toString() - ); - expect(res).toBe(false); + + describe('When new email already in use by multiple accounts', () => { + const setup = () => { + const mockTeacherUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], + }); + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockOtherTeacherUser = userFactory.buildWithId({ + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + + const mockTeacherAccount = accountFactory.buildWithId({ userId: mockTeacherUser.id }); + const mockStudentAccount = accountFactory.buildWithId({ userId: mockStudentUser.id }); + const mockOtherTeacherAccount = accountFactory.buildWithId({ + userId: mockOtherTeacherUser.id, + }); + + const mockAccounts = [mockTeacherAccount, mockStudentAccount, mockOtherTeacherAccount]; + userRepo.findByEmail.mockResolvedValueOnce([]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([mockAccounts, mockAccounts.length]); + + return { mockStudentUser, mockStudentAccount }; + }; + it('should return false', async () => { + const { mockStudentUser, mockStudentAccount } = setup(); + const res = await accountValidationService.isUniqueEmail( + 'multiple@account.username', + mockStudentUser.id, + mockStudentAccount.id, + mockStudentAccount.systemId?.toString() + ); + expect(res).toBe(false); + }); }); - it('should ignore existing username if other system', async () => { - const res = await accountValidationService.isUniqueEmail( - mockExternalUser.email, - mockExternalUser.id, - mockExternalUserAccount.id, - mockOtherExternalUserAccount.systemId?.toString() - ); - expect(res).toBe(true); + + describe('When its another system', () => { + const setup = () => { + const mockExternalUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockOtherExternalUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const externalSystemA = systemFactory.buildWithId(); + const externalSystemB = systemFactory.buildWithId(); + const mockExternalUserAccount = accountFactory.buildWithId({ + userId: mockExternalUser.id, + username: 'unique.within@system', + systemId: externalSystemA.id, + }); + const mockOtherExternalUserAccount = accountFactory.buildWithId({ + userId: mockOtherExternalUser.id, + username: 'unique.within@system', + systemId: externalSystemB.id, + }); + + userRepo.findByEmail.mockResolvedValueOnce([mockExternalUser]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockExternalUserAccount], 1]); + + return { mockExternalUser, mockExternalUserAccount, mockOtherExternalUserAccount }; + }; + it('should ignore existing username', async () => { + const { mockExternalUser, mockExternalUserAccount, mockOtherExternalUserAccount } = setup(); + const res = await accountValidationService.isUniqueEmail( + mockExternalUser.email, + mockExternalUser.id, + mockExternalUserAccount.id, + mockOtherExternalUserAccount.systemId?.toString() + ); + expect(res).toBe(true); + }); }); }); describe('isUniqueEmailForUser', () => { - it('should return true, if its the email of the given user', async () => { - const res = await accountValidationService.isUniqueEmailForUser(mockStudentUser.email, mockStudentUser.id); - expect(res).toBe(true); + describe('When its the email of the given user', () => { + const setup = () => { + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockStudentAccount = accountFactory.buildWithId({ userId: mockStudentUser.id }); + + userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockStudentAccount], 1]); + accountRepo.findByUserId.mockResolvedValueOnce(mockStudentAccount); + + return { mockStudentUser }; + }; + it('should return true', async () => { + const { mockStudentUser } = setup(); + const res = await accountValidationService.isUniqueEmailForUser(mockStudentUser.email, mockStudentUser.id); + expect(res).toBe(true); + }); }); - it('should return false, if not the given users email', async () => { - const res = await accountValidationService.isUniqueEmailForUser(mockStudentUser.email, mockAdminUser.id); - expect(res).toBe(false); + + describe('When its not the given users email', () => { + const setup = () => { + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockStudentAccount = accountFactory.buildWithId({ userId: mockStudentUser.id }); + + const mockAdminUser = userFactory.buildWithId({ + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockAdminAccount = accountFactory.buildWithId({ userId: mockAdminUser.id }); + + userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockStudentAccount], 1]); + accountRepo.findByUserId.mockResolvedValueOnce(mockAdminAccount); + + return { mockStudentUser, mockAdminUser }; + }; + it('should return false', async () => { + const { mockStudentUser, mockAdminUser } = setup(); + const res = await accountValidationService.isUniqueEmailForUser(mockStudentUser.email, mockAdminUser.id); + expect(res).toBe(false); + }); }); }); describe('isUniqueEmailForAccount', () => { - it('should return true, if its the email of the given user', async () => { - const res = await accountValidationService.isUniqueEmailForAccount(mockStudentUser.email, mockStudentAccount.id); - expect(res).toBe(true); + describe('When its the email of the given user', () => { + const setup = () => { + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockStudentAccount = accountFactory.buildWithId({ userId: mockStudentUser.id }); + + userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockStudentAccount], 1]); + accountRepo.findById.mockResolvedValueOnce(mockStudentAccount); + + return { mockStudentUser, mockStudentAccount }; + }; + it('should return true', async () => { + const { mockStudentUser, mockStudentAccount } = setup(); + const res = await accountValidationService.isUniqueEmailForAccount( + mockStudentUser.email, + mockStudentAccount.id + ); + expect(res).toBe(true); + }); }); - it('should return false, if not the given users email', async () => { - const res = await accountValidationService.isUniqueEmailForAccount(mockStudentUser.email, mockTeacherAccount.id); - expect(res).toBe(false); + describe('When its not the given users email', () => { + const setup = () => { + const mockTeacherUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], + }); + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockTeacherAccount = accountFactory.buildWithId({ userId: mockTeacherUser.id }); + const mockStudentAccount = accountFactory.buildWithId({ userId: mockStudentUser.id }); + + userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockStudentAccount], 1]); + accountRepo.findById.mockResolvedValueOnce(mockTeacherAccount); + + return { mockStudentUser, mockTeacherAccount }; + }; + it('should return false', async () => { + const { mockStudentUser, mockTeacherAccount } = setup(); + const res = await accountValidationService.isUniqueEmailForAccount( + mockStudentUser.email, + mockTeacherAccount.id + ); + expect(res).toBe(false); + }); }); - it('should ignore missing user for a given account', async () => { - const res = await accountValidationService.isUniqueEmailForAccount(oprhanAccount.username, oprhanAccount.id); - expect(res).toBe(true); + + describe('When user is missing in account', () => { + const setup = () => { + const oprhanAccount = accountFactory.buildWithId({ + username: 'orphan@account', + userId: undefined, + systemId: new ObjectId(), + }); + + userRepo.findByEmail.mockResolvedValueOnce([]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[], 0]); + accountRepo.findById.mockResolvedValueOnce(oprhanAccount); + + return { oprhanAccount }; + }; + it('should ignore missing user for given account', async () => { + const { oprhanAccount } = setup(); + const res = await accountValidationService.isUniqueEmailForAccount(oprhanAccount.username, oprhanAccount.id); + expect(res).toBe(true); + }); }); }); }); diff --git a/apps/server/src/modules/account/services/account.validation.service.ts b/apps/server/src/modules/account/services/account.validation.service.ts index fc47569ed71..2cabc9eabb3 100644 --- a/apps/server/src/modules/account/services/account.validation.service.ts +++ b/apps/server/src/modules/account/services/account.validation.service.ts @@ -5,9 +5,11 @@ import { AccountEntityToDtoMapper } from '../mapper/account-entity-to-dto.mapper import { AccountRepo } from '../repo/account.repo'; @Injectable() +// TODO: naming? export class AccountValidationService { constructor(private accountRepo: AccountRepo, private userRepo: UserRepo) {} + // TODO: this should be refactored and rewritten more nicely async isUniqueEmail(email: string, userId?: EntityId, accountId?: EntityId, systemId?: EntityId): Promise { const [foundUsers, [accounts]] = await Promise.all([ // Test coverage: Missing branch null check; unreachable @@ -27,12 +29,12 @@ export class AccountValidationService { } async isUniqueEmailForUser(email: string, userId: EntityId): Promise { - const account = await this.accountRepo.findByUserId(userId); + const account = await this.accountRepo.findByUserId(userId); // TODO: findOrFail? return this.isUniqueEmail(email, userId, account?.id, account?.systemId?.toString()); } async isUniqueEmailForAccount(email: string, accountId: EntityId): Promise { - const account = await this.accountRepo.findById(accountId); + const account = await this.accountRepo.findById(accountId); // TODO: findOrFail? return this.isUniqueEmail(email, account.userId?.toString(), account.id, account?.systemId?.toString()); } } diff --git a/apps/server/src/modules/account/services/dto/account.dto.ts b/apps/server/src/modules/account/services/dto/account.dto.ts index 760be1f2453..c3765576e50 100644 --- a/apps/server/src/modules/account/services/dto/account.dto.ts +++ b/apps/server/src/modules/account/services/dto/account.dto.ts @@ -1,6 +1,7 @@ import { EntityId } from '@shared/domain'; import { AccountSaveDto } from './account-save.dto'; +// TODO: this vs account-save.dto? please clean up :) export class AccountDto extends AccountSaveDto { readonly id: EntityId; diff --git a/apps/server/src/modules/account/uc/account.uc.spec.ts b/apps/server/src/modules/account/uc/account.uc.spec.ts index 0df73ab93d9..e210a5c9ab5 100644 --- a/apps/server/src/modules/account/uc/account.uc.spec.ts +++ b/apps/server/src/modules/account/uc/account.uc.spec.ts @@ -4,13 +4,11 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthorizationError, EntityNotFoundError, ForbiddenOperationError, ValidationError } from '@shared/common'; import { Account, - Counted, EntityId, Permission, PermissionService, Role, RoleName, - SchoolEntity, SchoolRolePermission, SchoolRoles, User, @@ -37,62 +35,19 @@ import { AccountUc } from './account.uc'; describe('AccountUc', () => { let module: TestingModule; let accountUc: AccountUc; - let userRepo: UserRepo; - let accountService: AccountService; - let accountValidationService: AccountValidationService; + let userRepo: DeepMocked; + let accountService: DeepMocked; + let accountValidationService: DeepMocked; let configService: DeepMocked; - let mockSchool: SchoolEntity; - let mockOtherSchool: SchoolEntity; - let mockSchoolWithStudentVisibility: SchoolEntity; - - let mockSuperheroUser: User; - let mockAdminUser: User; - let mockTeacherUser: User; - let mockOtherTeacherUser: User; - let mockTeacherNoUserNoSchoolPermissionUser: User; - let mockTeacherNoUserPermissionUser: User; - let mockStudentSchoolPermissionUser: User; - let mockStudentUser: User; - let mockOtherStudentUser: User; - let mockDifferentSchoolAdminUser: User; - let mockDifferentSchoolTeacherUser: User; - let mockDifferentSchoolStudentUser: User; - let mockUnknownRoleUser: User; - let mockExternalUser: User; - let mockUserWithoutAccount: User; - let mockUserWithoutRole: User; - let mockStudentUserWithoutAccount: User; - let mockOtherStudentSchoolPermissionUser: User; - - let mockSuperheroAccount: Account; - let mockTeacherAccount: Account; - let mockOtherTeacherAccount: Account; - let mockTeacherNoUserPermissionAccount: Account; - let mockTeacherNoUserNoSchoolPermissionAccount: Account; - let mockAdminAccount: Account; - let mockStudentAccount: Account; - let mockStudentSchoolPermissionAccount: Account; - let mockDifferentSchoolAdminAccount: Account; - let mockDifferentSchoolTeacherAccount: Account; - let mockDifferentSchoolStudentAccount: Account; - let mockUnknownRoleUserAccount: Account; - let mockExternalUserAccount: Account; - let mockAccountWithoutRole: Account; - let mockAccountWithoutUser: Account; - let mockAccountWithSystemId: Account; - let mockAccountWithLastFailedLogin: Account; - let mockAccountWithOldLastFailedLogin: Account; - let mockAccountWithNoLastFailedLogin: Account; - let mockAccounts: Account[]; - let mockUsers: User[]; - const defaultPassword = 'DummyPasswd!1'; const otherPassword = 'DummyPasswd!2'; const defaultPasswordHash = '$2a$10$/DsztV5o6P5piW2eWJsxw.4nHovmJGBA.QNwiTmuZ/uvUc40b.Uhu'; const LOGIN_BLOCK_TIME = 15; afterAll(async () => { + jest.restoreAllMocks(); + jest.resetAllMocks(); await module.close(); }); @@ -102,103 +57,7 @@ describe('AccountUc', () => { AccountUc, { provide: AccountService, - useValue: { - saveWithValidation: jest.fn().mockImplementation((account: AccountDto): Promise => { - if (account.username === 'fail@to.update') { - return Promise.reject(); - } - const accountEntity = mockAccounts.find( - (tempAccount) => tempAccount.userId?.toString() === account.userId - ); - if (accountEntity) { - Object.assign(accountEntity, account); - return Promise.resolve(); - } - return Promise.reject(); - }), - save: jest.fn().mockImplementation((account: AccountDto): Promise => { - if (account.username === 'fail@to.update') { - return Promise.reject(); - } - const accountEntity = mockAccounts.find( - (tempAccount) => tempAccount.userId?.toString() === account.userId - ); - if (accountEntity) { - Object.assign(accountEntity, account); - return Promise.resolve(); - } - return Promise.reject(); - }), - delete: (id: EntityId): Promise => { - const account = mockAccounts.find((tempAccount) => tempAccount.id?.toString() === id); - - if (account) { - return Promise.resolve(AccountEntityToDtoMapper.mapToDto(account)); - } - throw new EntityNotFoundError(Account.name); - }, - create: (): Promise => Promise.resolve(), - findByUserId: (userId: EntityId): Promise => { - const account = mockAccounts.find((tempAccount) => tempAccount.userId?.toString() === userId); - - if (account) { - return Promise.resolve(AccountEntityToDtoMapper.mapToDto(account)); - } - return Promise.resolve(null); - }, - findByUserIdOrFail: (userId: EntityId): Promise => { - const account = mockAccounts.find((tempAccount) => tempAccount.userId?.toString() === userId); - - if (account) { - return Promise.resolve(AccountEntityToDtoMapper.mapToDto(account)); - } - if (userId === 'accountWithoutUser') { - return Promise.resolve(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); - } - throw new EntityNotFoundError(Account.name); - }, - findById: (accountId: EntityId): Promise => { - const account = mockAccounts.find((tempAccount) => tempAccount.id === accountId); - - if (account) { - return Promise.resolve(AccountEntityToDtoMapper.mapToDto(account)); - } - throw new EntityNotFoundError(Account.name); - }, - findByUsernameAndSystemId: (username: string, systemId: EntityId | ObjectId): Promise => { - const account = mockAccounts.find( - (tempAccount) => tempAccount.username === username && tempAccount.systemId === systemId - ); - if (account) { - return Promise.resolve(AccountEntityToDtoMapper.mapToDto(account)); - } - throw new EntityNotFoundError(Account.name); - }, - searchByUsernameExactMatch: (username: string): Promise> => { - const account = mockAccounts.find((tempAccount) => tempAccount.username === username); - - if (account) { - return Promise.resolve([[AccountEntityToDtoMapper.mapToDto(account)], 1]); - } - if (username === 'not@available.username') { - return Promise.resolve([[AccountEntityToDtoMapper.mapToDto(mockOtherTeacherAccount)], 1]); - } - if (username === 'multiple@account.username') { - return Promise.resolve([ - mockAccounts.map((mockAccount) => AccountEntityToDtoMapper.mapToDto(mockAccount)), - mockAccounts.length, - ]); - } - return Promise.resolve([[], 0]); - }, - searchByUsernamePartialMatch: (): Promise> => - Promise.resolve([ - mockAccounts.map((mockAccount) => AccountEntityToDtoMapper.mapToDto(mockAccount)), - mockAccounts.length, - ]), - updateLastTriedFailedLogin: jest.fn(), - validatePassword: jest.fn().mockResolvedValue(true), - }, + useValue: createMock(), }, { provide: ConfigService, @@ -206,42 +65,12 @@ describe('AccountUc', () => { }, { provide: UserRepo, - useValue: { - findById: (userId: EntityId): Promise => { - const user = mockUsers.find((tempUser) => tempUser.id === userId); - if (user) { - return Promise.resolve(user); - } - throw new EntityNotFoundError(User.name); - }, - findByEmail: (email: string): Promise => { - const user = mockUsers.find((tempUser) => tempUser.email === email); - - if (user) { - return Promise.resolve([user]); - } - if (email === 'not@available.email') { - return Promise.resolve([mockExternalUser]); - } - if (email === 'multiple@user.email') { - return Promise.resolve(mockUsers); - } - return Promise.resolve([]); - }, - save: jest.fn().mockImplementation((user: User): Promise => { - if (user.firstName === 'failToUpdate' || user.email === 'user-fail@to.update') { - return Promise.reject(); - } - return Promise.resolve(); - }), - }, + useValue: createMock(), }, PermissionService, { provide: AccountValidationService, - useValue: { - isUniqueEmail: jest.fn().mockResolvedValue(true), - }, + useValue: createMock(), }, ], }).compile(); @@ -249,983 +78,3052 @@ describe('AccountUc', () => { accountUc = module.get(AccountUc); userRepo = module.get(UserRepo); accountService = module.get(AccountService); - await setupEntities(); accountValidationService = module.get(AccountValidationService); configService = module.get(ConfigService); + await setupEntities(); }); beforeEach(() => { - mockSchool = schoolFactory.buildWithId(); - mockOtherSchool = schoolFactory.buildWithId(); - mockSchoolWithStudentVisibility = schoolFactory.buildWithId(); - mockSchoolWithStudentVisibility.permissions = new SchoolRoles(); - mockSchoolWithStudentVisibility.permissions.teacher = new SchoolRolePermission(); - mockSchoolWithStudentVisibility.permissions.teacher.STUDENT_LIST = true; - - mockSuperheroUser = userFactory.buildWithId({ - school: mockSchool, - roles: [ - new Role({ - name: RoleName.SUPERHERO, - permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], - }), - ], + jest.clearAllMocks(); + jest.resetAllMocks(); + }); + + describe('updateMyAccount', () => { + describe('When user does not exist', () => { + const setup = () => { + userRepo.findById.mockImplementation(() => { + throw new EntityNotFoundError(User.name); + }); + }; + + it('should throw EntityNotFoundError', async () => { + setup(); + await expect(accountUc.updateMyAccount('accountWithoutUser', { passwordOld: defaultPassword })).rejects.toThrow( + EntityNotFoundError + ); + }); }); - mockAdminUser = userFactory.buildWithId({ - school: mockSchool, - roles: [ - new Role({ - name: RoleName.ADMINISTRATOR, - permissions: [ - Permission.TEACHER_EDIT, - Permission.STUDENT_EDIT, - Permission.STUDENT_LIST, - Permission.TEACHER_LIST, - Permission.TEACHER_CREATE, - Permission.STUDENT_CREATE, - Permission.TEACHER_DELETE, - Permission.STUDENT_DELETE, + + describe('When account does not exists', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockUserWithoutAccount = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), ], - }), - ], - }); - mockTeacherUser = userFactory.buildWithId({ - school: mockSchool, - roles: [ - new Role({ - name: RoleName.TEACHER, - permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], - }), - ], - }); - mockOtherTeacherUser = userFactory.buildWithId({ - school: mockSchool, - roles: [ - new Role({ - name: RoleName.TEACHER, - permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], - }), - ], - }); - mockTeacherNoUserPermissionUser = userFactory.buildWithId({ - school: mockSchoolWithStudentVisibility, - roles: [ - new Role({ - name: RoleName.TEACHER, - permissions: [], - }), - ], - }); - mockTeacherNoUserNoSchoolPermissionUser = userFactory.buildWithId({ - school: mockSchool, - roles: [ - new Role({ - name: RoleName.TEACHER, - permissions: [], - }), - ], - }); - mockStudentSchoolPermissionUser = userFactory.buildWithId({ - school: mockSchoolWithStudentVisibility, - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - mockOtherStudentSchoolPermissionUser = userFactory.buildWithId({ - school: mockSchoolWithStudentVisibility, - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - mockStudentUser = userFactory.buildWithId({ - school: mockSchool, - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - mockOtherStudentUser = userFactory.buildWithId({ - school: mockSchool, - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - mockDifferentSchoolAdminUser = userFactory.buildWithId({ - school: mockOtherSchool, - roles: [...mockAdminUser.roles], - }); - mockDifferentSchoolTeacherUser = userFactory.buildWithId({ - school: mockOtherSchool, - roles: [...mockTeacherUser.roles], - }); - mockDifferentSchoolStudentUser = userFactory.buildWithId({ - school: mockOtherSchool, - roles: [...mockStudentUser.roles], - }); - mockUserWithoutAccount = userFactory.buildWithId({ - school: mockSchool, - roles: [ - new Role({ - name: RoleName.ADMINISTRATOR, - permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], - }), - ], - }); - mockUserWithoutRole = userFactory.buildWithId({ - school: mockSchool, - roles: [], - }); - mockUnknownRoleUser = userFactory.buildWithId({ - school: mockSchool, - roles: [new Role({ name: 'undefinedRole' as RoleName, permissions: ['' as Permission] })], - }); - mockExternalUser = userFactory.buildWithId({ - school: mockSchool, - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - mockStudentUserWithoutAccount = userFactory.buildWithId({ - school: mockSchool, - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); + }); - mockSuperheroAccount = accountFactory.buildWithId({ - userId: mockSuperheroUser.id, - password: defaultPasswordHash, - }); - mockTeacherAccount = accountFactory.buildWithId({ - userId: mockTeacherUser.id, - password: defaultPasswordHash, - }); - mockOtherTeacherAccount = accountFactory.buildWithId({ - userId: mockOtherTeacherUser.id, - password: defaultPasswordHash, - }); - mockTeacherNoUserPermissionAccount = accountFactory.buildWithId({ - userId: mockTeacherNoUserPermissionUser.id, - password: defaultPasswordHash, - }); - mockTeacherNoUserNoSchoolPermissionAccount = accountFactory.buildWithId({ - userId: mockTeacherNoUserNoSchoolPermissionUser.id, - password: defaultPasswordHash, - }); - mockAdminAccount = accountFactory.buildWithId({ - userId: mockAdminUser.id, - password: defaultPasswordHash, - }); - mockStudentAccount = accountFactory.buildWithId({ - userId: mockStudentUser.id, - password: defaultPasswordHash, - }); - mockStudentSchoolPermissionAccount = accountFactory.buildWithId({ - userId: mockStudentSchoolPermissionUser.id, - password: defaultPasswordHash, - }); - mockAccountWithoutRole = accountFactory.buildWithId({ - userId: mockUserWithoutRole.id, - password: defaultPasswordHash, - }); - mockDifferentSchoolAdminAccount = accountFactory.buildWithId({ - userId: mockDifferentSchoolAdminUser.id, - password: defaultPasswordHash, - }); - mockDifferentSchoolTeacherAccount = accountFactory.buildWithId({ - userId: mockDifferentSchoolTeacherUser.id, - password: defaultPasswordHash, - }); - mockDifferentSchoolStudentAccount = accountFactory.buildWithId({ - userId: mockDifferentSchoolStudentUser.id, - password: defaultPasswordHash, - }); - mockUnknownRoleUserAccount = accountFactory.buildWithId({ - userId: mockUnknownRoleUser.id, - password: defaultPasswordHash, - }); - const externalSystem = systemFactory.buildWithId(); - mockExternalUserAccount = accountFactory.buildWithId({ - userId: mockExternalUser.id, - password: defaultPasswordHash, - systemId: externalSystem.id, - }); - mockAccountWithoutUser = accountFactory.buildWithId({ - userId: undefined, - password: defaultPasswordHash, - systemId: systemFactory.buildWithId().id, - }); - mockAccountWithSystemId = accountFactory.withSystemId(new ObjectId(10)).build(); - mockAccountWithLastFailedLogin = accountFactory.buildWithId({ - userId: undefined, - password: defaultPasswordHash, - systemId: systemFactory.buildWithId().id, - lasttriedFailedLogin: new Date(), - }); - mockAccountWithOldLastFailedLogin = accountFactory.buildWithId({ - userId: undefined, - password: defaultPasswordHash, - systemId: systemFactory.buildWithId().id, - lasttriedFailedLogin: new Date(new Date().getTime() - LOGIN_BLOCK_TIME - 1), - }); - mockAccountWithNoLastFailedLogin = accountFactory.buildWithId({ - userId: undefined, - password: defaultPasswordHash, - systemId: systemFactory.buildWithId().id, - lasttriedFailedLogin: undefined, - }); + accountService.findByUserIdOrFail.mockImplementation((): Promise => { + throw new EntityNotFoundError(Account.name); + }); - mockUsers = [ - mockSuperheroUser, - mockAdminUser, - mockTeacherUser, - mockOtherTeacherUser, - mockTeacherNoUserPermissionUser, - mockTeacherNoUserNoSchoolPermissionUser, - mockStudentUser, - mockStudentSchoolPermissionUser, - mockDifferentSchoolAdminUser, - mockDifferentSchoolTeacherUser, - mockDifferentSchoolStudentUser, - mockUnknownRoleUser, - mockExternalUser, - mockUserWithoutRole, - mockUserWithoutAccount, - mockStudentUserWithoutAccount, - mockOtherStudentUser, - mockOtherStudentSchoolPermissionUser, - ]; - - mockAccounts = [ - mockSuperheroAccount, - mockAdminAccount, - mockTeacherAccount, - mockOtherTeacherAccount, - mockTeacherNoUserPermissionAccount, - mockTeacherNoUserNoSchoolPermissionAccount, - mockStudentAccount, - mockStudentSchoolPermissionAccount, - mockDifferentSchoolAdminAccount, - mockDifferentSchoolTeacherAccount, - mockDifferentSchoolStudentAccount, - mockUnknownRoleUserAccount, - mockExternalUserAccount, - mockAccountWithoutRole, - mockAccountWithoutUser, - mockAccountWithSystemId, - mockAccountWithLastFailedLogin, - mockAccountWithOldLastFailedLogin, - mockAccountWithNoLastFailedLogin, - ]; - }); + return { mockUserWithoutAccount }; + }; - describe('updateMyAccount', () => { - it('should throw if user does not exist', async () => { - mockStudentUser.forcePasswordChange = true; - mockStudentUser.preferences = { firstLogin: true }; - await expect(accountUc.updateMyAccount('accountWithoutUser', { passwordOld: defaultPassword })).rejects.toThrow( - EntityNotFoundError - ); - }); - it('should throw if account does not exist', async () => { - await expect( - accountUc.updateMyAccount(mockUserWithoutAccount.id, { - passwordOld: defaultPassword, - }) - ).rejects.toThrow(EntityNotFoundError); - }); - it('should throw if account is external', async () => { - await expect( - accountUc.updateMyAccount(mockExternalUserAccount.userId?.toString() ?? '', { - passwordOld: defaultPassword, - }) - ).rejects.toThrow(ForbiddenOperationError); - }); - it('should throw if password does not match', async () => { - jest.spyOn(accountService, 'validatePassword').mockResolvedValueOnce(false); - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: 'DoesNotMatch', - }) - ).rejects.toThrow(AuthorizationError); - }); - it('should throw if changing own name is not allowed', async () => { - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - firstName: 'newFirstName', - }) - ).rejects.toThrow(ForbiddenOperationError); - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - lastName: 'newLastName', - }) - ).rejects.toThrow(ForbiddenOperationError); - }); - it('should allow to update email', async () => { - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - email: 'an@available.mail', - }) - ).resolves.not.toThrow(); - }); - it('should use email as account user name in lower case', async () => { - const accountSaveSpy = jest.spyOn(accountService, 'save'); - const testMail = 'AN@AVAILABLE.MAIL'; - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - email: testMail, - }) - ).resolves.not.toThrow(); - expect(accountSaveSpy).toBeCalledWith(expect.objectContaining({ username: testMail.toLowerCase() })); - }); - it('should use email as user email in lower case', async () => { - const userUpdateSpy = jest.spyOn(userRepo, 'save'); - const testMail = 'AN@AVAILABLE.MAIL'; - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - email: testMail, - }) - ).resolves.not.toThrow(); - expect(userUpdateSpy).toBeCalledWith(expect.objectContaining({ email: testMail.toLowerCase() })); - }); - it('should always update account user name AND user email together.', async () => { - const accountSaveSpy = jest.spyOn(accountService, 'save'); - const userUpdateSpy = jest.spyOn(userRepo, 'save'); - const testMail = 'an@available.mail'; - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - email: testMail, - }) - ).resolves.not.toThrow(); - expect(userUpdateSpy).toBeCalledWith(expect.objectContaining({ email: testMail.toLowerCase() })); - expect(accountSaveSpy).toBeCalledWith(expect.objectContaining({ username: testMail.toLowerCase() })); - }); - it('should throw if new email already in use', async () => { - const accountIsUniqueEmailSpy = jest.spyOn(accountValidationService, 'isUniqueEmail'); - accountIsUniqueEmailSpy.mockResolvedValueOnce(false); - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - email: mockAdminUser.email, - }) - ).rejects.toThrow(ValidationError); - }); - it('should allow to update with strong password', async () => { - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - passwordNew: otherPassword, - }) - ).resolves.not.toThrow(); - }); - it('should allow to update first and last name if teacher', async () => { - await expect( - accountUc.updateMyAccount(mockTeacherUser.id, { - passwordOld: defaultPassword, - firstName: 'newFirstName', - }) - ).resolves.not.toThrow(); - await expect( - accountUc.updateMyAccount(mockTeacherUser.id, { - passwordOld: defaultPassword, - lastName: 'newLastName', - }) - ).resolves.not.toThrow(); - }); - it('should allow to update first and last name if admin', async () => { - await expect( - accountUc.updateMyAccount(mockAdminUser.id, { - passwordOld: defaultPassword, - firstName: 'newFirstName', - }) - ).resolves.not.toThrow(); - await expect( - accountUc.updateMyAccount(mockAdminUser.id, { - passwordOld: defaultPassword, - lastName: 'newLastName', - }) - ).resolves.not.toThrow(); - }); - it('should allow to update first and last name if superhero', async () => { - await expect( - accountUc.updateMyAccount(mockSuperheroUser.id, { - passwordOld: defaultPassword, - firstName: 'newFirstName', - }) - ).resolves.not.toThrow(); - await expect( - accountUc.updateMyAccount(mockSuperheroUser.id, { - passwordOld: defaultPassword, - lastName: 'newLastName', - }) - ).resolves.not.toThrow(); - }); - it('should throw if user can not be updated', async () => { - await expect( - accountUc.updateMyAccount(mockTeacherUser.id, { - passwordOld: defaultPassword, - firstName: 'failToUpdate', - }) - ).rejects.toThrow(EntityNotFoundError); - }); - it('should throw if account can not be updated', async () => { - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - email: 'fail@to.update', - }) - ).rejects.toThrow(EntityNotFoundError); - }); - it('should not update password if no new password', async () => { - const spy = jest.spyOn(accountService, 'save'); - await accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - passwordNew: undefined, - email: 'newemail@to.update', - }); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - password: undefined, - }) - ); + it('should throw entity not found error', async () => { + const { mockUserWithoutAccount } = setup(); + await expect( + accountUc.updateMyAccount(mockUserWithoutAccount.id, { + passwordOld: defaultPassword, + }) + ).rejects.toThrow(EntityNotFoundError); + }); }); - }); + describe('When account is external', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); - describe('replaceMyTemporaryPassword', () => { - it('should throw if passwords do not match', async () => { - await expect( - accountUc.replaceMyTemporaryPassword( - mockStudentAccount.userId?.toString() ?? '', - defaultPassword, - 'FooPasswd!1' - ) - ).rejects.toThrow(ForbiddenOperationError); - }); + const mockExternalUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const externalSystem = systemFactory.buildWithId(); + const mockExternalUserAccount = accountFactory.buildWithId({ + userId: mockExternalUser.id, + password: defaultPasswordHash, + systemId: externalSystem.id, + }); - it('should throw if account does not exist', async () => { - await expect( - accountUc.replaceMyTemporaryPassword(mockUserWithoutAccount.id, defaultPassword, defaultPassword) - ).rejects.toThrow(EntityNotFoundError); - }); - it('should throw if user does not exist', async () => { - await expect( - accountUc.replaceMyTemporaryPassword('accountWithoutUser', defaultPassword, defaultPassword) - ).rejects.toThrow(EntityNotFoundError); - }); - it('should throw if account is external', async () => { - await expect( - accountUc.replaceMyTemporaryPassword( - mockExternalUserAccount.userId?.toString() ?? '', - defaultPassword, - defaultPassword - ) - ).rejects.toThrow(ForbiddenOperationError); - }); - it('should throw if not the users password is temporary', async () => { - mockStudentUser.forcePasswordChange = false; - mockStudentUser.preferences = { firstLogin: true }; - await expect( - accountUc.replaceMyTemporaryPassword( - mockStudentAccount.userId?.toString() ?? '', - defaultPassword, - defaultPassword - ) - ).rejects.toThrow(ForbiddenOperationError); - }); - it('should throw, if old password is the same as new password', async () => { - mockStudentUser.forcePasswordChange = false; - mockStudentUser.preferences = { firstLogin: false }; - await expect( - accountUc.replaceMyTemporaryPassword( - mockStudentAccount.userId?.toString() ?? '', - defaultPassword, - defaultPassword - ) - ).rejects.toThrow(ForbiddenOperationError); - }); - it('should throw, if old password is undefined', async () => { - mockStudentUser.forcePasswordChange = false; - mockStudentUser.preferences = { firstLogin: false }; - mockStudentAccount.password = undefined; - await expect( - accountUc.replaceMyTemporaryPassword( - mockStudentAccount.userId?.toString() ?? '', - defaultPassword, - defaultPassword - ) - ).rejects.toThrow(Error); - }); - it('should allow to set strong password, if the admin manipulated the users password', async () => { - mockStudentUser.forcePasswordChange = true; - mockStudentUser.preferences = { firstLogin: true }; - jest.spyOn(accountService, 'validatePassword').mockResolvedValueOnce(false); - await expect( - accountUc.replaceMyTemporaryPassword(mockStudentAccount.userId?.toString() ?? '', otherPassword, otherPassword) - ).resolves.not.toThrow(); - }); - it('should allow to set strong password, if this is the users first login', async () => { - mockStudentUser.forcePasswordChange = false; - mockStudentUser.preferences = { firstLogin: false }; - jest.spyOn(accountService, 'validatePassword').mockResolvedValueOnce(false); - await expect( - accountUc.replaceMyTemporaryPassword(mockStudentAccount.userId?.toString() ?? '', otherPassword, otherPassword) - ).resolves.not.toThrow(); - }); - it('should allow to set strong password, if this is the users first login (if undefined)', async () => { - mockStudentUser.forcePasswordChange = false; - mockStudentUser.preferences = undefined; - jest.spyOn(accountService, 'validatePassword').mockResolvedValueOnce(false); - await expect( - accountUc.replaceMyTemporaryPassword(mockStudentAccount.userId?.toString() ?? '', otherPassword, otherPassword) - ).resolves.not.toThrow(); - }); - it('should throw if user can not be updated', async () => { - mockStudentUser.forcePasswordChange = false; - mockStudentUser.preferences = { firstLogin: false }; - mockStudentUser.firstName = 'failToUpdate'; - jest.spyOn(accountService, 'validatePassword').mockResolvedValueOnce(false); - await expect( - accountUc.replaceMyTemporaryPassword(mockStudentAccount.userId?.toString() ?? '', otherPassword, otherPassword) - ).rejects.toThrow(EntityNotFoundError); - }); - it('should throw if account can not be updated', async () => { - mockStudentUser.forcePasswordChange = false; - mockStudentUser.preferences = { firstLogin: false }; - mockStudentAccount.username = 'fail@to.update'; - jest.spyOn(accountService, 'validatePassword').mockResolvedValueOnce(false); - await expect( - accountUc.replaceMyTemporaryPassword(mockStudentAccount.userId?.toString() ?? '', otherPassword, otherPassword) - ).rejects.toThrow(EntityNotFoundError); - }); - }); + accountService.findByUserIdOrFail.mockResolvedValueOnce( + AccountEntityToDtoMapper.mapToDto(mockExternalUserAccount) + ); - describe('searchAccounts', () => { - it('should return one account, if search type is userId', async () => { - const accounts = await accountUc.searchAccounts( - { userId: mockSuperheroUser.id } as ICurrentUser, - { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams - ); - const expected = new AccountSearchListResponse( - [AccountResponseMapper.mapToResponseFromEntity(mockStudentAccount)], - 1, - 0, - 1 - ); - expect(accounts).toStrictEqual(expected); - }); - it('should return empty list, if account is not found', async () => { - const accounts = await accountUc.searchAccounts( - { userId: mockSuperheroUser.id } as ICurrentUser, - { type: AccountSearchType.USER_ID, value: mockUserWithoutAccount.id } as AccountSearchQueryParams - ); - const expected = new AccountSearchListResponse([], 0, 0, 0); - expect(accounts).toStrictEqual(expected); - }); - it('should return one or more accounts, if search type is username', async () => { - const accounts = await accountUc.searchAccounts( - { userId: mockSuperheroUser.id } as ICurrentUser, - { type: AccountSearchType.USERNAME, value: '' } as AccountSearchQueryParams - ); - expect(accounts.skip).toEqual(0); - expect(accounts.limit).toEqual(10); - expect(accounts.total).toBeGreaterThan(1); - expect(accounts.data.length).toBeGreaterThan(1); - }); - it('should throw, if user has not the right permissions', async () => { - await expect( - accountUc.searchAccounts( - { userId: mockTeacherUser.id } as ICurrentUser, - { type: AccountSearchType.USER_ID, value: mockAdminUser.id } as AccountSearchQueryParams - ) - ).rejects.toThrow(ForbiddenOperationError); - - await expect( - accountUc.searchAccounts( - { userId: mockStudentUser.id } as ICurrentUser, - { type: AccountSearchType.USER_ID, value: mockOtherStudentUser.id } as AccountSearchQueryParams - ) - ).rejects.toThrow(ForbiddenOperationError); - - await expect( - accountUc.searchAccounts( - { userId: mockStudentUser.id } as ICurrentUser, - { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchQueryParams - ) - ).rejects.toThrow(ForbiddenOperationError); - }); - it('should throw, if search type is unknown', async () => { - await expect( - accountUc.searchAccounts( - { userId: mockSuperheroUser.id } as ICurrentUser, - { type: '' as AccountSearchType } as AccountSearchQueryParams - ) - ).rejects.toThrow('Invalid search type.'); - }); - it('should throw, if user is no superhero', async () => { - await expect( - accountUc.searchAccounts( - { userId: mockTeacherUser.id } as ICurrentUser, - { type: AccountSearchType.USERNAME, value: mockStudentUser.id } as AccountSearchQueryParams - ) - ).rejects.toThrow(ForbiddenOperationError); - }); + return { mockExternalUserAccount }; + }; + it('should throw ForbiddenOperationError', async () => { + const { mockExternalUserAccount } = setup(); - describe('hasPermissionsToAccessAccount', () => { - beforeEach(() => { - configService.get.mockReturnValue(false); - }); - it('admin can access teacher of the same school via user id', async () => { - const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + await expect( + accountUc.updateMyAccount(mockExternalUserAccount.userId?.toString() ?? '', { + passwordOld: defaultPassword, + }) + ).rejects.toThrow(ForbiddenOperationError); }); - it('admin can access student of the same school via user id', async () => { - const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); - }); - it('admin can not access admin of the same school via user id', async () => { - const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { type: AccountSearchType.USER_ID, value: mockAdminUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + }); + + describe('When password does not match', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValueOnce(false); + + return { mockStudentUser }; + }; + it('should throw AuthorizationError', async () => { + const { mockStudentUser } = setup(); + await expect( + accountUc.updateMyAccount(mockStudentUser.id, { + passwordOld: 'DoesNotMatch', + }) + ).rejects.toThrow(AuthorizationError); }); - it('admin can not access any account of a foreign school via user id', async () => { - const currentUser = { userId: mockDifferentSchoolAdminUser.id } as ICurrentUser; + }); + + describe('When changing own name is not allowed', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); - let params = { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValue(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValue(true); - params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + return { mockStudentUser }; + }; + it('should throw ForbiddenOperationError', async () => { + const { mockStudentUser } = setup(); + await expect( + accountUc.updateMyAccount(mockStudentUser.id, { + passwordOld: defaultPassword, + firstName: 'newFirstName', + }) + ).rejects.toThrow(ForbiddenOperationError); + await expect( + accountUc.updateMyAccount(mockStudentUser.id, { + passwordOld: defaultPassword, + lastName: 'newLastName', + }) + ).rejects.toThrow(ForbiddenOperationError); }); - it('teacher can access teacher of the same school via user id', async () => { - const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; - const params = { type: AccountSearchType.USER_ID, value: mockOtherTeacherUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + }); + + describe('When using student user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValue(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValue(true); + + return { mockStudentUser }; + }; + it('should allow to update email', async () => { + const { mockStudentUser } = setup(); + await expect( + accountUc.updateMyAccount(mockStudentUser.id, { + passwordOld: defaultPassword, + email: 'an@available.mail', + }) + ).resolves.not.toThrow(); }); - it('teacher can access student of the same school via user id', async () => { - const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; - const params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + }); + describe('When using student user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValue(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValue(true); + accountValidationService.isUniqueEmail.mockResolvedValueOnce(true); + const accountSaveSpy = jest.spyOn(accountService, 'save'); + + return { mockStudentUser, accountSaveSpy }; + }; + it('should use email as account user name in lower case', async () => { + const { mockStudentUser, accountSaveSpy } = setup(); + + const testMail = 'AN@AVAILABLE.MAIL'; + await expect( + accountUc.updateMyAccount(mockStudentUser.id, { + passwordOld: defaultPassword, + email: testMail, + }) + ).resolves.not.toThrow(); + expect(accountSaveSpy).toBeCalledWith(expect.objectContaining({ username: testMail.toLowerCase() })); }); - it('teacher can not access admin of the same school via user id', async () => { - const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; - const params = { type: AccountSearchType.USER_ID, value: mockAdminUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + }); + + describe('When using student user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValue(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValue(true); + accountValidationService.isUniqueEmail.mockResolvedValueOnce(true); + + const userUpdateSpy = jest.spyOn(userRepo, 'save'); + + return { mockStudentUser, userUpdateSpy }; + }; + it('should use email as user email in lower case', async () => { + const { mockStudentUser, userUpdateSpy } = setup(); + const testMail = 'AN@AVAILABLE.MAIL'; + await expect( + accountUc.updateMyAccount(mockStudentUser.id, { + passwordOld: defaultPassword, + email: testMail, + }) + ).resolves.not.toThrow(); + expect(userUpdateSpy).toBeCalledWith(expect.objectContaining({ email: testMail.toLowerCase() })); }); - it('teacher can not access any account of a foreign school via user id', async () => { - const currentUser = { userId: mockDifferentSchoolTeacherUser.id } as ICurrentUser; + }); + describe('When using student user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); - let params = { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + userRepo.findById.mockResolvedValue(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValue(true); + accountValidationService.isUniqueEmail.mockResolvedValueOnce(true); - params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + const accountSaveSpy = jest.spyOn(accountService, 'save'); + const userUpdateSpy = jest.spyOn(userRepo, 'save'); + + return { mockStudentUser, accountSaveSpy, userUpdateSpy }; + }; + it('should always update account user name AND user email together.', async () => { + const { mockStudentUser, accountSaveSpy, userUpdateSpy } = setup(); + const testMail = 'an@available.mail'; + await expect( + accountUc.updateMyAccount(mockStudentUser.id, { + passwordOld: defaultPassword, + email: testMail, + }) + ).resolves.not.toThrow(); + expect(userUpdateSpy).toBeCalledWith(expect.objectContaining({ email: testMail.toLowerCase() })); + expect(accountSaveSpy).toBeCalledWith(expect.objectContaining({ username: testMail.toLowerCase() })); }); - it('teacher can access student of the same school via user id if school has global permission', async () => { - configService.get.mockReturnValue(true); - const currentUser = { userId: mockTeacherNoUserPermissionUser.id } as ICurrentUser; - const params = { - type: AccountSearchType.USER_ID, - value: mockStudentSchoolPermissionUser.id, - } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + }); + + describe('When using student user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValue(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValue(true); + accountValidationService.isUniqueEmail.mockResolvedValueOnce(false); + + return { mockStudentUser }; + }; + it('should throw if new email already in use', async () => { + const { mockStudentUser } = setup(); + await expect( + accountUc.updateMyAccount(mockStudentUser.id, { + passwordOld: defaultPassword, + email: 'already@in.use', + }) + ).rejects.toThrow(ValidationError); }); - it('teacher can not access student of the same school if school has no global permission', async () => { - configService.get.mockReturnValue(true); - const currentUser = { userId: mockTeacherNoUserNoSchoolPermissionUser.id } as ICurrentUser; - const params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(ForbiddenOperationError); + }); + + describe('When using student user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValue(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValue(true); + + return { mockStudentUser }; + }; + it('should allow to update with strong password', async () => { + const { mockStudentUser } = setup(); + await expect( + accountUc.updateMyAccount(mockStudentUser.id, { + passwordOld: defaultPassword, + passwordNew: otherPassword, + }) + ).resolves.not.toThrow(); }); + }); + + describe('When using teacher user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + + const mockTeacherAccount = accountFactory.buildWithId({ + userId: mockTeacherUser.id, + password: defaultPasswordHash, + }); - it('student can not access student of the same school if school has global permission', async () => { - configService.get.mockReturnValue(true); - const currentUser = { userId: mockStudentSchoolPermissionUser.id } as ICurrentUser; - const params = { - type: AccountSearchType.USER_ID, - value: mockOtherStudentSchoolPermissionUser.id, - } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(ForbiddenOperationError); + userRepo.findById.mockResolvedValue(mockTeacherUser); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + accountService.validatePassword.mockResolvedValue(true); + + return { mockTeacherUser }; + }; + it('should allow to update first and last name', async () => { + const { mockTeacherUser } = setup(); + await expect( + accountUc.updateMyAccount(mockTeacherUser.id, { + passwordOld: defaultPassword, + firstName: 'newFirstName', + }) + ).resolves.not.toThrow(); + await expect( + accountUc.updateMyAccount(mockTeacherUser.id, { + passwordOld: defaultPassword, + lastName: 'newLastName', + }) + ).resolves.not.toThrow(); }); - it('student can not access any other account via user id', async () => { - const currentUser = { userId: mockStudentUser.id } as ICurrentUser; + }); + + describe('When using admin user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); - let params = { type: AccountSearchType.USER_ID, value: mockAdminUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + const mockAdminAccount = accountFactory.buildWithId({ + userId: mockAdminUser.id, + password: defaultPasswordHash, + }); - params = { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + userRepo.findById.mockResolvedValue(mockAdminUser); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockAdminAccount)); + accountService.validatePassword.mockResolvedValue(true); - params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + return { mockAdminUser }; + }; + it('should allow to update first and last name', async () => { + const { mockAdminUser } = setup(); + await expect( + accountUc.updateMyAccount(mockAdminUser.id, { + passwordOld: defaultPassword, + firstName: 'newFirstName', + }) + ).resolves.not.toThrow(); + await expect( + accountUc.updateMyAccount(mockAdminUser.id, { + passwordOld: defaultPassword, + lastName: 'newLastName', + }) + ).resolves.not.toThrow(); }); - it('superhero can access any account via username', async () => { - const currentUser = { userId: mockSuperheroUser.id } as ICurrentUser; + }); + + describe('When using superhero user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockSuperheroAccount = accountFactory.buildWithId({ + userId: mockSuperheroUser.id, + password: defaultPasswordHash, + }); - let params = { type: AccountSearchType.USERNAME, value: mockAdminAccount.username } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + userRepo.findById.mockResolvedValue(mockSuperheroUser); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockSuperheroAccount)); + accountService.validatePassword.mockResolvedValue(true); - params = { type: AccountSearchType.USERNAME, value: mockTeacherAccount.username } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + return { mockSuperheroUser }; + }; + it('should allow to update first and last name ', async () => { + const { mockSuperheroUser } = setup(); + await expect( + accountUc.updateMyAccount(mockSuperheroUser.id, { + passwordOld: defaultPassword, + firstName: 'newFirstName', + }) + ).resolves.not.toThrow(); + await expect( + accountUc.updateMyAccount(mockSuperheroUser.id, { + passwordOld: defaultPassword, + lastName: 'newLastName', + }) + ).resolves.not.toThrow(); + }); + }); - params = { type: AccountSearchType.USERNAME, value: mockStudentAccount.username } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + describe('When user can not be updated', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); - params = { - type: AccountSearchType.USERNAME, - value: mockDifferentSchoolAdminAccount.username, - } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockTeacherAccount = accountFactory.buildWithId({ + userId: mockTeacherUser.id, + password: defaultPasswordHash, + }); - params = { - type: AccountSearchType.USERNAME, - value: mockDifferentSchoolTeacherAccount.username, - } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + userRepo.findById.mockResolvedValue(mockTeacherUser); + userRepo.save.mockRejectedValueOnce(undefined); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + accountService.validatePassword.mockResolvedValue(true); - params = { - type: AccountSearchType.USERNAME, - value: mockDifferentSchoolStudentAccount.username, - } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + return { mockTeacherUser, mockTeacherAccount }; + }; + it('should throw EntityNotFoundError', async () => { + const { mockTeacherUser } = setup(); + await expect( + accountUc.updateMyAccount(mockTeacherUser.id, { + passwordOld: defaultPassword, + firstName: 'failToUpdate', + }) + ).rejects.toThrow(EntityNotFoundError); }); }); - }); - describe('findAccountById', () => { - it('should return an account, if the current user is a superhero', async () => { - const account = await accountUc.findAccountById( - { userId: mockSuperheroUser.id } as ICurrentUser, - { id: mockStudentAccount.id } as AccountByIdParams - ); - expect(account).toStrictEqual( - expect.objectContaining({ - id: mockStudentAccount.id, - username: mockStudentAccount.username, + describe('When account can not be updated', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ userId: mockStudentUser.id, - activated: mockStudentAccount.activated, - }) - ); - }); - it('should throw, if the current user is no superhero', async () => { - await expect( - accountUc.findAccountById( - { userId: mockTeacherUser.id } as ICurrentUser, - { id: mockStudentAccount.id } as AccountByIdParams - ) - ).rejects.toThrow(ForbiddenOperationError); - }); - it('should throw, if no account matches the search term', async () => { - await expect( - accountUc.findAccountById({ userId: mockSuperheroUser.id } as ICurrentUser, { id: 'xxx' } as AccountByIdParams) - ).rejects.toThrow(EntityNotFoundError); - }); - it('should throw, if target account has no user', async () => { - await expect( - accountUc.findAccountById({ userId: mockSuperheroUser.id } as ICurrentUser, { id: 'xxx' } as AccountByIdParams) - ).rejects.toThrow(EntityNotFoundError); - }); - }); + password: defaultPasswordHash, + }); - describe('saveAccount', () => { - afterEach(() => { - jest.clearAllMocks(); + userRepo.findById.mockResolvedValue(mockStudentUser); + userRepo.save.mockResolvedValueOnce(undefined); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValue(true); + accountService.save.mockRejectedValueOnce(undefined); + + accountValidationService.isUniqueEmail.mockResolvedValue(true); + + return { mockStudentUser }; + }; + it('should throw EntityNotFoundError', async () => { + const { mockStudentUser } = setup(); + await expect( + accountUc.updateMyAccount(mockStudentUser.id, { + passwordOld: defaultPassword, + email: 'fail@to.update', + }) + ).rejects.toThrow(EntityNotFoundError); + }); }); - it('should call account service', async () => { - const spy = jest.spyOn(accountService, 'saveWithValidation'); - const params: AccountSaveDto = { - username: 'john.doe@domain.tld', - password: defaultPassword, + describe('When no new password is given', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValue(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValue(true); + const spyAccountServiceSave = jest.spyOn(accountService, 'save'); + + accountValidationService.isUniqueEmail.mockResolvedValue(true); + + return { mockStudentUser, spyAccountServiceSave }; }; - await accountUc.saveAccount(params); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - username: 'john.doe@domain.tld', - }) - ); + it('should not update password', async () => { + const { mockStudentUser, spyAccountServiceSave } = setup(); + await accountUc.updateMyAccount(mockStudentUser.id, { + passwordOld: defaultPassword, + passwordNew: undefined, + email: 'newemail@to.update', + }); + expect(spyAccountServiceSave).toHaveBeenCalledWith( + expect.objectContaining({ + password: undefined, + }) + ); + }); }); }); - describe('updateAccountById', () => { - it('should throw if executing user does not exist', async () => { - const currentUser = { userId: '000000000000000' } as ICurrentUser; - const params = { id: mockStudentAccount.id } as AccountByIdParams; - const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(EntityNotFoundError); - }); - it('should throw if target account does not exist', async () => { - const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { id: '000000000000000' } as AccountByIdParams; - const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(EntityNotFoundError); - }); - it('should update target account password', async () => { - const previousPasswordHash = mockStudentAccount.password; - const currentUser = { userId: mockSuperheroUser.id } as ICurrentUser; - const params = { id: mockStudentAccount.id } as AccountByIdParams; - const body = { password: defaultPassword } as AccountByIdBodyParams; - expect(mockStudentUser.forcePasswordChange).toBeFalsy(); - await accountUc.updateAccountById(currentUser, params, body); - expect(mockStudentAccount.password).not.toBe(previousPasswordHash); - expect(mockStudentUser.forcePasswordChange).toBeTruthy(); - }); - it('should update target account username', async () => { - const newUsername = 'newUsername'; - const currentUser = { userId: mockSuperheroUser.id } as ICurrentUser; - const params = { id: mockStudentAccount.id } as AccountByIdParams; - const body = { username: newUsername } as AccountByIdBodyParams; - expect(mockStudentAccount.username).not.toBe(newUsername); - await accountUc.updateAccountById(currentUser, params, body); - expect(mockStudentAccount.username).toBe(newUsername.toLowerCase()); - }); - it('should update target account activation state', async () => { - const currentUser = { userId: mockSuperheroUser.id } as ICurrentUser; - const params = { id: mockStudentAccount.id } as AccountByIdParams; - const body = { activated: false } as AccountByIdBodyParams; - await accountUc.updateAccountById(currentUser, params, body); - expect(mockStudentAccount.activated).toBeFalsy(); - }); - it('should throw if account can not be updated', async () => { - const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { id: mockStudentAccount.id } as AccountByIdParams; - const body = { username: 'fail@to.update' } as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(EntityNotFoundError); - }); - it('should throw if user can not be updated', async () => { - const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { id: mockStudentAccount.id } as AccountByIdParams; - const body = { username: 'user-fail@to.update' } as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(EntityNotFoundError); + describe('replaceMyTemporaryPassword', () => { + describe('When passwords do not match', () => { + it('should throw ForbiddenOperationError', async () => { + await expect(accountUc.replaceMyTemporaryPassword('userId', defaultPassword, 'FooPasswd!1')).rejects.toThrow( + ForbiddenOperationError + ); + }); }); - it('should throw if target account has no user', async () => { - await expect( - accountUc.updateAccountById( - { userId: mockSuperheroUser.id } as ICurrentUser, - { id: mockAccountWithoutUser.id } as AccountByIdParams, - { username: 'user-fail@to.update' } as AccountByIdBodyParams - ) - ).rejects.toThrow(EntityNotFoundError); + + describe('When account does not exists', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + const mockUserWithoutAccount = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + + userRepo.findById.mockResolvedValueOnce(mockUserWithoutAccount); + accountService.findByUserIdOrFail.mockImplementation(() => { + throw new EntityNotFoundError(Account.name); + }); + + return { mockUserWithoutAccount }; + }; + it('should throw EntityNotFoundError', async () => { + const { mockUserWithoutAccount } = setup(); + await expect( + accountUc.replaceMyTemporaryPassword(mockUserWithoutAccount.id, defaultPassword, defaultPassword) + ).rejects.toThrow(EntityNotFoundError); + }); }); - it('should throw if new username already in use', async () => { - const accountIsUniqueEmailSpy = jest.spyOn(accountValidationService, 'isUniqueEmail'); - accountIsUniqueEmailSpy.mockResolvedValueOnce(false); - const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { id: mockStudentAccount.id } as AccountByIdParams; - const body = { username: mockOtherTeacherAccount.username } as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(ValidationError); + + describe('When user does not exist', () => { + const setup = () => { + userRepo.findById.mockRejectedValueOnce(undefined); + }; + it('should throw EntityNotFoundError', async () => { + setup(); + await expect( + accountUc.replaceMyTemporaryPassword('accountWithoutUser', defaultPassword, defaultPassword) + ).rejects.toThrow(EntityNotFoundError); + }); }); - describe('hasPermissionsToUpdateAccount', () => { - it('admin can edit teacher', async () => { - const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { id: mockTeacherAccount.id } as AccountByIdParams; - const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).resolves.not.toThrow(); + describe('When account is external', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockExternalUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const externalSystem = systemFactory.buildWithId(); + const mockExternalUserAccount = accountFactory.buildWithId({ + userId: mockExternalUser.id, + password: defaultPasswordHash, + systemId: externalSystem.id, + }); + + userRepo.findById.mockResolvedValueOnce(mockExternalUser); + accountService.findByUserIdOrFail.mockResolvedValueOnce( + AccountEntityToDtoMapper.mapToDto(mockExternalUserAccount) + ); + + return { mockExternalUserAccount }; + }; + it('should throw ForbiddenOperationError', async () => { + const { mockExternalUserAccount } = setup(); + await expect( + accountUc.replaceMyTemporaryPassword( + mockExternalUserAccount.userId?.toString() ?? '', + defaultPassword, + defaultPassword + ) + ).rejects.toThrow(ForbiddenOperationError); }); - it('teacher can edit student', async () => { - const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; + }); + describe('When not the users password is temporary', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + forcePasswordChange: false, + preferences: { firstLogin: true }, + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + + return { mockStudentAccount }; + }; + it('should throw ForbiddenOperationError', async () => { + const { mockStudentAccount } = setup(); + await expect( + accountUc.replaceMyTemporaryPassword( + mockStudentAccount.userId?.toString() ?? '', + defaultPassword, + defaultPassword + ) + ).rejects.toThrow(ForbiddenOperationError); + }); + }); + + describe('When old password is the same as new password', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + forcePasswordChange: false, + preferences: { firstLogin: false }, + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValueOnce(true); + + return { mockStudentAccount }; + }; + it('should throw ForbiddenOperationError', async () => { + const { mockStudentAccount } = setup(); + await expect( + accountUc.replaceMyTemporaryPassword( + mockStudentAccount.userId?.toString() ?? '', + defaultPassword, + defaultPassword + ) + ).rejects.toThrow(ForbiddenOperationError); + }); + }); + + describe('When old password is undefined', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + forcePasswordChange: false, + preferences: { firstLogin: false }, + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: undefined, + }); + + userRepo.findById.mockResolvedValueOnce(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValueOnce(true); + + return { mockStudentAccount }; + }; + it('should throw Error', async () => { + const { mockStudentAccount } = setup(); + await expect( + accountUc.replaceMyTemporaryPassword( + mockStudentAccount.userId?.toString() ?? '', + defaultPassword, + defaultPassword + ) + ).rejects.toThrow(Error); + }); + }); + + describe('When the admin manipulate the users password', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + forcePasswordChange: true, + preferences: { firstLogin: true }, + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValueOnce(false); + return { mockStudentAccount }; + }; + it('should allow to set strong password', async () => { + const { mockStudentAccount } = setup(); + + await expect( + accountUc.replaceMyTemporaryPassword( + mockStudentAccount.userId?.toString() ?? '', + otherPassword, + otherPassword + ) + ).resolves.not.toThrow(); + }); + }); + + describe('when a user logs in for the first time', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + forcePasswordChange: false, + preferences: { firstLogin: false }, + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValueOnce(false); + + return { mockStudentAccount }; + }; + it('should allow to set strong password', async () => { + const { mockStudentAccount } = setup(); + + await expect( + accountUc.replaceMyTemporaryPassword( + mockStudentAccount.userId?.toString() ?? '', + otherPassword, + otherPassword + ) + ).resolves.not.toThrow(); + }); + }); + + describe('when a user logs in for the first time (if undefined)', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + forcePasswordChange: false, + }); + mockStudentUser.preferences = undefined; + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValueOnce(false); + + return { mockStudentAccount }; + }; + it('should allow to set strong password', async () => { + const { mockStudentAccount } = setup(); + await expect( + accountUc.replaceMyTemporaryPassword( + mockStudentAccount.userId?.toString() ?? '', + otherPassword, + otherPassword + ) + ).resolves.not.toThrow(); + }); + }); + + describe('When user can not be updated', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + firstName: 'failToUpdate', + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + preferences: { firstLogin: false }, + forcePasswordChange: false, + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockStudentUser); + userRepo.save.mockRejectedValueOnce(undefined); + accountService.findByUserIdOrFail.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValueOnce(false); + + return { mockStudentAccount }; + }; + it('should throw EntityNotFoundError', async () => { + const { mockStudentAccount } = setup(); + await expect( + accountUc.replaceMyTemporaryPassword( + mockStudentAccount.userId?.toString() ?? '', + otherPassword, + otherPassword + ) + ).rejects.toThrow(EntityNotFoundError); + }); + }); + + describe('When account can not be updated', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + forcePasswordChange: false, + preferences: { firstLogin: false }, + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + username: 'fail@to.update', + }); + + userRepo.findById.mockResolvedValueOnce(mockStudentUser); + userRepo.save.mockResolvedValueOnce(); + accountService.findByUserIdOrFail.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.save.mockRejectedValueOnce(undefined); + accountService.validatePassword.mockResolvedValueOnce(false); + + return { mockStudentAccount }; + }; + it('should throw EntityNotFoundError', async () => { + const { mockStudentAccount } = setup(); + await expect( + accountUc.replaceMyTemporaryPassword( + mockStudentAccount.userId?.toString() ?? '', + otherPassword, + otherPassword + ) + ).rejects.toThrow(EntityNotFoundError); + }); + }); + }); + + describe('searchAccounts', () => { + describe('When search type is userId', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + const mockSchoolWithStudentVisibility = schoolFactory.buildWithId(); + mockSchoolWithStudentVisibility.permissions = new SchoolRoles(); + mockSchoolWithStudentVisibility.permissions.teacher = new SchoolRolePermission(); + mockSchoolWithStudentVisibility.permissions.teacher.STUDENT_LIST = true; + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockSuperheroUser).mockResolvedValueOnce(mockStudentUser); + accountService.findByUserId.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + + return { mockSuperheroUser, mockStudentUser, mockStudentAccount }; + }; + it('should return one account', async () => { + const { mockSuperheroUser, mockStudentUser, mockStudentAccount } = setup(); + const accounts = await accountUc.searchAccounts( + { userId: mockSuperheroUser.id } as ICurrentUser, + { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams + ); + const expected = new AccountSearchListResponse( + [AccountResponseMapper.mapToResponseFromEntity(mockStudentAccount)], + 1, + 0, + 1 + ); + expect(accounts).toStrictEqual(expected); + }); + }); + + describe('When account is not found', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockUserWithoutAccount = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + + userRepo.findById.mockResolvedValueOnce(mockSuperheroUser).mockResolvedValueOnce(mockUserWithoutAccount); + + return { mockSuperheroUser, mockUserWithoutAccount }; + }; + it('should return empty list', async () => { + const { mockSuperheroUser, mockUserWithoutAccount } = setup(); + const accounts = await accountUc.searchAccounts( + { userId: mockSuperheroUser.id } as ICurrentUser, + { type: AccountSearchType.USER_ID, value: mockUserWithoutAccount.id } as AccountSearchQueryParams + ); + const expected = new AccountSearchListResponse([], 0, 0, 0); + expect(accounts).toStrictEqual(expected); + }); + }); + describe('When search type is username', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockSuperheroUser).mockResolvedValueOnce(mockSuperheroUser); + accountService.searchByUsernamePartialMatch.mockResolvedValueOnce([ + [ + AccountEntityToDtoMapper.mapToDto(mockStudentAccount), + AccountEntityToDtoMapper.mapToDto(mockStudentAccount), + ], + 2, + ]); + + return { mockSuperheroUser }; + }; + it('should return one or more accounts, ', async () => { + const { mockSuperheroUser } = setup(); + const accounts = await accountUc.searchAccounts( + { userId: mockSuperheroUser.id } as ICurrentUser, + { type: AccountSearchType.USERNAME, value: '' } as AccountSearchQueryParams + ); + expect(accounts.skip).toEqual(0); + expect(accounts.limit).toEqual(10); + expect(accounts.total).toBeGreaterThan(1); + expect(accounts.data.length).toBeGreaterThan(1); + }); + }); + + describe('When user has not the right permissions', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockOtherStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + userRepo.findById.mockResolvedValueOnce(mockTeacherUser).mockResolvedValueOnce(mockAdminUser); + userRepo.findById.mockResolvedValueOnce(mockStudentUser).mockResolvedValueOnce(mockOtherStudentUser); + userRepo.findById.mockResolvedValueOnce(mockStudentUser).mockResolvedValueOnce(mockTeacherUser); + + return { mockTeacherUser, mockAdminUser, mockStudentUser, mockOtherStudentUser }; + }; + it('should throw ForbiddenOperationError', async () => { + const { mockTeacherUser, mockAdminUser, mockStudentUser, mockOtherStudentUser } = setup(); + await expect( + accountUc.searchAccounts( + { userId: mockTeacherUser.id } as ICurrentUser, + { type: AccountSearchType.USER_ID, value: mockAdminUser.id } as AccountSearchQueryParams + ) + ).rejects.toThrow(ForbiddenOperationError); + + await expect( + accountUc.searchAccounts( + { userId: mockStudentUser.id } as ICurrentUser, + { type: AccountSearchType.USER_ID, value: mockOtherStudentUser.id } as AccountSearchQueryParams + ) + ).rejects.toThrow(ForbiddenOperationError); + + await expect( + accountUc.searchAccounts( + { userId: mockStudentUser.id } as ICurrentUser, + { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchQueryParams + ) + ).rejects.toThrow(ForbiddenOperationError); + }); + }); + + describe('When search type is unknown', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + + userRepo.findById.mockResolvedValueOnce(mockSuperheroUser); + + return { mockSuperheroUser }; + }; + it('should throw Invalid search type', async () => { + const { mockSuperheroUser } = setup(); + await expect( + accountUc.searchAccounts( + { userId: mockSuperheroUser.id } as ICurrentUser, + { type: '' as AccountSearchType } as AccountSearchQueryParams + ) + ).rejects.toThrow('Invalid search type.'); + }); + }); + + describe('When user is not superhero', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + userRepo.findById.mockResolvedValueOnce(mockTeacherUser).mockResolvedValueOnce(mockStudentUser); + + return { mockStudentUser, mockTeacherUser }; + }; + it('should throw ForbiddenOperationError', async () => { + const { mockTeacherUser, mockStudentUser } = setup(); + await expect( + accountUc.searchAccounts( + { userId: mockTeacherUser.id } as ICurrentUser, + { type: AccountSearchType.USERNAME, value: mockStudentUser.id } as AccountSearchQueryParams + ) + ).rejects.toThrow(ForbiddenOperationError); + }); + }); + describe('hasPermissionsToAccessAccount', () => { + describe('When using an admin', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + + configService.get.mockReturnValue(false); + userRepo.findById.mockResolvedValueOnce(mockAdminUser).mockResolvedValueOnce(mockTeacherUser); + + return { mockAdminUser, mockTeacherUser }; + }; + it('should be able to access teacher of the same school via user id', async () => { + const { mockAdminUser, mockTeacherUser } = setup(); + const currentUser = { userId: mockAdminUser.id } as ICurrentUser; + const params = { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + }); + }); + + describe('When using an admin', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + configService.get.mockReturnValue(false); + userRepo.findById.mockResolvedValueOnce(mockAdminUser).mockResolvedValueOnce(mockStudentUser); + + return { mockAdminUser, mockStudentUser }; + }; + it('should be able to access student of the same school via user id', async () => { + const { mockAdminUser, mockStudentUser } = setup(); + const currentUser = { userId: mockAdminUser.id } as ICurrentUser; + const params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + }); + }); + + describe('When using an admin', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + + configService.get.mockReturnValue(false); + userRepo.findById.mockResolvedValueOnce(mockAdminUser).mockResolvedValueOnce(mockAdminUser); + + return { mockAdminUser }; + }; + + it('should not be able to access admin of the same school via user id', async () => { + const { mockAdminUser } = setup(); + const currentUser = { userId: mockAdminUser.id } as ICurrentUser; + const params = { type: AccountSearchType.USER_ID, value: mockAdminUser.id } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + }); + }); + + describe('When using an admin', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + const mockOtherSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockDifferentSchoolAdminUser = userFactory.buildWithId({ + school: mockOtherSchool, + roles: [...mockAdminUser.roles], + }); + + configService.get.mockReturnValue(false); + userRepo.findById.mockResolvedValueOnce(mockDifferentSchoolAdminUser).mockResolvedValueOnce(mockTeacherUser); + userRepo.findById.mockResolvedValueOnce(mockDifferentSchoolAdminUser).mockResolvedValueOnce(mockStudentUser); + + return { mockDifferentSchoolAdminUser, mockTeacherUser, mockStudentUser }; + }; + it('should not be able to access any account of a foreign school via user id', async () => { + const { mockDifferentSchoolAdminUser, mockTeacherUser, mockStudentUser } = setup(); + const currentUser = { userId: mockDifferentSchoolAdminUser.id } as ICurrentUser; + + let params = { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + + params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + }); + }); + + describe('When using a teacher', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockOtherTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + + configService.get.mockReturnValue(false); + userRepo.findById.mockResolvedValueOnce(mockTeacherUser).mockResolvedValueOnce(mockOtherTeacherUser); + + return { mockTeacherUser, mockOtherTeacherUser }; + }; + it('should be able to access teacher of the same school via user id', async () => { + const { mockTeacherUser, mockOtherTeacherUser } = setup(); + const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; + const params = { + type: AccountSearchType.USER_ID, + value: mockOtherTeacherUser.id, + } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + }); + }); + + describe('When using a teacher', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + configService.get.mockReturnValue(false); + userRepo.findById.mockResolvedValueOnce(mockTeacherUser).mockResolvedValueOnce(mockStudentUser); + + return { mockTeacherUser, mockStudentUser }; + }; + it('should be able to access student of the same school via user id', async () => { + const { mockTeacherUser, mockStudentUser } = setup(); + const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; + const params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + }); + }); + + describe('When using a teacher', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + + configService.get.mockReturnValue(false); + userRepo.findById.mockResolvedValueOnce(mockTeacherUser).mockResolvedValueOnce(mockAdminUser); + + return { mockTeacherUser, mockAdminUser }; + }; + it('should not be able to access admin of the same school via user id', async () => { + const { mockTeacherUser, mockAdminUser } = setup(); + const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; + const params = { type: AccountSearchType.USER_ID, value: mockAdminUser.id } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + }); + }); + + describe('When using a teacher', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + const mockOtherSchool = schoolFactory.buildWithId(); + + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockDifferentSchoolTeacherUser = userFactory.buildWithId({ + school: mockOtherSchool, + roles: [...mockTeacherUser.roles], + }); + + configService.get.mockReturnValue(false); + userRepo.findById + .mockResolvedValueOnce(mockDifferentSchoolTeacherUser) + .mockResolvedValueOnce(mockTeacherUser); + userRepo.findById + .mockResolvedValueOnce(mockDifferentSchoolTeacherUser) + .mockResolvedValueOnce(mockStudentUser); + + return { mockDifferentSchoolTeacherUser, mockTeacherUser, mockStudentUser }; + }; + it('should not be able to access any account of a foreign school via user id', async () => { + const { mockDifferentSchoolTeacherUser, mockTeacherUser, mockStudentUser } = setup(); + const currentUser = { userId: mockDifferentSchoolTeacherUser.id } as ICurrentUser; + + let params = { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + + params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + }); + }); + describe('When using a teacher', () => { + const setup = () => { + const mockSchoolWithStudentVisibility = schoolFactory.buildWithId(); + mockSchoolWithStudentVisibility.permissions = new SchoolRoles(); + mockSchoolWithStudentVisibility.permissions.teacher = new SchoolRolePermission(); + mockSchoolWithStudentVisibility.permissions.teacher.STUDENT_LIST = true; + + const mockTeacherNoUserPermissionUser = userFactory.buildWithId({ + school: mockSchoolWithStudentVisibility, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [], + }), + ], + }); + const mockStudentSchoolPermissionUser = userFactory.buildWithId({ + school: mockSchoolWithStudentVisibility, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + configService.get.mockReturnValue(true); + userRepo.findById + .mockResolvedValueOnce(mockTeacherNoUserPermissionUser) + .mockResolvedValueOnce(mockStudentSchoolPermissionUser); + + return { mockTeacherNoUserPermissionUser, mockStudentSchoolPermissionUser }; + }; + it('should be able to access student of the same school via user id if school has global permission', async () => { + const { mockTeacherNoUserPermissionUser, mockStudentSchoolPermissionUser } = setup(); + + const currentUser = { userId: mockTeacherNoUserPermissionUser.id } as ICurrentUser; + const params = { + type: AccountSearchType.USER_ID, + value: mockStudentSchoolPermissionUser.id, + } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + }); + }); + + describe('When using a teacher', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockTeacherNoUserNoSchoolPermissionUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + configService.get.mockReturnValue(false); + userRepo.findById + .mockResolvedValueOnce(mockTeacherNoUserNoSchoolPermissionUser) + .mockResolvedValueOnce(mockStudentUser); + + return { mockTeacherNoUserNoSchoolPermissionUser, mockStudentUser }; + }; + it('should not be able to access student of the same school if school has no global permission', async () => { + const { mockTeacherNoUserNoSchoolPermissionUser, mockStudentUser } = setup(); + configService.get.mockReturnValue(true); + const currentUser = { userId: mockTeacherNoUserNoSchoolPermissionUser.id } as ICurrentUser; + const params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(ForbiddenOperationError); + }); + }); + + describe('When using a student', () => { + const setup = () => { + const mockSchoolWithStudentVisibility = schoolFactory.buildWithId(); + mockSchoolWithStudentVisibility.permissions = new SchoolRoles(); + mockSchoolWithStudentVisibility.permissions.teacher = new SchoolRolePermission(); + mockSchoolWithStudentVisibility.permissions.teacher.STUDENT_LIST = true; + + const mockStudentSchoolPermissionUser = userFactory.buildWithId({ + school: mockSchoolWithStudentVisibility, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockOtherStudentSchoolPermissionUser = userFactory.buildWithId({ + school: mockSchoolWithStudentVisibility, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + configService.get.mockReturnValue(false); + userRepo.findById + .mockResolvedValueOnce(mockStudentSchoolPermissionUser) + .mockResolvedValueOnce(mockOtherStudentSchoolPermissionUser); + + return { mockStudentSchoolPermissionUser, mockOtherStudentSchoolPermissionUser }; + }; + it('should not be able to access student of the same school if school has global permission', async () => { + const { mockStudentSchoolPermissionUser, mockOtherStudentSchoolPermissionUser } = setup(); + configService.get.mockReturnValue(true); + const currentUser = { userId: mockStudentSchoolPermissionUser.id } as ICurrentUser; + const params = { + type: AccountSearchType.USER_ID, + value: mockOtherStudentSchoolPermissionUser.id, + } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(ForbiddenOperationError); + }); + }); + + describe('When using a student', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + configService.get.mockReturnValue(false); + userRepo.findById.mockResolvedValueOnce(mockStudentUser).mockResolvedValueOnce(mockAdminUser); + userRepo.findById.mockResolvedValueOnce(mockStudentUser).mockResolvedValueOnce(mockTeacherUser); + userRepo.findById.mockResolvedValueOnce(mockStudentUser).mockResolvedValueOnce(mockStudentUser); + + return { mockStudentUser, mockAdminUser, mockTeacherUser }; + }; + it('should not be able to access any other account via user id', async () => { + const { mockStudentUser, mockAdminUser, mockTeacherUser } = setup(); + const currentUser = { userId: mockStudentUser.id } as ICurrentUser; + + let params = { type: AccountSearchType.USER_ID, value: mockAdminUser.id } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + + params = { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + + params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + }); + }); + + describe('When using a superhero', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + const mockOtherSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockDifferentSchoolAdminUser = userFactory.buildWithId({ + school: mockOtherSchool, + roles: [...mockAdminUser.roles], + }); + const mockDifferentSchoolTeacherUser = userFactory.buildWithId({ + school: mockOtherSchool, + roles: [...mockTeacherUser.roles], + }); + const mockDifferentSchoolStudentUser = userFactory.buildWithId({ + school: mockOtherSchool, + roles: [...mockStudentUser.roles], + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + const mockDifferentSchoolAdminAccount = accountFactory.buildWithId({ + userId: mockDifferentSchoolAdminUser.id, + password: defaultPasswordHash, + }); + const mockDifferentSchoolTeacherAccount = accountFactory.buildWithId({ + userId: mockDifferentSchoolTeacherUser.id, + password: defaultPasswordHash, + }); + const mockAdminAccount = accountFactory.buildWithId({ + userId: mockAdminUser.id, + password: defaultPasswordHash, + }); + const mockTeacherAccount = accountFactory.buildWithId({ + userId: mockTeacherUser.id, + password: defaultPasswordHash, + }); + + const mockDifferentSchoolStudentAccount = accountFactory.buildWithId({ + userId: mockDifferentSchoolStudentUser.id, + password: defaultPasswordHash, + }); + + configService.get.mockReturnValue(false); + userRepo.findById.mockResolvedValue(mockSuperheroUser); + accountService.searchByUsernamePartialMatch.mockResolvedValue([[], 0]); + + return { + mockSuperheroUser, + mockAdminAccount, + mockTeacherAccount, + mockStudentAccount, + mockDifferentSchoolAdminAccount, + mockDifferentSchoolTeacherAccount, + mockDifferentSchoolStudentAccount, + }; + }; + it('should be able to access any account via username', async () => { + const { + mockSuperheroUser, + mockAdminAccount, + mockTeacherAccount, + mockStudentAccount, + mockDifferentSchoolAdminAccount, + mockDifferentSchoolTeacherAccount, + mockDifferentSchoolStudentAccount, + } = setup(); + + const currentUser = { userId: mockSuperheroUser.id } as ICurrentUser; + + let params = { + type: AccountSearchType.USERNAME, + value: mockAdminAccount.username, + } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + + params = { type: AccountSearchType.USERNAME, value: mockTeacherAccount.username } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + + params = { type: AccountSearchType.USERNAME, value: mockStudentAccount.username } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + + params = { + type: AccountSearchType.USERNAME, + value: mockDifferentSchoolAdminAccount.username, + } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + + params = { + type: AccountSearchType.USERNAME, + value: mockDifferentSchoolTeacherAccount.username, + } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + + params = { + type: AccountSearchType.USERNAME, + value: mockDifferentSchoolStudentAccount.username, + } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + }); + }); + }); + }); + + describe('findAccountById', () => { + describe('When the current user is a superhero', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockSuperheroUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + + return { mockSuperheroUser, mockStudentUser, mockStudentAccount }; + }; + it('should return an account', async () => { + const { mockSuperheroUser, mockStudentUser, mockStudentAccount } = setup(); + const account = await accountUc.findAccountById( + { userId: mockSuperheroUser.id } as ICurrentUser, + { id: mockStudentAccount.id } as AccountByIdParams + ); + expect(account).toStrictEqual( + expect.objectContaining({ + id: mockStudentAccount.id, + username: mockStudentAccount.username, + userId: mockStudentUser.id, + activated: mockStudentAccount.activated, + }) + ); + }); + }); + + describe('When the current user is no superhero', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockTeacherUser); + + return { mockTeacherUser, mockStudentAccount }; + }; + it('should throw ForbiddenOperationError', async () => { + const { mockTeacherUser, mockStudentAccount } = setup(); + await expect( + accountUc.findAccountById( + { userId: mockTeacherUser.id } as ICurrentUser, + { id: mockStudentAccount.id } as AccountByIdParams + ) + ).rejects.toThrow(ForbiddenOperationError); + }); + }); + + describe('When no account matches the search term', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + + userRepo.findById.mockResolvedValueOnce(mockSuperheroUser); + accountService.findById.mockImplementation((): Promise => { + throw new EntityNotFoundError(Account.name); + }); + + return { mockSuperheroUser }; + }; + it('should throw EntityNotFoundError', async () => { + const { mockSuperheroUser } = setup(); + await expect( + accountUc.findAccountById( + { userId: mockSuperheroUser.id } as ICurrentUser, + { id: 'xxx' } as AccountByIdParams + ) + ).rejects.toThrow(EntityNotFoundError); + }); + }); + + describe('When target account has no user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + + userRepo.findById.mockResolvedValueOnce(mockSuperheroUser); + accountService.findById.mockImplementation((): Promise => { + throw new EntityNotFoundError(Account.name); + }); + + return { mockSuperheroUser }; + }; + it('should throw EntityNotFoundError', async () => { + const { mockSuperheroUser } = setup(); + await expect( + accountUc.findAccountById( + { userId: mockSuperheroUser.id } as ICurrentUser, + { id: 'xxx' } as AccountByIdParams + ) + ).rejects.toThrow(EntityNotFoundError); + }); + }); + }); + + describe('saveAccount', () => { + describe('When saving an account', () => { + const setup = () => { + const spy = jest.spyOn(accountService, 'saveWithValidation'); + + return { spy }; + }; + it('should call account service', async () => { + const { spy } = setup(); + + const params: AccountSaveDto = { + username: 'john.doe@domain.tld', + password: defaultPassword, + }; + await accountUc.saveAccount(params); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + username: 'john.doe@domain.tld', + }) + ); + }); + }); + }); + + describe('updateAccountById', () => { + describe('when updating a user that does not exist', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockImplementation((): Promise => { + throw new EntityNotFoundError(User.name); + }); + + return { mockStudentAccount }; + }; + it('should throw EntityNotFoundError', async () => { + const { mockStudentAccount } = setup(); + const currentUser = { userId: '000000000000000' } as ICurrentUser; const params = { id: mockStudentAccount.id } as AccountByIdParams; const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).resolves.not.toThrow(); + await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(EntityNotFoundError); }); - it('admin can edit student', async () => { + }); + + describe('When target account does not exist', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + + const mockAccountWithoutUser = accountFactory.buildWithId({ + userId: undefined, + password: defaultPasswordHash, + systemId: systemFactory.buildWithId().id, + }); + + userRepo.findById.mockResolvedValue(mockAdminUser); + accountService.findById.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockAccountWithoutUser)); + + return { mockAdminUser }; + }; + it('should throw EntityNotFoundError', async () => { + const { mockAdminUser } = setup(); const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { id: mockStudentAccount.id } as AccountByIdParams; + const params = { id: '000000000000000' } as AccountByIdParams; const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).resolves.not.toThrow(); + await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(EntityNotFoundError); }); - it('teacher cannot edit other teacher', async () => { - const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; - const params = { id: mockOtherTeacherAccount.id } as AccountByIdParams; - const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(ForbiddenOperationError); + }); + + describe('When using superhero user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockSuperheroUser).mockResolvedValueOnce(mockStudentUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + + userRepo.save.mockResolvedValue(); + accountService.save.mockImplementation((account: AccountSaveDto): Promise => { + Object.assign(mockStudentAccount, account); + return Promise.resolve(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + }); + + return { mockStudentAccount, mockStudentUser, mockSuperheroUser }; + }; + it('should update target account password', async () => { + const { mockStudentAccount, mockSuperheroUser, mockStudentUser } = setup(); + const previousPasswordHash = mockStudentAccount.password; + const currentUser = { userId: mockSuperheroUser.id } as ICurrentUser; + const params = { id: mockStudentAccount.id } as AccountByIdParams; + const body = { password: defaultPassword } as AccountByIdBodyParams; + expect(mockStudentUser.forcePasswordChange).toBeFalsy(); + await accountUc.updateAccountById(currentUser, params, body); + expect(mockStudentAccount.password).not.toBe(previousPasswordHash); + expect(mockStudentUser.forcePasswordChange).toBeTruthy(); }); - it("other school's admin cannot edit teacher", async () => { - const currentUser = { userId: mockDifferentSchoolAdminUser.id } as ICurrentUser; - const params = { id: mockTeacherAccount.id } as AccountByIdParams; - const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(ForbiddenOperationError); + }); + + describe('When using superhero user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockSuperheroUser).mockResolvedValueOnce(mockStudentUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + + userRepo.save.mockResolvedValue(); + accountService.save.mockImplementation((account: AccountSaveDto): Promise => { + Object.assign(mockStudentAccount, account); + return Promise.resolve(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + }); + accountValidationService.isUniqueEmail.mockResolvedValue(true); + + return { mockStudentAccount, mockSuperheroUser }; + }; + it('should update target account username', async () => { + const { mockStudentAccount, mockSuperheroUser } = setup(); + const newUsername = 'newUsername'; + const currentUser = { userId: mockSuperheroUser.id } as ICurrentUser; + const params = { id: mockStudentAccount.id } as AccountByIdParams; + const body = { username: newUsername } as AccountByIdBodyParams; + expect(mockStudentAccount.username).not.toBe(newUsername); + await accountUc.updateAccountById(currentUser, params, body); + expect(mockStudentAccount.username).toBe(newUsername.toLowerCase()); }); - it('superhero can edit admin', async () => { + }); + + describe('When using superhero user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockSuperheroUser).mockResolvedValueOnce(mockStudentUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + + userRepo.save.mockResolvedValue(); + accountService.save.mockImplementation((account: AccountSaveDto): Promise => { + Object.assign(mockStudentAccount, account); + return Promise.resolve(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + }); + + return { mockStudentAccount, mockSuperheroUser }; + }; + it('should update target account activation state', async () => { + const { mockStudentAccount, mockSuperheroUser } = setup(); const currentUser = { userId: mockSuperheroUser.id } as ICurrentUser; - const params = { id: mockAdminAccount.id } as AccountByIdParams; - const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).resolves.not.toThrow(); + const params = { id: mockStudentAccount.id } as AccountByIdParams; + const body = { activated: false } as AccountByIdBodyParams; + await accountUc.updateAccountById(currentUser, params, body); + expect(mockStudentAccount.activated).toBeFalsy(); }); - it('undefined user role fails by default', async () => { - const currentUser = { userId: mockUnknownRoleUser.id } as ICurrentUser; - const params = { id: mockAccountWithoutRole.id } as AccountByIdParams; - const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(ForbiddenOperationError); + }); + + describe('When using an admin user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockAdminUser).mockResolvedValueOnce(mockStudentUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + + userRepo.save.mockResolvedValue(); + accountService.save.mockRejectedValueOnce(undefined); + + accountValidationService.isUniqueEmail.mockResolvedValue(true); + + return { mockStudentAccount, mockAdminUser }; + }; + it('should throw if account can not be updated', async () => { + const { mockStudentAccount, mockAdminUser } = setup(); + const currentUser = { userId: mockAdminUser.id } as ICurrentUser; + const params = { id: mockStudentAccount.id } as AccountByIdParams; + const body = { username: 'fail@to.update' } as AccountByIdBodyParams; + await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(EntityNotFoundError); }); - it('user without role cannot be edited', async () => { + }); + + describe('When user can not be updated', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockAdminUser).mockResolvedValueOnce(mockStudentUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + + userRepo.save.mockRejectedValueOnce(undefined); + + accountValidationService.isUniqueEmail.mockResolvedValue(true); + + return { mockStudentAccount, mockAdminUser }; + }; + it('should throw EntityNotFoundError', async () => { + const { mockStudentAccount, mockAdminUser } = setup(); const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { id: mockUnknownRoleUserAccount.id } as AccountByIdParams; - const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(ForbiddenOperationError); + const params = { id: mockStudentAccount.id } as AccountByIdParams; + const body = { username: 'user-fail@to.update' } as AccountByIdBodyParams; + await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(EntityNotFoundError); }); }); - }); - describe('deleteAccountById', () => { - it('should delete an account, if current user is authorized', async () => { - await expect( - accountUc.deleteAccountById( - { userId: mockSuperheroUser.id } as ICurrentUser, - { id: mockStudentAccount.id } as AccountByIdParams - ) - ).resolves.not.toThrow(); + describe('if target account has no user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockAccountWithoutUser = accountFactory.buildWithId({ + userId: undefined, + password: defaultPasswordHash, + systemId: systemFactory.buildWithId().id, + }); + + userRepo.findById.mockResolvedValueOnce(mockSuperheroUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockAccountWithoutUser)); + + return { mockSuperheroUser, mockAccountWithoutUser }; + }; + + it('should throw EntityNotFoundError', async () => { + const { mockSuperheroUser, mockAccountWithoutUser } = setup(); + await expect( + accountUc.updateAccountById( + { userId: mockSuperheroUser.id } as ICurrentUser, + { id: mockAccountWithoutUser.id } as AccountByIdParams, + { username: 'user-fail@to.update' } as AccountByIdBodyParams + ) + ).rejects.toThrow(EntityNotFoundError); + }); }); - it('should throw, if the current user is no superhero', async () => { - await expect( - accountUc.deleteAccountById( - { userId: mockAdminUser.id } as ICurrentUser, - { id: mockStudentAccount.id } as AccountByIdParams - ) - ).rejects.toThrow(ForbiddenOperationError); + + describe('When new username already in use', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockOtherTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockOtherTeacherAccount = accountFactory.buildWithId({ + userId: mockOtherTeacherUser.id, + password: defaultPasswordHash, + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockAdminUser).mockResolvedValueOnce(mockStudentUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + + userRepo.save.mockRejectedValueOnce(undefined); + + accountValidationService.isUniqueEmail.mockResolvedValueOnce(false); + + return { mockStudentAccount, mockAdminUser, mockOtherTeacherAccount }; + }; + it('should throw ValidationError', async () => { + const { mockStudentAccount, mockAdminUser, mockOtherTeacherAccount } = setup(); + const currentUser = { userId: mockAdminUser.id } as ICurrentUser; + const params = { id: mockStudentAccount.id } as AccountByIdParams; + const body = { username: mockOtherTeacherAccount.username } as AccountByIdBodyParams; + await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(ValidationError); + }); }); - it('should throw, if no account matches the search term', async () => { - await expect( - accountUc.deleteAccountById( - { userId: mockSuperheroUser.id } as ICurrentUser, - { id: 'xxx' } as AccountByIdParams - ) - ).rejects.toThrow(EntityNotFoundError); + + describe('hasPermissionsToUpdateAccount', () => { + describe('When using an admin user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + + const mockTeacherAccount = accountFactory.buildWithId({ + userId: mockTeacherUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockAdminUser).mockResolvedValueOnce(mockTeacherUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + + return { mockAdminUser, mockTeacherAccount }; + }; + it('should not throw error when editing a teacher', async () => { + const { mockAdminUser, mockTeacherAccount } = setup(); + const currentUser = { userId: mockAdminUser.id } as ICurrentUser; + const params = { id: mockTeacherAccount.id } as AccountByIdParams; + const body = {} as AccountByIdBodyParams; + await expect(accountUc.updateAccountById(currentUser, params, body)).resolves.not.toThrow(); + }); + }); + + describe('When using a teacher user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockTeacherUser).mockResolvedValueOnce(mockStudentUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + + return { mockStudentAccount, mockTeacherUser }; + }; + it('should not throw error when editing a student', async () => { + const { mockTeacherUser, mockStudentAccount } = setup(); + const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; + const params = { id: mockStudentAccount.id } as AccountByIdParams; + const body = {} as AccountByIdBodyParams; + await expect(accountUc.updateAccountById(currentUser, params, body)).resolves.not.toThrow(); + }); + }); + describe('When using an admin user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockAdminUser).mockResolvedValueOnce(mockStudentUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + + return { mockStudentAccount, mockAdminUser }; + }; + it('should not throw error when editing a student', async () => { + const { mockAdminUser, mockStudentAccount } = setup(); + const currentUser = { userId: mockAdminUser.id } as ICurrentUser; + const params = { id: mockStudentAccount.id } as AccountByIdParams; + const body = {} as AccountByIdBodyParams; + await expect(accountUc.updateAccountById(currentUser, params, body)).resolves.not.toThrow(); + }); + }); + + describe('When using a teacher user to edit another teacher', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockOtherTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockOtherTeacherAccount = accountFactory.buildWithId({ + userId: mockOtherTeacherUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockTeacherUser).mockResolvedValueOnce(mockOtherTeacherUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockOtherTeacherAccount)); + + return { mockOtherTeacherAccount, mockTeacherUser }; + }; + it('should throw ForbiddenOperationError', async () => { + const { mockTeacherUser, mockOtherTeacherAccount } = setup(); + const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; + const params = { id: mockOtherTeacherAccount.id } as AccountByIdParams; + const body = {} as AccountByIdBodyParams; + await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(ForbiddenOperationError); + }); + }); + + describe('When using an admin user of other school', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + const mockOtherSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockDifferentSchoolAdminUser = userFactory.buildWithId({ + school: mockOtherSchool, + roles: [...mockAdminUser.roles], + }); + + const mockTeacherAccount = accountFactory.buildWithId({ + userId: mockTeacherUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockDifferentSchoolAdminUser).mockResolvedValueOnce(mockTeacherUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + + return { mockDifferentSchoolAdminUser, mockTeacherAccount }; + }; + it('should throw ForbiddenOperationError', async () => { + const { mockDifferentSchoolAdminUser, mockTeacherAccount } = setup(); + const currentUser = { userId: mockDifferentSchoolAdminUser.id } as ICurrentUser; + const params = { id: mockTeacherAccount.id } as AccountByIdParams; + const body = {} as AccountByIdBodyParams; + await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(ForbiddenOperationError); + }); + }); + + describe('When using a superhero user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockAdminAccount = accountFactory.buildWithId({ + userId: mockAdminUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockSuperheroUser).mockResolvedValueOnce(mockAdminUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockAdminAccount)); + + return { mockAdminAccount, mockSuperheroUser }; + }; + it('should not throw error when editing a admin', async () => { + const { mockSuperheroUser, mockAdminAccount } = setup(); + const currentUser = { userId: mockSuperheroUser.id } as ICurrentUser; + const params = { id: mockAdminAccount.id } as AccountByIdParams; + const body = {} as AccountByIdBodyParams; + await expect(accountUc.updateAccountById(currentUser, params, body)).resolves.not.toThrow(); + }); + }); + + describe('When using an user with undefined role', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockUserWithoutRole = userFactory.buildWithId({ + school: mockSchool, + roles: [], + }); + const mockUnknownRoleUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: 'undefinedRole' as RoleName, permissions: ['' as Permission] })], + }); + const mockAccountWithoutRole = accountFactory.buildWithId({ + userId: mockUserWithoutRole.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockUnknownRoleUser).mockResolvedValueOnce(mockUserWithoutRole); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockAccountWithoutRole)); + + return { mockAccountWithoutRole, mockUnknownRoleUser }; + }; + it('should fail by default', async () => { + const { mockUnknownRoleUser, mockAccountWithoutRole } = setup(); + const currentUser = { userId: mockUnknownRoleUser.id } as ICurrentUser; + const params = { id: mockAccountWithoutRole.id } as AccountByIdParams; + const body = {} as AccountByIdBodyParams; + await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(ForbiddenOperationError); + }); + }); + + describe('When editing an user without role', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockUnknownRoleUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: 'undefinedRole' as RoleName, permissions: ['' as Permission] })], + }); + const mockUnknownRoleUserAccount = accountFactory.buildWithId({ + userId: mockUnknownRoleUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockAdminUser).mockResolvedValueOnce(mockUnknownRoleUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockUnknownRoleUserAccount)); + + return { mockAdminUser, mockUnknownRoleUserAccount }; + }; + it('should throw ForbiddenOperationError', async () => { + const { mockAdminUser, mockUnknownRoleUserAccount } = setup(); + const currentUser = { userId: mockAdminUser.id } as ICurrentUser; + const params = { id: mockUnknownRoleUserAccount.id } as AccountByIdParams; + const body = {} as AccountByIdBodyParams; + await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(ForbiddenOperationError); + }); + }); }); }); - describe('checkBrutForce', () => { - let updateMock: jest.Mock; - beforeAll(() => { - configService.get.mockReturnValue(LOGIN_BLOCK_TIME); - }); - afterAll(() => { - configService.get.mockRestore(); + describe('deleteAccountById', () => { + describe('When current user is authorized', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValue(mockSuperheroUser); + + accountService.findById.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + + return { mockSuperheroUser, mockStudentAccount }; + }; + it('should delete an account', async () => { + const { mockSuperheroUser, mockStudentAccount } = setup(); + await expect( + accountUc.deleteAccountById( + { userId: mockSuperheroUser.id } as ICurrentUser, + { id: mockStudentAccount.id } as AccountByIdParams + ) + ).resolves.not.toThrow(); + }); }); - beforeEach(() => { - // eslint-disable-next-line jest/unbound-method - updateMock = accountService.updateLastTriedFailedLogin as jest.Mock; - updateMock.mockClear(); + + describe('When the current user is not superhero', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockImplementation((userId: EntityId): Promise => { + if (mockAdminUser.id === userId) { + return Promise.resolve(mockAdminUser); + } + throw new EntityNotFoundError(User.name); + }); + + return { mockAdminUser, mockStudentAccount }; + }; + it('should throw ForbiddenOperationError', async () => { + const { mockAdminUser, mockStudentAccount } = setup(); + await expect( + accountUc.deleteAccountById( + { userId: mockAdminUser.id } as ICurrentUser, + { id: mockStudentAccount.id } as AccountByIdParams + ) + ).rejects.toThrow(ForbiddenOperationError); + }); }); - it('should throw, if time difference < the allowed time', async () => { - await expect( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - accountUc.checkBrutForce(mockAccountWithLastFailedLogin.username, mockAccountWithLastFailedLogin.systemId!) - ).rejects.toThrow(BruteForcePrevention); + + describe('When no account matches the search term', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + + userRepo.findById.mockImplementation((userId: EntityId): Promise => { + if (mockSuperheroUser.id === userId) { + return Promise.resolve(mockSuperheroUser); + } + throw new EntityNotFoundError(User.name); + }); + + accountService.findById.mockImplementation((id: EntityId): Promise => { + if (id === 'xxx') { + throw new EntityNotFoundError(Account.name); + } + return Promise.reject(); + }); + + return { mockSuperheroUser }; + }; + it('should throw, if no account matches the search term', async () => { + const { mockSuperheroUser } = setup(); + await expect( + accountUc.deleteAccountById( + { userId: mockSuperheroUser.id } as ICurrentUser, + { id: 'xxx' } as AccountByIdParams + ) + ).rejects.toThrow(EntityNotFoundError); + }); }); - it('should not throw Error, if the time difference > the allowed time', async () => { - await expect( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - accountUc.checkBrutForce(mockAccountWithSystemId.username, mockAccountWithSystemId.systemId!) - ).resolves.not.toThrow(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - expect(updateMock.mock.calls[0][0]).toEqual(mockAccountWithSystemId.id); - const newDate = new Date().getTime() - 10000; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - expect((updateMock.mock.calls[0][1] as Date).getTime()).toBeGreaterThan(newDate); + }); + + describe('checkBrutForce', () => { + describe('When time difference < the allowed time', () => { + const setup = () => { + const mockAccountWithLastFailedLogin = accountFactory.buildWithId({ + userId: undefined, + password: defaultPasswordHash, + systemId: systemFactory.buildWithId().id, + lasttriedFailedLogin: new Date(), + }); + + configService.get.mockReturnValue(LOGIN_BLOCK_TIME); + + accountService.findByUsernameAndSystemId.mockImplementation( + (username: string, systemId: EntityId | ObjectId): Promise => { + if ( + mockAccountWithLastFailedLogin.username === username && + mockAccountWithLastFailedLogin.systemId === systemId + ) { + return Promise.resolve(AccountEntityToDtoMapper.mapToDto(mockAccountWithLastFailedLogin)); + } + throw new EntityNotFoundError(Account.name); + } + ); + + return { mockAccountWithLastFailedLogin }; + }; + + it('should throw BruteForcePrevention', async () => { + const { mockAccountWithLastFailedLogin } = setup(); + await expect( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + accountUc.checkBrutForce(mockAccountWithLastFailedLogin.username, mockAccountWithLastFailedLogin.systemId!) + ).rejects.toThrow(BruteForcePrevention); + }); }); - it('should not throw, if lasttriedFailedLogin is undefined', async () => { - await expect( - accountUc.checkBrutForce( - mockAccountWithNoLastFailedLogin.username, + + describe('When the time difference > the allowed time', () => { + const setup = () => { + const mockAccountWithSystemId = accountFactory.withSystemId(new ObjectId(10)).build(); + + // eslint-disable-next-line jest/unbound-method + const updateMock = accountService.updateLastTriedFailedLogin as jest.Mock; + + configService.get.mockReturnValue(LOGIN_BLOCK_TIME); + + accountService.findByUsernameAndSystemId.mockImplementation( + (username: string, systemId: EntityId | ObjectId): Promise => { + if (mockAccountWithSystemId.username === username && mockAccountWithSystemId.systemId === systemId) { + return Promise.resolve(AccountEntityToDtoMapper.mapToDto(mockAccountWithSystemId)); + } + throw new EntityNotFoundError(Account.name); + } + ); + + return { mockAccountWithSystemId, updateMock }; + }; + + it('should not throw Error, ', async () => { + const { mockAccountWithSystemId, updateMock } = setup(); + + await expect( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - mockAccountWithNoLastFailedLogin.systemId! - ) - ).resolves.not.toThrow(); + accountUc.checkBrutForce(mockAccountWithSystemId.username, mockAccountWithSystemId.systemId!) + ).resolves.not.toThrow(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(updateMock.mock.calls[0][0]).toEqual(mockAccountWithSystemId.id); + const newDate = new Date().getTime() - 10000; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect((updateMock.mock.calls[0][1] as Date).getTime()).toBeGreaterThan(newDate); + }); + }); + + describe('When lasttriedFailedLogin is undefined', () => { + const setup = () => { + const mockAccountWithNoLastFailedLogin = accountFactory.buildWithId({ + userId: undefined, + password: defaultPasswordHash, + systemId: systemFactory.buildWithId().id, + lasttriedFailedLogin: undefined, + }); + + configService.get.mockReturnValue(LOGIN_BLOCK_TIME); + + accountService.findByUsernameAndSystemId.mockImplementation( + (username: string, systemId: EntityId | ObjectId): Promise => { + if ( + mockAccountWithNoLastFailedLogin.username === username && + mockAccountWithNoLastFailedLogin.systemId === systemId + ) { + return Promise.resolve(AccountEntityToDtoMapper.mapToDto(mockAccountWithNoLastFailedLogin)); + } + throw new EntityNotFoundError(Account.name); + } + ); + + return { mockAccountWithNoLastFailedLogin }; + }; + it('should not throw error', async () => { + const { mockAccountWithNoLastFailedLogin } = setup(); + await expect( + accountUc.checkBrutForce( + mockAccountWithNoLastFailedLogin.username, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + mockAccountWithNoLastFailedLogin.systemId! + ) + ).resolves.not.toThrow(); + }); }); }); }); diff --git a/apps/server/src/modules/account/uc/account.uc.ts b/apps/server/src/modules/account/uc/account.uc.ts index fa1d7ca4c60..2db4ed8843f 100644 --- a/apps/server/src/modules/account/uc/account.uc.ts +++ b/apps/server/src/modules/account/uc/account.uc.ts @@ -8,6 +8,7 @@ import { } from '@shared/common/error'; import { Account, EntityId, Permission, PermissionService, Role, RoleName, SchoolEntity, User } from '@shared/domain'; import { UserRepo } from '@shared/repo'; +// TODO: module internals should be imported with relative paths import { AccountService } from '@src/modules/account/services/account.service'; import { AccountDto } from '@src/modules/account/services/dto/account.dto'; @@ -42,6 +43,14 @@ export class AccountUc { private readonly configService: ConfigService ) {} + /* HINT: there is a lot of logic here that would belong into service layer, + but since that wasnt decided when this code was written this work is not prioritised right now + + Also this is mostly directly ported feathers code, that needs a general refactoring/rewrite pass + + also it should use the new authorisation service + */ + /** * This method processes the request on the GET account search endpoint from the account controller. * @@ -55,7 +64,9 @@ export class AccountUc { const limit = query.limit ?? 10; const executingUser = await this.userRepo.findById(currentUser.userId, true); + // HINT: this can be extracted if (query.type === AccountSearchType.USERNAME) { + // HINT: even superheroes should in the future be permission based if (!(await this.isSuperhero(currentUser))) { throw new ForbiddenOperationError('Current user is not authorized to search for accounts.'); } @@ -72,8 +83,10 @@ export class AccountUc { } const account = await this.accountService.findByUserId(query.value); if (account) { + // HINT: skip and limit should be from the query return new AccountSearchListResponse([AccountResponseMapper.mapToResponse(account)], 1, 0, 1); } + // HINT: skip and limit should be from the query return new AccountSearchListResponse([], 0, 0, 0); } @@ -93,7 +106,7 @@ export class AccountUc { throw new ForbiddenOperationError('Current user is not authorized to search for accounts.'); } const account = await this.accountService.findById(params.id); - return AccountResponseMapper.mapToResponse(account); + return AccountResponseMapper.mapToResponse(account); // TODO: mapping should be done in controller } async saveAccount(dto: AccountSaveDto): Promise { @@ -162,6 +175,8 @@ export class AccountUc { throw new EntityNotFoundError(Account.name); } } + // TODO: mapping from domain to api dto should be a responsability of the controller + return AccountResponseMapper.mapToResponse(targetAccount); } @@ -300,6 +315,7 @@ export class AccountUc { } } + // TODO: remove /** * * @deprecated this is for legacy login strategies only. Login strategies in Nest.js should use {@link AuthenticationService} diff --git a/apps/server/src/shared/testing/factory/account.factory.ts b/apps/server/src/shared/testing/factory/account.factory.ts index b0c0b8434c1..a3568dbf80a 100644 --- a/apps/server/src/shared/testing/factory/account.factory.ts +++ b/apps/server/src/shared/testing/factory/account.factory.ts @@ -5,6 +5,8 @@ import { ObjectId } from 'bson'; import { DeepPartial } from 'fishery'; import { BaseFactory } from './base.factory'; +export const defaultTestPassword = 'DummyPasswd!1'; +export const defaultTestPasswordHash = '$2a$10$/DsztV5o6P5piW2eWJsxw.4nHovmJGBA.QNwiTmuZ/uvUc40b.Uhu'; class AccountFactory extends BaseFactory { withSystemId(id: EntityId | ObjectId): this { const params: DeepPartial = { systemId: id }; @@ -21,10 +23,36 @@ class AccountFactory extends BaseFactory { return this.params(params); } + + withAllProperties(): this { + return this.params({ + userId: new ObjectId(), + username: 'username', + activated: true, + credentialHash: 'credentialHash', + expiresAt: new Date(), + lasttriedFailedLogin: new Date(), + password: defaultTestPassword, + systemId: new ObjectId(), + token: 'token', + }).afterBuild((acc) => { + return { + ...acc, + createdAt: new Date(), + updatedAt: new Date(), + }; + }); + } + + withoutSystemAndUserId(): this { + return this.params({ + username: 'username', + systemId: undefined, + userId: undefined, + }); + } } -export const defaultTestPassword = 'DummyPasswd!1'; -export const defaultTestPasswordHash = '$2a$10$/DsztV5o6P5piW2eWJsxw.4nHovmJGBA.QNwiTmuZ/uvUc40b.Uhu'; // !!! important username should not be contain a space !!! export const accountFactory = AccountFactory.define(Account, ({ sequence }) => { return { From 5c573c2ff047fb5cec7b05ed045e60ea2aec000f Mon Sep 17 00:00:00 2001 From: agnisa-cap Date: Fri, 6 Oct 2023 13:21:57 +0200 Subject: [PATCH 05/10] N21-1332 adjust group provisioning (#4456) * N21-1332 changes group provisioning to add only current user instead of whole group --- .../src/modules/group/domain/group.spec.ts | 60 ++++++++++++++++++- apps/server/src/modules/group/domain/group.ts | 6 ++ .../service/oidc-provisioning.service.spec.ts | 2 +- .../oidc/service/oidc-provisioning.service.ts | 13 ++-- .../sanis/sanis-response.mapper.spec.ts | 10 +--- .../strategy/sanis/sanis-response.mapper.ts | 5 +- 6 files changed, 77 insertions(+), 19 deletions(-) diff --git a/apps/server/src/modules/group/domain/group.spec.ts b/apps/server/src/modules/group/domain/group.spec.ts index b5ae8a03321..f2a22e1dc37 100644 --- a/apps/server/src/modules/group/domain/group.spec.ts +++ b/apps/server/src/modules/group/domain/group.spec.ts @@ -1,7 +1,7 @@ +import { RoleReference, UserDO } from '@shared/domain'; import { groupFactory, roleFactory, userDoFactory } from '@shared/testing'; import { ObjectId } from 'bson'; -import { RoleReference, UserDO } from '@shared/domain'; import { Group } from './group'; import { GroupUser } from './group-user'; @@ -135,4 +135,62 @@ describe('Group (Domain Object)', () => { }); }); }); + + describe('addUser', () => { + describe('when the user already exists in the group', () => { + const setup = () => { + const existingUser: GroupUser = new GroupUser({ + userId: new ObjectId().toHexString(), + roleId: new ObjectId().toHexString(), + }); + const group = groupFactory.build({ + users: [existingUser], + }); + + return { + group, + existingUser, + }; + }; + + it('should not add the user', () => { + const { group, existingUser } = setup(); + + group.addUser(existingUser); + + expect(group.users.length).toEqual(1); + }); + }); + + describe('when the user does not exist in the group', () => { + const setup = () => { + const newUser: GroupUser = new GroupUser({ + userId: new ObjectId().toHexString(), + roleId: new ObjectId().toHexString(), + }); + const group = groupFactory.build({ + users: [ + { + userId: new ObjectId().toHexString(), + roleId: new ObjectId().toHexString(), + }, + ], + }); + + return { + group, + newUser, + }; + }; + + it('should add the user', () => { + const { group, newUser } = setup(); + + group.addUser(newUser); + + expect(group.users).toContain(newUser); + expect(group.users.length).toEqual(2); + }); + }); + }); }); diff --git a/apps/server/src/modules/group/domain/group.ts b/apps/server/src/modules/group/domain/group.ts index 049043618ac..826bbd36b22 100644 --- a/apps/server/src/modules/group/domain/group.ts +++ b/apps/server/src/modules/group/domain/group.ts @@ -45,4 +45,10 @@ export class Group extends DomainObject { isEmpty(): boolean { return this.props.users.length === 0; } + + addUser(user: GroupUser): void { + if (!this.users.find((u) => u.userId === user.userId)) { + this.users.push(user); + } + } } diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.spec.ts index c4b27cac34b..7e6e05c7b2e 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.spec.ts @@ -661,7 +661,7 @@ describe('OidcProvisioningService', () => { describe('when provision group', () => { const setup = () => { - const group: Group = groupFactory.build(); + const group: Group = groupFactory.build({ users: [] }); groupService.findByExternalSource.mockResolvedValue(group); const school: LegacySchoolDo = legacySchoolDoFactory.build({ id: 'schoolId' }); diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.ts index 0aef3fdecb9..31b2d1ab8f3 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.ts @@ -119,10 +119,6 @@ export class OidcProvisioningService { } async provisionExternalGroup(externalGroup: ExternalGroupDto, systemId: EntityId): Promise { - if (externalGroup.users.length === 0) { - return; - } - const existingGroup: Group | null = await this.groupService.findByExternalSource( externalGroup.externalId, systemId @@ -145,6 +141,10 @@ export class OidcProvisioningService { const users: GroupUser[] = await this.getFilteredGroupUsers(externalGroup, systemId); + if (!users.length) { + return; + } + const group: Group = new Group({ id: existingGroup ? existingGroup.id : new ObjectId().toHexString(), name: externalGroup.name, @@ -156,8 +156,9 @@ export class OidcProvisioningService { organizationId, validFrom: externalGroup.from, validUntil: externalGroup.until, - users, + users: existingGroup ? existingGroup.users : [], }); + users.forEach((user: GroupUser) => group.addUser(user)); await this.groupService.save(group); } @@ -168,7 +169,7 @@ export class OidcProvisioningService { const user: UserDO | null = await this.userService.findByExternalId(externalGroupUser.externalUserId, systemId); const roles: RoleDto[] = await this.roleService.findByNames([externalGroupUser.roleName]); - if (!user || !user.id || roles.length !== 1 || !roles[0].id) { + if (!user?.id || roles.length !== 1 || !roles[0].id) { this.logger.info(new UserForGroupNotFoundLoggable(externalGroupUser)); return null; } diff --git a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts index 902e2e850f2..c52e654155b 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts @@ -171,10 +171,6 @@ describe('SanisResponseMapper', () => { until: group.gruppe.laufzeit.bis, externalId: group.gruppe.id, users: [ - { - externalUserId: group.sonstige_gruppenzugehoerige![0].ktid, - roleName: RoleName.STUDENT, - }, { externalUserId: personenkontext.id, roleName: RoleName.TEACHER, @@ -206,9 +202,7 @@ describe('SanisResponseMapper', () => { describe('when a group role mapping is missing', () => { const setup = () => { const { sanisResponse } = setupSanisResponse(); - sanisResponse.personenkontexte[0].gruppen![0]!.sonstige_gruppenzugehoerige![0].rollen = [ - SanisGroupRole.SCHOOL_SUPPORT, - ]; + sanisResponse.personenkontexte[0].gruppen![0]!.gruppenzugehoerigkeit.rollen = [SanisGroupRole.SCHOOL_SUPPORT]; return { sanisResponse, @@ -220,7 +214,7 @@ describe('SanisResponseMapper', () => { const result: ExternalGroupDto[] | undefined = mapper.mapToExternalGroupDtos(sanisResponse); - expect(result![0].users).toHaveLength(1); + expect(result![0].users).toHaveLength(0); }); }); diff --git a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts index 34a7ff4029c..e11e57e9068 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts @@ -66,7 +66,7 @@ export class SanisResponseMapper { } mapToExternalGroupDtos(source: SanisResponse): ExternalGroupDto[] | undefined { - const groups: SanisGruppenResponse[] | undefined = source.personenkontexte[0].gruppen; + const groups: SanisGruppenResponse[] | undefined = source.personenkontexte[0]?.gruppen; if (!groups) { return undefined; @@ -81,12 +81,11 @@ export class SanisResponseMapper { } const sanisGroupUsers: SanisSonstigeGruppenzugehoerigeResponse[] = [ - ...(group.sonstige_gruppenzugehoerige ?? []), { ktid: source.personenkontexte[0].id, rollen: group.gruppenzugehoerigkeit.rollen, }, - ].sort((a, b) => a.ktid.localeCompare(b.ktid)); + ].filter((sanisGroupUser) => sanisGroupUser.ktid && sanisGroupUser.rollen); const gruppenzugehoerigkeiten: ExternalGroupUserDto[] = sanisGroupUsers .map((relation): ExternalGroupUserDto | null => this.mapToExternalGroupUser(relation)) From ab0e6ba4afae16ed9af46ce203b2616192d56f2d Mon Sep 17 00:00:00 2001 From: WahlMartin <132356096+WahlMartin@users.noreply.github.com> Date: Fri, 6 Oct 2023 14:00:41 +0200 Subject: [PATCH 06/10] EW-622: Refactoring of Keycloak "clean" nest job (#4436) * working on user deletion * adding integration test * cleaning up code * fixes linter * renaming option variable to make purpose clearer * decreases await calls * Temp commit * Temp commit 2 * Makes integration test work * cleans up test case * Revert "Temp commit 2" This reverts commit 9c7d04679ccf169590647b58e672590dfde7c993. * Revert "Temp commit" This reverts commit 952075c3b263e58014a9c97f1f08dfbcfec62edd. * Revert "cleans up test case" This reverts commit 2d116cd58bf41f65c8205c114b4b5f660b44d32d. * Revert "Makes integration test work" This reverts commit e0c1af95524c2384c546056b31c6e7f2d4d000b1. * use setup function instead of beforeEach --------- Co-authored-by: psachmann --- .../console/keycloak-configuration.console.ts | 22 ++++- .../keycloak-seed.service.integration.spec.ts | 91 +++++++++++++++++++ .../service/keycloak-seed.service.spec.ts | 39 ++++++-- .../service/keycloak-seed.service.ts | 32 ++++--- .../uc/keycloak-configuration.uc.ts | 4 +- package-lock.json | 23 +++++ package.json | 1 + 7 files changed, 186 insertions(+), 26 deletions(-) create mode 100644 apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.integration.spec.ts diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/console/keycloak-configuration.console.ts b/apps/server/src/shared/infra/identity-management/keycloak-configuration/console/keycloak-configuration.console.ts index 57dfd7cdde0..1d597e7020a 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak-configuration/console/keycloak-configuration.console.ts +++ b/apps/server/src/shared/infra/identity-management/keycloak-configuration/console/keycloak-configuration.console.ts @@ -15,6 +15,10 @@ interface IMigrationOptions { query?: string; verbose?: boolean; } + +interface ICleanOptions { + pageSize?: number; +} @Console({ command: 'idm', description: 'Prefixes all Identity Management (IDM) related console commands.' }) export class KeycloakConsole { constructor( @@ -53,21 +57,29 @@ export class KeycloakConsole { } /** - * For local development. Cleans user from IDM + * Cleans users from IDM * * @param options */ @Command({ command: 'clean', description: 'Remove all users from the IDM.', - options: KeycloakConsole.retryFlags, + options: [ + ...KeycloakConsole.retryFlags, + { + flags: '- mps, --maxPageSize ', + description: 'Maximum users to delete per Keycloak API session. Default 100.', + required: false, + defaultValue: 100, + }, + ], }) - async clean(options: IRetryOptions): Promise { + async clean(options: IRetryOptions & ICleanOptions): Promise { await this.repeatCommand( 'clean', async () => { - const count = await this.keycloakConfigurationUc.clean(); - this.console.info(`Cleaned ${count} users into IDM`); + const count = await this.keycloakConfigurationUc.clean(options.pageSize ? Number(options.pageSize) : 100); + this.console.info(`Cleaned ${count} users in IDM`); return count; }, options.retryCount, diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.integration.spec.ts b/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.integration.spec.ts new file mode 100644 index 00000000000..87f28a28d76 --- /dev/null +++ b/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.integration.spec.ts @@ -0,0 +1,91 @@ +import KeycloakAdminClient from '@keycloak/keycloak-admin-client'; +import { faker } from '@faker-js/faker'; +import { ConfigModule } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { LoggerModule } from '@src/core/logger'; +import { v1 } from 'uuid'; +import { KeycloakAdministrationService } from '../../keycloak-administration/service/keycloak-administration.service'; +import { KeycloakConfigurationModule } from '../keycloak-configuration.module'; +import { KeycloakSeedService } from './keycloak-seed.service'; + +describe('KeycloakSeedService Integration', () => { + let module: TestingModule; + let keycloak: KeycloakAdminClient; + let keycloakSeedService: KeycloakSeedService; + let keycloakAdministrationService: KeycloakAdministrationService; + let isKeycloakAvailable = false; + const numberOfIdmUsers = 1009; + + const testRealm = `test-realm-${v1().toString()}`; + + const createIdmUser = async (index: number): Promise => { + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + await keycloak.users.create({ + username: `${index}.${lastName}@sp-sh.de`, + firstName, + lastName, + email: `${index}.${lastName}@sp-sh.de`, + }); + }; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + KeycloakConfigurationModule, + LoggerModule, + MongoMemoryDatabaseModule.forRoot(), + ConfigModule.forRoot({ + isGlobal: true, + ignoreEnvFile: true, + ignoreEnvVars: true, + validate: () => { + return { + FEATURE_IDENTITY_MANAGEMENT_STORE_ENABLED: true, + }; + }, + }), + ], + providers: [], + }).compile(); + keycloakAdministrationService = module.get(KeycloakAdministrationService); + isKeycloakAvailable = await keycloakAdministrationService.testKcConnection(); + if (isKeycloakAvailable) { + keycloak = await keycloakAdministrationService.callKcAdminClient(); + } + keycloakSeedService = module.get(KeycloakSeedService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + if (isKeycloakAvailable) { + await keycloak.realms.del({ realm: testRealm }); + } + }); + + // Execute this test for a test run against a running Keycloak instance + describe('clean', () => { + describe('Given all users are able to delete', () => { + const setup = async () => { + await keycloak.realms.create({ realm: testRealm, enabled: true }); + keycloak.setConfig({ realmName: testRealm }); + let i = 1; + for (i = 1; i <= numberOfIdmUsers; i += 1) { + // eslint-disable-next-line no-await-in-loop + await createIdmUser(i); + } + }; + + it('should delete all users in the IDM', async () => { + if (!isKeycloakAvailable) return; + await setup(); + const deletedUsers = await keycloakSeedService.clean(500); + expect(deletedUsers).toBe(numberOfIdmUsers); + }, 60000); + }); + }); +}); diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.spec.ts b/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.spec.ts index 8d411a5356a..19a3b28326e 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.spec.ts +++ b/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.spec.ts @@ -1,4 +1,5 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { LegacyLogger } from '@src/core/logger'; import KeycloakAdminClient from '@keycloak/keycloak-admin-client-cjs/keycloak-admin-client-cjs-index'; import UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation'; import { AuthenticationManagement } from '@keycloak/keycloak-admin-client/lib/resources/authenticationManagement'; @@ -40,8 +41,12 @@ jest.mock('node:fs/promises', () => { describe('KeycloakSeedService', () => { let module: TestingModule; let serviceUnderTest: KeycloakSeedService; + let logger: DeepMocked; let settings: IKeycloakSettings; + let infoLogSpy: jest.SpyInstance; + let errorLogSpy: jest.SpyInstance; + let kcAdminClient: DeepMocked; const kcApiUsersMock = createMock(); const kcApiAuthenticationManagementMock = createMock(); @@ -142,6 +147,10 @@ describe('KeycloakSeedService', () => { }, }, }, + { + provide: LegacyLogger, + useValue: createMock(), + }, ], }).compile(); serviceUnderTest = module.get(KeycloakSeedService); @@ -177,16 +186,33 @@ describe('KeycloakSeedService', () => { ]; kcApiUsersMock.create.mockResolvedValue({ id: '' }); kcApiUsersMock.del.mockImplementation(async (): Promise => Promise.resolve()); - kcApiUsersMock.find.mockImplementation(async (arg): Promise => { - if (arg?.username) { + kcApiUsersMock.find + .mockImplementationOnce(async (arg): Promise => { + if (arg?.username) { + return Promise.resolve([]); + } + const userArray = [adminUser, ...users]; + return Promise.resolve(userArray); + }) + .mockImplementationOnce(async (arg): Promise => { + if (arg?.username) { + return Promise.resolve([]); + } return Promise.resolve([]); - } - const userArray = [adminUser, ...users]; - return Promise.resolve(userArray); - }); + }) + .mockImplementationOnce(async (): Promise => { + const userArray = [adminUser, ...users]; + return Promise.resolve(userArray); + }) + .mockImplementation(async (): Promise => Promise.resolve([])); + logger = module.get(LegacyLogger); + infoLogSpy = jest.spyOn(logger, 'log'); + errorLogSpy = jest.spyOn(logger, 'error'); }); beforeEach(() => { + infoLogSpy.mockReset(); + errorLogSpy.mockReset(); kcApiUsersMock.create.mockClear(); kcApiUsersMock.del.mockClear(); kcApiUsersMock.find.mockClear(); @@ -208,7 +234,6 @@ describe('KeycloakSeedService', () => { it('should clean all users, but the admin', async () => { const deleteSpy = jest.spyOn(kcApiUsersMock, 'del'); await serviceUnderTest.clean(); - users.forEach((user) => { expect(deleteSpy).toHaveBeenCalledWith(expect.objectContaining({ id: user.id })); }); diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.ts b/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.ts index 078601d3248..eaf08cebe27 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.ts +++ b/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.ts @@ -1,5 +1,6 @@ import UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation'; import { Inject } from '@nestjs/common'; +import { LegacyLogger } from '@src/core/logger'; import fs from 'node:fs/promises'; import { IJsonAccount } from '../interface/json-account.interface'; import { IJsonUser } from '../interface/json-user.interface'; @@ -12,6 +13,7 @@ import { export class KeycloakSeedService { constructor( private readonly kcAdmin: KeycloakAdministrationService, + private readonly logger: LegacyLogger, @Inject(KeycloakConfigurationInputFiles) private readonly inputFiles: IKeycloakConfigurationInputFiles ) {} @@ -30,23 +32,29 @@ export class KeycloakSeedService { return userCount; } - public async clean(): Promise { - let kc = await this.kcAdmin.callKcAdminClient(); + public async clean(pageSize = 100): Promise { + let foundUsers = 1; + let deletedUsers = 0; const adminUser = this.kcAdmin.getAdminUser(); - const users = (await kc.users.find()).filter((user) => user.username !== adminUser); - - // eslint-disable-next-line no-restricted-syntax - for (const user of users) { - // needs to be called once per minute. To be save we call it in the loop. Ineffcient but ok, since only used to locally revert seeding + let kc = await this.kcAdmin.callKcAdminClient(); + this.logger.log(`Starting to delete users...`); + while (foundUsers > 0) { // eslint-disable-next-line no-await-in-loop kc = await this.kcAdmin.callKcAdminClient(); // eslint-disable-next-line no-await-in-loop - await kc.users.del({ - // can not be undefined, see filter above - id: user.id ?? '', - }); + const users = (await kc.users.find({ max: pageSize })).filter((user) => user.username !== adminUser); + foundUsers = users.length; + this.logger.log(`Amount of found Users: ${foundUsers}`); + for (const user of users) { + // eslint-disable-next-line no-await-in-loop + await kc.users.del({ + id: user.id ?? '', + }); + } + deletedUsers += foundUsers; + this.logger.log(`...deleted ${deletedUsers} users so far.`); } - return users.length; + return deletedUsers; } private async createOrUpdateIdmAccount(account: IJsonAccount, user: IJsonUser): Promise { diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/uc/keycloak-configuration.uc.ts b/apps/server/src/shared/infra/identity-management/keycloak-configuration/uc/keycloak-configuration.uc.ts index 0da4a04df4d..eabe596055e 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak-configuration/uc/keycloak-configuration.uc.ts +++ b/apps/server/src/shared/infra/identity-management/keycloak-configuration/uc/keycloak-configuration.uc.ts @@ -17,8 +17,8 @@ export class KeycloakConfigurationUc { return this.kcAdmin.testKcConnection(); } - public async clean(): Promise { - return this.keycloakSeedService.clean(); + public async clean(pageSize?: number): Promise { + return this.keycloakSeedService.clean(pageSize); } public async seed(): Promise { diff --git a/package-lock.json b/package-lock.json index c19ca347f18..92a97849e7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -132,6 +132,7 @@ }, "devDependencies": { "@aws-sdk/client-s3": "^3.352.0", + "@faker-js/faker": "^8.0.2", "@golevelup/ts-jest": "^0.3.4", "@jest-mock/express": "^1.4.5", "@nestjs/cli": "^10.1.17", @@ -2911,6 +2912,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@faker-js/faker": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.1.0.tgz", + "integrity": "sha512-38DT60rumHfBYynif3lmtxMqMqmsOQIxQgEuPZxCk2yUYN0eqWpTACgxi0VpidvsJB8CRxCpvP7B3anK85FjtQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0", + "npm": ">=6.14.13" + } + }, "node_modules/@feathers-plus/batch-loader": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/@feathers-plus/batch-loader/-/batch-loader-0.3.6.tgz", @@ -26605,6 +26622,12 @@ } } }, + "@faker-js/faker": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.1.0.tgz", + "integrity": "sha512-38DT60rumHfBYynif3lmtxMqMqmsOQIxQgEuPZxCk2yUYN0eqWpTACgxi0VpidvsJB8CRxCpvP7B3anK85FjtQ==", + "dev": true + }, "@feathers-plus/batch-loader": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/@feathers-plus/batch-loader/-/batch-loader-0.3.6.tgz", diff --git a/package.json b/package.json index 99a99b681d4..9a8c78c9377 100644 --- a/package.json +++ b/package.json @@ -214,6 +214,7 @@ }, "devDependencies": { "@aws-sdk/client-s3": "^3.352.0", + "@faker-js/faker": "^8.0.2", "@golevelup/ts-jest": "^0.3.4", "@jest-mock/express": "^1.4.5", "@nestjs/cli": "^10.1.17", From 2b27ebe46e724e5f61de316aa563834fcbbb2e0e Mon Sep 17 00:00:00 2001 From: wolfganggreschus Date: Tue, 10 Oct 2023 08:01:07 +0200 Subject: [PATCH 07/10] BC-4956 - due date validation (#4408) * change submissionControllerElement.dueDate from undefined to null --- .../content-element-update-content.spec.ts | 25 +++++++++++-------- .../update-element-content.body.params.ts | 1 + ...ssion-container-element-response.mapper.ts | 2 +- .../board/repo/board-do.builder-impl.ts | 5 +--- .../board/repo/recursive-save.visitor.ts | 5 +--- .../service/content-element-update.visitor.ts | 3 ++- .../board/service/content-element.service.ts | 1 - .../server/src/modules/board/uc/element.uc.ts | 1 - .../board/content-element.factory.ts | 1 + .../board/submission-container-element.do.ts | 6 ++--- ...ubmission-container-element-node.entity.ts | 4 +-- ...bmission-container-element-node.factory.ts | 4 ++- 12 files changed, 29 insertions(+), 29 deletions(-) diff --git a/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts b/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts index bee1ad63f0f..16ae21dee78 100644 --- a/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts @@ -11,6 +11,8 @@ import { SubmissionContainerElementNode, } from '@shared/domain'; import { + TestApiClient, + UserAndAccountTestFactory, cardNodeFactory, cleanupCollections, columnBoardNodeFactory, @@ -19,8 +21,6 @@ import { fileElementNodeFactory, richTextElementNodeFactory, submissionContainerElementNodeFactory, - TestApiClient, - UserAndAccountTestFactory, } from '@shared/testing'; import { ServerTestModule } from '@src/modules/server/server.module'; @@ -63,7 +63,10 @@ describe(`content element update content (api)`, () => { const parentCard = cardNodeFactory.buildWithId({ parent: column }); const richTextElement = richTextElementNodeFactory.buildWithId({ parent: parentCard }); const fileElement = fileElementNodeFactory.buildWithId({ parent: parentCard }); - const submissionContainerElement = submissionContainerElementNodeFactory.buildWithId({ parent: parentCard }); + const submissionContainerElement = submissionContainerElementNodeFactory.buildWithId({ + parent: parentCard, + dueDate: null, + }); const tomorrow = new Date(Date.now() + 86400000); const submissionContainerElementWithDueDate = submissionContainerElementNodeFactory.buildWithId({ @@ -166,7 +169,6 @@ describe(`content element update content (api)`, () => { it('should return status 204 (nothing changed) without dueDate parameter for submission container element', async () => { const { loggedInClient, submissionContainerElement } = await setup(); - const response = await loggedInClient.patch(`${submissionContainerElement.id}/content`, { data: { content: {}, @@ -177,7 +179,7 @@ describe(`content element update content (api)`, () => { expect(response.statusCode).toEqual(204); }); - it('should not change dueDate value without dueDate parameter for submission container element', async () => { + it('should not change dueDate when not proviced in submission container element without dueDate', async () => { const { loggedInClient, submissionContainerElement } = await setup(); await loggedInClient.patch(`${submissionContainerElement.id}/content`, { @@ -187,11 +189,10 @@ describe(`content element update content (api)`, () => { }, }); const result = await em.findOneOrFail(SubmissionContainerElementNode, submissionContainerElement.id); - - expect(result.dueDate).toBeUndefined(); + expect(result.dueDate).toBeNull(); }); - it('should set dueDate value when dueDate parameter is provided for submission container element', async () => { + it('should set dueDate value when provided for submission container element', async () => { const { loggedInClient, submissionContainerElement } = await setup(); const inThreeDays = new Date(Date.now() + 259200000); @@ -207,18 +208,20 @@ describe(`content element update content (api)`, () => { expect(result.dueDate).toEqual(inThreeDays); }); - it('should unset dueDate value when dueDate parameter is not provided for submission container element', async () => { + it('should unset dueDate value when dueDate parameter is null for submission container element', async () => { const { loggedInClient, submissionContainerElementWithDueDate } = await setup(); await loggedInClient.patch(`${submissionContainerElementWithDueDate.id}/content`, { data: { - content: {}, + content: { + dueDate: null, + }, type: 'submissionContainer', }, }); const result = await em.findOneOrFail(SubmissionContainerElementNode, submissionContainerElementWithDueDate.id); - expect(result.dueDate).toBeUndefined(); + expect(result.dueDate).toBeNull(); }); it('should return status 400 for wrong date format for submission container element', async () => { diff --git a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts index 05856e9ef5f..208ec7d1d2d 100644 --- a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts +++ b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts @@ -56,6 +56,7 @@ export class SubmissionContainerContentBody { @IsOptional() @ApiPropertyOptional({ required: false, + nullable: true, description: 'The point in time until when a submission can be handed in.', }) dueDate?: Date; diff --git a/apps/server/src/modules/board/controller/mapper/submission-container-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/submission-container-element-response.mapper.ts index 8b3dc6ae54f..fc68da31d13 100644 --- a/apps/server/src/modules/board/controller/mapper/submission-container-element-response.mapper.ts +++ b/apps/server/src/modules/board/controller/mapper/submission-container-element-response.mapper.ts @@ -19,7 +19,7 @@ export class SubmissionContainerElementResponseMapper implements BaseResponseMap timestamps: new TimestampsResponse({ lastUpdatedAt: element.updatedAt, createdAt: element.createdAt }), type: ContentElementType.SUBMISSION_CONTAINER, content: new SubmissionContainerElementContent({ - dueDate: element.dueDate || null, + dueDate: element.dueDate, }), }); diff --git a/apps/server/src/modules/board/repo/board-do.builder-impl.ts b/apps/server/src/modules/board/repo/board-do.builder-impl.ts index af58280b33f..0b1b2b59cb0 100644 --- a/apps/server/src/modules/board/repo/board-do.builder-impl.ts +++ b/apps/server/src/modules/board/repo/board-do.builder-impl.ts @@ -128,12 +128,9 @@ export class BoardDoBuilderImpl implements BoardDoBuilder { children: elements, createdAt: boardNode.createdAt, updatedAt: boardNode.updatedAt, + dueDate: boardNode.dueDate, }); - if (boardNode.dueDate) { - element.dueDate = boardNode.dueDate; - } - return element; } diff --git a/apps/server/src/modules/board/repo/recursive-save.visitor.ts b/apps/server/src/modules/board/repo/recursive-save.visitor.ts index 5561e636267..d35b80a93aa 100644 --- a/apps/server/src/modules/board/repo/recursive-save.visitor.ts +++ b/apps/server/src/modules/board/repo/recursive-save.visitor.ts @@ -130,12 +130,9 @@ export class RecursiveSaveVisitor implements BoardCompositeVisitor { id: submissionContainerElement.id, parent: parentData?.boardNode, position: parentData?.position, + dueDate: submissionContainerElement.dueDate, }); - if (submissionContainerElement.dueDate) { - boardNode.dueDate = submissionContainerElement.dueDate; - } - this.createOrUpdateBoardNode(boardNode); this.visitChildren(submissionContainerElement, boardNode); } diff --git a/apps/server/src/modules/board/service/content-element-update.visitor.ts b/apps/server/src/modules/board/service/content-element-update.visitor.ts index dfd430aa250..d5d950890d6 100644 --- a/apps/server/src/modules/board/service/content-element-update.visitor.ts +++ b/apps/server/src/modules/board/service/content-element-update.visitor.ts @@ -59,7 +59,8 @@ export class ContentElementUpdateVisitor implements BoardCompositeVisitor { visitSubmissionContainerElement(submissionContainerElement: SubmissionContainerElement): void { if (this.content instanceof SubmissionContainerContentBody) { - submissionContainerElement.dueDate = this.content.dueDate ?? undefined; + if (this.content.dueDate === undefined) return; + submissionContainerElement.dueDate = this.content.dueDate; } else { this.throwNotHandled(submissionContainerElement); } diff --git a/apps/server/src/modules/board/service/content-element.service.ts b/apps/server/src/modules/board/service/content-element.service.ts index bef5d076fc6..2a55ff17a08 100644 --- a/apps/server/src/modules/board/service/content-element.service.ts +++ b/apps/server/src/modules/board/service/content-element.service.ts @@ -47,7 +47,6 @@ export class ContentElementService { async update(element: AnyContentElementDo, content: AnyElementContentBody): Promise { const updater = new ContentElementUpdateVisitor(content); - element.accept(updater); const parent = await this.boardDoRepo.findParentOfId(element.id); diff --git a/apps/server/src/modules/board/uc/element.uc.ts b/apps/server/src/modules/board/uc/element.uc.ts index 0dafd9eb98f..b66ce1bd247 100644 --- a/apps/server/src/modules/board/uc/element.uc.ts +++ b/apps/server/src/modules/board/uc/element.uc.ts @@ -31,7 +31,6 @@ export class ElementUc { const element = await this.elementService.findById(elementId); await this.checkPermission(userId, element, Action.write); - await this.elementService.update(element, content); } diff --git a/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts b/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts index fb476d2dbd0..ff8966aa77f 100644 --- a/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts +++ b/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts @@ -65,6 +65,7 @@ export class ContentElementFactory { private buildSubmissionContainer() { const element = new SubmissionContainerElement({ id: new ObjectId().toHexString(), + dueDate: null, children: [], createdAt: new Date(), updatedAt: new Date(), diff --git a/apps/server/src/shared/domain/domainobject/board/submission-container-element.do.ts b/apps/server/src/shared/domain/domainobject/board/submission-container-element.do.ts index 09756153a90..0980eb7a569 100644 --- a/apps/server/src/shared/domain/domainobject/board/submission-container-element.do.ts +++ b/apps/server/src/shared/domain/domainobject/board/submission-container-element.do.ts @@ -3,11 +3,11 @@ import { SubmissionItem } from './submission-item.do'; import type { AnyBoardDo, BoardCompositeVisitor, BoardCompositeVisitorAsync } from './types'; export class SubmissionContainerElement extends BoardComposite { - get dueDate(): Date | undefined { + get dueDate(): Date | null { return this.props.dueDate; } - set dueDate(value: Date | undefined) { + set dueDate(value: Date | null) { this.props.dueDate = value; } @@ -26,7 +26,7 @@ export class SubmissionContainerElement extends BoardComposite(SubmissionContainerElementNode, () => { - return {}; + return { + dueDate: null, + }; }); From 4d5a69c7fbb1ecffc539ca507ab107ef7e436e4d Mon Sep 17 00:00:00 2001 From: Uwe Ilgenstein Date: Tue, 10 Oct 2023 14:03:42 +0200 Subject: [PATCH 08/10] BC-5044 - prevent password logging of failed edusharing requests --- src/middleware/errorHandler.js | 2 ++ src/services/edusharing/services/EduSharingConnectorV6.js | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/middleware/errorHandler.js b/src/middleware/errorHandler.js index 0cc57312116..463e924ee98 100644 --- a/src/middleware/errorHandler.js +++ b/src/middleware/errorHandler.js @@ -125,6 +125,7 @@ const secretDataKeys = (() => 'gradeComment', '_csrf', 'searchUserPassword', + 'authorization', ].map((k) => k.toLocaleLowerCase()))(); const filterSecretValue = (key, value) => { @@ -174,6 +175,7 @@ const filterSecrets = (error, req, res, next) => { if (error) { // req.url = filterQuery(req.url); req.originalUrl = filterQuery(req.originalUrl); + req.headers = filter(req.headers); req.body = filter(req.body); error.data = filter(error.data); error.options = filter(error.options); diff --git a/src/services/edusharing/services/EduSharingConnectorV6.js b/src/services/edusharing/services/EduSharingConnectorV6.js index 95a924a1eda..cc9f67f8230 100644 --- a/src/services/edusharing/services/EduSharingConnectorV6.js +++ b/src/services/edusharing/services/EduSharingConnectorV6.js @@ -114,7 +114,9 @@ class EduSharingConnector { if (err.statusCode === 404) { return null; } - logger.error(`Edu-Sharing failed request with error ${err.statusCode} ${err.message}`, options); + // eslint-disable-next-line no-unused-vars + const { headers, ...logOptions } = options; + logger.error(`Edu-Sharing failed request with error ${err.statusCode} ${err.message}`, logOptions); if (retried === true) { throw new GeneralError('Edu-Sharing Request failed'); } else { From a8f7cda9f399153caec5ce9340152b8a516f8c16 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport <106819770+hoeppner-dataport@users.noreply.github.com> Date: Wed, 11 Oct 2023 16:05:45 +0200 Subject: [PATCH 09/10] BC-5189 - link element (#4444) First implementation of LinkElement to be used on cards of columnBoards. * implemented open-graph-proxy-service to gather open graph data from urls * open-graph-data is fetched during updateElement and enriches the input from the user (=url) * invalid urls are handled in the open-graph-proxy * if multiple open-graph-images are provided the smallest one (exceeding a min-width) will be chosen * feature toogle is used to disable the feature for the moment --------- Co-authored-by: Oliver Happe --- apps/server/src/modules/board/board.module.ts | 2 + .../api-test/card-create.api.spec.ts | 11 +- .../content-element-update-content.spec.ts | 8 +- .../board/controller/card.controller.ts | 15 +- .../controller/dto/card/card.response.ts | 22 +- .../element/any-content-element.response.ts | 2 + .../board/controller/dto/element/index.ts | 5 +- .../dto/element/link-element.response.ts | 45 ++ .../update-element-content.body.params.ts | 18 + .../board/controller/element.controller.ts | 39 +- .../content-element-response.factory.spec.ts | 35 +- .../content-element-response.factory.ts | 2 + .../modules/board/controller/mapper/index.ts | 4 +- .../mapper/link-element-response.mapper.ts | 35 ++ .../board/repo/board-do.builder-impl.spec.ts | 24 +- .../board/repo/board-do.builder-impl.ts | 22 +- .../repo/recursive-delete.visitor.spec.ts | 21 + .../board/repo/recursive-delete.vistor.ts | 7 + .../board/repo/recursive-save.visitor.spec.ts | 18 + .../board/repo/recursive-save.visitor.ts | 18 + .../board-do-copy.service.spec.ts | 52 ++ .../recursive-copy.visitor.ts | 21 + .../board/service/card.service.spec.ts | 3 +- .../src/modules/board/service/card.service.ts | 10 +- .../content-element-update.visitor.spec.ts | 81 ++- .../service/content-element-update.visitor.ts | 75 ++- .../service/content-element.service.spec.ts | 70 ++- .../board/service/content-element.service.ts | 12 +- .../server/src/modules/board/service/index.ts | 1 + .../service/open-graph-proxy.service.spec.ts | 91 +++ .../board/service/open-graph-proxy.service.ts | 41 ++ .../server/src/modules/board/uc/element.uc.ts | 6 +- .../modules/copy-helper/types/copy.types.ts | 1 + .../domain/domainobject/board/card.do.ts | 2 + .../board/content-element.factory.ts | 16 + .../shared/domain/domainobject/board/index.ts | 3 +- .../board/link-element.do.spec.ts | 37 ++ .../domainobject/board/link-element.do.ts | 59 ++ .../board/types/any-content-element-do.ts | 13 +- .../board/types/board-composite-visitor.ts | 11 +- .../board/types/content-elements.enum.ts | 1 + .../src/shared/domain/entity/all-entities.ts | 2 + .../shared/domain/entity/boardnode/index.ts | 3 +- .../link-element-node.entity.spec.ts | 54 ++ .../boardnode/link-element-node.entity.ts | 36 ++ .../boardnode/types/board-do.builder.ts | 5 +- .../entity/boardnode/types/board-node-type.ts | 1 + .../shared/testing/factory/boardnode/index.ts | 3 +- .../boardnode/link-element-node.factory.ts | 14 + .../factory/domainobject/board/index.ts | 3 +- .../board/link-element.do.factory.ts | 15 + config/default.schema.json | 5 + config/development.json | 1 + package-lock.json | 585 +++++++++++++++++- package.json | 1 + src/services/config/publicAppConfigService.js | 1 + 56 files changed, 1550 insertions(+), 138 deletions(-) create mode 100644 apps/server/src/modules/board/controller/dto/element/link-element.response.ts create mode 100644 apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts create mode 100644 apps/server/src/modules/board/service/open-graph-proxy.service.spec.ts create mode 100644 apps/server/src/modules/board/service/open-graph-proxy.service.ts create mode 100644 apps/server/src/shared/domain/domainobject/board/link-element.do.spec.ts create mode 100644 apps/server/src/shared/domain/domainobject/board/link-element.do.ts create mode 100644 apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.spec.ts create mode 100644 apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.ts create mode 100644 apps/server/src/shared/testing/factory/boardnode/link-element-node.factory.ts create mode 100644 apps/server/src/shared/testing/factory/domainobject/board/link-element.do.factory.ts diff --git a/apps/server/src/modules/board/board.module.ts b/apps/server/src/modules/board/board.module.ts index a002766b56d..fb04364b6c3 100644 --- a/apps/server/src/modules/board/board.module.ts +++ b/apps/server/src/modules/board/board.module.ts @@ -14,6 +14,7 @@ import { ColumnBoardService, ColumnService, ContentElementService, + OpenGraphProxyService, SubmissionItemService, } from './service'; import { BoardDoCopyService, SchoolSpecificFileCopyServiceFactory } from './service/board-do-copy-service'; @@ -37,6 +38,7 @@ import { ColumnBoardCopyService } from './service/column-board-copy.service'; BoardDoCopyService, ColumnBoardCopyService, SchoolSpecificFileCopyServiceFactory, + OpenGraphProxyService, ], exports: [ BoardDoAuthorizableService, diff --git a/apps/server/src/modules/board/controller/api-test/card-create.api.spec.ts b/apps/server/src/modules/board/controller/api-test/card-create.api.spec.ts index beb31d4dd5c..a108d352759 100644 --- a/apps/server/src/modules/board/controller/api-test/card-create.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/card-create.api.spec.ts @@ -87,7 +87,7 @@ describe(`card create (api)`, () => { em.clear(); const createCardBodyParams = { - requiredEmptyElements: [ContentElementType.RICH_TEXT, ContentElementType.FILE], + requiredEmptyElements: [ContentElementType.RICH_TEXT, ContentElementType.FILE, ContentElementType.LINK], }; return { user, columnBoardNode, columnNode, createCardBodyParams }; @@ -111,7 +111,7 @@ describe(`card create (api)`, () => { expect(result.id).toBeDefined(); }); - it('created card should contain empty text and file elements', async () => { + it('created card should contain empty text, file and link elements', async () => { const { user, columnNode, createCardBodyParams } = await setup(); currentUser = mapUserToCurrentUser(user); @@ -129,6 +129,12 @@ describe(`card create (api)`, () => { alternativeText: '', }, }, + { + type: 'link', + content: { + url: '', + }, + }, ]; const { result } = await api.post(columnNode.id, createCardBodyParams); @@ -136,6 +142,7 @@ describe(`card create (api)`, () => { expect(elements[0]).toMatchObject(expectedEmptyElements[0]); expect(elements[1]).toMatchObject(expectedEmptyElements[1]); + expect(elements[2]).toMatchObject(expectedEmptyElements[2]); }); it('should return status 400 as the content element is unknown', async () => { const { user, columnNode } = await setup(); diff --git a/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts b/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts index 16ae21dee78..6a292ffa93d 100644 --- a/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts @@ -98,7 +98,7 @@ describe(`content element update content (api)`, () => { }; }; - it('should return status 204', async () => { + it('should return status 201', async () => { const { loggedInClient, richTextElement } = await setup(); const response = await loggedInClient.patch(`${richTextElement.id}/content`, { @@ -108,7 +108,7 @@ describe(`content element update content (api)`, () => { }, }); - expect(response.statusCode).toEqual(204); + expect(response.statusCode).toEqual(201); }); it('should actually change content of the element', async () => { @@ -167,7 +167,7 @@ describe(`content element update content (api)`, () => { expect(result.alternativeText).toEqual('rich text 1 some more text'); }); - it('should return status 204 (nothing changed) without dueDate parameter for submission container element', async () => { + it('should return status 201', async () => { const { loggedInClient, submissionContainerElement } = await setup(); const response = await loggedInClient.patch(`${submissionContainerElement.id}/content`, { data: { @@ -176,7 +176,7 @@ describe(`content element update content (api)`, () => { }, }); - expect(response.statusCode).toEqual(204); + expect(response.statusCode).toEqual(201); }); it('should not change dueDate when not proviced in submission container element without dueDate', async () => { diff --git a/apps/server/src/modules/board/controller/card.controller.ts b/apps/server/src/modules/board/controller/card.controller.ts index e76bdbe088c..38a979dbf1e 100644 --- a/apps/server/src/modules/board/controller/card.controller.ts +++ b/apps/server/src/modules/board/controller/card.controller.ts @@ -25,6 +25,7 @@ import { CreateContentElementBodyParams, ExternalToolElementResponse, FileElementResponse, + LinkElementResponse, MoveCardBodyParams, RenameBodyParams, RichTextElementResponse, @@ -116,19 +117,21 @@ export class CardController { @ApiOperation({ summary: 'Create a new element on a card.' }) @ApiExtraModels( - RichTextElementResponse, + ExternalToolElementResponse, FileElementResponse, - SubmissionContainerElementResponse, - ExternalToolElementResponse + LinkElementResponse, + RichTextElementResponse, + SubmissionContainerElementResponse ) @ApiResponse({ status: 201, schema: { oneOf: [ - { $ref: getSchemaPath(RichTextElementResponse) }, + { $ref: getSchemaPath(ExternalToolElementResponse) }, { $ref: getSchemaPath(FileElementResponse) }, + { $ref: getSchemaPath(LinkElementResponse) }, + { $ref: getSchemaPath(RichTextElementResponse) }, { $ref: getSchemaPath(SubmissionContainerElementResponse) }, - { $ref: getSchemaPath(ExternalToolElementResponse) }, ], }, }) @@ -137,7 +140,7 @@ export class CardController { @ApiResponse({ status: 404, type: NotFoundException }) @Post(':cardId/elements') async createElement( - @Param() urlParams: CardUrlParams, // TODO add type-property ? + @Param() urlParams: CardUrlParams, @Body() bodyParams: CreateContentElementBodyParams, @CurrentUser() currentUser: ICurrentUser ): Promise { diff --git a/apps/server/src/modules/board/controller/dto/card/card.response.ts b/apps/server/src/modules/board/controller/dto/card/card.response.ts index 44ee426fb6b..3577fcbc2a1 100644 --- a/apps/server/src/modules/board/controller/dto/card/card.response.ts +++ b/apps/server/src/modules/board/controller/dto/card/card.response.ts @@ -1,11 +1,23 @@ import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; import { DecodeHtmlEntities } from '@shared/controller'; -import { AnyContentElementResponse, FileElementResponse, SubmissionContainerElementResponse } from '../element'; -import { RichTextElementResponse } from '../element/rich-text-element.response'; +import { + AnyContentElementResponse, + ExternalToolElementResponse, + FileElementResponse, + LinkElementResponse, + RichTextElementResponse, + SubmissionContainerElementResponse, +} from '../element'; import { TimestampsResponse } from '../timestamps.response'; import { VisibilitySettingsResponse } from './visibility-settings.response'; -@ApiExtraModels(RichTextElementResponse) +@ApiExtraModels( + ExternalToolElementResponse, + FileElementResponse, + LinkElementResponse, + RichTextElementResponse, + SubmissionContainerElementResponse +) export class CardResponse { constructor({ id, title, height, elements, visibilitySettings, timestamps }: CardResponse) { this.id = id; @@ -32,8 +44,10 @@ export class CardResponse { type: 'array', items: { oneOf: [ - { $ref: getSchemaPath(RichTextElementResponse) }, + { $ref: getSchemaPath(ExternalToolElementResponse) }, { $ref: getSchemaPath(FileElementResponse) }, + { $ref: getSchemaPath(LinkElementResponse) }, + { $ref: getSchemaPath(RichTextElementResponse) }, { $ref: getSchemaPath(SubmissionContainerElementResponse) }, ], }, diff --git a/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts b/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts index 1382b75b3f5..18415d172fa 100644 --- a/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts +++ b/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts @@ -1,10 +1,12 @@ import { ExternalToolElementResponse } from './external-tool-element.response'; import { FileElementResponse } from './file-element.response'; +import { LinkElementResponse } from './link-element.response'; import { RichTextElementResponse } from './rich-text-element.response'; import { SubmissionContainerElementResponse } from './submission-container-element.response'; export type AnyContentElementResponse = | FileElementResponse + | LinkElementResponse | RichTextElementResponse | SubmissionContainerElementResponse | ExternalToolElementResponse; diff --git a/apps/server/src/modules/board/controller/dto/element/index.ts b/apps/server/src/modules/board/controller/dto/element/index.ts index b1ef77f8ec0..6787c007c1b 100644 --- a/apps/server/src/modules/board/controller/dto/element/index.ts +++ b/apps/server/src/modules/board/controller/dto/element/index.ts @@ -1,7 +1,8 @@ export * from './any-content-element.response'; export * from './create-content-element.body.params'; -export * from './update-element-content.body.params'; +export * from './external-tool-element.response'; export * from './file-element.response'; +export * from './link-element.response'; export * from './rich-text-element.response'; export * from './submission-container-element.response'; -export * from './external-tool-element.response'; +export * from './update-element-content.body.params'; diff --git a/apps/server/src/modules/board/controller/dto/element/link-element.response.ts b/apps/server/src/modules/board/controller/dto/element/link-element.response.ts new file mode 100644 index 00000000000..d6c4a7e7080 --- /dev/null +++ b/apps/server/src/modules/board/controller/dto/element/link-element.response.ts @@ -0,0 +1,45 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ContentElementType } from '@shared/domain'; +import { TimestampsResponse } from '../timestamps.response'; + +export class LinkElementContent { + constructor({ url, title, description, imageUrl }: LinkElementContent) { + this.url = url; + this.title = title; + this.description = description; + this.imageUrl = imageUrl; + } + + @ApiProperty() + url: string; + + @ApiProperty() + title: string; + + @ApiPropertyOptional() + description?: string; + + @ApiPropertyOptional() + imageUrl?: string; +} + +export class LinkElementResponse { + constructor({ id, content, timestamps, type }: LinkElementResponse) { + this.id = id; + this.content = content; + this.timestamps = timestamps; + this.type = type; + } + + @ApiProperty({ pattern: '[a-f0-9]{24}' }) + id: string; + + @ApiProperty({ enum: ContentElementType, enumName: 'ContentElementType' }) + type: ContentElementType.LINK; + + @ApiProperty() + content: LinkElementContent; + + @ApiProperty() + timestamps: TimestampsResponse; +} diff --git a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts index 208ec7d1d2d..5eb0f239c1f 100644 --- a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts +++ b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts @@ -31,6 +31,20 @@ export class FileElementContentBody extends ElementContentBody { @ApiProperty() content!: FileContentBody; } +export class LinkContentBody { + @IsString() + @ApiProperty({}) + url!: string; +} + +export class LinkElementContentBody extends ElementContentBody { + @ApiProperty({ type: ContentElementType.LINK }) + type!: ContentElementType.LINK; + + @ValidateNested() + @ApiProperty({}) + content!: LinkContentBody; +} export class RichTextContentBody { @IsString() @@ -89,6 +103,7 @@ export class ExternalToolElementContentBody extends ElementContentBody { export type AnyElementContentBody = | FileContentBody + | LinkContentBody | RichTextContentBody | SubmissionContainerContentBody | ExternalToolContentBody; @@ -100,6 +115,7 @@ export class UpdateElementContentBodyParams { property: 'type', subTypes: [ { value: FileElementContentBody, name: ContentElementType.FILE }, + { value: LinkElementContentBody, name: ContentElementType.LINK }, { value: RichTextElementContentBody, name: ContentElementType.RICH_TEXT }, { value: SubmissionContainerElementContentBody, name: ContentElementType.SUBMISSION_CONTAINER }, { value: ExternalToolElementContentBody, name: ContentElementType.EXTERNAL_TOOL }, @@ -110,6 +126,7 @@ export class UpdateElementContentBodyParams { @ApiProperty({ oneOf: [ { $ref: getSchemaPath(FileElementContentBody) }, + { $ref: getSchemaPath(LinkElementContentBody) }, { $ref: getSchemaPath(RichTextElementContentBody) }, { $ref: getSchemaPath(SubmissionContainerElementContentBody) }, { $ref: getSchemaPath(ExternalToolElementContentBody) }, @@ -117,6 +134,7 @@ export class UpdateElementContentBodyParams { }) data!: | FileElementContentBody + | LinkElementContentBody | RichTextElementContentBody | SubmissionContainerElementContentBody | ExternalToolElementContentBody; diff --git a/apps/server/src/modules/board/controller/element.controller.ts b/apps/server/src/modules/board/controller/element.controller.ts index 361e59bf6b6..2dacd2cf539 100644 --- a/apps/server/src/modules/board/controller/element.controller.ts +++ b/apps/server/src/modules/board/controller/element.controller.ts @@ -10,24 +10,31 @@ import { Post, Put, } from '@nestjs/common'; -import { ApiBody, ApiExtraModels, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiBody, ApiExtraModels, ApiOperation, ApiResponse, ApiTags, getSchemaPath } from '@nestjs/swagger'; import { ApiValidationError } from '@shared/common'; import { ICurrentUser } from '@src/modules/authentication'; import { Authenticate, CurrentUser } from '@src/modules/authentication/decorator/auth.decorator'; import { CardUc } from '../uc'; import { ElementUc } from '../uc/element.uc'; import { + AnyContentElementResponse, ContentElementUrlParams, CreateSubmissionItemBodyParams, ExternalToolElementContentBody, + ExternalToolElementResponse, FileElementContentBody, + FileElementResponse, + LinkElementContentBody, + LinkElementResponse, MoveContentElementBody, RichTextElementContentBody, + RichTextElementResponse, SubmissionContainerElementContentBody, + SubmissionContainerElementResponse, SubmissionItemResponse, UpdateElementContentBodyParams, } from './dto'; -import { SubmissionItemResponseMapper } from './mapper'; +import { ContentElementResponseFactory, SubmissionItemResponseMapper } from './mapper'; @ApiTags('Board Element') @Authenticate('jwt') @@ -60,20 +67,38 @@ export class ElementController { FileElementContentBody, RichTextElementContentBody, SubmissionContainerElementContentBody, - ExternalToolElementContentBody + ExternalToolElementContentBody, + LinkElementContentBody ) - @ApiResponse({ status: 204 }) + @ApiResponse({ + status: 201, + schema: { + oneOf: [ + { $ref: getSchemaPath(ExternalToolElementResponse) }, + { $ref: getSchemaPath(FileElementResponse) }, + { $ref: getSchemaPath(LinkElementResponse) }, + { $ref: getSchemaPath(RichTextElementResponse) }, + { $ref: getSchemaPath(SubmissionContainerElementResponse) }, + ], + }, + }) @ApiResponse({ status: 400, type: ApiValidationError }) @ApiResponse({ status: 403, type: ForbiddenException }) @ApiResponse({ status: 404, type: NotFoundException }) - @HttpCode(204) + @HttpCode(201) @Patch(':contentElementId/content') async updateElement( @Param() urlParams: ContentElementUrlParams, @Body() bodyParams: UpdateElementContentBodyParams, @CurrentUser() currentUser: ICurrentUser - ): Promise { - await this.elementUc.updateElementContent(currentUser.userId, urlParams.contentElementId, bodyParams.data.content); + ): Promise { + const element = await this.elementUc.updateElementContent( + currentUser.userId, + urlParams.contentElementId, + bodyParams.data.content + ); + const response = ContentElementResponseFactory.mapToResponse(element); + return response; } @ApiOperation({ summary: 'Delete a single content element.' }) diff --git a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts index f813e44ae7a..2b61e273185 100644 --- a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts +++ b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts @@ -1,27 +1,36 @@ import { NotImplementedException } from '@nestjs/common'; -import { fileElementFactory, richTextElementFactory, submissionContainerElementFactory } from '@shared/testing'; -import { FileElementResponse, RichTextElementResponse, SubmissionContainerElementResponse } from '../dto'; +import { + fileElementFactory, + linkElementFactory, + richTextElementFactory, + submissionContainerElementFactory, +} from '@shared/testing'; +import { + FileElementResponse, + LinkElementResponse, + RichTextElementResponse, + SubmissionContainerElementResponse, +} from '../dto'; import { ContentElementResponseFactory } from './content-element-response.factory'; describe(ContentElementResponseFactory.name, () => { - const setup = () => { - const fileElement = fileElementFactory.build(); - const richTextElement = richTextElementFactory.build(); - const submissionContainerElement = submissionContainerElementFactory.build(); - - return { fileElement, richTextElement, submissionContainerElement }; - }; - it('should return instance of FileElementResponse', () => { - const { fileElement } = setup(); + const fileElement = fileElementFactory.build(); const result = ContentElementResponseFactory.mapToResponse(fileElement); expect(result).toBeInstanceOf(FileElementResponse); }); + it('should return instance of LinkElementResponse', () => { + const linkElement = linkElementFactory.build(); + const result = ContentElementResponseFactory.mapToResponse(linkElement); + + expect(result).toBeInstanceOf(LinkElementResponse); + }); + it('should return instance of RichTextElementResponse', () => { - const { richTextElement } = setup(); + const richTextElement = richTextElementFactory.build(); const result = ContentElementResponseFactory.mapToResponse(richTextElement); @@ -29,7 +38,7 @@ describe(ContentElementResponseFactory.name, () => { }); it('should return instance of SubmissionContainerElementResponse', () => { - const { submissionContainerElement } = setup(); + const submissionContainerElement = submissionContainerElementFactory.build(); const result = ContentElementResponseFactory.mapToResponse(submissionContainerElement); diff --git a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts index 3ccf11b1bf2..bda46e4b73f 100644 --- a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts +++ b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts @@ -4,12 +4,14 @@ import { AnyContentElementResponse } from '../dto'; import { BaseResponseMapper } from './base-mapper.interface'; import { ExternalToolElementResponseMapper } from './external-tool-element-response.mapper'; import { FileElementResponseMapper } from './file-element-response.mapper'; +import { LinkElementResponseMapper } from './link-element-response.mapper'; import { RichTextElementResponseMapper } from './rich-text-element-response.mapper'; import { SubmissionContainerElementResponseMapper } from './submission-container-element-response.mapper'; export class ContentElementResponseFactory { private static mappers: BaseResponseMapper[] = [ FileElementResponseMapper.getInstance(), + LinkElementResponseMapper.getInstance(), RichTextElementResponseMapper.getInstance(), SubmissionContainerElementResponseMapper.getInstance(), ExternalToolElementResponseMapper.getInstance(), diff --git a/apps/server/src/modules/board/controller/mapper/index.ts b/apps/server/src/modules/board/controller/mapper/index.ts index 116692df5a4..a24a905ae3f 100644 --- a/apps/server/src/modules/board/controller/mapper/index.ts +++ b/apps/server/src/modules/board/controller/mapper/index.ts @@ -2,7 +2,9 @@ export * from './board-response.mapper'; export * from './card-response.mapper'; export * from './column-response.mapper'; export * from './content-element-response.factory'; +export * from './external-tool-element-response.mapper'; +export * from './file-element-response.mapper'; +export * from './link-element-response.mapper'; export * from './rich-text-element-response.mapper'; export * from './submission-container-element-response.mapper'; export * from './submission-item-response.mapper'; -export * from './external-tool-element-response.mapper'; diff --git a/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts new file mode 100644 index 00000000000..e6a31b2c07a --- /dev/null +++ b/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts @@ -0,0 +1,35 @@ +import { ContentElementType, LinkElement } from '@shared/domain'; +import { LinkElementContent, LinkElementResponse, TimestampsResponse } from '../dto'; +import { BaseResponseMapper } from './base-mapper.interface'; + +export class LinkElementResponseMapper implements BaseResponseMapper { + private static instance: LinkElementResponseMapper; + + public static getInstance(): LinkElementResponseMapper { + if (!LinkElementResponseMapper.instance) { + LinkElementResponseMapper.instance = new LinkElementResponseMapper(); + } + + return LinkElementResponseMapper.instance; + } + + mapToResponse(element: LinkElement): LinkElementResponse { + const result = new LinkElementResponse({ + id: element.id, + timestamps: new TimestampsResponse({ lastUpdatedAt: element.updatedAt, createdAt: element.createdAt }), + type: ContentElementType.LINK, + content: new LinkElementContent({ + url: element.url, + title: element.title, + description: element.description, + imageUrl: element.imageUrl, + }), + }); + + return result; + } + + canMap(element: LinkElement): boolean { + return element instanceof LinkElement; + } +} diff --git a/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts b/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts index d640d3f6330..8bbc859fa17 100644 --- a/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts +++ b/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts @@ -1,10 +1,11 @@ -import { BoardNodeType, ExternalToolElement } from '@shared/domain'; +import { BoardNodeType, ExternalToolElement, LinkElement } from '@shared/domain'; import { cardNodeFactory, columnBoardNodeFactory, columnNodeFactory, externalToolElementNodeFactory, fileElementNodeFactory, + linkElementNodeFactory, richTextElementNodeFactory, setupEntities, submissionContainerElementNodeFactory, @@ -195,7 +196,7 @@ describe(BoardDoBuilderImpl.name, () => { expect(domainObject.constructor.name).toBe(ExternalToolElement.name); }); - it('should throw error if submissionContainerElement is not a leaf', () => { + it('should throw error if externalToolElement is not a leaf', () => { const externalToolElementNode = externalToolElementNodeFactory.buildWithId(); const columnNode = columnNodeFactory.buildWithId({ parent: externalToolElementNode }); @@ -205,6 +206,25 @@ describe(BoardDoBuilderImpl.name, () => { }); }); + describe('when building a link element', () => { + it('should work without descendants', () => { + const linkElementNode = linkElementNodeFactory.buildWithId(); + + const domainObject = new BoardDoBuilderImpl().buildLinkElement(linkElementNode); + + expect(domainObject.constructor.name).toBe(LinkElement.name); + }); + + it('should throw error if linkElement is not a leaf', () => { + const linkElementNode = linkElementNodeFactory.buildWithId(); + const columnNode = columnNodeFactory.buildWithId({ parent: linkElementNode }); + + expect(() => { + new BoardDoBuilderImpl([columnNode]).buildLinkElement(linkElementNode); + }).toThrowError(); + }); + }); + describe('ensure board node types', () => { it('should do nothing if type is correct', () => { const card = cardNodeFactory.build(); diff --git a/apps/server/src/modules/board/repo/board-do.builder-impl.ts b/apps/server/src/modules/board/repo/board-do.builder-impl.ts index 0b1b2b59cb0..18b0583daa1 100644 --- a/apps/server/src/modules/board/repo/board-do.builder-impl.ts +++ b/apps/server/src/modules/board/repo/board-do.builder-impl.ts @@ -7,6 +7,7 @@ import type { ColumnNode, ExternalToolElementNodeEntity, FileElementNode, + LinkElementNode, RichTextElementNode, SubmissionContainerElementNode, SubmissionItemNode, @@ -19,6 +20,7 @@ import { ColumnBoard, ExternalToolElement, FileElement, + LinkElement, RichTextElement, SubmissionContainerElement, SubmissionItem, @@ -73,12 +75,15 @@ export class BoardDoBuilderImpl implements BoardDoBuilder { public buildCard(boardNode: CardNode): Card { this.ensureBoardNodeType(this.getChildren(boardNode), [ BoardNodeType.FILE_ELEMENT, + BoardNodeType.LINK_ELEMENT, BoardNodeType.RICH_TEXT_ELEMENT, BoardNodeType.SUBMISSION_CONTAINER_ELEMENT, BoardNodeType.EXTERNAL_TOOL, ]); - const elements = this.buildChildren(boardNode); + const elements = this.buildChildren< + ExternalToolElement | FileElement | LinkElement | RichTextElement | SubmissionContainerElement + >(boardNode); const card = new Card({ id: boardNode.id, @@ -105,6 +110,21 @@ export class BoardDoBuilderImpl implements BoardDoBuilder { return element; } + public buildLinkElement(boardNode: LinkElementNode): LinkElement { + this.ensureLeafNode(boardNode); + + const element = new LinkElement({ + id: boardNode.id, + url: boardNode.url, + title: boardNode.title, + imageUrl: boardNode.imageUrl, + children: [], + createdAt: boardNode.createdAt, + updatedAt: boardNode.updatedAt, + }); + return element; + } + public buildRichTextElement(boardNode: RichTextElementNode): RichTextElement { this.ensureLeafNode(boardNode); diff --git a/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts b/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts index 75f0e9e2e99..d94e7ae5557 100644 --- a/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts +++ b/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts @@ -7,6 +7,7 @@ import { columnFactory, externalToolElementFactory, fileElementFactory, + linkElementFactory, setupEntities, submissionContainerElementFactory, submissionItemFactory, @@ -145,6 +146,26 @@ describe(RecursiveDeleteVisitor.name, () => { }); }); + describe('visitLinkElementAsync', () => { + const setup = () => { + const childLinkElement = linkElementFactory.build(); + const linkElement = linkElementFactory.build({ + children: [childLinkElement], + }); + + return { linkElement, childLinkElement }; + }; + + it('should call entity remove', async () => { + const { linkElement, childLinkElement } = setup(); + + await service.visitLinkElementAsync(linkElement); + + expect(em.remove).toHaveBeenCalledWith(em.getReference(linkElement.constructor, linkElement.id)); + expect(em.remove).toHaveBeenCalledWith(em.getReference(childLinkElement.constructor, childLinkElement.id)); + }); + }); + describe('visitSubmissionContainerElementAsync', () => { const setup = () => { const childSubmissionContainerElement = submissionContainerElementFactory.build(); diff --git a/apps/server/src/modules/board/repo/recursive-delete.vistor.ts b/apps/server/src/modules/board/repo/recursive-delete.vistor.ts index aaed699c26c..c2177e5dd1c 100644 --- a/apps/server/src/modules/board/repo/recursive-delete.vistor.ts +++ b/apps/server/src/modules/board/repo/recursive-delete.vistor.ts @@ -13,6 +13,7 @@ import { SubmissionContainerElement, SubmissionItem, } from '@shared/domain'; +import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; import { FilesStorageClientAdapterService } from '@src/modules/files-storage-client'; @Injectable() @@ -44,6 +45,12 @@ export class RecursiveDeleteVisitor implements BoardCompositeVisitorAsync { await this.visitChildrenAsync(fileElement); } + async visitLinkElementAsync(linkElement: LinkElement): Promise { + this.deleteNode(linkElement); + + await this.visitChildrenAsync(linkElement); + } + async visitRichTextElementAsync(richTextElement: RichTextElement): Promise { this.deleteNode(richTextElement); await this.visitChildrenAsync(richTextElement); diff --git a/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts b/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts index 088b6f7f54c..3fd95c18525 100644 --- a/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts +++ b/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts @@ -7,6 +7,7 @@ import { ColumnNode, ExternalToolElementNodeEntity, FileElementNode, + LinkElementNode, RichTextElementNode, SubmissionContainerElementNode, SubmissionItemNode, @@ -19,6 +20,7 @@ import { contextExternalToolEntityFactory, externalToolElementFactory, fileElementFactory, + linkElementFactory, richTextElementFactory, setupEntities, submissionContainerElementFactory, @@ -137,6 +139,22 @@ describe(RecursiveSaveVisitor.name, () => { }); }); + describe('when visiting a link element composite', () => { + it('should create or update the node', () => { + const linkElement = linkElementFactory.build(); + jest.spyOn(visitor, 'createOrUpdateBoardNode'); + + visitor.visitLinkElement(linkElement); + + const expectedNode: Partial = { + id: linkElement.id, + type: BoardNodeType.LINK_ELEMENT, + url: linkElement.url, + }; + expect(visitor.createOrUpdateBoardNode).toHaveBeenCalledWith(expect.objectContaining(expectedNode)); + }); + }); + describe('when visiting a rich text element composite', () => { it('should create or update the node', () => { const richTextElement = richTextElementFactory.build(); diff --git a/apps/server/src/modules/board/repo/recursive-save.visitor.ts b/apps/server/src/modules/board/repo/recursive-save.visitor.ts index d35b80a93aa..7b2c7901605 100644 --- a/apps/server/src/modules/board/repo/recursive-save.visitor.ts +++ b/apps/server/src/modules/board/repo/recursive-save.visitor.ts @@ -22,6 +22,8 @@ import { SubmissionItem, SubmissionItemNode, } from '@shared/domain'; +import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; +import { LinkElementNode } from '@shared/domain/entity/boardnode/link-element-node.entity'; import { ContextExternalToolEntity } from '@src/modules/tool/context-external-tool/entity'; import { BoardNodeRepo } from './board-node.repo'; @@ -108,6 +110,22 @@ export class RecursiveSaveVisitor implements BoardCompositeVisitor { this.visitChildren(fileElement, boardNode); } + visitLinkElement(linkElement: LinkElement): void { + const parentData = this.parentsMap.get(linkElement.id); + + const boardNode = new LinkElementNode({ + id: linkElement.id, + url: linkElement.url, + title: linkElement.title, + imageUrl: linkElement.imageUrl, + parent: parentData?.boardNode, + position: parentData?.position, + }); + + this.createOrUpdateBoardNode(boardNode); + this.visitChildren(linkElement, boardNode); + } + visitRichTextElement(richTextElement: RichTextElement): void { const parentData = this.parentsMap.get(richTextElement.id); diff --git a/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts b/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts index ba3643e6051..4b5393854d2 100644 --- a/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts +++ b/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts @@ -11,8 +11,10 @@ import { isColumnBoard, isExternalToolElement, isFileElement, + isLinkElement, isRichTextElement, isSubmissionContainerElement, + LinkElement, RichTextElement, SubmissionContainerElement, } from '@shared/domain'; @@ -23,6 +25,7 @@ import { columnFactory, externalToolElementFactory, fileElementFactory, + linkElementFactory, richTextElementFactory, setupEntities, submissionContainerElementFactory, @@ -706,4 +709,53 @@ describe('recursive board copy visitor', () => { expect(result.type).toEqual(CopyElementType.EXTERNAL_TOOL_ELEMENT); }); }); + + describe('when copying a link element', () => { + const setup = () => { + const original = linkElementFactory.build(); + + return { original, ...setupfileCopyService() }; + }; + + const getLinkElementFromStatus = (status: CopyStatus): LinkElement => { + const copy = status.copyEntity; + + expect(isLinkElement(copy)).toEqual(true); + + return copy as LinkElement; + }; + + it('should return a link element as copy', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(isLinkElement(result.copyEntity)).toEqual(true); + }); + + it('should create new id', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getLinkElementFromStatus(result); + + expect(copy.id).not.toEqual(original.id); + }); + + it('should show status successful', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.status).toEqual(CopyStatusEnum.SUCCESS); + }); + + it('should be of type LinkElement', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.type).toEqual(CopyElementType.LINK_ELEMENT); + }); + }); }); diff --git a/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts index 4d1bf55f5ae..7b17194c15f 100644 --- a/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts +++ b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts @@ -11,6 +11,7 @@ import { SubmissionContainerElement, SubmissionItem, } from '@shared/domain'; +import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; import { FileRecordParentType } from '@shared/infra/rabbitmq'; import { CopyElementType, CopyStatus, CopyStatusEnum } from '@src/modules/copy-helper'; import { ObjectId } from 'bson'; @@ -122,6 +123,26 @@ export class RecursiveCopyVisitor implements BoardCompositeVisitorAsync { this.copyMap.set(original.id, copy); } + async visitLinkElementAsync(original: LinkElement): Promise { + const copy = new LinkElement({ + id: new ObjectId().toHexString(), + url: original.url, + title: original.title, + imageUrl: original.imageUrl, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + this.resultMap.set(original.id, { + copyEntity: copy, + type: CopyElementType.LINK_ELEMENT, + status: CopyStatusEnum.SUCCESS, + }); + this.copyMap.set(original.id, copy); + + return Promise.resolve(); + } + async visitRichTextElementAsync(original: RichTextElement): Promise { const copy = new RichTextElement({ id: new ObjectId().toHexString(), diff --git a/apps/server/src/modules/board/service/card.service.spec.ts b/apps/server/src/modules/board/service/card.service.spec.ts index 6a9a8f1d944..eb155793412 100644 --- a/apps/server/src/modules/board/service/card.service.spec.ts +++ b/apps/server/src/modules/board/service/card.service.spec.ts @@ -88,7 +88,8 @@ describe(CardService.name, () => { }; it('should call the card repository', async () => { - const { cardIds } = setup(); + const { cards, cardIds } = setup(); + boardDoRepo.findByIds.mockResolvedValueOnce(cards); await service.findByIds(cardIds); diff --git a/apps/server/src/modules/board/service/card.service.ts b/apps/server/src/modules/board/service/card.service.ts index 3ef34806397..b7fee25c94f 100644 --- a/apps/server/src/modules/board/service/card.service.ts +++ b/apps/server/src/modules/board/service/card.service.ts @@ -14,16 +14,16 @@ export class CardService { ) {} async findById(cardId: EntityId): Promise { - const card = await this.boardDoRepo.findByClassAndId(Card, cardId); - return card; + return this.boardDoRepo.findByClassAndId(Card, cardId); } async findByIds(cardIds: EntityId[]): Promise { const cards = await this.boardDoRepo.findByIds(cardIds); - if (cards.every((card) => card instanceof Card)) { - return cards as Card[]; + if (cards.some((card) => !(card instanceof Card))) { + throw new NotFoundException('some ids do not belong to a card'); } - throw new NotFoundException('some ids do not belong to a card'); + + return cards as Card[]; } async create(parent: Column, requiredEmptyElements?: ContentElementType[]): Promise { diff --git a/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts b/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts index b96a6ca9a41..8a8368fce2b 100644 --- a/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts +++ b/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts @@ -6,12 +6,14 @@ import { columnFactory, externalToolElementFactory, fileElementFactory, + linkElementFactory, richTextElementFactory, submissionContainerElementFactory, submissionItemFactory, } from '@shared/testing'; import { ExternalToolContentBody, FileContentBody, RichTextContentBody } from '../controller/dto'; import { ContentElementUpdateVisitor } from './content-element-update.visitor'; +import { OpenGraphProxyService } from './open-graph-proxy.service'; describe(ContentElementUpdateVisitor.name, () => { describe('when visiting an unsupported component', () => { @@ -23,36 +25,37 @@ describe(ContentElementUpdateVisitor.name, () => { content.text = 'a text'; content.inputFormat = InputFormat.RICH_TEXT_CK5; const submissionItem = submissionItemFactory.build(); - const updater = new ContentElementUpdateVisitor(content); + const openGraphProxyService = new OpenGraphProxyService(); + const updater = new ContentElementUpdateVisitor(content, openGraphProxyService); return { board, column, card, submissionItem, updater }; }; describe('when component is a column board', () => { - it('should throw an error', () => { + it('should throw an error', async () => { const { board, updater } = setup(); - expect(() => updater.visitColumnBoard(board)).toThrow(); + await expect(updater.visitColumnBoardAsync(board)).rejects.toThrow(); }); }); describe('when component is a column', () => { - it('should throw an error', () => { + it('should throw an error', async () => { const { column, updater } = setup(); - expect(() => updater.visitColumn(column)).toThrow(); + await expect(() => updater.visitColumnAsync(column)).rejects.toThrow(); }); }); describe('when component is a card', () => { - it('should throw an error', () => { + it('should throw an error', async () => { const { card, updater } = setup(); - expect(() => updater.visitCard(card)).toThrow(); + await expect(() => updater.visitCardAsync(card)).rejects.toThrow(); }); }); describe('when component is a submission-item', () => { - it('should throw an error', () => { + it('should throw an error', async () => { const { submissionItem, updater } = setup(); - expect(() => updater.visitSubmissionItem(submissionItem)).toThrow(); + await expect(() => updater.visitSubmissionItemAsync(submissionItem)).rejects.toThrow(); }); }); }); @@ -63,15 +66,34 @@ describe(ContentElementUpdateVisitor.name, () => { const content = new RichTextContentBody(); content.text = 'a text'; content.inputFormat = InputFormat.RICH_TEXT_CK5; - const updater = new ContentElementUpdateVisitor(content); + const openGraphProxyService = new OpenGraphProxyService(); + const updater = new ContentElementUpdateVisitor(content, openGraphProxyService); return { fileElement, updater }; }; - it('should throw an error', () => { + it('should throw an error', async () => { const { fileElement, updater } = setup(); - expect(() => updater.visitFileElement(fileElement)).toThrow(); + await expect(() => updater.visitFileElementAsync(fileElement)).rejects.toThrow(); + }); + }); + + describe('when visiting a link element using the wrong content', () => { + const setup = () => { + const linkElement = linkElementFactory.build(); + const content = new FileContentBody(); + content.caption = 'a caption'; + const openGraphProxyService = new OpenGraphProxyService(); + const updater = new ContentElementUpdateVisitor(content, openGraphProxyService); + + return { linkElement, updater }; + }; + + it('should throw an error', async () => { + const { linkElement, updater } = setup(); + + await expect(() => updater.visitLinkElementAsync(linkElement)).rejects.toThrow(); }); }); @@ -80,15 +102,16 @@ describe(ContentElementUpdateVisitor.name, () => { const richTextElement = richTextElementFactory.build(); const content = new FileContentBody(); content.caption = 'a caption'; - const updater = new ContentElementUpdateVisitor(content); + const openGraphProxyService = new OpenGraphProxyService(); + const updater = new ContentElementUpdateVisitor(content, openGraphProxyService); return { richTextElement, updater }; }; - it('should throw an error', () => { + it('should throw an error', async () => { const { richTextElement, updater } = setup(); - expect(() => updater.visitRichTextElement(richTextElement)).toThrow(); + await expect(() => updater.visitRichTextElementAsync(richTextElement)).rejects.toThrow(); }); }); @@ -98,15 +121,16 @@ describe(ContentElementUpdateVisitor.name, () => { const content = new RichTextContentBody(); content.text = 'a text'; content.inputFormat = InputFormat.RICH_TEXT_CK5; - const updater = new ContentElementUpdateVisitor(content); + const openGraphProxyService = new OpenGraphProxyService(); + const updater = new ContentElementUpdateVisitor(content, openGraphProxyService); return { submissionContainerElement, updater }; }; - it('should throw an error', () => { + it('should throw an error', async () => { const { submissionContainerElement, updater } = setup(); - expect(() => updater.visitSubmissionContainerElement(submissionContainerElement)).toThrow(); + await expect(() => updater.visitSubmissionContainerElementAsync(submissionContainerElement)).rejects.toThrow(); }); }); @@ -116,15 +140,16 @@ describe(ContentElementUpdateVisitor.name, () => { const externalToolElement = externalToolElementFactory.build({ contextExternalToolId: undefined }); const content = new ExternalToolContentBody(); content.contextExternalToolId = new ObjectId().toHexString(); - const updater = new ContentElementUpdateVisitor(content); + const openGraphProxyService = new OpenGraphProxyService(); + const updater = new ContentElementUpdateVisitor(content, openGraphProxyService); return { externalToolElement, updater, content }; }; - it('should update the content', () => { + it('should update the content', async () => { const { externalToolElement, updater, content } = setup(); - updater.visitExternalToolElement(externalToolElement); + await updater.visitExternalToolElementAsync(externalToolElement); expect(externalToolElement.contextExternalToolId).toEqual(content.contextExternalToolId); }); @@ -136,15 +161,16 @@ describe(ContentElementUpdateVisitor.name, () => { const content = new RichTextContentBody(); content.text = 'a text'; content.inputFormat = InputFormat.RICH_TEXT_CK5; - const updater = new ContentElementUpdateVisitor(content); + const openGraphProxyService = new OpenGraphProxyService(); + const updater = new ContentElementUpdateVisitor(content, openGraphProxyService); return { externalToolElement, updater }; }; - it('should throw an error', () => { + it('should throw an error', async () => { const { externalToolElement, updater } = setup(); - expect(() => updater.visitExternalToolElement(externalToolElement)).toThrow(); + await expect(() => updater.visitExternalToolElementAsync(externalToolElement)).rejects.toThrow(); }); }); @@ -152,15 +178,16 @@ describe(ContentElementUpdateVisitor.name, () => { const setup = () => { const externalToolElement = externalToolElementFactory.build(); const content = new ExternalToolContentBody(); - const updater = new ContentElementUpdateVisitor(content); + const openGraphProxyService = new OpenGraphProxyService(); + const updater = new ContentElementUpdateVisitor(content, openGraphProxyService); return { externalToolElement, updater }; }; - it('should throw an error', () => { + it('should throw an error', async () => { const { externalToolElement, updater } = setup(); - expect(() => updater.visitExternalToolElement(externalToolElement)).toThrow(); + await expect(() => updater.visitExternalToolElementAsync(externalToolElement)).rejects.toThrow(); }); }); }); diff --git a/apps/server/src/modules/board/service/content-element-update.visitor.ts b/apps/server/src/modules/board/service/content-element-update.visitor.ts index d5d950890d6..0f75bcaee2d 100644 --- a/apps/server/src/modules/board/service/content-element-update.visitor.ts +++ b/apps/server/src/modules/board/service/content-element-update.visitor.ts @@ -1,7 +1,8 @@ +import { Injectable } from '@nestjs/common'; import { sanitizeRichText } from '@shared/controller'; import { AnyBoardDo, - BoardCompositeVisitor, + BoardCompositeVisitorAsync, Card, Column, ColumnBoard, @@ -12,74 +13,94 @@ import { SubmissionContainerElement, SubmissionItem, } from '@shared/domain'; +import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; import { AnyElementContentBody, ExternalToolContentBody, FileContentBody, + LinkContentBody, RichTextContentBody, SubmissionContainerContentBody, } from '../controller/dto'; +import { OpenGraphProxyService } from './open-graph-proxy.service'; -export class ContentElementUpdateVisitor implements BoardCompositeVisitor { +@Injectable() +export class ContentElementUpdateVisitor implements BoardCompositeVisitorAsync { private readonly content: AnyElementContentBody; - constructor(content: AnyElementContentBody) { + constructor(content: AnyElementContentBody, private readonly openGraphProxyService: OpenGraphProxyService) { this.content = content; } - visitColumnBoard(columnBoard: ColumnBoard): void { - this.throwNotHandled(columnBoard); + async visitColumnBoardAsync(columnBoard: ColumnBoard): Promise { + return this.rejectNotHandled(columnBoard); } - visitColumn(column: Column): void { - this.throwNotHandled(column); + async visitColumnAsync(column: Column): Promise { + return this.rejectNotHandled(column); } - visitCard(card: Card): void { - this.throwNotHandled(card); + async visitCardAsync(card: Card): Promise { + return this.rejectNotHandled(card); } - visitFileElement(fileElement: FileElement): void { + async visitFileElementAsync(fileElement: FileElement): Promise { if (this.content instanceof FileContentBody) { fileElement.caption = sanitizeRichText(this.content.caption, InputFormat.PLAIN_TEXT); fileElement.alternativeText = sanitizeRichText(this.content.alternativeText, InputFormat.PLAIN_TEXT); - } else { - this.throwNotHandled(fileElement); + return Promise.resolve(); } + return this.rejectNotHandled(fileElement); } - visitRichTextElement(richTextElement: RichTextElement): void { + async visitLinkElementAsync(linkElement: LinkElement): Promise { + if (this.content instanceof LinkContentBody) { + const urlWithProtocol = /:\/\//.test(this.content.url) ? this.content.url : `https://${this.content.url}`; + linkElement.url = new URL(urlWithProtocol).toString(); + const openGraphData = await this.openGraphProxyService.fetchOpenGraphData(linkElement.url); + linkElement.title = openGraphData.title; + linkElement.description = openGraphData.description; + if (openGraphData.image) { + linkElement.imageUrl = openGraphData.image.url; + } + return Promise.resolve(); + } + return this.rejectNotHandled(linkElement); + } + + async visitRichTextElementAsync(richTextElement: RichTextElement): Promise { if (this.content instanceof RichTextContentBody) { richTextElement.text = sanitizeRichText(this.content.text, this.content.inputFormat); richTextElement.inputFormat = this.content.inputFormat; - } else { - this.throwNotHandled(richTextElement); + return Promise.resolve(); } + return this.rejectNotHandled(richTextElement); } - visitSubmissionContainerElement(submissionContainerElement: SubmissionContainerElement): void { + async visitSubmissionContainerElementAsync(submissionContainerElement: SubmissionContainerElement): Promise { if (this.content instanceof SubmissionContainerContentBody) { - if (this.content.dueDate === undefined) return; - submissionContainerElement.dueDate = this.content.dueDate; - } else { - this.throwNotHandled(submissionContainerElement); + if (this.content.dueDate !== undefined) { + submissionContainerElement.dueDate = this.content.dueDate; + } + return Promise.resolve(); } + return this.rejectNotHandled(submissionContainerElement); } - visitSubmissionItem(submission: SubmissionItem): void { - this.throwNotHandled(submission); + async visitSubmissionItemAsync(submission: SubmissionItem): Promise { + return this.rejectNotHandled(submission); } - visitExternalToolElement(externalToolElement: ExternalToolElement): void { + async visitExternalToolElementAsync(externalToolElement: ExternalToolElement): Promise { if (this.content instanceof ExternalToolContentBody && this.content.contextExternalToolId !== undefined) { // Updates should not remove an existing reference to a tool, to prevent orphan tool instances externalToolElement.contextExternalToolId = this.content.contextExternalToolId; - } else { - this.throwNotHandled(externalToolElement); + return Promise.resolve(); } + return this.rejectNotHandled(externalToolElement); } - private throwNotHandled(component: AnyBoardDo) { - throw new Error(`Cannot update element of type: '${component.constructor.name}'`); + private rejectNotHandled(component: AnyBoardDo): Promise { + return Promise.reject(new Error(`Cannot update element of type: '${component.constructor.name}'`)); } } diff --git a/apps/server/src/modules/board/service/content-element.service.spec.ts b/apps/server/src/modules/board/service/content-element.service.spec.ts index 1d41925dfab..b1326450089 100644 --- a/apps/server/src/modules/board/service/content-element.service.spec.ts +++ b/apps/server/src/modules/board/service/content-element.service.spec.ts @@ -1,18 +1,32 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { ContentElementFactory, ContentElementType, FileElement, InputFormat, RichTextElement } from '@shared/domain'; +import { + ContentElementFactory, + ContentElementType, + FileElement, + InputFormat, + RichTextElement, + SubmissionContainerElement, +} from '@shared/domain'; import { setupEntities } from '@shared/testing'; import { cardFactory, fileElementFactory, + linkElementFactory, richTextElementFactory, submissionContainerElementFactory, } from '@shared/testing/factory/domainobject'; -import { FileContentBody, RichTextContentBody, SubmissionContainerContentBody } from '../controller/dto'; +import { + FileContentBody, + LinkContentBody, + RichTextContentBody, + SubmissionContainerContentBody, +} from '../controller/dto'; import { BoardDoRepo } from '../repo'; import { BoardDoService } from './board-do.service'; import { ContentElementService } from './content-element.service'; +import { OpenGraphProxyService } from './open-graph-proxy.service'; describe(ContentElementService.name, () => { let module: TestingModule; @@ -20,6 +34,7 @@ describe(ContentElementService.name, () => { let boardDoRepo: DeepMocked; let boardDoService: DeepMocked; let contentElementFactory: DeepMocked; + let openGraphProxyService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -37,6 +52,10 @@ describe(ContentElementService.name, () => { provide: ContentElementFactory, useValue: createMock(), }, + { + provide: OpenGraphProxyService, + useValue: createMock(), + }, ], }).compile(); @@ -44,6 +63,7 @@ describe(ContentElementService.name, () => { boardDoRepo = module.get(BoardDoRepo); boardDoService = module.get(BoardDoService); contentElementFactory = module.get(ContentElementFactory); + openGraphProxyService = module.get(OpenGraphProxyService); await setupEntities(); }); @@ -229,6 +249,44 @@ describe(ContentElementService.name, () => { }); }); + describe('when element is a link element', () => { + const setup = () => { + const linkElement = linkElementFactory.build(); + + const content = new LinkContentBody(); + content.url = 'https://www.medium.com/great-article'; + const card = cardFactory.build(); + boardDoRepo.findParentOfId.mockResolvedValue(card); + + const imageResponse = { + title: 'Webpage-title', + description: '', + url: linkElement.url, + image: { url: 'https://my-open-graph-proxy.scvs.de/image/adefcb12ed3a' }, + }; + + openGraphProxyService.fetchOpenGraphData.mockResolvedValueOnce(imageResponse); + + return { linkElement, content, card, imageResponse }; + }; + + it('should persist the element', async () => { + const { linkElement, content, card } = setup(); + + await service.update(linkElement, content); + + expect(boardDoRepo.save).toHaveBeenCalledWith(linkElement, card); + }); + + it('should call open graph service', async () => { + const { linkElement, content, card } = setup(); + + await service.update(linkElement, content); + + expect(boardDoRepo.save).toHaveBeenCalledWith(linkElement, card); + }); + }); + describe('when element is a submission container element', () => { const setup = () => { const submissionContainerElement = submissionContainerElementFactory.build(); @@ -245,17 +303,17 @@ describe(ContentElementService.name, () => { it('should update the element', async () => { const { submissionContainerElement, content } = setup(); - await service.update(submissionContainerElement, content); + const element = (await service.update(submissionContainerElement, content)) as SubmissionContainerElement; - expect(submissionContainerElement.dueDate).toEqual(content.dueDate); + expect(element.dueDate).toEqual(content.dueDate); }); it('should persist the element', async () => { const { submissionContainerElement, content, card } = setup(); - await service.update(submissionContainerElement, content); + const element = await service.update(submissionContainerElement, content); - expect(boardDoRepo.save).toHaveBeenCalledWith(submissionContainerElement, card); + expect(boardDoRepo.save).toHaveBeenCalledWith(element, card); }); }); }); diff --git a/apps/server/src/modules/board/service/content-element.service.ts b/apps/server/src/modules/board/service/content-element.service.ts index 2a55ff17a08..a7c957173f3 100644 --- a/apps/server/src/modules/board/service/content-element.service.ts +++ b/apps/server/src/modules/board/service/content-element.service.ts @@ -11,13 +11,15 @@ import { AnyElementContentBody } from '../controller/dto'; import { BoardDoRepo } from '../repo'; import { BoardDoService } from './board-do.service'; import { ContentElementUpdateVisitor } from './content-element-update.visitor'; +import { OpenGraphProxyService } from './open-graph-proxy.service'; @Injectable() export class ContentElementService { constructor( private readonly boardDoRepo: BoardDoRepo, private readonly boardDoService: BoardDoService, - private readonly contentElementFactory: ContentElementFactory + private readonly contentElementFactory: ContentElementFactory, + private readonly openGraphProxyService: OpenGraphProxyService ) {} async findById(elementId: EntityId): Promise { @@ -45,12 +47,14 @@ export class ContentElementService { await this.boardDoService.move(element, targetCard, targetPosition); } - async update(element: AnyContentElementDo, content: AnyElementContentBody): Promise { - const updater = new ContentElementUpdateVisitor(content); - element.accept(updater); + async update(element: AnyContentElementDo, content: AnyElementContentBody): Promise { + const updater = new ContentElementUpdateVisitor(content, this.openGraphProxyService); + await element.acceptAsync(updater); const parent = await this.boardDoRepo.findParentOfId(element.id); await this.boardDoRepo.save(element, parent); + + return element; } } diff --git a/apps/server/src/modules/board/service/index.ts b/apps/server/src/modules/board/service/index.ts index 8ff2787f35d..ac9c686d4b4 100644 --- a/apps/server/src/modules/board/service/index.ts +++ b/apps/server/src/modules/board/service/index.ts @@ -4,4 +4,5 @@ export * from './card.service'; export * from './column-board.service'; export * from './column.service'; export * from './content-element.service'; +export * from './open-graph-proxy.service'; export * from './submission-item.service'; diff --git a/apps/server/src/modules/board/service/open-graph-proxy.service.spec.ts b/apps/server/src/modules/board/service/open-graph-proxy.service.spec.ts new file mode 100644 index 00000000000..debe76cdeba --- /dev/null +++ b/apps/server/src/modules/board/service/open-graph-proxy.service.spec.ts @@ -0,0 +1,91 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { setupEntities } from '@shared/testing'; +import { ImageObject } from 'open-graph-scraper/dist/lib/types'; +import { OpenGraphProxyService } from './open-graph-proxy.service'; + +let ogsResponseMock = {}; +jest.mock( + 'open-graph-scraper', + () => () => + Promise.resolve({ + error: false, + html: '', + response: {}, + result: ogsResponseMock, + }) +); + +describe(OpenGraphProxyService.name, () => { + let module: TestingModule; + let service: OpenGraphProxyService; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [OpenGraphProxyService], + }).compile(); + + service = module.get(OpenGraphProxyService); + + await setupEntities(); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('create', () => { + it('should return also the original url', async () => { + const url = 'https://de.wikipedia.org'; + + const result = await service.fetchOpenGraphData(url); + + expect(result).toEqual(expect.objectContaining({ url })); + }); + + it('should thrown an error if url is an empty string', async () => { + const url = ''; + + await expect(service.fetchOpenGraphData(url)).rejects.toThrow(); + }); + + it('should return ogTitle as title', async () => { + const ogTitle = 'My Title'; + const url = 'https://de.wikipedia.org'; + ogsResponseMock = { ogTitle }; + + const result = await service.fetchOpenGraphData(url); + + expect(result).toEqual(expect.objectContaining({ title: ogTitle })); + }); + + it('should return ogImage as title', async () => { + const ogImage: ImageObject[] = [ + { + width: 800, + type: 'jpeg', + url: 'big-image.jpg', + }, + { + width: 500, + type: 'jpeg', + url: 'medium-image.jpg', + }, + { + width: 300, + type: 'jpeg', + url: 'small-image.jpg', + }, + ]; + const url = 'https://de.wikipedia.org'; + ogsResponseMock = { ogImage }; + + const result = await service.fetchOpenGraphData(url); + + expect(result).toEqual(expect.objectContaining({ image: ogImage[1] })); + }); + }); +}); diff --git a/apps/server/src/modules/board/service/open-graph-proxy.service.ts b/apps/server/src/modules/board/service/open-graph-proxy.service.ts new file mode 100644 index 00000000000..2b54d75ee82 --- /dev/null +++ b/apps/server/src/modules/board/service/open-graph-proxy.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import ogs from 'open-graph-scraper'; +import { ImageObject } from 'open-graph-scraper/dist/lib/types'; + +type OpenGraphData = { + title: string; + description: string; + url: string; + image?: ImageObject; +}; + +@Injectable() +export class OpenGraphProxyService { + async fetchOpenGraphData(url: string): Promise { + if (url.length === 0) { + throw new Error(`OpenGraphProxyService requires a valid URL. Given URL: ${url}`); + } + + const data = await ogs({ url }); + // WIP: add nice debug logging for available openGraphData?!? + + const title = data.result.ogTitle ?? ''; + const description = data.result.ogDescription ?? ''; + const image = data.result.ogImage ? this.pickImage(data.result.ogImage) : undefined; + + return { + title, + description, + image, + url, + }; + } + + private pickImage(images: ImageObject[], minWidth = 400): ImageObject | undefined { + const sortedImages = [...images]; + sortedImages.sort((a, b) => (a.width && b.width ? Number(a.width) - Number(b.width) : 0)); + const smallestBigEnoughImage = sortedImages.find((i) => i.width && i.width >= minWidth); + const fallbackImage = images[0] && images[0].width === undefined ? images[0] : undefined; + return smallestBigEnoughImage ?? fallbackImage; + } +} diff --git a/apps/server/src/modules/board/uc/element.uc.ts b/apps/server/src/modules/board/uc/element.uc.ts index b66ce1bd247..e5dc039168c 100644 --- a/apps/server/src/modules/board/uc/element.uc.ts +++ b/apps/server/src/modules/board/uc/element.uc.ts @@ -28,10 +28,12 @@ export class ElementUc { } async updateElementContent(userId: EntityId, elementId: EntityId, content: AnyElementContentBody) { - const element = await this.elementService.findById(elementId); + let element = await this.elementService.findById(elementId); await this.checkPermission(userId, element, Action.write); - await this.elementService.update(element, content); + + element = await this.elementService.update(element, content); + return element; } async createSubmissionItem( diff --git a/apps/server/src/modules/copy-helper/types/copy.types.ts b/apps/server/src/modules/copy-helper/types/copy.types.ts index 77f0f80e4cc..fff1f0da795 100644 --- a/apps/server/src/modules/copy-helper/types/copy.types.ts +++ b/apps/server/src/modules/copy-helper/types/copy.types.ts @@ -34,6 +34,7 @@ export enum CopyElementType { 'LESSON_CONTENT_TEXT' = 'LESSON_CONTENT_TEXT', 'LERNSTORE_MATERIAL' = 'LERNSTORE_MATERIAL', 'LERNSTORE_MATERIAL_GROUP' = 'LERNSTORE_MATERIAL_GROUP', + 'LINK_ELEMENT' = 'LINK_ELEMENT', 'LTITOOL_GROUP' = 'LTITOOL_GROUP', 'METADATA' = 'METADATA', 'RICHTEXT_ELEMENT' = 'RICHTEXT_ELEMENT', diff --git a/apps/server/src/shared/domain/domainobject/board/card.do.ts b/apps/server/src/shared/domain/domainobject/board/card.do.ts index 652d30ff027..62931e418dd 100644 --- a/apps/server/src/shared/domain/domainobject/board/card.do.ts +++ b/apps/server/src/shared/domain/domainobject/board/card.do.ts @@ -1,6 +1,7 @@ import { BoardComposite, BoardCompositeProps } from './board-composite.do'; import { ExternalToolElement } from './external-tool-element.do'; import { FileElement } from './file-element.do'; +import { LinkElement } from './link-element.do'; import { RichTextElement } from './rich-text-element.do'; import { SubmissionContainerElement } from './submission-container-element.do'; import type { AnyBoardDo, BoardCompositeVisitor, BoardCompositeVisitorAsync } from './types'; @@ -25,6 +26,7 @@ export class Card extends BoardComposite { isAllowedAsChild(domainObject: AnyBoardDo): boolean { const allowed = domainObject instanceof FileElement || + domainObject instanceof LinkElement || domainObject instanceof RichTextElement || domainObject instanceof SubmissionContainerElement || domainObject instanceof ExternalToolElement; diff --git a/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts b/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts index ff8966aa77f..8c34ca54b56 100644 --- a/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts +++ b/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts @@ -3,6 +3,7 @@ import { InputFormat } from '@shared/domain/types'; import { ObjectId } from 'bson'; import { ExternalToolElement } from './external-tool-element.do'; import { FileElement } from './file-element.do'; +import { LinkElement } from './link-element.do'; import { RichTextElement } from './rich-text-element.do'; import { SubmissionContainerElement } from './submission-container-element.do'; import { AnyContentElementDo, ContentElementType } from './types'; @@ -16,6 +17,9 @@ export class ContentElementFactory { case ContentElementType.FILE: element = this.buildFile(); break; + case ContentElementType.LINK: + element = this.buildLink(); + break; case ContentElementType.RICH_TEXT: element = this.buildRichText(); break; @@ -49,6 +53,18 @@ export class ContentElementFactory { return element; } + private buildLink() { + const element = new LinkElement({ + id: new ObjectId().toHexString(), + url: '', + title: '', + createdAt: new Date(), + updatedAt: new Date(), + }); + + return element; + } + private buildRichText() { const element = new RichTextElement({ id: new ObjectId().toHexString(), diff --git a/apps/server/src/shared/domain/domainobject/board/index.ts b/apps/server/src/shared/domain/domainobject/board/index.ts index 86f4d2639c3..9701ba40099 100644 --- a/apps/server/src/shared/domain/domainobject/board/index.ts +++ b/apps/server/src/shared/domain/domainobject/board/index.ts @@ -3,10 +3,11 @@ export * from './card.do'; export * from './column-board.do'; export * from './column.do'; export * from './content-element.factory'; +export * from './external-tool-element.do'; export * from './file-element.do'; +export * from './link-element.do'; export * from './rich-text-element.do'; export * from './submission-container-element.do'; export * from './submission-item.do'; export * from './submission-item.factory'; -export * from './external-tool-element.do'; export * from './types'; diff --git a/apps/server/src/shared/domain/domainobject/board/link-element.do.spec.ts b/apps/server/src/shared/domain/domainobject/board/link-element.do.spec.ts new file mode 100644 index 00000000000..4a044e9be58 --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/board/link-element.do.spec.ts @@ -0,0 +1,37 @@ +import { createMock } from '@golevelup/ts-jest'; +import { linkElementFactory } from '@shared/testing'; +import { LinkElement } from './link-element.do'; +import { BoardCompositeVisitor, BoardCompositeVisitorAsync } from './types'; + +describe(LinkElement.name, () => { + describe('when trying to add a child to a link element', () => { + it('should throw an error ', () => { + const linkElement = linkElementFactory.build(); + const linkElementFactoryChild = linkElementFactory.build(); + + expect(() => linkElement.addChild(linkElementFactoryChild)).toThrow(); + }); + }); + + describe('accept', () => { + it('should call the right visitor method', () => { + const visitor = createMock(); + const linkElement = linkElementFactory.build(); + + linkElement.accept(visitor); + + expect(visitor.visitLinkElement).toHaveBeenCalledWith(linkElement); + }); + }); + + describe('acceptAsync', () => { + it('should call the right async visitor method', async () => { + const visitor = createMock(); + const linkElement = linkElementFactory.build(); + + await linkElement.acceptAsync(visitor); + + expect(visitor.visitLinkElementAsync).toHaveBeenCalledWith(linkElement); + }); + }); +}); diff --git a/apps/server/src/shared/domain/domainobject/board/link-element.do.ts b/apps/server/src/shared/domain/domainobject/board/link-element.do.ts new file mode 100644 index 00000000000..7b38cbd938e --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/board/link-element.do.ts @@ -0,0 +1,59 @@ +import { BoardComposite, BoardCompositeProps } from './board-composite.do'; +import type { BoardCompositeVisitor, BoardCompositeVisitorAsync } from './types'; + +export class LinkElement extends BoardComposite { + get url(): string { + return this.props.url ?? ''; + } + + set url(value: string) { + this.props.url = value; + } + + get title(): string { + return this.props.title ?? ''; + } + + set title(value: string) { + this.props.title = value; + } + + get description(): string { + return this.props.description ?? ''; + } + + set description(value: string) { + this.props.description = value ?? ''; + } + + get imageUrl(): string { + return this.props.imageUrl ?? ''; + } + + set imageUrl(value: string) { + this.props.imageUrl = value; + } + + isAllowedAsChild(): boolean { + return false; + } + + accept(visitor: BoardCompositeVisitor): void { + visitor.visitLinkElement(this); + } + + async acceptAsync(visitor: BoardCompositeVisitorAsync): Promise { + await visitor.visitLinkElementAsync(this); + } +} + +export interface LinkElementProps extends BoardCompositeProps { + url: string; + title: string; + description?: string; + imageUrl?: string; +} + +export function isLinkElement(reference: unknown): reference is LinkElement { + return reference instanceof LinkElement; +} diff --git a/apps/server/src/shared/domain/domainobject/board/types/any-content-element-do.ts b/apps/server/src/shared/domain/domainobject/board/types/any-content-element-do.ts index d6ccfbd56ca..614071e658c 100644 --- a/apps/server/src/shared/domain/domainobject/board/types/any-content-element-do.ts +++ b/apps/server/src/shared/domain/domainobject/board/types/any-content-element-do.ts @@ -1,17 +1,24 @@ import { ExternalToolElement } from '../external-tool-element.do'; import { FileElement } from '../file-element.do'; +import { LinkElement } from '../link-element.do'; import { RichTextElement } from '../rich-text-element.do'; import { SubmissionContainerElement } from '../submission-container-element.do'; import type { AnyBoardDo } from './any-board-do'; -export type AnyContentElementDo = FileElement | RichTextElement | SubmissionContainerElement | ExternalToolElement; +export type AnyContentElementDo = + | ExternalToolElement + | FileElement + | LinkElement + | RichTextElement + | SubmissionContainerElement; export const isAnyContentElement = (element: AnyBoardDo): element is AnyContentElementDo => { const result = + element instanceof ExternalToolElement || element instanceof FileElement || + element instanceof LinkElement || element instanceof RichTextElement || - element instanceof SubmissionContainerElement || - element instanceof ExternalToolElement; + element instanceof SubmissionContainerElement; return result; }; diff --git a/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts b/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts index 38e16fc8e5f..3fbd4abdd96 100644 --- a/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts +++ b/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts @@ -1,17 +1,19 @@ import type { Card } from '../card.do'; import type { ColumnBoard } from '../column-board.do'; import type { Column } from '../column.do'; -import { ExternalToolElement } from '../external-tool-element.do'; +import type { ExternalToolElement } from '../external-tool-element.do'; import type { FileElement } from '../file-element.do'; -import { RichTextElement } from '../rich-text-element.do'; -import { SubmissionContainerElement } from '../submission-container-element.do'; -import { SubmissionItem } from '../submission-item.do'; +import type { LinkElement } from '../link-element.do'; +import type { RichTextElement } from '../rich-text-element.do'; +import type { SubmissionContainerElement } from '../submission-container-element.do'; +import type { SubmissionItem } from '../submission-item.do'; export interface BoardCompositeVisitor { visitColumnBoard(columnBoard: ColumnBoard): void; visitColumn(column: Column): void; visitCard(card: Card): void; visitFileElement(fileElement: FileElement): void; + visitLinkElement(linkElement: LinkElement): void; visitRichTextElement(richTextElement: RichTextElement): void; visitSubmissionContainerElement(submissionContainerElement: SubmissionContainerElement): void; visitSubmissionItem(submissionItem: SubmissionItem): void; @@ -23,6 +25,7 @@ export interface BoardCompositeVisitorAsync { visitColumnAsync(column: Column): Promise; visitCardAsync(card: Card): Promise; visitFileElementAsync(fileElement: FileElement): Promise; + visitLinkElementAsync(linkElement: LinkElement): Promise; visitRichTextElementAsync(richTextElement: RichTextElement): Promise; visitSubmissionContainerElementAsync(submissionContainerElement: SubmissionContainerElement): Promise; visitSubmissionItemAsync(submissionItem: SubmissionItem): Promise; diff --git a/apps/server/src/shared/domain/domainobject/board/types/content-elements.enum.ts b/apps/server/src/shared/domain/domainobject/board/types/content-elements.enum.ts index 4c6ce7269bd..b8d4e166e25 100644 --- a/apps/server/src/shared/domain/domainobject/board/types/content-elements.enum.ts +++ b/apps/server/src/shared/domain/domainobject/board/types/content-elements.enum.ts @@ -1,5 +1,6 @@ export enum ContentElementType { FILE = 'file', + LINK = 'link', RICH_TEXT = 'richText', SUBMISSION_CONTAINER = 'submissionContainer', EXTERNAL_TOOL = 'externalTool', diff --git a/apps/server/src/shared/domain/entity/all-entities.ts b/apps/server/src/shared/domain/entity/all-entities.ts index 406fda13bf4..6cbb3bf9810 100644 --- a/apps/server/src/shared/domain/entity/all-entities.ts +++ b/apps/server/src/shared/domain/entity/all-entities.ts @@ -13,6 +13,7 @@ import { ColumnNode, ExternalToolElementNodeEntity, FileElementNode, + LinkElementNode, RichTextElementNode, SubmissionContainerElementNode, SubmissionItemNode, @@ -58,6 +59,7 @@ export const ALL_ENTITIES = [ ColumnNode, ClassEntity, FileElementNode, + LinkElementNode, RichTextElementNode, SubmissionContainerElementNode, SubmissionItemNode, diff --git a/apps/server/src/shared/domain/entity/boardnode/index.ts b/apps/server/src/shared/domain/entity/boardnode/index.ts index cd7cc9d65be..a3a56e6dfe0 100644 --- a/apps/server/src/shared/domain/entity/boardnode/index.ts +++ b/apps/server/src/shared/domain/entity/boardnode/index.ts @@ -2,9 +2,10 @@ export * from './boardnode.entity'; export * from './card-node.entity'; export * from './column-board-node.entity'; export * from './column-node.entity'; +export * from './external-tool-element-node.entity'; export * from './file-element-node.entity'; +export * from './link-element-node.entity'; export * from './rich-text-element-node.entity'; export * from './submission-container-element-node.entity'; export * from './submission-item-node.entity'; -export * from './external-tool-element-node.entity'; export * from './types'; diff --git a/apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.spec.ts b/apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.spec.ts new file mode 100644 index 00000000000..1093e57922e --- /dev/null +++ b/apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.spec.ts @@ -0,0 +1,54 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { linkElementFactory } from '@shared/testing'; +import { LinkElementNode } from './link-element-node.entity'; +import { BoardDoBuilder, BoardNodeType } from './types'; + +describe(LinkElementNode.name, () => { + describe('when trying to create a link element', () => { + const setup = () => { + const elementProps = { url: 'https://www.any-fake.url/that-is-linked.html', title: 'A Great WebPage' }; + const builder: DeepMocked = createMock(); + + return { elementProps, builder }; + }; + + it('should create a LinkElementNode', () => { + const { elementProps } = setup(); + + const element = new LinkElementNode(elementProps); + + expect(element.type).toEqual(BoardNodeType.LINK_ELEMENT); + }); + }); + + describe('useDoBuilder()', () => { + const setup = () => { + const element = new LinkElementNode({ + url: 'https://www.any-fake.url/that-is-linked.html', + title: 'A Great WebPage', + }); + const builder: DeepMocked = createMock(); + const elementDo = linkElementFactory.build(); + + builder.buildLinkElement.mockReturnValue(elementDo); + + return { element, builder, elementDo }; + }; + + it('should call the specific builder method', () => { + const { element, builder } = setup(); + + element.useDoBuilder(builder); + + expect(builder.buildLinkElement).toHaveBeenCalledWith(element); + }); + + it('should return RichTextElementDo', () => { + const { element, builder, elementDo } = setup(); + + const result = element.useDoBuilder(builder); + + expect(result).toEqual(elementDo); + }); + }); +}); diff --git a/apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.ts b/apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.ts new file mode 100644 index 00000000000..0102821d97b --- /dev/null +++ b/apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.ts @@ -0,0 +1,36 @@ +import { Entity, Property } from '@mikro-orm/core'; +import { AnyBoardDo } from '../../domainobject'; +import { BoardNode, BoardNodeProps } from './boardnode.entity'; +import { BoardDoBuilder, BoardNodeType } from './types'; + +@Entity({ discriminatorValue: BoardNodeType.LINK_ELEMENT }) +export class LinkElementNode extends BoardNode { + @Property() + url: string; + + @Property() + title: string; + + @Property() + imageUrl?: string; + + constructor(props: LinkElementNodeProps) { + super(props); + this.type = BoardNodeType.LINK_ELEMENT; + this.url = props.url; + this.title = props.title; + this.imageUrl = props.imageUrl; + } + + useDoBuilder(builder: BoardDoBuilder): AnyBoardDo { + const domainObject = builder.buildLinkElement(this); + + return domainObject; + } +} + +export interface LinkElementNodeProps extends BoardNodeProps { + url: string; + title: string; + imageUrl?: string; +} diff --git a/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts b/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts index a5c2a8b2e16..1b759a41180 100644 --- a/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts +++ b/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts @@ -1,18 +1,20 @@ -import { SubmissionItem } from '@shared/domain/domainobject/board/submission-item.do'; import type { Card, Column, ColumnBoard, ExternalToolElement, FileElement, + LinkElement, RichTextElement, SubmissionContainerElement, + SubmissionItem, } from '../../../domainobject'; import type { CardNode } from '../card-node.entity'; import type { ColumnBoardNode } from '../column-board-node.entity'; import type { ColumnNode } from '../column-node.entity'; import type { ExternalToolElementNodeEntity } from '../external-tool-element-node.entity'; import type { FileElementNode } from '../file-element-node.entity'; +import type { LinkElementNode } from '../link-element-node.entity'; import type { RichTextElementNode } from '../rich-text-element-node.entity'; import type { SubmissionContainerElementNode } from '../submission-container-element-node.entity'; import type { SubmissionItemNode } from '../submission-item-node.entity'; @@ -22,6 +24,7 @@ export interface BoardDoBuilder { buildColumn(boardNode: ColumnNode): Column; buildCard(boardNode: CardNode): Card; buildFileElement(boardNode: FileElementNode): FileElement; + buildLinkElement(boardNode: LinkElementNode): LinkElement; buildRichTextElement(boardNode: RichTextElementNode): RichTextElement; buildSubmissionContainerElement(boardNode: SubmissionContainerElementNode): SubmissionContainerElement; buildSubmissionItem(boardNode: SubmissionItemNode): SubmissionItem; diff --git a/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts b/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts index a1b44207907..0b25a81b053 100644 --- a/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts +++ b/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts @@ -3,6 +3,7 @@ export enum BoardNodeType { COLUMN = 'column', CARD = 'card', FILE_ELEMENT = 'file-element', + LINK_ELEMENT = 'link-element', RICH_TEXT_ELEMENT = 'rich-text-element', SUBMISSION_CONTAINER_ELEMENT = 'submission-container-element', SUBMISSION_ITEM = 'submission-item', diff --git a/apps/server/src/shared/testing/factory/boardnode/index.ts b/apps/server/src/shared/testing/factory/boardnode/index.ts index 410a399ccff..14ae5c29312 100644 --- a/apps/server/src/shared/testing/factory/boardnode/index.ts +++ b/apps/server/src/shared/testing/factory/boardnode/index.ts @@ -1,8 +1,9 @@ export * from './card-node.factory'; export * from './column-board-node.factory'; export * from './column-node.factory'; +export * from './external-tool-element-node.factory'; export * from './file-element-node.factory'; +export * from './link-element-node.factory'; export * from './rich-text-element-node.factory'; export * from './submission-container-element-node.factory'; export * from './submission-item-node.factory'; -export * from './external-tool-element-node.factory'; diff --git a/apps/server/src/shared/testing/factory/boardnode/link-element-node.factory.ts b/apps/server/src/shared/testing/factory/boardnode/link-element-node.factory.ts new file mode 100644 index 00000000000..1725634705f --- /dev/null +++ b/apps/server/src/shared/testing/factory/boardnode/link-element-node.factory.ts @@ -0,0 +1,14 @@ +/* istanbul ignore file */ +import { LinkElementNode, LinkElementNodeProps } from '@shared/domain'; +import { BaseFactory } from '../base.factory'; + +export const linkElementNodeFactory = BaseFactory.define( + LinkElementNode, + ({ sequence }) => { + const url = `https://www.example.com/link/${sequence}`; + return { + url, + title: `The example page ${sequence}`, + }; + } +); diff --git a/apps/server/src/shared/testing/factory/domainobject/board/index.ts b/apps/server/src/shared/testing/factory/domainobject/board/index.ts index e7b3bae56ed..9a6cdf84839 100644 --- a/apps/server/src/shared/testing/factory/domainobject/board/index.ts +++ b/apps/server/src/shared/testing/factory/domainobject/board/index.ts @@ -1,8 +1,9 @@ export * from './card.do.factory'; export * from './column-board.do.factory'; export * from './column.do.factory'; +export * from './external-tool.do.factory'; export * from './file-element.do.factory'; +export * from './link-element.do.factory'; export * from './rich-text-element.do.factory'; export * from './submission-container-element.do.factory'; export * from './submission-item.do.factory'; -export * from './external-tool.do.factory'; diff --git a/apps/server/src/shared/testing/factory/domainobject/board/link-element.do.factory.ts b/apps/server/src/shared/testing/factory/domainobject/board/link-element.do.factory.ts new file mode 100644 index 00000000000..af0e55a1912 --- /dev/null +++ b/apps/server/src/shared/testing/factory/domainobject/board/link-element.do.factory.ts @@ -0,0 +1,15 @@ +/* istanbul ignore file */ +import { LinkElement, LinkElementProps } from '@shared/domain'; +import { ObjectId } from 'bson'; +import { BaseFactory } from '../../base.factory'; + +export const linkElementFactory = BaseFactory.define(LinkElement, ({ sequence }) => { + return { + id: new ObjectId().toHexString(), + url: `https://www.example.com/link/${sequence}`, + title: 'Website opengraph title', + children: [], + createdAt: new Date(), + updatedAt: new Date(), + }; +}); diff --git a/config/default.schema.json b/config/default.schema.json index 9103cfd7d4f..e6084e5c331 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1050,6 +1050,11 @@ "default": false, "description": "Enable submissions in column board." }, + "FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED": { + "type": "boolean", + "default": false, + "description": "Enable link elements in column board." + }, "COLUMN_BOARD_HELP_LINK": { "type": "string", "default": "https://docs.dbildungscloud.de/pages/viewpage.action?pageId=270827606", diff --git a/config/development.json b/config/development.json index a2b8ba524a9..43d1b18640f 100644 --- a/config/development.json +++ b/config/development.json @@ -68,5 +68,6 @@ "FEATURE_COURSE_SHARE": true, "FEATURE_COLUMN_BOARD_ENABLED": true, "FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED": true, + "FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED": true, "FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED": true } diff --git a/package-lock.json b/package-lock.json index 92a97849e7e..dde18164a6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -99,6 +99,7 @@ "nest-winston": "^1.9.4", "nestjs-console": "^9.0.0", "oauth-1.0a": "^2.2.6", + "open-graph-scraper": "^6.2.2", "p-limit": "^3.1.0", "papaparse": "^5.1.1", "passport": "^0.6.0", @@ -7427,6 +7428,11 @@ "node": ">= 0.8" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + }, "node_modules/bowser": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", @@ -7902,6 +7908,162 @@ "node": "*" } }, + "node_modules/cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + }, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cheerio-select/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/cheerio-select/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/cheerio-select/node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/cheerio-select/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -8882,6 +9044,83 @@ "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" }, + "node_modules/css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-select/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/css-select/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/css-select/node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/css-select/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/daemon": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/daemon/-/daemon-1.1.0.tgz", @@ -9154,9 +9393,9 @@ } }, "node_modules/domelementtype": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz", - "integrity": "sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", "funding": [ { "type": "github", @@ -18029,6 +18268,17 @@ "gauge": "~1.2.0" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/number-allocator": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.9.tgz", @@ -18452,6 +18702,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open-graph-scraper": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/open-graph-scraper/-/open-graph-scraper-6.2.2.tgz", + "integrity": "sha512-cQO0c0HF9ZMhSoIEOKMyxbSYwKn6qWBDEdQeCvZnAVwKCxSWj2DV8AwC1J4JCiwZbn/C4grGCJXpvmlAyTXrBg==", + "dependencies": { + "chardet": "^1.6.0", + "cheerio": "^1.0.0-rc.12", + "undici": "^5.22.1", + "validator": "^13.9.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/open-graph-scraper/node_modules/chardet": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-1.6.0.tgz", + "integrity": "sha512-+QOTw3otC4+FxdjK9RopGpNOglADbr4WPFi0SonkO99JbpkTPbMxmdm4NenhF5Zs+4gPXLI1+y2uazws5TMe8w==" + }, "node_modules/optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -18769,6 +19038,54 @@ "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", "integrity": "sha1-8r0iH2zJcKk42IVWq8WJyqqiveE=" }, + "node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", + "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", + "dependencies": { + "domhandler": "^5.0.2", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -23512,6 +23829,17 @@ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==" }, + "node_modules/undici": { + "version": "5.25.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.25.2.tgz", + "integrity": "sha512-tch8RbCfn1UUH1PeVCXva4V8gDpGAud/w0WubD6sHC46vYQ3KDxL+xv1A2UxK0N6jrVedutuPHxe1XIoqerwMw==", + "dependencies": { + "busboy": "^1.6.0" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/universal-analytics": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/universal-analytics/-/universal-analytics-0.5.3.tgz", @@ -23726,9 +24054,9 @@ } }, "node_modules/validator": { - "version": "13.7.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz", - "integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==", + "version": "13.11.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz", + "integrity": "sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==", "engines": { "node": ">= 0.10" } @@ -30064,6 +30392,11 @@ } } }, + "boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + }, "bowser": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", @@ -30431,6 +30764,114 @@ "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", "dev": true }, + "cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "requires": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + }, + "dependencies": { + "dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + } + }, + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "requires": { + "domelementtype": "^2.3.0" + } + }, + "domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "requires": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + } + }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + }, + "htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + } + } + }, + "cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "requires": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "dependencies": { + "dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + } + }, + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "requires": { + "domelementtype": "^2.3.0" + } + }, + "domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "requires": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + } + }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + } + } + }, "chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -31229,6 +31670,58 @@ "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" }, + "css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "requires": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "dependencies": { + "dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + } + }, + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "requires": { + "domelementtype": "^2.3.0" + } + }, + "domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "requires": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + } + }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + } + } + }, + "css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==" + }, "daemon": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/daemon/-/daemon-1.1.0.tgz", @@ -31427,9 +31920,9 @@ } }, "domelementtype": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz", - "integrity": "sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==" + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" }, "domhandler": { "version": "4.3.0", @@ -38138,6 +38631,14 @@ "gauge": "~1.2.0" } }, + "nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "requires": { + "boolbase": "^1.0.0" + } + }, "number-allocator": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.9.tgz", @@ -38464,6 +38965,24 @@ "is-wsl": "^2.2.0" } }, + "open-graph-scraper": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/open-graph-scraper/-/open-graph-scraper-6.2.2.tgz", + "integrity": "sha512-cQO0c0HF9ZMhSoIEOKMyxbSYwKn6qWBDEdQeCvZnAVwKCxSWj2DV8AwC1J4JCiwZbn/C4grGCJXpvmlAyTXrBg==", + "requires": { + "chardet": "^1.6.0", + "cheerio": "^1.0.0-rc.12", + "undici": "^5.22.1", + "validator": "^13.9.0" + }, + "dependencies": { + "chardet": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-1.6.0.tgz", + "integrity": "sha512-+QOTw3otC4+FxdjK9RopGpNOglADbr4WPFi0SonkO99JbpkTPbMxmdm4NenhF5Zs+4gPXLI1+y2uazws5TMe8w==" + } + } + }, "optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -38696,6 +39215,40 @@ "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", "integrity": "sha1-8r0iH2zJcKk42IVWq8WJyqqiveE=" }, + "parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "requires": { + "entities": "^4.4.0" + }, + "dependencies": { + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + } + } + }, + "parse5-htmlparser2-tree-adapter": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", + "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", + "requires": { + "domhandler": "^5.0.2", + "parse5": "^7.0.0" + }, + "dependencies": { + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "requires": { + "domelementtype": "^2.3.0" + } + } + } + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -42272,6 +42825,14 @@ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==" }, + "undici": { + "version": "5.25.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.25.2.tgz", + "integrity": "sha512-tch8RbCfn1UUH1PeVCXva4V8gDpGAud/w0WubD6sHC46vYQ3KDxL+xv1A2UxK0N6jrVedutuPHxe1XIoqerwMw==", + "requires": { + "busboy": "^1.6.0" + } + }, "universal-analytics": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/universal-analytics/-/universal-analytics-0.5.3.tgz", @@ -42462,9 +43023,9 @@ } }, "validator": { - "version": "13.7.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz", - "integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==" + "version": "13.11.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz", + "integrity": "sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==" }, "vary": { "version": "1.1.2", diff --git a/package.json b/package.json index 9a8c78c9377..97cb84c069e 100644 --- a/package.json +++ b/package.json @@ -181,6 +181,7 @@ "nest-winston": "^1.9.4", "nestjs-console": "^9.0.0", "oauth-1.0a": "^2.2.6", + "open-graph-scraper": "^6.2.2", "p-limit": "^3.1.0", "papaparse": "^5.1.1", "passport": "^0.6.0", diff --git a/src/services/config/publicAppConfigService.js b/src/services/config/publicAppConfigService.js index 31a3ae22224..06a54c6cf96 100644 --- a/src/services/config/publicAppConfigService.js +++ b/src/services/config/publicAppConfigService.js @@ -39,6 +39,7 @@ const exposedVars = [ 'SC_TITLE', 'FEATURE_COLUMN_BOARD_ENABLED', 'FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED', + 'FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED', 'FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED', 'FEATURE_COURSE_SHARE', 'FEATURE_COURSE_SHARE_NEW', From 43d02f81a6e832e2df394f1f3f54e932fa6f1a78 Mon Sep 17 00:00:00 2001 From: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> Date: Thu, 12 Oct 2023 12:22:26 +0200 Subject: [PATCH 10/10] BC-5048 - better integration of virusscan (#4421) - add stream to antivirus for previewable types only - add feature flag - fix bug by zero byte file upload --- .../files-storage-copy-files.api.spec.ts | 2 +- .../files-storage-delete-files.api.spec.ts | 2 +- .../files-storage-download-upload.api.spec.ts | 2 +- .../files-storage-preview.api.spec.ts | 2 +- .../files-storage-restore-files.api.spec.ts | 2 +- .../controller/dto/file-storage.params.ts | 3 +- .../files-storage/entity/filerecord.entity.ts | 8 +- .../files-storage/files-storage.config.ts | 4 +- .../files-storage/files-storage.module.ts | 2 + .../files-storage-copy.service.spec.ts | 2 +- .../files-storage-delete.service.spec.ts | 2 +- .../files-storage-download.service.spec.ts | 2 +- .../service/files-storage-get.service.spec.ts | 2 +- .../files-storage-restore.service.spec.ts | 2 +- .../files-storage-update.service.spec.ts | 2 +- .../files-storage-upload.service.spec.ts | 49 ++++++-- .../service/files-storage.service.ts | 25 ++++- .../uc/files-storage-copy.uc.spec.ts | 2 +- .../uc/files-storage-delete.uc.spec.ts | 2 +- .../files-storage-download-preview.uc.spec.ts | 2 +- .../uc/files-storage-download.uc.spec.ts | 2 +- .../uc/files-storage-get.uc.spec.ts | 2 +- .../uc/files-storage-restore.uc.spec.ts | 2 +- .../uc/files-storage-update.uc.spec.ts | 2 +- .../uc/files-storage-upload.uc.spec.ts | 2 +- .../infra/antivirus/antivirus.module.spec.ts | 2 + .../infra/antivirus/antivirus.module.ts | 28 +++-- .../infra/antivirus/antivirus.service.spec.ts | 105 ++++++++++++++++++ .../infra/antivirus/antivirus.service.ts | 40 +++++-- .../src/shared/infra/antivirus/index.ts | 3 + .../infra/antivirus/interfaces/antivirus.ts | 21 ++++ .../infra/antivirus/interfaces/index.ts | 1 + config/default.schema.json | 24 ++++ package-lock.json | 55 +++++++++ package.json | 2 + 35 files changed, 356 insertions(+), 54 deletions(-) create mode 100644 apps/server/src/shared/infra/antivirus/index.ts create mode 100644 apps/server/src/shared/infra/antivirus/interfaces/antivirus.ts create mode 100644 apps/server/src/shared/infra/antivirus/interfaces/index.ts diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-storage-copy-files.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-storage-copy-files.api.spec.ts index a0b0c63a8a6..659197c73d9 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-storage-copy-files.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage-copy-files.api.spec.ts @@ -4,7 +4,7 @@ import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; import { EntityId, Permission } from '@shared/domain'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { cleanupCollections, diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-storage-delete-files.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-storage-delete-files.api.spec.ts index de360aa60f9..e63bc036510 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-storage-delete-files.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage-delete-files.api.spec.ts @@ -4,7 +4,7 @@ import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; import { EntityId, Permission } from '@shared/domain'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { cleanupCollections, diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-storage-download-upload.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-storage-download-upload.api.spec.ts index 9ec672dfd36..de8cda56198 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-storage-download-upload.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage-download-upload.api.spec.ts @@ -4,7 +4,7 @@ import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; import { EntityId, Permission } from '@shared/domain'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { cleanupCollections, mapUserToCurrentUser, roleFactory, schoolFactory, userFactory } from '@shared/testing'; import { ICurrentUser } from '@src/modules/authentication'; diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-storage-preview.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-storage-preview.api.spec.ts index d1aedc97f9e..f905cae5399 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-storage-preview.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage-preview.api.spec.ts @@ -4,7 +4,7 @@ import { ExecutionContext, INestApplication, NotFoundException, StreamableFile } import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; import { EntityId, Permission } from '@shared/domain'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { cleanupCollections, mapUserToCurrentUser, roleFactory, schoolFactory, userFactory } from '@shared/testing'; import { ICurrentUser } from '@src/modules/authentication'; diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-storage-restore-files.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-storage-restore-files.api.spec.ts index 603221b6651..7835c0217d4 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-storage-restore-files.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage-restore-files.api.spec.ts @@ -4,7 +4,7 @@ import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; import { EntityId, Permission } from '@shared/domain'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { cleanupCollections, diff --git a/apps/server/src/modules/files-storage/controller/dto/file-storage.params.ts b/apps/server/src/modules/files-storage/controller/dto/file-storage.params.ts index d474685caf3..6555b7bd0f9 100644 --- a/apps/server/src/modules/files-storage/controller/dto/file-storage.params.ts +++ b/apps/server/src/modules/files-storage/controller/dto/file-storage.params.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { StringToBoolean } from '@shared/controller'; import { EntityId } from '@shared/domain'; +import { ScanResult } from '@shared/infra/antivirus'; import { Allow, IsBoolean, IsEnum, IsMongoId, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator'; import { FileRecordParentType } from '../../entity'; import { PreviewOutputMimeTypes, PreviewWidth } from '../../interface'; @@ -51,7 +52,7 @@ export class DownloadFileParams { fileName!: string; } -export class ScanResultParams { +export class ScanResultParams implements ScanResult { @ApiProperty() @Allow() virus_detected?: boolean; 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 81fe5b34ba6..a87789d30fc 100644 --- a/apps/server/src/modules/files-storage/entity/filerecord.entity.ts +++ b/apps/server/src/modules/files-storage/entity/filerecord.entity.ts @@ -254,6 +254,12 @@ export class FileRecord extends BaseEntityWithTimestamps { return isVerified; } + public isPreviewPossible(): boolean { + const isPreviewPossible = Object.values(PreviewInputMimeTypes).includes(this.mimeType); + + return isPreviewPossible; + } + public getParentInfo(): IParentInfo { const { parentId, parentType, schoolId } = this; @@ -269,7 +275,7 @@ export class FileRecord extends BaseEntityWithTimestamps { return PreviewStatus.PREVIEW_NOT_POSSIBLE_SCAN_STATUS_BLOCKED; } - if (!Object.values(PreviewInputMimeTypes).includes(this.mimeType)) { + if (!this.isPreviewPossible()) { return PreviewStatus.PREVIEW_NOT_POSSIBLE_WRONG_MIME_TYPE; } diff --git a/apps/server/src/modules/files-storage/files-storage.config.ts b/apps/server/src/modules/files-storage/files-storage.config.ts index f3532b157e8..7dc01e9f4e1 100644 --- a/apps/server/src/modules/files-storage/files-storage.config.ts +++ b/apps/server/src/modules/files-storage/files-storage.config.ts @@ -6,14 +6,16 @@ export const FILES_STORAGE_S3_CONNECTION = 'FILES_STORAGE_S3_CONNECTION'; export interface IFileStorageConfig extends ICoreModuleConfig { MAX_FILE_SIZE: number; MAX_SECURITY_CHECK_FILE_SIZE: number; + USE_STREAM_TO_ANTIVIRUS: boolean; } const fileStorageConfig: IFileStorageConfig = { INCOMING_REQUEST_TIMEOUT: Configuration.get('FILES_STORAGE__INCOMING_REQUEST_TIMEOUT') as number, INCOMING_REQUEST_TIMEOUT_COPY_API: Configuration.get('INCOMING_REQUEST_TIMEOUT_COPY_API') as number, MAX_FILE_SIZE: Configuration.get('FILES_STORAGE__MAX_FILE_SIZE') as number, - MAX_SECURITY_CHECK_FILE_SIZE: Configuration.get('FILE_SECURITY_CHECK_MAX_FILE_SIZE') as number, + MAX_SECURITY_CHECK_FILE_SIZE: Configuration.get('FILES_STORAGE__MAX_FILE_SIZE') as number, NEST_LOG_LEVEL: Configuration.get('NEST_LOG_LEVEL') as string, + USE_STREAM_TO_ANTIVIRUS: Configuration.get('FILES_STORAGE__USE_STREAM_TO_ANTIVIRUS') as boolean, }; // The configurations lookup 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 29caa14e8c8..248654218ef 100644 --- a/apps/server/src/modules/files-storage/files-storage.module.ts +++ b/apps/server/src/modules/files-storage/files-storage.module.ts @@ -23,6 +23,8 @@ const imports = [ filesServiceBaseUrl: Configuration.get('FILES_STORAGE__SERVICE_BASE_URL') as string, exchange: Configuration.get('ANTIVIRUS_EXCHANGE') as string, routingKey: Configuration.get('ANTIVIRUS_ROUTING_KEY') as string, + hostname: Configuration.get('CLAMAV__SERVICE_HOSTNAME') as string, + port: Configuration.get('CLAMAV__SERVICE_PORT') as number, }), S3ClientModule.register([s3Config]), ]; diff --git a/apps/server/src/modules/files-storage/service/files-storage-copy.service.spec.ts b/apps/server/src/modules/files-storage/service/files-storage-copy.service.spec.ts index 3605ae74080..4ba05e540a8 100644 --- a/apps/server/src/modules/files-storage/service/files-storage-copy.service.spec.ts +++ b/apps/server/src/modules/files-storage/service/files-storage-copy.service.spec.ts @@ -2,7 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/files-storage/service/files-storage-delete.service.spec.ts b/apps/server/src/modules/files-storage/service/files-storage-delete.service.spec.ts index 627f35d91da..cd76b564b31 100644 --- a/apps/server/src/modules/files-storage/service/files-storage-delete.service.spec.ts +++ b/apps/server/src/modules/files-storage/service/files-storage-delete.service.spec.ts @@ -3,7 +3,7 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { InternalServerErrorException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/files-storage/service/files-storage-download.service.spec.ts b/apps/server/src/modules/files-storage/service/files-storage-download.service.spec.ts index a578bca51a1..bcee168c2b9 100644 --- a/apps/server/src/modules/files-storage/service/files-storage-download.service.spec.ts +++ b/apps/server/src/modules/files-storage/service/files-storage-download.service.spec.ts @@ -3,7 +3,7 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { NotAcceptableException, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { GetFile, S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/files-storage/service/files-storage-get.service.spec.ts b/apps/server/src/modules/files-storage/service/files-storage-get.service.spec.ts index 3565e5a328a..95f7c2d204c 100644 --- a/apps/server/src/modules/files-storage/service/files-storage-get.service.spec.ts +++ b/apps/server/src/modules/files-storage/service/files-storage-get.service.spec.ts @@ -2,7 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/files-storage/service/files-storage-restore.service.spec.ts b/apps/server/src/modules/files-storage/service/files-storage-restore.service.spec.ts index 1c3460c3926..c82f96074f1 100644 --- a/apps/server/src/modules/files-storage/service/files-storage-restore.service.spec.ts +++ b/apps/server/src/modules/files-storage/service/files-storage-restore.service.spec.ts @@ -2,7 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/files-storage/service/files-storage-update.service.spec.ts b/apps/server/src/modules/files-storage/service/files-storage-update.service.spec.ts index 9da800baf90..8523c7388fd 100644 --- a/apps/server/src/modules/files-storage/service/files-storage-update.service.spec.ts +++ b/apps/server/src/modules/files-storage/service/files-storage-update.service.spec.ts @@ -3,7 +3,7 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { ConflictException, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/files-storage/service/files-storage-upload.service.spec.ts b/apps/server/src/modules/files-storage/service/files-storage-upload.service.spec.ts index 0ba3f729dae..022e6a4bf0d 100644 --- a/apps/server/src/modules/files-storage/service/files-storage-upload.service.spec.ts +++ b/apps/server/src/modules/files-storage/service/files-storage-upload.service.spec.ts @@ -3,14 +3,14 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { BadRequestException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { readableStreamWithFileTypeFactory } from '@shared/testing/factory/readable-stream-with-file-type.factory'; import { LegacyLogger } from '@src/core/logger'; import { MimeType } from 'file-type'; import FileType from 'file-type-cjs/file-type-cjs-index'; -import { Readable } from 'stream'; +import { PassThrough, Readable } from 'stream'; import { FileRecordParams } from '../controller/dto'; import { FileDto } from '../dto'; import { FileRecord, FileRecordParentType } from '../entity'; @@ -122,6 +122,12 @@ describe('FilesStorageService upload methods', () => { const readableStreamWithFileType = readableStreamWithFileTypeFactory.build(); + antivirusService.checkStream.mockResolvedValueOnce({ + virus_detected: undefined, + virus_signature: undefined, + error: undefined, + }); + return { params, file, @@ -170,7 +176,7 @@ describe('FilesStorageService upload methods', () => { await service.uploadFile(userId, params, file); - expect(getMimeTypeSpy).toHaveBeenCalledWith(file.data); + expect(getMimeTypeSpy).toHaveBeenCalledWith(expect.any(PassThrough)); }); it('should call getFileRecordsOfParent with correct params', async () => { @@ -199,14 +205,6 @@ describe('FilesStorageService upload methods', () => { ); }); - it('should call antivirusService.send with fileRecord', async () => { - const { params, file, userId } = setup(); - - const fileRecord = await service.uploadFile(userId, params, file); - - expect(antivirusService.send).toHaveBeenCalledWith(fileRecord.securityCheck.requestToken); - }); - it('should call storageClient.create with correct params', async () => { const { params, file, userId } = setup(); @@ -223,6 +221,29 @@ describe('FilesStorageService upload methods', () => { expect(result).toBeInstanceOf(FileRecord); }); + + describe('Antivirus handling by upload ', () => { + describe('when useStreamToAntivirus is true', () => { + it('should call antivirusService.send with fileRecord', async () => { + const { params, file, userId } = setup(); + configService.get.mockReturnValueOnce(true); + await service.uploadFile(userId, params, file); + + expect(antivirusService.checkStream).toHaveBeenCalledWith(file); + }); + }); + + describe('when useStreamToAntivirus is false', () => { + it('should call antivirusService.send with fileRecord', async () => { + const { params, file, userId } = setup(); + configService.get.mockReturnValueOnce(false); + + const fileRecord = await service.uploadFile(userId, params, file); + + expect(antivirusService.send).toHaveBeenCalledWith(fileRecord.securityCheck.requestToken); + }); + }); + }); }); describe('WHEN file record repo throws error', () => { @@ -294,6 +315,7 @@ describe('FilesStorageService upload methods', () => { } }); + configService.get.mockReturnValueOnce(true); configService.get.mockReturnValueOnce(2); const error = new BadRequestException(ErrorType.FILE_TOO_BIG); @@ -315,6 +337,9 @@ describe('FilesStorageService upload methods', () => { jest.spyOn(service, 'getFileRecordsOfParent').mockResolvedValue([[fileRecord], 1]); + // Mock for useStreamToAntivirus + configService.get.mockReturnValueOnce(false); + // Mock for max file size configService.get.mockReturnValueOnce(10); @@ -364,6 +389,8 @@ describe('FilesStorageService upload methods', () => { jest.spyOn(FileType, 'fileTypeStream').mockResolvedValueOnce(readableStreamWithFileType); + configService.get.mockReturnValueOnce(false); + // The fileRecord._id must be set by fileRecordRepo.save. Otherwise createPath fails. // eslint-disable-next-line @typescript-eslint/require-await fileRecordRepo.save.mockImplementation(async (fr) => { diff --git a/apps/server/src/modules/files-storage/service/files-storage.service.ts b/apps/server/src/modules/files-storage/service/files-storage.service.ts index 2b6bc4b179a..8c0c85630de 100644 --- a/apps/server/src/modules/files-storage/service/files-storage.service.ts +++ b/apps/server/src/modules/files-storage/service/files-storage.service.ts @@ -8,11 +8,11 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Counted, EntityId } from '@shared/domain'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { LegacyLogger } from '@src/core/logger'; import FileType from 'file-type-cjs/file-type-cjs-index'; -import { Readable } from 'stream'; +import { PassThrough, Readable } from 'stream'; import { CopyFileResponse, CopyFilesOfParentParams, @@ -104,7 +104,8 @@ export class FilesStorageService { private async detectMimeType(file: FileDto): Promise<{ mimeType: string; stream: Readable }> { if (this.isStreamMimeTypeDetectionPossible(file.mimeType)) { - const { stream, mime: detectedMimeType } = await this.detectMimeTypeByStream(file.data); + const source = file.data.pipe(new PassThrough()); + const { stream, mime: detectedMimeType } = await this.detectMimeTypeByStream(source); const mimeType = detectedMimeType ?? file.mimeType; @@ -151,18 +152,32 @@ export class FilesStorageService { file: FileDto ): Promise { const filePath = createPath(params.schoolId, fileRecord.id); + const useStreamToAntivirus = this.configService.get('USE_STREAM_TO_ANTIVIRUS'); try { const fileSizePromise = this.countFileSize(file); - await this.storageClient.create(filePath, file); + if (useStreamToAntivirus && fileRecord.isPreviewPossible()) { + const streamToAntivirus = file.data.pipe(new PassThrough()); + + const [, antivirusClientResponse] = await Promise.all([ + this.storageClient.create(filePath, file), + this.antivirusService.checkStream(streamToAntivirus), + ]); + const { status, reason } = FileRecordMapper.mapScanResultParamsToDto(antivirusClientResponse); + fileRecord.updateSecurityCheckStatus(status, reason); + } else { + await this.storageClient.create(filePath, file); + } // The actual file size is set here because it is known only after the whole file is streamed. fileRecord.size = await fileSizePromise; this.throwErrorIfFileIsTooBig(fileRecord.size); await this.fileRecordRepo.save(fileRecord); - await this.sendToAntivirus(fileRecord); + if (!useStreamToAntivirus || !fileRecord.isPreviewPossible()) { + await this.sendToAntivirus(fileRecord); + } } catch (error) { await this.storageClient.delete([filePath]); await this.fileRecordRepo.delete(fileRecord); diff --git a/apps/server/src/modules/files-storage/uc/files-storage-copy.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-copy.uc.spec.ts index 21592585ce4..5d4ab900549 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-copy.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-copy.uc.spec.ts @@ -4,7 +4,7 @@ import { HttpService } from '@nestjs/axios'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityId, Permission } from '@shared/domain'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-delete.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-delete.uc.spec.ts index 9a870a7f4e1..eb13f830be6 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-delete.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-delete.uc.spec.ts @@ -4,7 +4,7 @@ import { HttpService } from '@nestjs/axios'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Counted, EntityId } from '@shared/domain'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-download-preview.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-download-preview.uc.spec.ts index 2d62feaa08e..3a2f6f1ac21 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-download-preview.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-download-preview.uc.spec.ts @@ -3,7 +3,7 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { HttpService } from '@nestjs/axios'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-download.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-download.uc.spec.ts index ee3eb1ecef3..b1aa6d4b437 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-download.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-download.uc.spec.ts @@ -3,7 +3,7 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { HttpService } from '@nestjs/axios'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-get.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-get.uc.spec.ts index 610f54d8d3b..02cdb82ded6 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-get.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-get.uc.spec.ts @@ -2,7 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { HttpService } from '@nestjs/axios'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-restore.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-restore.uc.spec.ts index 05961b33339..be8a6d32561 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-restore.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-restore.uc.spec.ts @@ -3,7 +3,7 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { HttpService } from '@nestjs/axios'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-update.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-update.uc.spec.ts index 75621cd82da..57ec96cff61 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-update.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-update.uc.spec.ts @@ -2,7 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { HttpService } from '@nestjs/axios'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-upload.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-upload.uc.spec.ts index 78348078565..903a2f2a6a6 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-upload.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-upload.uc.spec.ts @@ -4,7 +4,7 @@ import { HttpService } from '@nestjs/axios'; import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { AxiosHeadersKeyValue, axiosResponseFactory, fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/shared/infra/antivirus/antivirus.module.spec.ts b/apps/server/src/shared/infra/antivirus/antivirus.module.spec.ts index 594e283861d..ea7e85b109f 100644 --- a/apps/server/src/shared/infra/antivirus/antivirus.module.spec.ts +++ b/apps/server/src/shared/infra/antivirus/antivirus.module.spec.ts @@ -11,6 +11,8 @@ describe('AntivirusModule', () => { filesServiceBaseUrl: 'http://localhost', exchange: 'exchange', routingKey: 'routingKey', + hostname: 'localhost', + port: 3311, }; beforeAll(async () => { diff --git a/apps/server/src/shared/infra/antivirus/antivirus.module.ts b/apps/server/src/shared/infra/antivirus/antivirus.module.ts index c9078925532..197e263b97d 100644 --- a/apps/server/src/shared/infra/antivirus/antivirus.module.ts +++ b/apps/server/src/shared/infra/antivirus/antivirus.module.ts @@ -1,12 +1,7 @@ -import { Module, DynamicModule } from '@nestjs/common'; +import { DynamicModule, Module } from '@nestjs/common'; +import NodeClam from 'clamscan'; import { AntivirusService } from './antivirus.service'; - -interface AntivirusModuleOptions { - enabled: boolean; - filesServiceBaseUrl: string; - exchange: string; - routingKey: string; -} +import { AntivirusModuleOptions } from './interfaces'; @Module({}) export class AntivirusModule { @@ -24,7 +19,24 @@ export class AntivirusModule { routingKey: options.routingKey, }, }, + { + provide: NodeClam, + useFactory: () => { + const isLocalhost = options.hostname === 'localhost'; + + return new NodeClam().init({ + debugMode: isLocalhost, + clamdscan: { + host: options.hostname, + port: options.port, + bypassTest: isLocalhost, + localFallback: false, + }, + }); + }, + }, ], + exports: [AntivirusService], }; } diff --git a/apps/server/src/shared/infra/antivirus/antivirus.service.spec.ts b/apps/server/src/shared/infra/antivirus/antivirus.service.spec.ts index 6a02415a82b..247b04c48a0 100644 --- a/apps/server/src/shared/infra/antivirus/antivirus.service.spec.ts +++ b/apps/server/src/shared/infra/antivirus/antivirus.service.spec.ts @@ -2,6 +2,8 @@ import { AmqpConnection } from '@golevelup/nestjs-rabbitmq'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import NodeClam from 'clamscan'; +import { Readable } from 'stream'; import { v4 as uuid } from 'uuid'; import { AntivirusService } from './antivirus.service'; @@ -9,6 +11,7 @@ describe('AntivirusService', () => { let module: TestingModule; let service: AntivirusService; let amqpConnection: DeepMocked; + let clamavConnection: DeepMocked; const antivirusServiceOptions = { enabled: true, @@ -22,12 +25,14 @@ describe('AntivirusService', () => { providers: [ AntivirusService, { provide: AmqpConnection, useValue: createMock() }, + { provide: NodeClam, useValue: createMock() }, { provide: 'ANTIVIRUS_SERVICE_OPTIONS', useValue: antivirusServiceOptions }, ], }).compile(); service = module.get(AntivirusService); amqpConnection = module.get(AmqpConnection); + clamavConnection = module.get(NodeClam); }); afterAll(async () => { @@ -82,4 +87,104 @@ describe('AntivirusService', () => { }); }); }); + + describe('checkStream', () => { + describe('when service can handle passing parameters', () => { + const setup = () => { + const readable = Readable.from('abc'); + + return { readable }; + }; + + it('should call scanStream', async () => { + const { readable } = setup(); + + await service.checkStream(readable); + + expect(clamavConnection.scanStream).toHaveBeenCalledWith(readable); + }); + }); + + describe('when file infected', () => { + const setup = () => { + const readable = Readable.from('abc'); + // @ts-expect-error unknown types + clamavConnection.scanStream.mockResolvedValueOnce({ isInfected: true, viruses: ['test'] }); + + const expectedResult = { + virus_detected: true, + virus_signature: 'test', + }; + return { readable, expectedResult }; + }; + + it('should return scan result', async () => { + const { readable, expectedResult } = setup(); + + const result = await service.checkStream(readable); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('when file not scanned', () => { + const setup = () => { + const readable = Readable.from('abc'); + // @ts-expect-error unknown types + clamavConnection.scanStream.mockResolvedValueOnce({ isInfected: null }); + + const expectedResult = { + virus_detected: undefined, + virus_signature: undefined, + error: '', + }; + return { readable, expectedResult }; + }; + + it('should return scan result', async () => { + const { readable, expectedResult } = setup(); + + const result = await service.checkStream(readable); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('when file is good', () => { + const setup = () => { + const readable = Readable.from('abc'); + // @ts-expect-error unknown types + clamavConnection.scanStream.mockResolvedValueOnce({ isInfected: false }); + + const expectedResult = { + virus_detected: false, + }; + return { readable, expectedResult }; + }; + + it('should return scan result', async () => { + const { readable, expectedResult } = setup(); + + const result = await service.checkStream(readable); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('when clamavConnection.scanStream throw an error', () => { + const setup = () => { + const readable = Readable.from('abc'); + // @ts-expect-error unknown types + clamavConnection.scanStream.mockRejectedValueOnce(new Error('fail')); + + return { readable }; + }; + + it('should throw with InternalServerErrorException by error', async () => { + const { readable } = setup(); + + await expect(() => service.checkStream(readable)).rejects.toThrowError(InternalServerErrorException); + }); + }); + }); }); diff --git a/apps/server/src/shared/infra/antivirus/antivirus.service.ts b/apps/server/src/shared/infra/antivirus/antivirus.service.ts index c3c38494853..1f49b9907fb 100644 --- a/apps/server/src/shared/infra/antivirus/antivirus.service.ts +++ b/apps/server/src/shared/infra/antivirus/antivirus.service.ts @@ -2,21 +2,45 @@ import { AmqpConnection } from '@golevelup/nestjs-rabbitmq'; import { Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; import { ErrorUtils } from '@src/core/error/utils'; import { API_VERSION_PATH, FilesStorageInternalActions } from '@src/modules/files-storage/files-storage.const'; - -interface AntivirusServiceOptions { - enabled: boolean; - filesServiceBaseUrl: string; - exchange: string; - routingKey: string; -} +import NodeClam from 'clamscan'; +import { Readable } from 'stream'; +import { AntivirusServiceOptions, ScanResult } from './interfaces'; @Injectable() export class AntivirusService { constructor( private readonly amqpConnection: AmqpConnection, - @Inject('ANTIVIRUS_SERVICE_OPTIONS') private readonly options: AntivirusServiceOptions + @Inject('ANTIVIRUS_SERVICE_OPTIONS') private readonly options: AntivirusServiceOptions, + private readonly clamConnection: NodeClam ) {} + public async checkStream(stream: Readable) { + const scanResult: ScanResult = { + virus_detected: undefined, + virus_signature: undefined, + error: undefined, + }; + try { + const { isInfected, viruses } = await this.clamConnection.scanStream(stream); + if (isInfected === true) { + scanResult.virus_detected = true; + scanResult.virus_signature = viruses.join(','); + } else if (isInfected === null) { + scanResult.virus_detected = undefined; + scanResult.error = ''; + } else { + scanResult.virus_detected = false; + } + } catch (err) { + throw new InternalServerErrorException( + null, + ErrorUtils.createHttpExceptionOptions(err, 'AntivirusService:checkStream') + ); + } + + return scanResult; + } + public async send(requestToken: string | undefined): Promise { try { if (this.options.enabled && requestToken) { diff --git a/apps/server/src/shared/infra/antivirus/index.ts b/apps/server/src/shared/infra/antivirus/index.ts new file mode 100644 index 00000000000..833c46d81a7 --- /dev/null +++ b/apps/server/src/shared/infra/antivirus/index.ts @@ -0,0 +1,3 @@ +export * from './interfaces'; +export * from './antivirus.module'; +export * from './antivirus.service'; diff --git a/apps/server/src/shared/infra/antivirus/interfaces/antivirus.ts b/apps/server/src/shared/infra/antivirus/interfaces/antivirus.ts new file mode 100644 index 00000000000..0d648afacce --- /dev/null +++ b/apps/server/src/shared/infra/antivirus/interfaces/antivirus.ts @@ -0,0 +1,21 @@ +export interface AntivirusModuleOptions { + enabled: boolean; + filesServiceBaseUrl: string; + exchange: string; + routingKey: string; + hostname: string; + port: number; +} + +export interface AntivirusServiceOptions { + enabled: boolean; + filesServiceBaseUrl: string; + exchange: string; + routingKey: string; +} + +export interface ScanResult { + virus_detected?: boolean; + virus_signature?: string; + error?: string; +} diff --git a/apps/server/src/shared/infra/antivirus/interfaces/index.ts b/apps/server/src/shared/infra/antivirus/interfaces/index.ts new file mode 100644 index 00000000000..6c4771f9cd5 --- /dev/null +++ b/apps/server/src/shared/infra/antivirus/interfaces/index.ts @@ -0,0 +1 @@ +export * from './antivirus'; diff --git a/config/default.schema.json b/config/default.schema.json index e6084e5c331..5aba0e9aad8 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -259,6 +259,11 @@ "type": "string", "default": "files-storage", "description": "rabbitmq exchange name for antivirus" + }, + "USE_STREAM_TO_ANTIVIRUS": { + "type": "boolean", + "default": false, + "description": "send file to antivirus by uploading" } } }, @@ -454,6 +459,25 @@ } } }, + "CLAMAV": { + "type": "object", + "description": "Properties of the ClamAV server", + "required": [], + "properties": { + "SERVICE_HOSTNAME": { + "type": "string", + "description": "IP of host to connect to TCP interface" + }, + "SERVICE_PORT": { + "type": "number", + "description": "Port of host to use when connecting via TCP interface" + } + }, + "default": { + "SERVICE_HOSTNAME": "localhost", + "SERVICE_PORT": 3310 + } + }, "RABBITMQ_URI": { "type": "string", "format": "uri", diff --git a/package-lock.json b/package-lock.json index dde18164a6f..e36ecdd9873 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,7 @@ "cache-manager": "^2.9.0", "cache-manager-redis-store": "^2.0.0", "chalk": "^5.0.0", + "clamscan": "^2.1.2", "class-transformer": "^0.4.0", "class-validator": "^0.14.0", "client-oauth2": "^4.2.5", @@ -143,6 +144,7 @@ "@types/amqplib": "^0.8.2", "@types/bcryptjs": "^2.4.2", "@types/busboy": "^1.5.0", + "@types/clamscan": "^2.0.5", "@types/cookie": "^0.4.1", "@types/crypto-js": "^4.0.2", "@types/express": "^4.17.11", @@ -5460,6 +5462,25 @@ "integrity": "sha512-/ceqdqeRraGolFTcfoXNiqjyQhZzbINDngeoAq9GoHa8PPK1yNzTaxWjA6BFWp5Ua9JpXEMSS4s5i9tS0hOJtw==", "dev": true }, + "node_modules/@types/clamscan": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/clamscan/-/clamscan-2.0.5.tgz", + "integrity": "sha512-bFqdscswqBia3yKEJZVVWELOVvWKHUR1dCmH4xshYwu0T9YSfZd35Q8Z9jYW0ygxqGlHjLXMb2/7C6CJITbDgg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "axios": "^0.24.0" + } + }, + "node_modules/@types/clamscan/node_modules/axios": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz", + "integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==", + "dev": true, + "dependencies": { + "follow-redirects": "^1.14.4" + } + }, "node_modules/@types/connect": { "version": "3.4.35", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", @@ -8122,6 +8143,14 @@ "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==", "dev": true }, + "node_modules/clamscan": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clamscan/-/clamscan-2.1.2.tgz", + "integrity": "sha512-pcovgLHcrg3l/mI51Kuk0kN++07pSZdBTskISw0UFvsm8UXda8oNCm0eLeODxFg85Mz+k+TtSS9+XPlriJ8/Fg==", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/class-transformer": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.4.0.tgz", @@ -28782,6 +28811,27 @@ "integrity": "sha512-/ceqdqeRraGolFTcfoXNiqjyQhZzbINDngeoAq9GoHa8PPK1yNzTaxWjA6BFWp5Ua9JpXEMSS4s5i9tS0hOJtw==", "dev": true }, + "@types/clamscan": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/clamscan/-/clamscan-2.0.5.tgz", + "integrity": "sha512-bFqdscswqBia3yKEJZVVWELOVvWKHUR1dCmH4xshYwu0T9YSfZd35Q8Z9jYW0ygxqGlHjLXMb2/7C6CJITbDgg==", + "dev": true, + "requires": { + "@types/node": "*", + "axios": "^0.24.0" + }, + "dependencies": { + "axios": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz", + "integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==", + "dev": true, + "requires": { + "follow-redirects": "^1.14.4" + } + } + } + }, "@types/connect": { "version": "3.4.35", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", @@ -30913,6 +30963,11 @@ "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==", "dev": true }, + "clamscan": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clamscan/-/clamscan-2.1.2.tgz", + "integrity": "sha512-pcovgLHcrg3l/mI51Kuk0kN++07pSZdBTskISw0UFvsm8UXda8oNCm0eLeODxFg85Mz+k+TtSS9+XPlriJ8/Fg==" + }, "class-transformer": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.4.0.tgz", diff --git a/package.json b/package.json index 97cb84c069e..3c5a73df7d1 100644 --- a/package.json +++ b/package.json @@ -140,6 +140,7 @@ "cache-manager": "^2.9.0", "cache-manager-redis-store": "^2.0.0", "chalk": "^5.0.0", + "clamscan": "^2.1.2", "class-transformer": "^0.4.0", "class-validator": "^0.14.0", "client-oauth2": "^4.2.5", @@ -225,6 +226,7 @@ "@types/amqplib": "^0.8.2", "@types/bcryptjs": "^2.4.2", "@types/busboy": "^1.5.0", + "@types/clamscan": "^2.0.5", "@types/cookie": "^0.4.1", "@types/crypto-js": "^4.0.2", "@types/express": "^4.17.11",