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 {