diff --git a/README.md b/README.md index 606aa230d48..30d15f57ee8 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,11 @@ There is also some old documentation in the server code /apps/server/doc/*.md. Note that not all the .md file here are up to date. +## NestJS Console Applications + +### Sync console +> Find the [Documentation](./apps/server/src/infra/sync/console/README.md) here. + ## Feathers application diff --git a/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 index 48621e77490..43c876279ae 100644 --- a/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 +++ b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 @@ -517,17 +517,6 @@ data: # ========== Start of the CTL seed data configuration section. echo "Inserting ctl seed data secrets to external-tools..." - mongosh $DATABASE__URL --quiet --eval 'db.getCollection("external-tools").updateOne( - { - "name": "Moodle Fortbildung", - }, - { $set: { - "config_secret": "'$CTL_SEED_SECRET_MOODLE_FORTB'", - } }, - { - "upsert": true - } - );' mongosh $DATABASE__URL --quiet --eval 'db.getCollection("external-tools").updateOne( { "name": "Product Test Onlinediagnose Grundschule - Mathematik", diff --git a/apps/server/src/core/interfaces/core-module-config.ts b/apps/server/src/core/core.config.ts similarity index 75% rename from apps/server/src/core/interfaces/core-module-config.ts rename to apps/server/src/core/core.config.ts index a51eb7bd08d..82049e65924 100644 --- a/apps/server/src/core/interfaces/core-module-config.ts +++ b/apps/server/src/core/core.config.ts @@ -1,4 +1,4 @@ import { InterceptorConfig } from '@shared/common'; -import { LoggerConfig } from '../logger'; +import { LoggerConfig } from './logger'; export interface CoreModuleConfig extends InterceptorConfig, LoggerConfig {} diff --git a/apps/server/src/core/index.ts b/apps/server/src/core/index.ts index 2617f97a6c6..bceab23d256 100644 --- a/apps/server/src/core/index.ts +++ b/apps/server/src/core/index.ts @@ -1,3 +1,3 @@ export { CoreModule } from './core.module'; export { DomainErrorHandler } from './error'; -export * from './interfaces'; +export { CoreModuleConfig } from './core.config'; diff --git a/apps/server/src/core/interfaces/index.ts b/apps/server/src/core/interfaces/index.ts deleted file mode 100644 index 4528a838ec2..00000000000 --- a/apps/server/src/core/interfaces/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './core-module-config'; diff --git a/apps/server/src/core/logger/index.ts b/apps/server/src/core/logger/index.ts index 5948341d8c6..1037edaaa34 100644 --- a/apps/server/src/core/logger/index.ts +++ b/apps/server/src/core/logger/index.ts @@ -5,3 +5,4 @@ export * from './logger'; export * from './error-logger'; export * from './types'; export * from './logging.utils'; +export { LoggerConfig } from './logger.config'; diff --git a/apps/server/src/core/logger/interfaces/index.ts b/apps/server/src/core/logger/interfaces/index.ts index 0f2797950dc..77c1c08176d 100644 --- a/apps/server/src/core/logger/interfaces/index.ts +++ b/apps/server/src/core/logger/interfaces/index.ts @@ -1,3 +1,2 @@ -export * from './logger-config'; export * from './legacy-logger.interface'; export * from './loggable'; diff --git a/apps/server/src/core/logger/interfaces/logger-config.ts b/apps/server/src/core/logger/logger.config.ts similarity index 69% rename from apps/server/src/core/logger/interfaces/logger-config.ts rename to apps/server/src/core/logger/logger.config.ts index 851986d07b9..d3d55b97a1e 100644 --- a/apps/server/src/core/logger/interfaces/logger-config.ts +++ b/apps/server/src/core/logger/logger.config.ts @@ -1,4 +1,4 @@ export interface LoggerConfig { NEST_LOG_LEVEL: string; - EXIT_ON_ERROR?: boolean; + EXIT_ON_ERROR: boolean; } diff --git a/apps/server/src/core/logger/logger.module.ts b/apps/server/src/core/logger/logger.module.ts index 89054e13c00..c580918a278 100644 --- a/apps/server/src/core/logger/logger.module.ts +++ b/apps/server/src/core/logger/logger.module.ts @@ -3,7 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { utilities, WinstonModule } from 'nest-winston'; import winston from 'winston'; import { ErrorLogger } from './error-logger'; -import { LoggerConfig } from './interfaces'; +import { LoggerConfig } from './logger.config'; import { LegacyLogger } from './legacy-logger.service'; import { Logger } from './logger'; diff --git a/apps/server/src/infra/auth-guard/auth-guard.config.ts b/apps/server/src/infra/auth-guard/auth-guard.config.ts new file mode 100644 index 00000000000..4b6fc411410 --- /dev/null +++ b/apps/server/src/infra/auth-guard/auth-guard.config.ts @@ -0,0 +1,6 @@ +export interface AuthGuardConfig { + ADMIN_API__ALLOWED_API_KEYS: string[]; + JWT_AUD: string; + JWT_LIFETIME: string; + AUTHENTICATION: string; +} diff --git a/apps/server/src/infra/auth-guard/index.ts b/apps/server/src/infra/auth-guard/index.ts index 506de413930..53ca333e40d 100644 --- a/apps/server/src/infra/auth-guard/index.ts +++ b/apps/server/src/infra/auth-guard/index.ts @@ -1,5 +1,6 @@ export { JwtValidationAdapter } from './adapter'; export { AuthGuardModule } from './auth-guard.module'; +export { AuthGuardConfig } from './auth-guard.config'; export { XApiKeyConfig, authConfig } from './config'; export { CurrentUser, JWT, JwtAuthentication } from './decorator'; // JwtAuthGuard only exported because api tests still overried this guard. diff --git a/apps/server/src/infra/auth-guard/interface/jwt-payload.ts b/apps/server/src/infra/auth-guard/interface/jwt-payload.ts index 51747ccf2c0..cf2dbdf4dba 100644 --- a/apps/server/src/infra/auth-guard/interface/jwt-payload.ts +++ b/apps/server/src/infra/auth-guard/interface/jwt-payload.ts @@ -4,8 +4,8 @@ export interface CreateJwtPayload { schoolId: string; roles: string[]; systemId?: string; // without this the user needs to change his PW during first login - support?: boolean; - // support UserId is missed see featherJS + support: boolean; + supportUserId?: string; isExternalUser: boolean; } diff --git a/apps/server/src/infra/auth-guard/interface/user.ts b/apps/server/src/infra/auth-guard/interface/user.ts index 72bf50c85c0..a1239c4e828 100644 --- a/apps/server/src/infra/auth-guard/interface/user.ts +++ b/apps/server/src/infra/auth-guard/interface/user.ts @@ -14,7 +14,9 @@ export interface ICurrentUser { systemId?: EntityId; /** True if a support member impersonates the user */ - impersonated: boolean; + support: boolean; + + supportUserId?: EntityId; /** True if the user is an external user e.g. an oauth user or ldap user */ isExternalUser: boolean; diff --git a/apps/server/src/infra/auth-guard/mapper/current-user.factory.spec.ts b/apps/server/src/infra/auth-guard/mapper/current-user.factory.spec.ts index b73eda5f04a..7f744447a07 100644 --- a/apps/server/src/infra/auth-guard/mapper/current-user.factory.spec.ts +++ b/apps/server/src/infra/auth-guard/mapper/current-user.factory.spec.ts @@ -1,7 +1,7 @@ import { setupEntities } from '@shared/testing'; import { ObjectId } from 'bson'; -import { CurrentUserBuilder } from './current-user.factory'; import { ICurrentUser } from '../interface'; +import { CurrentUserBuilder } from './current-user.factory'; describe('CurrentUserBuilder', () => { beforeAll(async () => { @@ -35,7 +35,7 @@ describe('CurrentUserBuilder', () => { schoolId: requiredProps.schoolId, accountId: requiredProps.accountId, roles: requiredProps.roles, - impersonated: false, + support: false, isExternalUser: false, systemId: undefined, externalIdToken: undefined, @@ -53,7 +53,7 @@ describe('CurrentUserBuilder', () => { schoolId: requiredProps.schoolId, accountId: requiredProps.accountId, roles: requiredProps.roles, - impersonated: true, + support: true, isExternalUser: false, systemId: undefined, externalIdToken: undefined, @@ -72,7 +72,7 @@ describe('CurrentUserBuilder', () => { schoolId: requiredProps.schoolId, accountId: requiredProps.accountId, roles: requiredProps.roles, - impersonated: false, + support: false, isExternalUser: true, systemId: undefined, externalIdToken: undefined, @@ -92,7 +92,7 @@ describe('CurrentUserBuilder', () => { schoolId: requiredProps.schoolId, accountId: requiredProps.accountId, roles: requiredProps.roles, - impersonated: false, + support: false, isExternalUser: false, systemId, externalIdToken: undefined, @@ -112,7 +112,7 @@ describe('CurrentUserBuilder', () => { schoolId: requiredProps.schoolId, accountId: requiredProps.accountId, roles: requiredProps.roles, - impersonated: false, + support: false, isExternalUser: true, systemId: undefined, externalIdToken, diff --git a/apps/server/src/infra/auth-guard/mapper/current-user.factory.ts b/apps/server/src/infra/auth-guard/mapper/current-user.factory.ts index 608be132384..0b7a514e806 100644 --- a/apps/server/src/infra/auth-guard/mapper/current-user.factory.ts +++ b/apps/server/src/infra/auth-guard/mapper/current-user.factory.ts @@ -1,5 +1,5 @@ -import { EntityId } from '@shared/domain/types'; import { TypeGuard } from '@shared/common'; +import { EntityId } from '@shared/domain/types'; import { ICurrentUser } from '../interface'; interface RequiredCurrentUserProps { @@ -30,7 +30,7 @@ export class CurrentUserBuilder { schoolId: requiredProps.schoolId, accountId: requiredProps.accountId, roles: requiredProps.roles, - impersonated: false, + support: false, isExternalUser: false, systemId: undefined, externalIdToken: undefined, @@ -45,7 +45,7 @@ export class CurrentUserBuilder { public asUserSupporter(asUserSupporter?: boolean) { if (asUserSupporter === true) { - this.props.impersonated = asUserSupporter; + this.props.support = asUserSupporter; } return this; diff --git a/apps/server/src/infra/auth-guard/mapper/jwt.factory.spec.ts b/apps/server/src/infra/auth-guard/mapper/jwt.factory.spec.ts index 2a851457d05..3239d691389 100644 --- a/apps/server/src/infra/auth-guard/mapper/jwt.factory.spec.ts +++ b/apps/server/src/infra/auth-guard/mapper/jwt.factory.spec.ts @@ -1,4 +1,5 @@ import { currentUserFactory, setupEntities } from '@shared/testing'; +import { ObjectId } from 'bson'; import { CreateJwtPayload } from '../interface'; import { JwtPayloadFactory } from './jwt.factory'; @@ -19,6 +20,27 @@ describe('JwtPayloadFactory', () => { roles: currentUser.roles, schoolId: currentUser.schoolId, userId: currentUser.userId, + support: false, + isExternalUser: false, + }); + }); + }); + + describe('buildFromSupportUser', () => { + it('should map current user to create jwt payload', () => { + const currentUser = currentUserFactory.build(); + const supportUserId = new ObjectId().toHexString(); + + const createJwtPayload = JwtPayloadFactory.buildFromSupportUser(currentUser, supportUserId); + + expect(createJwtPayload).toMatchObject({ + accountId: currentUser.accountId, + systemId: currentUser.systemId, + roles: currentUser.roles, + schoolId: currentUser.schoolId, + userId: currentUser.userId, + support: true, + supportUserId, isExternalUser: false, }); }); diff --git a/apps/server/src/infra/auth-guard/mapper/jwt.factory.ts b/apps/server/src/infra/auth-guard/mapper/jwt.factory.ts index 1de22badd88..5eed0eea135 100644 --- a/apps/server/src/infra/auth-guard/mapper/jwt.factory.ts +++ b/apps/server/src/infra/auth-guard/mapper/jwt.factory.ts @@ -1,3 +1,4 @@ +import { EntityId } from '@shared/domain/types'; import { CreateJwtPayload, ICurrentUser } from '../interface'; export class JwtPayloadFactory { @@ -12,7 +13,25 @@ export class JwtPayloadFactory { schoolId: currentUser.schoolId, roles: currentUser.roles, systemId: currentUser.systemId, - support: currentUser.impersonated, + support: false, + supportUserId: undefined, + isExternalUser: currentUser.isExternalUser, + }; + + const createJwtPayload = JwtPayloadFactory.build(data); + + return createJwtPayload; + } + + public static buildFromSupportUser(currentUser: ICurrentUser, supportUserId: EntityId): CreateJwtPayload { + const data = { + accountId: currentUser.accountId, + userId: currentUser.userId, + schoolId: currentUser.schoolId, + roles: currentUser.roles, + systemId: currentUser.systemId, + support: true, + supportUserId, isExternalUser: currentUser.isExternalUser, }; diff --git a/apps/server/src/infra/auth-guard/strategy/jwt.strategy.spec.ts b/apps/server/src/infra/auth-guard/strategy/jwt.strategy.spec.ts index 75fa02dd882..eff14de3ca4 100644 --- a/apps/server/src/infra/auth-guard/strategy/jwt.strategy.spec.ts +++ b/apps/server/src/infra/auth-guard/strategy/jwt.strategy.spec.ts @@ -97,7 +97,7 @@ describe('jwt strategy', () => { schoolId: mockJwtPayload.schoolId, accountId: mockJwtPayload.accountId, systemId: mockJwtPayload.systemId, - impersonated: mockJwtPayload.support, + support: mockJwtPayload.support, }); }); }); diff --git a/apps/server/src/infra/auth-guard/strategy/ws-jwt.strategy.spec.ts b/apps/server/src/infra/auth-guard/strategy/ws-jwt.strategy.spec.ts index 0fc2f87d1a8..685710a922b 100644 --- a/apps/server/src/infra/auth-guard/strategy/ws-jwt.strategy.spec.ts +++ b/apps/server/src/infra/auth-guard/strategy/ws-jwt.strategy.spec.ts @@ -98,7 +98,7 @@ describe('jwt strategy', () => { schoolId: mockJwtPayload.schoolId, accountId: mockJwtPayload.accountId, systemId: mockJwtPayload.systemId, - impersonated: mockJwtPayload.support, + support: mockJwtPayload.support, }); }); }); diff --git a/apps/server/src/infra/calendar/calendar.config.ts b/apps/server/src/infra/calendar/calendar.config.ts new file mode 100644 index 00000000000..5a66c0814ec --- /dev/null +++ b/apps/server/src/infra/calendar/calendar.config.ts @@ -0,0 +1,3 @@ +import { LoggerConfig } from '@src/core/logger'; + +export interface CalendarConfig extends LoggerConfig {} diff --git a/apps/server/src/infra/calendar/index.ts b/apps/server/src/infra/calendar/index.ts index 1cb878f97e9..bcb294e648e 100644 --- a/apps/server/src/infra/calendar/index.ts +++ b/apps/server/src/infra/calendar/index.ts @@ -1,3 +1,4 @@ -export * from './calendar.module'; +export { CalendarModule } from './calendar.module'; export * from './service/calendar.service'; export * from './dto/calendar-event.dto'; +export { CalendarConfig } from './calendar.config'; diff --git a/apps/server/src/infra/sync/console/README.md b/apps/server/src/infra/sync/console/README.md new file mode 100644 index 00000000000..cd3536b76f8 --- /dev/null +++ b/apps/server/src/infra/sync/console/README.md @@ -0,0 +1,22 @@ +# Sync console +This is a console application that allows you to start the synchronization process for different sources. + +## Usage +To start the synchronization process, run the following command: +```bash +npm run nest:start:console sync run +``` + +Where `` is the name of the system you want to start the synchronization for. The currently available systems are: +- `tsp` - Synchronize Thüringer schulportal. + +If the target is not provided, the synchronization will not start and the available targets will be displayed in an error message. +```bash +{ + message: 'Either synchronization is not activated or the target entered is invalid', + data: { enteredTarget: 'tsp', availableTargets: { TSP: 'tsp' }} +} +``` + +## TSP synchronization +The TSP synchronization is controlled with a feature flag `FEATURE_TSP_SYNC_ENABLED`. This is now set to `false`. \ No newline at end of file diff --git a/apps/server/src/infra/tsp-client/README.md b/apps/server/src/infra/tsp-client/README.md new file mode 100644 index 00000000000..5f4e66829b7 --- /dev/null +++ b/apps/server/src/infra/tsp-client/README.md @@ -0,0 +1,40 @@ +# TSP API CLIENT + +> A short introduction how this module can be used and the api client is generated. + +## How to use the api client + +The clients for the different Tsp endpoints should be created through TspClientFactory. +Through the create methods of the factory the basic configuration will be set. Currently the +factory sets the base url and generates the JWT used for the requests. You can use the client +like this: + +```typescript +export class MyNewService { + // inject the factory into the constructor + constructor(private readonly tspClientFactory: TspClientFactory) {} + + public async doSomeStuff(): Promise { + // this will create a fully initialized client + const exportClient = tspClientFactory.createExportClient(); + + // calling the api + const versionResponse = await exportClient.version(); + + + // do other stuff... + } +} +``` + +## How the code generation works + +We are using the openapi-generator-cli to generate apis, models and supporting files in the +`generated` directory. **DO NOT** modify anything in the `generated` folder, because it will +be deleted on the next client generation. + +The client generation is done with the npm command `npm run generate-client:tsp-api`. This +will delete the old and create new files. We are using the `tsp-api` generator configuration +from the `openapitools.json` found in the repository root. You can add new endpoints by +extending the `FILTER` list in the `openapiNormalizer` section with new `operationId` entries. +New models must be added to the list of `models` in the `globalProperty` section. diff --git a/apps/server/src/infra/tsp-client/generated/.gitignore b/apps/server/src/infra/tsp-client/generated/.gitignore new file mode 100644 index 00000000000..149b5765472 --- /dev/null +++ b/apps/server/src/infra/tsp-client/generated/.gitignore @@ -0,0 +1,4 @@ +wwwroot/*.js +node_modules +typings +dist diff --git a/apps/server/src/infra/tsp-client/generated/.npmignore b/apps/server/src/infra/tsp-client/generated/.npmignore new file mode 100644 index 00000000000..999d88df693 --- /dev/null +++ b/apps/server/src/infra/tsp-client/generated/.npmignore @@ -0,0 +1 @@ +# empty npmignore to ensure all required files (e.g., in the dist folder) are published by npm \ No newline at end of file diff --git a/apps/server/src/infra/tsp-client/generated/.openapi-generator-ignore b/apps/server/src/infra/tsp-client/generated/.openapi-generator-ignore new file mode 100644 index 00000000000..7484ee590a3 --- /dev/null +++ b/apps/server/src/infra/tsp-client/generated/.openapi-generator-ignore @@ -0,0 +1,23 @@ +# OpenAPI Generator Ignore +# Generated by openapi-generator https://github.com/openapitools/openapi-generator + +# Use this file to prevent files from being overwritten by the generator. +# The patterns follow closely to .gitignore or .dockerignore. + +# As an example, the C# client generator defines ApiClient.cs. +# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: +#ApiClient.cs + +# You can match any string of characters against a directory, file or extension with a single asterisk (*): +#foo/*/qux +# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux + +# You can recursively match patterns against a directory, file or extension with a double asterisk (**): +#foo/**/qux +# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux + +# You can also negate patterns with an exclamation (!). +# For example, you can ignore all files in a docs folder with the file extension .md: +#docs/*.md +# Then explicitly reverse the ignore rule for a single file: +#!docs/README.md diff --git a/apps/server/src/infra/tsp-client/generated/.openapi-generator/FILES b/apps/server/src/infra/tsp-client/generated/.openapi-generator/FILES new file mode 100644 index 00000000000..f5006ca3a0b --- /dev/null +++ b/apps/server/src/infra/tsp-client/generated/.openapi-generator/FILES @@ -0,0 +1,18 @@ +.gitignore +.npmignore +.openapi-generator-ignore +api.ts +api/export-api.ts +base.ts +common.ts +configuration.ts +git_push.sh +index.ts +models/index.ts +models/robj-export-klasse.ts +models/robj-export-lehrer-migration.ts +models/robj-export-lehrer.ts +models/robj-export-schueler-migration.ts +models/robj-export-schueler.ts +models/robj-export-schule.ts +models/version-response.ts diff --git a/apps/server/src/infra/tsp-client/generated/api.ts b/apps/server/src/infra/tsp-client/generated/api.ts new file mode 100644 index 00000000000..d214c8a28d1 --- /dev/null +++ b/apps/server/src/infra/tsp-client/generated/api.ts @@ -0,0 +1,18 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * + * TIP-Rest Api v1 + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export * from './api/export-api'; + diff --git a/apps/server/src/infra/tsp-client/generated/api/export-api.ts b/apps/server/src/infra/tsp-client/generated/api/export-api.ts new file mode 100644 index 00000000000..c1b2f2b1579 --- /dev/null +++ b/apps/server/src/infra/tsp-client/generated/api/export-api.ts @@ -0,0 +1,615 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * + * TIP-Rest Api v1 + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import type { Configuration } from '../configuration'; +import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; +import globalAxios from 'axios'; +// Some imports not used depending on template conditions +// @ts-ignore +import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction } from '../common'; +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; +// @ts-ignore +import type { RobjExportKlasse } from '../models'; +// @ts-ignore +import type { RobjExportLehrer } from '../models'; +// @ts-ignore +import type { RobjExportLehrerMigration } from '../models'; +// @ts-ignore +import type { RobjExportSchueler } from '../models'; +// @ts-ignore +import type { RobjExportSchuelerMigration } from '../models'; +// @ts-ignore +import type { RobjExportSchule } from '../models'; +// @ts-ignore +import type { VersionResponse } from '../models'; +/** + * ExportApi - axios parameter creator + * @export + */ +export const ExportApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @summary liefert eine Liste von allen geändert oder erstellten Klassen seit dem gegebenen Datum + * @param {string} [dtLetzteAenderung] Datum der letzten Änderung eingeben + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + exportKlasseList: async (dtLetzteAenderung?: string, options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/schulverwaltung_export_klasse`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + if (dtLetzteAenderung !== undefined) { + localVarQueryParameter['dtLetzteAenderung'] = dtLetzteAenderung; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary liefert eine Liste von allen geändert oder erstellten Lehrer seit dem gegebenen Datum + * @param {string} [dtLetzteAenderung] Datum der letzten Änderung eingeben + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + exportLehrerList: async (dtLetzteAenderung?: string, options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/schulverwaltung_export_lehrer`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + if (dtLetzteAenderung !== undefined) { + localVarQueryParameter['dtLetzteAenderung'] = dtLetzteAenderung; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary liefert eine Liste von allen Lehrern. Zu einem Lehrer wird die alte und die neue uid geliefert. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + exportLehrerListMigration: async (options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/schulverwaltung_export_lehrer_migration`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary liefert eine Liste von allen geändert oder erstellten Schüler seit dem gegebenen Datum + * @param {string} [dtLetzteAenderung] Datum der letzten Änderung eingeben + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + exportSchuelerList: async (dtLetzteAenderung?: string, options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/schulverwaltung_export_schueler`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + if (dtLetzteAenderung !== undefined) { + localVarQueryParameter['dtLetzteAenderung'] = dtLetzteAenderung; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary liefert eine Liste von allen Lehrern. Zu einem Schüler wird die alte und die neue uid geliefert. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + exportSchuelerListMigration: async (options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/schulverwaltung_export_schueler_migration`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary liefert eine Liste von allen geändert oder erstellten Schulen seit dem gegebenen Datum + * @param {string} [dtLetzteAenderung] Datum der letzten Änderung eingeben + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + exportSchuleList: async (dtLetzteAenderung?: string, options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/schulverwaltung_export_schule`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + if (dtLetzteAenderung !== undefined) { + localVarQueryParameter['dtLetzteAenderung'] = dtLetzteAenderung; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary liefert die aktuelle Version zurück + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + version: async (options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/schulverwaltung_export_version`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * ExportApi - functional programming interface + * @export + */ +export const ExportApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = ExportApiAxiosParamCreator(configuration) + return { + /** + * + * @summary liefert eine Liste von allen geändert oder erstellten Klassen seit dem gegebenen Datum + * @param {string} [dtLetzteAenderung] Datum der letzten Änderung eingeben + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async exportKlasseList(dtLetzteAenderung?: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.exportKlasseList(dtLetzteAenderung, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ExportApi.exportKlasseList']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary liefert eine Liste von allen geändert oder erstellten Lehrer seit dem gegebenen Datum + * @param {string} [dtLetzteAenderung] Datum der letzten Änderung eingeben + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async exportLehrerList(dtLetzteAenderung?: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.exportLehrerList(dtLetzteAenderung, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ExportApi.exportLehrerList']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary liefert eine Liste von allen Lehrern. Zu einem Lehrer wird die alte und die neue uid geliefert. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async exportLehrerListMigration(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.exportLehrerListMigration(options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ExportApi.exportLehrerListMigration']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary liefert eine Liste von allen geändert oder erstellten Schüler seit dem gegebenen Datum + * @param {string} [dtLetzteAenderung] Datum der letzten Änderung eingeben + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async exportSchuelerList(dtLetzteAenderung?: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.exportSchuelerList(dtLetzteAenderung, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ExportApi.exportSchuelerList']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary liefert eine Liste von allen Lehrern. Zu einem Schüler wird die alte und die neue uid geliefert. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async exportSchuelerListMigration(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.exportSchuelerListMigration(options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ExportApi.exportSchuelerListMigration']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary liefert eine Liste von allen geändert oder erstellten Schulen seit dem gegebenen Datum + * @param {string} [dtLetzteAenderung] Datum der letzten Änderung eingeben + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async exportSchuleList(dtLetzteAenderung?: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.exportSchuleList(dtLetzteAenderung, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ExportApi.exportSchuleList']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary liefert die aktuelle Version zurück + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async version(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.version(options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ExportApi.version']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + } +}; + +/** + * ExportApi - factory interface + * @export + */ +export const ExportApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = ExportApiFp(configuration) + return { + /** + * + * @summary liefert eine Liste von allen geändert oder erstellten Klassen seit dem gegebenen Datum + * @param {string} [dtLetzteAenderung] Datum der letzten Änderung eingeben + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + exportKlasseList(dtLetzteAenderung?: string, options?: any): AxiosPromise> { + return localVarFp.exportKlasseList(dtLetzteAenderung, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary liefert eine Liste von allen geändert oder erstellten Lehrer seit dem gegebenen Datum + * @param {string} [dtLetzteAenderung] Datum der letzten Änderung eingeben + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + exportLehrerList(dtLetzteAenderung?: string, options?: any): AxiosPromise> { + return localVarFp.exportLehrerList(dtLetzteAenderung, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary liefert eine Liste von allen Lehrern. Zu einem Lehrer wird die alte und die neue uid geliefert. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + exportLehrerListMigration(options?: any): AxiosPromise> { + return localVarFp.exportLehrerListMigration(options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary liefert eine Liste von allen geändert oder erstellten Schüler seit dem gegebenen Datum + * @param {string} [dtLetzteAenderung] Datum der letzten Änderung eingeben + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + exportSchuelerList(dtLetzteAenderung?: string, options?: any): AxiosPromise> { + return localVarFp.exportSchuelerList(dtLetzteAenderung, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary liefert eine Liste von allen Lehrern. Zu einem Schüler wird die alte und die neue uid geliefert. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + exportSchuelerListMigration(options?: any): AxiosPromise> { + return localVarFp.exportSchuelerListMigration(options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary liefert eine Liste von allen geändert oder erstellten Schulen seit dem gegebenen Datum + * @param {string} [dtLetzteAenderung] Datum der letzten Änderung eingeben + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + exportSchuleList(dtLetzteAenderung?: string, options?: any): AxiosPromise> { + return localVarFp.exportSchuleList(dtLetzteAenderung, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary liefert die aktuelle Version zurück + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + version(options?: any): AxiosPromise { + return localVarFp.version(options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * ExportApi - interface + * @export + * @interface ExportApi + */ +export interface ExportApiInterface { + /** + * + * @summary liefert eine Liste von allen geändert oder erstellten Klassen seit dem gegebenen Datum + * @param {string} [dtLetzteAenderung] Datum der letzten Änderung eingeben + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ExportApiInterface + */ + exportKlasseList(dtLetzteAenderung?: string, options?: RawAxiosRequestConfig): AxiosPromise>; + + /** + * + * @summary liefert eine Liste von allen geändert oder erstellten Lehrer seit dem gegebenen Datum + * @param {string} [dtLetzteAenderung] Datum der letzten Änderung eingeben + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ExportApiInterface + */ + exportLehrerList(dtLetzteAenderung?: string, options?: RawAxiosRequestConfig): AxiosPromise>; + + /** + * + * @summary liefert eine Liste von allen Lehrern. Zu einem Lehrer wird die alte und die neue uid geliefert. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ExportApiInterface + */ + exportLehrerListMigration(options?: RawAxiosRequestConfig): AxiosPromise>; + + /** + * + * @summary liefert eine Liste von allen geändert oder erstellten Schüler seit dem gegebenen Datum + * @param {string} [dtLetzteAenderung] Datum der letzten Änderung eingeben + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ExportApiInterface + */ + exportSchuelerList(dtLetzteAenderung?: string, options?: RawAxiosRequestConfig): AxiosPromise>; + + /** + * + * @summary liefert eine Liste von allen Lehrern. Zu einem Schüler wird die alte und die neue uid geliefert. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ExportApiInterface + */ + exportSchuelerListMigration(options?: RawAxiosRequestConfig): AxiosPromise>; + + /** + * + * @summary liefert eine Liste von allen geändert oder erstellten Schulen seit dem gegebenen Datum + * @param {string} [dtLetzteAenderung] Datum der letzten Änderung eingeben + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ExportApiInterface + */ + exportSchuleList(dtLetzteAenderung?: string, options?: RawAxiosRequestConfig): AxiosPromise>; + + /** + * + * @summary liefert die aktuelle Version zurück + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ExportApiInterface + */ + version(options?: RawAxiosRequestConfig): AxiosPromise; + +} + +/** + * ExportApi - object-oriented interface + * @export + * @class ExportApi + * @extends {BaseAPI} + */ +export class ExportApi extends BaseAPI implements ExportApiInterface { + /** + * + * @summary liefert eine Liste von allen geändert oder erstellten Klassen seit dem gegebenen Datum + * @param {string} [dtLetzteAenderung] Datum der letzten Änderung eingeben + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ExportApi + */ + public exportKlasseList(dtLetzteAenderung?: string, options?: RawAxiosRequestConfig) { + return ExportApiFp(this.configuration).exportKlasseList(dtLetzteAenderung, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary liefert eine Liste von allen geändert oder erstellten Lehrer seit dem gegebenen Datum + * @param {string} [dtLetzteAenderung] Datum der letzten Änderung eingeben + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ExportApi + */ + public exportLehrerList(dtLetzteAenderung?: string, options?: RawAxiosRequestConfig) { + return ExportApiFp(this.configuration).exportLehrerList(dtLetzteAenderung, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary liefert eine Liste von allen Lehrern. Zu einem Lehrer wird die alte und die neue uid geliefert. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ExportApi + */ + public exportLehrerListMigration(options?: RawAxiosRequestConfig) { + return ExportApiFp(this.configuration).exportLehrerListMigration(options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary liefert eine Liste von allen geändert oder erstellten Schüler seit dem gegebenen Datum + * @param {string} [dtLetzteAenderung] Datum der letzten Änderung eingeben + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ExportApi + */ + public exportSchuelerList(dtLetzteAenderung?: string, options?: RawAxiosRequestConfig) { + return ExportApiFp(this.configuration).exportSchuelerList(dtLetzteAenderung, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary liefert eine Liste von allen Lehrern. Zu einem Schüler wird die alte und die neue uid geliefert. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ExportApi + */ + public exportSchuelerListMigration(options?: RawAxiosRequestConfig) { + return ExportApiFp(this.configuration).exportSchuelerListMigration(options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary liefert eine Liste von allen geändert oder erstellten Schulen seit dem gegebenen Datum + * @param {string} [dtLetzteAenderung] Datum der letzten Änderung eingeben + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ExportApi + */ + public exportSchuleList(dtLetzteAenderung?: string, options?: RawAxiosRequestConfig) { + return ExportApiFp(this.configuration).exportSchuleList(dtLetzteAenderung, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary liefert die aktuelle Version zurück + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ExportApi + */ + public version(options?: RawAxiosRequestConfig) { + return ExportApiFp(this.configuration).version(options).then((request) => request(this.axios, this.basePath)); + } +} + diff --git a/apps/server/src/infra/tsp-client/generated/base.ts b/apps/server/src/infra/tsp-client/generated/base.ts new file mode 100644 index 00000000000..71bce5185c6 --- /dev/null +++ b/apps/server/src/infra/tsp-client/generated/base.ts @@ -0,0 +1,86 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * + * TIP-Rest Api v1 + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import type { Configuration } from './configuration'; +// Some imports not used depending on template conditions +// @ts-ignore +import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; +import globalAxios from 'axios'; + +export const BASE_PATH = "http://localhost/tip-ms/api".replace(/\/+$/, ""); + +/** + * + * @export + */ +export const COLLECTION_FORMATS = { + csv: ",", + ssv: " ", + tsv: "\t", + pipes: "|", +}; + +/** + * + * @export + * @interface RequestArgs + */ +export interface RequestArgs { + url: string; + options: RawAxiosRequestConfig; +} + +/** + * + * @export + * @class BaseAPI + */ +export class BaseAPI { + protected configuration: Configuration | undefined; + + constructor(configuration?: Configuration, protected basePath: string = BASE_PATH, protected axios: AxiosInstance = globalAxios) { + if (configuration) { + this.configuration = configuration; + this.basePath = configuration.basePath ?? basePath; + } + } +}; + +/** + * + * @export + * @class RequiredError + * @extends {Error} + */ +export class RequiredError extends Error { + constructor(public field: string, msg?: string) { + super(msg); + this.name = "RequiredError" + } +} + +interface ServerMap { + [key: string]: { + url: string, + description: string, + }[]; +} + +/** + * + * @export + */ +export const operationServerMap: ServerMap = { +} diff --git a/apps/server/src/infra/tsp-client/generated/common.ts b/apps/server/src/infra/tsp-client/generated/common.ts new file mode 100644 index 00000000000..7f61c675c86 --- /dev/null +++ b/apps/server/src/infra/tsp-client/generated/common.ts @@ -0,0 +1,150 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * + * TIP-Rest Api v1 + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import type { Configuration } from "./configuration"; +import type { RequestArgs } from "./base"; +import type { AxiosInstance, AxiosResponse } from 'axios'; +import { RequiredError } from "./base"; + +/** + * + * @export + */ +export const DUMMY_BASE_URL = 'https://example.com' + +/** + * + * @throws {RequiredError} + * @export + */ +export const assertParamExists = function (functionName: string, paramName: string, paramValue: unknown) { + if (paramValue === null || paramValue === undefined) { + throw new RequiredError(paramName, `Required parameter ${paramName} was null or undefined when calling ${functionName}.`); + } +} + +/** + * + * @export + */ +export const setApiKeyToObject = async function (object: any, keyParamName: string, configuration?: Configuration) { + if (configuration && configuration.apiKey) { + const localVarApiKeyValue = typeof configuration.apiKey === 'function' + ? await configuration.apiKey(keyParamName) + : await configuration.apiKey; + object[keyParamName] = localVarApiKeyValue; + } +} + +/** + * + * @export + */ +export const setBasicAuthToObject = function (object: any, configuration?: Configuration) { + if (configuration && (configuration.username || configuration.password)) { + object["auth"] = { username: configuration.username, password: configuration.password }; + } +} + +/** + * + * @export + */ +export const setBearerAuthToObject = async function (object: any, configuration?: Configuration) { + if (configuration && configuration.accessToken) { + const accessToken = typeof configuration.accessToken === 'function' + ? await configuration.accessToken() + : await configuration.accessToken; + object["Authorization"] = "Bearer " + accessToken; + } +} + +/** + * + * @export + */ +export const setOAuthToObject = async function (object: any, name: string, scopes: string[], configuration?: Configuration) { + if (configuration && configuration.accessToken) { + const localVarAccessTokenValue = typeof configuration.accessToken === 'function' + ? await configuration.accessToken(name, scopes) + : await configuration.accessToken; + object["Authorization"] = "Bearer " + localVarAccessTokenValue; + } +} + +function setFlattenedQueryParams(urlSearchParams: URLSearchParams, parameter: any, key: string = ""): void { + if (parameter == null) return; + if (typeof parameter === "object") { + if (Array.isArray(parameter)) { + (parameter as any[]).forEach(item => setFlattenedQueryParams(urlSearchParams, item, key)); + } + else { + Object.keys(parameter).forEach(currentKey => + setFlattenedQueryParams(urlSearchParams, parameter[currentKey], `${key}${key !== '' ? '.' : ''}${currentKey}`) + ); + } + } + else { + if (urlSearchParams.has(key)) { + urlSearchParams.append(key, parameter); + } + else { + urlSearchParams.set(key, parameter); + } + } +} + +/** + * + * @export + */ +export const setSearchParams = function (url: URL, ...objects: any[]) { + const searchParams = new URLSearchParams(url.search); + setFlattenedQueryParams(searchParams, objects); + url.search = searchParams.toString(); +} + +/** + * + * @export + */ +export const serializeDataIfNeeded = function (value: any, requestOptions: any, configuration?: Configuration) { + const nonString = typeof value !== 'string'; + const needsSerialization = nonString && configuration && configuration.isJsonMime + ? configuration.isJsonMime(requestOptions.headers['Content-Type']) + : nonString; + return needsSerialization + ? JSON.stringify(value !== undefined ? value : {}) + : (value || ""); +} + +/** + * + * @export + */ +export const toPathString = function (url: URL) { + return url.pathname + url.search + url.hash +} + +/** + * + * @export + */ +export const createRequestFunction = function (axiosArgs: RequestArgs, globalAxios: AxiosInstance, BASE_PATH: string, configuration?: Configuration) { + return >(axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => { + const axiosRequestArgs = {...axiosArgs.options, url: (axios.defaults.baseURL ? '' : configuration?.basePath ?? basePath) + axiosArgs.url}; + return axios.request(axiosRequestArgs); + }; +} diff --git a/apps/server/src/infra/tsp-client/generated/configuration.ts b/apps/server/src/infra/tsp-client/generated/configuration.ts new file mode 100644 index 00000000000..141f3554a7d --- /dev/null +++ b/apps/server/src/infra/tsp-client/generated/configuration.ts @@ -0,0 +1,110 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * + * TIP-Rest Api v1 + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface ConfigurationParameters { + apiKey?: string | Promise | ((name: string) => string) | ((name: string) => Promise); + username?: string; + password?: string; + accessToken?: string | Promise | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise); + basePath?: string; + serverIndex?: number; + baseOptions?: any; + formDataCtor?: new () => any; +} + +export class Configuration { + /** + * parameter for apiKey security + * @param name security name + * @memberof Configuration + */ + apiKey?: string | Promise | ((name: string) => string) | ((name: string) => Promise); + /** + * parameter for basic security + * + * @type {string} + * @memberof Configuration + */ + username?: string; + /** + * parameter for basic security + * + * @type {string} + * @memberof Configuration + */ + password?: string; + /** + * parameter for oauth2 security + * @param name security name + * @param scopes oauth2 scope + * @memberof Configuration + */ + accessToken?: string | Promise | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise); + /** + * override base path + * + * @type {string} + * @memberof Configuration + */ + basePath?: string; + /** + * override server index + * + * @type {number} + * @memberof Configuration + */ + serverIndex?: number; + /** + * base options for axios calls + * + * @type {any} + * @memberof Configuration + */ + baseOptions?: any; + /** + * The FormData constructor that will be used to create multipart form data + * requests. You can inject this here so that execution environments that + * do not support the FormData class can still run the generated client. + * + * @type {new () => FormData} + */ + formDataCtor?: new () => any; + + constructor(param: ConfigurationParameters = {}) { + this.apiKey = param.apiKey; + this.username = param.username; + this.password = param.password; + this.accessToken = param.accessToken; + this.basePath = param.basePath; + this.serverIndex = param.serverIndex; + this.baseOptions = param.baseOptions; + this.formDataCtor = param.formDataCtor; + } + + /** + * Check if the given MIME is a JSON MIME. + * JSON MIME examples: + * application/json + * application/json; charset=UTF8 + * APPLICATION/JSON + * application/vnd.company+json + * @param mime - MIME (Multipurpose Internet Mail Extensions) + * @return True if the given MIME is JSON, false otherwise. + */ + public isJsonMime(mime: string): boolean { + const jsonMime: RegExp = new RegExp('^(application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(;.*)?$', 'i'); + return mime !== null && (jsonMime.test(mime) || mime.toLowerCase() === 'application/json-patch+json'); + } +} diff --git a/apps/server/src/infra/tsp-client/generated/git_push.sh b/apps/server/src/infra/tsp-client/generated/git_push.sh new file mode 100644 index 00000000000..f53a75d4fab --- /dev/null +++ b/apps/server/src/infra/tsp-client/generated/git_push.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/ +# +# Usage example: /bin/sh ./git_push.sh wing328 openapi-petstore-perl "minor update" "gitlab.com" + +git_user_id=$1 +git_repo_id=$2 +release_note=$3 +git_host=$4 + +if [ "$git_host" = "" ]; then + git_host="github.com" + echo "[INFO] No command line input provided. Set \$git_host to $git_host" +fi + +if [ "$git_user_id" = "" ]; then + git_user_id="GIT_USER_ID" + echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id" +fi + +if [ "$git_repo_id" = "" ]; then + git_repo_id="GIT_REPO_ID" + echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id" +fi + +if [ "$release_note" = "" ]; then + release_note="Minor update" + echo "[INFO] No command line input provided. Set \$release_note to $release_note" +fi + +# Initialize the local directory as a Git repository +git init + +# Adds the files in the local repository and stages them for commit. +git add . + +# Commits the tracked changes and prepares them to be pushed to a remote repository. +git commit -m "$release_note" + +# Sets the new remote +git_remote=$(git remote) +if [ "$git_remote" = "" ]; then # git remote not defined + + if [ "$GIT_TOKEN" = "" ]; then + echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment." + git remote add origin https://${git_host}/${git_user_id}/${git_repo_id}.git + else + git remote add origin https://${git_user_id}:"${GIT_TOKEN}"@${git_host}/${git_user_id}/${git_repo_id}.git + fi + +fi + +git pull origin master + +# Pushes (Forces) the changes in the local repository up to the remote repository +echo "Git pushing to https://${git_host}/${git_user_id}/${git_repo_id}.git" +git push origin master 2>&1 | grep -v 'To https' diff --git a/apps/server/src/infra/tsp-client/generated/index.ts b/apps/server/src/infra/tsp-client/generated/index.ts new file mode 100644 index 00000000000..ad35bdeb9b5 --- /dev/null +++ b/apps/server/src/infra/tsp-client/generated/index.ts @@ -0,0 +1,18 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * + * TIP-Rest Api v1 + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export * from "./api"; +export * from "./configuration"; +export * from "./models"; diff --git a/apps/server/src/infra/tsp-client/generated/models/index.ts b/apps/server/src/infra/tsp-client/generated/models/index.ts new file mode 100644 index 00000000000..06742e128ad --- /dev/null +++ b/apps/server/src/infra/tsp-client/generated/models/index.ts @@ -0,0 +1,7 @@ +export * from './robj-export-klasse'; +export * from './robj-export-lehrer'; +export * from './robj-export-lehrer-migration'; +export * from './robj-export-schueler'; +export * from './robj-export-schueler-migration'; +export * from './robj-export-schule'; +export * from './version-response'; diff --git a/apps/server/src/infra/tsp-client/generated/models/robj-export-klasse.ts b/apps/server/src/infra/tsp-client/generated/models/robj-export-klasse.ts new file mode 100644 index 00000000000..76b4075bf2f --- /dev/null +++ b/apps/server/src/infra/tsp-client/generated/models/robj-export-klasse.ts @@ -0,0 +1,60 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * + * TIP-Rest Api v1 + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * + * @export + * @interface RobjExportKlasse + */ +export interface RobjExportKlasse { + /** + * + * @type {string} + * @memberof RobjExportKlasse + */ + 'id'?: string; + /** + * + * @type {string} + * @memberof RobjExportKlasse + */ + 'version'?: string; + /** + * + * @type {string} + * @memberof RobjExportKlasse + */ + 'klasseId'?: string; + /** + * + * @type {string} + * @memberof RobjExportKlasse + */ + 'klasseName'?: string; + /** + * + * @type {string} + * @memberof RobjExportKlasse + */ + 'schuleNummer'?: string; + /** + * + * @type {string} + * @memberof RobjExportKlasse + */ + 'lehrerUid'?: string; +} + diff --git a/apps/server/src/infra/tsp-client/generated/models/robj-export-lehrer-migration.ts b/apps/server/src/infra/tsp-client/generated/models/robj-export-lehrer-migration.ts new file mode 100644 index 00000000000..18ecaa90428 --- /dev/null +++ b/apps/server/src/infra/tsp-client/generated/models/robj-export-lehrer-migration.ts @@ -0,0 +1,36 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * + * TIP-Rest Api v1 + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * + * @export + * @interface RobjExportLehrerMigration + */ +export interface RobjExportLehrerMigration { + /** + * + * @type {string} + * @memberof RobjExportLehrerMigration + */ + 'lehrerUidAlt'?: string; + /** + * + * @type {string} + * @memberof RobjExportLehrerMigration + */ + 'lehrerUidNeu'?: string; +} + diff --git a/apps/server/src/infra/tsp-client/generated/models/robj-export-lehrer.ts b/apps/server/src/infra/tsp-client/generated/models/robj-export-lehrer.ts new file mode 100644 index 00000000000..9fe048f7598 --- /dev/null +++ b/apps/server/src/infra/tsp-client/generated/models/robj-export-lehrer.ts @@ -0,0 +1,54 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * + * TIP-Rest Api v1 + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * + * @export + * @interface RobjExportLehrer + */ +export interface RobjExportLehrer { + /** + * + * @type {string} + * @memberof RobjExportLehrer + */ + 'lehrerUid'?: string; + /** + * + * @type {string} + * @memberof RobjExportLehrer + */ + 'lehrerTitel'?: string; + /** + * + * @type {string} + * @memberof RobjExportLehrer + */ + 'lehrerVorname'?: string; + /** + * + * @type {string} + * @memberof RobjExportLehrer + */ + 'lehrerNachname'?: string; + /** + * + * @type {string} + * @memberof RobjExportLehrer + */ + 'schuleNummer'?: string; +} + diff --git a/apps/server/src/infra/tsp-client/generated/models/robj-export-schueler-migration.ts b/apps/server/src/infra/tsp-client/generated/models/robj-export-schueler-migration.ts new file mode 100644 index 00000000000..9adc7801628 --- /dev/null +++ b/apps/server/src/infra/tsp-client/generated/models/robj-export-schueler-migration.ts @@ -0,0 +1,36 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * + * TIP-Rest Api v1 + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * + * @export + * @interface RobjExportSchuelerMigration + */ +export interface RobjExportSchuelerMigration { + /** + * + * @type {string} + * @memberof RobjExportSchuelerMigration + */ + 'schuelerUidAlt'?: string; + /** + * + * @type {string} + * @memberof RobjExportSchuelerMigration + */ + 'schuelerUidNeu'?: string; +} + diff --git a/apps/server/src/infra/tsp-client/generated/models/robj-export-schueler.ts b/apps/server/src/infra/tsp-client/generated/models/robj-export-schueler.ts new file mode 100644 index 00000000000..f10a37b4bc1 --- /dev/null +++ b/apps/server/src/infra/tsp-client/generated/models/robj-export-schueler.ts @@ -0,0 +1,54 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * + * TIP-Rest Api v1 + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * + * @export + * @interface RobjExportSchueler + */ +export interface RobjExportSchueler { + /** + * + * @type {string} + * @memberof RobjExportSchueler + */ + 'schuelerUid'?: string; + /** + * + * @type {string} + * @memberof RobjExportSchueler + */ + 'schuelerVorname'?: string; + /** + * + * @type {string} + * @memberof RobjExportSchueler + */ + 'schuelerNachname'?: string; + /** + * + * @type {string} + * @memberof RobjExportSchueler + */ + 'schuleNummer'?: string; + /** + * + * @type {string} + * @memberof RobjExportSchueler + */ + 'klasseId'?: string; +} + diff --git a/apps/server/src/infra/tsp-client/generated/models/robj-export-schule.ts b/apps/server/src/infra/tsp-client/generated/models/robj-export-schule.ts new file mode 100644 index 00000000000..e56f8a55429 --- /dev/null +++ b/apps/server/src/infra/tsp-client/generated/models/robj-export-schule.ts @@ -0,0 +1,36 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * + * TIP-Rest Api v1 + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * + * @export + * @interface RobjExportSchule + */ +export interface RobjExportSchule { + /** + * + * @type {string} + * @memberof RobjExportSchule + */ + 'schuleNummer'?: string; + /** + * + * @type {string} + * @memberof RobjExportSchule + */ + 'schuleName'?: string; +} + diff --git a/apps/server/src/infra/tsp-client/generated/models/version-response.ts b/apps/server/src/infra/tsp-client/generated/models/version-response.ts new file mode 100644 index 00000000000..e8167411b1a --- /dev/null +++ b/apps/server/src/infra/tsp-client/generated/models/version-response.ts @@ -0,0 +1,30 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * + * TIP-Rest Api v1 + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * + * @export + * @interface VersionResponse + */ +export interface VersionResponse { + /** + * + * @type {string} + * @memberof VersionResponse + */ + 'version'?: string; +} + diff --git a/apps/server/src/infra/tsp-client/index.ts b/apps/server/src/infra/tsp-client/index.ts new file mode 100644 index 00000000000..66f5a6ebced --- /dev/null +++ b/apps/server/src/infra/tsp-client/index.ts @@ -0,0 +1,3 @@ +export * from './generated/api'; +export * from './generated/models'; +export { TspClientFactory } from './tsp-client-factory'; diff --git a/apps/server/src/infra/tsp-client/tsp-client-config.ts b/apps/server/src/infra/tsp-client/tsp-client-config.ts new file mode 100644 index 00000000000..95c1da4131b --- /dev/null +++ b/apps/server/src/infra/tsp-client/tsp-client-config.ts @@ -0,0 +1,9 @@ +export interface TspRestClientConfig { + SC_DOMAIN: string; + HOST: string; + TSP_API_BASE_URL: string; + TSP_API_CLIENT_ID: string; + TSP_API_CLIENT_SECRET: string; + TSP_API_TOKEN_LIFETIME_MS: number; + TSP_API_SIGNATURE_KEY: string; +} diff --git a/apps/server/src/infra/tsp-client/tsp-client-factory.spec.ts b/apps/server/src/infra/tsp-client/tsp-client-factory.spec.ts new file mode 100644 index 00000000000..45c7c13072a --- /dev/null +++ b/apps/server/src/infra/tsp-client/tsp-client-factory.spec.ts @@ -0,0 +1,105 @@ +import { faker } from '@faker-js/faker'; +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ServerConfig } from '@src/modules/server'; +import { TspClientFactory } from './tsp-client-factory'; + +describe('TspClientFactory', () => { + let module: TestingModule; + let sut: TspClientFactory; + let configServiceMock: DeepMocked>; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + TspClientFactory, + { + provide: ConfigService, + useValue: createMock>({ + getOrThrow: (key: string) => { + switch (key) { + case 'SC_DOMAIN': + return faker.internet.domainName(); + case 'HOST': + return faker.internet.url(); + case 'TSP_API_BASE_URL': + return 'https://test2.schulportal-thueringen.de/tip-ms/api/'; + case 'TSP_API_CLIENT_ID': + return faker.string.uuid(); + case 'TSP_API_CLIENT_SECRET': + return faker.string.uuid(); + case 'TSP_API_SIGNATURE_KEY': + return faker.string.uuid(); + case 'TSP_API_TOKEN_LIFETIME_MS': + return faker.number.int(); + default: + throw new Error(`Unknown key: ${key}`); + } + }, + }), + }, + ], + }).compile(); + + sut = module.get(TspClientFactory); + configServiceMock = module.get(ConfigService); + }); + + afterAll(async () => { + await module.close(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('createExportClient', () => { + describe('when createExportClient is called', () => { + it('should return ExportApiInterface', () => { + const result = sut.createExportClient(); + + expect(result).toBeDefined(); + expect(configServiceMock.getOrThrow).toHaveBeenCalledTimes(0); + }); + }); + + describe('when token is cached', () => { + const setup = () => { + Reflect.set(sut, 'cachedToken', faker.string.alpha()); + const client = sut.createExportClient(); + + return client; + }; + + it('should return ExportApiInterface', () => { + const result = setup(); + + expect(result).toBeDefined(); + expect(configServiceMock.getOrThrow).toHaveBeenCalledTimes(0); + }); + }); + }); + + // TODO: add a working integration test + describe.skip('when using the created client', () => { + const setup = () => { + const client = sut.createExportClient(); + + return client; + }; + + it('should return the migration version', async () => { + const client = setup(); + + const result = await client.version(); + + expect(result.status).toBe(200); + expect(result.data.version).toBeDefined(); + }); + }); +}); diff --git a/apps/server/src/infra/tsp-client/tsp-client-factory.ts b/apps/server/src/infra/tsp-client/tsp-client-factory.ts new file mode 100644 index 00000000000..835cd8e333d --- /dev/null +++ b/apps/server/src/infra/tsp-client/tsp-client-factory.ts @@ -0,0 +1,73 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { randomUUID } from 'crypto'; +import * as jwt from 'jsonwebtoken'; +import { Configuration, ExportApiFactory, ExportApiInterface } from './generated'; +import { TspRestClientConfig } from './tsp-client-config'; + +@Injectable() +export class TspClientFactory { + private readonly domain: string; + + private readonly host: string; + + private readonly baseUrl: string; + + private readonly clientId: string; + + private readonly clientSecret: string; + + private readonly signingKey: string; + + private readonly tokenLifetime: number; + + private cachedToken: string | undefined; + + private tokenExpiresAt: number | undefined; + + constructor(configService: ConfigService) { + this.domain = configService.getOrThrow('SC_DOMAIN'); + this.host = configService.getOrThrow('HOST'); + this.baseUrl = configService.getOrThrow('TSP_API_BASE_URL'); + this.clientId = configService.getOrThrow('TSP_API_CLIENT_ID'); + this.clientSecret = configService.getOrThrow('TSP_API_CLIENT_SECRET'); + this.signingKey = configService.getOrThrow('TSP_API_SIGNATURE_KEY'); + this.tokenLifetime = configService.getOrThrow('TSP_API_TOKEN_LIFETIME_MS'); + } + + public createExportClient(): ExportApiInterface { + const factory = ExportApiFactory( + new Configuration({ + accessToken: this.createJwt(), + basePath: this.baseUrl, + }) + ); + + return factory; + } + + private createJwt(): string { + const now = Date.now(); + + if (this.cachedToken && this.tokenExpiresAt && this.tokenExpiresAt > now) { + return this.cachedToken; + } + + this.tokenExpiresAt = now + this.tokenLifetime; + + const payload = { + apiClientId: this.clientId, + apiClientSecret: this.clientSecret, + iss: this.domain, + aud: this.baseUrl, + sub: this.host, + exp: this.tokenExpiresAt, + iat: this.tokenExpiresAt - this.tokenLifetime, + jti: randomUUID(), + }; + + this.cachedToken = jwt.sign(payload, this.signingKey); + + return this.cachedToken; + } +} diff --git a/apps/server/src/infra/tsp-client/tsp-client.module.ts b/apps/server/src/infra/tsp-client/tsp-client.module.ts new file mode 100644 index 00000000000..c5b459df3f8 --- /dev/null +++ b/apps/server/src/infra/tsp-client/tsp-client.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { TspClientFactory } from './tsp-client-factory'; + +@Module({ + providers: [TspClientFactory], + exports: [TspClientFactory], +}) +export class TspClientModule {} diff --git a/apps/server/src/modules/account/account-config.ts b/apps/server/src/modules/account/account-config.ts index 9a0676782f2..e2623aa6f18 100644 --- a/apps/server/src/modules/account/account-config.ts +++ b/apps/server/src/modules/account/account-config.ts @@ -1,4 +1,7 @@ -export interface AccountConfig { +import { LoggerConfig } from '@src/core/logger'; +import { SystemConfig } from '@modules/system'; + +export interface AccountConfig extends LoggerConfig, SystemConfig { LOGIN_BLOCK_TIME: number; TEACHER_STUDENT_VISIBILITY__IS_CONFIGURABLE: boolean; FEATURE_IDENTITY_MANAGEMENT_LOGIN_ENABLED: boolean; diff --git a/apps/server/src/modules/account/account.module.ts b/apps/server/src/modules/account/account.module.ts index 4acde7473fb..454a42ad96e 100644 --- a/apps/server/src/modules/account/account.module.ts +++ b/apps/server/src/modules/account/account.module.ts @@ -2,7 +2,6 @@ import { IdentityManagementModule } from '@infra/identity-management'; import { SystemModule } from '@modules/system'; import { Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; - import { CqrsModule } from '@nestjs/cqrs'; import { UserRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger/logger.module'; @@ -23,7 +22,7 @@ function accountIdmToDtoMapperFactory(configService: ConfigService { schoolId: school.id, accountId: account.id, isExternalUser: true, - // impersonated: false, + // support: false, // externalIdToken: undefined, }); }); diff --git a/apps/server/src/modules/authentication/index.ts b/apps/server/src/modules/authentication/index.ts index 81f415d4f46..d3bc6d80975 100644 --- a/apps/server/src/modules/authentication/index.ts +++ b/apps/server/src/modules/authentication/index.ts @@ -1,3 +1,4 @@ export { AuthenticationConfig } from './authentication-config'; export { AuthenticationModule } from './authentication.module'; export { AuthenticationService } from './services'; +export { LoginDto } from './uc'; diff --git a/apps/server/src/modules/authentication/loggable/index.ts b/apps/server/src/modules/authentication/loggable/index.ts index ed6249cc5bd..2d9f2567796 100644 --- a/apps/server/src/modules/authentication/loggable/index.ts +++ b/apps/server/src/modules/authentication/loggable/index.ts @@ -1,4 +1,5 @@ -export * from './school-in-migration.loggable-exception'; export * from './account-not-found.loggable-exception'; -export * from './user-could-not-be-authenticated.loggable.exception'; +export * from './school-in-migration.loggable-exception'; +export * from './shd-user-create-token.loggable'; export * from './user-authenticated.loggable'; +export * from './user-could-not-be-authenticated.loggable.exception'; diff --git a/apps/server/src/modules/authentication/loggable/shd-user-create-token.loggable.spec.ts b/apps/server/src/modules/authentication/loggable/shd-user-create-token.loggable.spec.ts new file mode 100644 index 00000000000..63ebc3135ed --- /dev/null +++ b/apps/server/src/modules/authentication/loggable/shd-user-create-token.loggable.spec.ts @@ -0,0 +1,24 @@ +import { ShdUserCreateTokenLoggable } from './shd-user-create-token.loggable'; + +describe(ShdUserCreateTokenLoggable.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const loggable = new ShdUserCreateTokenLoggable('supportUserId', 'targetUserId', 10); + + return { + loggable, + }; + }; + + it('should return the correct log message', () => { + const { loggable } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + message: `The support employee with the Id supportUserId has created a short live JWT for the user with the Id targetUserId. The JWT expires expires in 0.16666666666666666 minutes`, + data: {}, + }); + }); + }); +}); diff --git a/apps/server/src/modules/authentication/loggable/shd-user-create-token.loggable.ts b/apps/server/src/modules/authentication/loggable/shd-user-create-token.loggable.ts new file mode 100644 index 00000000000..3c3712bd5d1 --- /dev/null +++ b/apps/server/src/modules/authentication/loggable/shd-user-create-token.loggable.ts @@ -0,0 +1,20 @@ +import { EntityId } from '@shared/domain/types'; +import { Loggable } from '@src/core/logger/interfaces'; +import { LogMessage } from '@src/core/logger/types'; + +export class ShdUserCreateTokenLoggable implements Loggable { + constructor(private supportUserId: EntityId, private targetUserId: EntityId, private expiredIn: number) {} + + getLogMessage(): LogMessage { + const message: LogMessage = { + message: `The support employee with the Id ${ + this.supportUserId + } has created a short live JWT for the user with the Id ${this.targetUserId}. The JWT expires expires in ${ + this.expiredIn / 60 + } minutes`, + data: {}, + }; + + return message; + } +} diff --git a/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts b/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts index 8a13db33dd4..19f243333e9 100644 --- a/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts +++ b/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts @@ -156,7 +156,7 @@ describe('CurrentUserMapper', () => { userId, externalIdToken: idToken, isExternalUser: true, - impersonated: false, + support: false, }); }); }); @@ -194,7 +194,7 @@ describe('CurrentUserMapper', () => { userId, externalIdToken: idToken, isExternalUser: true, - impersonated: false, + support: false, }); }); }); diff --git a/apps/server/src/modules/authentication/services/authentication.service.spec.ts b/apps/server/src/modules/authentication/services/authentication.service.spec.ts index ceff02a335b..31db4ac0e28 100644 --- a/apps/server/src/modules/authentication/services/authentication.service.spec.ts +++ b/apps/server/src/modules/authentication/services/authentication.service.spec.ts @@ -1,14 +1,18 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { JwtPayloadFactory } from '@infra/auth-guard'; import { Account, AccountService } from '@modules/account'; +import { accountDoFactory } from '@modules/account/testing'; import { UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { Test, TestingModule } from '@nestjs/testing'; -import { currentUserFactory } from '@shared/testing'; +import { currentUserFactory, setupEntities, userFactory } from '@shared/testing'; +import { Logger } from '@src/core/logger'; import jwt from 'jsonwebtoken'; import { BruteForceError } from '../errors/brute-force.error'; import { JwtWhitelistAdapter } from '../helper/jwt-whitelist.adapter'; import { UserAccountDeactivatedLoggableException } from '../loggable/user-account-deactivated-exception'; +import { CurrentUserMapper } from '../mapper'; import { AuthenticationService } from './authentication.service'; jest.mock('jsonwebtoken'); @@ -20,6 +24,7 @@ describe('AuthenticationService', () => { let jwtWhitelistAdapter: DeepMocked; let accountService: DeepMocked; let jwtService: DeepMocked; + let configService: DeepMocked; const mockAccount: Account = new Account({ id: 'mockAccountId', @@ -29,6 +34,8 @@ describe('AuthenticationService', () => { }); beforeAll(async () => { + await setupEntities(); + module = await Test.createTestingModule({ providers: [ AuthenticationService, @@ -48,6 +55,10 @@ describe('AuthenticationService', () => { provide: ConfigService, useValue: createMock({ get: () => 15 }), }, + { + provide: Logger, + useValue: createMock(), + }, ], }).compile(); @@ -55,6 +66,7 @@ describe('AuthenticationService', () => { authenticationService = module.get(AuthenticationService); accountService = module.get(AccountService); jwtService = module.get(JwtService); + configService = module.get(ConfigService); }); afterEach(() => { @@ -102,6 +114,7 @@ describe('AuthenticationService', () => { username: 'mockedUsername', deactivatedAt: new Date(), }); + it('should throw USER_ACCOUNT_DEACTIVATED exception', async () => { const deactivatedAccount = setup(); accountService.findByUsernameAndSystemId.mockResolvedValue(deactivatedAccount); @@ -111,19 +124,86 @@ describe('AuthenticationService', () => { }); }); - describe('generateJwt', () => { + describe('generateSupportJwt', () => { describe('when generating new jwt', () => { + const setup = () => { + const supportUser = userFactory.asSuperhero().buildWithId(); + const targetUser = userFactory.asTeacher().buildWithId(); + const targetUserAccount = accountDoFactory.build({ userId: targetUser.id }); + const mockCurrentUser = CurrentUserMapper.userToICurrentUser( + targetUserAccount.id, + targetUser, + false, + targetUserAccount.systemId + ); + const expiresIn = 150; + + accountService.findByUserIdOrFail.mockResolvedValueOnce(targetUserAccount); + configService.get.mockReturnValueOnce(expiresIn); + jwtService.sign.mockReturnValueOnce('jwt'); + + const expectedPayload = JwtPayloadFactory.buildFromSupportUser(mockCurrentUser, supportUser.id); + + return { supportUser, targetUser, mockCurrentUser, targetUserAccount, expectedPayload, expiresIn }; + }; + it('should pass the correct parameters', async () => { + const { supportUser, targetUser, mockCurrentUser, expectedPayload, expiresIn } = setup(); + + await authenticationService.generateSupportJwt(supportUser, targetUser); + + expect(jwtService.sign).toBeCalledWith( + expectedPayload, + expect.objectContaining({ + subject: mockCurrentUser.accountId, + jwtid: expect.any(String), + expiresIn, + }) + ); + }); + + it('should return the generated jwt', async () => { + const { mockCurrentUser } = setup(); + + const result = await authenticationService.generateCurrentUserJwt(mockCurrentUser); + + expect(result).toEqual('jwt'); + }); + }); + }); + + describe('generateCurrentUserJwt', () => { + describe('when generating new jwt', () => { + const setup = () => { const mockCurrentUser = currentUserFactory.withRole('random role').build(); + const expectedPayload = JwtPayloadFactory.buildFromCurrentUser(mockCurrentUser); + const expiresIn = 15; + configService.get.mockReturnValueOnce(expiresIn); + jwtService.sign.mockReturnValueOnce('jwt'); + + return { mockCurrentUser, expectedPayload, expiresIn }; + }; - await authenticationService.generateJwt(mockCurrentUser); + it('should pass the correct parameters', async () => { + const { mockCurrentUser, expectedPayload, expiresIn } = setup(); + await authenticationService.generateCurrentUserJwt(mockCurrentUser); expect(jwtService.sign).toBeCalledWith( - mockCurrentUser, + expectedPayload, expect.objectContaining({ subject: mockCurrentUser.accountId, + jwtid: expect.any(String), + expiresIn, }) ); }); + + it('should return the generated jwt', async () => { + const { mockCurrentUser } = setup(); + + const result = await authenticationService.generateCurrentUserJwt(mockCurrentUser); + + expect(result).toEqual('jwt'); + }); }); }); diff --git a/apps/server/src/modules/authentication/services/authentication.service.ts b/apps/server/src/modules/authentication/services/authentication.service.ts index 33e33b6ba36..dd1864c575c 100644 --- a/apps/server/src/modules/authentication/services/authentication.service.ts +++ b/apps/server/src/modules/authentication/services/authentication.service.ts @@ -1,15 +1,18 @@ -import { CreateJwtPayload } from '@infra/auth-guard'; +import { CreateJwtPayload, ICurrentUser, JwtPayloadFactory } from '@infra/auth-guard'; import { Account, AccountService } from '@modules/account'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; +import { User } from '@shared/domain/entity'; +import { Logger } from '@src/core/logger'; import { randomUUID } from 'crypto'; import jwt, { JwtPayload } from 'jsonwebtoken'; +import { AuthenticationConfig } from '../authentication-config'; import { BruteForceError, UnauthorizedLoggableException } from '../errors'; import { JwtWhitelistAdapter } from '../helper/jwt-whitelist.adapter'; +import { ShdUserCreateTokenLoggable } from '../loggable'; import { UserAccountDeactivatedLoggableException } from '../loggable/user-account-deactivated-exception'; -import { LoginDto } from '../uc/dto'; -import { AuthenticationConfig } from '../authentication-config'; +import { CurrentUserMapper } from '../mapper'; @Injectable() export class AuthenticationService { @@ -17,8 +20,11 @@ export class AuthenticationService { private readonly jwtService: JwtService, private readonly jwtWhitelistAdapter: JwtWhitelistAdapter, private readonly accountService: AccountService, - private readonly configService: ConfigService - ) {} + private readonly configService: ConfigService, + private readonly logger: Logger + ) { + this.logger.setContext(AuthenticationService.name); + } public async loadAccount(username: string, systemId?: string): Promise { let account: Account | undefined | null; @@ -40,18 +46,43 @@ export class AuthenticationService { return account; } - public async generateJwt(createJwtPayload: CreateJwtPayload): Promise { + private async generateJwt(createJwtPayload: CreateJwtPayload, expiresIn?: number | string): Promise { const jti = randomUUID(); - const accessToken = this.jwtService.sign(createJwtPayload, { subject: createJwtPayload.accountId, jwtid: jti, + expiresIn, }); - const result = new LoginDto({ accessToken }); await this.jwtWhitelistAdapter.addToWhitelist(createJwtPayload.accountId, jti); - return result; + return accessToken; + } + + public async generateCurrentUserJwt(currentUser: ICurrentUser): Promise { + const createJwtPayload = JwtPayloadFactory.buildFromCurrentUser(currentUser); + const expiresIn = this.configService.get('JWT_LIFETIME'); + const jwtToken = await this.generateJwt(createJwtPayload, expiresIn); + + return jwtToken; + } + + public async generateSupportJwt(supportUser: User, targetUser: User): Promise { + const targetUserAccount = await this.accountService.findByUserIdOrFail(targetUser.id); + const currentUser = CurrentUserMapper.userToICurrentUser( + targetUserAccount.id, + targetUser, + false, + targetUserAccount.systemId + ); + const createJwtPayload = JwtPayloadFactory.buildFromSupportUser(currentUser, supportUser.id); + const expiresIn = this.configService.get('JWT_LIFETIME_SUPPORT_SECONDS'); + + const jwtToken = await this.generateJwt(createJwtPayload, expiresIn); + + this.logger.info(new ShdUserCreateTokenLoggable(supportUser.id, targetUser.id, expiresIn)); + + return jwtToken; } public async removeJwtFromWhitelist(jwtToken: string): Promise { diff --git a/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts b/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts index 522bf599da3..95e98a18aca 100644 --- a/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts +++ b/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts @@ -433,7 +433,7 @@ describe('LdapStrategy', () => { systemId: system.id, accountId: account.id, isExternalUser: true, - impersonated: false, + support: false, }); }); }); @@ -499,7 +499,7 @@ describe('LdapStrategy', () => { systemId: system.id, accountId: account.id, isExternalUser: true, - impersonated: false, + support: false, }); }); }); diff --git a/apps/server/src/modules/authentication/strategy/oauth2.strategy.spec.ts b/apps/server/src/modules/authentication/strategy/oauth2.strategy.spec.ts index a8401c2da6a..a86dad25432 100644 --- a/apps/server/src/modules/authentication/strategy/oauth2.strategy.spec.ts +++ b/apps/server/src/modules/authentication/strategy/oauth2.strategy.spec.ts @@ -88,7 +88,7 @@ describe('Oauth2Strategy', () => { accountId: account.id, externalIdToken: idToken, isExternalUser: true, - impersonated: false, + support: false, }); }); }); diff --git a/apps/server/src/modules/authentication/uc/index.ts b/apps/server/src/modules/authentication/uc/index.ts new file mode 100644 index 00000000000..8616d4505f9 --- /dev/null +++ b/apps/server/src/modules/authentication/uc/index.ts @@ -0,0 +1,2 @@ +export { LoginDto } from './dto'; +export { LoginUc } from './login.uc'; diff --git a/apps/server/src/modules/authentication/uc/login.uc.spec.ts b/apps/server/src/modules/authentication/uc/login.uc.spec.ts index 4b0d356402a..adc3bb2b6b5 100644 --- a/apps/server/src/modules/authentication/uc/login.uc.spec.ts +++ b/apps/server/src/modules/authentication/uc/login.uc.spec.ts @@ -1,5 +1,6 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; +import { currentUserFactory } from '@shared/testing'; import { AuthenticationService } from '../services/authentication.service'; import { LoginDto } from './dto'; import { LoginUc } from './login.uc'; @@ -28,18 +29,9 @@ describe('LoginUc', () => { describe('getLoginData', () => { describe('when userInfo is given', () => { const setup = () => { - const userInfo = { - accountId: '', - roles: [], - schoolId: '', - userId: '', - systemId: '', - impersonated: false, - isExternalUser: false, - someProperty: 'shouldNotBeMapped', - }; + const userInfo = currentUserFactory.build(); const loginDto: LoginDto = new LoginDto({ accessToken: 'accessToken' }); - authenticationService.generateJwt.mockResolvedValue(loginDto); + authenticationService.generateCurrentUserJwt.mockResolvedValue('accessToken'); return { userInfo, @@ -52,13 +44,13 @@ describe('LoginUc', () => { await loginUc.getLoginData(userInfo); - expect(authenticationService.generateJwt).toHaveBeenCalledWith({ + expect(authenticationService.generateCurrentUserJwt).toHaveBeenCalledWith({ accountId: userInfo.accountId, userId: userInfo.userId, schoolId: userInfo.schoolId, roles: userInfo.roles, systemId: userInfo.systemId, - support: userInfo.impersonated, + support: userInfo.support, isExternalUser: userInfo.isExternalUser, }); }); diff --git a/apps/server/src/modules/authentication/uc/login.uc.ts b/apps/server/src/modules/authentication/uc/login.uc.ts index 08dc12c1f06..54fee7f0051 100644 --- a/apps/server/src/modules/authentication/uc/login.uc.ts +++ b/apps/server/src/modules/authentication/uc/login.uc.ts @@ -1,4 +1,4 @@ -import { ICurrentUser, JwtPayloadFactory } from '@infra/auth-guard'; +import { ICurrentUser } from '@infra/auth-guard'; import { Injectable } from '@nestjs/common'; import { AuthenticationService } from '../services'; import { LoginDto } from './dto'; @@ -8,13 +8,11 @@ export class LoginUc { constructor(private readonly authService: AuthenticationService) {} async getLoginData(currentUser: ICurrentUser): Promise { - const createJwtPayload = JwtPayloadFactory.buildFromCurrentUser(currentUser); - - const accessTokenDto = await this.authService.generateJwt(createJwtPayload); + const jwtToken = await this.authService.generateCurrentUserJwt(currentUser); await this.authService.updateLastLogin(currentUser.accountId); const loginDto = new LoginDto({ - accessToken: accessTokenDto.accessToken, + accessToken: jwtToken, }); return loginDto; diff --git a/apps/server/src/modules/authorization/authorization.config.ts b/apps/server/src/modules/authorization/authorization.config.ts new file mode 100644 index 00000000000..af29e70dbdf --- /dev/null +++ b/apps/server/src/modules/authorization/authorization.config.ts @@ -0,0 +1,3 @@ +import { LoggerConfig } from '@src/core/logger'; + +export interface AuthorizationConfig extends LoggerConfig {} diff --git a/apps/server/src/modules/authorization/authorization.module.ts b/apps/server/src/modules/authorization/authorization.module.ts index df626b37b2f..289b7209f49 100644 --- a/apps/server/src/modules/authorization/authorization.module.ts +++ b/apps/server/src/modules/authorization/authorization.module.ts @@ -56,7 +56,7 @@ import { FeathersAuthorizationService, FeathersAuthProvider } from './feathers'; exports: [ FeathersAuthorizationService, AuthorizationService, - SystemRule, + SystemRule, // Why export? This is a no go! AuthorizationInjectionService, AuthorizationHelper, ], diff --git a/apps/server/src/modules/authorization/domain/rules/instance.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/instance.rule.spec.ts index b5dd35bbc4a..d2168b5d7c3 100644 --- a/apps/server/src/modules/authorization/domain/rules/instance.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/instance.rule.spec.ts @@ -1,4 +1,4 @@ -import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { DeepMocked } from '@golevelup/ts-jest'; import { instanceFactory } from '@modules/instance/testing'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain/interface'; @@ -17,13 +17,7 @@ describe(InstanceRule.name, () => { await setupEntities(); module = await Test.createTestingModule({ - providers: [ - InstanceRule, - { - provide: AuthorizationHelper, - useValue: createMock(), - }, - ], + providers: [InstanceRule, AuthorizationHelper], }).compile(); rule = module.get(InstanceRule); @@ -77,65 +71,116 @@ describe(InstanceRule.name, () => { }); describe('hasPermission', () => { - describe('when the user has all permissions', () => { - const setup = () => { - const user = userFactory.build(); - const instance = instanceFactory.build(); - const context: AuthorizationContext = { - action: Action.write, - requiredPermissions: [Permission.FILESTORAGE_VIEW], + describe('when user is a superhero', () => { + describe('when the user has write permissions', () => { + const setup = () => { + const user = userFactory.asSuperhero().build(); + const instance = instanceFactory.build(); + const context: AuthorizationContext = { + action: Action.write, + requiredPermissions: [Permission.INSTANCE_VIEW], + }; + + return { + user, + instance, + context, + }; }; - authorizationHelper.hasAllPermissions.mockReturnValue(true); + it('should check all permissions', () => { + const { user, instance, context } = setup(); + const spy = jest.spyOn(authorizationHelper, 'hasAllPermissions'); - return { - user, - instance, - context, - }; - }; + rule.hasPermission(user, instance, context); + + expect(spy).toHaveBeenCalledWith(user, context.requiredPermissions); + }); - it('should check all permissions', () => { - const { user, instance, context } = setup(); + it('should return true', () => { + const { user, instance, context } = setup(); - rule.hasPermission(user, instance, context); + const result = rule.hasPermission(user, instance, context); - expect(authorizationHelper.hasAllPermissions).toHaveBeenCalledWith(user, context.requiredPermissions); + expect(result).toEqual(true); + }); }); - it('should return true', () => { - const { user, instance, context } = setup(); + describe('when the user has read permissions', () => { + const setup = () => { + const user = userFactory.asSuperhero().build(); + const instance = instanceFactory.build(); + const context: AuthorizationContext = { + action: Action.read, + requiredPermissions: [Permission.INSTANCE_VIEW], + }; + + return { + user, + instance, + context, + }; + }; - const result = rule.hasPermission(user, instance, context); + it('should return true', () => { + const { user, instance, context } = setup(); - expect(result).toEqual(true); + const result = rule.hasPermission(user, instance, context); + + expect(result).toEqual(true); + }); }); }); - describe('when the user has no permission', () => { - const setup = () => { - const user = userFactory.build(); - const instance = instanceFactory.build(); - const context: AuthorizationContext = { - action: Action.write, - requiredPermissions: [Permission.FILESTORAGE_VIEW], + describe('when user is a student', () => { + describe('when the user has no write permission', () => { + const setup = () => { + const user = userFactory.asStudent().build(); + const instance = instanceFactory.build(); + const context: AuthorizationContext = { + action: Action.write, + requiredPermissions: [Permission.FILESTORAGE_CREATE], + }; + + return { + user, + instance, + context, + }; }; - authorizationHelper.hasAllPermissions.mockReturnValue(false); + it('should return false', () => { + const { user, instance, context } = setup(); - return { - user, - instance, - context, + const result = rule.hasPermission(user, instance, context); + + expect(result).toEqual(false); + }); + }); + + describe('when the user has read permission', () => { + const setup = () => { + const user = userFactory.asStudent().build(); + const instance = instanceFactory.build(); + const context: AuthorizationContext = { + action: Action.read, + requiredPermissions: [Permission.FILESTORAGE_VIEW], + }; + + return { + user, + instance, + context, + }; }; - }; - it('should return false', () => { - const { user, instance, context } = setup(); + it('should return true', () => { + const { user, instance, context } = setup(); - const result = rule.hasPermission(user, instance, context); + const result = rule.hasPermission(user, instance, context); - expect(result).toEqual(false); + expect(result).toEqual(true); + }); }); }); }); diff --git a/apps/server/src/modules/authorization/domain/rules/instance.rule.ts b/apps/server/src/modules/authorization/domain/rules/instance.rule.ts index 4aaba878552..7389aca1023 100644 --- a/apps/server/src/modules/authorization/domain/rules/instance.rule.ts +++ b/apps/server/src/modules/authorization/domain/rules/instance.rule.ts @@ -1,6 +1,8 @@ import { Instance } from '@modules/instance'; import { Injectable } from '@nestjs/common'; import { User } from '@shared/domain/entity'; +import { RoleName } from '@shared/domain/interface'; +import { Action } from '@infra/authorization-client'; import { AuthorizationHelper } from '../service/authorization.helper'; import { AuthorizationContext, Rule } from '../type'; @@ -9,13 +11,20 @@ export class InstanceRule implements Rule { constructor(private readonly authorizationHelper: AuthorizationHelper) {} public isApplicable(user: User, object: unknown): boolean { - const isMatched: boolean = object instanceof Instance; + const isMatched = object instanceof Instance; return isMatched; } public hasPermission(user: User, entity: Instance, context: AuthorizationContext): boolean { - const hasPermission: boolean = this.authorizationHelper.hasAllPermissions(user, context.requiredPermissions); + const hasPermission = this.authorizationHelper.hasAllPermissions(user, context.requiredPermissions); + + // As temporary solution until the user with write access to instance added as group, we must check the role. + if (context.action === Action.WRITE) { + const hasRole = this.authorizationHelper.hasRole(user, RoleName.SUPERHERO); + + return hasPermission && hasRole; + } return hasPermission; } diff --git a/apps/server/src/modules/authorization/domain/service/authorization.helper.spec.ts b/apps/server/src/modules/authorization/domain/service/authorization.helper.spec.ts index 994d7833e4c..4dba8bc5357 100644 --- a/apps/server/src/modules/authorization/domain/service/authorization.helper.spec.ts +++ b/apps/server/src/modules/authorization/domain/service/authorization.helper.spec.ts @@ -1,5 +1,5 @@ import { courseFactory } from '@modules/learnroom/testing'; -import { Permission } from '@shared/domain/interface'; +import { Permission, RoleName } from '@shared/domain/interface'; import { courseFactory as courseEntityFactory, roleFactory, @@ -228,4 +228,38 @@ describe('AuthorizationHelper', () => { }); }); }); + + describe('hasRole', () => { + describe('when user has role', () => { + const setup = () => { + const user = userFactory.asTeacher().buildWithId(); + + return { user }; + }; + + it('should return true', () => { + const { user } = setup(); + + const result = service.hasRole(user, RoleName.TEACHER); + + expect(result).toBe(true); + }); + }); + + describe('when user does not have role', () => { + const setup = () => { + const user = userFactory.asStudent().buildWithId(); + + return { user }; + }; + + it('should return false', () => { + const { user } = setup(); + + const result = service.hasRole(user, RoleName.TEACHER); + + expect(result).toBe(false); + }); + }); + }); }); diff --git a/apps/server/src/modules/authorization/domain/service/authorization.helper.ts b/apps/server/src/modules/authorization/domain/service/authorization.helper.ts index df4d297b9f0..d289b10bc28 100644 --- a/apps/server/src/modules/authorization/domain/service/authorization.helper.ts +++ b/apps/server/src/modules/authorization/domain/service/authorization.helper.ts @@ -1,6 +1,7 @@ import { Collection } from '@mikro-orm/core'; import { Injectable } from '@nestjs/common'; import { Role, User } from '@shared/domain/entity'; +import { RoleName } from '@shared/domain/interface'; @Injectable() export class AuthorizationHelper { @@ -35,6 +36,10 @@ export class AuthorizationHelper { return result; } + public hasRole(user: User, roleName: RoleName) { + return user.roles.getItems().some((role) => role.name === roleName); + } + private isUserReferenced(user: User, entity: T, prop: K): boolean { let result = false; diff --git a/apps/server/src/modules/authorization/index.ts b/apps/server/src/modules/authorization/index.ts index 11e184dbd03..115cfb338e3 100644 --- a/apps/server/src/modules/authorization/index.ts +++ b/apps/server/src/modules/authorization/index.ts @@ -1,4 +1,5 @@ export { AuthorizationModule } from './authorization.module'; +export { AuthorizationConfig } from './authorization.config'; export { // Action should not be exported, but hard to solve for now. The AuthorizationContextBuilder is the prefared way Action, diff --git a/apps/server/src/modules/board/repo/board-node.repo.ts b/apps/server/src/modules/board/repo/board-node.repo.ts index c439f1a9d36..ff506fab382 100644 --- a/apps/server/src/modules/board/repo/board-node.repo.ts +++ b/apps/server/src/modules/board/repo/board-node.repo.ts @@ -79,24 +79,6 @@ export class BoardNodeRepo { return boardNodes; } - // TODO maybe we don't need that method - // async findCommonParentOfIds(ids: EntityId[], depth?: number): Promise { - // const entities = await this.em.find(BoardNodeEntity, { id: { $in: ids } }); - // const sortedPaths = entities.map((e) => e.path).sort(); - // const commonPath = sortedPaths[0]; - // const dontMatch = sortedPaths.some((p) => !p.startsWith(commonPath)); - - // if (!commonPath || commonPath === ROOT_PATH || dontMatch) { - // throw new EntityNotFoundError(`Parent node of [${ids.join(',')}] not found`); - // } - - // const commonAncestorIds = commonPath.split(',').filter((id) => id !== ''); - // const parentId = commonAncestorIds[commonAncestorIds.length - 1]; - // const parentNode = await this.findById(parentId, depth); - - // return parentNode; - // } - async save(boardNode: AnyBoardNode | AnyBoardNode[]): Promise { return this.persist(boardNode).flush(); } diff --git a/apps/server/src/modules/class/class.config.ts b/apps/server/src/modules/class/class.config.ts new file mode 100644 index 00000000000..3cf59be1ea3 --- /dev/null +++ b/apps/server/src/modules/class/class.config.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ClassConfig {} diff --git a/apps/server/src/modules/class/index.ts b/apps/server/src/modules/class/index.ts index 32139fc690c..db22328ba30 100644 --- a/apps/server/src/modules/class/index.ts +++ b/apps/server/src/modules/class/index.ts @@ -1,3 +1,4 @@ -export * from './class.module'; +export { ClassModule } from './class.module'; export * from './domain'; -export * from './service'; +export { ClassConfig } from './class.config'; +export { ClassService } from './service'; diff --git a/apps/server/src/modules/deletion-console/deletion-console.module.ts b/apps/server/src/modules/deletion-console/deletion-console.module.ts index 45898df09b8..8cab979dc6f 100644 --- a/apps/server/src/modules/deletion-console/deletion-console.module.ts +++ b/apps/server/src/modules/deletion-console/deletion-console.module.ts @@ -31,7 +31,7 @@ import { DeletionExecutionConsole } from './deletion-execution.console'; user: DB_USERNAME, allowGlobalContext: true, entities: [...ALL_ENTITIES, FileEntity], - debug: true, + // debug: true, // use it for locally debugging of queries }), AccountModule, HttpModule, diff --git a/apps/server/src/modules/deletion/api/uc/deletion-request.uc.ts b/apps/server/src/modules/deletion/api/uc/deletion-request.uc.ts index e8b0bdee473..05d6c9c13fb 100644 --- a/apps/server/src/modules/deletion/api/uc/deletion-request.uc.ts +++ b/apps/server/src/modules/deletion/api/uc/deletion-request.uc.ts @@ -7,11 +7,12 @@ import { LegacyLogger } from '@src/core/logger'; import { DomainDeletionReportBuilder } from '../../domain/builder'; import { DeletionLog, DeletionRequest } from '../../domain/do'; import { DataDeletedEvent, UserDeletedEvent } from '../../domain/event'; -import { DeletionConfig, DomainDeletionReport } from '../../domain/interface'; +import { DomainDeletionReport } from '../../domain/interface'; import { DeletionLogService, DeletionRequestService } from '../../domain/service'; import { DeletionRequestLogResponseBuilder } from '../builder'; import { DeletionRequestBodyProps, DeletionRequestLogResponse, DeletionRequestResponse } from '../controller/dto'; import { DeletionTargetRefBuilder } from '../controller/dto/builder'; +import { DeletionConfig } from '../../deletion.config'; @Injectable() @EventsHandler(DataDeletedEvent) diff --git a/apps/server/src/modules/deletion/deletion-api.module.ts b/apps/server/src/modules/deletion/deletion-api.module.ts index 72e3054d07d..2294ebe421e 100644 --- a/apps/server/src/modules/deletion/deletion-api.module.ts +++ b/apps/server/src/modules/deletion/deletion-api.module.ts @@ -13,6 +13,7 @@ import { DeletionExecutionsController } from './api/controller/deletion-executio import { DeletionRequestsController } from './api/controller/deletion-requests.controller'; import { DeletionRequestUc } from './api/uc'; +// The most of this imports should not be part of the api module. @Module({ imports: [ CalendarModule, diff --git a/apps/server/src/modules/deletion/deletion.config.ts b/apps/server/src/modules/deletion/deletion.config.ts new file mode 100644 index 00000000000..a205674e786 --- /dev/null +++ b/apps/server/src/modules/deletion/deletion.config.ts @@ -0,0 +1,24 @@ +import { LoggerConfig } from '@src/core/logger'; +import { CalendarConfig } from '@src/infra/calendar'; +import { ClassConfig } from '@modules/class'; +import { XApiKeyConfig } from '@infra/auth-guard'; +import { NewsConfig } from '@modules/news'; +import { TeamsConfig } from '@modules/teams'; +import { PseudonymConfig } from '@modules/pseudonym'; +import { FilesConfig } from '@modules/files'; +import { RocketChatUserConfig } from '@modules/rocketchat-user'; + +export interface DeletionConfig + extends LoggerConfig, + CalendarConfig, + ClassConfig, + NewsConfig, + TeamsConfig, + PseudonymConfig, + FilesConfig, + RocketChatUserConfig, + XApiKeyConfig { + ADMIN_API__MODIFICATION_THRESHOLD_MS: number; + ADMIN_API__MAX_CONCURRENT_DELETION_REQUESTS: number; + ADMIN_API__DELETION_DELAY_MILLISECONDS: number; +} diff --git a/apps/server/src/modules/deletion/domain/interface/deletion-config.ts b/apps/server/src/modules/deletion/domain/interface/deletion-config.ts deleted file mode 100644 index 08330b05fe5..00000000000 --- a/apps/server/src/modules/deletion/domain/interface/deletion-config.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface DeletionConfig { - ADMIN_API__MODIFICATION_THRESHOLD_MS: number; - ADMIN_API__MAX_CONCURRENT_DELETION_REQUESTS: number; - ADMIN_API__DELETION_DELAY_MILLISECONDS: number; -} diff --git a/apps/server/src/modules/deletion/domain/interface/index.ts b/apps/server/src/modules/deletion/domain/interface/index.ts index 5812dc4bce6..54adf4f155a 100644 --- a/apps/server/src/modules/deletion/domain/interface/index.ts +++ b/apps/server/src/modules/deletion/domain/interface/index.ts @@ -2,4 +2,3 @@ export * from './deletion-service'; export * from './domain-deletion-report'; export * from './domain-operation-report'; export * from './deletion-target-ref'; -export * from './deletion-config'; diff --git a/apps/server/src/modules/deletion/domain/service/deletion-request.service.ts b/apps/server/src/modules/deletion/domain/service/deletion-request.service.ts index c1f56fc124a..7e72b076bcc 100644 --- a/apps/server/src/modules/deletion/domain/service/deletion-request.service.ts +++ b/apps/server/src/modules/deletion/domain/service/deletion-request.service.ts @@ -5,7 +5,7 @@ import { ConfigService } from '@nestjs/config'; import { DeletionRequestRepo } from '../../repo'; import { DeletionRequest } from '../do'; import { DomainName, StatusModel } from '../types'; -import { DeletionConfig } from '../interface'; +import { DeletionConfig } from '../../deletion.config'; @Injectable() export class DeletionRequestService { diff --git a/apps/server/src/modules/deletion/index.ts b/apps/server/src/modules/deletion/index.ts index 704a0d16cbf..893ab0a791a 100644 --- a/apps/server/src/modules/deletion/index.ts +++ b/apps/server/src/modules/deletion/index.ts @@ -1,4 +1,5 @@ export { DeletionModule } from './deletion.module'; +export { DeletionConfig } from './deletion.config'; export { DataDeletedEvent, UserDeletedEvent } from './domain/event'; export { DomainDeletionReportBuilder, DomainOperationReportBuilder } from './domain/builder'; export { DomainName, OperationType, StatusModel } from './domain/types'; @@ -6,4 +7,3 @@ export { DeletionService, DomainDeletionReport, DomainOperationReport } from './ export { DataDeletionDomainOperationLoggable } from './domain/loggable'; export { DeletionErrorLoggableException } from './domain/loggable-exception'; export { OperationReportHelper } from './domain/helper'; -export { DeletionConfig } from './domain/interface'; diff --git a/apps/server/src/modules/files-storage/files-storage.config.ts b/apps/server/src/modules/files-storage/files-storage.config.ts index 722cc31b0c6..a5e90d90b3e 100644 --- a/apps/server/src/modules/files-storage/files-storage.config.ts +++ b/apps/server/src/modules/files-storage/files-storage.config.ts @@ -25,6 +25,7 @@ const fileStorageConfig: FileStorageConfig = { USE_STREAM_TO_ANTIVIRUS: Configuration.get('FILES_STORAGE__USE_STREAM_TO_ANTIVIRUS') as boolean, ...authorizationClientConfig, ...defaultConfig, + EXIT_ON_ERROR: Configuration.get('EXIT_ON_ERROR') as boolean, }; // The configurations lookup diff --git a/apps/server/src/modules/files/files.config.ts b/apps/server/src/modules/files/files.config.ts new file mode 100644 index 00000000000..1dd398a4c4c --- /dev/null +++ b/apps/server/src/modules/files/files.config.ts @@ -0,0 +1,3 @@ +import { LoggerConfig } from '@src/core/logger'; + +export interface FilesConfig extends LoggerConfig {} diff --git a/apps/server/src/modules/files/index.ts b/apps/server/src/modules/files/index.ts index 158749cfd9d..455d71ecad9 100644 --- a/apps/server/src/modules/files/index.ts +++ b/apps/server/src/modules/files/index.ts @@ -1,2 +1,3 @@ -export * from './files.module'; +export { FilesModule } from './files.module'; +export { FilesConfig } from './files.config'; export * from './service'; diff --git a/apps/server/src/modules/group/group.config.ts b/apps/server/src/modules/group/group.config.ts new file mode 100644 index 00000000000..9d039f1670f --- /dev/null +++ b/apps/server/src/modules/group/group.config.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface GroupConfig {} diff --git a/apps/server/src/modules/group/index.ts b/apps/server/src/modules/group/index.ts index 01004d5e52e..54b5f9effae 100644 --- a/apps/server/src/modules/group/index.ts +++ b/apps/server/src/modules/group/index.ts @@ -1,3 +1,4 @@ -export * from './group.module'; +export { GroupModule } from './group.module'; +export { GroupConfig } from './group.config'; export * from './domain'; export { GroupService } from './service'; diff --git a/apps/server/src/modules/h5p-editor/h5p-editor.config.ts b/apps/server/src/modules/h5p-editor/h5p-editor.config.ts index e00f32542de..19c9bf7467f 100644 --- a/apps/server/src/modules/h5p-editor/h5p-editor.config.ts +++ b/apps/server/src/modules/h5p-editor/h5p-editor.config.ts @@ -1,10 +1,11 @@ import { Configuration } from '@hpi-schul-cloud/commons'; +import { AuthGuardConfig } from '@infra/auth-guard'; import { AuthorizationClientConfig } from '@infra/authorization-client'; import { S3Config } from '@infra/s3-client'; import { LanguageType } from '@shared/domain/interface'; import { CoreModuleConfig } from '@src/core'; -export interface H5PEditorConfig extends CoreModuleConfig, AuthorizationClientConfig { +export interface H5PEditorConfig extends CoreModuleConfig, AuthorizationClientConfig, AuthGuardConfig { NEST_LOG_LEVEL: string; INCOMING_REQUEST_TIMEOUT: number; } @@ -17,6 +18,11 @@ const h5pEditorConfig: H5PEditorConfig = { NEST_LOG_LEVEL: Configuration.get('NEST_LOG_LEVEL') as string, INCOMING_REQUEST_TIMEOUT: Configuration.get('H5P_EDITOR__INCOMING_REQUEST_TIMEOUT') as number, ...authorizationClientConfig, + EXIT_ON_ERROR: Configuration.get('EXIT_ON_ERROR') as boolean, + ADMIN_API__ALLOWED_API_KEYS: [], + JWT_AUD: Configuration.get('JWT_AUD') as string, + JWT_LIFETIME: Configuration.get('JWT_LIFETIME') as string, + AUTHENTICATION: Configuration.get('AUTHENTICATION') as string, }; export const translatorConfig = { diff --git a/apps/server/src/modules/h5p-editor/uc/h5p-ajax.uc.spec.ts b/apps/server/src/modules/h5p-editor/uc/h5p-ajax.uc.spec.ts index 21c160640e7..6e59f257342 100644 --- a/apps/server/src/modules/h5p-editor/uc/h5p-ajax.uc.spec.ts +++ b/apps/server/src/modules/h5p-editor/uc/h5p-ajax.uc.spec.ts @@ -1,4 +1,5 @@ import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { ICurrentUser } from '@infra/auth-guard'; import { AuthorizationClientAdapter } from '@infra/authorization-client'; import { H5PAjaxEndpoint, H5PEditor, H5PPlayer, H5pError } from '@lumieducation/h5p-server'; import { HttpException } from '@nestjs/common'; @@ -7,7 +8,6 @@ import { UserDO } from '@shared/domain/domainobject'; import { LanguageType } from '@shared/domain/interface'; import { setupEntities } from '@shared/testing'; import { UserService } from '@src/modules/user'; -import { ICurrentUser } from '@infra/auth-guard'; import { H5PContentRepo } from '../repo'; import { LibraryStorage } from '../service'; import { H5PEditorUc } from './h5p.uc'; @@ -74,7 +74,7 @@ describe('H5P Ajax', () => { schoolId: 'dummySchool', accountId: 'dummyAccountId', isExternalUser: false, - impersonated: false, + support: false, }; it('should call H5PAjaxEndpoint.getAjax and return the result', async () => { @@ -117,7 +117,7 @@ describe('H5P Ajax', () => { schoolId: 'dummySchool', accountId: 'dummyAccountId', isExternalUser: false, - impersonated: false, + support: false, }; it('should call H5PAjaxEndpoint.postAjax and return the result', async () => { diff --git a/apps/server/src/modules/h5p-editor/uc/h5p-delete.uc.spec.ts b/apps/server/src/modules/h5p-editor/uc/h5p-delete.uc.spec.ts index 4f0189f3f8e..70f1a833b8f 100644 --- a/apps/server/src/modules/h5p-editor/uc/h5p-delete.uc.spec.ts +++ b/apps/server/src/modules/h5p-editor/uc/h5p-delete.uc.spec.ts @@ -20,7 +20,7 @@ const createParams = () => { schoolId: 'mockSchoolId', userId: 'mockUserId', isExternalUser: false, - impersonated: false, + support: false, }; return { content, mockCurrentUser }; diff --git a/apps/server/src/modules/h5p-editor/uc/h5p-files.uc.spec.ts b/apps/server/src/modules/h5p-editor/uc/h5p-files.uc.spec.ts index bd9fd15bb30..700c65437e9 100644 --- a/apps/server/src/modules/h5p-editor/uc/h5p-files.uc.spec.ts +++ b/apps/server/src/modules/h5p-editor/uc/h5p-files.uc.spec.ts @@ -23,7 +23,7 @@ const createParams = () => { schoolId: 'mockSchoolId', userId: 'mockUserId', isExternalUser: false, - impersonated: false, + support: false, }; const mockContentParameters: Awaited> = { diff --git a/apps/server/src/modules/h5p-editor/uc/h5p-get-editor.uc.spec.ts b/apps/server/src/modules/h5p-editor/uc/h5p-get-editor.uc.spec.ts index ed520eb6d78..5d3a247670f 100644 --- a/apps/server/src/modules/h5p-editor/uc/h5p-get-editor.uc.spec.ts +++ b/apps/server/src/modules/h5p-editor/uc/h5p-get-editor.uc.spec.ts @@ -22,7 +22,7 @@ const createParams = () => { schoolId: 'mockSchoolId', userId: 'mockUserId', isExternalUser: false, - impersonated: false, + support: false, }; const editorResponseMock = { scripts: ['test.js'] } as IEditorModel; diff --git a/apps/server/src/modules/h5p-editor/uc/h5p-get-player.uc.spec.ts b/apps/server/src/modules/h5p-editor/uc/h5p-get-player.uc.spec.ts index a8285d05600..385910d5798 100644 --- a/apps/server/src/modules/h5p-editor/uc/h5p-get-player.uc.spec.ts +++ b/apps/server/src/modules/h5p-editor/uc/h5p-get-player.uc.spec.ts @@ -20,7 +20,7 @@ const createParams = () => { schoolId: 'mockSchoolId', userId: 'mockUserId', isExternalUser: false, - impersonated: false, + support: false, }; const playerResponseMock = expect.objectContaining({ diff --git a/apps/server/src/modules/h5p-editor/uc/h5p-save-create.uc.spec.ts b/apps/server/src/modules/h5p-editor/uc/h5p-save-create.uc.spec.ts index bfde1cd4ba4..9bfb0edd965 100644 --- a/apps/server/src/modules/h5p-editor/uc/h5p-save-create.uc.spec.ts +++ b/apps/server/src/modules/h5p-editor/uc/h5p-save-create.uc.spec.ts @@ -28,7 +28,7 @@ const createParams = () => { schoolId: 'mockSchoolId', userId: 'mockUserId', isExternalUser: false, - impersonated: false, + support: false, }; return { contentId, parameters, metadata, mainLibraryUbername, parentId, mockCurrentUser }; diff --git a/apps/server/src/modules/idp-console/idp-console.module.ts b/apps/server/src/modules/idp-console/idp-console.module.ts index d9ad00aa321..4b36404e7e5 100644 --- a/apps/server/src/modules/idp-console/idp-console.module.ts +++ b/apps/server/src/modules/idp-console/idp-console.module.ts @@ -27,7 +27,7 @@ import { SynchronizationUc } from './uc'; user: DB_USERNAME, allowGlobalContext: true, entities: [...ALL_ENTITIES, SynchronizationEntity], - debug: true, + // debug: true, // use it for locally debugging of queries }), UserModule, LoggerModule, diff --git a/apps/server/src/modules/instance/service/instance.service.ts b/apps/server/src/modules/instance/service/instance.service.ts index e53ab5850ab..05fd2d0dd21 100644 --- a/apps/server/src/modules/instance/service/instance.service.ts +++ b/apps/server/src/modules/instance/service/instance.service.ts @@ -9,13 +9,13 @@ export class InstanceService implements AuthorizationLoaderServiceGeneric { - const instance: Instance = await this.instanceRepo.findById(id); + const instance = await this.instanceRepo.findById(id); return instance; } public async getInstance(): Promise { - const instance: Instance = await this.instanceRepo.getInstance(); + const instance = await this.instanceRepo.getInstance(); return instance; } diff --git a/apps/server/src/modules/learnroom/controller/api-test/course.api.spec.ts b/apps/server/src/modules/learnroom/controller/api-test/course.api.spec.ts index c9991fd5ebc..6fd315a438b 100644 --- a/apps/server/src/modules/learnroom/controller/api-test/course.api.spec.ts +++ b/apps/server/src/modules/learnroom/controller/api-test/course.api.spec.ts @@ -150,7 +150,7 @@ describe('Course Controller (API)', () => { const response = await loggedInClient.postWithAttachment('import', 'file', course, courseFileName); expect(response.statusCode).toEqual(201); - }); + }, 10000); }); describe('[POST] /courses/:courseId/stop-sync', () => { diff --git a/apps/server/src/modules/legacy-school/index.ts b/apps/server/src/modules/legacy-school/index.ts index 8ee2c7fe67c..be9fdb8d93c 100644 --- a/apps/server/src/modules/legacy-school/index.ts +++ b/apps/server/src/modules/legacy-school/index.ts @@ -1,4 +1,5 @@ -export * from './legacy-school.module'; +export { LegacySchoolModule } from './legacy-school.module'; +export { LegacySchoolConfig } from './legacy-school.config'; export * from './service'; export { SchoolSystemOptionsBuilder, diff --git a/apps/server/src/modules/legacy-school/legacy-school.config.ts b/apps/server/src/modules/legacy-school/legacy-school.config.ts new file mode 100644 index 00000000000..0afa19ebd23 --- /dev/null +++ b/apps/server/src/modules/legacy-school/legacy-school.config.ts @@ -0,0 +1,4 @@ +import { GroupConfig } from '@modules/group'; +import { LoggerConfig } from '@src/core/logger'; + +export interface LegacySchoolConfig extends GroupConfig, LoggerConfig {} diff --git a/apps/server/src/modules/lesson/controller/api-test/lesson-tasks.api.spec.ts b/apps/server/src/modules/lesson/controller/api-test/lesson-tasks.api.spec.ts new file mode 100644 index 00000000000..b2115060a46 --- /dev/null +++ b/apps/server/src/modules/lesson/controller/api-test/lesson-tasks.api.spec.ts @@ -0,0 +1,52 @@ +import { EntityManager } from '@mikro-orm/core'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { courseFactory, lessonFactory, taskFactory, TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; +import { ServerTestModule } from '@src/modules/server'; + +describe('Lesson Controller (API) - GET list of lesson tasks /lessons/:lessonId/tasks', () => { + let module: TestingModule; + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + app = module.createNestApplication(); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, '/lessons'); + + await app.init(); + }); + + afterAll(async () => { + await app.close(); + await module.close(); + }); + + describe('when lesson exists', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const course = courseFactory.buildWithId({ teachers: [teacherUser] }); + const lesson = lessonFactory.build({ course }); + const tasks = taskFactory.buildList(3, { creator: teacherUser, lesson }); + + await em.persistAndFlush([teacherAccount, teacherUser, course, lesson, ...tasks]); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, course, lesson, tasks }; + }; + + it('should return a list of tasks', async () => { + const { loggedInClient, lesson } = await setup(); + + const response = await loggedInClient.get(`/${lesson.id}/tasks`); + + expect(response.status).toBe(200); + expect((response.body as []).length).toEqual(3); + }); + }); +}); diff --git a/apps/server/src/modules/lesson/controller/dto/index.ts b/apps/server/src/modules/lesson/controller/dto/index.ts index af82f2cc84b..f279fa3606d 100644 --- a/apps/server/src/modules/lesson/controller/dto/index.ts +++ b/apps/server/src/modules/lesson/controller/dto/index.ts @@ -1,5 +1,6 @@ -export * from './lesson.url.params'; -export { LessonsUrlParams } from './lessons.url.params'; export * from './lesson-content.response'; +export * from './lesson-linked-task.response'; export * from './lesson.response'; +export * from './lesson.url.params'; +export { LessonsUrlParams } from './lessons.url.params'; export * from './material.response'; diff --git a/apps/server/src/modules/lesson/controller/dto/lesson-linked-task.response.ts b/apps/server/src/modules/lesson/controller/dto/lesson-linked-task.response.ts new file mode 100644 index 00000000000..e71fe063da2 --- /dev/null +++ b/apps/server/src/modules/lesson/controller/dto/lesson-linked-task.response.ts @@ -0,0 +1,55 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { InputFormat } from '@shared/domain/types'; + +export class LessonLinkedTaskResponse { + @ApiProperty() + public readonly name: string; + + @ApiProperty() + public readonly description: string; + + @ApiProperty({ enum: InputFormat }) + public readonly descriptionInputFormat: InputFormat; + + @ApiProperty({ type: Date, nullable: true }) + availableDate?: Date; + + @ApiProperty({ type: Date, nullable: true }) + dueDate?: Date; + + @ApiProperty() + public readonly private: boolean = true; + + @ApiProperty({ nullable: true }) + public readonly publicSubmissions?: boolean; + + @ApiProperty({ nullable: true }) + public readonly teamSubmissions?: boolean; + + @ApiProperty({ nullable: true }) + public readonly creator?: string; + + @ApiProperty({ nullable: true }) + public readonly courseId?: string; + + @ApiProperty({ type: [String] }) + public readonly submissionIds: string[] = []; + + @ApiProperty({ type: [String] }) + public readonly finishedIds: string[] = []; + + constructor(props: Readonly) { + this.name = props.name; + this.description = props.description; + this.descriptionInputFormat = props.descriptionInputFormat; + this.availableDate = props.availableDate; + this.dueDate = props.dueDate; + this.private = props.private; + this.creator = props.creator; + this.courseId = props.courseId; + this.publicSubmissions = props.publicSubmissions; + this.teamSubmissions = props.teamSubmissions; + this.submissionIds = props.submissionIds; + this.finishedIds = props.finishedIds; + } +} diff --git a/apps/server/src/modules/lesson/controller/lesson.controller.ts b/apps/server/src/modules/lesson/controller/lesson.controller.ts index 48d0ea0a952..b48dba8031b 100644 --- a/apps/server/src/modules/lesson/controller/lesson.controller.ts +++ b/apps/server/src/modules/lesson/controller/lesson.controller.ts @@ -3,6 +3,7 @@ import { Controller, Delete, Get, Param } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { LessonUC } from '../uc'; import { LessonMetadataListResponse, LessonResponse, LessonUrlParams, LessonsUrlParams } from './dto'; +import { LessonLinkedTaskResponse } from './dto/lesson-linked-task.response'; import { LessonMapper } from './mapper/lesson.mapper'; @ApiTags('Lesson') @@ -33,4 +34,14 @@ export class LessonController { const response = new LessonResponse(lesson); return response; } + + @Get(':lessonId/tasks') + async getLessonTasks( + @Param() urlParams: LessonUrlParams, + @CurrentUser() currentUser: ICurrentUser + ): Promise { + const tasks = await this.lessonUC.getLessonLinkedTasks(currentUser.userId, urlParams.lessonId); + + return tasks; + } } diff --git a/apps/server/src/modules/lesson/controller/mapper/lesson.mapper.spec.ts b/apps/server/src/modules/lesson/controller/mapper/lesson.mapper.spec.ts new file mode 100644 index 00000000000..73a1c8cc796 --- /dev/null +++ b/apps/server/src/modules/lesson/controller/mapper/lesson.mapper.spec.ts @@ -0,0 +1,52 @@ +import { MikroORM } from '@mikro-orm/core'; +import { setupEntities, submissionFactory, taskFactory, userFactory } from '@shared/testing'; +import { LessonLinkedTaskResponse } from '../dto/lesson-linked-task.response'; +import { LessonMapper } from './lesson.mapper'; + +describe('LessonMapper', () => { + let orm: MikroORM; + + beforeAll(async () => { + orm = await setupEntities(); + }); + + afterAll(async () => { + await orm.close(); + }); + + describe('mapTaskToResponse', () => { + describe('when mapping task to response', () => { + const setup = () => { + const task = taskFactory.buildWithId({ + publicSubmissions: true, + teamSubmissions: true, + submissions: submissionFactory.buildListWithId(2), + finished: userFactory.buildListWithId(2), + }); + + return { task }; + }; + + it('should map task to response', () => { + const { task } = setup(); + + const result = LessonMapper.mapTaskToResponse(task); + + expect(result).toEqual({ + name: task.name, + description: task.description, + descriptionInputFormat: task.descriptionInputFormat, + availableDate: task.availableDate, + dueDate: task.dueDate, + private: task.private, + creator: task.creator?.id, + publicSubmissions: task.publicSubmissions, + teamSubmissions: task.teamSubmissions, + courseId: task.course?.id, + submissionIds: task.submissions.toArray().map((submission) => submission.id), + finishedIds: task.finished.toArray().map((submission) => submission.id), + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/lesson/controller/mapper/lesson.mapper.ts b/apps/server/src/modules/lesson/controller/mapper/lesson.mapper.ts index d2a804e18fc..680de79853d 100644 --- a/apps/server/src/modules/lesson/controller/mapper/lesson.mapper.ts +++ b/apps/server/src/modules/lesson/controller/mapper/lesson.mapper.ts @@ -1,9 +1,29 @@ -import { LessonEntity } from '@shared/domain/entity'; +import { LessonEntity, Task } from '@shared/domain/entity'; import { LessonMetadataResponse } from '../dto'; +import { LessonLinkedTaskResponse } from '../dto/lesson-linked-task.response'; export class LessonMapper { - static mapToMetadataResponse(lesson: LessonEntity): LessonMetadataResponse { + public static mapToMetadataResponse(lesson: LessonEntity): LessonMetadataResponse { const dto = new LessonMetadataResponse({ _id: lesson.id, name: lesson.name }); return dto; } + + public static mapTaskToResponse(task: Task): LessonLinkedTaskResponse { + const response = new LessonLinkedTaskResponse({ + name: task.name, + description: task.description, + descriptionInputFormat: task.descriptionInputFormat, + availableDate: task.availableDate, + dueDate: task.dueDate, + private: task.private, + creator: task.creator?.id, + publicSubmissions: task.publicSubmissions, + teamSubmissions: task.teamSubmissions, + courseId: task.course?.id, + submissionIds: task.submissions.toArray().map((submission) => submission.id), + finishedIds: task.finished.toArray().map((submission) => submission.id), + }); + + return response; + } } diff --git a/apps/server/src/modules/lesson/uc/lesson.uc.spec.ts b/apps/server/src/modules/lesson/uc/lesson.uc.spec.ts index 12083aa3291..ff436d84a1d 100644 --- a/apps/server/src/modules/lesson/uc/lesson.uc.spec.ts +++ b/apps/server/src/modules/lesson/uc/lesson.uc.spec.ts @@ -1,9 +1,9 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { CourseService } from '@modules/learnroom/service'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain/interface'; import { courseFactory, lessonFactory, setupEntities, userFactory } from '@shared/testing'; -import { CourseService } from '@modules/learnroom/service'; import { LessonService } from '../service'; import { LessonUC } from './lesson.uc'; @@ -193,4 +193,56 @@ describe('LessonUC', () => { }); }); }); + + describe('getLessonLinkedTasks', () => { + describe('when user is a valid teacher', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const lesson = lessonFactory.buildWithId(); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + lessonService.findById.mockResolvedValueOnce(lesson); + + authorizationService.hasPermission.mockReturnValueOnce(true); + + return { user, lesson }; + }; + + it('should get user with permissions from authorizationService', async () => { + const { user } = setup(); + + await lessonUC.getLessonLinkedTasks(user.id, 'lessonId'); + + expect(authorizationService.getUserWithPermissions).toHaveBeenCalledWith(user.id); + }); + + it('should get lesson from lessonService', async () => { + const { user, lesson } = setup(); + + await lessonUC.getLessonLinkedTasks(user.id, lesson.id); + + expect(lessonService.findById).toHaveBeenCalledWith(lesson.id); + }); + + it('should return check permission', async () => { + const { user, lesson } = setup(); + + await lessonUC.getLessonLinkedTasks(user.id, lesson.id); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + expect.objectContaining({ ...user }), + expect.objectContaining({ ...lesson }), + AuthorizationContextBuilder.read([Permission.TOPIC_VIEW]) + ); + }); + + it('should return tasks', async () => { + const { user, lesson } = setup(); + + const result = await lessonUC.getLessonLinkedTasks(user.id, lesson.id); + + expect(result).toEqual(lesson.getLessonLinkedTasks().map((task) => task)); + }); + }); + }); }); diff --git a/apps/server/src/modules/lesson/uc/lesson.uc.ts b/apps/server/src/modules/lesson/uc/lesson.uc.ts index ec618a37841..4944dca29a4 100644 --- a/apps/server/src/modules/lesson/uc/lesson.uc.ts +++ b/apps/server/src/modules/lesson/uc/lesson.uc.ts @@ -1,9 +1,11 @@ import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { CourseService } from '@modules/learnroom/service/course.service'; import { Injectable } from '@nestjs/common'; +import { LessonEntity } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { LessonEntity } from '@shared/domain/entity'; -import { CourseService } from '@modules/learnroom/service/course.service'; +import { LessonLinkedTaskResponse } from '../controller/dto/lesson-linked-task.response'; +import { LessonMapper } from '../controller/mapper/lesson.mapper'; import { LessonService } from '../service'; @Injectable() @@ -51,4 +53,15 @@ export class LessonUC { return lesson; } + + async getLessonLinkedTasks(userId: EntityId, lessonId: EntityId): Promise { + const user = await this.authorizationService.getUserWithPermissions(userId); + const lesson = await this.lessonService.findById(lessonId); + + this.authorizationService.checkPermission(user, lesson, AuthorizationContextBuilder.read([Permission.TOPIC_VIEW])); + + const tasks = lesson.getLessonLinkedTasks().map((task) => LessonMapper.mapTaskToResponse(task)); + + return tasks; + } } diff --git a/apps/server/src/modules/news/index.ts b/apps/server/src/modules/news/index.ts index 389b4e32022..750554e8121 100644 --- a/apps/server/src/modules/news/index.ts +++ b/apps/server/src/modules/news/index.ts @@ -1 +1,2 @@ -export * from './news.module'; +export { NewsModule } from './news.module'; +export { NewsConfig } from './news.config'; diff --git a/apps/server/src/modules/news/news.config.ts b/apps/server/src/modules/news/news.config.ts new file mode 100644 index 00000000000..537b2bf9bf5 --- /dev/null +++ b/apps/server/src/modules/news/news.config.ts @@ -0,0 +1,4 @@ +import { AuthorizationConfig } from '@modules/authorization'; +import { LoggerConfig } from '@src/core/logger'; + +export interface NewsConfig extends AuthorizationConfig, LoggerConfig {} diff --git a/apps/server/src/modules/news/news.module.ts b/apps/server/src/modules/news/news.module.ts index 0ae71e0ca54..996d82fc813 100644 --- a/apps/server/src/modules/news/news.module.ts +++ b/apps/server/src/modules/news/news.module.ts @@ -8,6 +8,7 @@ import { TeamNewsController } from './controller/team-news.controller'; import { NewsUc } from './uc/news.uc'; import { NewsService } from './service/news.service'; +// imports from deletion module? @Module({ imports: [AuthorizationModule, CqrsModule, LoggerModule], controllers: [NewsController, TeamNewsController], diff --git a/apps/server/src/modules/pseudonym/index.ts b/apps/server/src/modules/pseudonym/index.ts index 803e4f09b0b..d1d70abbf00 100644 --- a/apps/server/src/modules/pseudonym/index.ts +++ b/apps/server/src/modules/pseudonym/index.ts @@ -1,2 +1,3 @@ -export * from './pseudonym.module'; +export { PseudonymModule } from './pseudonym.module'; +export { PseudonymConfig } from './pseudonym.config'; export * from './service'; diff --git a/apps/server/src/modules/pseudonym/pseudonym.config.ts b/apps/server/src/modules/pseudonym/pseudonym.config.ts new file mode 100644 index 00000000000..2e70a085cf0 --- /dev/null +++ b/apps/server/src/modules/pseudonym/pseudonym.config.ts @@ -0,0 +1,6 @@ +import { LoggerConfig } from '@src/core/logger'; +import { LearnroomConfig } from '@modules/learnroom'; +import { ToolConfig } from '@modules/tool'; +import { UserConfig } from '@modules/user'; + +export interface PseudonymConfig extends UserConfig, LearnroomConfig, ToolConfig, LoggerConfig {} diff --git a/apps/server/src/modules/pseudonym/pseudonym.module.ts b/apps/server/src/modules/pseudonym/pseudonym.module.ts index bf55575c060..ad6735f3bae 100644 --- a/apps/server/src/modules/pseudonym/pseudonym.module.ts +++ b/apps/server/src/modules/pseudonym/pseudonym.module.ts @@ -4,6 +4,7 @@ import { LegacyLogger, LoggerModule } from '@src/core/logger'; import { ExternalToolPseudonymRepo, PseudonymsRepo } from './repo'; import { PseudonymService } from './service'; +// Why import this module LearnroomModule and ToolModule, The UserModule should also checked, but maybe it is ok. @Module({ imports: [LoggerModule, CqrsModule], providers: [PseudonymService, PseudonymsRepo, ExternalToolPseudonymRepo, LegacyLogger], diff --git a/apps/server/src/modules/registration-pin/index.ts b/apps/server/src/modules/registration-pin/index.ts index 89a77b2fa2c..1817a7b68c0 100644 --- a/apps/server/src/modules/registration-pin/index.ts +++ b/apps/server/src/modules/registration-pin/index.ts @@ -1,2 +1,3 @@ -export * from './registration-pin.module'; +export { RegistrationPinModule } from './registration-pin.module'; +export { RegistrationPinConfig } from './registration-pin.config'; export { RegistrationPinService } from './service'; diff --git a/apps/server/src/modules/registration-pin/registration-pin.config.ts b/apps/server/src/modules/registration-pin/registration-pin.config.ts new file mode 100644 index 00000000000..d1d4e3e575b --- /dev/null +++ b/apps/server/src/modules/registration-pin/registration-pin.config.ts @@ -0,0 +1,3 @@ +import { LoggerConfig } from '@src/core/logger'; + +export interface RegistrationPinConfig extends LoggerConfig {} diff --git a/apps/server/src/modules/rocketchat-user/index.ts b/apps/server/src/modules/rocketchat-user/index.ts index 34ae0f25f87..5a9308b827a 100644 --- a/apps/server/src/modules/rocketchat-user/index.ts +++ b/apps/server/src/modules/rocketchat-user/index.ts @@ -1,3 +1,4 @@ -export * from './rocketchat-user.module'; +export { RocketChatUserModule } from './rocketchat-user.module'; +export { RocketChatUserConfig } from './rocketchat-user.config'; export * from './service'; export * from './domain'; diff --git a/apps/server/src/modules/rocketchat-user/rocketchat-user.config.ts b/apps/server/src/modules/rocketchat-user/rocketchat-user.config.ts new file mode 100644 index 00000000000..0ff0e769e01 --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/rocketchat-user.config.ts @@ -0,0 +1,9 @@ +import { LoggerConfig } from '@src/core/logger'; + +export interface RocketChatUserConfig extends LoggerConfig { + ROCKET_CHAT_URI: string; + ROCKET_CHAT_ADMIN_ID: string; + ROCKET_CHAT_ADMIN_TOKEN: string; + ROCKET_CHAT_ADMIN_USER: string; + ROCKET_CHAT_ADMIN_PASSWORD: string; +} diff --git a/apps/server/src/modules/role/index.ts b/apps/server/src/modules/role/index.ts index f62345e1a95..8fe2e5e9c8f 100644 --- a/apps/server/src/modules/role/index.ts +++ b/apps/server/src/modules/role/index.ts @@ -1,2 +1,3 @@ export { RoleModule } from './role.module'; export { RoleService, RoleDto } from './service'; +export { RoleConfig } from './role.config'; diff --git a/apps/server/src/modules/role/role.config.ts b/apps/server/src/modules/role/role.config.ts new file mode 100644 index 00000000000..5a1d9a86230 --- /dev/null +++ b/apps/server/src/modules/role/role.config.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface RoleConfig {} diff --git a/apps/server/src/modules/room/api/dto/index.ts b/apps/server/src/modules/room/api/dto/index.ts deleted file mode 100644 index dbc1ea0f59a..00000000000 --- a/apps/server/src/modules/room/api/dto/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './response'; diff --git a/apps/server/src/modules/room/api/dto/request/create-room.body.params.ts b/apps/server/src/modules/room/api/dto/request/create-room.body.params.ts new file mode 100644 index 00000000000..286abeba50c --- /dev/null +++ b/apps/server/src/modules/room/api/dto/request/create-room.body.params.ts @@ -0,0 +1,43 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { SanitizeHtml } from '@shared/controller'; +import { RoomCreateProps } from '@src/modules/room/domain'; +import { RoomColor } from '@src/modules/room/domain/type'; +import { IsDate, IsEnum, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class CreateRoomBodyParams implements RoomCreateProps { + @ApiProperty({ + description: 'The name of the room', + required: true, + }) + @IsString() + @MinLength(1) + @MaxLength(100) + @SanitizeHtml() + name!: string; + + @ApiProperty({ + description: 'The display color of the room', + enum: RoomColor, + enumName: 'RoomColor', + }) + @IsEnum(RoomColor) + color!: RoomColor; + + @IsDate() + @IsOptional() + @ApiPropertyOptional({ + description: 'Start date of the room', + required: false, + type: Date, + }) + startDate?: Date; + + @IsDate() + @IsOptional() + @ApiPropertyOptional({ + description: 'End date of the room', + required: false, + type: Date, + }) + endDate?: Date; +} diff --git a/apps/server/src/modules/room/api/dto/request/index.ts b/apps/server/src/modules/room/api/dto/request/index.ts deleted file mode 100644 index a439df9b95e..00000000000 --- a/apps/server/src/modules/room/api/dto/request/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './room-pagination.params'; diff --git a/apps/server/src/modules/room/api/dto/request/room.url.params.ts b/apps/server/src/modules/room/api/dto/request/room.url.params.ts new file mode 100644 index 00000000000..ee7b93b1e91 --- /dev/null +++ b/apps/server/src/modules/room/api/dto/request/room.url.params.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsMongoId } from 'class-validator'; + +export class RoomUrlParams { + @IsMongoId() + @ApiProperty() + roomId!: string; +} diff --git a/apps/server/src/modules/room/api/dto/request/update-room.body.params.ts b/apps/server/src/modules/room/api/dto/request/update-room.body.params.ts new file mode 100644 index 00000000000..8afebced7d3 --- /dev/null +++ b/apps/server/src/modules/room/api/dto/request/update-room.body.params.ts @@ -0,0 +1,43 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { SanitizeHtml } from '@shared/controller'; +import { RoomUpdateProps } from '@src/modules/room/domain'; +import { RoomColor } from '@src/modules/room/domain/type'; +import { IsDate, IsEnum, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class UpdateRoomBodyParams implements RoomUpdateProps { + @ApiProperty({ + description: 'The name of the room', + required: true, + }) + @IsString() + @MinLength(1) + @MaxLength(100) + @SanitizeHtml() + name!: string; + + @ApiProperty({ + description: 'The display color of the room', + enum: RoomColor, + enumName: 'RoomColor', + }) + @IsEnum(RoomColor) + color!: RoomColor; + + @IsDate() + @IsOptional() + @ApiPropertyOptional({ + description: 'Start date of the room', + required: false, + type: Date, + }) + startDate?: Date; + + @IsDate() + @IsOptional() + @ApiPropertyOptional({ + description: 'Start date of the room', + required: false, + type: Date, + }) + endDate?: Date; +} diff --git a/apps/server/src/modules/room/api/dto/response/index.ts b/apps/server/src/modules/room/api/dto/response/index.ts deleted file mode 100644 index 31ddda6d7f0..00000000000 --- a/apps/server/src/modules/room/api/dto/response/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './room.response'; -export * from './room-list.response'; -export * from '../request/room-pagination.params'; diff --git a/apps/server/src/modules/room/api/dto/response/room-details.response.ts b/apps/server/src/modules/room/api/dto/response/room-details.response.ts new file mode 100644 index 00000000000..f0d39b68284 --- /dev/null +++ b/apps/server/src/modules/room/api/dto/response/room-details.response.ts @@ -0,0 +1,38 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { RoomColor } from '@src/modules/room/domain/type'; +import { IsEnum } from 'class-validator'; + +export class RoomDetailsResponse { + @ApiProperty() + id: string; + + @ApiProperty() + name: string; + + @ApiProperty({ enum: RoomColor, enumName: 'RoomColor' }) + @IsEnum(RoomColor) + color: RoomColor; + + @ApiPropertyOptional({ type: Date }) + startDate?: Date; + + @ApiPropertyOptional({ type: Date }) + endDate?: Date; + + @ApiProperty({ type: Date }) + createdAt: Date; + + @ApiProperty({ type: Date }) + updatedAt: Date; + + constructor(room: RoomDetailsResponse) { + this.id = room.id; + this.name = room.name; + this.color = room.color; + + this.startDate = room.startDate; + this.endDate = room.endDate; + this.createdAt = room.createdAt; + this.updatedAt = room.updatedAt; + } +} diff --git a/apps/server/src/modules/room/api/dto/response/room.response.ts b/apps/server/src/modules/room/api/dto/response/room-item.response.ts similarity index 50% rename from apps/server/src/modules/room/api/dto/response/room.response.ts rename to apps/server/src/modules/room/api/dto/response/room-item.response.ts index cc577f21cbf..88b13ec3dab 100644 --- a/apps/server/src/modules/room/api/dto/response/room.response.ts +++ b/apps/server/src/modules/room/api/dto/response/room-item.response.ts @@ -1,34 +1,37 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum } from 'class-validator'; +import { RoomColor } from '../../../domain/type'; -export class RoomResponse { +export class RoomItemResponse { @ApiProperty() id: string; @ApiProperty() name: string; - @ApiProperty() - color: string; + @ApiProperty({ enum: RoomColor, enumName: 'RoomColor' }) + @IsEnum(RoomColor) + color: RoomColor; @ApiPropertyOptional({ type: Date }) startDate?: Date; @ApiPropertyOptional({ type: Date }) - untilDate?: Date; + endDate?: Date; - @ApiPropertyOptional({ type: Date }) - createdAt?: Date; + @ApiProperty({ type: Date }) + createdAt: Date; - @ApiPropertyOptional({ type: Date }) - updatedAt?: Date; + @ApiProperty({ type: Date }) + updatedAt: Date; - constructor(room: RoomResponse) { + constructor(room: RoomItemResponse) { this.id = room.id; this.name = room.name; this.color = room.color; this.startDate = room.startDate; - this.untilDate = room.untilDate; + this.endDate = room.endDate; this.createdAt = room.createdAt; this.updatedAt = room.updatedAt; } diff --git a/apps/server/src/modules/room/api/dto/response/room-list.response.ts b/apps/server/src/modules/room/api/dto/response/room-list.response.ts index d5bde7a5987..63d84e6ea3f 100644 --- a/apps/server/src/modules/room/api/dto/response/room-list.response.ts +++ b/apps/server/src/modules/room/api/dto/response/room-list.response.ts @@ -1,13 +1,13 @@ import { ApiProperty } from '@nestjs/swagger'; import { PaginationResponse } from '@shared/controller'; -import { RoomResponse } from './room.response'; +import { RoomItemResponse } from './room-item.response'; -export class RoomListResponse extends PaginationResponse { - constructor(data: RoomResponse[], total: number, skip?: number, limit?: number) { +export class RoomListResponse extends PaginationResponse { + constructor(data: RoomItemResponse[], total: number, skip?: number, limit?: number) { super(total, skip, limit); this.data = data; } - @ApiProperty({ type: [RoomResponse] }) - data: RoomResponse[]; + @ApiProperty({ type: [RoomItemResponse] }) + data: RoomItemResponse[]; } diff --git a/apps/server/src/modules/room/api/index.ts b/apps/server/src/modules/room/api/index.ts index dd7800c0e50..46181a718c4 100644 --- a/apps/server/src/modules/room/api/index.ts +++ b/apps/server/src/modules/room/api/index.ts @@ -1,4 +1,4 @@ -export * from './dto'; -export * from './mapper'; -export * from './room.controller'; -export * from './room.uc'; +import { RoomController } from './room.controller'; +import { RoomUc } from './room.uc'; + +export { RoomController, RoomUc }; diff --git a/apps/server/src/modules/room/api/mapper/index.ts b/apps/server/src/modules/room/api/mapper/index.ts deleted file mode 100644 index 4f626e8e9c9..00000000000 --- a/apps/server/src/modules/room/api/mapper/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './room.mapper'; diff --git a/apps/server/src/modules/room/api/mapper/room.mapper.ts b/apps/server/src/modules/room/api/mapper/room.mapper.ts index 5c33c020638..3b559182926 100644 --- a/apps/server/src/modules/room/api/mapper/room.mapper.ts +++ b/apps/server/src/modules/room/api/mapper/room.mapper.ts @@ -1,25 +1,45 @@ import { Page } from '@shared/domain/domainobject'; -import { RoomPaginationParams } from '../dto/request/room-pagination.params'; -import { RoomResponse, RoomListResponse } from '../dto'; import { Room } from '../../domain/do/room.do'; +import { RoomPaginationParams } from '../dto/request/room-pagination.params'; +import { RoomDetailsResponse } from '../dto/response/room-details.response'; +import { RoomItemResponse } from '../dto/response/room-item.response'; +import { RoomListResponse } from '../dto/response/room-list.response'; export class RoomMapper { - static mapToRoomResponse(room: Room): RoomResponse { - const response = new RoomResponse({ + static mapToRoomItemResponse(room: Room): RoomItemResponse { + const response = new RoomItemResponse({ id: room.id, name: room.name, color: room.color, startDate: room.startDate, - untilDate: room.untilDate, + endDate: room.endDate, + createdAt: room.createdAt, + updatedAt: room.updatedAt, }); return response; } static mapToRoomListResponse(rooms: Page, pagination: RoomPaginationParams): RoomListResponse { - const roomResponseData: RoomResponse[] = rooms.data.map((room): RoomResponse => this.mapToRoomResponse(room)); + const roomResponseData: RoomItemResponse[] = rooms.data.map( + (room): RoomItemResponse => this.mapToRoomItemResponse(room) + ); const response = new RoomListResponse(roomResponseData, rooms.total, pagination.skip, pagination.limit); return response; } + + static mapToRoomDetailsResponse(room: Room): RoomDetailsResponse { + const response = new RoomDetailsResponse({ + id: room.id, + name: room.name, + color: room.color, + startDate: room.startDate, + endDate: room.endDate, + createdAt: room.createdAt, + updatedAt: room.updatedAt, + }); + + return response; + } } diff --git a/apps/server/src/modules/room/api/room.controller.ts b/apps/server/src/modules/room/api/room.controller.ts index 513cf8d282a..6847a853b00 100644 --- a/apps/server/src/modules/room/api/room.controller.ts +++ b/apps/server/src/modules/room/api/room.controller.ts @@ -1,14 +1,33 @@ +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; +import { + Body, + Controller, + Delete, + ForbiddenException, + Get, + HttpCode, + HttpStatus, + NotFoundException, + Param, + Patch, + Post, + Query, + UnauthorizedException, +} from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { Controller, ForbiddenException, Get, HttpStatus, Query, UnauthorizedException } from '@nestjs/common'; import { ApiValidationError } from '@shared/common'; -import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; -import { ErrorResponse } from '@src/core/error/dto'; import { IFindOptions } from '@shared/domain/interface'; -import { RoomUc } from './room.uc'; +import { ErrorResponse } from '@src/core/error/dto'; import { Room } from '../domain'; +import { CreateRoomBodyParams } from './dto/request/create-room.body.params'; +import { RoomPaginationParams } from './dto/request/room-pagination.params'; +import { RoomUrlParams } from './dto/request/room.url.params'; +import { UpdateRoomBodyParams } from './dto/request/update-room.body.params'; +import { RoomDetailsResponse } from './dto/response/room-details.response'; import { RoomListResponse } from './dto/response/room-list.response'; import { RoomMapper } from './mapper/room.mapper'; -import { RoomPaginationParams } from './dto/request/room-pagination.params'; +import { RoomUc } from './room.uc'; +import { RoomItemResponse } from './dto/response/room-item.response'; @ApiTags('Room') @JwtAuthentication() @@ -35,4 +54,74 @@ export class RoomController { return response; } + + @Post() + @ApiOperation({ summary: 'Create a new room' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Returns the details of a room', type: RoomItemResponse }) + @ApiResponse({ status: HttpStatus.BAD_REQUEST, type: ApiValidationError }) + @ApiResponse({ status: HttpStatus.UNAUTHORIZED, type: UnauthorizedException }) + @ApiResponse({ status: HttpStatus.FORBIDDEN, type: ForbiddenException }) + @ApiResponse({ status: '5XX', type: ErrorResponse }) + async createRoom( + @CurrentUser() currentUser: ICurrentUser, + @Body() createRoomParams: CreateRoomBodyParams + ): Promise { + const room = await this.roomUc.createRoom(currentUser.userId, createRoomParams); + + const response = RoomMapper.mapToRoomItemResponse(room); + + return response; + } + + @Get(':roomId') + @ApiOperation({ summary: 'Get the details of a room' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Returns the details of a room', type: RoomDetailsResponse }) + @ApiResponse({ status: HttpStatus.BAD_REQUEST, type: ApiValidationError }) + @ApiResponse({ status: HttpStatus.UNAUTHORIZED, type: UnauthorizedException }) + @ApiResponse({ status: HttpStatus.FORBIDDEN, type: ForbiddenException }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, type: NotFoundException }) + @ApiResponse({ status: '5XX', type: ErrorResponse }) + async getRoomDetails( + @CurrentUser() currentUser: ICurrentUser, + @Param() urlParams: RoomUrlParams + ): Promise { + const room = await this.roomUc.getSingleRoom(currentUser.userId, urlParams.roomId); + + const response = RoomMapper.mapToRoomDetailsResponse(room); + + return response; + } + + @Patch(':roomId') + @ApiOperation({ summary: 'Create a new room' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Returns the details of a room', type: RoomDetailsResponse }) + @ApiResponse({ status: HttpStatus.BAD_REQUEST, type: ApiValidationError }) + @ApiResponse({ status: HttpStatus.UNAUTHORIZED, type: UnauthorizedException }) + @ApiResponse({ status: HttpStatus.FORBIDDEN, type: ForbiddenException }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, type: NotFoundException }) + @ApiResponse({ status: '5XX', type: ErrorResponse }) + async updateRoom( + @CurrentUser() currentUser: ICurrentUser, + @Param() urlParams: RoomUrlParams, + @Body() updateRoomParams: UpdateRoomBodyParams + ): Promise { + const room = await this.roomUc.updateRoom(currentUser.userId, urlParams.roomId, updateRoomParams); + + const response = RoomMapper.mapToRoomDetailsResponse(room); + + return response; + } + + @Delete(':roomId') + @ApiOperation({ summary: 'Delete a room' }) + @ApiResponse({ status: HttpStatus.NO_CONTENT, description: 'Deletion successful' }) + @ApiResponse({ status: HttpStatus.BAD_REQUEST, type: ApiValidationError }) + @ApiResponse({ status: HttpStatus.UNAUTHORIZED, type: UnauthorizedException }) + @ApiResponse({ status: HttpStatus.FORBIDDEN, type: ForbiddenException }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, type: NotFoundException }) + @ApiResponse({ status: '5XX', type: ErrorResponse }) + @HttpCode(204) + async deleteRoom(@CurrentUser() currentUser: ICurrentUser, @Param() urlParams: RoomUrlParams): Promise { + await this.roomUc.deleteRoom(currentUser.userId, urlParams.roomId); + } } diff --git a/apps/server/src/modules/room/api/room.uc.ts b/apps/server/src/modules/room/api/room.uc.ts index a84d43af42d..f97581d6948 100644 --- a/apps/server/src/modules/room/api/room.uc.ts +++ b/apps/server/src/modules/room/api/room.uc.ts @@ -2,10 +2,9 @@ import { ConfigService } from '@nestjs/config'; import { Injectable } from '@nestjs/common'; import { Page } from '@shared/domain/domainobject'; import { EntityId } from '@shared/domain/types'; -// import { User } from '@shared/domain/entity'; import { IFindOptions } from '@shared/domain/interface'; import { FeatureDisabledLoggableException } from '@shared/common/loggable-exception'; -import { Room, RoomService } from '../domain'; +import { Room, RoomCreateProps, RoomService, RoomUpdateProps } from '../domain'; import { RoomConfig } from '../room.config'; @Injectable() @@ -16,9 +15,7 @@ export class RoomUc { ) {} public async getRooms(userId: EntityId, findOptions: IFindOptions): Promise> { - if (!this.configService.get('FEATURE_ROOMS_ENABLED')) { - throw new FeatureDisabledLoggableException('FEATURE_ROOMS_ENABLED'); - } + this.checkFeatureEnabled(); // TODO check authorization // const user: User = await this.authorizationService.getUserWithPermissions(userId); @@ -26,4 +23,49 @@ export class RoomUc { const rooms = await this.roomService.getRooms(findOptions); return rooms; } + + public async createRoom(userId: EntityId, props: RoomCreateProps): Promise { + this.checkFeatureEnabled(); + + // TODO check authorization + + const room = await this.roomService.createRoom(props); + return room; + } + + public async getSingleRoom(userId: EntityId, roomId: EntityId): Promise { + this.checkFeatureEnabled(); + + // TODO check authorization + + const room = await this.roomService.getSingleRoom(roomId); + return room; + } + + public async updateRoom(userId: EntityId, roomId: EntityId, props: RoomUpdateProps): Promise { + this.checkFeatureEnabled(); + + // TODO check authorization + + const room = await this.roomService.getSingleRoom(roomId); + await this.roomService.updateRoom(room, props); + + return room; + } + + public async deleteRoom(userId: EntityId, roomId: EntityId): Promise { + this.checkFeatureEnabled(); + + // TODO check authorization + + const room = await this.roomService.getSingleRoom(roomId); + + await this.roomService.deleteRoom(room); + } + + private checkFeatureEnabled(): void { + if (!this.configService.get('FEATURE_ROOMS_ENABLED')) { + throw new FeatureDisabledLoggableException('FEATURE_ROOMS_ENABLED'); + } + } } diff --git a/apps/server/src/modules/room/api/test/room-create.api.spec.ts b/apps/server/src/modules/room/api/test/room-create.api.spec.ts new file mode 100644 index 00000000000..c897e686988 --- /dev/null +++ b/apps/server/src/modules/room/api/test/room-create.api.spec.ts @@ -0,0 +1,178 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { TestApiClient, UserAndAccountTestFactory, cleanupCollections } from '@shared/testing'; +import { ServerTestModule, serverConfig, type ServerConfig } from '@src/modules/server'; +import { RoomEntity } from '../../repo'; + +describe('Room Controller (API)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + let config: ServerConfig; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + em = app.get(EntityManager); + testApiClient = new TestApiClient(app, 'rooms'); + + config = serverConfig(); + }); + + beforeEach(async () => { + await cleanupCollections(em); + config.FEATURE_ROOMS_ENABLED = true; + }); + + afterAll(async () => { + await app.close(); + }); + + describe('POST /rooms', () => { + describe('when the user is not authenticated', () => { + it('should return a 401 error', async () => { + const response = await testApiClient.post(); + expect(response.status).toBe(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when the feature is disabled', () => { + const setup = async () => { + config.FEATURE_ROOMS_ENABLED = false; + + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient }; + }; + + it('should return a 403 error', async () => { + const { loggedInClient } = await setup(); + const params = { name: 'Room #1', color: 'red' }; + const response = await loggedInClient.post(undefined, params); + expect(response.status).toBe(HttpStatus.FORBIDDEN); + }); + }); + + describe('when the user has the required permissions', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + await em.persistAndFlush([teacherAccount, teacherUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient }; + }; + + describe('when the required parameters are given', () => { + it('should create the room', async () => { + const { loggedInClient } = await setup(); + const params = { name: 'Room #1', color: 'red' }; + + const response = await loggedInClient.post(undefined, params); + const roomId = (response.body as { id: string }).id; + + expect(response.status).toBe(HttpStatus.CREATED); + await expect(em.findOneOrFail(RoomEntity, roomId)).resolves.toMatchObject({ id: roomId, color: 'red' }); + }); + }); + + describe('when a start date is given', () => { + it('should create the room', async () => { + const { loggedInClient } = await setup(); + + const params = { name: 'Room #1', color: 'red', startDate: '2024-10-01' }; + const response = await loggedInClient.post(undefined, params); + const roomId = (response.body as { id: string }).id; + + expect(response.status).toBe(HttpStatus.CREATED); + await expect(em.findOneOrFail(RoomEntity, roomId)).resolves.toMatchObject({ + id: roomId, + startDate: new Date('2024-10-01'), + }); + }); + + describe('when the date is invalid', () => { + it('should return a 400 error', async () => { + const { loggedInClient } = await setup(); + const params = { name: 'Room #1', color: 'red', startDate: 'invalid date' }; + const response = await loggedInClient.post(undefined, params); + expect(response.status).toBe(HttpStatus.BAD_REQUEST); + }); + }); + }); + + describe('when an end date is given', () => { + it('should create the room', async () => { + const { loggedInClient } = await setup(); + const params = { name: 'Room #1', color: 'red', endDate: '2024-10-20' }; + + const response = await loggedInClient.post(undefined, params); + const roomId = (response.body as { id: string }).id; + + expect(response.status).toBe(HttpStatus.CREATED); + await expect(em.findOneOrFail(RoomEntity, roomId)).resolves.toMatchObject({ + id: roomId, + endDate: new Date('2024-10-20'), + }); + }); + + describe('when the date is invalid', () => { + it('should return a 400 error', async () => { + const { loggedInClient } = await setup(); + const params = { name: 'Room #1', color: 'red', endDate: 'invalid date' }; + const response = await loggedInClient.post(undefined, params); + expect(response.status).toBe(HttpStatus.BAD_REQUEST); + }); + }); + }); + + describe('when the start date is before the end date', () => { + it('should create the room', async () => { + const { loggedInClient } = await setup(); + const params = { + name: 'Room #1', + color: 'red', + startDate: '2024-10-01', + endDate: '2024-10-20', + }; + + const response = await loggedInClient.post(undefined, params); + const roomId = (response.body as { id: string }).id; + + expect(response.status).toBe(HttpStatus.CREATED); + await expect(em.findOneOrFail(RoomEntity, roomId)).resolves.toMatchObject({ + id: roomId, + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + }); + }); + + describe('when the start date is after the end date', () => { + it('should return a 400 error', async () => { + const { loggedInClient } = await setup(); + const params = { + name: 'Room #1', + color: 'red', + startDate: '2024-10-20', + endDate: '2024-10-01', + }; + + const response = await loggedInClient.post(undefined, params); + + expect(response.status).toBe(HttpStatus.BAD_REQUEST); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/room/api/test/room-delete.api.spec.ts b/apps/server/src/modules/room/api/test/room-delete.api.spec.ts new file mode 100644 index 00000000000..ff9055f571e --- /dev/null +++ b/apps/server/src/modules/room/api/test/room-delete.api.spec.ts @@ -0,0 +1,120 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { HttpStatus, INestApplication, NotFoundException } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { TestApiClient, UserAndAccountTestFactory, cleanupCollections } from '@shared/testing'; +import { ServerTestModule, serverConfig, type ServerConfig } from '@src/modules/server'; +import { RoomEntity } from '../../repo'; +import { roomEntityFactory } from '../../testing/room-entity.factory'; + +describe('Room Controller (API)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + let config: ServerConfig; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + em = app.get(EntityManager); + testApiClient = new TestApiClient(app, 'rooms'); + + config = serverConfig(); + }); + + beforeEach(async () => { + await cleanupCollections(em); + config.FEATURE_ROOMS_ENABLED = true; + }); + + afterAll(async () => { + await app.close(); + }); + + describe('DELETE /rooms/:id', () => { + describe('when the user is not authenticated', () => { + it('should return a 401 error', async () => { + const someId = new ObjectId().toHexString(); + const response = await testApiClient.delete(someId); + expect(response.status).toBe(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when the feature is disabled', () => { + const setup = async () => { + config.FEATURE_ROOMS_ENABLED = false; + + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient }; + }; + + it('should return a 403 error', async () => { + const { loggedInClient } = await setup(); + const someId = new ObjectId().toHexString(); + const response = await loggedInClient.delete(someId); + expect(response.status).toBe(HttpStatus.FORBIDDEN); + }); + }); + + describe('when id is not a valid mongo id', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient }; + }; + + it('should return a 400 error', async () => { + const { loggedInClient } = await setup(); + const response = await loggedInClient.delete('42'); + expect(response.status).toBe(HttpStatus.BAD_REQUEST); + }); + }); + + describe('when the user has the required permissions', () => { + const setup = async () => { + const room = roomEntityFactory.build(); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + await em.persistAndFlush([room, teacherAccount, teacherUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, room }; + }; + + describe('when the room exists', () => { + it('should delete the room', async () => { + const { loggedInClient, room } = await setup(); + + const response = await loggedInClient.delete(room.id); + + expect(response.status).toBe(HttpStatus.NO_CONTENT); + await expect(em.findOneOrFail(RoomEntity, room.id)).rejects.toThrow(NotFoundException); + }); + }); + + describe('when the room does not exist', () => { + it('should return a 404 error', async () => { + const { loggedInClient } = await setup(); + const someId = new ObjectId().toHexString(); + + const response = await loggedInClient.delete(someId); + + expect(response.status).toBe(HttpStatus.NOT_FOUND); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/room/api/test/room-get.api.spec.ts b/apps/server/src/modules/room/api/test/room-get.api.spec.ts new file mode 100644 index 00000000000..de20ce624c5 --- /dev/null +++ b/apps/server/src/modules/room/api/test/room-get.api.spec.ts @@ -0,0 +1,129 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { TestApiClient, UserAndAccountTestFactory, cleanupCollections } from '@shared/testing'; +import { ServerTestModule, serverConfig, type ServerConfig } from '@src/modules/server'; +import { roomEntityFactory } from '../../testing/room-entity.factory'; + +describe('Room Controller (API)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + let config: ServerConfig; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + em = app.get(EntityManager); + testApiClient = new TestApiClient(app, 'rooms'); + + config = serverConfig(); + }); + + beforeEach(async () => { + await cleanupCollections(em); + config.FEATURE_ROOMS_ENABLED = true; + }); + + afterAll(async () => { + await app.close(); + }); + + describe('GET /rooms/:id', () => { + describe('when the user is not authenticated', () => { + it('should return a 401 error', async () => { + const someId = new ObjectId().toHexString(); + const response = await testApiClient.get(someId); + expect(response.status).toBe(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when the feature is disabled', () => { + const setup = async () => { + config.FEATURE_ROOMS_ENABLED = false; + + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient }; + }; + + it('should return a 403 error', async () => { + const { loggedInClient } = await setup(); + const someId = new ObjectId().toHexString(); + const response = await loggedInClient.get(someId); + expect(response.status).toBe(HttpStatus.FORBIDDEN); + }); + }); + + describe('when id is not a valid mongo id', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient }; + }; + + it('should return a 400 error', async () => { + const { loggedInClient } = await setup(); + const response = await loggedInClient.get('42'); + expect(response.status).toBe(HttpStatus.BAD_REQUEST); + }); + }); + + describe('when the user has the required permissions', () => { + const setup = async () => { + const room = roomEntityFactory.build(); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + await em.persistAndFlush([room, teacherAccount, teacherUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + const expectedResponse = { + id: room.id, + name: room.name, + color: room.color, + startDate: room.startDate?.toISOString(), + endDate: room.endDate?.toISOString(), + createdAt: room.createdAt.toISOString(), + updatedAt: room.updatedAt.toISOString(), + }; + + return { loggedInClient, room, expectedResponse }; + }; + + describe('when the room exists', () => { + it('should return a room', async () => { + const { loggedInClient, room, expectedResponse } = await setup(); + + const response = await loggedInClient.get(room.id); + + expect(response.status).toBe(HttpStatus.OK); + expect(response.body).toEqual(expectedResponse); + }); + }); + + describe('when the room does not exist', () => { + it('should return a 404 error', async () => { + const { loggedInClient } = await setup(); + const someId = new ObjectId().toHexString(); + + const response = await loggedInClient.get(someId); + + expect(response.status).toBe(HttpStatus.NOT_FOUND); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/room/api/test/room.api.spec.ts b/apps/server/src/modules/room/api/test/room-index.api.spec.ts similarity index 94% rename from apps/server/src/modules/room/api/test/room.api.spec.ts rename to apps/server/src/modules/room/api/test/room-index.api.spec.ts index 5e088931e16..e42fcb54bd3 100644 --- a/apps/server/src/modules/room/api/test/room.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-index.api.spec.ts @@ -4,7 +4,7 @@ import { Test } from '@nestjs/testing'; import { TestApiClient, UserAndAccountTestFactory, cleanupCollections } from '@shared/testing'; import { serverConfig, type ServerConfig, ServerTestModule } from '@src/modules/server'; import { roomEntityFactory } from '../../testing/room-entity.factory'; -import { RoomListResponse } from '../dto'; +import { RoomListResponse } from '../dto/response/room-list.response'; describe('Room Controller (API)', () => { let app: INestApplication; @@ -77,7 +77,9 @@ describe('Room Controller (API)', () => { name: room.name, color: room.color, startDate: room.startDate?.toISOString(), - untilDate: room.untilDate?.toISOString(), + endDate: room.endDate?.toISOString(), + createdAt: room.createdAt.toISOString(), + updatedAt: room.updatedAt.toISOString(), }; }); const expectedResponse = { diff --git a/apps/server/src/modules/room/api/test/room-update.api.spec.ts b/apps/server/src/modules/room/api/test/room-update.api.spec.ts new file mode 100644 index 00000000000..139a2eaba8c --- /dev/null +++ b/apps/server/src/modules/room/api/test/room-update.api.spec.ts @@ -0,0 +1,279 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { TestApiClient, UserAndAccountTestFactory, cleanupCollections } from '@shared/testing'; +import { ServerTestModule, serverConfig, type ServerConfig } from '@src/modules/server'; +import { RoomEntity } from '../../repo'; +import { roomEntityFactory } from '../../testing'; + +describe('Room Controller (API)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + let config: ServerConfig; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + em = app.get(EntityManager); + testApiClient = new TestApiClient(app, 'rooms'); + + config = serverConfig(); + }); + + beforeEach(async () => { + await cleanupCollections(em); + config.FEATURE_ROOMS_ENABLED = true; + }); + + afterAll(async () => { + await app.close(); + }); + + describe('PATCH /rooms/:id', () => { + describe('when the user is not authenticated', () => { + it('should return a 401 error', async () => { + const someId = new ObjectId().toHexString(); + const response = await testApiClient.patch(someId); + expect(response.status).toBe(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when the feature is disabled', () => { + const setup = async () => { + config.FEATURE_ROOMS_ENABLED = false; + + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient }; + }; + + it('should return a 403 error', async () => { + const { loggedInClient } = await setup(); + const someId = new ObjectId().toHexString(); + const params = { name: 'Room #101', color: 'green' }; + const response = await loggedInClient.patch(someId, params); + expect(response.status).toBe(HttpStatus.FORBIDDEN); + }); + }); + + describe('when id is not a valid mongo id', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient }; + }; + + it('should return a 400 error', async () => { + const { loggedInClient } = await setup(); + const params = { name: 'Room #101', color: 'green' }; + const response = await loggedInClient.patch('42', params); + expect(response.status).toBe(HttpStatus.BAD_REQUEST); + }); + }); + + describe('when the user has the required permissions', () => { + const setup = async () => { + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + await em.persistAndFlush([room, teacherAccount, teacherUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, room }; + }; + + describe('when the room does not exist', () => { + it('should return a 404 error', async () => { + const { loggedInClient } = await setup(); + const someId = new ObjectId().toHexString(); + const params = { name: 'Room #101', color: 'green' }; + + const response = await loggedInClient.patch(someId, params); + + expect(response.status).toBe(HttpStatus.NOT_FOUND); + }); + }); + + describe('when the required parameters are given', () => { + it('should update the room', async () => { + const { loggedInClient, room } = await setup(); + const params = { name: 'Room #101', color: 'green' }; + + const response = await loggedInClient.patch(room.id, params); + + expect(response.status).toBe(HttpStatus.OK); + await expect(em.findOneOrFail(RoomEntity, room.id)).resolves.toMatchObject({ + id: room.id, + name: 'Room #101', + color: 'green', + }); + }); + + describe('when name is empty', () => { + it('should return a 400 error', async () => { + const { loggedInClient, room } = await setup(); + const params = { name: '', color: 'red' }; + + const response = await loggedInClient.patch(room.id, params); + + expect(response.status).toBe(HttpStatus.BAD_REQUEST); + }); + }); + + describe('when color is empty', () => { + it('should return a 400 error', async () => { + const { loggedInClient, room } = await setup(); + const params = { name: 'Room #101', color: '' }; + + const response = await loggedInClient.patch(room.id, params); + + expect(response.status).toBe(HttpStatus.BAD_REQUEST); + }); + }); + + describe('when color is not part of the enum', () => { + it('should return a 400 error', async () => { + const { loggedInClient, room } = await setup(); + const params = { name: 'Room #101', color: 'fancy-color' }; + + const response = await loggedInClient.patch(room.id, params); + + expect(response.status).toBe(HttpStatus.BAD_REQUEST); + }); + }); + }); + + describe('when a start date is given', () => { + it('should update the room', async () => { + const { loggedInClient, room } = await setup(); + + const params = { name: 'Room #101', color: 'green', startDate: '2024-10-02' }; + const response = await loggedInClient.patch(room.id, params); + + expect(response.status).toBe(HttpStatus.OK); + await expect(em.findOneOrFail(RoomEntity, room.id)).resolves.toMatchObject({ + id: room.id, + startDate: new Date('2024-10-02'), + }); + }); + + describe('when the date is invalid', () => { + it('should return a 400 error', async () => { + const { loggedInClient, room } = await setup(); + const params = { name: 'Room #101', color: 'green', startDate: 'invalid date' }; + const response = await loggedInClient.patch(room.id, params); + expect(response.status).toBe(HttpStatus.BAD_REQUEST); + }); + }); + + describe('when the date is null', () => { + it('should unset the property', async () => { + const { loggedInClient, room } = await setup(); + const params = { name: 'Room #101', color: 'green', startDate: null }; + + const response = await loggedInClient.patch(room.id, params); + + expect(response.status).toBe(HttpStatus.OK); + await expect(em.findOneOrFail(RoomEntity, room.id)).resolves.toMatchObject({ + id: room.id, + startDate: null, + }); + }); + }); + }); + + describe('when an end date is given', () => { + it('should update the room', async () => { + const { loggedInClient, room } = await setup(); + const params = { name: 'Room #101', color: 'green', endDate: '2024-10-18' }; + + const response = await loggedInClient.patch(room.id, params); + + expect(response.status).toBe(HttpStatus.OK); + await expect(em.findOneOrFail(RoomEntity, room.id)).resolves.toMatchObject({ + id: room.id, + endDate: new Date('2024-10-18'), + }); + }); + + describe('when the date is invalid', () => { + it('should return a 400 error', async () => { + const { loggedInClient, room } = await setup(); + const params = { name: 'Room #101', color: 'green', endDate: 'invalid date' }; + const response = await loggedInClient.patch(room.id, params); + expect(response.status).toBe(HttpStatus.BAD_REQUEST); + }); + }); + + describe('when the date is null', () => { + it('should unset the property', async () => { + const { loggedInClient, room } = await setup(); + const params = { name: 'Room #101', color: 'green', endDate: null }; + + const response = await loggedInClient.patch(room.id, params); + + expect(response.status).toBe(HttpStatus.OK); + await expect(em.findOneOrFail(RoomEntity, room.id)).resolves.toMatchObject({ + id: room.id, + endDate: null, + }); + }); + }); + }); + + describe('when the start date is before the end date', () => { + it('should update the room', async () => { + const { loggedInClient, room } = await setup(); + const params = { + name: 'Room #101', + color: 'green', + startDate: '2024-10-05', + endDate: '2024-10-18', + }; + + const response = await loggedInClient.patch(room.id, params); + + expect(response.status).toBe(HttpStatus.OK); + await expect(em.findOneOrFail(RoomEntity, room.id)).resolves.toMatchObject({ + id: room.id, + startDate: new Date('2024-10-05'), + endDate: new Date('2024-10-18'), + }); + }); + }); + + describe('when the start date is after the end date', () => { + it('should return a 400 error', async () => { + const { loggedInClient, room } = await setup(); + const params = { + name: 'Room #101', + color: 'green', + startDate: '2024-10-10', + endDate: '2024-10-05', + }; + + const response = await loggedInClient.patch(room.id, params); + + expect(response.status).toBe(HttpStatus.BAD_REQUEST); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/room/domain/do/room.do.spec.ts b/apps/server/src/modules/room/domain/do/room.do.spec.ts index 452e039b201..bd213e52259 100644 --- a/apps/server/src/modules/room/domain/do/room.do.spec.ts +++ b/apps/server/src/modules/room/domain/do/room.do.spec.ts @@ -1,6 +1,8 @@ +import { ValidationError } from '@shared/common'; import { EntityId } from '@shared/domain/types'; -import { Room, RoomProps } from './room.do'; import { roomFactory } from '../../testing'; +import { RoomColor } from '../type'; +import { Room, RoomProps } from './room.do'; describe('Room', () => { let room: Room; @@ -8,9 +10,9 @@ describe('Room', () => { const roomProps: RoomProps = { id: roomId, name: 'Conference Room', - color: 'blue', + color: RoomColor.BLUE, startDate: new Date('2024-01-01'), - untilDate: new Date('2024-12-31'), + endDate: new Date('2024-12-31'), createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), }; @@ -41,9 +43,9 @@ describe('Room', () => { }); it('should get and set color', () => { - expect(room.color).toBe('blue'); - room.color = 'red'; - expect(room.color).toBe('red'); + expect(room.color).toBe(RoomColor.BLUE); + room.color = RoomColor.RED; + expect(room.color).toBe(RoomColor.RED); }); it('should get and set startDate', () => { @@ -53,11 +55,11 @@ describe('Room', () => { expect(room.startDate).toEqual(newStartDate); }); - it('should get and set untilDate', () => { - expect(room.untilDate).toEqual(new Date('2024-12-31')); - const newUntilDate = new Date('2024-11-30'); - room.untilDate = newUntilDate; - expect(room.untilDate).toEqual(newUntilDate); + it('should get and set endDate', () => { + expect(room.endDate).toEqual(new Date('2024-12-31')); + const newEndDate = new Date('2024-11-30'); + room.endDate = newEndDate; + expect(room.endDate).toEqual(newEndDate); }); it('should get createdAt', () => { @@ -69,4 +71,55 @@ describe('Room', () => { const expectedUpdatedAt = new Date('2024-01-01'); expect(room.updatedAt).toEqual(expectedUpdatedAt); }); + + describe('time frame validation', () => { + const setup = () => { + const props: RoomProps = { + id: roomId, + name: 'Conference Room', + color: RoomColor.BLUE, + startDate: new Date('2024-01-01'), + endDate: new Date('2024-12-31'), + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }; + + return { props }; + }; + + describe('when costructor is called with invalid time frame', () => { + it('should throw validation error', () => { + const buildInvalid = () => { + const { props } = setup(); + props.startDate = new Date('2024-12-31'); + props.endDate = new Date('2024-01-01'); + // eslint-disable-next-line no-new + new Room(props); + }; + expect(buildInvalid).toThrowError(ValidationError); + }); + }); + + describe('when setting start date after end date', () => { + it('should throw validation error', () => { + const setInvalidStartDate = () => { + const { props } = setup(); + const inValidRoom = new Room(props); + inValidRoom.startDate = new Date('2025-01-01'); + }; + expect(setInvalidStartDate).toThrowError(ValidationError); + }); + }); + + describe('when setting end date before start date', () => { + it('should throw validation error', () => { + const setInvalidEndDate = () => { + const { props } = setup(); + const inValidRoom = new Room(props); + inValidRoom.endDate = new Date('2023-12-31'); + }; + expect(setInvalidEndDate).toThrowError(ValidationError); + }); + }); + }); }); diff --git a/apps/server/src/modules/room/domain/do/room.do.ts b/apps/server/src/modules/room/domain/do/room.do.ts index bcab318ac15..05feb9d07d4 100644 --- a/apps/server/src/modules/room/domain/do/room.do.ts +++ b/apps/server/src/modules/room/domain/do/room.do.ts @@ -1,17 +1,27 @@ +import { ValidationError } from '@shared/common'; import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; import { EntityId } from '@shared/domain/types'; +import { RoomColor } from '../type'; export interface RoomProps extends AuthorizableObject { id: EntityId; name: string; - color: string; + color: RoomColor; startDate?: Date; - untilDate?: Date; + endDate?: Date; createdAt: Date; updatedAt: Date; } +export type RoomCreateProps = Pick; +export type RoomUpdateProps = RoomCreateProps; // will probably change in the future + export class Room extends DomainObject { + public constructor(props: RoomProps) { + super(props); + this.validateTimeSpan(); + } + public getProps(): RoomProps { // Note: Propagated hotfix. Will be resolved with mikro-orm update. Look at the comment in board-node.do.ts. // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -30,11 +40,11 @@ export class Room extends DomainObject { this.props.name = value; } - public get color(): string { + public get color(): RoomColor { return this.props.color; } - public set color(value: string) { + public set color(value: RoomColor) { this.props.color = value; } @@ -44,14 +54,16 @@ export class Room extends DomainObject { public set startDate(value: Date) { this.props.startDate = value; + this.validateTimeSpan(); } - public get untilDate(): Date | undefined { - return this.props.untilDate; + public get endDate(): Date | undefined { + return this.props.endDate; } - public set untilDate(value: Date) { - this.props.untilDate = value; + public set endDate(value: Date) { + this.props.endDate = value; + this.validateTimeSpan(); } public get createdAt(): Date { @@ -61,4 +73,14 @@ export class Room extends DomainObject { public get updatedAt(): Date { return this.props.updatedAt; } + + private validateTimeSpan() { + if (this.props.startDate != null && this.props.endDate != null && this.props.startDate > this.props.endDate) { + throw new ValidationError( + `Invalid room timespan. Start date '${this.props.startDate.toISOString()}' has to be before end date: '${this.props.endDate.toISOString()}'. Room id='${ + this.id + }'` + ); + } + } } diff --git a/apps/server/src/modules/room/domain/service/room.service.spec.ts b/apps/server/src/modules/room/domain/service/room.service.spec.ts new file mode 100644 index 00000000000..2735aee6b20 --- /dev/null +++ b/apps/server/src/modules/room/domain/service/room.service.spec.ts @@ -0,0 +1,150 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Page } from '@shared/domain/domainobject'; +import { RoomService } from './room.service'; +import { RoomRepo } from '../../repo'; +import { Room, RoomCreateProps, RoomUpdateProps } from '../do'; +import { roomFactory } from '../../testing'; +import { RoomColor } from '../type'; + +describe('RoomService', () => { + let module: TestingModule; + let service: RoomService; + let roomRepo: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + RoomService, + { + provide: RoomRepo, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(RoomService); + roomRepo = module.get(RoomRepo); + }); + + afterAll(async () => { + await module.close(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('getRooms', () => { + const setup = () => { + const rooms: Room[] = roomFactory.buildList(2); + const paginatedRooms: Page = new Page(rooms, rooms.length); + roomRepo.findRooms.mockResolvedValue(paginatedRooms); + + return { + paginatedRooms, + }; + }; + + it('should call repo to get rooms', async () => { + setup(); + + await service.getRooms({}); + + expect(roomRepo.findRooms).toHaveBeenCalledWith({}); + }); + + it('should return rooms', async () => { + const { paginatedRooms } = setup(); + + const result = await service.getRooms({}); + + expect(result).toEqual(paginatedRooms); + }); + }); + + describe('createRoom', () => { + const setup = () => { + const props: RoomCreateProps = { + name: 'room #1', + color: RoomColor.ORANGE, + }; + return { props }; + }; + + it('should call repo to save room', async () => { + const { props } = setup(); + + await service.createRoom(props); + + expect(roomRepo.save).toHaveBeenCalledWith(expect.objectContaining(props)); + }); + }); + + describe('getSingleRoom', () => { + const setup = () => { + const room = roomFactory.build(); + roomRepo.findById.mockResolvedValue(room); + + return { room }; + }; + + it('should call repo to get room', async () => { + const { room } = setup(); + + await service.getSingleRoom(room.id); + + expect(roomRepo.findById).toHaveBeenCalledWith(room.id); + }); + + it('should return room', async () => { + const { room } = setup(); + + const result = await service.getSingleRoom(room.id); + + expect(result).toBe(room); + }); + }); + + describe('updateRoom', () => { + const setup = () => { + const room = roomFactory.build({ + name: 'initial name', + color: RoomColor.ORANGE, + }); + + const props: RoomUpdateProps = { + name: 'updated name', + color: RoomColor.BLUE_GREY, + }; + + return { props, room }; + }; + + it('should update the room properties', async () => { + const { props, room } = setup(); + + await service.updateRoom(room, props); + + expect(room).toMatchObject(props); + }); + + it('should call repo to save room', async () => { + const { props, room } = setup(); + + await service.updateRoom(room, props); + + expect(roomRepo.save).toHaveBeenCalledWith(room); + }); + }); + + describe('deleteRoom', () => { + it('should call repo to delete room', async () => { + const room = roomFactory.build(); + + await service.deleteRoom(room); + + expect(roomRepo.delete).toHaveBeenCalledWith(room); + }); + }); +}); diff --git a/apps/server/src/modules/room/domain/service/room.service.ts b/apps/server/src/modules/room/domain/service/room.service.ts index afaecde6cc4..865b9697d60 100644 --- a/apps/server/src/modules/room/domain/service/room.service.ts +++ b/apps/server/src/modules/room/domain/service/room.service.ts @@ -1,8 +1,10 @@ +import { ObjectId } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; import { Page } from '@shared/domain/domainobject'; import { IFindOptions } from '@shared/domain/interface'; -import { Room } from '../do'; +import { EntityId } from '@shared/domain/types'; import { RoomRepo } from '../../repo'; +import { Room, RoomCreateProps, RoomProps, RoomUpdateProps } from '../do'; @Injectable() export class RoomService { @@ -13,4 +15,34 @@ export class RoomService { return rooms; } + + public async createRoom(props: RoomCreateProps): Promise { + const roomProps: RoomProps = { + id: new ObjectId().toHexString(), + ...props, + createdAt: new Date(), + updatedAt: new Date(), + }; + const room = new Room(roomProps); + + await this.roomRepo.save(room); + + return room; + } + + public async getSingleRoom(roomId: EntityId): Promise { + const room = await this.roomRepo.findById(roomId); + + return room; + } + + public async updateRoom(room: Room, props: RoomUpdateProps): Promise { + Object.assign(room, props); + + await this.roomRepo.save(room); + } + + public async deleteRoom(room: Room): Promise { + await this.roomRepo.delete(room); + } } diff --git a/apps/server/src/modules/room/domain/service/room.services.spec.ts b/apps/server/src/modules/room/domain/service/room.services.spec.ts deleted file mode 100644 index 71f4b0fa18f..00000000000 --- a/apps/server/src/modules/room/domain/service/room.services.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { Page } from '@shared/domain/domainobject'; -import { RoomService } from './room.service'; -import { RoomRepo } from '../../repo'; -import { Room } from '../do'; -import { roomFactory } from '../../testing'; - -describe('RoomService', () => { - let module: TestingModule; - let service: RoomService; - let roomRepo: DeepMocked; - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [ - RoomService, - { - provide: RoomRepo, - useValue: createMock(), - }, - ], - }).compile(); - - service = module.get(RoomService); - roomRepo = module.get(RoomRepo); - }); - - afterAll(async () => { - await module.close(); - }); - - beforeEach(() => { - jest.resetAllMocks(); - }); - - describe('getRooms', () => { - const setup = () => { - const rooms: Room[] = roomFactory.buildList(2); - const paginatedRooms: Page = new Page(rooms, rooms.length); - roomRepo.findRooms.mockResolvedValue(paginatedRooms); - - return { - paginatedRooms, - }; - }; - it('should call repo to get rooms', async () => { - setup(); - - await service.getRooms({}); - - expect(roomRepo.findRooms).toHaveBeenCalledWith({}); - }); - it('should return rooms', async () => { - const { paginatedRooms } = setup(); - - const result = await service.getRooms({}); - - expect(result).toEqual(paginatedRooms); - }); - }); -}); diff --git a/apps/server/src/modules/room/domain/type/index.ts b/apps/server/src/modules/room/domain/type/index.ts new file mode 100644 index 00000000000..6fc079cf850 --- /dev/null +++ b/apps/server/src/modules/room/domain/type/index.ts @@ -0,0 +1 @@ +export * from './room-color.enum'; diff --git a/apps/server/src/modules/room/domain/type/room-color.enum.ts b/apps/server/src/modules/room/domain/type/room-color.enum.ts new file mode 100644 index 00000000000..5ee92c572f2 --- /dev/null +++ b/apps/server/src/modules/room/domain/type/room-color.enum.ts @@ -0,0 +1,14 @@ +export enum RoomColor { + BLUE_GREY = 'blue-grey', + PINK = 'pink', + RED = 'red', + ORANGE = 'orange', + OLIVE = 'olive', + GREEN = 'green', + TURQUOISE = 'turquoise', + LIGHT_BLUE = 'light-blue', + BLUE = 'blue', + MAGENTA = 'magenta', + PURPLE = 'purple', + BROWN = 'brown', +} diff --git a/apps/server/src/modules/room/repo/entity/room.entity.spec.ts b/apps/server/src/modules/room/repo/entity/room.entity.spec.ts deleted file mode 100644 index fa8f302da74..00000000000 --- a/apps/server/src/modules/room/repo/entity/room.entity.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { ObjectId } from '@mikro-orm/mongodb'; -import { RoomEntity, RoomEntityProps } from './room.entity'; - -describe('RoomEntity', () => { - const setup = () => { - const roomProps: RoomEntityProps = { - name: 'Test Room', - color: '#FF0000', - startDate: new Date('2023-01-01'), - untilDate: new Date('2023-12-31'), - }; - - return { roomProps }; - }; - - describe('constructor', () => { - it('should create a RoomEntity instance with provided properties', () => { - const { roomProps } = setup(); - const room = new RoomEntity(roomProps); - - expect(room).toBeInstanceOf(RoomEntity); - expect(room.name).toBe(roomProps.name); - expect(room.color).toBe(roomProps.color); - expect(room.startDate).toEqual(roomProps.startDate); - expect(room.untilDate).toEqual(roomProps.untilDate); - }); - - it('should create a RoomEntity instance with an id if provided', () => { - const { roomProps } = setup(); - const id = new ObjectId().toHexString(); - const roomWithId = new RoomEntity({ ...roomProps, id }); - - expect(roomWithId.id).toBe(id); - }); - - it('should create a RoomEntity instance without optional properties', () => { - const minimalProps: RoomEntityProps = { - name: 'Minimal Room', - color: '#00FF00', - }; - const minimalRoom = new RoomEntity(minimalProps); - - expect(minimalRoom.name).toBe(minimalProps.name); - expect(minimalRoom.color).toBe(minimalProps.color); - expect(minimalRoom.startDate).toBeUndefined(); - expect(minimalRoom.untilDate).toBeUndefined(); - }); - }); -}); diff --git a/apps/server/src/modules/room/repo/entity/room.entity.ts b/apps/server/src/modules/room/repo/entity/room.entity.ts index 529b9bda14e..3a174be1d9d 100644 --- a/apps/server/src/modules/room/repo/entity/room.entity.ts +++ b/apps/server/src/modules/room/repo/entity/room.entity.ts @@ -1,40 +1,22 @@ import { Entity, Property } from '@mikro-orm/core'; import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; import { Room, RoomProps } from '../../domain/do/room.do'; - -export interface RoomEntityProps { - id?: string; - name: string; - color: string; - startDate?: Date; - untilDate?: Date; -} +import { RoomColor } from '../../domain/type'; @Entity({ tableName: 'rooms' }) export class RoomEntity extends BaseEntityWithTimestamps implements RoomProps { - @Property() - name: string; + @Property({ nullable: false }) + name!: string; - @Property() - color: string; + @Property({ nullable: false }) + color!: RoomColor; @Property({ nullable: true }) startDate?: Date; @Property({ nullable: true }) - untilDate?: Date; + endDate?: Date; @Property({ persist: false }) domainObject: Room | undefined; - - constructor(props: RoomEntityProps) { - super(); - if (props.id) { - this.id = props.id; - } - this.name = props.name; - this.color = props.color; - this.startDate = props.startDate; - this.untilDate = props.untilDate; - } } diff --git a/apps/server/src/modules/room/repo/room-domain.mapper.spec.ts b/apps/server/src/modules/room/repo/room-domain.mapper.spec.ts index be9cac2c829..22f25ad841c 100644 --- a/apps/server/src/modules/room/repo/room-domain.mapper.spec.ts +++ b/apps/server/src/modules/room/repo/room-domain.mapper.spec.ts @@ -1,4 +1,6 @@ -import { Room } from '../domain/do/room.do'; +import { Room, RoomProps } from '../domain/do/room.do'; +import { RoomColor } from '../domain/type'; +import { roomEntityFactory } from '../testing'; import { RoomEntity } from './entity'; import { RoomDomainMapper } from './room-domain.mapper'; @@ -8,9 +10,9 @@ describe('RoomDomainMapper', () => { const roomEntity = { id: '1', name: 'Test Room', - color: '#FF0000', + color: RoomColor.RED, startDate: new Date('2023-01-01'), - untilDate: new Date('2023-12-31'), + endDate: new Date('2023-12-31'), } as RoomEntity; const result = RoomDomainMapper.mapEntityToDo(roomEntity); @@ -19,9 +21,9 @@ describe('RoomDomainMapper', () => { expect(result.getProps()).toEqual({ id: '1', name: 'Test Room', - color: '#FF0000', + color: RoomColor.RED, startDate: new Date('2023-01-01'), - untilDate: new Date('2023-12-31'), + endDate: new Date('2023-12-31'), }); }); @@ -29,9 +31,9 @@ describe('RoomDomainMapper', () => { const existingRoom = new Room({ id: '1', name: 'Existing Room', - color: '#00FF00', + color: RoomColor.GREEN, startDate: new Date('2023-01-01'), - untilDate: new Date('2023-12-31'), + endDate: new Date('2023-12-31'), createdAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'), }); @@ -39,9 +41,9 @@ describe('RoomDomainMapper', () => { const roomEntity = { id: '2', name: 'Test Room', - color: '#FF0000', + color: RoomColor.RED, startDate: new Date('2023-02-01'), - untilDate: new Date('2023-11-30'), + endDate: new Date('2023-11-30'), domainObject: existingRoom, } as RoomEntity; @@ -52,14 +54,63 @@ describe('RoomDomainMapper', () => { expect(result.getProps()).toEqual({ id: '1', name: 'Existing Room', - color: '#00FF00', + color: RoomColor.GREEN, startDate: new Date('2023-01-01'), - untilDate: new Date('2023-12-31'), + endDate: new Date('2023-12-31'), createdAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'), }); expect(result.getProps().id).toBe('1'); expect(result.getProps().id).not.toBe(roomEntity.id); }); + + it('should wrap the actual entity reference in the domain object', () => { + const roomEntity = { + id: '1', + name: 'Test Room', + color: RoomColor.RED, + startDate: new Date('2023-01-01'), + endDate: new Date('2023-12-31'), + } as RoomEntity; + + const result = RoomDomainMapper.mapEntityToDo(roomEntity); + // @ts-expect-error check necessary + const { props } = result; + + expect(props === roomEntity).toBe(true); + }); + }); + + describe('mapDoToEntity', () => { + describe('when domain object props are instanceof RoomEntity', () => { + it('should return the entity', () => { + const roomEntity = roomEntityFactory.build(); + const room = new Room(roomEntity); + + const result = RoomDomainMapper.mapDoToEntity(room); + + expect(result).toBe(roomEntity); + }); + }); + + describe('when domain object props are not instanceof RoomEntity', () => { + it('should convert them to an entity and return it', () => { + const roomEntity: RoomProps = { + id: '66d581c3ef74c548a4efea1d', + name: 'Test Room #1', + color: RoomColor.RED, + startDate: new Date('2023-01-01'), + endDate: new Date('2023-12-31'), + createdAt: new Date('2024-10-1'), + updatedAt: new Date('2024-10-1'), + }; + const room = new Room(roomEntity); + + const result = RoomDomainMapper.mapDoToEntity(room); + + expect(result).toBeInstanceOf(RoomEntity); + expect(result).toMatchObject(roomEntity); + }); + }); }); }); diff --git a/apps/server/src/modules/room/repo/room-domain.mapper.ts b/apps/server/src/modules/room/repo/room-domain.mapper.ts index e9e9916ffd0..c271add9f7e 100644 --- a/apps/server/src/modules/room/repo/room-domain.mapper.ts +++ b/apps/server/src/modules/room/repo/room-domain.mapper.ts @@ -8,19 +8,30 @@ export class RoomDomainMapper { return roomEntity.domainObject; } - const room: Room = new Room({ - id: roomEntity.id, - name: roomEntity.name, - color: roomEntity.color, - startDate: roomEntity.startDate, - untilDate: roomEntity.untilDate, - createdAt: roomEntity.createdAt, - updatedAt: roomEntity.updatedAt, - }); + const room = new Room(roomEntity); // attach to identity map roomEntity.domainObject = room; return room; } + + static mapDoToEntity(room: Room): RoomEntity { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const { props } = room; + + if (!(props instanceof RoomEntity)) { + const entity = new RoomEntity(); + Object.assign(entity, props); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + room.props = entity; + + return entity; + } + + return props; + } } diff --git a/apps/server/src/modules/room/repo/room.repo.spec.ts b/apps/server/src/modules/room/repo/room.repo.spec.ts index 43c4de212f9..6dac9069800 100644 --- a/apps/server/src/modules/room/repo/room.repo.spec.ts +++ b/apps/server/src/modules/room/repo/room.repo.spec.ts @@ -1,13 +1,14 @@ +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { NotFoundError } from '@mikro-orm/core'; import { EntityManager } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { Page } from '@shared/domain/domainobject'; -import { MongoMemoryDatabaseModule } from '@infra/database'; import { cleanupCollections } from '@shared/testing'; import { Room } from '../domain/do/room.do'; +import { roomEntityFactory, roomFactory } from '../testing'; +import { RoomEntity } from './entity'; import { RoomDomainMapper } from './room-domain.mapper'; import { RoomRepo } from './room.repo'; -import { roomEntityFactory } from '../testing'; -import { RoomEntity } from './entity/room.entity'; describe('RoomRepo', () => { let module: TestingModule; @@ -59,9 +60,74 @@ describe('RoomRepo', () => { }); }); - describe('entityName', () => { - it('should return RoomEntity', () => { - expect(repo.entityName).toBe(RoomEntity); + describe('findById', () => { + const setup = async () => { + const roomEntity = roomEntityFactory.buildWithId(); + await em.persistAndFlush(roomEntity); + em.clear(); + + return { roomEntity }; + }; + + it('should be able to find a room by id', async () => { + const { roomEntity } = await setup(); + + const result = await repo.findById(roomEntity.id); + + expect(result.getProps()).toMatchObject(roomEntity); + }); + }); + + describe('save', () => { + const setup = () => { + const rooms = roomFactory.buildList(3); + return { rooms }; + }; + + it('should be able to persist a single room', async () => { + const { rooms } = setup(); + + await repo.save(rooms[0]); + const result = await em.findOneOrFail(RoomEntity, rooms[0].id); + + expect(rooms[0].getProps()).toMatchObject(result); + }); + + it('should be able to persist many rooms', async () => { + const { rooms } = setup(); + + await repo.save(rooms); + const result = await em.find(RoomEntity, { id: { $in: rooms.map((r) => r.id) } }); + + expect(result.length).toBe(rooms.length); + }); + }); + + describe('delete', () => { + const setup = async () => { + const roomEntities = roomEntityFactory.buildListWithId(3); + await em.persistAndFlush(roomEntities); + const rooms = roomEntities.map((entity) => new Room(entity)); + em.clear(); + + return { rooms }; + }; + + it('should be able to delete a single room', async () => { + const { rooms } = await setup(); + + await repo.delete(rooms[0]); + + await expect(em.findOneOrFail(RoomEntity, rooms[0].id)).rejects.toThrow(NotFoundError); + }); + + it('should be able to delete many rooms', async () => { + const { rooms } = await setup(); + + await repo.delete(rooms); + + const remainingCount = await em.count(RoomEntity); + expect(remainingCount).toBe(0); }); }); }); diff --git a/apps/server/src/modules/room/repo/room.repo.ts b/apps/server/src/modules/room/repo/room.repo.ts index 23e1d7067bd..440293ac6a3 100644 --- a/apps/server/src/modules/room/repo/room.repo.ts +++ b/apps/server/src/modules/room/repo/room.repo.ts @@ -1,8 +1,9 @@ -import { EntityName, QueryOrder } from '@mikro-orm/core'; +import { QueryOrder, Utils } from '@mikro-orm/core'; import { EntityManager } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; import { Page } from '@shared/domain/domainobject'; import { IFindOptions } from '@shared/domain/interface'; +import { EntityId } from '@shared/domain/types'; import { Room } from '../domain/do/room.do'; import { RoomEntity } from './entity/room.entity'; import { RoomDomainMapper } from './room-domain.mapper'; @@ -12,10 +13,6 @@ import { RoomScope } from './room.scope'; export class RoomRepo { constructor(private readonly em: EntityManager) {} - get entityName(): EntityName { - return RoomEntity; - } - public async findRooms(findOptions: IFindOptions): Promise> { const scope = new RoomScope(); scope.allowEmptyQuery(true); @@ -34,4 +31,37 @@ export class RoomRepo { return page; } + + public async findById(id: EntityId): Promise { + const entity = await this.em.findOneOrFail(RoomEntity, id); + const domainobject = RoomDomainMapper.mapEntityToDo(entity); + + return domainobject; + } + + public async save(room: Room | Room[]): Promise { + const rooms = Utils.asArray(room); + + rooms.forEach((r) => { + const entity = RoomDomainMapper.mapDoToEntity(r); + this.em.persist(entity); + }); + + await this.flush(); + } + + public async delete(room: Room | Room[]): Promise { + const rooms = Utils.asArray(room); + + rooms.forEach((r) => { + const entity = RoomDomainMapper.mapDoToEntity(r); + this.em.remove(entity); + }); + + await this.em.flush(); + } + + private async flush(): Promise { + return this.em.flush(); + } } diff --git a/apps/server/src/modules/room/room-api.module.ts b/apps/server/src/modules/room/room-api.module.ts index 4fc716728b7..c9344a6c848 100644 --- a/apps/server/src/modules/room/room-api.module.ts +++ b/apps/server/src/modules/room/room-api.module.ts @@ -1,9 +1,8 @@ import { AuthorizationModule } from '@modules/authorization'; import { Module } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; -import { RoomController } from './api/room.controller'; +import { RoomController, RoomUc } from './api'; import { RoomModule } from './room.module'; -import { RoomUc } from './api/room.uc'; @Module({ imports: [RoomModule, AuthorizationModule, LoggerModule], diff --git a/apps/server/src/modules/room/testing/room-entity.factory.ts b/apps/server/src/modules/room/testing/room-entity.factory.ts index 289912ccb7f..fee0b2c80b0 100644 --- a/apps/server/src/modules/room/testing/room-entity.factory.ts +++ b/apps/server/src/modules/room/testing/room-entity.factory.ts @@ -1,11 +1,17 @@ -import { BaseFactory } from '@shared/testing/factory/base.factory'; -import { RoomEntity, RoomEntityProps } from '../repo/entity/room.entity'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { EntityFactory } from '@shared/testing/factory/entity.factory'; +import { RoomEntity } from '../repo/entity/room.entity'; +import { RoomProps } from '../domain'; +import { RoomColor } from '../domain/type'; -export const roomEntityFactory = BaseFactory.define(RoomEntity, ({ sequence }) => { +export const roomEntityFactory = EntityFactory.define(RoomEntity, ({ sequence }) => { return { + id: new ObjectId().toHexString(), name: `room #${sequence}`, - color: ['blue', 'red', 'green', 'yellow'][Math.floor(Math.random() * 4)], + color: [RoomColor.BLUE, RoomColor.RED, RoomColor.GREEN, RoomColor.MAGENTA][Math.floor(Math.random() * 4)], startDate: new Date(), - untilDate: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), + endDate: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), + createdAt: new Date(), + updatedAt: new Date(), }; }); diff --git a/apps/server/src/modules/room/testing/room.factory.ts b/apps/server/src/modules/room/testing/room.factory.ts index 24b0eb4abe8..099c98b831c 100644 --- a/apps/server/src/modules/room/testing/room.factory.ts +++ b/apps/server/src/modules/room/testing/room.factory.ts @@ -1,16 +1,17 @@ import { BaseFactory } from '@shared/testing'; import { ObjectId } from '@mikro-orm/mongodb'; import { Room, RoomProps } from '../domain/do/room.do'; +import { RoomColor } from '../domain/type'; export const roomFactory = BaseFactory.define(Room, ({ sequence }) => { const props: RoomProps = { id: new ObjectId().toHexString(), name: `room #${sequence}`, - color: ['blue', 'red', 'green', 'yellow'][Math.floor(Math.random() * 4)], + color: [RoomColor.BLUE, RoomColor.RED, RoomColor.GREEN, RoomColor.MAGENTA][Math.floor(Math.random() * 4)], startDate: new Date(), createdAt: new Date(), updatedAt: new Date(), - untilDate: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), + endDate: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), }; return props; diff --git a/apps/server/src/modules/server/admin-api-server.config.ts b/apps/server/src/modules/server/admin-api-server.config.ts new file mode 100644 index 00000000000..826f61970f3 --- /dev/null +++ b/apps/server/src/modules/server/admin-api-server.config.ts @@ -0,0 +1,70 @@ +import { DeletionConfig } from '@modules/deletion'; +import { AuthGuardConfig } from '@infra/auth-guard'; +import { LegacySchoolConfig } from '@modules/legacy-school'; +import { UserConfig } from '@modules/user'; +import { RegistrationPinConfig } from '@modules/registration-pin'; +import { ToolConfig } from '@modules/tool'; +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { LanguageType } from '@shared/domain/interface'; + +export interface AdminApiServerConfig + extends DeletionConfig, + LegacySchoolConfig, + UserConfig, + RegistrationPinConfig, + ToolConfig, + AuthGuardConfig { + ETHERPAD__API_KEY?: string; + ETHERPAD__URI?: string; +} + +const config: AdminApiServerConfig = { + ADMIN_API__MODIFICATION_THRESHOLD_MS: Configuration.get('ADMIN_API__MODIFICATION_THRESHOLD_MS') as number, + ADMIN_API__MAX_CONCURRENT_DELETION_REQUESTS: Configuration.get( + 'ADMIN_API__MAX_CONCURRENT_DELETION_REQUESTS' + ) as number, + ADMIN_API__DELETION_DELAY_MILLISECONDS: Configuration.get('ADMIN_API__DELETION_DELAY_MILLISECONDS') as number, + NEST_LOG_LEVEL: Configuration.get('NEST_LOG_LEVEL') as string, + EXIT_ON_ERROR: Configuration.get('EXIT_ON_ERROR') as boolean, + AVAILABLE_LANGUAGES: (Configuration.get('I18N__AVAILABLE_LANGUAGES') as string).split(',') as LanguageType[], + FEATURE_COMMON_CARTRIDGE_COURSE_EXPORT_ENABLED: Configuration.get( + 'FEATURE_COMMON_CARTRIDGE_COURSE_EXPORT_ENABLED' + ) as boolean, + GEOGEBRA_BASE_URL: Configuration.get('GEOGEBRA_BASE_URL') as string, + FEATURE_COLUMN_BOARD_ENABLED: Configuration.get('FEATURE_COLUMN_BOARD_ENABLED') as boolean, + FEATURE_COPY_SERVICE_ENABLED: Configuration.get('FEATURE_COPY_SERVICE_ENABLED') as boolean, + FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_ENABLED: Configuration.get( + 'FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_ENABLED' + ) as boolean, + FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_MAX_FILE_SIZE: Configuration.get( + 'FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_MAX_FILE_SIZE' + ) as number, + FEATURE_CTL_TOOLS_TAB_ENABLED: Configuration.get('FEATURE_CTL_TOOLS_TAB_ENABLED') as boolean, + FEATURE_LTI_TOOLS_TAB_ENABLED: Configuration.get('FEATURE_LTI_TOOLS_TAB_ENABLED') as boolean, + CTL_TOOLS__EXTERNAL_TOOL_MAX_LOGO_SIZE_IN_BYTES: Configuration.get( + 'CTL_TOOLS__EXTERNAL_TOOL_MAX_LOGO_SIZE_IN_BYTES' + ) as number, + CTL_TOOLS_BACKEND_URL: Configuration.get('PUBLIC_BACKEND_URL') as string, + FEATURE_CTL_TOOLS_COPY_ENABLED: Configuration.get('FEATURE_CTL_TOOLS_COPY_ENABLED') as boolean, + CTL_TOOLS_RELOAD_TIME_MS: Configuration.get('CTL_TOOLS_RELOAD_TIME_MS') as number, + FILES_STORAGE__SERVICE_BASE_URL: Configuration.get('FILES_STORAGE__SERVICE_BASE_URL') as string, + ROCKET_CHAT_URI: Configuration.get('ROCKET_CHAT_URI') as string, + ROCKET_CHAT_ADMIN_ID: Configuration.get('ROCKET_CHAT_ADMIN_ID') as string, + ROCKET_CHAT_ADMIN_TOKEN: Configuration.get('ROCKET_CHAT_ADMIN_TOKEN') as string, + ROCKET_CHAT_ADMIN_USER: Configuration.get('ROCKET_CHAT_ADMIN_USER') as string, + ROCKET_CHAT_ADMIN_PASSWORD: Configuration.get('ROCKET_CHAT_ADMIN_PASSWORD') as string, + ADMIN_API__ALLOWED_API_KEYS: (Configuration.get('ADMIN_API__ALLOWED_API_KEYS') as string) + .split(',') + .map((part) => (part.split(':').pop() ?? '').trim()), + JWT_AUD: Configuration.get('JWT_AUD') as string, + JWT_LIFETIME: Configuration.get('JWT_LIFETIME') as string, + AUTHENTICATION: Configuration.get('AUTHENTICATION') as string, + LOGIN_BLOCK_TIME: 0, + TEACHER_STUDENT_VISIBILITY__IS_CONFIGURABLE: Configuration.get( + 'TEACHER_STUDENT_VISIBILITY__IS_CONFIGURABLE' + ) as boolean, + FEATURE_IDENTITY_MANAGEMENT_LOGIN_ENABLED: Configuration.get('FEATURE_IDENTITY_MANAGEMENT_LOGIN_ENABLED') as boolean, + FEATURE_IDENTITY_MANAGEMENT_STORE_ENABLED: Configuration.get('FEATURE_IDENTITY_MANAGEMENT_STORE_ENABLED') as boolean, +}; + +export const adminApiServerConfig = () => config; diff --git a/apps/server/src/modules/server/admin-api.server.module.ts b/apps/server/src/modules/server/admin-api.server.module.ts index acd0a8d286e..8f380ff96bb 100644 --- a/apps/server/src/modules/server/admin-api.server.module.ts +++ b/apps/server/src/modules/server/admin-api.server.module.ts @@ -16,11 +16,11 @@ import { MongoDatabaseModuleOptions, MongoMemoryDatabaseModule } from '@src/infr import { EtherpadClientModule } from '@src/infra/etherpad-client'; import { RabbitMQWrapperModule, RabbitMQWrapperTestModule } from '@src/infra/rabbitmq'; import { AdminApiRegistrationPinModule } from '../registration-pin/admin-api-registration-pin.module'; -import { serverConfig } from './server.config'; import { defaultMikroOrmOptions } from './server.module'; +import { adminApiServerConfig } from './admin-api-server.config'; const serverModules = [ - ConfigModule.forRoot(createConfigModuleOptions(serverConfig)), + ConfigModule.forRoot(createConfigModuleOptions(adminApiServerConfig)), DeletionApiModule, LegacySchoolAdminApiModule, UserAdminApiModule, @@ -44,7 +44,7 @@ const serverModules = [ password: DB_PASSWORD, user: DB_USERNAME, entities: [...ALL_ENTITIES, FileEntity], - debug: true, + // debug: true, // use it for locally debugging of queries }), CqrsModule, LoggerModule, diff --git a/apps/server/src/modules/server/server.config.ts b/apps/server/src/modules/server/server.config.ts index ceac280b176..fa875ec0c60 100644 --- a/apps/server/src/modules/server/server.config.ts +++ b/apps/server/src/modules/server/server.config.ts @@ -19,16 +19,18 @@ import { RoomConfig } from '@modules/room'; import type { SchoolConfig } from '@modules/school'; import type { SharingConfig } from '@modules/sharing'; import { getTldrawClientConfig, type TldrawClientConfig } from '@modules/tldraw-client'; -import type { ToolConfig } from '@modules/tool/tool-config'; +import type { ToolConfig } from '@modules/tool'; import type { UserConfig } from '@modules/user'; import type { UserImportConfig } from '@modules/user-import'; import type { UserLoginMigrationConfig } from '@modules/user-login-migration'; -import { VideoConferenceConfig } from '@modules/video-conference'; -import { LanguageType } from '@shared/domain/interface'; -import { SchulcloudTheme } from '@shared/domain/types'; +import type { VideoConferenceConfig } from '@modules/video-conference'; +import type { LanguageType } from '@shared/domain/interface'; +import type { SchulcloudTheme } from '@shared/domain/types'; import type { CoreModuleConfig } from '@src/core'; -import { BbbConfig } from '../video-conference/bbb'; -import { Timezone } from './types/timezone.enum'; +import { TspRestClientConfig } from '@src/infra/tsp-client/tsp-client-config'; +import type { ShdConfig } from '@modules/shd'; +import type { BbbConfig } from '@modules/video-conference/bbb'; +import type { Timezone } from './types/timezone.enum'; export enum NodeEnvType { TEST = 'test', @@ -67,7 +69,9 @@ export interface ServerConfig UserImportConfig, VideoConferenceConfig, BbbConfig, - AlertConfig { + TspRestClientConfig, + AlertConfig, + ShdConfig { NODE_ENV: NodeEnvType; SC_DOMAIN: string; HOST: string; @@ -304,6 +308,16 @@ const config: ServerConfig = { FEATURE_SANIS_GROUP_PROVISIONING_ENABLED: Configuration.get('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED') as boolean, FEATURE_AI_TUTOR_ENABLED: Configuration.get('FEATURE_AI_TUTOR_ENABLED') as boolean, FEATURE_ROOMS_ENABLED: Configuration.get('FEATURE_ROOMS_ENABLED') as boolean, + TSP_API_BASE_URL: Configuration.get('TSP_API_BASE_URL') as string, + TSP_API_CLIENT_ID: Configuration.get('TSP_API_CLIENT_ID') as string, + TSP_API_CLIENT_SECRET: Configuration.get('TSP_API_CLIENT_SECRET') as string, + TSP_API_TOKEN_LIFETIME_MS: Configuration.get('TSP_API_TOKEN_LIFETIME_MS') as number, + TSP_API_SIGNATURE_KEY: Configuration.get('TSP_API_SIGNATURE_KEY') as string, + ROCKET_CHAT_URI: Configuration.get('ROCKET_CHAT_URI') as string, + ROCKET_CHAT_ADMIN_ID: Configuration.get('ROCKET_CHAT_ADMIN_ID') as string, + ROCKET_CHAT_ADMIN_TOKEN: Configuration.get('ROCKET_CHAT_ADMIN_TOKEN') as string, + ROCKET_CHAT_ADMIN_USER: Configuration.get('ROCKET_CHAT_ADMIN_USER') as string, + ROCKET_CHAT_ADMIN_PASSWORD: Configuration.get('ROCKET_CHAT_ADMIN_PASSWORD') as string, }; export const serverConfig = () => config; diff --git a/apps/server/src/modules/server/server.module.ts b/apps/server/src/modules/server/server.module.ts index 3e2d46d02cf..f1ff7d226be 100644 --- a/apps/server/src/modules/server/server.module.ts +++ b/apps/server/src/modules/server/server.module.ts @@ -46,6 +46,7 @@ import { ALL_ENTITIES } from '@shared/domain/entity'; import { createConfigModuleOptions, DB_PASSWORD, DB_URL, DB_USERNAME } from '@src/config'; import { CoreModule } from '@src/core'; import { LoggerModule } from '@src/core/logger'; +import { ShdApiModule } from '@modules/shd/shd.api.module'; import { ServerConfigController, ServerController, ServerUc } from './api'; import { SERVER_CONFIG_TOKEN, serverConfig } from './server.config'; @@ -100,6 +101,7 @@ const serverModules = [ UserLicenseModule, RoomApiModule, RosterModule, + ShdApiModule, ]; export const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { diff --git a/apps/server/src/modules/shd/api/api-tests/shd.api.spec.ts b/apps/server/src/modules/shd/api/api-tests/shd.api.spec.ts new file mode 100644 index 00000000000..933038cee96 --- /dev/null +++ b/apps/server/src/modules/shd/api/api-tests/shd.api.spec.ts @@ -0,0 +1,160 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { InstanceEntity } from '@modules/instance'; +import { instanceEntityFactory } from '@modules/instance/testing'; +import { ServerTestModule } from '@modules/server'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; +import { LoginDto } from '@src/modules/authentication'; +import { TargetUserIdParams } from '../dtos/target-user-id.params'; + +const forbiddenResponse = { + code: 403, + message: 'Forbidden', + title: 'Forbidden', + type: 'FORBIDDEN', +}; + +const unauthorizedReponse = { + code: 401, + type: 'UNAUTHORIZED', + title: 'Unauthorized', + message: 'Unauthorized', +}; + +describe('Shd API', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + em = app.get(EntityManager); + testApiClient = new TestApiClient(app, 'shd'); + }); + + afterAll(async () => { + await app.close(); + }); + + afterEach(async () => { + await em.nativeDelete(InstanceEntity, {}); + }); + + describe('supportJwt', () => { + const prepareData = () => { + const { superheroAccount, superheroUser } = UserAndAccountTestFactory.buildSuperhero(); + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + const instance = instanceEntityFactory.build(); + + return { superheroAccount, superheroUser, studentAccount, studentUser, instance }; + }; + + describe('given unprivileged user want to access', () => { + const setup = async () => { + const { superheroAccount, superheroUser, studentAccount, studentUser, instance } = prepareData(); + + await em.persistAndFlush([superheroAccount, superheroUser, studentAccount, studentUser, instance]); + em.clear(); + + const data: TargetUserIdParams = { + userId: superheroUser.id, + }; + + const loggedInClient = await testApiClient.login(studentAccount); + + return { data, loggedInClient }; + }; + + describe('when jwt is not passed', () => { + it('should respond with unauthorized exception', async () => { + const { data } = await setup(); + + const response = await testApiClient.post('supportJwt', data); + + expect(response.statusCode).toEqual(HttpStatus.UNAUTHORIZED); + expect(response.body).toEqual(unauthorizedReponse); + }); + }); + + describe('when user has not the privilege to request supportJwt', () => { + it('should respond with unauthorized exception', async () => { + const { data, loggedInClient } = await setup(); + + const response = await loggedInClient.post('supportJwt', data); + + expect(response.statusCode).toEqual(HttpStatus.FORBIDDEN); + expect(response.body).toEqual(forbiddenResponse); + }); + }); + }); + + describe('given privileged user want to access', () => { + const setup = async (userId?: string) => { + const { superheroAccount, superheroUser, studentAccount, studentUser, instance } = prepareData(); + + await em.persistAndFlush([superheroAccount, superheroUser, studentAccount, studentUser, instance]); + em.clear(); + + const data: TargetUserIdParams = { + userId: userId ?? studentUser.id, + }; + + const loggedInClient = await testApiClient.login(superheroAccount); + + return { data, loggedInClient }; + }; + + describe('when requested user exists', () => { + it('should respond with loginDto', async () => { + const { data, loggedInClient } = await setup(); + + const response = await loggedInClient.post('supportJwt', data); + + expect(response.statusCode).toEqual(HttpStatus.CREATED); + expect(response.body).toMatchObject({ + accessToken: expect.any(String), + }); + }); + }); + + describe('when requested user not exists', () => { + it('should return 404', async () => { + const notExistedUserId = new ObjectId().toHexString(); + const { data, loggedInClient } = await setup(notExistedUserId); + + const response = await loggedInClient.post(`/supportJwt`, data); + + expect(response.status).toEqual(HttpStatus.NOT_FOUND); + expect(response.body).toEqual({ + code: 404, + message: expect.any(String), + title: 'Not Found', + type: 'NOT_FOUND', + }); + }); + }); + + describe('when invalid data passed', () => { + it('should return 400', async () => { + const invalidUserId = 'someId'; + const { data, loggedInClient } = await setup(invalidUserId); + + const response = await loggedInClient.post(`/supportJwt`, data); + + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); + expect(response.body).toEqual( + expect.objectContaining({ + validationErrors: [{ errors: ['userId must be a mongodb id'], field: ['userId'] }], + }) + ); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/shd/api/dtos/target-user-id.params.ts b/apps/server/src/modules/shd/api/dtos/target-user-id.params.ts new file mode 100644 index 00000000000..3b795d993d4 --- /dev/null +++ b/apps/server/src/modules/shd/api/dtos/target-user-id.params.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsMongoId } from 'class-validator'; + +export class TargetUserIdParams { + @ApiProperty() + @IsMongoId() + userId!: string; +} diff --git a/apps/server/src/modules/shd/api/index.ts b/apps/server/src/modules/shd/api/index.ts new file mode 100644 index 00000000000..71b9c0057d7 --- /dev/null +++ b/apps/server/src/modules/shd/api/index.ts @@ -0,0 +1,2 @@ +export * from './shd.controller'; +export * from './shd.uc'; diff --git a/apps/server/src/modules/shd/api/shd.controller.ts b/apps/server/src/modules/shd/api/shd.controller.ts new file mode 100644 index 00000000000..d9ca202f3ee --- /dev/null +++ b/apps/server/src/modules/shd/api/shd.controller.ts @@ -0,0 +1,27 @@ +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; +import { Body, Controller, ForbiddenException, Post, UnauthorizedException } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiValidationError } from '@shared/common'; +import { TargetUserIdParams } from './dtos/target-user-id.params'; +import { ShdUc } from './shd.uc'; + +@ApiTags('Shd') +@JwtAuthentication() +@Controller('shd') +export class ShdController { + constructor(private readonly shdUc: ShdUc) {} + + @ApiOperation({ summary: 'Create a support jwt for a user.' }) + @ApiResponse({ status: 201, type: String }) + @ApiResponse({ status: 400, type: ApiValidationError }) + @ApiResponse({ status: 401, type: UnauthorizedException }) + @ApiResponse({ status: 403, type: ForbiddenException }) + @ApiBody({ required: true, type: TargetUserIdParams }) + @Post('/supportJwt') + public async supportJwt(@Body() bodyParams: TargetUserIdParams, @CurrentUser() currentUser: ICurrentUser) { + const supportUserId = currentUser.userId; + const loginDto = await this.shdUc.createSupportJwt(bodyParams, supportUserId); + + return loginDto; + } +} diff --git a/apps/server/src/modules/shd/api/shd.uc.ts b/apps/server/src/modules/shd/api/shd.uc.ts new file mode 100644 index 00000000000..f04692c39a8 --- /dev/null +++ b/apps/server/src/modules/shd/api/shd.uc.ts @@ -0,0 +1,32 @@ +import { AuthenticationService, LoginDto } from '@modules/authentication'; +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { InstanceService } from '@modules/instance'; +import { Injectable } from '@nestjs/common'; +import { Permission } from '@shared/domain/interface'; +import { EntityId } from '@shared/domain/types'; +import { TargetUserIdParams } from './dtos/target-user-id.params'; + +@Injectable() +export class ShdUc { + constructor( + private readonly authorizationService: AuthorizationService, + private readonly authenticationService: AuthenticationService, + private readonly instanceService: InstanceService + ) {} + + public async createSupportJwt(params: TargetUserIdParams, supportUserId: EntityId): Promise { + const [supportUser, instance, targetUser] = await Promise.all([ + this.authorizationService.getUserWithPermissions(supportUserId), + this.instanceService.getInstance(), + this.authorizationService.getUserWithPermissions(params.userId), + ]); + + const authContext = AuthorizationContextBuilder.write([Permission.CREATE_SUPPORT_JWT]); + this.authorizationService.checkPermission(supportUser, instance, authContext); + + const jwtToken = await this.authenticationService.generateSupportJwt(supportUser, targetUser); + const loginDto = new LoginDto({ accessToken: jwtToken }); + + return loginDto; + } +} diff --git a/apps/server/src/modules/shd/index.ts b/apps/server/src/modules/shd/index.ts new file mode 100644 index 00000000000..654e1f65cfc --- /dev/null +++ b/apps/server/src/modules/shd/index.ts @@ -0,0 +1 @@ +export { ShdConfig } from './shd.config'; diff --git a/apps/server/src/modules/shd/shd.api.module.ts b/apps/server/src/modules/shd/shd.api.module.ts new file mode 100644 index 00000000000..b6624db1758 --- /dev/null +++ b/apps/server/src/modules/shd/shd.api.module.ts @@ -0,0 +1,13 @@ +import { AuthGuardModule } from '@infra/auth-guard'; +import { AuthenticationModule } from '@modules/authentication'; +import { AuthorizationModule } from '@modules/authorization/authorization.module'; +import { InstanceModule } from '@modules/instance'; +import { Module } from '@nestjs/common'; +import { ShdController, ShdUc } from './api'; + +@Module({ + imports: [AuthorizationModule, AuthenticationModule, AuthGuardModule, InstanceModule], + controllers: [ShdController], + providers: [ShdUc], +}) +export class ShdApiModule {} diff --git a/apps/server/src/modules/shd/shd.config.ts b/apps/server/src/modules/shd/shd.config.ts new file mode 100644 index 00000000000..32536a84e23 --- /dev/null +++ b/apps/server/src/modules/shd/shd.config.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ShdConfig {} diff --git a/apps/server/src/modules/system/index.ts b/apps/server/src/modules/system/index.ts index e391f13df16..c3ce9640348 100644 --- a/apps/server/src/modules/system/index.ts +++ b/apps/server/src/modules/system/index.ts @@ -4,10 +4,11 @@ export { OidcConfig, System, SystemProps, - SYSTEM_REPO, + SYSTEM_REPO, // Repo and token of it should not be exported SystemRepo, SystemType, SystemDeletedEvent, } from './domain'; export { SystemService } from './service'; export { SystemModule } from './system.module'; +export { SystemConfig } from './system.config'; diff --git a/apps/server/src/modules/system/system.config.ts b/apps/server/src/modules/system/system.config.ts new file mode 100644 index 00000000000..52165418aa2 --- /dev/null +++ b/apps/server/src/modules/system/system.config.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SystemConfig {} diff --git a/apps/server/src/modules/teams/index.ts b/apps/server/src/modules/teams/index.ts index eb6818085df..4ce7e452fce 100644 --- a/apps/server/src/modules/teams/index.ts +++ b/apps/server/src/modules/teams/index.ts @@ -1,2 +1,3 @@ -export * from './teams.module'; +export { TeamsModule } from './teams.module'; +export { TeamsConfig } from './teams.config'; export * from './service'; diff --git a/apps/server/src/modules/teams/teams.config.ts b/apps/server/src/modules/teams/teams.config.ts new file mode 100644 index 00000000000..a98c6bb981f --- /dev/null +++ b/apps/server/src/modules/teams/teams.config.ts @@ -0,0 +1,3 @@ +import { LoggerConfig } from '@src/core/logger'; + +export interface TeamsConfig extends LoggerConfig {} diff --git a/apps/server/src/modules/tool/context-external-tool/controller/admin-api-context-external-tool.controller.ts b/apps/server/src/modules/tool/context-external-tool/controller/admin-api-context-external-tool.controller.ts index 5117a9bf8d2..580bebd8f3b 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/admin-api-context-external-tool.controller.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/admin-api-context-external-tool.controller.ts @@ -1,10 +1,8 @@ import { ApiKeyGuard } from '@infra/auth-guard'; import { Body, Controller, Post, UseGuards } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; -import { ContextExternalTool } from '../domain'; import { ContextExternalToolRequestMapper, ContextExternalToolResponseMapper } from '../mapper'; import { AdminApiContextExternalToolUc } from '../uc'; -import { ContextExternalToolDto } from '../uc/dto/context-external-tool.types'; import { ContextExternalToolPostParams, ContextExternalToolResponse } from './dto'; @ApiTags('AdminApi: Context External Tool') @@ -16,16 +14,15 @@ export class AdminApiContextExternalToolController { @Post() @ApiOperation({ summary: 'Creates a ContextExternalTool' }) async createContextExternalTool(@Body() body: ContextExternalToolPostParams): Promise { - const contextExternalToolProps: ContextExternalToolDto = - ContextExternalToolRequestMapper.mapContextExternalToolRequest(body); + const contextExternalToolProps = ContextExternalToolRequestMapper.mapContextExternalToolRequest(body); - const contextExternalTool: ContextExternalTool = await this.adminApiContextExternalToolUc.createContextExternalTool( + const contextExternalTool = await this.adminApiContextExternalToolUc.createContextExternalTool( contextExternalToolProps ); - const response: ContextExternalToolResponse = + const contextExternalToolResonse = ContextExternalToolResponseMapper.mapContextExternalToolResponse(contextExternalTool); - return response; + return contextExternalToolResonse; } } diff --git a/apps/server/src/modules/tool/context-external-tool/controller/api-test/admin-api-context-external-tool.api.spec.ts b/apps/server/src/modules/tool/context-external-tool/controller/api-test/admin-api-context-external-tool.api.spec.ts index 9ad18358203..3ce153ea042 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/api-test/admin-api-context-external-tool.api.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/api-test/admin-api-context-external-tool.api.spec.ts @@ -1,10 +1,11 @@ import { EntityManager, MikroORM } from '@mikro-orm/core'; -import { serverConfig } from '@modules/server'; import { AdminApiServerTestModule } from '@modules/server/admin-api.server.module'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Course, SchoolEntity } from '@shared/domain/entity'; import { courseFactory, schoolEntityFactory, TestApiClient } from '@shared/testing'; +// admin-api-context-external-tool and test file is wrong placed need to be part of a admin-api-module folder +import { adminApiServerConfig } from '@modules/server/admin-api-server.config'; import { ToolContextType } from '../../../common/enum'; import { ExternalToolResponse } from '../../../external-tool/controller/dto'; import { CustomParameterScope, CustomParameterType, ExternalToolEntity } from '../../../external-tool/entity'; @@ -20,13 +21,9 @@ describe('AdminApiContextExternalTool (API)', () => { let orm: MikroORM; let testApiClient: TestApiClient; - const apiKey = 'validApiKey'; - const basePath = 'admin/tools/context-external-tools'; beforeAll(async () => { - serverConfig().ADMIN_API__ALLOWED_API_KEYS = [apiKey]; - const module: TestingModule = await Test.createTestingModule({ imports: [AdminApiServerTestModule], }).compile(); @@ -35,7 +32,10 @@ describe('AdminApiContextExternalTool (API)', () => { await app.init(); em = module.get(EntityManager); orm = app.get(MikroORM); - testApiClient = new TestApiClient(app, basePath, apiKey, true); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const apiKeys = adminApiServerConfig().ADMIN_API__ALLOWED_API_KEYS as string[]; // check config/test.json + testApiClient = new TestApiClient(app, basePath, apiKeys[0], true); }); afterAll(async () => { @@ -117,10 +117,10 @@ describe('AdminApiContextExternalTool (API)', () => { const response = await testApiClient.post().send(postParams); - const body: ExternalToolResponse = response.body as ExternalToolResponse; + const body = response.body as ExternalToolResponse; expect(response.statusCode).toEqual(HttpStatus.CREATED); - expect(body).toEqual({ + expect(body).toMatchObject({ id: expect.any(String), schoolToolId: postParams.schoolToolId, contextId: postParams.contextId, @@ -132,7 +132,7 @@ describe('AdminApiContextExternalTool (API)', () => { ], }); - const contextExternalTool: ContextExternalToolEntity | null = await em.findOne(ContextExternalToolEntity, { + const contextExternalTool = await em.findOne(ContextExternalToolEntity, { id: body.id, }); expect(contextExternalTool).toBeDefined(); diff --git a/apps/server/src/modules/tool/external-tool/controller/admin-api-external-tool.controller.ts b/apps/server/src/modules/tool/external-tool/controller/admin-api-external-tool.controller.ts index 5d92c67f142..883b2d2cf42 100644 --- a/apps/server/src/modules/tool/external-tool/controller/admin-api-external-tool.controller.ts +++ b/apps/server/src/modules/tool/external-tool/controller/admin-api-external-tool.controller.ts @@ -1,10 +1,8 @@ import { ApiKeyGuard } from '@infra/auth-guard'; import { Body, Controller, Post, UseGuards } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; -import { ExternalTool } from '../domain'; - import { ExternalToolRequestMapper, ExternalToolResponseMapper } from '../mapper'; -import { AdminApiExternalToolUc, ExternalToolCreate } from '../uc'; +import { AdminApiExternalToolUc } from '../uc'; import { ExternalToolCreateParams, ExternalToolResponse } from './dto'; @ApiTags('AdminApi: External Tools') @@ -19,12 +17,11 @@ export class AdminApiExternalToolController { @Post() @ApiOperation({ summary: 'Creates an ExternalTool' }) async createExternalTool(@Body() externalToolParams: ExternalToolCreateParams): Promise { - const externalTool: ExternalToolCreate = this.externalToolDOMapper.mapCreateRequest(externalToolParams); - - const created: ExternalTool = await this.adminApiExternalToolUc.createExternalTool(externalTool); + const externalToolCreateParams = this.externalToolDOMapper.mapCreateRequest(externalToolParams); + const externalTool = await this.adminApiExternalToolUc.createExternalTool(externalToolCreateParams); - const mapped: ExternalToolResponse = ExternalToolResponseMapper.mapToExternalToolResponse(created); + const externalToolResponse = ExternalToolResponseMapper.mapToExternalToolResponse(externalTool); - return mapped; + return externalToolResponse; } } diff --git a/apps/server/src/modules/tool/external-tool/controller/api-test/admin-api-external-tool.api.spec.ts b/apps/server/src/modules/tool/external-tool/controller/api-test/admin-api-external-tool.api.spec.ts index 9d9a9b7af34..09e589efc61 100644 --- a/apps/server/src/modules/tool/external-tool/controller/api-test/admin-api-external-tool.api.spec.ts +++ b/apps/server/src/modules/tool/external-tool/controller/api-test/admin-api-external-tool.api.spec.ts @@ -1,10 +1,10 @@ import { EntityManager, MikroORM } from '@mikro-orm/core'; -import { serverConfig } from '@modules/server'; import { AdminApiServerTestModule } from '@modules/server/admin-api.server.module'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { TestApiClient } from '@shared/testing'; -import { Response } from 'supertest'; +// admin-api-external-tool and test file is wrong placed need to be part of a admin-api-module folder +import { adminApiServerConfig } from '@modules/server/admin-api-server.config'; import { CustomParameterLocationParams, CustomParameterScopeTypeParams, @@ -20,13 +20,9 @@ describe('AdminApiExternalTool (API)', () => { let orm: MikroORM; let testApiClient: TestApiClient; - const apiKey = 'validApiKey'; - const basePath = 'admin/tools/external-tools'; beforeAll(async () => { - serverConfig().ADMIN_API__ALLOWED_API_KEYS = [apiKey]; - const module: TestingModule = await Test.createTestingModule({ imports: [AdminApiServerTestModule], }).compile(); @@ -35,7 +31,10 @@ describe('AdminApiExternalTool (API)', () => { await app.init(); em = module.get(EntityManager); orm = app.get(MikroORM); - testApiClient = new TestApiClient(app, basePath, apiKey, true); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const apiKeys = adminApiServerConfig().ADMIN_API__ALLOWED_API_KEYS as string[]; // check config/test.json + testApiClient = new TestApiClient(app, basePath, apiKeys[0], true); }); afterAll(async () => { @@ -107,12 +106,12 @@ describe('AdminApiExternalTool (API)', () => { it('should create a tool', async () => { const { postParams } = setup(); - const response: Response = await testApiClient.post().send(postParams); + const response = await testApiClient.post().send(postParams); const body: ExternalToolResponse = response.body as ExternalToolResponse; expect(response.status).toEqual(HttpStatus.CREATED); - expect(body).toEqual({ + expect(response.body).toMatchObject({ id: body.id, name: 'Tool 1', parameters: [ @@ -140,7 +139,7 @@ describe('AdminApiExternalTool (API)', () => { openNewTab: true, }); - const externalTool: ExternalToolEntity | null = await em.findOne(ExternalToolEntity, { id: body.id }); + const externalTool = await em.findOne(ExternalToolEntity, { id: body.id }); expect(externalTool).toBeDefined(); }); }); diff --git a/apps/server/src/modules/tool/index.ts b/apps/server/src/modules/tool/index.ts index a8006029057..1750c9cd829 100644 --- a/apps/server/src/modules/tool/index.ts +++ b/apps/server/src/modules/tool/index.ts @@ -3,3 +3,4 @@ export * from './context-external-tool/service/context-external-tool-authorizabl export * from './external-tool'; export * from './tool.module'; export { ExternalToolAuthorizableService } from './external-tool/service/external-tool-authorizable.service'; +export { ToolConfig } from './tool-config'; diff --git a/apps/server/src/modules/tool/school-external-tool/controller/api-test/admin-api-school-external-tool.api.spec.ts b/apps/server/src/modules/tool/school-external-tool/controller/api-test/admin-api-school-external-tool.api.spec.ts index d3906501ec8..336d333497e 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/api-test/admin-api-school-external-tool.api.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/api-test/admin-api-school-external-tool.api.spec.ts @@ -1,10 +1,11 @@ import { EntityManager, MikroORM } from '@mikro-orm/core'; -import { serverConfig } from '@modules/server'; import { AdminApiServerTestModule } from '@modules/server/admin-api.server.module'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { SchoolEntity } from '@shared/domain/entity'; import { schoolEntityFactory, TestApiClient } from '@shared/testing'; +// admin-api-external-tool and test file is wrong placed need to be part of a admin-api-module folder +import { adminApiServerConfig } from '@modules/server/admin-api-server.config'; import { ExternalToolResponse } from '../../../external-tool/controller/dto'; import { CustomParameterScope, CustomParameterType, ExternalToolEntity } from '../../../external-tool/entity'; import { customParameterEntityFactory, externalToolEntityFactory } from '../../../external-tool/testing'; @@ -18,13 +19,9 @@ describe('AdminApiSchoolExternalTool (API)', () => { let orm: MikroORM; let testApiClient: TestApiClient; - const apiKey = 'validApiKey'; - const basePath = 'admin/tools/school-external-tools'; beforeAll(async () => { - serverConfig().ADMIN_API__ALLOWED_API_KEYS = [apiKey]; - const module: TestingModule = await Test.createTestingModule({ imports: [AdminApiServerTestModule], }).compile(); @@ -33,7 +30,10 @@ describe('AdminApiSchoolExternalTool (API)', () => { await app.init(); em = module.get(EntityManager); orm = app.get(MikroORM); - testApiClient = new TestApiClient(app, basePath, apiKey, true); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const apiKeys = adminApiServerConfig().ADMIN_API__ALLOWED_API_KEYS as string[]; // check config/test.json + testApiClient = new TestApiClient(app, basePath, apiKeys[0], true); }); afterAll(async () => { diff --git a/apps/server/src/modules/user/index.ts b/apps/server/src/modules/user/index.ts index e0db2a06b79..89ee2457e9e 100644 --- a/apps/server/src/modules/user/index.ts +++ b/apps/server/src/modules/user/index.ts @@ -1,3 +1,3 @@ -export * from './interfaces'; +export { UserConfig } from './user.config'; export { UserModule } from './user.module'; -export * from './service/user.service'; +export { UserService } from './service'; diff --git a/apps/server/src/modules/user/service/user.service.spec.ts b/apps/server/src/modules/user/service/user.service.spec.ts index c76d3b90a4e..e58f25cd9b7 100644 --- a/apps/server/src/modules/user/service/user.service.spec.ts +++ b/apps/server/src/modules/user/service/user.service.spec.ts @@ -1,4 +1,6 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ICurrentUser } from '@infra/auth-guard'; +import { CalendarService } from '@infra/calendar'; import { EntityManager, MikroORM } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; import { Account, AccountService } from '@modules/account'; @@ -25,8 +27,6 @@ import { UserRepo } from '@shared/repo'; import { UserDORepo } from '@shared/repo/user/user-do.repo'; import { roleFactory, setupEntities, userDoFactory, userFactory } from '@shared/testing'; import { Logger } from '@src/core/logger'; -import { CalendarService } from '@infra/calendar'; -import { ICurrentUser } from '@infra/auth-guard'; import { UserDto } from '../uc/dto/user.dto'; import { UserQuery } from './user-query.type'; import { UserService } from './user.service'; @@ -301,7 +301,7 @@ describe('UserService', () => { accountId: account.id, roles: [role.id], isExternalUser: false, - impersonated: false, + support: false, }); }); }); diff --git a/apps/server/src/modules/user/user.config.ts b/apps/server/src/modules/user/user.config.ts new file mode 100644 index 00000000000..b46c7049a5e --- /dev/null +++ b/apps/server/src/modules/user/user.config.ts @@ -0,0 +1,16 @@ +import { RoleConfig } from '@modules/role'; +import { LoggerConfig } from '@src/core/logger'; +import { CalendarConfig } from '@infra/calendar'; +import { AccountConfig } from '@modules/account'; +import { RegistrationPinConfig } from '@modules/registration-pin'; +import { LegacySchoolConfig } from '@modules/legacy-school'; + +export interface UserConfig + extends RoleConfig, + AccountConfig, + LoggerConfig, + RegistrationPinConfig, + CalendarConfig, + LegacySchoolConfig { + AVAILABLE_LANGUAGES: string[]; +} diff --git a/apps/server/src/modules/user/user.module.ts b/apps/server/src/modules/user/user.module.ts index 364ae05f0f0..501226c6d5f 100644 --- a/apps/server/src/modules/user/user.module.ts +++ b/apps/server/src/modules/user/user.module.ts @@ -1,14 +1,14 @@ import { AccountModule } from '@modules/account'; import { LegacySchoolModule } from '@modules/legacy-school'; -import { RoleModule } from '@modules/role/role.module'; +import { RoleModule } from '@modules/role'; import { forwardRef, Module } from '@nestjs/common'; import { UserRepo } from '@shared/repo'; import { UserDORepo } from '@shared/repo/user/user-do.repo'; import { LoggerModule } from '@src/core/logger'; import { CqrsModule } from '@nestjs/cqrs'; import { RegistrationPinModule } from '@modules/registration-pin'; -import { CalendarModule } from '@src/infra/calendar'; -import { UserService } from './service/user.service'; +import { CalendarModule } from '@infra/calendar'; +import { UserService } from './service'; @Module({ imports: [ diff --git a/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.spec.ts b/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.spec.ts index 2f025b46d03..fc31febf442 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.spec.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.spec.ts @@ -165,7 +165,7 @@ describe('VideoConferenceUc', () => { schoolId: 'schoolId', accountId: 'accountId', isExternalUser: false, - impersonated: false, + support: false, }; defaultOptions = { everybodyJoinsAsModerator: false, diff --git a/apps/server/src/shared/testing/factory/currentuser.factory.ts b/apps/server/src/shared/testing/factory/currentuser.factory.ts index 4a6b4329da1..820a88c718b 100644 --- a/apps/server/src/shared/testing/factory/currentuser.factory.ts +++ b/apps/server/src/shared/testing/factory/currentuser.factory.ts @@ -15,7 +15,9 @@ class CurrentUser implements ICurrentUser { isExternalUser: boolean; - impersonated: boolean; + support: boolean; + + supportUserId?: string; constructor(data: ICurrentUser) { this.userId = data.userId; @@ -24,7 +26,8 @@ class CurrentUser implements ICurrentUser { this.accountId = data.accountId; this.systemId = data.systemId || ''; this.isExternalUser = data.isExternalUser; - this.impersonated = false; + this.support = false; + this.supportUserId = data.supportUserId; } } @@ -55,6 +58,7 @@ export const currentUserFactory = CurrentUserFactory.define(CurrentUser, () => { accountId: new ObjectId().toHexString(), systemId: new ObjectId().toHexString(), isExternalUser: false, - impersonated: false, + support: false, + supportUserId: undefined, }; }); diff --git a/apps/server/src/shared/testing/factory/entity.factory.ts b/apps/server/src/shared/testing/factory/entity.factory.ts new file mode 100644 index 00000000000..8b9e470445d --- /dev/null +++ b/apps/server/src/shared/testing/factory/entity.factory.ts @@ -0,0 +1,173 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import type { EntityId } from '@shared/domain/types'; +import { BuildOptions, DeepPartial, Factory, GeneratorFn, HookFn } from 'fishery'; + +/** + * Entity factory based on thoughtbot/fishery + * https://github.com/thoughtbot/fishery + * + * This one is intended for entities that have their state solely managed by domain objects. + * These entities should not be initialized by the entity the constructor. So it shouldn't have any parameters. + * + * The factory takes the entity class and the entity properties type as parameters + * and produces an entitiy by: + * - calling the constructor without any parameters + * - and then assigning the properties + * + * @template T The entity to be built + * @template U The properties interface of the entity + * @template I The transient parameters that your factory supports + * @template C The class of the factory object being created. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export class EntityFactory { + protected readonly propsFactory: Factory; + + constructor(private readonly EntityClass: { new (): T }, propsFactory: Factory) { + this.propsFactory = propsFactory; + } + + /** + * Define a factory + * @template T The entity to be built + * @template U The properties interface of the entity + * @template I The transient parameters that your factory supports + * @template C The class of the factory object being created. + * @param EntityClass The constructor of the entity to be built. + * @param generator Your factory function - see `Factory.define()` in thoughtbot/fishery + * @returns + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static define>( + this: new (EntityClass: { new (): T }, propsFactory: Factory) => F, + EntityClass: { new (): T }, + generator: GeneratorFn + ): F { + const propsFactory = Factory.define(generator); + const factory = new this(EntityClass, propsFactory); + return factory; + } + + /** + * Build an entity using your factory + * @param params + * @returns an entity + */ + build(params?: DeepPartial, options: BuildOptions = {}): T { + const props = this.propsFactory.build(params, options); + const entity = new this.EntityClass(); + Object.assign(entity, props); + + return entity; + } + + /** + * Build an entity using your factory and generate a id for it. + * @param params + * @param id + * @returns an entity + */ + buildWithId(params?: DeepPartial, id?: string, options: BuildOptions = {}): T { + const entity = this.build(params, options) as { _id: ObjectId; id: EntityId }; + const generatedId = new ObjectId(id); + const entityWithId = Object.assign(entity, { _id: generatedId, id: generatedId.toHexString() }); + + return entityWithId as T; + } + + /** + * Build a list of entities using your factory + * @param number + * @param params + * @returns a list of entities + */ + buildList(number: number, params?: DeepPartial, options: BuildOptions = {}): T[] { + const list: T[] = []; + for (let i = 0; i < number; i += 1) { + list.push(this.build(params, options)); + } + + return list; + } + + buildListWithId(number: number, params?: DeepPartial, options: BuildOptions = {}): T[] { + const list: T[] = []; + for (let i = 0; i < number; i += 1) { + list.push(this.buildWithId(params, undefined, options)); + } + + return list; + } + + /** + * Extend the factory by adding a function to be called after an object is built. + * @param afterBuildFn - the function to call. It accepts your object of type T. The value this function returns gets returned from "build" + * @returns a new factory + */ + afterBuild(afterBuildFn: HookFn): this { + const newPropsFactory = this.propsFactory.afterBuild(afterBuildFn); + const newFactory = this.clone(newPropsFactory); + + return newFactory; + } + + /** + * Extend the factory by adding default associations to be passed to the factory when "build" is called + * @param associations + * @returns a new factory + */ + associations(associations: Partial): this { + const newPropsFactory = this.propsFactory.associations(associations); + const newFactory = this.clone(newPropsFactory); + + return newFactory; + } + + /** + * Extend the factory by adding default parameters to be passed to the factory when "build" is called + * @param params + * @returns a new factory + */ + params(params: DeepPartial): this { + const newPropsFactory = this.propsFactory.params(params); + const newFactory = this.clone(newPropsFactory); + + return newFactory; + } + + /** + * Extend the factory by adding default transient parameters to be passed to the factory when "build" is called + * @param transient - transient params + * @returns a new factory + */ + transient(transient: Partial): this { + const newPropsFactory = this.propsFactory.transient(transient); + const newFactory = this.clone(newPropsFactory); + + return newFactory; + } + + /** + * Set sequence back to its default value + */ + rewindSequence(): void { + this.propsFactory.rewindSequence(); + } + + protected clone>(this: F, propsFactory: Factory): F { + const copy = new (this.constructor as { + new (propsOfFactory: Factory): F; + })(propsFactory); + + return copy; + } + + /** + * Get the next sequence value + * @returns the next sequence value + */ + protected sequence(): number { + // eslint-disable-next-line @typescript-eslint/dot-notation + return this.propsFactory['sequence'](); + } +} diff --git a/apps/server/src/shared/testing/factory/index.ts b/apps/server/src/shared/testing/factory/index.ts index 72234a94ddb..d0d2e9568d9 100644 --- a/apps/server/src/shared/testing/factory/index.ts +++ b/apps/server/src/shared/testing/factory/index.ts @@ -9,6 +9,7 @@ export * from './course.factory'; export * from './coursegroup.factory'; export * from './currentuser.factory'; export * from './domainobject'; +export * from './entity.factory'; export * from './external-group-dto.factory'; export { externalSchoolDtoFactory } from './external-school-dto.factory'; export * from './external-tool-pseudonym.factory'; diff --git a/apps/server/src/shared/testing/factory/jwtpayload.factory.ts b/apps/server/src/shared/testing/factory/jwtpayload.factory.ts index bd5c0582182..5a9f16a677e 100644 --- a/apps/server/src/shared/testing/factory/jwtpayload.factory.ts +++ b/apps/server/src/shared/testing/factory/jwtpayload.factory.ts @@ -14,7 +14,9 @@ class JwtPayloadImpl implements JwtPayload { systemId?: string; - support?: boolean; + support: boolean; + + supportUserId?: string; isExternalUser: boolean; @@ -38,6 +40,7 @@ class JwtPayloadImpl implements JwtPayload { this.systemId = data.systemId || ''; this.support = data.support || false; this.isExternalUser = data.isExternalUser; + this.supportUserId = data.supportUserId; this.aud = data.aud; this.exp = data.exp; this.iat = data.iat; diff --git a/apps/server/src/shared/testing/map-user-to-current-user.ts b/apps/server/src/shared/testing/map-user-to-current-user.ts index ba394d41b58..64792503974 100644 --- a/apps/server/src/shared/testing/map-user-to-current-user.ts +++ b/apps/server/src/shared/testing/map-user-to-current-user.ts @@ -16,7 +16,7 @@ export const mapUserToCurrentUser = ( schoolId: user.school.id, accountId: account ? account.id : new ObjectId().toHexString(), systemId, - impersonated: impersonated || false, + support: impersonated || false, isExternalUser: false, }; diff --git a/apps/server/src/shared/testing/test-api-client.ts b/apps/server/src/shared/testing/test-api-client.ts index 1acb82e757e..99e1cb507e3 100644 --- a/apps/server/src/shared/testing/test-api-client.ts +++ b/apps/server/src/shared/testing/test-api-client.ts @@ -1,7 +1,7 @@ import { INestApplication } from '@nestjs/common'; import supertest, { Response } from 'supertest'; -import { AccountEntity } from '@src/modules/account/domain/entity/account.entity'; -import { defaultTestPassword } from '@src/modules/account/testing/account.factory'; +import type { AccountEntity } from '@modules/account/domain/entity/account.entity'; +import { defaultTestPassword } from '@modules/account/testing/account.factory'; interface AuthenticationResponse { accessToken: string; diff --git a/apps/server/src/shared/testing/user-role-permissions.ts b/apps/server/src/shared/testing/user-role-permissions.ts index 6a3008efaf4..378ba8859d1 100644 --- a/apps/server/src/shared/testing/user-role-permissions.ts +++ b/apps/server/src/shared/testing/user-role-permissions.ts @@ -149,4 +149,5 @@ export const superheroPermissions = [ Permission.USER_LOGIN_MIGRATION_FORCE, Permission.USER_LOGIN_MIGRATION_ROLLBACK, Permission.INSTANCE_VIEW, + Permission.CREATE_SUPPORT_JWT, ]; diff --git a/backup/setup/external-tools.json b/backup/setup/external-tools.json index f9e53c37498..1a88cae3c20 100644 --- a/backup/setup/external-tools.json +++ b/backup/setup/external-tools.json @@ -930,35 +930,6 @@ "version": 1, "restrictToContexts": [] }, - { - "_id": { - "$oid": "65fd44ba09e6ffd0bae3b8d3" - }, - "createdAt": { - "$date": { - "$numberLong": "1711097018086" - } - }, - "updatedAt": { - "$date": { - "$numberLong": "1711117950821" - } - }, - "name": "Moodle Fortbildung", - "url": "https://moodle-01.staging.dataport.dbildungsplattform.de/enrol/lti/cartridge.php/2/07aa5a7e070aa4ffac138a000d502638/cartridge.xml", - "config_type": "lti11", - "config_baseUrl": "https://moodle-01.staging.dataport.dbildungsplattform.de/enrol/lti/tool.php?id=2", - "config_key": "moodle", - "config_lti_message_type": "basic-lti-launch-request", - "config_privacy_permission": "e-mail", - "config_launch_presentation_locale": "de-DE", - "parameters": [], - "isHidden": false, - "isDeactivated": false, - "openNewTab": true, - "version": 3, - "restrictToContexts": [] - }, { "_id": { "$oid": "65fd9736cb3d21d77bee50a6" diff --git a/config/default.schema.json b/config/default.schema.json index e2240f97819..1d9f3f14fca 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -130,8 +130,8 @@ "description": "Lifetime of generated JWTs in days." }, "JWT_LIFETIME_SUPPORT_SECONDS": { - "type": "string", - "default": "3600", + "type": "number", + "default": 3600, "description": "Lifetime of generated support JWTs." }, "JWT_TIMEOUT_SECONDS": { @@ -174,6 +174,11 @@ "default": "", "description": "The key used to sign/verify TSP request tokens." }, + "TSP_API_TOKEN_LIFETIME_MS": { + "type": "number", + "default": "30000", + "description": "The TSP token lifetime in milliseconds." + }, "FEATURE_TSP_ENABLED": { "type": "boolean", "default": false, diff --git a/openapitools.json b/openapitools.json index 97d49682b51..01614e969dc 100644 --- a/openapitools.json +++ b/openapitools.json @@ -2,6 +2,32 @@ "$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json", "spaces": 2, "generator-cli": { - "version": "7.6.0" + "version": "7.6.0", + "generators": { + "tsp-api": { + "generatorName": "typescript-axios", + "inputSpec": "https://test2.schulportal-thueringen.de/tip-ms/api/swagger.json", + "output": "./apps/server/src/infra/tsp-client/generated", + "skipValidateSpec": true, + "enablePostProcessFile": true, + "openapiNormalizer": { + "FILTER": "operationId:exportKlasseList|exportLehrerListMigration|exportLehrerList|exportSchuelerListMigration|exportSchuelerList|exportSchuleList|version" + }, + "globalProperty": { + "models": "RobjExportKlasse:RobjExportLehrerMigration:RobjExportLehrer:RobjExportSchuelerMigration:RobjExportSchueler:RobjExportSchule:VersionResponse", + "apis": "", + "supportingFiles": "" + }, + "additionalProperties": { + "apiPackage": "api", + "enumNameSuffix": "", + "enumPropertyNaming": "UPPERCASE", + "modelPackage": "models", + "supportsES6": true, + "withInterfaces": true, + "withSeparateModelsAndApi": true + } + } + } } } diff --git a/package.json b/package.json index c71342f0d6d..37f044e9f3e 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "nest:start:common-cartridge": "node dist/apps/server/apps/common-cartridge.app", "nest:start:common-cartridge:dev": "nest start common-cartridge --watch --", "nest:start:common-cartridge:debug": "nest start common-cartridge --debug --watch --", - "nest:start:sync":"npm run nest:start:console -- sync run", + "nest:start:sync": "npm run nest:start:console -- sync run", "nest:test": "npm run nest:test:cov && npm run nest:lint", "nest:test:all": "jest \"^((?!(\\.load)\\.spec\\.ts).)*\"", "nest:test:unit": "jest \"^((?!(\\.api|\\.load)\\.spec\\.ts).)*\\.spec\\.ts$\"", @@ -121,7 +121,9 @@ "schoolExport": "node ./scripts/schoolExport.js", "schoolImport": "node ./scripts/schoolImport.js", "generate-client:authorization": "node ./scripts/generate-client.js -u 'http://localhost:3030/api/v3/docs-json/' -p 'apps/server/src/infra/authorization-client/authorization-api-client' -c 'openapitools-config.json' -f 'operationId:AuthorizationReferenceController_authorizeByReference'", - "generate-client:etherpad": "node ./scripts/generate-client.js -u 'http://localhost:9001/api/openapi.json' -p 'apps/server/src/infra/etherpad-client/etherpad-api-client' -c 'openapitools-config.json'" + "generate-client:etherpad": "node ./scripts/generate-client.js -u 'http://localhost:9001/api/openapi.json' -p 'apps/server/src/infra/etherpad-client/etherpad-api-client' -c 'openapitools-config.json'", + "pregenerate-client:tsp-api": "rimraf ./apps/server/src/infra/tsp-client/generated", + "generate-client:tsp-api": "openapi-generator-cli generate -c ./openapitools.json --generator-key tsp-api" }, "dependencies": { "@aws-sdk/lib-storage": "^3.617.0", diff --git a/sonar-project.properties b/sonar-project.properties index 476998e113b..f85109f473b 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,8 +3,8 @@ sonar.projectKey=hpi-schul-cloud_schulcloud-server sonar.sources=. sonar.tests=. sonar.test.inclusions=**/*.spec.ts -sonar.exclusions=**/*.js,jest.config.ts,globalSetup.ts,globalTeardown.ts,**/*.app.ts,**/seed-data/*.ts,**/migrations/mikro-orm/*.ts,**/etherpad-api-client/**/*.ts,**/authorization-api-client/**/*.ts, **/course-api-client/**/*.ts,**/board-api-client/**/*.ts -sonar.coverage.exclusions=**/board-management.uc.ts,**/*.module.ts,**/*.factory.ts,**/migrations/mikro-orm/*.ts,**/globalSetup.ts,**/globalTeardown.ts,**/etherpad-api-client/**/*.ts,**/authorization-api-client/**/*.ts, **/course-api-client/**/*.ts,**/board-api-client/**/*.ts -sonar.cpd.exclusions=**/controller/dto/*.ts +sonar.exclusions=**/*.js,jest.config.ts,globalSetup.ts,globalTeardown.ts,**/*.app.ts,**/seed-data/*.ts,**/migrations/mikro-orm/*.ts,**/etherpad-api-client/**/*.ts,**/authorization-api-client/**/*.ts, **/course-api-client/**/*.ts,**/board-api-client/**/*.ts,**/generated/**/*.ts +sonar.coverage.exclusions=**/board-management.uc.ts,**/*.module.ts,**/*.factory.ts,**/migrations/mikro-orm/*.ts,**/globalSetup.ts,**/globalTeardown.ts,**/etherpad-api-client/**/*.ts,**/authorization-api-client/**/*.ts, **/course-api-client/**/*.ts,**/board-api-client/**/*.ts,**/generated/**/*.ts +sonar.cpd.exclusions=**/controller/dto/**/*.ts,**/api/dto/**/*.ts,**/shared/testing/factory/*.factory.ts sonar.javascript.lcov.reportPaths=merged-lcov.info sonar.typescript.tsconfigPaths=tsconfig.json,src/apps/server/tsconfig.app.json diff --git a/src/middleware/errorHandler.js b/src/middleware/errorHandler.js index 463e924ee98..abf01a5803d 100644 --- a/src/middleware/errorHandler.js +++ b/src/middleware/errorHandler.js @@ -126,6 +126,7 @@ const secretDataKeys = (() => '_csrf', 'searchUserPassword', 'authorization', + 'student-email', ].map((k) => k.toLocaleLowerCase()))(); const filterSecretValue = (key, value) => { diff --git a/src/services/account/docs/openapi_template.yaml b/src/services/account/docs/openapi_template.yaml index dc7104db313..e449ae26964 100644 --- a/src/services/account/docs/openapi_template.yaml +++ b/src/services/account/docs/openapi_template.yaml @@ -1,9 +1,9 @@ security: - jwtBearer: [] info: - title: Schul-Cloud Account Service API + title: dBildungscloud Account Service API description: - This is the API specification for the HPI Schul-Cloud Account service. + This is the API specification for the dBildungscloud Account service. contact: name: support @@ -29,8 +29,6 @@ components: description: TODO jwtTimer: description: TODO - supportJWT: - description: TODO confirm: description: TODO @@ -78,31 +76,6 @@ paths: content: {} description: no request body required - /accounts/supportJWT: - post: - parameters: [] - responses: - '201': - description: created - content: - application/json: - schema: - $ref: '#/components/schemas/supportJWT' - '401': - description: not authenticated - '500': - description: general error - description: Creates a new resource with data. - summary: '' - tags: - - accounts - security: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/supportJWT' /accounts/confirm: post: parameters: [] diff --git a/src/services/account/index.js b/src/services/account/index.js index cd0ffd04304..46a408d60e9 100644 --- a/src/services/account/index.js +++ b/src/services/account/index.js @@ -1,12 +1,10 @@ const { static: staticContent } = require('@feathersjs/express'); const path = require('path'); -const { supportJWTServiceSetup, jwtTimerServiceSetup } = require('./services'); +const { jwtTimerServiceSetup } = require('./services'); module.exports = (app) => { app.use('/accounts/api', staticContent(path.join(__dirname, '/docs/openapi.yaml'))); app.configure(jwtTimerServiceSetup); - - app.configure(supportJWTServiceSetup); }; diff --git a/src/services/account/services/SupportJWTService.js b/src/services/account/services/SupportJWTService.js deleted file mode 100644 index 1c075c2f138..00000000000 --- a/src/services/account/services/SupportJWTService.js +++ /dev/null @@ -1,173 +0,0 @@ -/* eslint-disable max-classes-per-file */ -const CryptoJS = require('crypto-js'); - -const { authenticate } = require('@feathersjs/authentication'); -const { ObjectId } = require('mongoose').Types; - -const { Configuration } = require('@hpi-schul-cloud/commons'); -const { BadRequest } = require('../../../errors'); -const { hasPermission } = require('../../../hooks/index'); -const logger = require('../../../logger'); - -const { addTokenToWhitelistWithIdAndJti } = require('../../authentication/logic/whitelist'); - -class JWT { - /** - * @param {String} secret The server jwt secret. - * @param {String} [audience] Name of jwt creator. - * @param {Number} [expiredOffset] The jwt expire time in ms. - */ - constructor(secret, audience, expiredOffset) { - this.secret = secret; - this.aud = audience; - this.expiredOffset = expiredOffset; - } - - base64url(source) { - // Encode in classical base64 - let encodedSource = CryptoJS.enc.Base64.stringify(source); - - // Remove padding equal characters - encodedSource = encodedSource.replace(/=+$/, ''); - - // Replace characters according to base64url specifications - encodedSource = encodedSource.replace(/\+/g, '-'); - encodedSource = encodedSource.replace(/\//g, '_'); - - return encodedSource; - } - - Utf8Stringify(input) { - return CryptoJS.enc.Utf8.parse(JSON.stringify(input)); - } - - HmacSHA256(signature, _secret) { - const secret = _secret || this.secret; - if (!secret) { - throw new Error('No secret is defined.'); - } - return CryptoJS.HmacSHA256(signature, secret); - } - - async create(supportUserId, userData, secret) { - const header = { - alg: 'HS256', - typ: 'access', - }; - - const iat = new Date().valueOf(); - const exp = iat + this.expiredOffset; - - const jwtData = { - ...userData, - support: true, // mark for support jwts - supportUserId, - iat, - exp, - aud: this.aud, - iss: 'feathers', - sub: userData.accountId, - jti: `support_${ObjectId()}`, - }; - - const stringifiedHeader = this.Utf8Stringify(header); - const encodedHeader = this.base64url(stringifiedHeader); - - const stringifiedData = this.Utf8Stringify(jwtData); - const encodedData = this.base64url(stringifiedData); - - let signature = `${encodedHeader}.${encodedData}`; - signature = this.HmacSHA256(signature, secret); // algorithm: 'HS256', - signature = this.base64url(signature); - - await addTokenToWhitelistWithIdAndJti(jwtData.accountId, jwtData.jti); - - const jwt = `${encodedHeader}.${encodedData}.${signature}`; - - return jwt; - } -} - -const hooks = {}; -hooks.before = { - create: [authenticate('jwt'), hasPermission('CREATE_SUPPORT_JWT')], -}; - -class SupportJWTService { - /** - * @param {String} secret The server jwt secret. - * @param {String} [audience] Name of jwt creator. - * @param {Number} [expiredOffset] The jwt expire time in ms. - */ - constructor(secret, audience, expiredOffset) { - this.err = Object.freeze({ - missingParams: 'Missing param userId.', - canNotCreateJWT: 'Can not create support jwt.', - }); - - this.jwt = new JWT(secret, audience, expiredOffset); - this.expiredOffset = expiredOffset; - } - - static getSetupHooks() { - return hooks; - } - - executeInfo(currentUserId, userId) { - const minutes = this.expiredOffset / (60 * 1000); - // eslint-disable-next-line max-len - logger.info( - `[support][jwt] The support employee with the Id ${currentUserId} has created a short live JWT for the user with the Id ${userId}. The JWT expires expires in ${minutes} minutes` - ); - } - - async create({ userId }, params) { - try { - if (!userId) { - throw new BadRequest(this.err.missingParams); - } - // eslint-disable-next-line no-param-reassign - const requestedUserId = userId.toString(); - const currentUserId = params.account.userId.toString(); - - const account = await this.app.service('nest-account-service').findByUserId(userId); - if (!account && !account.id) { - throw new Error(`Account for user with the id ${userId} does not exist.`); - } - - const user = await this.app.service('usersModel').get(requestedUserId); - - const userData = await this.app.service('authentication').getUserData(user, account); - const jwt = await this.jwt.create(currentUserId, userData); - - this.executeInfo(currentUserId, requestedUserId); - return jwt; - } catch (err) { - logger.error(this.err.canNotCreateJWT, err); - return err; - } - } - - setup(app) { - this.app = app; - } -} - -const supportJWTServiceSetup = (app) => { - const authenticationSecret = Configuration.get('AUTHENTICATION'); - const audienceName = Configuration.get('JWT_AUD'); - const jwtLifetimeInMs = Configuration.get('JWT_LIFETIME_SUPPORT_SECONDS') * 1000; - - const instance = new SupportJWTService(authenticationSecret, audienceName, jwtLifetimeInMs); - - const path = 'accounts/supportJWT'; - app.use(path, instance); - const service = app.service(path); - service.hooks(SupportJWTService.getSetupHooks()); -}; - -module.exports = { - hooks, - SupportJWTService, - supportJWTServiceSetup, -}; diff --git a/src/services/account/services/index.js b/src/services/account/services/index.js index 7043342ddb1..ea92ef82e28 100644 --- a/src/services/account/services/index.js +++ b/src/services/account/services/index.js @@ -1,7 +1,5 @@ -const { supportJWTServiceSetup } = require('./SupportJWTService'); const { jwtTimerServiceSetup } = require('./jwtTimerService'); module.exports = { - supportJWTServiceSetup, jwtTimerServiceSetup, }; diff --git a/src/utils/apiValidation.js b/src/utils/apiValidation.js index 71b4d90f673..5b399d2dd0c 100644 --- a/src/utils/apiValidation.js +++ b/src/utils/apiValidation.js @@ -7,7 +7,6 @@ const ignorePathsList = [ /|(.*\/accounts\/api($|\/$))/, /|(.*\/accounts\/confirm($|\/$))/, /|(.*\/accounts\/jwtTimer($|\/$))/, // todo: proper api-integrationtests with redis - /|(.*\/accounts\/supportJWT($|\/$))/, /|(.*\/activationModel($|\/$))/, /|(.*\/activationModel\/[0-9a-f]{24}($|\/$))/, /|(.*\/activation($|\/$))/, diff --git a/test/services/account/services/SupportJWTService.test.js b/test/services/account/services/SupportJWTService.test.js deleted file mode 100644 index 0368ab0df2d..00000000000 --- a/test/services/account/services/SupportJWTService.test.js +++ /dev/null @@ -1,109 +0,0 @@ -const assert = require('assert'); -const chai = require('chai'); -const { decode } = require('jsonwebtoken'); - -const { expect } = chai; - -const appPromise = require('../../../../src/app'); -const testObjects = require('../../helpers/testObjects')(appPromise()); -const { setupNestServices, closeNestServices } = require('../../../utils/setup.nest.services'); - -describe('supportJWTService', () => { - let app; - let supportJWTService; - let meService; - const testedPermission = 'CREATE_SUPPORT_JWT'; - - let server; - let nestServices; - - before(async () => { - app = await appPromise(); - supportJWTService = app.service('accounts/supportJWT'); - meService = app.service('legacy/v1/me'); - server = await app.listen(0); - nestServices = await setupNestServices(app); - }); - - after(async () => { - await testObjects.cleanup(); - await server.close(); - await closeNestServices(nestServices); - }); - - it('registered the supportJWT service', () => { - assert.ok(supportJWTService); - }); - - it('accepts only POST/create requests', async () => { - for (const method of ['create']) { - expect(supportJWTService[method]).to.exist; - } - for (const method of ['get', 'update', 'patch', 'remove', 'find']) { - expect(supportJWTService[method]).to.not.exist; - } - }); - - it(`create with ${testedPermission} permission should work.`, async () => { - const superhero = await testObjects.setupUser({ roles: 'superhero' }); - const student = await testObjects.setupUser({ roles: 'student' }); - - const { roles } = await app.service('users').get(superhero.userId, { query: { $populate: 'roles' } }); - - expect(roles[0].permissions).to.include(testedPermission); - - const jwt = await supportJWTService.create({ userId: student.userId }, superhero.requestParams); - - const decodedJWT = decode(jwt); - - const { expiredOffset } = supportJWTService; - - expect(decodedJWT.support).to.be.true; - expect(decodedJWT.accountId).to.be.equal(student.accountId); - expect(decodedJWT.userId).to.be.equal(student.userId); - expect(decodedJWT.sub).to.be.equal(student.accountId); - expect(decodedJWT.exp <= new Date().valueOf() + expiredOffset).to.be.true; - }); - - it(`create without ${testedPermission} permission should not work.`, async () => { - const teacher = await testObjects.setupUser({ roles: 'teacher' }); - const student = await testObjects.setupUser({ roles: 'student' }); - - const { roles } = await app.service('users').get(teacher.user._id, { query: { $populate: 'roles' } }); - - try { - await supportJWTService.create({ userId: student.userId }, teacher.requestParams); - } catch (err) { - expect(roles[0].permissions).to.not.include(testedPermission); - expect(err.code).to.be.equal(403); - } - }); - - it('accountId, userId, roles, and schoolId values should be present in jwtData', async () => { - const superhero = await testObjects.setupUser({ roles: 'superhero' }); - const student = await testObjects.setupUser({ roles: 'student' }); - - const jwt = await supportJWTService.create({ userId: student.userId }, superhero.requestParams); - - const { accountId, userId, roles, schoolId } = decode(jwt); - - expect(accountId).to.be.equal(student.account.id.toString()); - expect(userId).to.be.equal(student.user._id.toString()); - expect(roles[0]).to.be.equal(student.user.roles[0].toString()); - expect(schoolId).to.be.equal(student.user.schoolId.toString()); - }); - - it('superhero data should be the same as the requested user data when using the support jwt', async () => { - const superhero = await testObjects.setupUser({ roles: 'superhero' }); - const student = await testObjects.setupUser({ roles: 'student' }); - - const requestedUserJwt = await supportJWTService.create({ userId: student.userId }, superhero.requestParams); - - let meSHdata = await meService.find(superhero.requestParams); - expect(meSHdata._id).to.not.be.equal(student.user._id.toString()); - - superhero.requestParams.authentication.accessToken = requestedUserJwt; - meSHdata = await meService.find(superhero.requestParams); - expect(meSHdata._id).to.be.equal(student.user._id.toString()); - }); -}); diff --git a/test/services/account/services/jwtTimerService.test.js b/test/services/account/services/jwtTimerService.test.js index 453ff4b5c6f..f141dcc647b 100644 --- a/test/services/account/services/jwtTimerService.test.js +++ b/test/services/account/services/jwtTimerService.test.js @@ -13,11 +13,6 @@ const testObjects = require('../../helpers/testObjects')(appPromise()); const { setupNestServices, closeNestServices } = require('../../../utils/setup.nest.services'); describe('jwtTimer service', () => { - it('registered the supportJWT service', async () => { - // eslint-disable-next-line global-require - const defaultApp = await require('../../../../src/app')(); - assert.ok(defaultApp.service('accounts/jwtTimer')); - }); describe('redis mocked', () => { let app; let nestServices;