diff --git a/apps/server/src/apps/server.app.ts b/apps/server/src/apps/server.app.ts index 81a35f6bfa6..6452ca1cd47 100644 --- a/apps/server/src/apps/server.app.ts +++ b/apps/server/src/apps/server.app.ts @@ -19,6 +19,7 @@ import { join } from 'path'; // register source-map-support for debugging import { install as sourceMapInstall } from 'source-map-support'; +import { FeathersRosterService } from '@src/modules/pseudonym'; import legacyAppPromise = require('../../../../src/app'); import { AppStartLoggable } from './helpers/app-start-loggable'; @@ -80,6 +81,8 @@ async function bootstrap() { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access feathersExpress.services['nest-team-service'] = nestApp.get(TeamService); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + feathersExpress.services['nest-feathers-roster-service'] = nestApp.get(FeathersRosterService); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment feathersExpress.services['nest-orm'] = orm; // mount instances diff --git a/apps/server/src/modules/learnroom/service/course.service.spec.ts b/apps/server/src/modules/learnroom/service/course.service.spec.ts index 56d5bb359b4..67c581d6818 100644 --- a/apps/server/src/modules/learnroom/service/course.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/course.service.spec.ts @@ -40,12 +40,18 @@ describe('CourseService', () => { }); describe('findById', () => { - it('should call findById from course repository', async () => { + const setup = () => { const courseId = 'courseId'; courseRepo.findById.mockResolvedValueOnce({} as Course); + return { courseId }; + }; + + it('should call findById from course repository', async () => { + const { courseId } = setup(); + await expect(courseService.findById(courseId)).resolves.not.toThrow(); - expect(courseRepo.findById).toBeCalledTimes(1); + expect(courseRepo.findById).toBeCalledWith(courseId); }); }); @@ -78,4 +84,21 @@ describe('CourseService', () => { expect(result).toEqual(3); }); }); + + describe('findAllByUserId', () => { + const setup = () => { + const userId = 'userId'; + courseRepo.findAllByUserId.mockResolvedValueOnce([[], 0]); + + return { userId }; + }; + + it('should call findAllByUserId from course repository', async () => { + const { userId } = setup(); + + await expect(courseService.findAllByUserId(userId)).resolves.not.toThrow(); + + expect(courseRepo.findAllByUserId).toBeCalledWith(userId); + }); + }); }); diff --git a/apps/server/src/modules/learnroom/service/course.service.ts b/apps/server/src/modules/learnroom/service/course.service.ts index ef14fcfabb2..c2e7364ffe9 100644 --- a/apps/server/src/modules/learnroom/service/course.service.ts +++ b/apps/server/src/modules/learnroom/service/course.service.ts @@ -19,4 +19,10 @@ export class CourseService { return count; } + + async findAllByUserId(userId: EntityId): Promise { + const [courses] = await this.repo.findAllByUserId(userId); + + return courses; + } } diff --git a/apps/server/src/modules/oauth-provider/service/id-token.service.spec.ts b/apps/server/src/modules/oauth-provider/service/id-token.service.spec.ts index 454874571f9..48f21de4077 100644 --- a/apps/server/src/modules/oauth-provider/service/id-token.service.spec.ts +++ b/apps/server/src/modules/oauth-provider/service/id-token.service.spec.ts @@ -1,5 +1,4 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { Test, TestingModule } from '@nestjs/testing'; import { Pseudonym, TeamEntity, UserDO } from '@shared/domain'; import { TeamsRepo } from '@shared/repo'; @@ -24,18 +23,7 @@ describe('IdTokenService', () => { let teamsRepo: DeepMocked; let userService: DeepMocked; - const hostUrl = 'https://host.de'; - beforeAll(async () => { - jest.spyOn(Configuration, 'get').mockImplementation((key: string) => { - switch (key) { - case 'HOST': - return hostUrl; - default: - return null; - } - }); - module = await Test.createTestingModule({ providers: [ IdTokenService, @@ -90,24 +78,26 @@ describe('IdTokenService', () => { userService.findById.mockResolvedValue(user); userService.getDisplayName.mockResolvedValue(displayName); oauthProviderLoginFlowService.findToolByClientId.mockResolvedValue(tool); - pseudonymService.findByUserAndTool.mockResolvedValue(pseudonym); + pseudonymService.findByUserAndToolOrThrow.mockResolvedValue(pseudonym); + const iframeSubject = 'iframeSubject'; + pseudonymService.getIframeSubject.mockReturnValueOnce(iframeSubject); return { user, displayName, tool, pseudonym, + iframeSubject, }; }; it('should return the correct id token', async () => { - const { user } = setup(); + const { user, iframeSubject } = setup(); const result: IdToken = await service.createIdToken('userId', [], 'clientId'); expect(result).toEqual({ - iframe: - '', + iframe: iframeSubject, schoolId: user.schoolId, }); }); @@ -129,7 +119,9 @@ describe('IdTokenService', () => { userService.findById.mockResolvedValue(user); userService.getDisplayName.mockResolvedValue(displayName); oauthProviderLoginFlowService.findToolByClientId.mockResolvedValue(tool); - pseudonymService.findByUserAndTool.mockResolvedValue(pseudonym); + pseudonymService.findByUserAndToolOrThrow.mockResolvedValue(pseudonym); + const iframeSubject = 'iframeSubject'; + pseudonymService.getIframeSubject.mockReturnValueOnce(iframeSubject); return { team, @@ -137,17 +129,17 @@ describe('IdTokenService', () => { displayName, tool, pseudonym, + iframeSubject, }; }; it('should return the correct id token', async () => { - const { user, team } = setup(); + const { user, team, iframeSubject } = setup(); const result: IdToken = await service.createIdToken('userId', [OauthScope.GROUPS], 'clientId'); expect(result).toEqual({ - iframe: - '', + iframe: iframeSubject, schoolId: user.schoolId, groups: [ { @@ -172,24 +164,26 @@ describe('IdTokenService', () => { userService.findById.mockResolvedValue(user); userService.getDisplayName.mockResolvedValue(displayName); oauthProviderLoginFlowService.findToolByClientId.mockResolvedValue(tool); - pseudonymService.findByUserAndTool.mockResolvedValue(pseudonym); + pseudonymService.findByUserAndToolOrThrow.mockResolvedValue(pseudonym); + const iframeSubject = 'iframeSubject'; + pseudonymService.getIframeSubject.mockReturnValueOnce(iframeSubject); return { user, displayName, tool, pseudonym, + iframeSubject, }; }; it('should return the correct id token', async () => { - const { user } = setup(); + const { user, iframeSubject } = setup(); const result: IdToken = await service.createIdToken('userId', [OauthScope.EMAIL], 'clientId'); expect(result).toEqual({ - iframe: - '', + iframe: iframeSubject, schoolId: user.schoolId, email: user.email, }); @@ -209,24 +203,26 @@ describe('IdTokenService', () => { userService.findById.mockResolvedValue(user); userService.getDisplayName.mockResolvedValue(displayName); oauthProviderLoginFlowService.findToolByClientId.mockResolvedValue(tool); - pseudonymService.findByUserAndTool.mockResolvedValue(pseudonym); + pseudonymService.findByUserAndToolOrThrow.mockResolvedValue(pseudonym); + const iframeSubject = 'iframeSubject'; + pseudonymService.getIframeSubject.mockReturnValueOnce(iframeSubject); return { user, displayName, tool, pseudonym, + iframeSubject, }; }; it('should return the correct id token', async () => { - const { user, displayName } = setup(); + const { user, displayName, iframeSubject } = setup(); const result: IdToken = await service.createIdToken('userId', [OauthScope.PROFILE], 'clientId'); expect(result).toEqual({ - iframe: - '', + iframe: iframeSubject, schoolId: user.schoolId, name: displayName, userId: user.id, diff --git a/apps/server/src/modules/oauth-provider/service/id-token.service.ts b/apps/server/src/modules/oauth-provider/service/id-token.service.ts index 8eec9407d6c..dbe1b2c54fc 100644 --- a/apps/server/src/modules/oauth-provider/service/id-token.service.ts +++ b/apps/server/src/modules/oauth-provider/service/id-token.service.ts @@ -1,29 +1,21 @@ -import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { Injectable } from '@nestjs/common'; import { LtiToolDO, Pseudonym, TeamEntity, UserDO } from '@shared/domain'; import { TeamsRepo } from '@shared/repo'; import { PseudonymService } from '@src/modules/pseudonym'; -import { UserService } from '@src/modules/user'; import { ExternalTool } from '@src/modules/tool/external-tool/domain'; +import { UserService } from '@src/modules/user'; +import { IdTokenCreationLoggableException } from '../error/id-token-creation-exception.loggable'; import { GroupNameIdTuple, IdToken, OauthScope } from '../interface'; import { OauthProviderLoginFlowService } from './oauth-provider.login-flow.service'; -import { IdTokenCreationLoggableException } from '../error/id-token-creation-exception.loggable'; @Injectable() export class IdTokenService { - private readonly host: string; - - protected iFrameProperties: string; - constructor( private readonly oauthProviderLoginFlowService: OauthProviderLoginFlowService, private readonly pseudonymService: PseudonymService, private readonly teamsRepo: TeamsRepo, private readonly userService: UserService - ) { - this.host = Configuration.get('HOST') as string; - this.iFrameProperties = 'title="username" style="height: 26px; width: 180px; border: none;"'; - } + ) {} async createIdToken(userId: string, scopes: string[], clientId: string): Promise { let teams: TeamEntity[] = []; @@ -63,8 +55,10 @@ export class IdTokenService { throw new IdTokenCreationLoggableException(clientId, user.id); } - const pseudonym: Pseudonym = await this.pseudonymService.findByUserAndTool(user, tool); + const pseudonym: Pseudonym = await this.pseudonymService.findByUserAndToolOrThrow(user, tool); + + const iframeSubject: string = this.pseudonymService.getIframeSubject(pseudonym.pseudonym); - return ``; + return iframeSubject; } } diff --git a/apps/server/src/modules/pseudonym/controller/api-test/pseudonym.api.spec.ts b/apps/server/src/modules/pseudonym/controller/api-test/pseudonym.api.spec.ts new file mode 100644 index 00000000000..05bd5d4f2f5 --- /dev/null +++ b/apps/server/src/modules/pseudonym/controller/api-test/pseudonym.api.spec.ts @@ -0,0 +1,157 @@ +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { + cleanupCollections, + externalToolEntityFactory, + externalToolPseudonymEntityFactory, + schoolFactory, + TestApiClient, + UserAndAccountTestFactory, +} from '@shared/testing'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Response } from 'supertest'; +import { SchoolEntity } from '@shared/domain'; +import { ServerTestModule } from '@src/modules/server'; +import { ExternalToolEntity } from '@src/modules/tool/external-tool/entity'; +import { UUID } from 'bson'; +import { ExternalToolPseudonymEntity } from '../../entity'; +import { PseudonymResponse } from '../dto'; + +describe('PseudonymController (API)', () => { + let app: INestApplication; + let em: EntityManager; + + let testApiClient: TestApiClient; + + beforeAll(async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = moduleRef.createNestApplication(); + + await app.init(); + + em = app.get(EntityManager); + testApiClient = new TestApiClient(app, 'pseudonyms'); + }); + + afterAll(async () => { + await app.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('[GET] pseudonyms/:pseudonym', () => { + describe('when user is not authenticated', () => { + it('should return unauthorized', async () => { + const response: Response = await testApiClient.get(new ObjectId().toHexString()); + + expect(response.statusCode).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when valid params are given', () => { + const setup = async () => { + const school: SchoolEntity = schoolFactory.buildWithId(); + const { studentUser, studentAccount } = UserAndAccountTestFactory.buildStudent({ school }, []); + const pseudonymString: string = new UUID().toString(); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId(); + const pseudonym: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.buildWithId({ + pseudonym: pseudonymString, + toolId: externalToolEntity.id, + userId: studentUser.id, + }); + + await em.persistAndFlush([studentAccount, studentUser, pseudonym, externalToolEntity, school]); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(studentAccount); + + return { loggedInClient, pseudonym, pseudonymString }; + }; + + it('should return a pseudonymResponse', async () => { + const { loggedInClient, pseudonymString, pseudonym } = await setup(); + + const response: Response = await loggedInClient.get(pseudonymString); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ + id: pseudonym.id, + userId: pseudonym.userId.toString(), + toolId: pseudonym.toolId.toString(), + }); + }); + }); + + describe('when pseudonym is not connected to the users school', () => { + const setup = async () => { + const school: SchoolEntity = schoolFactory.buildWithId(); + const { studentUser, studentAccount } = UserAndAccountTestFactory.buildStudent(); + const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }); + const pseudonymString: string = new UUID().toString(); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId(); + const pseudonym: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.buildWithId({ + pseudonym: pseudonymString, + toolId: externalToolEntity.id, + userId: teacherUser.id, + }); + + await em.persistAndFlush([ + studentAccount, + studentUser, + teacherUser, + teacherAccount, + pseudonym, + externalToolEntity, + school, + ]); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(studentAccount); + + return { loggedInClient, pseudonymString }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, pseudonymString } = await setup(); + + const response: Response = await loggedInClient.get(pseudonymString); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when pseudonym does not exist in db', () => { + const setup = async () => { + const school: SchoolEntity = schoolFactory.buildWithId(); + const { studentUser, studentAccount } = UserAndAccountTestFactory.buildStudent({ school }); + const pseudonymString: string = new UUID().toString(); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId(); + const pseudonym: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.buildWithId({ + pseudonym: new UUID().toString(), + toolId: externalToolEntity.id, + userId: studentUser.id, + }); + + await em.persistAndFlush([studentAccount, studentUser, pseudonym, externalToolEntity, school]); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(studentAccount); + + return { loggedInClient, pseudonymString }; + }; + + it('should return not found', async () => { + const { loggedInClient, pseudonymString } = await setup(); + + const response: Response = await loggedInClient.get(pseudonymString); + + expect(response.statusCode).toEqual(HttpStatus.NOT_FOUND); + }); + }); + }); +}); diff --git a/apps/server/src/modules/pseudonym/controller/dto/index.ts b/apps/server/src/modules/pseudonym/controller/dto/index.ts new file mode 100644 index 00000000000..0c30123c6b6 --- /dev/null +++ b/apps/server/src/modules/pseudonym/controller/dto/index.ts @@ -0,0 +1 @@ +export * from './pseudonym.response'; diff --git a/apps/server/src/modules/pseudonym/controller/dto/pseudonym-params.ts b/apps/server/src/modules/pseudonym/controller/dto/pseudonym-params.ts new file mode 100644 index 00000000000..172e7845b9f --- /dev/null +++ b/apps/server/src/modules/pseudonym/controller/dto/pseudonym-params.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class PseudonymParams { + @IsString() + @ApiProperty({ nullable: false, required: true }) + pseudonym!: string; +} diff --git a/apps/server/src/modules/pseudonym/controller/dto/pseudonym.response.ts b/apps/server/src/modules/pseudonym/controller/dto/pseudonym.response.ts new file mode 100644 index 00000000000..3ff77b5b3a4 --- /dev/null +++ b/apps/server/src/modules/pseudonym/controller/dto/pseudonym.response.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class PseudonymResponse { + @ApiProperty() + id: string; + + @ApiProperty() + toolId: string; + + @ApiProperty() + userId: string; + + constructor(response: PseudonymResponse) { + this.id = response.id; + this.toolId = response.toolId; + this.userId = response.userId; + } +} diff --git a/apps/server/src/modules/pseudonym/controller/pseudonym.controller.ts b/apps/server/src/modules/pseudonym/controller/pseudonym.controller.ts new file mode 100644 index 00000000000..02aade8a446 --- /dev/null +++ b/apps/server/src/modules/pseudonym/controller/pseudonym.controller.ts @@ -0,0 +1,38 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { + ApiForbiddenResponse, + ApiFoundResponse, + ApiOperation, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import { Pseudonym } from '@shared/domain'; +import { Authenticate, CurrentUser } from '@src/modules/authentication/decorator/auth.decorator'; +import { ICurrentUser } from '@src/modules/authentication'; +import { PseudonymMapper } from '../mapper/pseudonym.mapper'; +import { PseudonymUc } from '../uc'; +import { PseudonymResponse } from './dto'; +import { PseudonymParams } from './dto/pseudonym-params'; + +@ApiTags('Pseudonym') +@Authenticate('jwt') +@Controller('pseudonyms') +export class PseudonymController { + constructor(private readonly pseudonymUc: PseudonymUc) {} + + @Get(':pseudonym') + @ApiFoundResponse({ description: 'Pseudonym has been found.', type: PseudonymResponse }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + @ApiOperation({ summary: 'Returns the related user and tool information to a pseudonym' }) + async getPseudonym( + @Param() params: PseudonymParams, + @CurrentUser() currentUser: ICurrentUser + ): Promise { + const pseudonym: Pseudonym = await this.pseudonymUc.findPseudonymByPseudonym(currentUser.userId, params.pseudonym); + + const pseudonymResponse: PseudonymResponse = PseudonymMapper.mapToResponse(pseudonym); + + return pseudonymResponse; + } +} diff --git a/apps/server/src/modules/pseudonym/domain/index.ts b/apps/server/src/modules/pseudonym/domain/index.ts new file mode 100644 index 00000000000..9ff491df8d0 --- /dev/null +++ b/apps/server/src/modules/pseudonym/domain/index.ts @@ -0,0 +1 @@ +export * from './pseudonym-search-query'; diff --git a/apps/server/src/modules/pseudonym/domain/pseudonym-search-query.ts b/apps/server/src/modules/pseudonym/domain/pseudonym-search-query.ts new file mode 100644 index 00000000000..2706b0b7cca --- /dev/null +++ b/apps/server/src/modules/pseudonym/domain/pseudonym-search-query.ts @@ -0,0 +1,5 @@ +export interface PseudonymSearchQuery { + pseudonym?: string; + toolId?: string; + userId?: string; +} diff --git a/apps/server/src/modules/pseudonym/entity/pseudonym.scope.spec.ts b/apps/server/src/modules/pseudonym/entity/pseudonym.scope.spec.ts new file mode 100644 index 00000000000..28b020f68e8 --- /dev/null +++ b/apps/server/src/modules/pseudonym/entity/pseudonym.scope.spec.ts @@ -0,0 +1,59 @@ +import { ObjectId, UUID } from 'bson'; +import { PseudonymScope } from './pseudonym.scope'; + +describe('PseudonymScope', () => { + let scope: PseudonymScope; + + beforeEach(() => { + scope = new PseudonymScope(); + scope.allowEmptyQuery(true); + }); + + describe('byPseudonym', () => { + it('should return scope with added pseudonym to query', () => { + const param = UUID.generate().toString(); + + scope.byPseudonym(param); + + expect(scope.query).toEqual({ pseudonym: param }); + }); + + it('should return scope without added pseudonym to query', () => { + scope.byPseudonym(undefined); + + expect(scope.query).toEqual({}); + }); + }); + + describe('byUserId', () => { + it('should return scope with added userId to query', () => { + const param = new ObjectId().toHexString(); + + scope.byUserId(param); + + expect(scope.query).toEqual({ userId: new ObjectId(param) }); + }); + + it('should return scope without added userId to query', () => { + scope.byUserId(undefined); + + expect(scope.query).toEqual({}); + }); + }); + + describe('byToolId', () => { + it('should return scope with added toolId to query', () => { + const param = new ObjectId().toHexString(); + + scope.byToolId(param); + + expect(scope.query).toEqual({ toolId: new ObjectId(param) }); + }); + + it('should return scope without added toolId to query', () => { + scope.byToolId(undefined); + + expect(scope.query).toEqual({}); + }); + }); +}); diff --git a/apps/server/src/modules/pseudonym/entity/pseudonym.scope.ts b/apps/server/src/modules/pseudonym/entity/pseudonym.scope.ts new file mode 100644 index 00000000000..cba4e3b6247 --- /dev/null +++ b/apps/server/src/modules/pseudonym/entity/pseudonym.scope.ts @@ -0,0 +1,26 @@ +import { Scope } from '@shared/repo'; +import { ObjectId } from 'bson'; +import { ExternalToolPseudonymEntity } from './external-tool-pseudonym.entity'; + +export class PseudonymScope extends Scope { + byPseudonym(pseudonym: string | undefined): this { + if (pseudonym) { + this.addQuery({ pseudonym }); + } + return this; + } + + byUserId(userId: string | undefined): this { + if (userId) { + this.addQuery({ userId: new ObjectId(userId) }); + } + return this; + } + + byToolId(toolId: string | undefined): this { + if (toolId) { + this.addQuery({ toolId: new ObjectId(toolId) }); + } + return this; + } +} diff --git a/apps/server/src/modules/pseudonym/mapper/pseudonym.mapper.ts b/apps/server/src/modules/pseudonym/mapper/pseudonym.mapper.ts new file mode 100644 index 00000000000..a12ecdfea70 --- /dev/null +++ b/apps/server/src/modules/pseudonym/mapper/pseudonym.mapper.ts @@ -0,0 +1,14 @@ +import { Pseudonym } from '@shared/domain'; +import { PseudonymResponse } from '../controller/dto'; + +export class PseudonymMapper { + static mapToResponse(pseudonym: Pseudonym): PseudonymResponse { + const response: PseudonymResponse = new PseudonymResponse({ + id: pseudonym.id, + toolId: pseudonym.toolId, + userId: pseudonym.userId, + }); + + return response; + } +} diff --git a/apps/server/src/modules/pseudonym/pseudonym-api.module.ts b/apps/server/src/modules/pseudonym/pseudonym-api.module.ts new file mode 100644 index 00000000000..8ba18f1cb72 --- /dev/null +++ b/apps/server/src/modules/pseudonym/pseudonym-api.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AuthorizationModule } from '@src/modules/authorization'; +import { LegacySchoolModule } from '@src/modules/legacy-school'; +import { PseudonymModule } from './pseudonym.module'; +import { PseudonymController } from './controller/pseudonym.controller'; +import { PseudonymUc } from './uc'; + +@Module({ + imports: [PseudonymModule, AuthorizationModule, LegacySchoolModule], + providers: [PseudonymUc], + controllers: [PseudonymController], +}) +export class PseudonymApiModule {} diff --git a/apps/server/src/modules/pseudonym/pseudonym.module.ts b/apps/server/src/modules/pseudonym/pseudonym.module.ts index 01e649d8374..21da4ef3c59 100644 --- a/apps/server/src/modules/pseudonym/pseudonym.module.ts +++ b/apps/server/src/modules/pseudonym/pseudonym.module.ts @@ -1,10 +1,15 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { LegacyLogger } from '@src/core/logger'; +import { LearnroomModule } from '@src/modules/learnroom'; +import { UserModule } from '@src/modules/user'; +import { ToolModule } from '@src/modules/tool'; +import { AuthorizationModule } from '@src/modules/authorization'; import { ExternalToolPseudonymRepo, PseudonymsRepo } from './repo'; -import { PseudonymService } from './service'; +import { FeathersRosterService, PseudonymService } from './service'; @Module({ - providers: [PseudonymService, PseudonymsRepo, ExternalToolPseudonymRepo, LegacyLogger], - exports: [PseudonymService], + imports: [UserModule, LearnroomModule, forwardRef(() => ToolModule), forwardRef(() => AuthorizationModule)], + providers: [PseudonymService, PseudonymsRepo, ExternalToolPseudonymRepo, LegacyLogger, FeathersRosterService], + exports: [PseudonymService, FeathersRosterService], }) export class PseudonymModule {} diff --git a/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.spec.ts b/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.integration.spec.ts similarity index 62% rename from apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.spec.ts rename to apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.integration.spec.ts index b30506114fb..9d3711aff02 100644 --- a/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.spec.ts +++ b/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.integration.spec.ts @@ -2,12 +2,13 @@ import { createMock } from '@golevelup/ts-jest'; import { NotFoundError } from '@mikro-orm/core'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; +import { Page, Pseudonym } from '@shared/domain'; import { MongoMemoryDatabaseModule } from '@shared/infra/database'; -import { cleanupCollections, pseudonymFactory, externalToolPseudonymEntityFactory, userFactory } from '@shared/testing'; +import { cleanupCollections, externalToolPseudonymEntityFactory, pseudonymFactory, userFactory } from '@shared/testing'; import { pseudonymEntityFactory } from '@shared/testing/factory/pseudonym.factory'; import { LegacyLogger } from '@src/core/logger'; import { v4 as uuidv4 } from 'uuid'; -import { Pseudonym } from '@shared/domain'; +import { PseudonymSearchQuery } from '../domain'; import { ExternalToolPseudonymEntity } from '../entity'; import { ExternalToolPseudonymRepo } from './external-tool-pseudonym.repo'; @@ -280,4 +281,168 @@ describe('ExternalToolPseudonymRepo', () => { }); }); }); + + describe('findPseudonymByPseudonym', () => { + describe('when pseudonym is existing', () => { + const setup = async () => { + const entity: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.buildWithId(); + await em.persistAndFlush(entity); + em.clear(); + + return { + entity, + }; + }; + + it('should return a pseudonym', async () => { + const { entity } = await setup(); + + const result: Pseudonym | null = await repo.findPseudonymByPseudonym(entity.pseudonym); + + expect(result?.id).toEqual(entity.id); + }); + }); + + describe('when pseudonym not exists', () => { + it('should return null', async () => { + const pseudonym: Pseudonym | null = await repo.findPseudonymByPseudonym(uuidv4()); + + expect(pseudonym).toBeNull(); + }); + }); + }); + + describe('findPseudonym', () => { + describe('when query with all parameters is given', () => { + const setup = async () => { + const query: PseudonymSearchQuery = { + userId: new ObjectId().toHexString(), + }; + + const pseudonym1: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.build({ + userId: query.userId, + toolId: new ObjectId().toHexString(), + pseudonym: 'pseudonym1', + }); + + const pseudonym2: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.build({ + userId: query.userId, + toolId: new ObjectId().toHexString(), + pseudonym: 'pseudonym2', + }); + + const pseudonym3: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.build({ + userId: query.userId, + toolId: new ObjectId().toHexString(), + pseudonym: 'pseudonym3', + }); + + const pseudonyms: ExternalToolPseudonymEntity[] = [pseudonym1, pseudonym2, pseudonym3]; + + await em.persistAndFlush([pseudonym1, pseudonym2, pseudonym3]); + em.clear(); + + return { + query, + pseudonyms, + }; + }; + + it('should return all three pseudonyms', async () => { + const { query, pseudonyms } = await setup(); + + const page: Page = await repo.findPseudonym(query); + + expect(page.data.length).toEqual(pseudonyms.length); + }); + }); + + describe('when pagination has a limit of 1', () => { + const setup = async () => { + const query: PseudonymSearchQuery = { + userId: new ObjectId().toHexString(), + }; + + const pseudonym1: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.build({ + userId: query.userId, + toolId: new ObjectId().toHexString(), + pseudonym: 'pseudonym1', + }); + + const pseudonym2: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.build({ + userId: query.userId, + toolId: new ObjectId().toHexString(), + pseudonym: 'pseudonym2', + }); + + const pseudonym3: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.build({ + userId: query.userId, + toolId: new ObjectId().toHexString(), + pseudonym: 'pseudonym3', + }); + + const pseudonyms: ExternalToolPseudonymEntity[] = [pseudonym1, pseudonym2, pseudonym3]; + + await em.persistAndFlush([pseudonym1, pseudonym2, pseudonym3]); + em.clear(); + + return { + query, + pseudonyms, + }; + }; + + it('should return one pseudonym', async () => { + const { query } = await setup(); + + const page: Page = await repo.findPseudonym(query, { pagination: { limit: 1 } }); + + expect(page.data.length).toEqual(1); + }); + }); + + describe('when pagination has a limit of 1 and skip is set to 2', () => { + const setup = async () => { + const query: PseudonymSearchQuery = { + userId: new ObjectId().toHexString(), + }; + + const pseudonym1: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.build({ + userId: query.userId, + toolId: new ObjectId().toHexString(), + pseudonym: 'pseudonym1', + }); + + const pseudonym2: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.build({ + userId: query.userId, + toolId: new ObjectId().toHexString(), + pseudonym: 'pseudonym2', + }); + + const pseudonym3: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.build({ + userId: query.userId, + toolId: new ObjectId().toHexString(), + pseudonym: 'pseudonym3', + }); + + const pseudonyms: ExternalToolPseudonymEntity[] = [pseudonym1, pseudonym2, pseudonym3]; + + await em.persistAndFlush([pseudonym1, pseudonym2, pseudonym3]); + em.clear(); + + return { + query, + pseudonyms, + }; + }; + + it('should return the third element', async () => { + const { query, pseudonyms } = await setup(); + + const page: Page = await repo.findPseudonym(query, { pagination: { skip: 2 } }); + + expect(page.data[0].id).toEqual(pseudonyms[2].id); + }); + }); + }); }); diff --git a/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.ts b/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.ts index f35f5037ea9..79a17a80541 100644 --- a/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.ts +++ b/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.ts @@ -1,7 +1,10 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; -import { EntityId, Pseudonym } from '@shared/domain'; +import { EntityId, IFindOptions, IPagination, Page, Pseudonym } from '@shared/domain'; +import { Scope } from '@shared/repo'; +import { PseudonymSearchQuery } from '../domain'; import { ExternalToolPseudonymEntity, IExternalToolPseudonymEntityProps } from '../entity'; +import { PseudonymScope } from '../entity/pseudonym.scope'; @Injectable() export class ExternalToolPseudonymRepo { @@ -71,8 +74,22 @@ export class ExternalToolPseudonymRepo { return promise; } + async findPseudonymByPseudonym(pseudonym: string): Promise { + const entities: ExternalToolPseudonymEntity | null = await this.em.findOne(ExternalToolPseudonymEntity, { + pseudonym, + }); + + if (!entities) { + return null; + } + + const domainObject: Pseudonym = this.mapEntityToDomainObject(entities); + + return domainObject; + } + protected mapEntityToDomainObject(entity: ExternalToolPseudonymEntity): Pseudonym { - return new Pseudonym({ + const pseudonym = new Pseudonym({ id: entity.id, pseudonym: entity.pseudonym, toolId: entity.toolId.toHexString(), @@ -80,6 +97,8 @@ export class ExternalToolPseudonymRepo { createdAt: entity.createdAt, updatedAt: entity.updatedAt, }); + + return pseudonym; } protected mapDomainObjectToEntityProperties(entityDO: Pseudonym): IExternalToolPseudonymEntityProps { @@ -89,4 +108,23 @@ export class ExternalToolPseudonymRepo { userId: new ObjectId(entityDO.userId), }; } + + async findPseudonym(query: PseudonymSearchQuery, options?: IFindOptions): Promise> { + const pagination: IPagination = options?.pagination ?? {}; + const scope: Scope = new PseudonymScope() + .byPseudonym(query.pseudonym) + .byToolId(query.toolId) + .byUserId(query.userId) + .allowEmptyQuery(true); + + const [entities, total] = await this.em.findAndCount(ExternalToolPseudonymEntity, scope.query, { + offset: pagination?.skip, + limit: pagination?.limit, + }); + + const entityDos: Pseudonym[] = entities.map((entity) => this.mapEntityToDomainObject(entity)); + const page: Page = new Page(entityDos, total); + + return page; + } } diff --git a/apps/server/src/modules/pseudonym/repo/pseudonyms.repo.spec.ts b/apps/server/src/modules/pseudonym/repo/pseudonyms.repo.spec.ts index 63440796960..548ba1b0512 100644 --- a/apps/server/src/modules/pseudonym/repo/pseudonyms.repo.spec.ts +++ b/apps/server/src/modules/pseudonym/repo/pseudonyms.repo.spec.ts @@ -2,14 +2,14 @@ import { createMock } from '@golevelup/ts-jest'; import { NotFoundError } from '@mikro-orm/core'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; +import { Pseudonym } from '@shared/domain'; import { MongoMemoryDatabaseModule } from '@shared/infra/database'; import { cleanupCollections, pseudonymFactory, userFactory } from '@shared/testing'; import { pseudonymEntityFactory } from '@shared/testing/factory/pseudonym.factory'; -import { v4 as uuidv4 } from 'uuid'; import { LegacyLogger } from '@src/core/logger'; -import { Pseudonym } from '@shared/domain'; -import { PseudonymsRepo } from './pseudonyms.repo'; +import { v4 as uuidv4 } from 'uuid'; import { PseudonymEntity } from '../entity'; +import { PseudonymsRepo } from './pseudonyms.repo'; describe('PseudonymRepo', () => { let module: TestingModule; diff --git a/apps/server/src/modules/pseudonym/service/feathers-roster.service.spec.ts b/apps/server/src/modules/pseudonym/service/feathers-roster.service.spec.ts new file mode 100644 index 00000000000..6c55067552d --- /dev/null +++ b/apps/server/src/modules/pseudonym/service/feathers-roster.service.spec.ts @@ -0,0 +1,634 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { DatabaseObjectNotFoundException } from '@mikro-orm/core'; +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { Course, Pseudonym, RoleName, LegacySchoolDo, UserDO, SchoolEntity } from '@shared/domain'; +import { + contextExternalToolFactory, + courseFactory, + externalToolFactory, + pseudonymFactory, + legacySchoolDoFactory, + schoolExternalToolFactory, + schoolFactory, + setupEntities, + UserAndAccountTestFactory, + userDoFactory, +} from '@shared/testing'; +import { CourseService } from '@src/modules/learnroom/service/course.service'; +import { ToolContextType } from '@src/modules/tool/common/enum'; +import { ContextExternalTool, ContextRef } from '@src/modules/tool/context-external-tool/domain'; +import { ContextExternalToolService } from '@src/modules/tool/context-external-tool/service'; +import { ExternalTool } from '@src/modules/tool/external-tool/domain'; +import { ExternalToolService } from '@src/modules/tool/external-tool/service'; +import { SchoolExternalTool } from '@src/modules/tool/school-external-tool/domain'; +import { SchoolExternalToolService } from '@src/modules/tool/school-external-tool/service'; +import { UserService } from '@src/modules/user'; +import { ObjectId } from 'bson'; +import { FeathersRosterService } from './feathers-roster.service'; +import { PseudonymService } from './pseudonym.service'; + +describe('FeathersRosterService', () => { + let module: TestingModule; + let service: FeathersRosterService; + + let userService: DeepMocked; + let pseudonymService: DeepMocked; + let courseService: DeepMocked; + let externalToolService: DeepMocked; + let schoolExternalToolService: DeepMocked; + let contextExternalToolService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + FeathersRosterService, + { + provide: UserService, + useValue: createMock(), + }, + { + provide: PseudonymService, + useValue: createMock(), + }, + { + provide: CourseService, + useValue: createMock(), + }, + { + provide: ExternalToolService, + useValue: createMock(), + }, + { + provide: SchoolExternalToolService, + useValue: createMock(), + }, + { + provide: ContextExternalToolService, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(FeathersRosterService); + pseudonymService = module.get(PseudonymService); + userService = module.get(UserService); + courseService = module.get(CourseService); + externalToolService = module.get(ExternalToolService); + schoolExternalToolService = module.get(SchoolExternalToolService); + contextExternalToolService = module.get(ContextExternalToolService); + + await setupEntities(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('getUsersMetadata', () => { + describe('when pseudonym is given', () => { + const setup = () => { + const pseudonym: Pseudonym = pseudonymFactory.build(); + const user: UserDO = userDoFactory + .withRoles([{ id: new ObjectId().toHexString(), name: RoleName.STUDENT }]) + .build(); + const iFrameSubject = 'iFrameSubject'; + + pseudonymService.findPseudonymByPseudonym.mockResolvedValue(pseudonym); + userService.findById.mockResolvedValue(user); + pseudonymService.getIframeSubject.mockReturnValue(iFrameSubject); + + return { + pseudonym, + user, + iFrameSubject, + }; + }; + + it('should call the pseudonym service to find the pseudonym', async () => { + const { pseudonym } = setup(); + + await service.getUsersMetadata(pseudonym.pseudonym); + + expect(pseudonymService.findPseudonymByPseudonym).toHaveBeenCalledWith(pseudonym.pseudonym); + }); + + it('should call the user service to find the user', async () => { + const { pseudonym } = setup(); + + await service.getUsersMetadata(pseudonym.pseudonym); + + expect(userService.findById).toHaveBeenCalledWith(pseudonym.userId); + }); + + it('should call the pseudonym service to get the iframe subject', async () => { + const { pseudonym } = setup(); + + await service.getUsersMetadata(pseudonym.pseudonym); + + expect(pseudonymService.getIframeSubject).toHaveBeenCalledWith(pseudonym.pseudonym); + }); + + it('should return user metadata', async () => { + const { pseudonym, user, iFrameSubject } = setup(); + + const result = await service.getUsersMetadata(pseudonym.pseudonym); + + expect(result).toEqual({ + data: { + user_id: user.id, + username: iFrameSubject, + type: 'student', + }, + }); + }); + }); + + describe('when pseudonym does not exists', () => { + const setup = () => { + const pseudonym: Pseudonym = pseudonymFactory.build(); + + pseudonymService.findPseudonymByPseudonym.mockResolvedValue(null); + + return { + pseudonym, + }; + }; + + it('should throw an NotFoundLoggableException', async () => { + const { pseudonym } = setup(); + + const func = service.getUsersMetadata(pseudonym.pseudonym); + + await expect(func).rejects.toThrow( + new NotFoundLoggableException(UserDO.name, 'pseudonym', pseudonym.pseudonym) + ); + }); + }); + + describe('when user does not exists', () => { + const setup = () => { + const pseudonym: Pseudonym = pseudonymFactory.build(); + const user: UserDO = userDoFactory + .withRoles([{ id: new ObjectId().toHexString(), name: RoleName.STUDENT }]) + .build(); + + pseudonymService.findPseudonymByPseudonym.mockResolvedValue(pseudonym); + userService.findById.mockRejectedValueOnce(new DatabaseObjectNotFoundException(new Error())); + + return { + pseudonym, + user, + }; + }; + + it('should throw database exception', async () => { + const { pseudonym } = setup(); + + const func = service.getUsersMetadata(pseudonym.pseudonym); + + await expect(func).rejects.toThrow(DatabaseObjectNotFoundException); + }); + }); + }); + + describe('getUserGroups', () => { + describe('when pseudonym is given', () => { + const setup = () => { + const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); + const clientId = 'testClientId'; + const externalTool: ExternalTool = externalToolFactory.withOauth2Config({ clientId }).buildWithId(); + const externalToolId: string = externalTool.id as string; + + const otherExternalTool: ExternalTool = externalToolFactory.buildWithId(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: externalTool.id, + schoolId: school.id, + }); + const otherSchoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: otherExternalTool.id, + schoolId: school.id, + }); + const pseudonym: Pseudonym = pseudonymFactory.build(); + const user: UserDO = userDoFactory + .withRoles([{ id: new ObjectId().toHexString(), name: RoleName.STUDENT }]) + .build(); + const courseA: Course = courseFactory.buildWithId(); + const courseB: Course = courseFactory.buildWithId(); + const courseC: Course = courseFactory.buildWithId(); + const courses: Course[] = [courseA, courseB, courseC]; + + const contextExternalTool: ContextExternalTool = contextExternalToolFactory + .withSchoolExternalToolRef(schoolExternalTool.id as string, schoolExternalTool.schoolId) + .buildWithId({ + contextRef: new ContextRef({ + id: courseA.id, + type: ToolContextType.COURSE, + }), + }); + + const otherContextExternalTool: ContextExternalTool = contextExternalToolFactory + .withSchoolExternalToolRef(otherSchoolExternalTool.id as string, otherSchoolExternalTool.schoolId) + .buildWithId({ + contextRef: new ContextRef({ + id: courseA.id, + type: ToolContextType.COURSE, + }), + }); + + pseudonymService.findPseudonymByPseudonym.mockResolvedValue(pseudonym); + externalToolService.findExternalToolByOAuth2ConfigClientId.mockResolvedValue(externalTool); + courseService.findAllByUserId.mockResolvedValue(courses); + contextExternalToolService.findAllByContext.mockResolvedValueOnce([ + contextExternalTool, + otherContextExternalTool, + ]); + contextExternalToolService.findAllByContext.mockResolvedValueOnce([otherContextExternalTool]); + contextExternalToolService.findAllByContext.mockResolvedValueOnce([]); + schoolExternalToolService.getSchoolExternalToolById.mockResolvedValueOnce(schoolExternalTool); + schoolExternalToolService.getSchoolExternalToolById.mockResolvedValueOnce(otherSchoolExternalTool); + externalToolService.findExternalToolById.mockResolvedValueOnce(externalTool); + externalToolService.findExternalToolById.mockResolvedValueOnce(otherExternalTool); + + return { + pseudonym, + externalToolId, + clientId, + user, + courses, + schoolExternalTool, + otherSchoolExternalTool, + otherExternalTool, + }; + }; + + it('should call the pseudonym service to find the pseudonym', async () => { + const { pseudonym, clientId } = setup(); + + await service.getUserGroups(pseudonym.pseudonym, clientId); + + expect(pseudonymService.findPseudonymByPseudonym).toHaveBeenCalledWith(pseudonym.pseudonym); + }); + + it('should call the course service to find the courses for the userId of the pseudonym', async () => { + const { pseudonym, clientId } = setup(); + + await service.getUserGroups(pseudonym.pseudonym, clientId); + + expect(courseService.findAllByUserId).toHaveBeenCalledWith(pseudonym.userId); + }); + + it('should call the context external tool service to find the external tools for each course', async () => { + const { pseudonym, courses, clientId } = setup(); + + await service.getUserGroups(pseudonym.pseudonym, clientId); + + expect(contextExternalToolService.findAllByContext.mock.calls).toEqual([ + [new ContextRef({ id: courses[0].id, type: ToolContextType.COURSE })], + [new ContextRef({ id: courses[1].id, type: ToolContextType.COURSE })], + [new ContextRef({ id: courses[2].id, type: ToolContextType.COURSE })], + ]); + }); + + it('should call school external tool service to find the school external tool for each context external tool', async () => { + const { pseudonym, clientId, schoolExternalTool, otherSchoolExternalTool } = setup(); + + await service.getUserGroups(pseudonym.pseudonym, clientId); + + expect(schoolExternalToolService.getSchoolExternalToolById.mock.calls).toEqual([ + [schoolExternalTool.id], + [otherSchoolExternalTool.id], + ]); + }); + + it('should call external tool service to find the external tool for each school external tool', async () => { + const { pseudonym, clientId, otherExternalTool, externalToolId } = setup(); + + await service.getUserGroups(pseudonym.pseudonym, clientId); + + expect(externalToolService.findExternalToolById.mock.calls).toEqual([[externalToolId], [otherExternalTool.id]]); + }); + + it('should return a group for each course where the tool of the users pseudonym is used', async () => { + const { pseudonym, clientId, courses } = setup(); + + const result = await service.getUserGroups(pseudonym.pseudonym, clientId); + + expect(result).toEqual({ + data: { + groups: [ + { + group_id: courses[0].id, + name: courses[0].name, + student_count: courses[0].students.count(), + }, + ], + }, + }); + }); + }); + + describe('when pseudonym does not exists', () => { + const setup = () => { + const pseudonym: Pseudonym = pseudonymFactory.build(); + + pseudonymService.findPseudonymByPseudonym.mockResolvedValue(null); + + return { + pseudonym, + }; + }; + + it('should throw an NotFoundLoggableException', async () => { + const { pseudonym } = setup(); + + const func = service.getUserGroups(pseudonym.pseudonym, 'externalToolId'); + + await expect(func).rejects.toThrow( + new NotFoundLoggableException(UserDO.name, 'pseudonym', pseudonym.pseudonym) + ); + }); + }); + }); + + describe('getGroup', () => { + describe('when valid courseId and oauth2ClientId is given', () => { + const setup = () => { + let courseA: Course = courseFactory.buildWithId(); + const schoolEntity: SchoolEntity = schoolFactory.buildWithId(); + const school: LegacySchoolDo = legacySchoolDoFactory.build({ id: schoolEntity.id }); + const externalTool: ExternalTool = externalToolFactory.buildWithId(); + const externalToolId: string = externalTool.id as string; + const otherExternalTool: ExternalTool = externalToolFactory.buildWithId(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: externalTool.id, + schoolId: school.id, + }); + const otherSchoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: otherExternalTool.id, + schoolId: school.id, + }); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory + .withSchoolExternalToolRef(schoolExternalTool.id as string, schoolExternalTool.schoolId) + .buildWithId({ + contextRef: new ContextRef({ + id: courseA.id, + type: ToolContextType.COURSE, + }), + }); + + const otherContextExternalTool: ContextExternalTool = contextExternalToolFactory + .withSchoolExternalToolRef(otherSchoolExternalTool.id as string, otherSchoolExternalTool.schoolId) + .buildWithId({ + contextRef: new ContextRef({ + id: courseA.id, + type: ToolContextType.COURSE, + }), + }); + + const { studentUser } = UserAndAccountTestFactory.buildStudent(); + const student1: UserDO = userDoFactory.build({ id: studentUser.id }); + const student1Pseudonym: Pseudonym = pseudonymFactory.build({ + userId: student1.id, + toolId: contextExternalTool.id, + }); + + const { studentUser: studentUser2 } = UserAndAccountTestFactory.buildStudent(); + const student2: UserDO = userDoFactory.build({ id: studentUser2.id }); + const student2Pseudonym: Pseudonym = pseudonymFactory.build({ + userId: student2.id, + toolId: contextExternalTool.id, + }); + + const { teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const teacher: UserDO = userDoFactory.build({ id: teacherUser.id }); + const teacherPseudonym: Pseudonym = pseudonymFactory.build({ + userId: teacher.id, + toolId: contextExternalTool.id, + }); + + const { teacherUser: substitutionTeacherUser } = UserAndAccountTestFactory.buildTeacher(); + const substitutionTeacher: UserDO = userDoFactory.build({ id: substitutionTeacherUser.id }); + const substitutionTeacherPseudonym: Pseudonym = pseudonymFactory.build({ + userId: substitutionTeacher.id, + toolId: contextExternalTool.id, + }); + + courseA = courseFactory.build({ + ...courseA, + school: schoolEntity, + students: [studentUser, studentUser2], + teachers: [teacherUser], + substitutionTeachers: [substitutionTeacherUser], + }); + + courseService.findById.mockResolvedValue(courseA); + externalToolService.findExternalToolByOAuth2ConfigClientId.mockResolvedValue(externalTool); + schoolExternalToolService.findSchoolExternalTools.mockResolvedValueOnce([schoolExternalTool]); + contextExternalToolService.findAllByContext.mockResolvedValueOnce([ + contextExternalTool, + otherContextExternalTool, + ]); + + userService.findById.mockResolvedValueOnce(student1); + pseudonymService.findOrCreatePseudonym.mockResolvedValueOnce(student1Pseudonym); + userService.findById.mockResolvedValueOnce(student2); + pseudonymService.findOrCreatePseudonym.mockResolvedValueOnce(student2Pseudonym); + userService.findById.mockResolvedValueOnce(teacher); + pseudonymService.findOrCreatePseudonym.mockResolvedValueOnce(teacherPseudonym); + userService.findById.mockResolvedValueOnce(substitutionTeacher); + pseudonymService.findOrCreatePseudonym.mockResolvedValueOnce(substitutionTeacherPseudonym); + + const mockedIframeSubject = 'mockedIframeSubject'; + pseudonymService.getIframeSubject.mockReturnValue(mockedIframeSubject); + + return { + externalTool, + externalToolId, + courseA, + schoolExternalTool, + mockedIframeSubject, + student1, + student2, + teacher, + substitutionTeacher, + student1Pseudonym, + student2Pseudonym, + teacherPseudonym, + substitutionTeacherPseudonym, + }; + }; + + it('should call the course service to find the course', async () => { + const { externalToolId, courseA } = setup(); + + await service.getGroup(courseA.id, externalToolId); + + expect(courseService.findById).toHaveBeenCalledWith(courseA.id); + }); + + it('should call the external tool service to find the external tool', async () => { + const { externalToolId } = setup(); + + await service.getGroup('courseId', externalToolId); + + expect(externalToolService.findExternalToolByOAuth2ConfigClientId).toHaveBeenCalledWith(externalToolId); + }); + + it('should call the school external tool service to find the school external tool', async () => { + const { externalToolId, schoolExternalTool } = setup(); + + await service.getGroup('courseId', externalToolId); + + expect(schoolExternalToolService.findSchoolExternalTools).toHaveBeenCalledWith({ + schoolId: schoolExternalTool.schoolId, + toolId: schoolExternalTool.toolId, + }); + }); + + it('should call the context external tool service to find the context external tool', async () => { + const { externalToolId, courseA } = setup(); + + await service.getGroup(courseA.id, externalToolId); + + expect(contextExternalToolService.findAllByContext).toHaveBeenCalledWith( + new ContextRef({ id: courseA.id, type: ToolContextType.COURSE }) + ); + }); + + it('should call the user service to find the students', async () => { + const { externalToolId, courseA } = setup(); + + await service.getGroup(courseA.id, externalToolId); + + expect(userService.findById.mock.calls).toEqual([ + [courseA.students[0].id], + [courseA.students[1].id], + [courseA.teachers[0].id], + [courseA.substitutionTeachers[0].id], + ]); + }); + + it('should call the pseudonym service to find the pseudonyms', async () => { + const { externalToolId, externalTool, courseA, student1, student2, teacher, substitutionTeacher } = setup(); + + await service.getGroup(courseA.id, externalToolId); + + expect(pseudonymService.findOrCreatePseudonym.mock.calls).toEqual([ + [student1, externalTool], + [student2, externalTool], + [teacher, externalTool], + [substitutionTeacher, externalTool], + ]); + }); + + it('should return a group for the course where the tool of the users pseudonym is used', async () => { + const { + externalToolId, + courseA, + mockedIframeSubject, + student1Pseudonym, + student2Pseudonym, + teacherPseudonym, + substitutionTeacherPseudonym, + } = setup(); + + const result = await service.getGroup(courseA.id, externalToolId); + + expect(result).toEqual({ + data: { + students: [ + { + user_id: student1Pseudonym.pseudonym, + username: mockedIframeSubject, + }, + { + user_id: student2Pseudonym.pseudonym, + username: mockedIframeSubject, + }, + ], + teachers: [ + { + user_id: teacherPseudonym.pseudonym, + username: mockedIframeSubject, + }, + { + user_id: substitutionTeacherPseudonym.pseudonym, + username: mockedIframeSubject, + }, + ], + }, + }); + }); + }); + + describe('when invalid oauth2Client was given and external tool was not found', () => { + const setup = () => { + externalToolService.findExternalToolByOAuth2ConfigClientId.mockResolvedValueOnce(null); + }; + + it('should throw an NotFoundLoggableException', async () => { + setup(); + + const func = service.getGroup('courseId', 'oauth2ClientId'); + + await expect(func).rejects.toThrow( + new NotFoundLoggableException(ExternalTool.name, 'config.clientId', 'oauth2ClientId') + ); + }); + }); + + describe('when no school external tool was found which belongs to the external tool', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.buildWithId(); + const externalToolId: string = externalTool.id as string; + const course: Course = courseFactory.buildWithId(); + + courseService.findById.mockResolvedValue(course); + externalToolService.findExternalToolByOAuth2ConfigClientId.mockResolvedValueOnce(externalTool); + schoolExternalToolService.findSchoolExternalTools.mockResolvedValueOnce([]); + + return { + externalToolId, + }; + }; + + it('should throw an NotFoundLoggableException', async () => { + const { externalToolId } = setup(); + + const func = service.getGroup('courseId', 'oauth2ClientId'); + + await expect(func).rejects.toThrow( + new NotFoundLoggableException(SchoolExternalTool.name, 'toolId', externalToolId) + ); + }); + }); + + describe('when no context external tool was found which belongs to the course', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.buildWithId(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const course: Course = courseFactory.buildWithId(); + + courseService.findById.mockResolvedValue(course); + externalToolService.findExternalToolByOAuth2ConfigClientId.mockResolvedValueOnce(externalTool); + schoolExternalToolService.findSchoolExternalTools.mockResolvedValueOnce([schoolExternalTool]); + contextExternalToolService.findAllByContext.mockResolvedValueOnce([]); + }; + + it('should throw an NotFoundLoggableException', async () => { + setup(); + + const func = service.getGroup('courseId', 'oauth2ClientId'); + + await expect(func).rejects.toThrow( + new NotFoundLoggableException(ContextExternalTool.name, 'contextRef.id', 'courseId') + ); + }); + }); + }); +}); diff --git a/apps/server/src/modules/pseudonym/service/feathers-roster.service.ts b/apps/server/src/modules/pseudonym/service/feathers-roster.service.ts new file mode 100644 index 00000000000..a5fd359b6c1 --- /dev/null +++ b/apps/server/src/modules/pseudonym/service/feathers-roster.service.ts @@ -0,0 +1,242 @@ +import { Injectable } from '@nestjs/common'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { Course, EntityId, Pseudonym, RoleName, RoleReference, UserDO } from '@shared/domain'; +import { CourseService } from '@src/modules/learnroom/service'; +import { ToolContextType } from '@src/modules/tool/common/enum'; +import { ContextExternalTool, ContextRef } from '@src/modules/tool/context-external-tool/domain'; +import { ContextExternalToolService } from '@src/modules/tool/context-external-tool/service'; +import { ExternalTool } from '@src/modules/tool/external-tool/domain'; +import { ExternalToolService } from '@src/modules/tool/external-tool/service'; +import { SchoolExternalTool } from '@src/modules/tool/school-external-tool/domain'; +import { SchoolExternalToolService } from '@src/modules/tool/school-external-tool/service'; +import { UserService } from '@src/modules/user'; +import { PseudonymService } from './pseudonym.service'; + +interface UserMetdata { + data: { + user_id: string; + username: string; + type: string; + }; +} + +interface UserGroups { + data: { + groups: UserGroup[]; + }; +} + +interface UserGroup { + group_id: string; + name: string; + student_count: number; +} + +interface UserData { + user_id: string; + username: string; +} + +interface Group { + data: { + students: UserData[]; + teachers: UserData[]; + }; +} + +/** + * Please do not use this service in any other nest modules. + * This service will be called from feathers to get the roster data for ctl pseudonyms {@link ExternalToolPseudonymEntity}. + * These data will be used e.g. by bettermarks to resolve and display the usernames. + */ +@Injectable() +export class FeathersRosterService { + constructor( + private readonly userService: UserService, + private readonly pseudonymService: PseudonymService, + private readonly courseService: CourseService, + private readonly externalToolService: ExternalToolService, + private readonly schoolExternalToolService: SchoolExternalToolService, + private readonly contextExternalToolService: ContextExternalToolService + ) {} + + async getUsersMetadata(pseudonym: string): Promise { + const loadedPseudonym: Pseudonym = await this.findPseudonymByPseudonym(pseudonym); + const user: UserDO = await this.userService.findById(loadedPseudonym.userId); + + const userMetadata: UserMetdata = { + data: { + user_id: user.id as string, + username: this.pseudonymService.getIframeSubject(loadedPseudonym.pseudonym), + type: this.getUserRole(user), + }, + }; + + return userMetadata; + } + + async getUserGroups(pseudonym: string, oauth2ClientId: string): Promise { + const loadedPseudonym: Pseudonym = await this.findPseudonymByPseudonym(pseudonym); + const externalTool: ExternalTool = await this.validateAndGetExternalTool(oauth2ClientId); + + let courses: Course[] = await this.getCoursesFromUsersPseudonym(loadedPseudonym); + courses = await this.filterCoursesByToolAvailability(courses, externalTool.id as string); + + const userGroups: UserGroups = { + data: { + groups: courses.map((course) => { + return { + group_id: course.id, + name: course.name, + student_count: course.students.length, + }; + }), + }, + }; + + return userGroups; + } + + async getGroup(courseId: EntityId, oauth2ClientId: string): Promise { + const course: Course = await this.courseService.findById(courseId); + const externalTool: ExternalTool = await this.validateAndGetExternalTool(oauth2ClientId); + + await this.validateSchoolExternalTool(course.school.id, externalTool.id as string); + await this.validateContextExternalTools(courseId); + + const [studentEntities, teacherEntities, substitutionTeacherEntities] = await Promise.all([ + course.students.loadItems(), + course.teachers.loadItems(), + course.substitutionTeachers.loadItems(), + ]); + + const [students, teachers, substitutionTeachers] = await Promise.all([ + Promise.all(studentEntities.map((user) => this.userService.findById(user.id))), + Promise.all(teacherEntities.map((user) => this.userService.findById(user.id))), + Promise.all(substitutionTeacherEntities.map((user) => this.userService.findById(user.id))), + ]); + + const [studentPseudonyms, teacherPseudonyms, substitutionTeacherPseudonyms] = await Promise.all([ + this.getAndPseudonyms(students, externalTool), + this.getAndPseudonyms(teachers, externalTool), + this.getAndPseudonyms(substitutionTeachers, externalTool), + ]); + + const allTeacherPseudonyms: Pseudonym[] = teacherPseudonyms.concat(substitutionTeacherPseudonyms); + + const group: Group = { + data: { + students: studentPseudonyms.map((pseudonym: Pseudonym) => this.mapPseudonymToUserData(pseudonym)), + teachers: allTeacherPseudonyms.map((pseudonym: Pseudonym) => this.mapPseudonymToUserData(pseudonym)), + }, + }; + + return group; + } + + private async getAndPseudonyms(users: UserDO[], externalTool: ExternalTool): Promise { + const pseudonyms: Pseudonym[] = await Promise.all( + users.map((user: UserDO) => this.pseudonymService.findOrCreatePseudonym(user, externalTool)) + ); + + return pseudonyms; + } + + private getUserRole(user: UserDO): string { + const roleName = user.roles.some((role: RoleReference) => role.name === RoleName.TEACHER) + ? RoleName.TEACHER + : RoleName.STUDENT; + + return roleName; + } + + private async findPseudonymByPseudonym(pseudonym: string): Promise { + const loadedPseudonym: Pseudonym | null = await this.pseudonymService.findPseudonymByPseudonym(pseudonym); + + if (!loadedPseudonym) { + throw new NotFoundLoggableException(Pseudonym.name, 'pseudonym', pseudonym); + } + + return loadedPseudonym; + } + + private async getCoursesFromUsersPseudonym(pseudonym: Pseudonym): Promise { + const courses: Course[] = await this.courseService.findAllByUserId(pseudonym.userId); + + return courses; + } + + private async filterCoursesByToolAvailability(courses: Course[], externalToolId: string): Promise { + const validCourses: Course[] = []; + + await Promise.all( + courses.map(async (course: Course) => { + const contextExternalTools: ContextExternalTool[] = await this.contextExternalToolService.findAllByContext( + new ContextRef({ + id: course.id, + type: ToolContextType.COURSE, + }) + ); + + for await (const contextExternalTool of contextExternalTools) { + const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.getSchoolExternalToolById( + contextExternalTool.schoolToolRef.schoolToolId + ); + const externalTool: ExternalTool = await this.externalToolService.findExternalToolById( + schoolExternalTool.toolId + ); + const isRequiredTool: boolean = externalTool.id === externalToolId; + + if (isRequiredTool) { + validCourses.push(course); + break; + } + } + }) + ); + + return validCourses; + } + + private async validateAndGetExternalTool(oauth2ClientId: string): Promise { + const externalTool: ExternalTool | null = await this.externalToolService.findExternalToolByOAuth2ConfigClientId( + oauth2ClientId + ); + + if (!externalTool || !externalTool.id) { + throw new NotFoundLoggableException(ExternalTool.name, 'config.clientId', oauth2ClientId); + } + + return externalTool; + } + + private async validateSchoolExternalTool(schoolId: EntityId, toolId: string): Promise { + const schoolExternalTools: SchoolExternalTool[] = await this.schoolExternalToolService.findSchoolExternalTools({ + schoolId, + toolId, + }); + + if (schoolExternalTools.length === 0) { + throw new NotFoundLoggableException(SchoolExternalTool.name, 'toolId', toolId); + } + } + + private async validateContextExternalTools(courseId: EntityId): Promise { + const contextExternalTools: ContextExternalTool[] = await this.contextExternalToolService.findAllByContext( + new ContextRef({ id: courseId, type: ToolContextType.COURSE }) + ); + + if (contextExternalTools.length === 0) { + throw new NotFoundLoggableException(ContextExternalTool.name, 'contextRef.id', courseId); + } + } + + private mapPseudonymToUserData(pseudonym: Pseudonym): UserData { + const userData: UserData = { + user_id: pseudonym.pseudonym, + username: this.pseudonymService.getIframeSubject(pseudonym.pseudonym), + }; + + return userData; + } +} diff --git a/apps/server/src/modules/pseudonym/service/index.ts b/apps/server/src/modules/pseudonym/service/index.ts index 04ad0bac3ab..c0474020149 100644 --- a/apps/server/src/modules/pseudonym/service/index.ts +++ b/apps/server/src/modules/pseudonym/service/index.ts @@ -1 +1,2 @@ export * from './pseudonym.service'; +export * from './feathers-roster.service'; diff --git a/apps/server/src/modules/pseudonym/service/pseudonym.service.spec.ts b/apps/server/src/modules/pseudonym/service/pseudonym.service.spec.ts index 80c5b461c67..e2fbb6e1b1f 100644 --- a/apps/server/src/modules/pseudonym/service/pseudonym.service.spec.ts +++ b/apps/server/src/modules/pseudonym/service/pseudonym.service.spec.ts @@ -1,9 +1,11 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { LtiToolDO, Pseudonym, UserDO } from '@shared/domain'; +import { IFindOptions, LtiToolDO, Page, Pseudonym, UserDO } from '@shared/domain'; import { externalToolFactory, ltiToolDOFactory, pseudonymFactory, userDoFactory } from '@shared/testing/factory'; import { ExternalTool } from '@src/modules/tool/external-tool/domain'; +import { PseudonymSearchQuery } from '../domain'; import { ExternalToolPseudonymRepo, PseudonymsRepo } from '../repo'; import { PseudonymService } from './pseudonym.service'; @@ -42,7 +44,7 @@ describe('PseudonymService', () => { await module.close(); }); - describe('findByUserAndTool', () => { + describe('findByUserAndToolOrThrow', () => { describe('when user or tool is missing', () => { const setup = () => { const user: UserDO = userDoFactory.build({ id: undefined }); @@ -57,7 +59,9 @@ describe('PseudonymService', () => { it('should throw an error', async () => { const { user, externalTool } = setup(); - await expect(service.findByUserAndTool(user, externalTool)).rejects.toThrowError(InternalServerErrorException); + await expect(service.findByUserAndToolOrThrow(user, externalTool)).rejects.toThrowError( + InternalServerErrorException + ); }); }); @@ -75,7 +79,7 @@ describe('PseudonymService', () => { it('should call externalToolPseudonymRepo', async () => { const { user, externalTool } = setup(); - await service.findByUserAndTool(user, externalTool); + await service.findByUserAndToolOrThrow(user, externalTool); expect(externalToolPseudonymRepo.findByUserIdAndToolIdOrFail).toHaveBeenCalledWith(user.id, externalTool.id); }); @@ -95,7 +99,7 @@ describe('PseudonymService', () => { it('should call pseudonymRepo', async () => { const { user, ltiToolDO } = setup(); - await service.findByUserAndTool(user, ltiToolDO); + await service.findByUserAndToolOrThrow(user, ltiToolDO); expect(pseudonymRepo.findByUserIdAndToolIdOrFail).toHaveBeenCalledWith(user.id, ltiToolDO.id); }); @@ -119,7 +123,7 @@ describe('PseudonymService', () => { it('should call pseudonymRepo.findByUserIdAndToolId', async () => { const { user, externalTool } = setup(); - await service.findByUserAndTool(user, externalTool); + await service.findByUserAndToolOrThrow(user, externalTool); expect(externalToolPseudonymRepo.findByUserIdAndToolIdOrFail).toHaveBeenCalledWith(user.id, externalTool.id); }); @@ -127,7 +131,7 @@ describe('PseudonymService', () => { it('should return a pseudonym', async () => { const { pseudonym, user, externalTool } = setup(); - const result: Pseudonym = await service.findByUserAndTool(user, externalTool); + const result: Pseudonym = await service.findByUserAndToolOrThrow(user, externalTool); expect(result).toEqual(pseudonym); }); @@ -148,7 +152,7 @@ describe('PseudonymService', () => { it('should pass the error without catching', async () => { const { user, externalTool } = setup(); - const func = async () => service.findByUserAndTool(user, externalTool); + const func = async () => service.findByUserAndToolOrThrow(user, externalTool); await expect(func).rejects.toThrow(NotFoundException); }); @@ -419,4 +423,112 @@ describe('PseudonymService', () => { }); }); }); + + describe('findPseudonymByPseudonym', () => { + describe('when pseudonym is missing', () => { + const setup = () => { + externalToolPseudonymRepo.findPseudonymByPseudonym.mockResolvedValue(null); + }; + + it('should return null', async () => { + setup(); + + const result: Pseudonym | null = await service.findPseudonymByPseudonym('pseudonym'); + + expect(result).toBeNull(); + }); + }); + + describe('when pseudonym is found', () => { + const setup = () => { + const pseudonym: Pseudonym = pseudonymFactory.build({ pseudonym: 'pseudonym' }); + + externalToolPseudonymRepo.findPseudonymByPseudonym.mockResolvedValue(pseudonym); + + return { + pseudonym, + }; + }; + + it('should call pseudonymRepo', async () => { + const { pseudonym } = setup(); + + await service.findPseudonymByPseudonym(pseudonym.pseudonym); + + expect(externalToolPseudonymRepo.findPseudonymByPseudonym).toHaveBeenCalledWith(pseudonym.pseudonym); + }); + + it('should return the pseudonym', async () => { + const { pseudonym } = setup(); + + const result: Pseudonym | null = await service.findPseudonymByPseudonym(pseudonym.pseudonym); + + expect(result).toBeDefined(); + }); + }); + }); + + describe('findPseudonym', () => { + describe('when query and params are given', () => { + const setup = () => { + const query: PseudonymSearchQuery = { + pseudonym: 'pseudonym', + }; + const options: IFindOptions = {}; + const page: Page = new Page([pseudonymFactory.build()], 1); + + externalToolPseudonymRepo.findPseudonym.mockResolvedValueOnce(page); + + return { + query, + options, + page, + }; + }; + + it('should call service with query and params', async () => { + const { query, options } = setup(); + + await service.findPseudonym(query, options); + + expect(externalToolPseudonymRepo.findPseudonym).toHaveBeenCalledWith(query, options); + }); + + it('should return page with pseudonyms', async () => { + const { query, options, page } = setup(); + + const pseudonymPage: Page = await service.findPseudonym(query, options); + + expect(pseudonymPage).toEqual>({ + data: [page.data[0]], + total: page.total, + }); + }); + }); + }); + + describe('getIframeSubject', () => { + describe('when pseudonym is given', () => { + const setup = () => { + const pseudonym = 'pseudonym'; + const host = 'https://host.de'; + Configuration.set('HOST', host); + + return { + pseudonym, + host, + }; + }; + + it('should return the iframeSubject', () => { + const { pseudonym, host } = setup(); + + const result: string = service.getIframeSubject(pseudonym); + + expect(result).toEqual( + `` + ); + }); + }); + }); }); diff --git a/apps/server/src/modules/pseudonym/service/pseudonym.service.ts b/apps/server/src/modules/pseudonym/service/pseudonym.service.ts index 9df391dbd33..23819f2fd3b 100644 --- a/apps/server/src/modules/pseudonym/service/pseudonym.service.ts +++ b/apps/server/src/modules/pseudonym/service/pseudonym.service.ts @@ -1,8 +1,10 @@ +import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { ObjectId } from '@mikro-orm/mongodb'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; -import { LtiToolDO, Pseudonym, UserDO } from '@shared/domain'; +import { IFindOptions, LtiToolDO, Page, Pseudonym, UserDO } from '@shared/domain'; import { ExternalTool } from '@src/modules/tool/external-tool/domain'; import { v4 as uuidv4 } from 'uuid'; +import { PseudonymSearchQuery } from '../domain'; import { ExternalToolPseudonymRepo, PseudonymsRepo } from '../repo'; @Injectable() @@ -12,7 +14,7 @@ export class PseudonymService { private readonly externalToolPseudonymRepo: ExternalToolPseudonymRepo ) {} - public async findByUserAndTool(user: UserDO, tool: ExternalTool | LtiToolDO): Promise { + public async findByUserAndToolOrThrow(user: UserDO, tool: ExternalTool | LtiToolDO): Promise { if (!user.id || !tool.id) { throw new InternalServerErrorException('User or tool id is missing'); } @@ -114,4 +116,24 @@ export class PseudonymService { return this.pseudonymRepo; } + + async findPseudonymByPseudonym(pseudonym: string): Promise { + const result: Pseudonym | null = await this.externalToolPseudonymRepo.findPseudonymByPseudonym(pseudonym); + + return result; + } + + async findPseudonym(query: PseudonymSearchQuery, options: IFindOptions): Promise> { + const result: Page = await this.externalToolPseudonymRepo.findPseudonym(query, options); + + return result; + } + + getIframeSubject(pseudonym: string): string { + const iFrameSubject = ``; + + return iFrameSubject; + } } diff --git a/apps/server/src/modules/pseudonym/uc/index.ts b/apps/server/src/modules/pseudonym/uc/index.ts new file mode 100644 index 00000000000..26aac594b34 --- /dev/null +++ b/apps/server/src/modules/pseudonym/uc/index.ts @@ -0,0 +1 @@ +export * from './pseudonym.uc'; diff --git a/apps/server/src/modules/pseudonym/uc/pseudonym.uc.spec.ts b/apps/server/src/modules/pseudonym/uc/pseudonym.uc.spec.ts new file mode 100644 index 00000000000..87fbfcbb526 --- /dev/null +++ b/apps/server/src/modules/pseudonym/uc/pseudonym.uc.spec.ts @@ -0,0 +1,141 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { LegacySchoolDo, Pseudonym, SchoolEntity, User } from '@shared/domain'; +import { legacySchoolDoFactory, pseudonymFactory, schoolFactory, setupEntities, userFactory } from '@shared/testing'; +import { ForbiddenException } from '@nestjs/common'; +import { Action, AuthorizationService } from '@src/modules/authorization'; +import { LegacySchoolService } from '@src/modules/legacy-school'; +import { PseudonymService } from '../service'; +import { PseudonymUc } from './pseudonym.uc'; + +describe('PseudonymUc', () => { + let module: TestingModule; + let uc: PseudonymUc; + + let pseudonymService: DeepMocked; + let authorizationService: DeepMocked; + let schoolService: DeepMocked; + + beforeAll(async () => { + await setupEntities(); + + module = await Test.createTestingModule({ + providers: [ + PseudonymUc, + { + provide: PseudonymService, + useValue: createMock(), + }, + { + provide: AuthorizationService, + useValue: createMock(), + }, + { + provide: LegacySchoolService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(PseudonymUc); + pseudonymService = module.get(PseudonymService); + authorizationService = module.get(AuthorizationService); + schoolService = module.get(LegacySchoolService); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('findPseudonymByPseudonym', () => { + describe('when valid user and params are given', () => { + const setup = () => { + const userId = 'userId'; + const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); + const schoolEntity: SchoolEntity = schoolFactory.buildWithId(); + const user: User = userFactory.buildWithId({ school: schoolEntity }); + user.school = schoolEntity; + const pseudonym: Pseudonym = new Pseudonym(pseudonymFactory.build({ userId: user.id })); + + authorizationService.getUserWithPermissions.mockResolvedValue(user); + pseudonymService.findPseudonymByPseudonym.mockResolvedValueOnce(pseudonym); + schoolService.getSchoolById.mockResolvedValue(school); + + return { + userId, + user, + school, + schoolEntity, + pseudonym, + }; + }; + + it('should call authorization service with params', async () => { + const { userId, user, school } = setup(); + + await uc.findPseudonymByPseudonym(userId, 'pseudonym'); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith(user, school, { + action: Action.read, + requiredPermissions: [], + }); + }); + + it('should call service with pseudonym', async () => { + const { userId } = setup(); + + await uc.findPseudonymByPseudonym(userId, 'pseudonym'); + + expect(pseudonymService.findPseudonymByPseudonym).toHaveBeenCalledWith('pseudonym'); + }); + + it('should call school service with school id from pseudonym user', async () => { + const { userId, schoolEntity } = setup(); + + await uc.findPseudonymByPseudonym(userId, 'pseudonym'); + + expect(schoolService.getSchoolById).toHaveBeenCalledWith(schoolEntity.id); + }); + + it('should return pseudonym', async () => { + const { userId, pseudonym } = setup(); + + const foundPseudonym: Pseudonym = await uc.findPseudonymByPseudonym(userId, 'pseudonym'); + + expect(foundPseudonym).toEqual(pseudonym); + }); + }); + + describe('when user is not authorized', () => { + const setup = () => { + const userId = 'userId'; + const user: User = userFactory.buildWithId(); + const school: SchoolEntity = schoolFactory.buildWithId(); + user.school = school; + const pseudonym: Pseudonym = new Pseudonym(pseudonymFactory.build()); + + authorizationService.getUserWithPermissions.mockResolvedValue(user); + authorizationService.checkPermission.mockImplementationOnce(() => { + throw new ForbiddenException(); + }); + pseudonymService.findPseudonymByPseudonym.mockResolvedValueOnce(pseudonym); + + return { + userId, + }; + }; + + it('should throw forbidden exception', async () => { + const { userId } = setup(); + + const func = async () => uc.findPseudonymByPseudonym(userId, 'pseudonym'); + + await expect(func()).rejects.toThrow(ForbiddenException); + }); + }); + }); +}); diff --git a/apps/server/src/modules/pseudonym/uc/pseudonym.uc.ts b/apps/server/src/modules/pseudonym/uc/pseudonym.uc.ts new file mode 100644 index 00000000000..a960a33bc3c --- /dev/null +++ b/apps/server/src/modules/pseudonym/uc/pseudonym.uc.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId, LegacySchoolDo, Pseudonym, User } from '@shared/domain'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { AuthorizationContextBuilder, AuthorizationService } from '@src/modules/authorization'; +import { LegacySchoolService } from '@src/modules/legacy-school'; +import { PseudonymService } from '../service'; + +@Injectable() +export class PseudonymUc { + constructor( + private readonly pseudonymService: PseudonymService, + private readonly authorizationService: AuthorizationService, + private readonly schoolService: LegacySchoolService + ) {} + + async findPseudonymByPseudonym(userId: EntityId, pseudonym: string): Promise { + const user: User = await this.authorizationService.getUserWithPermissions(userId); + + const foundPseudonym: Pseudonym | null = await this.pseudonymService.findPseudonymByPseudonym(pseudonym); + + if (foundPseudonym === null) { + throw new NotFoundLoggableException(Pseudonym.name, 'pseudonym', pseudonym); + } + + const pseudonymUserId: string = foundPseudonym.userId; + const pseudonymUser: User = await this.authorizationService.getUserWithPermissions(pseudonymUserId); + const pseudonymSchool: LegacySchoolDo = await this.schoolService.getSchoolById(pseudonymUser.school.id); + + this.authorizationService.checkPermission(user, pseudonymSchool, AuthorizationContextBuilder.read([])); + + return foundPseudonym; + } +} diff --git a/apps/server/src/modules/server/server.module.ts b/apps/server/src/modules/server/server.module.ts index 22997d30f50..20b346929cd 100644 --- a/apps/server/src/modules/server/server.module.ts +++ b/apps/server/src/modules/server/server.module.ts @@ -36,6 +36,7 @@ import connectRedis from 'connect-redis'; import session from 'express-session'; import { RedisClient } from 'redis'; import { TeamsApiModule } from '@src/modules/teams/teams-api.module'; +import { PseudonymApiModule } from '@src/modules/pseudonym/pseudonym-api.module'; import { ServerController } from './controller/server.controller'; import { serverConfig } from './server.config'; @@ -74,6 +75,7 @@ const serverModules = [ BoardApiModule, GroupApiModule, TeamsApiModule, + PseudonymApiModule, ]; export const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { diff --git a/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.ts b/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.ts index b6ce4ffd55e..826d6d69d91 100644 --- a/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.ts +++ b/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.ts @@ -3,7 +3,7 @@ import { EntityId, LtiPrivacyPermission, Pseudonym, RoleName, UserDO } from '@sh import { RoleReference } from '@shared/domain/domainobject'; import { CourseService } from '@src/modules/learnroom/service'; import { LegacySchoolService } from '@src/modules/legacy-school'; -import { PseudonymService } from '@src/modules/pseudonym'; +import { PseudonymService } from '@src/modules/pseudonym/service'; import { UserService } from '@src/modules/user'; import { Authorization } from 'oauth-1.0a'; import { LtiRole } from '../../../common/enum'; diff --git a/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.spec.ts b/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.spec.ts index 8efd3ff9a7d..bc4b3878ea5 100644 --- a/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.spec.ts +++ b/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.spec.ts @@ -241,7 +241,7 @@ describe('NextCloudStrategy', () => { ltiToolRepo.findByName.mockResolvedValue([nextcloudTool]); client.getNameWithPrefix.mockReturnValue(groupId); - pseudonymService.findByUserAndTool.mockRejectedValueOnce(undefined); + pseudonymService.findByUserAndToolOrThrow.mockRejectedValueOnce(undefined); client.findGroupFolderIdForGroupId.mockResolvedValue(folderId); client.getGroupUsers.mockResolvedValue([userId]); @@ -306,7 +306,7 @@ describe('NextCloudStrategy', () => { ltiToolRepo.findByName.mockResolvedValue([nextcloudTool]); client.getNameWithPrefix.mockReturnValue(groupId); client.getGroupUsers.mockResolvedValue([nextCloudUserId]); - pseudonymService.findByUserAndTool.mockRejectedValueOnce(undefined); + pseudonymService.findByUserAndToolOrThrow.mockRejectedValueOnce(undefined); client.findGroupFolderIdForGroupId.mockResolvedValueOnce(folderId); const expectedFolderName: string = NextcloudStrategySpec.specGenerateGroupFolderName(teamDto.id, teamDto.name); @@ -396,7 +396,7 @@ describe('NextCloudStrategy', () => { ltiToolRepo.findByName.mockResolvedValue([nextcloudTool]); client.getGroupUsers.mockResolvedValue([]); - pseudonymService.findByUserAndTool.mockResolvedValue(pseudonym); + pseudonymService.findByUserAndToolOrThrow.mockResolvedValue(pseudonym); client.getNameWithPrefix.mockReturnValue(nextCloudUserId); userService.findById.mockResolvedValue(userDo); @@ -424,7 +424,7 @@ describe('NextCloudStrategy', () => { await strategy.specUpdateTeamUsersInGroup(groupId, teamUsers); - expect(pseudonymService.findByUserAndTool).toHaveBeenCalledWith(userDo, nextcloudTool); + expect(pseudonymService.findByUserAndToolOrThrow).toHaveBeenCalledWith(userDo, nextcloudTool); }); it('should call userService.findById', async () => { @@ -476,7 +476,7 @@ describe('NextCloudStrategy', () => { ltiToolRepo.findByName.mockResolvedValue([nextcloudTool]); client.getGroupUsers.mockResolvedValue([nextCloudUserId]); - pseudonymService.findByUserAndTool.mockResolvedValue(pseudonym); + pseudonymService.findByUserAndToolOrThrow.mockResolvedValue(pseudonym); client.getNameWithPrefix.mockReturnValue(nextCloudUserId); return { teamUsers, nextCloudUserId, groupId }; @@ -495,7 +495,7 @@ describe('NextCloudStrategy', () => { await strategy.specUpdateTeamUsersInGroup(groupId, teamUsers); - expect(pseudonymService.findByUserAndTool).not.toHaveBeenCalled(); + expect(pseudonymService.findByUserAndToolOrThrow).not.toHaveBeenCalled(); }); it('should not call clients getNameWithPrefix', async () => { @@ -542,7 +542,7 @@ describe('NextCloudStrategy', () => { ltiToolRepo.findByName.mockResolvedValue([nextcloudTool]); client.getGroupUsers.mockResolvedValue([nextCloudUserId]); - pseudonymService.findByUserAndTool.mockResolvedValueOnce(pseudonym).mockRejectedValueOnce(undefined); + pseudonymService.findByUserAndToolOrThrow.mockResolvedValueOnce(pseudonym).mockRejectedValueOnce(undefined); client.getNameWithPrefix.mockReturnValue(nextCloudUserId); return { teamUsers, groupId }; @@ -582,7 +582,7 @@ describe('NextCloudStrategy', () => { client.getGroupUsers.mockResolvedValue([nextCloudUserId]); ltiToolRepo.findByName.mockResolvedValue([nextcloudTool, nextcloudTool2]); - pseudonymService.findByUserAndTool.mockResolvedValue(pseudonym); + pseudonymService.findByUserAndToolOrThrow.mockResolvedValue(pseudonym); return { user, teamUsers, pseudonym, nextCloudUserId, groupId }; }; diff --git a/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.ts b/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.ts index 16f8c62aff3..1292bff3a42 100644 --- a/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.ts +++ b/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.ts @@ -135,7 +135,7 @@ export class NextcloudStrategy implements ICollaborativeStorageStrategy { teamUsers.map(async (teamUser: TeamUserDto): Promise => { const user: UserDO = await this.userService.findById(teamUser.userId); const userId = await this.pseudonymService - .findByUserAndTool(user, nextcloudTool) + .findByUserAndToolOrThrow(user, nextcloudTool) .then((pseudonymDO: Pseudonym) => this.client.getNameWithPrefix(pseudonymDO.pseudonym)) .catch(() => ''); diff --git a/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts b/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts index f61ae282350..1ba9fa09aea 100644 --- a/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts @@ -236,7 +236,7 @@ describe('ExternalToolRepo', () => { }; describe('pagination', () => { - it('should return all ltiTools when options with pagination is set to undefined', async () => { + it('should return all external tools when options with pagination is set to undefined', async () => { const { queryExternalToolDO, ltiTools } = await setupFind(); const page: Page = await repo.find(queryExternalToolDO, undefined); @@ -244,7 +244,7 @@ describe('ExternalToolRepo', () => { expect(page.data.length).toBe(ltiTools.length); }); - it('should return one ltiTool when pagination has a limit of 1', async () => { + it('should return one external tools when pagination has a limit of 1', async () => { const { queryExternalToolDO, options } = await setupFind(); options.pagination = { limit: 1 }; @@ -253,7 +253,7 @@ describe('ExternalToolRepo', () => { expect(page.data.length).toBe(1); }); - it('should return no ltiTool when pagination has a limit of 1 and skip is set to 2', async () => { + it('should return no external tools when pagination has a limit of 1 and skip is set to 2', async () => { const { queryExternalToolDO, options } = await setupFind(); options.pagination = { limit: 1, skip: 3 }; @@ -264,7 +264,7 @@ describe('ExternalToolRepo', () => { }); describe('order', () => { - it('should return ltiTools ordered by default _id when no order is specified', async () => { + it('should return external tools ordered by default _id when no order is specified', async () => { const { queryExternalToolDO, options, ltiTools } = await setupFind(); const page: Page = await repo.find(queryExternalToolDO, options); @@ -274,7 +274,7 @@ describe('ExternalToolRepo', () => { expect(page.data[2].name).toEqual(ltiTools[2].name); }); - it('should return ltiTools ordered by name ascending', async () => { + it('should return external tools ordered by name ascending', async () => { const { queryExternalToolDO, options, ltiTools } = await setupFind(); options.order = { diff --git a/src/services/roster/index.js b/src/services/roster/index.js index 1d1c84a4087..182f2e74a4e 100644 --- a/src/services/roster/index.js +++ b/src/services/roster/index.js @@ -1,6 +1,6 @@ const { static: staticContent } = require('@feathersjs/express'); const path = require('path'); - +const { Configuration } = require('@hpi-schul-cloud/commons/lib'); const hooks = require('./hooks'); const globalHooks = require('../../hooks'); const oauth2 = require('../oauth2/hooks'); @@ -30,8 +30,13 @@ module.exports = function roster() { const metadataHandler = { async find(params) { const { pseudonym } = params; - const userParam = params.route.user; + if (Configuration.get('FEATURE_CTL_TOOLS_TAB_ENABLED')) { + const userMetadata = await app.service('nest-feathers-roster-service').getUsersMetadata(pseudonym); + return userMetadata; + } + + const userParam = params.route.user; const pseudonyms = await app.service('pseudonym').find({ query: { pseudonym, @@ -84,6 +89,13 @@ module.exports = function roster() { */ const userGroupsHandler = { async find(params) { + if (Configuration.get('FEATURE_CTL_TOOLS_TAB_ENABLED')) { + const userGroups = await app + .service('nest-feathers-roster-service') + .getUserGroups(params.pseudonym, params.tokenInfo.client_id); + return userGroups; + } + const pseudonyms = await app.service('pseudonym').find({ query: { pseudonym: params.pseudonym, @@ -112,11 +124,13 @@ module.exports = function roster() { // all users courses with given tool enabled return { data: { - groups: courses.map((course) => ({ - group_id: course._id.toString(), - name: course.name, - student_count: course.userIds.length, - })), + groups: courses.map((course) => { + return { + group_id: course._id.toString(), + name: course.name, + student_count: course.userIds.length, + }; + }), }, }; }, @@ -144,6 +158,11 @@ module.exports = function roster() { */ const groupsHandler = { async get(id, params) { + if (Configuration.get('FEATURE_CTL_TOOLS_TAB_ENABLED')) { + const group = await app.service('nest-feathers-roster-service').getGroup(id, params.tokenInfo.client_id); + return group; + } + const courseService = app.service('courses'); const courseId = id; if (!isValidObjectId(courseId)) { @@ -184,14 +203,18 @@ module.exports = function roster() { return { data: { - students: users.data.map((user) => ({ - user_id: user.pseudonym, - username: oauth2.getSubject(user.pseudonym, app.settings.services.web), - })), - teachers: teachers.data.map((user) => ({ - user_id: user.pseudonym, - username: oauth2.getSubject(user.pseudonym, app.settings.services.web), - })), + students: users.data.map((user) => { + return { + user_id: user.pseudonym, + username: oauth2.getSubject(user.pseudonym, app.settings.services.web), + }; + }), + teachers: teachers.data.map((user) => { + return { + user_id: user.pseudonym, + username: oauth2.getSubject(user.pseudonym, app.settings.services.web), + }; + }), }, }; }, diff --git a/test/services/roster/index.test.js b/test/services/roster/index.test.js index af54f95ab81..791ad6b627f 100644 --- a/test/services/roster/index.test.js +++ b/test/services/roster/index.test.js @@ -2,8 +2,12 @@ const assert = require('assert'); const chai = require('chai'); const chaiHttp = require('chai-http'); +const { Configuration } = require('@hpi-schul-cloud/commons/lib'); +const sinon = require('sinon'); const appPromise = require('../../../src/app'); - +const { + FeathersRosterService, +} = require('../../../dist/apps/server/modules/pseudonym/service/feathers-roster.service'); chai.use(chaiHttp); @@ -88,6 +92,10 @@ describe('roster service', function oauth() { return Promise.resolve(); }); + afterEach(() => { + sinon.restore(); + }); + after(() => Promise.all([ pseudonymService.remove(null, { query: {} }), @@ -103,41 +111,199 @@ describe('roster service', function oauth() { assert.ok(groupsService); }); - it('GET metadata', () => - metadataService.find({ route: { user: pseudonym1 } }).then((metadata) => { - assert.strictEqual(pseudonym1, metadata.data.user_id); - assert.strictEqual('teacher', metadata.data.type); - })); - - it('GET user groups', (done) => { - userGroupsService - .find({ - route: { user: pseudonym1 }, - tokenInfo: { client_id: testToolTemplate.oAuthClientId }, - }) - .then((groups) => { - const group1 = groups.data.groups[0]; - assert.strictEqual(testCourse._id, group1.group_id); - assert.strictEqual(testCourse.name, group1.name); - assert.strictEqual(testCourse.userIds.length, group1.student_count); - done(); + describe('GET metadata', () => { + describe('when CTL feature is enabled', () => { + const setup = () => { + Configuration.set('FEATURE_CTL_TOOLS_TAB_ENABLED', true); + const nestGetUsersMetadataStub = sinon.stub(FeathersRosterService.prototype, 'getUsersMetadata'); + + return { + nestGetUsersMetadataStub, + }; + }; + + it('should call nest feathers roster service', () => { + const { nestGetUsersMetadataStub } = setup(); + + metadataService.find({ route: { user: pseudonym1 } }).then((metadata) => { + assert.strictEqual(pseudonym1, metadata.data.user_id); + assert.strictEqual('teacher', metadata.data.type); + assert.ok(nestGetUsersMetadataStub.calledOnce); + }); + }); + }); + + describe('when CTL feature is not enabled', () => { + const setup = () => { + Configuration.set('FEATURE_CTL_TOOLS_TAB_ENABLED', false); + + const nestGetUsersMetadataStub = sinon.stub(FeathersRosterService.prototype, 'getUsersMetadata'); + + return { + nestGetUsersMetadataStub, + }; + }; + + it('should not call nest feathers roster service', () => { + const { nestGetUsersMetadataStub } = setup(); + + metadataService.find({ route: { user: pseudonym1 } }).then(() => { + assert.ok(nestGetUsersMetadataStub.notCalled); + }); + }); + + it('GET metadata', () => { + setup(); + + metadataService.find({ route: { user: pseudonym1 } }).then((metadata) => { + assert.strictEqual(pseudonym1, metadata.data.user_id); + assert.strictEqual('teacher', metadata.data.type); + }); + }); + }); + }); + + describe('GET user groups', () => { + describe('when CTL feature is enabled', () => { + const setup = () => { + Configuration.set('FEATURE_CTL_TOOLS_TAB_ENABLED', true); + + const nestGetUserGroupsStub = sinon.stub(FeathersRosterService.prototype, 'getUserGroups'); + + return { + nestGetUserGroupsStub, + }; + }; + + it('should call nest feathers roster service', () => { + const { nestGetUserGroupsStub } = setup(); + + userGroupsService + .find({ + route: { user: pseudonym1 }, + tokenInfo: { client_id: testToolTemplate.oAuthClientId }, + }) + .then(() => { + assert.ok(nestGetUserGroupsStub.calledOnce); + }); + }); + }); + + describe('when CTL feature is not enabled', () => { + const setup = () => { + Configuration.set('FEATURE_CTL_TOOLS_TAB_ENABLED', false); + + const nestGetUserGroupsStub = sinon.stub(FeathersRosterService.prototype, 'getUserGroups'); + + return { + nestGetUserGroupsStub, + }; + }; + + it('should not call nest feathers roster service', () => { + const { nestGetUserGroupsStub } = setup(); + + userGroupsService + .find({ + route: { user: pseudonym1 }, + tokenInfo: { client_id: testToolTemplate.oAuthClientId }, + }) + .then(() => { + assert.ok(nestGetUserGroupsStub.notCalled); + }); }); + + it('GET user groups', (done) => { + setup(); + + userGroupsService + .find({ + route: { user: pseudonym1 }, + tokenInfo: { client_id: testToolTemplate.oAuthClientId }, + }) + .then((groups) => { + const group1 = groups.data.groups[0]; + assert.strictEqual(testCourse._id, group1.group_id); + assert.strictEqual(testCourse.name, group1.name); + assert.strictEqual(testCourse.userIds.length, group1.student_count); + done(); + }); + }); + }); }); - it('GET group', (done) => { - groupsService - .get(testCourse._id, { - tokenInfo: { - client_id: testToolTemplate.oAuthClientId, - obfuscated_subject: pseudonym1, - }, - }) - .then((group) => { - assert.strictEqual(pseudonym1, group.data.teachers[0].user_id); - const properties = 'title="username" style="height: 26px; width: 180px; border: none;"'; - const iframe = ``; - assert.strictEqual(iframe, group.data.teachers[0].username); - done(); + describe('GET group', () => { + describe('when CTL feature is enabled', () => { + const setup = () => { + Configuration.set('FEATURE_CTL_TOOLS_TAB_ENABLED', true); + + const nestGroupStub = sinon.stub(FeathersRosterService.prototype, 'getGroup'); + + return { + nestGroupStub, + }; + }; + + it('should call nest feathers roster service', () => { + const { nestGroupStub } = setup(); + + groupsService + .get(testCourse._id, { + tokenInfo: { + client_id: testToolTemplate.oAuthClientId, + obfuscated_subject: pseudonym1, + }, + }) + .then(() => { + assert.ok(nestGroupStub.calledOnce); + }); + }); + }); + + describe('when CTL feature is not enabled', () => { + const setup = () => { + Configuration.set('FEATURE_CTL_TOOLS_TAB_ENABLED', false); + + const nestGroupStub = sinon.stub(FeathersRosterService.prototype, 'getGroup'); + + return { + nestGroupStub, + }; + }; + + it('should not call nest feathers roster service', () => { + const { nestGroupStub } = setup(); + + groupsService + .get(testCourse._id, { + tokenInfo: { + client_id: testToolTemplate.oAuthClientId, + obfuscated_subject: pseudonym1, + }, + }) + .then(() => { + assert.ok(nestGroupStub.notCalled); + }); + }); + + it('GET group', (done) => { + setup(); + + groupsService + .get(testCourse._id, { + tokenInfo: { + client_id: testToolTemplate.oAuthClientId, + obfuscated_subject: pseudonym1, + }, + }) + .then((group) => { + assert.strictEqual(pseudonym1, group.data.teachers[0].user_id); + const properties = 'title="username" style="height: 26px; width: 180px; border: none;"'; + const iframe = ``; + assert.strictEqual(iframe, group.data.teachers[0].username); + done(); + }); }); + }); }); });