From b90f46088b1dbe6b460be4942544836da552318c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= <103562092+MarvinOehlerkingCap@users.noreply.github.com> Date: Fri, 24 Nov 2023 16:03:01 +0100 Subject: [PATCH 1/7] N21-1505 Prevent deletion of global systems (#4574) --- apps/server/src/apps/server.app.ts | 19 +- ...-configuration.service.integration.spec.ts | 18 +- .../keycloak-configuration.service.spec.ts | 6 +- ...cloak-identity-management-oauth.service.ts | 2 +- .../src/modules/account/account.module.ts | 4 +- .../account.validation.service.spec.ts | 6 +- .../src/modules/account/uc/account.uc.spec.ts | 12 +- .../authentication/authentication.module.ts | 12 +- .../controllers/api-test/login.api.spec.ts | 12 +- .../services/ldap.service.spec.ts | 10 +- .../strategy/ldap.strategy.spec.ts | 28 +- .../authentication/strategy/ldap.strategy.ts | 4 +- .../authorization/authorization.module.ts | 4 +- .../src/modules/authorization/domain/index.ts | 1 + .../authorization/domain/rules/group.rule.ts | 4 +- .../authorization/domain/rules/index.ts | 1 + .../domain/rules/system.rule.spec.ts | 260 ++++++++++++++++++ .../authorization/domain/rules/system.rule.ts | 43 +++ .../domain/service/rule-manager.spec.ts | 14 +- .../domain/service/rule-manager.ts | 9 +- .../server/src/modules/authorization/index.ts | 2 + .../controller/api-test/group.api.spec.ts | 4 +- .../src/modules/group/uc/group.uc.spec.ts | 14 +- apps/server/src/modules/group/uc/group.uc.ts | 8 +- .../modules/management/seed-data/systems.ts | 10 +- .../oauth/service/hydra.service.spec.ts | 14 +- .../modules/oauth/service/hydra.service.ts | 8 +- .../oauth/service/oauth.service.spec.ts | 20 +- .../modules/oauth/service/oauth.service.ts | 12 +- .../modules/oauth/uc/hydra-oauth.uc.spec.ts | 10 +- .../src/modules/oauth/uc/hydra-oauth.uc.ts | 6 +- .../service/provisioning.service.spec.ts | 12 +- .../service/provisioning.service.ts | 6 +- .../controller/api-test/system.api.spec.ts | 95 ++++--- .../modules/system/controller/dto/index.ts | 5 + .../system/controller/system.controller.ts | 20 +- .../server/src/modules/system/domain/index.ts | 3 + .../src/modules/system/domain/ldap-config.ts | 13 + .../src/modules/system/domain/oauth-config.ts | 46 ++++ .../src/modules/system/domain/system.do.ts | 28 ++ apps/server/src/modules/system/index.ts | 3 +- .../server/src/modules/system/mapper/index.ts | 2 + .../system/mapper/system-oidc.mapper.spec.ts | 16 +- .../system/mapper/system-oidc.mapper.ts | 4 +- .../system/mapper/system.mapper.spec.ts | 16 +- .../modules/system/mapper/system.mapper.ts | 4 +- apps/server/src/modules/system/repo/index.ts | 1 + .../system/repo/system-domain.mapper.ts | 50 ++++ .../modules/system/repo/system.repo.spec.ts | 177 ++++++++++++ .../src/modules/system/repo/system.repo.ts | 36 +++ .../modules/system/service/dto/system.dto.ts | 4 +- .../src/modules/system/service/index.ts | 6 +- .../service/legacy-system.service.spec.ts | 237 ++++++++++++++++ .../system/service/legacy-system.service.ts | 95 +++++++ .../service/system-oidc.service.spec.ts | 22 +- .../system/service/system-oidc.service.ts | 6 +- .../system/service/system.service.spec.ts | 235 ++++------------ .../modules/system/service/system.service.ts | 90 +----- .../src/modules/system/system-api.module.ts | 5 +- .../src/modules/system/system.module.ts | 11 +- .../src/modules/system/uc/system.uc.spec.ts | 184 +++++++++++-- .../server/src/modules/system/uc/system.uc.ts | 37 ++- .../api-test/import-user.api.spec.ts | 4 +- .../controller/import-user.controller.spec.ts | 8 +- .../user-import/uc/user-import.uc.spec.ts | 24 +- .../modules/user-import/uc/user-import.uc.ts | 4 +- .../modules/user-import/user-import.module.ts | 6 +- .../api-test/user-login-migration.api.spec.ts | 90 +++--- .../user-login-migration.service.spec.ts | 10 +- .../service/user-login-migration.service.ts | 4 +- .../uc/user-login-migration.uc.spec.ts | 8 +- .../domain/entity/system.entity.spec.ts | 17 +- .../src/shared/domain/entity/system.entity.ts | 30 +- .../legacy-school.repo.integration.spec.ts | 10 +- apps/server/src/shared/repo/system/index.ts | 2 +- ...=> legacy-system.repo.integration.spec.ts} | 24 +- .../{system.repo.ts => legacy-system.repo.ts} | 6 +- .../user/user-do.repo.integration.spec.ts | 6 +- .../repo/user/user.repo.integration.spec.ts | 12 +- ...r-login-migration.repo.integration.spec.ts | 8 +- .../testing/factory/domainobject/index.ts | 1 + .../domainobject/system/system.factory.ts | 10 + .../testing/factory/group-entity.factory.ts | 6 +- .../testing/factory/import-user.factory.ts | 7 +- .../src/shared/testing/factory/index.ts | 2 +- ...stem.factory.ts => systemEntityFactory.ts} | 30 +- .../factory/user-login-migration.factory.ts | 4 +- src/services/system/hooks/verifyPayload.js | 3 +- src/services/system/model.js | 1 + test/services/system/index.test.js | 51 +++- test/utils/setup.nest.services.js | 5 + 91 files changed, 1761 insertions(+), 678 deletions(-) create mode 100644 apps/server/src/modules/authorization/domain/rules/system.rule.spec.ts create mode 100644 apps/server/src/modules/authorization/domain/rules/system.rule.ts create mode 100644 apps/server/src/modules/system/controller/dto/index.ts create mode 100644 apps/server/src/modules/system/domain/index.ts create mode 100644 apps/server/src/modules/system/domain/ldap-config.ts create mode 100644 apps/server/src/modules/system/domain/oauth-config.ts create mode 100644 apps/server/src/modules/system/domain/system.do.ts create mode 100644 apps/server/src/modules/system/mapper/index.ts create mode 100644 apps/server/src/modules/system/repo/index.ts create mode 100644 apps/server/src/modules/system/repo/system-domain.mapper.ts create mode 100644 apps/server/src/modules/system/repo/system.repo.spec.ts create mode 100644 apps/server/src/modules/system/repo/system.repo.ts create mode 100644 apps/server/src/modules/system/service/legacy-system.service.spec.ts create mode 100644 apps/server/src/modules/system/service/legacy-system.service.ts rename apps/server/src/shared/repo/system/{system.repo.integration.spec.ts => legacy-system.repo.integration.spec.ts} (82%) rename apps/server/src/shared/repo/system/{system.repo.ts => legacy-system.repo.ts} (81%) create mode 100644 apps/server/src/shared/testing/factory/domainobject/system/system.factory.ts rename apps/server/src/shared/testing/factory/{system.factory.ts => systemEntityFactory.ts} (58%) diff --git a/apps/server/src/apps/server.app.ts b/apps/server/src/apps/server.app.ts index 6322bcd568f..7f51ee9b92e 100644 --- a/apps/server/src/apps/server.app.ts +++ b/apps/server/src/apps/server.app.ts @@ -1,33 +1,34 @@ /* istanbul ignore file */ +import { Mail, MailService } from '@infra/mail'; // application imports /* eslint-disable no-console */ import { MikroORM } from '@mikro-orm/core'; -import { NestFactory } from '@nestjs/core'; -import { ExpressAdapter } from '@nestjs/platform-express'; -import { enableOpenApiDocs } from '@shared/controller/swagger'; -import { Mail, MailService } from '@infra/mail'; -import { LegacyLogger, Logger } from '@src/core/logger'; import { AccountService } from '@modules/account'; -import { TeamService } from '@modules/teams/service/team.service'; import { AccountValidationService } from '@modules/account/services/account.validation.service'; import { AccountUc } from '@modules/account/uc/account.uc'; +import { SystemRule } from '@modules/authorization/domain/rules'; import { CollaborativeStorageUc } from '@modules/collaborative-storage/uc/collaborative-storage.uc'; import { GroupService } from '@modules/group'; +import { FeathersRosterService } from '@modules/pseudonym'; import { RocketChatService } from '@modules/rocketchat'; import { ServerModule } from '@modules/server'; +import { TeamService } from '@modules/teams/service/team.service'; +import { NestFactory } from '@nestjs/core'; +import { ExpressAdapter } from '@nestjs/platform-express'; +import { enableOpenApiDocs } from '@shared/controller/swagger'; +import { LegacyLogger, Logger } from '@src/core/logger'; import express from 'express'; import { join } from 'path'; // register source-map-support for debugging import { install as sourceMapInstall } from 'source-map-support'; -import { FeathersRosterService } from '@modules/pseudonym'; -import legacyAppPromise = require('../../../../src/app'); import { AppStartLoggable } from './helpers/app-start-loggable'; import { addPrometheusMetricsMiddlewaresIfEnabled, createAndStartPrometheusMetricsAppIfEnabled, } from './helpers/prometheus-metrics'; +import legacyAppPromise = require('../../../../src/app'); async function bootstrap() { sourceMapInstall(); @@ -85,6 +86,8 @@ async function bootstrap() { feathersExpress.services['nest-feathers-roster-service'] = nestApp.get(FeathersRosterService); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access feathersExpress.services['nest-group-service'] = nestApp.get(GroupService); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access + feathersExpress.services['nest-system-rule'] = nestApp.get(SystemRule); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment feathersExpress.services['nest-orm'] = orm; diff --git a/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.integration.spec.ts b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.integration.spec.ts index 98ed3552918..bae96a2d119 100644 --- a/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.integration.spec.ts +++ b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.integration.spec.ts @@ -1,14 +1,14 @@ import { createMock } from '@golevelup/ts-jest'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import KeycloakAdminClient from '@keycloak/keycloak-admin-client-cjs/keycloak-admin-client-cjs-index'; import AuthenticationExecutionExportRepresentation from '@keycloak/keycloak-admin-client/lib/defs/authenticationExecutionExportRepresentation'; import AuthenticationFlowRepresentation from '@keycloak/keycloak-admin-client/lib/defs/authenticationFlowRepresentation'; +import { LegacySystemService } from '@modules/system'; import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { MongoMemoryDatabaseModule } from '@infra/database'; -import { SystemRepo } from '@shared/repo/system/system.repo'; -import { systemFactory } from '@shared/testing/factory'; +import { LegacySystemRepo } from '@shared/repo'; +import { systemEntityFactory } from '@shared/testing/factory'; import { LoggerModule } from '@src/core/logger'; -import { SystemService } from '@modules/system/service/system.service'; import { v1 } from 'uuid'; import { KeycloakAdministrationService } from '../../keycloak-administration/service/keycloak-administration.service'; import { KeycloakConfigurationModule } from '../keycloak-configuration.module'; @@ -17,15 +17,15 @@ import { KeycloakConfigurationService } from './keycloak-configuration.service'; describe('KeycloakConfigurationService Integration', () => { let module: TestingModule; let keycloak: KeycloakAdminClient; - let systemRepo: SystemRepo; + let systemRepo: LegacySystemRepo; let keycloakAdministrationService: KeycloakAdministrationService; let keycloakConfigurationService: KeycloakConfigurationService; let isKeycloakAvailable = false; const testRealm = `test-realm-${v1().toString()}`; const flowAlias = 'Direct Broker Flow'; - const systemServiceMock = createMock(); - const systems = systemFactory.withOidcConfig().buildList(1); + const systemServiceMock = createMock(); + const systems = systemEntityFactory.withOidcConfig().buildList(1); beforeAll(async () => { module = await Test.createTestingModule({ @@ -38,9 +38,9 @@ describe('KeycloakConfigurationService Integration', () => { validationOptions: { infer: true }, }), ], - providers: [SystemRepo], + providers: [LegacySystemRepo], }).compile(); - systemRepo = module.get(SystemRepo); + systemRepo = module.get(LegacySystemRepo); keycloakAdministrationService = module.get(KeycloakAdministrationService); keycloakConfigurationService = module.get(KeycloakConfigurationService); isKeycloakAvailable = await keycloakAdministrationService.testKcConnection(); diff --git a/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.spec.ts b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.spec.ts index 0b47e876ba0..76bcbceebf2 100644 --- a/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.spec.ts +++ b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.spec.ts @@ -13,7 +13,7 @@ import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { SystemEntity, SystemTypeEnum } from '@shared/domain'; -import { systemFactory } from '@shared/testing'; +import { systemEntityFactory } from '@shared/testing'; import { AxiosResponse } from 'axios'; import { of } from 'rxjs'; import { v1 } from 'uuid'; @@ -63,7 +63,9 @@ describe('KeycloakConfigurationService Unit', () => { }; }; - const systems: SystemEntity[] = systemFactory.withOidcConfig().buildListWithId(1, { type: SystemTypeEnum.OIDC }); + const systems: SystemEntity[] = systemEntityFactory + .withOidcConfig() + .buildListWithId(1, { type: SystemTypeEnum.OIDC }); const oidcSystems = SystemOidcMapper.mapFromEntitiesToDtos(systems); const idps: IdentityProviderRepresentation[] = [ { diff --git a/apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.ts b/apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.ts index cbfa289165e..b6458640c75 100644 --- a/apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.ts +++ b/apps/server/src/infra/identity-management/keycloak/service/keycloak-identity-management-oauth.service.ts @@ -1,5 +1,5 @@ import { DefaultEncryptionService, EncryptionService } from '@infra/encryption'; -import { OauthConfigDto } from '@modules/system/service'; +import { OauthConfigDto } from '@modules/system/service/dto'; import { HttpService } from '@nestjs/axios'; import { Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; diff --git a/apps/server/src/modules/account/account.module.ts b/apps/server/src/modules/account/account.module.ts index 9a57ba37a44..2a60d4c453a 100644 --- a/apps/server/src/modules/account/account.module.ts +++ b/apps/server/src/modules/account/account.module.ts @@ -2,7 +2,7 @@ import { IdentityManagementModule } from '@infra/identity-management'; import { Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PermissionService } from '@shared/domain'; -import { SystemRepo, UserRepo } from '@shared/repo'; +import { LegacySystemRepo, UserRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger/logger.module'; import { ServerConfig } from '../server/server.config'; import { AccountIdmToDtoMapper, AccountIdmToDtoMapperDb, AccountIdmToDtoMapperIdm } from './mapper'; @@ -24,7 +24,7 @@ function accountIdmToDtoMapperFactory(configService: ConfigService { mockOtherTeacherAccount = accountFactory.buildWithId({ userId: mockOtherTeacherUser.id, }); - const externalSystemA = systemFactory.buildWithId(); - const externalSystemB = systemFactory.buildWithId(); + const externalSystemA = systemEntityFactory.buildWithId(); + const externalSystemB = systemEntityFactory.buildWithId(); mockExternalUserAccount = accountFactory.buildWithId({ userId: mockExternalUser.id, username: 'unique.within@system', diff --git a/apps/server/src/modules/account/uc/account.uc.spec.ts b/apps/server/src/modules/account/uc/account.uc.spec.ts index 6810c2baa5c..2552469b8cf 100644 --- a/apps/server/src/modules/account/uc/account.uc.spec.ts +++ b/apps/server/src/modules/account/uc/account.uc.spec.ts @@ -20,7 +20,7 @@ import { User, } from '@shared/domain'; import { UserRepo } from '@shared/repo'; -import { accountFactory, schoolFactory, setupEntities, systemFactory, userFactory } from '@shared/testing'; +import { accountFactory, schoolFactory, setupEntities, systemEntityFactory, userFactory } from '@shared/testing'; import { BruteForcePrevention } from '@src/imports-from-feathers'; import { ObjectId } from 'bson'; import { @@ -431,7 +431,7 @@ describe('AccountUc', () => { userId: mockUnknownRoleUser.id, password: defaultPasswordHash, }); - const externalSystem = systemFactory.buildWithId(); + const externalSystem = systemEntityFactory.buildWithId(); mockExternalUserAccount = accountFactory.buildWithId({ userId: mockExternalUser.id, password: defaultPasswordHash, @@ -440,25 +440,25 @@ describe('AccountUc', () => { mockAccountWithoutUser = accountFactory.buildWithId({ userId: undefined, password: defaultPasswordHash, - systemId: systemFactory.buildWithId().id, + systemId: systemEntityFactory.buildWithId().id, }); mockAccountWithSystemId = accountFactory.withSystemId(new ObjectId(10)).build(); mockAccountWithLastFailedLogin = accountFactory.buildWithId({ userId: undefined, password: defaultPasswordHash, - systemId: systemFactory.buildWithId().id, + systemId: systemEntityFactory.buildWithId().id, lasttriedFailedLogin: new Date(), }); mockAccountWithOldLastFailedLogin = accountFactory.buildWithId({ userId: undefined, password: defaultPasswordHash, - systemId: systemFactory.buildWithId().id, + systemId: systemEntityFactory.buildWithId().id, lasttriedFailedLogin: new Date(new Date().getTime() - LOGIN_BLOCK_TIME - 1), }); mockAccountWithNoLastFailedLogin = accountFactory.buildWithId({ userId: undefined, password: defaultPasswordHash, - systemId: systemFactory.buildWithId().id, + systemId: systemEntityFactory.buildWithId().id, lasttriedFailedLogin: undefined, }); diff --git a/apps/server/src/modules/authentication/authentication.module.ts b/apps/server/src/modules/authentication/authentication.module.ts index 8f2bdcd3b0d..ca9872f75e0 100644 --- a/apps/server/src/modules/authentication/authentication.module.ts +++ b/apps/server/src/modules/authentication/authentication.module.ts @@ -1,14 +1,14 @@ -import { Module } from '@nestjs/common'; -import { JwtModule, JwtModuleOptions } from '@nestjs/jwt'; -import { PassportModule } from '@nestjs/passport'; import { CacheWrapperModule } from '@infra/cache'; import { IdentityManagementModule } from '@infra/identity-management'; -import { LegacySchoolRepo, SystemRepo, UserRepo } from '@shared/repo'; -import { LoggerModule } from '@src/core/logger'; import { AccountModule } from '@modules/account'; import { OauthModule } from '@modules/oauth/oauth.module'; import { RoleModule } from '@modules/role'; import { SystemModule } from '@modules/system'; +import { Module } from '@nestjs/common'; +import { JwtModule, JwtModuleOptions } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { LegacySchoolRepo, LegacySystemRepo, UserRepo } from '@shared/repo'; +import { LoggerModule } from '@src/core/logger'; import { Algorithm, SignOptions } from 'jsonwebtoken'; import { jwtConstants } from './constants'; import { AuthenticationService } from './services/authentication.service'; @@ -69,7 +69,7 @@ const jwtModuleOptions: JwtModuleOptions = { JwtStrategy, JwtValidationAdapter, UserRepo, - SystemRepo, + LegacySystemRepo, LegacySchoolRepo, LocalStrategy, AuthenticationService, diff --git a/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts b/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts index 04683e182a8..6541da27b08 100644 --- a/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts +++ b/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts @@ -5,7 +5,7 @@ import { ServerTestModule } from '@modules/server/server.module'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Account, RoleName, SchoolEntity, SystemEntity, User } from '@shared/domain'; -import { accountFactory, roleFactory, schoolFactory, systemFactory, userFactory } from '@shared/testing'; +import { accountFactory, roleFactory, schoolFactory, systemEntityFactory, userFactory } from '@shared/testing'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import crypto, { KeyPairKeyObjectResult } from 'crypto'; @@ -149,7 +149,7 @@ describe('Login Controller (api)', () => { describe('when user login succeeds', () => { const setup = async () => { const schoolExternalId = 'mockSchoolExternalId'; - const system: SystemEntity = systemFactory.withLdapConfig().buildWithId({}); + const system: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({}); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [system], externalId: schoolExternalId }); const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); @@ -200,7 +200,7 @@ describe('Login Controller (api)', () => { describe('when user login fails', () => { const setup = async () => { const schoolExternalId = 'mockSchoolExternalId'; - const system: SystemEntity = systemFactory.withLdapConfig().buildWithId({}); + const system: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({}); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [system], externalId: schoolExternalId }); const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); @@ -238,7 +238,7 @@ describe('Login Controller (api)', () => { describe('when logging in as a user of the Central LDAP of Brandenburg', () => { const setup = async () => { const officialSchoolNumber = '01234'; - const system: SystemEntity = systemFactory.withLdapConfig().buildWithId({}); + const system: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({}); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [system], externalId: officialSchoolNumber, @@ -301,7 +301,7 @@ describe('Login Controller (api)', () => { const schoolExternalId = 'schoolExternalId'; const userExternalId = 'userExternalId'; - const system = systemFactory.withOauthConfig().buildWithId({}); + const system = systemEntityFactory.withOauthConfig().buildWithId({}); const school = schoolFactory.buildWithId({ systems: [system], externalId: schoolExternalId }); const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); const user = userFactory.buildWithId({ school, roles: [studentRoles], externalId: userExternalId }); @@ -391,7 +391,7 @@ describe('Login Controller (api)', () => { const schoolExternalId = 'schoolExternalId'; const userExternalId = 'userExternalId'; - const system = systemFactory.withOauthConfig().buildWithId({}); + const system = systemEntityFactory.withOauthConfig().buildWithId({}); const school = schoolFactory.buildWithId({ systems: [system], externalId: schoolExternalId }); const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); const user = userFactory.buildWithId({ school, roles: [studentRoles], externalId: userExternalId }); diff --git a/apps/server/src/modules/authentication/services/ldap.service.spec.ts b/apps/server/src/modules/authentication/services/ldap.service.spec.ts index 8b334ac195c..ec0b8c05bb3 100644 --- a/apps/server/src/modules/authentication/services/ldap.service.spec.ts +++ b/apps/server/src/modules/authentication/services/ldap.service.spec.ts @@ -2,7 +2,7 @@ import { createMock } from '@golevelup/ts-jest'; import { UnauthorizedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { SystemEntity } from '@shared/domain'; -import { systemFactory } from '@shared/testing'; +import { systemEntityFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { LdapService } from './ldap.service'; @@ -59,7 +59,7 @@ describe('LdapService', () => { describe('checkLdapCredentials', () => { describe('when credentials are correct', () => { it('should login successfully', async () => { - const system: SystemEntity = systemFactory.withLdapConfig().buildWithId(); + const system: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId(); await expect( ldapService.checkLdapCredentials(system, 'connectSucceeds', 'mockPassword') ).resolves.not.toThrow(); @@ -68,7 +68,7 @@ describe('LdapService', () => { describe('when no ldap config is provided', () => { it('should throw error', async () => { - const system: SystemEntity = systemFactory.buildWithId(); + const system: SystemEntity = systemEntityFactory.buildWithId(); await expect(ldapService.checkLdapCredentials(system, 'mockUsername', 'mockPassword')).rejects.toThrow( new Error(`no LDAP config found in system ${system.id}`) ); @@ -77,7 +77,7 @@ describe('LdapService', () => { describe('when user is not authorized', () => { it('should throw unauthorized error', async () => { - const system: SystemEntity = systemFactory.withLdapConfig().buildWithId(); + const system: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId(); await expect(ldapService.checkLdapCredentials(system, 'mockUsername', 'mockPassword')).rejects.toThrow( new UnauthorizedException('User could not authenticate') ); @@ -86,7 +86,7 @@ describe('LdapService', () => { describe('when connected flag is not set', () => { it('should throw unauthorized error', async () => { - const system: SystemEntity = systemFactory.withLdapConfig().buildWithId(); + const system: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId(); await expect(ldapService.checkLdapCredentials(system, 'connectWithoutFlag', 'mockPassword')).rejects.toThrow( new UnauthorizedException('User could not authenticate') ); 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 b3067de04eb..c45a25cc310 100644 --- a/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts +++ b/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts @@ -4,7 +4,7 @@ import { UnauthorizedException } from '@nestjs/common'; import { PassportModule } from '@nestjs/passport'; import { Test, TestingModule } from '@nestjs/testing'; import { LegacySchoolDo, RoleName, SystemEntity, User } from '@shared/domain'; -import { LegacySchoolRepo, SystemRepo, UserRepo } from '@shared/repo'; +import { LegacySchoolRepo, LegacySystemRepo, UserRepo } from '@shared/repo'; import { accountDtoFactory, defaultTestPassword, @@ -12,7 +12,7 @@ import { legacySchoolDoFactory, schoolFactory, setupEntities, - systemFactory, + systemEntityFactory, userFactory, } from '@shared/testing'; import { Logger } from '@src/core/logger'; @@ -30,7 +30,7 @@ describe('LdapStrategy', () => { let schoolRepoMock: DeepMocked; let authenticationServiceMock: DeepMocked; let ldapServiceMock: DeepMocked; - let systemRepo: DeepMocked; + let systemRepo: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -56,8 +56,8 @@ describe('LdapStrategy', () => { useValue: createMock(), }, { - provide: SystemRepo, - useValue: createMock(), + provide: LegacySystemRepo, + useValue: createMock(), }, { provide: Logger, @@ -71,7 +71,7 @@ describe('LdapStrategy', () => { schoolRepoMock = module.get(LegacySchoolRepo); userRepoMock = module.get(UserRepo); ldapServiceMock = module.get(LdapService); - systemRepo = module.get(SystemRepo); + systemRepo = module.get(LegacySystemRepo); }); afterAll(async () => { @@ -87,7 +87,7 @@ describe('LdapStrategy', () => { const setup = () => { const username = 'mockUserName'; - const system: SystemEntity = systemFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); + const system: SystemEntity = systemEntityFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); const user: User = userFactory.withRoleByName(RoleName.STUDENT).buildWithId({ ldapDn: undefined }); @@ -134,7 +134,7 @@ describe('LdapStrategy', () => { const setup = () => { const username = 'mockUserName'; - const system: SystemEntity = systemFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); + const system: SystemEntity = systemEntityFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); const user: User = userFactory.withRoleByName(RoleName.STUDENT).buildWithId({ ldapDn: 'mockLdapDn' }); @@ -181,7 +181,7 @@ describe('LdapStrategy', () => { const setup = () => { const username = 'mockUserName'; - const system: SystemEntity = systemFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); + const system: SystemEntity = systemEntityFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); const user: User = userFactory.withRoleByName(RoleName.STUDENT).buildWithId({ ldapDn: 'mockLdapDn' }); @@ -228,7 +228,7 @@ describe('LdapStrategy', () => { const setup = () => { const username = 'mockUserName'; - const system: SystemEntity = systemFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); + const system: SystemEntity = systemEntityFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); const user: User = userFactory.withRoleByName(RoleName.STUDENT).buildWithId({ ldapDn: 'mockLdapDn' }); @@ -275,7 +275,7 @@ describe('LdapStrategy', () => { const setup = () => { const username = 'mockUserName'; - const system: SystemEntity = systemFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); + const system: SystemEntity = systemEntityFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); const user: User = userFactory.withRoleByName(RoleName.STUDENT).buildWithId({ ldapDn: 'mockLdapDn' }); @@ -327,7 +327,7 @@ describe('LdapStrategy', () => { const error = new Error('error'); const username = 'mockUserName'; - const system: SystemEntity = systemFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); + const system: SystemEntity = systemEntityFactory.withLdapConfig({ rootPath: 'rootPath' }).buildWithId(); const user: User = userFactory.withRoleByName(RoleName.STUDENT).buildWithId({ ldapDn: 'mockLdapDn' }); @@ -382,7 +382,7 @@ describe('LdapStrategy', () => { const setup = () => { const username = 'mockUserName'; - const system: SystemEntity = systemFactory.withLdapConfig().buildWithId(); + const system: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId(); const user: User = userFactory .withRoleByName(RoleName.STUDENT) @@ -445,7 +445,7 @@ describe('LdapStrategy', () => { const setup = () => { const username = 'mockUserName'; - const system: SystemEntity = systemFactory.withLdapConfig().buildWithId(); + const system: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId(); const user: User = userFactory .withRoleByName(RoleName.STUDENT) diff --git a/apps/server/src/modules/authentication/strategy/ldap.strategy.ts b/apps/server/src/modules/authentication/strategy/ldap.strategy.ts index 6f33e92f21a..8ffd1aa0bb6 100644 --- a/apps/server/src/modules/authentication/strategy/ldap.strategy.ts +++ b/apps/server/src/modules/authentication/strategy/ldap.strategy.ts @@ -2,7 +2,7 @@ import { AccountDto } from '@modules/account/services/dto'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { LegacySchoolDo, SystemEntity, User } from '@shared/domain'; -import { LegacySchoolRepo, SystemRepo, UserRepo } from '@shared/repo'; +import { LegacySchoolRepo, LegacySystemRepo, UserRepo } from '@shared/repo'; import { ErrorLoggable } from '@src/core/error/loggable/error.loggable'; import { Logger } from '@src/core/logger'; import { Strategy } from 'passport-custom'; @@ -15,7 +15,7 @@ import { LdapService } from '../services/ldap.service'; @Injectable() export class LdapStrategy extends PassportStrategy(Strategy, 'ldap') { constructor( - private readonly systemRepo: SystemRepo, + private readonly systemRepo: LegacySystemRepo, private readonly schoolRepo: LegacySchoolRepo, private readonly ldapService: LdapService, private readonly authenticationService: AuthenticationService, diff --git a/apps/server/src/modules/authorization/authorization.module.ts b/apps/server/src/modules/authorization/authorization.module.ts index f734a72ed8a..ea4f5d178fe 100644 --- a/apps/server/src/modules/authorization/authorization.module.ts +++ b/apps/server/src/modules/authorization/authorization.module.ts @@ -13,6 +13,7 @@ import { LessonRule, SchoolExternalToolRule, SubmissionRule, + SystemRule, TaskRule, TeamRule, UserLoginMigrationRule, @@ -43,7 +44,8 @@ import { FeathersAuthorizationService, FeathersAuthProvider } from './feathers'; UserRule, UserLoginMigrationRule, LegacySchoolRule, + SystemRule, ], - exports: [FeathersAuthorizationService, AuthorizationService], + exports: [FeathersAuthorizationService, AuthorizationService, SystemRule], }) export class AuthorizationModule {} diff --git a/apps/server/src/modules/authorization/domain/index.ts b/apps/server/src/modules/authorization/domain/index.ts index 0f5cfe67874..ccc52341b5b 100644 --- a/apps/server/src/modules/authorization/domain/index.ts +++ b/apps/server/src/modules/authorization/domain/index.ts @@ -2,3 +2,4 @@ export * from './service'; export * from './mapper'; export * from './error'; export * from './type'; +export { SystemRule } from './rules'; diff --git a/apps/server/src/modules/authorization/domain/rules/group.rule.ts b/apps/server/src/modules/authorization/domain/rules/group.rule.ts index e25e90230c8..9f9b82e15e5 100644 --- a/apps/server/src/modules/authorization/domain/rules/group.rule.ts +++ b/apps/server/src/modules/authorization/domain/rules/group.rule.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; -import { User } from '@shared/domain'; +import { User } from '@shared/domain/entity'; import { Group } from '@src/modules/group'; -import { AuthorizationContext, Rule } from '../type'; import { AuthorizationHelper } from '../service/authorization.helper'; +import { AuthorizationContext, Rule } from '../type'; @Injectable() export class GroupRule implements Rule { diff --git a/apps/server/src/modules/authorization/domain/rules/index.ts b/apps/server/src/modules/authorization/domain/rules/index.ts index b78f43051d0..8a4f1df5109 100644 --- a/apps/server/src/modules/authorization/domain/rules/index.ts +++ b/apps/server/src/modules/authorization/domain/rules/index.ts @@ -15,3 +15,4 @@ export * from './team.rule'; export * from './user-login-migration.rule'; export * from './user.rule'; export * from './group.rule'; +export { SystemRule } from './system.rule'; diff --git a/apps/server/src/modules/authorization/domain/rules/system.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/system.rule.spec.ts new file mode 100644 index 00000000000..589e21a03d3 --- /dev/null +++ b/apps/server/src/modules/authorization/domain/rules/system.rule.spec.ts @@ -0,0 +1,260 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { System } from '@modules/system'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Permission, SchoolEntity, SystemEntity, User } from '@shared/domain'; +import { schoolFactory, setupEntities, systemEntityFactory, systemFactory, userFactory } from '@shared/testing'; +import { AuthorizationContextBuilder } from '../mapper'; +import { AuthorizationHelper } from '../service/authorization.helper'; +import { SystemRule } from './system.rule'; + +describe(SystemRule.name, () => { + let module: TestingModule; + let rule: SystemRule; + + let authorizationHelper: DeepMocked; + + beforeAll(async () => { + await setupEntities(); + + module = await Test.createTestingModule({ + providers: [ + SystemRule, + { + provide: AuthorizationHelper, + useValue: createMock(), + }, + ], + }).compile(); + + rule = module.get(SystemRule); + authorizationHelper = module.get(AuthorizationHelper); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('isApplicable', () => { + describe('when the entity is applicable', () => { + const setup = () => { + const user: User = userFactory.buildWithId(); + const system: System = systemFactory.build(); + + return { + user, + system, + }; + }; + + it('should return true', () => { + const { user, system } = setup(); + + const result = rule.isApplicable(user, system); + + expect(result).toEqual(true); + }); + }); + + describe('when the entity is not applicable', () => { + const setup = () => { + const user: User = userFactory.buildWithId(); + + return { + user, + }; + }; + + it('should return false', () => { + const { user } = setup(); + + const result = rule.isApplicable(user, {} as unknown as System); + + expect(result).toEqual(false); + }); + }); + }); + + describe('hasPermission', () => { + describe('when the user reads a system at his school and has the required permission', () => { + const setup = () => { + const system: System = systemFactory.build(); + const systemEntity: SystemEntity = systemEntityFactory.buildWithId(undefined, system.id); + const school: SchoolEntity = schoolFactory.buildWithId({ + systems: [systemEntity], + }); + const user: User = userFactory.buildWithId({ school }); + const authorizationContext = AuthorizationContextBuilder.read([Permission.SYSTEM_VIEW]); + + authorizationHelper.hasAllPermissions.mockReturnValueOnce(true); + + return { + user, + system, + authorizationContext, + }; + }; + + it('should check the permission', () => { + const { user, system, authorizationContext } = setup(); + + rule.hasPermission(user, system, authorizationContext); + + expect(authorizationHelper.hasAllPermissions).toHaveBeenCalledWith( + user, + authorizationContext.requiredPermissions + ); + }); + + it('should return true', () => { + const { user, system, authorizationContext } = setup(); + + const result = rule.hasPermission(user, system, authorizationContext); + + expect(result).toEqual(true); + }); + }); + + describe('when the user reads a system, but does not have the permission', () => { + const setup = () => { + const system: System = systemFactory.build(); + const systemEntity: SystemEntity = systemEntityFactory.buildWithId(undefined, system.id); + const school: SchoolEntity = schoolFactory.buildWithId({ + systems: [systemEntity], + }); + const user: User = userFactory.buildWithId({ school }); + const authorizationContext = AuthorizationContextBuilder.read([Permission.SYSTEM_VIEW]); + + authorizationHelper.hasAllPermissions.mockReturnValueOnce(false); + + return { + user, + system, + authorizationContext, + }; + }; + + it('should return false', () => { + const { user, system, authorizationContext } = setup(); + + const result = rule.hasPermission(user, system, authorizationContext); + + expect(result).toEqual(false); + }); + }); + + describe('when the user reads a system that is not at his school', () => { + const setup = () => { + const system: System = systemFactory.build(); + const school: SchoolEntity = schoolFactory.buildWithId({ + systems: [], + }); + const user: User = userFactory.buildWithId({ school }); + const authorizationContext = AuthorizationContextBuilder.read([Permission.SYSTEM_VIEW]); + + authorizationHelper.hasAllPermissions.mockReturnValueOnce(true); + + return { + user, + system, + authorizationContext, + }; + }; + + it('should return false', () => { + const { user, system, authorizationContext } = setup(); + + const result = rule.hasPermission(user, system, authorizationContext); + + expect(result).toEqual(false); + }); + }); + + describe('when the user writes a ldap system at his school and has the required permission and the ldap provider is "general"', () => { + const setup = () => { + const system: System = systemFactory.build({ ldapConfig: { provider: 'general' } }); + const systemEntity: SystemEntity = systemEntityFactory.buildWithId(undefined, system.id); + const school: SchoolEntity = schoolFactory.buildWithId({ + systems: [systemEntity], + }); + const user: User = userFactory.buildWithId({ school }); + const authorizationContext = AuthorizationContextBuilder.write([Permission.SYSTEM_CREATE]); + + authorizationHelper.hasAllPermissions.mockReturnValueOnce(true); + + return { + user, + system, + authorizationContext, + }; + }; + + it('should return true', () => { + const { user, system, authorizationContext } = setup(); + + const result = rule.hasPermission(user, system, authorizationContext); + + expect(result).toEqual(true); + }); + }); + + describe('when the user writes a ldap system at his school and has the required permission and the ldap provider is not "general"', () => { + const setup = () => { + const system: System = systemFactory.build({ ldapConfig: { provider: 'other provider' } }); + const systemEntity: SystemEntity = systemEntityFactory.buildWithId(undefined, system.id); + const school: SchoolEntity = schoolFactory.buildWithId({ + systems: [systemEntity], + }); + const user: User = userFactory.buildWithId({ school }); + const authorizationContext = AuthorizationContextBuilder.write([Permission.SYSTEM_CREATE]); + + authorizationHelper.hasAllPermissions.mockReturnValueOnce(true); + + return { + user, + system, + authorizationContext, + }; + }; + + it('should return false', () => { + const { user, system, authorizationContext } = setup(); + + const result = rule.hasPermission(user, system, authorizationContext); + + expect(result).toEqual(false); + }); + }); + + describe('when the user writes a non-ldap system at his school and has the required permission', () => { + const setup = () => { + const system: System = systemFactory.build({ ldapConfig: undefined }); + const systemEntity: SystemEntity = systemEntityFactory.buildWithId(undefined, system.id); + const school: SchoolEntity = schoolFactory.buildWithId({ + systems: [systemEntity], + }); + const user: User = userFactory.buildWithId({ school }); + const authorizationContext = AuthorizationContextBuilder.write([Permission.SYSTEM_CREATE]); + + authorizationHelper.hasAllPermissions.mockReturnValueOnce(true); + + return { + user, + system, + authorizationContext, + }; + }; + + it('should return false', () => { + const { user, system, authorizationContext } = setup(); + + const result = rule.hasPermission(user, system, authorizationContext); + + expect(result).toEqual(false); + }); + }); + }); +}); diff --git a/apps/server/src/modules/authorization/domain/rules/system.rule.ts b/apps/server/src/modules/authorization/domain/rules/system.rule.ts new file mode 100644 index 00000000000..8e63dae744c --- /dev/null +++ b/apps/server/src/modules/authorization/domain/rules/system.rule.ts @@ -0,0 +1,43 @@ +import { System } from '@modules/system'; +import { Injectable } from '@nestjs/common'; +import { User } from '@shared/domain/entity'; +import { AuthorizationHelper } from '../service/authorization.helper'; +import { Action, AuthorizationContext, Rule } from '../type'; + +@Injectable() +export class SystemRule implements Rule { + constructor(private readonly authorizationHelper: AuthorizationHelper) {} + + public isApplicable(user: User, domainObject: System): boolean { + const isMatched: boolean = domainObject instanceof System; + + return isMatched; + } + + public hasPermission(user: User, domainObject: System, context: AuthorizationContext): boolean { + const hasPermissions: boolean = this.authorizationHelper.hasAllPermissions(user, context.requiredPermissions); + + const hasAccess: boolean = user.school.systems.getIdentifiers().includes(domainObject.id); + + let isAuthorized: boolean = hasPermissions && hasAccess; + + if (context.action === Action.write) { + isAuthorized = isAuthorized && this.canEdit(domainObject); + } + + return isAuthorized; + } + + public canEdit(system: unknown): boolean { + const canEdit: boolean = + typeof system === 'object' && + !!system && + 'ldapConfig' in system && + typeof system.ldapConfig === 'object' && + !!system.ldapConfig && + 'provider' in system.ldapConfig && + system.ldapConfig.provider === 'general'; + + return canEdit; + } +} diff --git a/apps/server/src/modules/authorization/domain/service/rule-manager.spec.ts b/apps/server/src/modules/authorization/domain/service/rule-manager.spec.ts index 5b62f850416..950c881a9a7 100644 --- a/apps/server/src/modules/authorization/domain/service/rule-manager.spec.ts +++ b/apps/server/src/modules/authorization/domain/service/rule-manager.spec.ts @@ -8,15 +8,16 @@ import { ContextExternalToolRule, CourseGroupRule, CourseRule, + GroupRule, + LegacySchoolRule, LessonRule, SchoolExternalToolRule, - LegacySchoolRule, SubmissionRule, + SystemRule, TaskRule, TeamRule, - UserRule, UserLoginMigrationRule, - GroupRule, + UserRule, } from '../rules'; import { RuleManager } from './rule-manager'; @@ -35,6 +36,7 @@ describe('RuleManager', () => { let contextExternalToolRule: DeepMocked; let userLoginMigrationRule: DeepMocked; let groupRule: DeepMocked; + let systemRule: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -55,6 +57,7 @@ describe('RuleManager', () => { { provide: BoardDoRule, useValue: createMock() }, { provide: ContextExternalToolRule, useValue: createMock() }, { provide: UserLoginMigrationRule, useValue: createMock() }, + { provide: SystemRule, useValue: createMock() }, ], }).compile(); @@ -72,6 +75,7 @@ describe('RuleManager', () => { contextExternalToolRule = await module.get(ContextExternalToolRule); userLoginMigrationRule = await module.get(UserLoginMigrationRule); groupRule = await module.get(GroupRule); + systemRule = await module.get(SystemRule); }); afterEach(() => { @@ -103,6 +107,7 @@ describe('RuleManager', () => { contextExternalToolRule.isApplicable.mockReturnValueOnce(false); userLoginMigrationRule.isApplicable.mockReturnValueOnce(false); groupRule.isApplicable.mockReturnValueOnce(false); + systemRule.isApplicable.mockReturnValueOnce(false); return { user, object, context }; }; @@ -125,6 +130,7 @@ describe('RuleManager', () => { expect(contextExternalToolRule.isApplicable).toBeCalled(); expect(userLoginMigrationRule.isApplicable).toBeCalled(); expect(groupRule.isApplicable).toBeCalled(); + expect(systemRule.isApplicable).toBeCalled(); }); it('should return CourseRule', () => { @@ -155,6 +161,7 @@ describe('RuleManager', () => { contextExternalToolRule.isApplicable.mockReturnValueOnce(false); userLoginMigrationRule.isApplicable.mockReturnValueOnce(false); groupRule.isApplicable.mockReturnValueOnce(false); + systemRule.isApplicable.mockReturnValueOnce(false); return { user, object, context }; }; @@ -185,6 +192,7 @@ describe('RuleManager', () => { contextExternalToolRule.isApplicable.mockReturnValueOnce(false); userLoginMigrationRule.isApplicable.mockReturnValueOnce(false); groupRule.isApplicable.mockReturnValueOnce(false); + systemRule.isApplicable.mockReturnValueOnce(false); return { user, object, context }; }; diff --git a/apps/server/src/modules/authorization/domain/service/rule-manager.ts b/apps/server/src/modules/authorization/domain/service/rule-manager.ts index 6e6237d125f..2f35f5e9939 100644 --- a/apps/server/src/modules/authorization/domain/service/rule-manager.ts +++ b/apps/server/src/modules/authorization/domain/service/rule-manager.ts @@ -1,22 +1,23 @@ import { Injectable, InternalServerErrorException, NotImplementedException } from '@nestjs/common'; import { BaseDO, User } from '@shared/domain'; import { AuthorizableObject } from '@shared/domain/domain-object'; // fix import when it is avaible -import type { AuthorizationContext, Rule } from '../type'; import { BoardDoRule, ContextExternalToolRule, CourseGroupRule, CourseRule, + GroupRule, LegacySchoolRule, LessonRule, SchoolExternalToolRule, SubmissionRule, + SystemRule, TaskRule, TeamRule, UserLoginMigrationRule, UserRule, - GroupRule, } from '../rules'; +import type { AuthorizationContext, Rule } from '../type'; @Injectable() export class RuleManager { @@ -35,7 +36,8 @@ export class RuleManager { private readonly boardDoRule: BoardDoRule, private readonly contextExternalToolRule: ContextExternalToolRule, private readonly userLoginMigrationRule: UserLoginMigrationRule, - private readonly groupRule: GroupRule + private readonly groupRule: GroupRule, + private readonly systemRule: SystemRule ) { this.rules = [ this.courseRule, @@ -51,6 +53,7 @@ export class RuleManager { this.contextExternalToolRule, this.userLoginMigrationRule, this.groupRule, + this.systemRule, ]; } diff --git a/apps/server/src/modules/authorization/index.ts b/apps/server/src/modules/authorization/index.ts index 13ae209f13d..b90a586ff54 100644 --- a/apps/server/src/modules/authorization/index.ts +++ b/apps/server/src/modules/authorization/index.ts @@ -12,6 +12,8 @@ export { AuthorizationService, ForbiddenLoggableException, Rule, + // For the use in feathers + SystemRule, } from './domain'; // Should not used anymore export { FeathersAuthorizationService } from './feathers'; diff --git a/apps/server/src/modules/group/controller/api-test/group.api.spec.ts b/apps/server/src/modules/group/controller/api-test/group.api.spec.ts index dc1753a6c7f..f5ab532f9b7 100644 --- a/apps/server/src/modules/group/controller/api-test/group.api.spec.ts +++ b/apps/server/src/modules/group/controller/api-test/group.api.spec.ts @@ -10,7 +10,7 @@ import { roleFactory, schoolFactory, schoolYearFactory, - systemFactory, + systemEntityFactory, TestApiClient, UserAndAccountTestFactory, userFactory, @@ -51,7 +51,7 @@ describe('Group (API)', () => { const teacherRole: Role = roleFactory.buildWithId({ name: RoleName.TEACHER }); const teacherUser: User = userFactory.buildWithId({ school, roles: [teacherRole] }); - const system: SystemEntity = systemFactory.buildWithId(); + const system: SystemEntity = systemEntityFactory.buildWithId(); const schoolYear: SchoolYearEntity = schoolYearFactory.buildWithId(); const clazz: ClassEntity = classEntityFactory.buildWithId({ name: 'Group A', diff --git a/apps/server/src/modules/group/uc/group.uc.spec.ts b/apps/server/src/modules/group/uc/group.uc.spec.ts index 51cf6151d45..10cfc03821f 100644 --- a/apps/server/src/modules/group/uc/group.uc.spec.ts +++ b/apps/server/src/modules/group/uc/group.uc.spec.ts @@ -7,7 +7,7 @@ import { classFactory } from '@modules/class/domain/testing/factory/class.factor import { LegacySchoolService, SchoolYearService } from '@modules/legacy-school'; import { RoleService } from '@modules/role'; import { RoleDto } from '@modules/role/service/dto/role.dto'; -import { SystemDto, SystemService } from '@modules/system'; +import { LegacySystemService, SystemDto } from '@modules/system'; import { UserService } from '@modules/user'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; @@ -23,13 +23,13 @@ import { userDoFactory, userFactory, } from '@shared/testing'; +import { SchoolYearQueryType } from '../controller/dto/interface'; import { Group, GroupTypes } from '../domain'; +import { UnknownQueryTypeLoggableException } from '../loggable'; import { GroupService } from '../service'; import { ClassInfoDto, ResolvedGroupDto } from './dto'; import { ClassRootType } from './dto/class-root-type'; import { GroupUc } from './group.uc'; -import { SchoolYearQueryType } from '../controller/dto/interface'; -import { UnknownQueryTypeLoggableException } from '../loggable'; describe('GroupUc', () => { let module: TestingModule; @@ -37,7 +37,7 @@ describe('GroupUc', () => { let groupService: DeepMocked; let classService: DeepMocked; - let systemService: DeepMocked; + let systemService: DeepMocked; let userService: DeepMocked; let roleService: DeepMocked; let schoolService: DeepMocked; @@ -57,8 +57,8 @@ describe('GroupUc', () => { useValue: createMock(), }, { - provide: SystemService, - useValue: createMock(), + provide: LegacySystemService, + useValue: createMock(), }, { provide: UserService, @@ -86,7 +86,7 @@ describe('GroupUc', () => { uc = module.get(GroupUc); groupService = module.get(GroupService); classService = module.get(ClassService); - systemService = module.get(SystemService); + systemService = module.get(LegacySystemService); userService = module.get(UserService); roleService = module.get(RoleService); schoolService = module.get(LegacySchoolService); diff --git a/apps/server/src/modules/group/uc/group.uc.ts b/apps/server/src/modules/group/uc/group.uc.ts index f7399fa2fc9..2de4d9d5a2c 100644 --- a/apps/server/src/modules/group/uc/group.uc.ts +++ b/apps/server/src/modules/group/uc/group.uc.ts @@ -4,24 +4,24 @@ import { Class } from '@modules/class/domain'; import { LegacySchoolService, SchoolYearService } from '@modules/legacy-school'; import { RoleService } from '@modules/role'; import { RoleDto } from '@modules/role/service/dto/role.dto'; -import { SystemDto, SystemService } from '@modules/system'; +import { LegacySystemService, SystemDto } from '@modules/system'; import { UserService } from '@modules/user'; import { Injectable } from '@nestjs/common'; import { EntityId, LegacySchoolDo, Page, Permission, SchoolYearEntity, SortOrder, User, UserDO } from '@shared/domain'; +import { SchoolYearQueryType } from '../controller/dto/interface'; import { Group, GroupUser } from '../domain'; +import { UnknownQueryTypeLoggableException } from '../loggable'; import { GroupService } from '../service'; import { SortHelper } from '../util'; import { ClassInfoDto, ResolvedGroupDto, ResolvedGroupUser } from './dto'; import { GroupUcMapper } from './mapper/group-uc.mapper'; -import { SchoolYearQueryType } from '../controller/dto/interface'; -import { UnknownQueryTypeLoggableException } from '../loggable'; @Injectable() export class GroupUc { constructor( private readonly groupService: GroupService, private readonly classService: ClassService, - private readonly systemService: SystemService, + private readonly systemService: LegacySystemService, private readonly userService: UserService, private readonly roleService: RoleService, private readonly schoolService: LegacySchoolService, diff --git a/apps/server/src/modules/management/seed-data/systems.ts b/apps/server/src/modules/management/seed-data/systems.ts index a9140458f6d..7aca67d896d 100644 --- a/apps/server/src/modules/management/seed-data/systems.ts +++ b/apps/server/src/modules/management/seed-data/systems.ts @@ -1,10 +1,10 @@ /* eslint-disable no-template-curly-in-string */ -import { SystemProperties } from '@shared/domain'; +import { SystemEntityProps } from '@shared/domain'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { systemFactory } from '@shared/testing'; +import { systemEntityFactory } from '@shared/testing'; import { DeepPartial } from 'fishery'; -type SystemPartial = DeepPartial & { +type SystemPartial = DeepPartial & { id?: string; createdAt?: string; updatedAt?: string; @@ -66,7 +66,7 @@ const data: SystemPartial[] = [ export function generateSystems(injectEnvVars: (s: string) => string) { const systems = data.map((d) => { d = JSON.parse(injectEnvVars(JSON.stringify(d))) as typeof d; - const params: DeepPartial = { + const params: DeepPartial = { alias: d.alias, displayName: d.displayName, type: d.type, @@ -77,7 +77,7 @@ export function generateSystems(injectEnvVars: (s: string) => string) { provisioningUrl: d.provisioningUrl, url: d.url, }; - const system = systemFactory.buildWithId(params, d.id); + const system = systemEntityFactory.buildWithId(params, d.id); if (d.createdAt) system.createdAt = new Date(d.createdAt); if (d.updatedAt) system.updatedAt = new Date(d.updatedAt); diff --git a/apps/server/src/modules/oauth/service/hydra.service.spec.ts b/apps/server/src/modules/oauth/service/hydra.service.spec.ts index 2dc2a22a6ce..37db961eb9d 100644 --- a/apps/server/src/modules/oauth/service/hydra.service.spec.ts +++ b/apps/server/src/modules/oauth/service/hydra.service.spec.ts @@ -1,18 +1,18 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { DefaultEncryptionService, SymetricKeyEncryptionService } from '@infra/encryption'; import { ObjectId } from '@mikro-orm/mongodb'; +import { CookiesDto } from '@modules/oauth/service/dto/cookies.dto'; +import { HydraRedirectDto } from '@modules/oauth/service/dto/hydra.redirect.dto'; +import { HydraSsoService } from '@modules/oauth/service/hydra.service'; import { HttpService } from '@nestjs/axios'; import { InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { LtiPrivacyPermission, LtiRoleType, OauthConfig } from '@shared/domain'; +import { LtiPrivacyPermission, LtiRoleType, OauthConfigEntity } from '@shared/domain'; import { LtiToolDO } from '@shared/domain/domainobject/ltitool.do'; -import { DefaultEncryptionService, SymetricKeyEncryptionService } from '@infra/encryption'; import { LtiToolRepo } from '@shared/repo'; import { axiosResponseFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { CookiesDto } from '@modules/oauth/service/dto/cookies.dto'; -import { HydraRedirectDto } from '@modules/oauth/service/dto/hydra.redirect.dto'; -import { HydraSsoService } from '@modules/oauth/service/hydra.service'; import { AxiosResponse } from 'axios'; import { of } from 'rxjs'; import { StatelessAuthorizationParams } from '../controller/dto/stateless-authorization.params'; @@ -47,7 +47,7 @@ describe('HydraService', () => { const scopes = 'openid uuid'; const apiHost = 'localhost'; - const oauthConfig: OauthConfig = new OauthConfig({ + const oauthConfig: OauthConfigEntity = new OauthConfigEntity({ clientId: '12345', clientSecret: 'mocksecret', tokenEndpoint: `${hydraUri}/oauth2/token`, @@ -242,7 +242,7 @@ describe('HydraService', () => { ltiToolRepo.findByOauthClientId.mockResolvedValue(ltiToolDoMock); // Act - const result: OauthConfig = await service.generateConfig(oauthConfig.clientId); + const result: OauthConfigEntity = await service.generateConfig(oauthConfig.clientId); // Assert expect(result).toEqual(oauthConfig); diff --git a/apps/server/src/modules/oauth/service/hydra.service.ts b/apps/server/src/modules/oauth/service/hydra.service.ts index 9c02a537d42..a74e1936af8 100644 --- a/apps/server/src/modules/oauth/service/hydra.service.ts +++ b/apps/server/src/modules/oauth/service/hydra.service.ts @@ -6,7 +6,7 @@ import { HydraRedirectDto } from '@modules/oauth/service/dto/hydra.redirect.dto' import { HttpService } from '@nestjs/axios'; import { Inject, InternalServerErrorException } from '@nestjs/common'; import { Injectable } from '@nestjs/common/decorators/core/injectable.decorator'; -import { OauthConfig } from '@shared/domain'; +import { OauthConfigEntity } from '@shared/domain'; import { LtiToolDO } from '@shared/domain/domainobject/ltitool.do'; import { LtiToolRepo } from '@shared/repo'; import { LegacyLogger } from '@src/core/logger'; @@ -26,7 +26,7 @@ export class HydraSsoService { private readonly HOST: string = Configuration.get('HOST') as string; - async initAuth(oauthConfig: OauthConfig, axiosConfig: AxiosRequestConfig): Promise { + async initAuth(oauthConfig: OauthConfigEntity, axiosConfig: AxiosRequestConfig): Promise { const query = QueryString.stringify({ response_type: oauthConfig.responseType, scope: oauthConfig.scope, @@ -96,7 +96,7 @@ export class HydraSsoService { return cookiesDto; } - async generateConfig(oauthClientId: string): Promise { + async generateConfig(oauthClientId: string): Promise { const tool: LtiToolDO = await this.ltiRepo.findByOauthClientId(oauthClientId); // Needs to be checked, because the fields can be undefined @@ -105,7 +105,7 @@ export class HydraSsoService { } const hydraUri: string = Configuration.get('HYDRA_PUBLIC_URI') as string; - const hydraOauthConfig = new OauthConfig({ + const hydraOauthConfig = new OauthConfigEntity({ authEndpoint: `${hydraUri}/oauth2/auth`, clientId: tool.oAuthClientId, clientSecret: this.oAuthEncryptionService.encrypt(tool.secret), diff --git a/apps/server/src/modules/oauth/service/oauth.service.spec.ts b/apps/server/src/modules/oauth/service/oauth.service.spec.ts index 368586af7c4..390ee95c5e5 100644 --- a/apps/server/src/modules/oauth/service/oauth.service.spec.ts +++ b/apps/server/src/modules/oauth/service/oauth.service.spec.ts @@ -6,16 +6,16 @@ import { LegacySchoolService } from '@modules/legacy-school'; import { ProvisioningService } from '@modules/provisioning'; import { OauthConfigDto } from '@modules/system/service'; import { SystemDto } from '@modules/system/service/dto/system.dto'; -import { SystemService } from '@modules/system/service/system.service'; import { UserService } from '@modules/user'; import { MigrationCheckService } from '@modules/user-login-migration'; import { Test, TestingModule } from '@nestjs/testing'; -import { LegacySchoolDo, OauthConfig, SchoolFeatures, SystemEntity, UserDO } from '@shared/domain'; +import { LegacySchoolDo, OauthConfigEntity, SchoolFeatures, SystemEntity, UserDO } from '@shared/domain'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { legacySchoolDoFactory, setupEntities, systemFactory, userDoFactory } from '@shared/testing'; +import { legacySchoolDoFactory, setupEntities, systemEntityFactory, userDoFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { OauthDataDto } from '@src/modules/provisioning/dto'; import jwt, { JwtPayload } from 'jsonwebtoken'; +import { LegacySystemService } from '../../system/service/legacy-system.service'; import { OAuthTokenDto } from '../interface'; import { OAuthSSOError, UserNotFoundAfterProvisioningLoggableException } from '../loggable'; import { OauthTokenResponse } from './dto'; @@ -44,13 +44,13 @@ describe('OAuthService', () => { let oAuthEncryptionService: DeepMocked; let provisioningService: DeepMocked; let userService: DeepMocked; - let systemService: DeepMocked; + let systemService: DeepMocked; let oauthAdapterService: DeepMocked; let migrationCheckService: DeepMocked; let schoolService: DeepMocked; let testSystem: SystemEntity; - let testOauthConfig: OauthConfig; + let testOauthConfig: OauthConfigEntity; const hostUri = 'https://mock.de'; @@ -81,8 +81,8 @@ describe('OAuthService', () => { useValue: createMock(), }, { - provide: SystemService, - useValue: createMock(), + provide: LegacySystemService, + useValue: createMock(), }, { provide: OauthAdapterService, @@ -99,7 +99,7 @@ describe('OAuthService', () => { oAuthEncryptionService = module.get(DefaultEncryptionService); provisioningService = module.get(ProvisioningService); userService = module.get(UserService); - systemService = module.get(SystemService); + systemService = module.get(LegacySystemService); oauthAdapterService = module.get(OauthAdapterService); migrationCheckService = module.get(MigrationCheckService); schoolService = module.get(LegacySchoolService); @@ -124,8 +124,8 @@ describe('OAuthService', () => { } }); - testSystem = systemFactory.withOauthConfig().buildWithId(); - testOauthConfig = testSystem.oauthConfig as OauthConfig; + testSystem = systemEntityFactory.withOauthConfig().buildWithId(); + testOauthConfig = testSystem.oauthConfig as OauthConfigEntity; }); describe('requestToken', () => { diff --git a/apps/server/src/modules/oauth/service/oauth.service.ts b/apps/server/src/modules/oauth/service/oauth.service.ts index 299198ef33a..5f13511766c 100644 --- a/apps/server/src/modules/oauth/service/oauth.service.ts +++ b/apps/server/src/modules/oauth/service/oauth.service.ts @@ -1,13 +1,13 @@ import { DefaultEncryptionService, EncryptionService } from '@infra/encryption'; import { LegacySchoolService } from '@modules/legacy-school'; import { OauthDataDto, ProvisioningService } from '@modules/provisioning'; -import { SystemService } from '@modules/system'; +import { LegacySystemService } from '@modules/system'; import { SystemDto } from '@modules/system/service'; import { UserService } from '@modules/user'; import { MigrationCheckService } from '@modules/user-login-migration'; import { Inject } from '@nestjs/common'; import { Injectable } from '@nestjs/common/decorators/core/injectable.decorator'; -import { EntityId, LegacySchoolDo, OauthConfig, SchoolFeatures, UserDO } from '@shared/domain'; +import { EntityId, LegacySchoolDo, OauthConfigEntity, SchoolFeatures, UserDO } from '@shared/domain'; import { LegacyLogger } from '@src/core/logger'; import jwt, { JwtPayload } from 'jsonwebtoken'; import { OAuthTokenDto } from '../interface'; @@ -24,7 +24,7 @@ export class OAuthService { @Inject(DefaultEncryptionService) private readonly oAuthEncryptionService: EncryptionService, private readonly logger: LegacyLogger, private readonly provisioningService: ProvisioningService, - private readonly systemService: SystemService, + private readonly systemService: LegacySystemService, private readonly migrationCheckService: MigrationCheckService, private readonly schoolService: LegacySchoolService ) { @@ -118,7 +118,7 @@ export class OAuthService { return !!school.features?.includes(SchoolFeatures.OAUTH_PROVISIONING_ENABLED); } - async requestToken(code: string, oauthConfig: OauthConfig, redirectUri: string): Promise { + async requestToken(code: string, oauthConfig: OauthConfigEntity, redirectUri: string): Promise { const payload: AuthenticationCodeGrantTokenRequest = this.buildTokenRequestPayload(code, oauthConfig, redirectUri); const responseToken: OauthTokenResponse = await this.oauthAdapterService.sendAuthenticationCodeTokenRequest( @@ -130,7 +130,7 @@ export class OAuthService { return tokenDto; } - async validateToken(idToken: string, oauthConfig: OauthConfig): Promise { + async validateToken(idToken: string, oauthConfig: OauthConfigEntity): Promise { const publicKey: string = await this.oauthAdapterService.getPublicKey(oauthConfig.jwksEndpoint); const decodedJWT: string | JwtPayload = jwt.verify(idToken, publicKey, { algorithms: ['RS256'], @@ -147,7 +147,7 @@ export class OAuthService { private buildTokenRequestPayload( code: string, - oauthConfig: OauthConfig, + oauthConfig: OauthConfigEntity, redirectUri: string ): AuthenticationCodeGrantTokenRequest { const decryptedClientSecret: string = this.oAuthEncryptionService.decrypt(oauthConfig.clientSecret); diff --git a/apps/server/src/modules/oauth/uc/hydra-oauth.uc.spec.ts b/apps/server/src/modules/oauth/uc/hydra-oauth.uc.spec.ts index b889995b9e5..e66be61ef83 100644 --- a/apps/server/src/modules/oauth/uc/hydra-oauth.uc.spec.ts +++ b/apps/server/src/modules/oauth/uc/hydra-oauth.uc.spec.ts @@ -1,19 +1,19 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { HydraSsoService, OAuthService } from '@modules/oauth'; +import { HydraRedirectDto } from '@modules/oauth/service/dto/hydra.redirect.dto'; import { HttpModule } from '@nestjs/axios'; import { InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { OauthConfig } from '@shared/domain'; +import { OauthConfigEntity } from '@shared/domain'; import { axiosResponseFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { HydraRedirectDto } from '@modules/oauth/service/dto/hydra.redirect.dto'; -import { OAuthService, HydraSsoService } from '@modules/oauth'; import { AxiosResponse } from 'axios'; import { HydraOauthUc } from '.'; import { AuthorizationParams } from '../controller/dto'; import { StatelessAuthorizationParams } from '../controller/dto/stateless-authorization.params'; -import { OAuthSSOError } from '../loggable'; import { OAuthTokenDto } from '../interface'; +import { OAuthSSOError } from '../loggable'; class HydraOauthUcSpec extends HydraOauthUc { public validateStatusSpec = (status: number) => this.validateStatus(status); @@ -40,7 +40,7 @@ describe('HydraOauthUc', () => { const hydraUri = 'hydraUri'; const apiHost = 'apiHost'; const nextcloudScopes = 'nextcloudscope'; - const hydraOauthConfig = new OauthConfig({ + const hydraOauthConfig = new OauthConfigEntity({ authEndpoint: `${hydraUri}/oauth2/auth`, clientId: 'toolClientId', clientSecret: 'toolSecret', diff --git a/apps/server/src/modules/oauth/uc/hydra-oauth.uc.ts b/apps/server/src/modules/oauth/uc/hydra-oauth.uc.ts index 2c461e6db4d..258fb2fb7d5 100644 --- a/apps/server/src/modules/oauth/uc/hydra-oauth.uc.ts +++ b/apps/server/src/modules/oauth/uc/hydra-oauth.uc.ts @@ -1,6 +1,6 @@ import { HydraRedirectDto } from '@modules/oauth/service/dto/hydra.redirect.dto'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; -import { OauthConfig } from '@shared/domain'; +import { OauthConfigEntity } from '@shared/domain'; import { LegacyLogger } from '@src/core/logger'; import { AxiosRequestConfig, AxiosResponse } from 'axios'; import { AuthorizationParams } from '../controller/dto'; @@ -28,7 +28,7 @@ export class HydraOauthUc { error || 'sso_auth_code_step' ); } - const hydraOauthConfig: OauthConfig = await this.hydraSsoService.generateConfig(oauthClientId); + const hydraOauthConfig: OauthConfigEntity = await this.hydraSsoService.generateConfig(oauthClientId); const oauthTokens: OAuthTokenDto = await this.oauthService.requestToken( code, @@ -44,7 +44,7 @@ export class HydraOauthUc { protected validateStatus = (status: number): boolean => status === 200 || status === 302; async requestAuthCode(userId: string, jwt: string, oauthClientId: string): Promise { - const hydraOauthConfig: OauthConfig = await this.hydraSsoService.generateConfig(oauthClientId); + const hydraOauthConfig: OauthConfigEntity = await this.hydraSsoService.generateConfig(oauthClientId); const axiosConfig: AxiosRequestConfig = { headers: {}, withCredentials: true, diff --git a/apps/server/src/modules/provisioning/service/provisioning.service.spec.ts b/apps/server/src/modules/provisioning/service/provisioning.service.spec.ts index 1d80c6c7b90..1664c649b10 100644 --- a/apps/server/src/modules/provisioning/service/provisioning.service.spec.ts +++ b/apps/server/src/modules/provisioning/service/provisioning.service.spec.ts @@ -1,9 +1,9 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { SystemDto } from '@modules/system/service/dto/system.dto'; import { InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { SystemDto } from '@modules/system/service/dto/system.dto'; -import { SystemService } from '@modules/system/service/system.service'; +import { LegacySystemService } from '../../system/service/legacy-system.service'; import { ExternalUserDto, OauthDataDto, @@ -18,7 +18,7 @@ describe('ProvisioningService', () => { let module: TestingModule; let service: ProvisioningService; - let systemService: DeepMocked; + let systemService: DeepMocked; let provisioningStrategy: DeepMocked; beforeAll(async () => { @@ -26,8 +26,8 @@ describe('ProvisioningService', () => { providers: [ ProvisioningService, { - provide: SystemService, - useValue: createMock(), + provide: LegacySystemService, + useValue: createMock(), }, { provide: SanisProvisioningStrategy, @@ -57,7 +57,7 @@ describe('ProvisioningService', () => { }).compile(); service = module.get(ProvisioningService); - systemService = module.get(SystemService); + systemService = module.get(LegacySystemService); provisioningStrategy = module.get(SanisProvisioningStrategy); }); diff --git a/apps/server/src/modules/provisioning/service/provisioning.service.ts b/apps/server/src/modules/provisioning/service/provisioning.service.ts index 50ee527001c..8f7330645b5 100644 --- a/apps/server/src/modules/provisioning/service/provisioning.service.ts +++ b/apps/server/src/modules/provisioning/service/provisioning.service.ts @@ -1,7 +1,7 @@ +import { LegacySystemService } from '@modules/system'; +import { SystemDto } from '@modules/system/service/dto/system.dto'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { SystemService } from '@modules/system'; -import { SystemDto } from '@modules/system/service/dto/system.dto'; import { OauthDataDto, OauthDataStrategyInputDto, ProvisioningDto, ProvisioningSystemDto } from '../dto'; import { ProvisioningSystemInputMapper } from '../mapper/provisioning-system-input.mapper'; import { @@ -19,7 +19,7 @@ export class ProvisioningService { >(); constructor( - private readonly systemService: SystemService, + private readonly systemService: LegacySystemService, private readonly sanisStrategy: SanisProvisioningStrategy, private readonly iservStrategy: IservProvisioningStrategy, private readonly oidcMockStrategy: OidcMockProvisioningStrategy diff --git a/apps/server/src/modules/system/controller/api-test/system.api.spec.ts b/apps/server/src/modules/system/controller/api-test/system.api.spec.ts index 3167558461d..a449db0ffc9 100644 --- a/apps/server/src/modules/system/controller/api-test/system.api.spec.ts +++ b/apps/server/src/modules/system/controller/api-test/system.api.spec.ts @@ -1,54 +1,40 @@ -import { EntityManager } from '@mikro-orm/mongodb'; -import { ICurrentUser } from '@modules/authentication'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { ServerTestModule } from '@modules/server'; -import { ExecutionContext, INestApplication } from '@nestjs/common'; +import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { OauthConfig, SystemEntity } from '@shared/domain'; -import { cleanupCollections, systemFactory } from '@shared/testing'; -import { Request } from 'express'; -import request, { Response } from 'supertest'; -import { PublicSystemListResponse } from '../dto/public-system-list.response'; -import { PublicSystemResponse } from '../dto/public-system-response'; +import { OauthConfigEntity, SchoolEntity, SystemEntity } from '@shared/domain'; +import { schoolFactory, systemEntityFactory, TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; +import { Response } from 'supertest'; +import { PublicSystemListResponse, PublicSystemResponse } from '../dto'; + +const baseRouteName = '/systems'; describe('System (API)', () => { let app: INestApplication; let em: EntityManager; - let currentUser: ICurrentUser; + let testApiClient: TestApiClient; beforeAll(async () => { - const moduleRef: TestingModule = await Test.createTestingModule({ + const module: TestingModule = await Test.createTestingModule({ imports: [ServerTestModule], - }) - .overrideGuard(JwtAuthGuard) - .useValue({ - canActivate(context: ExecutionContext) { - const req: Request = context.switchToHttp().getRequest(); - req.user = currentUser; - return true; - }, - }) - .compile(); - - app = moduleRef.createNestApplication(); + }).compile(); + + app = module.createNestApplication(); await app.init(); - em = app.get(EntityManager); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, baseRouteName); }); afterAll(async () => { await app.close(); }); - afterEach(async () => { - await cleanupCollections(em); - }); - describe('[GET] systems/public', () => { describe('when the endpoint is called', () => { const setup = async () => { - const system1: SystemEntity = systemFactory.buildWithId(); - const system2: SystemEntity = systemFactory.withOauthConfig().buildWithId(); - const system2OauthConfig: OauthConfig = system2.oauthConfig as OauthConfig; + const system1: SystemEntity = systemEntityFactory.buildWithId(); + const system2: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId(); + const system2OauthConfig: OauthConfigEntity = system2.oauthConfig as OauthConfigEntity; await em.persistAndFlush([system1, system2]); em.clear(); @@ -59,7 +45,7 @@ describe('System (API)', () => { it('should return a list of all systems', async () => { const { system1, system2, system2OauthConfig } = await setup(); - const response: Response = await request(app.getHttpServer()).get(`/systems/public`).expect(200); + const response: Response = await testApiClient.get(`/public`).expect(200); expect(response.body).toEqual({ data: [ @@ -98,8 +84,8 @@ describe('System (API)', () => { describe('[GET] systems/public/:systemId', () => { describe('when the endpoint is called with a known systemId', () => { const setup = async () => { - const system1: SystemEntity = systemFactory.buildWithId(); - const system2: SystemEntity = systemFactory.buildWithId(); + const system1: SystemEntity = systemEntityFactory.buildWithId(); + const system2: SystemEntity = systemEntityFactory.buildWithId(); await em.persistAndFlush([system1, system2]); em.clear(); @@ -110,7 +96,7 @@ describe('System (API)', () => { it('should return the system', async () => { const { system1 } = await setup(); - const response: Response = await request(app.getHttpServer()).get(`/systems/public/${system1.id}`).expect(200); + const response: Response = await testApiClient.get(`/public/${system1.id}`).expect(200); expect(response.body).toEqual({ id: system1.id, @@ -121,4 +107,41 @@ describe('System (API)', () => { }); }); }); + + describe('[DELETE] systems/:systemId', () => { + describe('when the endpoint is called with a known systemId', () => { + const setup = async () => { + const system: SystemEntity = systemEntityFactory.withLdapConfig({ provider: 'general' }).buildWithId(); + const school: SchoolEntity = schoolFactory.build({ systems: [system] }); + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school }); + + await em.persistAndFlush([system, adminAccount, adminUser, school]); + em.clear(); + + const adminClient = await testApiClient.login(adminAccount); + + return { + system, + adminClient, + }; + }; + + it('should delete the system', async () => { + const { system, adminClient } = await setup(); + + const response = await adminClient.delete(system.id); + + expect(response.status).toEqual(HttpStatus.NO_CONTENT); + expect(await em.findOne(SystemEntity, { id: system.id })).toBeNull(); + }); + }); + + describe('when not authenticated', () => { + it('should return unauthorized', async () => { + const response = await testApiClient.delete(new ObjectId().toHexString()); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + }); }); diff --git a/apps/server/src/modules/system/controller/dto/index.ts b/apps/server/src/modules/system/controller/dto/index.ts new file mode 100644 index 00000000000..9ff34a1b421 --- /dev/null +++ b/apps/server/src/modules/system/controller/dto/index.ts @@ -0,0 +1,5 @@ +export { SystemIdParams } from './system-id.params'; +export { SystemFilterParams } from './system.filter.params'; +export { OauthConfigResponse } from './oauth-config.response'; +export { PublicSystemResponse } from './public-system-response'; +export { PublicSystemListResponse } from './public-system-list.response'; diff --git a/apps/server/src/modules/system/controller/system.controller.ts b/apps/server/src/modules/system/controller/system.controller.ts index bbeca71b83c..6eabcc53762 100644 --- a/apps/server/src/modules/system/controller/system.controller.ts +++ b/apps/server/src/modules/system/controller/system.controller.ts @@ -1,11 +1,9 @@ -import { Controller, Get, Param, Query } from '@nestjs/common'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { SystemFilterParams } from '@modules/system/controller/dto/system.filter.params'; +import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { Controller, Delete, Get, HttpCode, HttpStatus, Param, Query } from '@nestjs/common'; +import { ApiForbiddenResponse, ApiOperation, ApiResponse, ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger'; import { SystemDto } from '../service'; import { SystemUc } from '../uc/system.uc'; -import { PublicSystemListResponse } from './dto/public-system-list.response'; -import { PublicSystemResponse } from './dto/public-system-response'; -import { SystemIdParams } from './dto/system-id.params'; +import { PublicSystemListResponse, PublicSystemResponse, SystemFilterParams, SystemIdParams } from './dto'; import { SystemResponseMapper } from './mapper/system-response.mapper'; @ApiTags('Systems') @@ -42,4 +40,14 @@ export class SystemController { return mapped; } + + @Authenticate('jwt') + @Delete(':systemId') + @ApiForbiddenResponse() + @ApiUnauthorizedResponse() + @ApiOperation({ summary: 'Deletes a system.' }) + @HttpCode(HttpStatus.NO_CONTENT) + async deleteSystem(@CurrentUser() currentUser: ICurrentUser, @Param() params: SystemIdParams): Promise { + await this.systemUc.delete(currentUser.userId, params.systemId); + } } diff --git a/apps/server/src/modules/system/domain/index.ts b/apps/server/src/modules/system/domain/index.ts new file mode 100644 index 00000000000..16e2a044335 --- /dev/null +++ b/apps/server/src/modules/system/domain/index.ts @@ -0,0 +1,3 @@ +export { System, SystemProps } from './system.do'; +export { LdapConfig } from './ldap-config'; +export { OauthConfig } from './oauth-config'; diff --git a/apps/server/src/modules/system/domain/ldap-config.ts b/apps/server/src/modules/system/domain/ldap-config.ts new file mode 100644 index 00000000000..137d1fc92f6 --- /dev/null +++ b/apps/server/src/modules/system/domain/ldap-config.ts @@ -0,0 +1,13 @@ +export class LdapConfig { + active: boolean; + + url: string; + + provider?: string; + + constructor(props: LdapConfig) { + this.active = props.active; + this.url = props.url; + this.provider = props.provider; + } +} diff --git a/apps/server/src/modules/system/domain/oauth-config.ts b/apps/server/src/modules/system/domain/oauth-config.ts new file mode 100644 index 00000000000..165691a0797 --- /dev/null +++ b/apps/server/src/modules/system/domain/oauth-config.ts @@ -0,0 +1,46 @@ +export class OauthConfig { + clientId: string; + + clientSecret: string; + + idpHint?: string; + + redirectUri: string; + + grantType: string; + + tokenEndpoint: string; + + authEndpoint: string; + + responseType: string; + + scope: string; + + provider: string; + + /** + * If this is set it will be used to redirect the user after login to the logout endpoint of the identity provider. + */ + logoutEndpoint?: string; + + issuer: string; + + jwksEndpoint: string; + + constructor(oauthConfigDto: OauthConfig) { + this.clientId = oauthConfigDto.clientId; + this.clientSecret = oauthConfigDto.clientSecret; + this.idpHint = oauthConfigDto.idpHint; + this.redirectUri = oauthConfigDto.redirectUri; + this.grantType = oauthConfigDto.grantType; + this.tokenEndpoint = oauthConfigDto.tokenEndpoint; + this.authEndpoint = oauthConfigDto.authEndpoint; + this.responseType = oauthConfigDto.responseType; + this.scope = oauthConfigDto.scope; + this.provider = oauthConfigDto.provider; + this.logoutEndpoint = oauthConfigDto.logoutEndpoint; + this.issuer = oauthConfigDto.issuer; + this.jwksEndpoint = oauthConfigDto.jwksEndpoint; + } +} diff --git a/apps/server/src/modules/system/domain/system.do.ts b/apps/server/src/modules/system/domain/system.do.ts new file mode 100644 index 00000000000..f909d3196e9 --- /dev/null +++ b/apps/server/src/modules/system/domain/system.do.ts @@ -0,0 +1,28 @@ +import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; +import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { LdapConfig } from './ldap-config'; +import { OauthConfig } from './oauth-config'; + +export interface SystemProps extends AuthorizableObject { + type: string; + + url?: string; + + alias?: string; + + displayName?: string; + + provisioningStrategy?: SystemProvisioningStrategy; + + provisioningUrl?: string; + + oauthConfig?: OauthConfig; + + ldapConfig?: LdapConfig; +} + +export class System extends DomainObject { + get ldapConfig(): LdapConfig | undefined { + return this.props.ldapConfig; + } +} diff --git a/apps/server/src/modules/system/index.ts b/apps/server/src/modules/system/index.ts index 2e912fd9246..9cedfa41938 100644 --- a/apps/server/src/modules/system/index.ts +++ b/apps/server/src/modules/system/index.ts @@ -1,2 +1,3 @@ export * from './system.module'; -export * from './service'; +export { SystemService, LegacySystemService, SystemDto, OauthConfigDto, OidcConfigDto } from './service'; +export { System, SystemProps, OauthConfig, LdapConfig } from './domain'; diff --git a/apps/server/src/modules/system/mapper/index.ts b/apps/server/src/modules/system/mapper/index.ts new file mode 100644 index 00000000000..9dc568e504c --- /dev/null +++ b/apps/server/src/modules/system/mapper/index.ts @@ -0,0 +1,2 @@ +export { SystemMapper } from './system.mapper'; +export { SystemOidcMapper } from './system-oidc.mapper'; diff --git a/apps/server/src/modules/system/mapper/system-oidc.mapper.spec.ts b/apps/server/src/modules/system/mapper/system-oidc.mapper.spec.ts index 2486d4a1872..0b230c87eed 100644 --- a/apps/server/src/modules/system/mapper/system-oidc.mapper.spec.ts +++ b/apps/server/src/modules/system/mapper/system-oidc.mapper.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SystemEntity } from '@shared/domain'; -import { systemFactory } from '@shared/testing'; -import { SystemOidcMapper } from '@modules/system/mapper/system-oidc.mapper'; +import { systemEntityFactory } from '@shared/testing'; +import { SystemOidcMapper } from './system-oidc.mapper'; describe('SystemOidcMapper', () => { let module: TestingModule; @@ -18,7 +18,7 @@ describe('SystemOidcMapper', () => { describe('mapFromEntityToDto', () => { it('should map all fields', () => { - const systemEntity = systemFactory.withOauthConfig().withOidcConfig().build(); + const systemEntity = systemEntityFactory.withOauthConfig().withOidcConfig().build(); const result = SystemOidcMapper.mapFromEntityToDto(systemEntity); expect(result).toBeDefined(); @@ -34,7 +34,7 @@ describe('SystemOidcMapper', () => { expect(result?.defaultScopes).toEqual(systemEntity.oidcConfig?.defaultScopes); }); it('should return undefined if parent system has no oidc config', () => { - const systemEntity = systemFactory.withOauthConfig().build(); + const systemEntity = systemEntityFactory.withOauthConfig().build(); const result = SystemOidcMapper.mapFromEntityToDto(systemEntity); expect(result).toBeUndefined(); }); @@ -43,8 +43,8 @@ describe('SystemOidcMapper', () => { describe('mapFromEntitiesToDtos', () => { it('should map all given entities', () => { const systemEntities: SystemEntity[] = [ - systemFactory.withOidcConfig().build(), - systemFactory.withOidcConfig().build(), + systemEntityFactory.withOidcConfig().build(), + systemEntityFactory.withOidcConfig().build(), ]; const result = SystemOidcMapper.mapFromEntitiesToDtos(systemEntities); @@ -53,8 +53,8 @@ describe('SystemOidcMapper', () => { }); it('should map oidc config only config if exists', () => { - const systemEntity = systemFactory.withOidcConfig().build(); - const systemEntities: SystemEntity[] = [systemEntity, systemFactory.withOauthConfig().build()]; + const systemEntity = systemEntityFactory.withOidcConfig().build(); + const systemEntities: SystemEntity[] = [systemEntity, systemEntityFactory.withOauthConfig().build()]; const results = SystemOidcMapper.mapFromEntitiesToDtos(systemEntities); diff --git a/apps/server/src/modules/system/mapper/system-oidc.mapper.ts b/apps/server/src/modules/system/mapper/system-oidc.mapper.ts index 8726ce09a78..e486ae458fd 100644 --- a/apps/server/src/modules/system/mapper/system-oidc.mapper.ts +++ b/apps/server/src/modules/system/mapper/system-oidc.mapper.ts @@ -1,5 +1,5 @@ -import { OidcConfig, SystemEntity } from '@shared/domain'; import { OidcConfigDto } from '@modules/system/service/dto/oidc-config.dto'; +import { OidcConfigEntity, SystemEntity } from '@shared/domain'; export class SystemOidcMapper { static mapFromEntityToDto(entity: SystemEntity): OidcConfigDto | undefined { @@ -9,7 +9,7 @@ export class SystemOidcMapper { return undefined; } - static mapFromOidcConfigEntityToDto(systemId: string, oidcConfig: OidcConfig): OidcConfigDto { + static mapFromOidcConfigEntityToDto(systemId: string, oidcConfig: OidcConfigEntity): OidcConfigDto { return new OidcConfigDto({ parentSystemId: systemId, clientId: oidcConfig.clientId, diff --git a/apps/server/src/modules/system/mapper/system.mapper.spec.ts b/apps/server/src/modules/system/mapper/system.mapper.spec.ts index 54c20cc0cff..8bbf4986d9e 100644 --- a/apps/server/src/modules/system/mapper/system.mapper.spec.ts +++ b/apps/server/src/modules/system/mapper/system.mapper.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SystemEntity } from '@shared/domain'; -import { systemFactory } from '@shared/testing'; -import { SystemMapper } from '@modules/system/mapper/system.mapper'; +import { systemEntityFactory } from '@shared/testing'; +import { SystemMapper } from './system.mapper'; describe('SystemMapper', () => { let module: TestingModule; @@ -18,7 +18,7 @@ describe('SystemMapper', () => { describe('mapFromEntityToDto', () => { it('should map all fields', () => { - const systemEntity = systemFactory.withOauthConfig().withOidcConfig().build(); + const systemEntity = systemEntityFactory.withOauthConfig().withOidcConfig().build(); const result = SystemMapper.mapFromEntityToDto(systemEntity); @@ -32,7 +32,7 @@ describe('SystemMapper', () => { }); it('should map take alias as default instead of displayName', () => { // Arrange - const systemEntity = systemFactory.withOauthConfig().build(); + const systemEntity = systemEntityFactory.withOauthConfig().build(); systemEntity.displayName = undefined; // Act @@ -47,8 +47,8 @@ describe('SystemMapper', () => { describe('mapFromEntitiesToDtos', () => { it('should map all given entities', () => { const systemEntities: SystemEntity[] = [ - systemFactory.withOauthConfig().build(), - systemFactory.build({ oauthConfig: undefined }), + systemEntityFactory.withOauthConfig().build(), + systemEntityFactory.build({ oauthConfig: undefined }), ]; const result = SystemMapper.mapFromEntitiesToDtos(systemEntities); @@ -58,8 +58,8 @@ describe('SystemMapper', () => { it('should map oauth config if exists', () => { const systemEntities: SystemEntity[] = [ - systemFactory.withOauthConfig().build(), - systemFactory.build({ oauthConfig: undefined }), + systemEntityFactory.withOauthConfig().build(), + systemEntityFactory.build({ oauthConfig: undefined }), ]; const result = SystemMapper.mapFromEntitiesToDtos(systemEntities); diff --git a/apps/server/src/modules/system/mapper/system.mapper.ts b/apps/server/src/modules/system/mapper/system.mapper.ts index ae29fea67c8..420de9f2a69 100644 --- a/apps/server/src/modules/system/mapper/system.mapper.ts +++ b/apps/server/src/modules/system/mapper/system.mapper.ts @@ -1,6 +1,6 @@ -import { OauthConfig, SystemEntity } from '@shared/domain'; import { OauthConfigDto } from '@modules/system/service/dto/oauth-config.dto'; import { SystemDto } from '@modules/system/service/dto/system.dto'; +import { OauthConfigEntity, SystemEntity } from '@shared/domain'; export class SystemMapper { static mapFromEntityToDto(entity: SystemEntity): SystemDto { @@ -17,7 +17,7 @@ export class SystemMapper { }); } - static mapFromOauthConfigEntityToDto(oauthConfig: OauthConfig | undefined): OauthConfigDto | undefined { + static mapFromOauthConfigEntityToDto(oauthConfig: OauthConfigEntity | undefined): OauthConfigDto | undefined { if (!oauthConfig) return undefined; return new OauthConfigDto({ clientId: oauthConfig.clientId, diff --git a/apps/server/src/modules/system/repo/index.ts b/apps/server/src/modules/system/repo/index.ts new file mode 100644 index 00000000000..7bf41b20479 --- /dev/null +++ b/apps/server/src/modules/system/repo/index.ts @@ -0,0 +1 @@ +export { SystemRepo } from './system.repo'; diff --git a/apps/server/src/modules/system/repo/system-domain.mapper.ts b/apps/server/src/modules/system/repo/system-domain.mapper.ts new file mode 100644 index 00000000000..422f838c0ed --- /dev/null +++ b/apps/server/src/modules/system/repo/system-domain.mapper.ts @@ -0,0 +1,50 @@ +import { LdapConfigEntity, OauthConfigEntity, SystemEntity } from '@shared/domain'; +import { LdapConfig, OauthConfig, SystemProps } from '../domain'; + +export class SystemDomainMapper { + public static mapEntityToDomainObjectProperties(entity: SystemEntity): SystemProps { + const mapped: SystemProps = { + id: entity.id, + url: entity.url, + type: entity.type, + provisioningUrl: entity.provisioningUrl, + provisioningStrategy: entity.provisioningStrategy, + displayName: entity.displayName, + alias: entity.alias, + oauthConfig: entity.oauthConfig ? this.mapOauthConfigEntityToDomainObject(entity.oauthConfig) : undefined, + ldapConfig: entity.ldapConfig ? this.mapLdapConfigEntityToDomainObject(entity.ldapConfig) : undefined, + }; + + return mapped; + } + + private static mapOauthConfigEntityToDomainObject(oauthConfig: OauthConfigEntity): OauthConfig { + const mapped: OauthConfig = new OauthConfig({ + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + idpHint: oauthConfig.idpHint, + authEndpoint: oauthConfig.authEndpoint, + responseType: oauthConfig.responseType, + scope: oauthConfig.scope, + provider: oauthConfig.provider, + logoutEndpoint: oauthConfig.logoutEndpoint, + issuer: oauthConfig.issuer, + jwksEndpoint: oauthConfig.jwksEndpoint, + grantType: oauthConfig.grantType, + tokenEndpoint: oauthConfig.tokenEndpoint, + redirectUri: oauthConfig.redirectUri, + }); + + return mapped; + } + + private static mapLdapConfigEntityToDomainObject(ldapConfig: LdapConfigEntity): LdapConfig { + const mapped: LdapConfig = new LdapConfig({ + active: !!ldapConfig.active, + url: ldapConfig.url, + provider: ldapConfig.provider, + }); + + return mapped; + } +} diff --git a/apps/server/src/modules/system/repo/system.repo.spec.ts b/apps/server/src/modules/system/repo/system.repo.spec.ts new file mode 100644 index 00000000000..584035c0e46 --- /dev/null +++ b/apps/server/src/modules/system/repo/system.repo.spec.ts @@ -0,0 +1,177 @@ +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { LdapConfigEntity, OauthConfigEntity, SystemEntity } from '@shared/domain'; +import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { cleanupCollections, systemEntityFactory } from '@shared/testing'; +import { System, SystemProps } from '../domain'; +import { SystemDomainMapper } from './system-domain.mapper'; +import { SystemRepo } from './system.repo'; + +describe(SystemRepo.name, () => { + let module: TestingModule; + let repo: SystemRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot()], + providers: [SystemRepo], + }).compile(); + + repo = module.get(SystemRepo); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('findById', () => { + describe('when the system exists', () => { + const setup = async () => { + const oauthConfig = new OauthConfigEntity({ + clientId: '12345', + clientSecret: 'mocksecret', + idpHint: 'mock-oauth-idpHint', + tokenEndpoint: 'http://mock.de/mock/auth/public/mockToken', + grantType: 'authorization_code', + redirectUri: 'http://mockhost:3030/api/v3/sso/oauth/', + scope: 'openid uuid', + responseType: 'code', + authEndpoint: 'http://mock.de/auth', + provider: 'mock_type', + logoutEndpoint: 'http://mock.de/logout', + issuer: 'mock_issuer', + jwksEndpoint: 'http://mock.de/jwks', + }); + const ldapConfig = new LdapConfigEntity({ + url: 'ldaps:mock.de:389', + active: true, + provider: 'mock_provider', + }); + const system: SystemEntity = systemEntityFactory.buildWithId({ + type: 'oauth', + url: 'https://mock.de', + alias: 'alias', + displayName: 'displayName', + provisioningStrategy: SystemProvisioningStrategy.OIDC, + provisioningUrl: 'https://provisioningurl.de', + oauthConfig, + ldapConfig, + }); + + await em.persistAndFlush([system]); + em.clear(); + + return { + system, + oauthConfig, + ldapConfig, + }; + }; + + it('should return the system', async () => { + const { system, oauthConfig, ldapConfig } = await setup(); + + const result = await repo.findById(system.id); + + expect(result?.getProps()).toEqual({ + id: system.id, + type: system.type, + url: system.url, + displayName: system.displayName, + alias: system.alias, + provisioningStrategy: system.provisioningStrategy, + provisioningUrl: system.provisioningUrl, + oauthConfig: { + issuer: oauthConfig.issuer, + provider: oauthConfig.provider, + jwksEndpoint: oauthConfig.jwksEndpoint, + redirectUri: oauthConfig.redirectUri, + idpHint: oauthConfig.idpHint, + authEndpoint: oauthConfig.authEndpoint, + clientSecret: oauthConfig.clientSecret, + grantType: oauthConfig.grantType, + logoutEndpoint: oauthConfig.logoutEndpoint, + responseType: oauthConfig.responseType, + tokenEndpoint: oauthConfig.tokenEndpoint, + clientId: oauthConfig.clientId, + scope: oauthConfig.scope, + }, + ldapConfig: { + url: ldapConfig.url, + provider: ldapConfig.provider, + active: !!ldapConfig.active, + }, + }); + }); + }); + + describe('when the system does not exist', () => { + it('should return null', async () => { + const result = await repo.findById(new ObjectId().toHexString()); + + expect(result).toBeNull(); + }); + }); + }); + + describe('delete', () => { + describe('when the system exists', () => { + const setup = async () => { + const systemEntity: SystemEntity = systemEntityFactory.buildWithId(); + + await em.persistAndFlush([systemEntity]); + em.clear(); + + const props: SystemProps = SystemDomainMapper.mapEntityToDomainObjectProperties(systemEntity); + const system: System = new System(props); + + return { + system, + }; + }; + + it('should delete the system', async () => { + const { system } = await setup(); + + await repo.delete(system); + + expect(await em.findOne(SystemEntity, { id: system.id })).toBeNull(); + }); + + it('should return true', async () => { + const { system } = await setup(); + + const result = await repo.delete(system); + + expect(result).toEqual(true); + }); + }); + + describe('when the system does not exists', () => { + const setup = () => { + const systemEntity: SystemEntity = systemEntityFactory.buildWithId(); + const props: SystemProps = SystemDomainMapper.mapEntityToDomainObjectProperties(systemEntity); + const system: System = new System(props); + + return { + system, + }; + }; + + it('should return false', async () => { + const { system } = setup(); + + const result = await repo.delete(system); + + expect(result).toEqual(false); + }); + }); + }); +}); diff --git a/apps/server/src/modules/system/repo/system.repo.ts b/apps/server/src/modules/system/repo/system.repo.ts new file mode 100644 index 00000000000..3251ec87230 --- /dev/null +++ b/apps/server/src/modules/system/repo/system.repo.ts @@ -0,0 +1,36 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; +import { EntityId, SystemEntity } from '@shared/domain'; +import { System, SystemProps } from '../domain'; +import { SystemDomainMapper } from './system-domain.mapper'; + +@Injectable() +export class SystemRepo { + constructor(private readonly em: EntityManager) {} + + public async findById(id: EntityId): Promise { + const entity: SystemEntity | null = await this.em.findOne(SystemEntity, { id }); + + if (!entity) { + return null; + } + + const props: SystemProps = SystemDomainMapper.mapEntityToDomainObjectProperties(entity); + + const domainObject: System = new System(props); + + return domainObject; + } + + public async delete(domainObject: System): Promise { + const entity: SystemEntity | null = await this.em.findOne(SystemEntity, { id: domainObject.id }); + + if (!entity) { + return false; + } + + await this.em.removeAndFlush(entity); + + return true; + } +} diff --git a/apps/server/src/modules/system/service/dto/system.dto.ts b/apps/server/src/modules/system/service/dto/system.dto.ts index 1ea7e4a84ee..bdffdf75423 100644 --- a/apps/server/src/modules/system/service/dto/system.dto.ts +++ b/apps/server/src/modules/system/service/dto/system.dto.ts @@ -1,6 +1,6 @@ -import { EntityId } from '@shared/domain'; -import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { OauthConfigDto } from '@modules/system/service/dto/oauth-config.dto'; +import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { EntityId } from '@shared/domain/types'; export class SystemDto { id?: EntityId; diff --git a/apps/server/src/modules/system/service/index.ts b/apps/server/src/modules/system/service/index.ts index f0956aa12ad..6be1d3fb0fa 100644 --- a/apps/server/src/modules/system/service/index.ts +++ b/apps/server/src/modules/system/service/index.ts @@ -1,2 +1,4 @@ -export * from './system.service'; -export * from './dto'; +export { LegacySystemService } from './legacy-system.service'; +export { SystemDto, OauthConfigDto, OidcConfigDto } from './dto'; +export { SystemService } from './system.service'; +export { SystemOidcService } from './system-oidc.service'; diff --git a/apps/server/src/modules/system/service/legacy-system.service.spec.ts b/apps/server/src/modules/system/service/legacy-system.service.spec.ts new file mode 100644 index 00000000000..b600e4d2891 --- /dev/null +++ b/apps/server/src/modules/system/service/legacy-system.service.spec.ts @@ -0,0 +1,237 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { IdentityManagementOauthService } from '@infra/identity-management'; +import { Test, TestingModule } from '@nestjs/testing'; +import { EntityNotFoundError } from '@shared/common'; +import { OauthConfigEntity, SystemEntity, SystemTypeEnum } from '@shared/domain'; +import { LegacySystemRepo } from '@shared/repo'; +import { systemEntityFactory } from '@shared/testing'; +import { SystemMapper } from '../mapper'; +import { LegacySystemService } from './legacy-system.service'; + +describe(LegacySystemService.name, () => { + let module: TestingModule; + let systemService: LegacySystemService; + let systemRepoMock: DeepMocked; + let kcIdmOauthServiceMock: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + LegacySystemService, + { + provide: LegacySystemRepo, + useValue: createMock(), + }, + { + provide: IdentityManagementOauthService, + useValue: createMock(), + }, + ], + }).compile(); + systemRepoMock = module.get(LegacySystemRepo); + systemService = module.get(LegacySystemService); + kcIdmOauthServiceMock = module.get(IdentityManagementOauthService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findById', () => { + describe('when identity management is available', () => { + const standaloneSystem = systemEntityFactory.buildWithId({ alias: 'standaloneSystem' }); + const oidcSystem = systemEntityFactory.withOidcConfig().buildWithId({ alias: 'oidcSystem' }); + const oauthSystem = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'oauthSystem' }); + const setup = (system: SystemEntity) => { + systemRepoMock.findById.mockResolvedValue(system); + kcIdmOauthServiceMock.isOauthConfigAvailable.mockResolvedValue(true); + kcIdmOauthServiceMock.getOauthConfig.mockResolvedValue(oauthSystem.oauthConfig as OauthConfigEntity); + }; + + it('should return found system', async () => { + setup(standaloneSystem); + const result = await systemService.findById(standaloneSystem.id); + expect(result).toStrictEqual(SystemMapper.mapFromEntityToDto(standaloneSystem)); + }); + + it('should return found system with generated oauth config for oidc systems', async () => { + setup(oidcSystem); + if (oauthSystem.oauthConfig === undefined) { + fail('oauth system has no oauth configuration'); + } + const result = await systemService.findById(oidcSystem.id); + expect(result).toEqual( + expect.objectContaining({ + id: oidcSystem.id, + type: SystemTypeEnum.OAUTH, + alias: oidcSystem.alias, + displayName: oidcSystem.displayName, + url: oidcSystem.url, + provisioningStrategy: oidcSystem.provisioningStrategy, + provisioningUrl: oidcSystem.provisioningUrl, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + oauthConfig: expect.objectContaining({ + clientId: oauthSystem.oauthConfig.clientId, + clientSecret: oauthSystem.oauthConfig.clientSecret, + idpHint: oidcSystem.oidcConfig?.idpHint, + redirectUri: oauthSystem.oauthConfig.redirectUri + oidcSystem.id, + grantType: oauthSystem.oauthConfig.grantType, + tokenEndpoint: oauthSystem.oauthConfig.tokenEndpoint, + authEndpoint: oauthSystem.oauthConfig.authEndpoint, + responseType: oauthSystem.oauthConfig.responseType, + scope: oauthSystem.oauthConfig.scope, + provider: oauthSystem.oauthConfig.provider, + logoutEndpoint: oauthSystem.oauthConfig.logoutEndpoint, + issuer: oauthSystem.oauthConfig.issuer, + jwksEndpoint: oauthSystem.oauthConfig.jwksEndpoint, + }), + }) + ); + }); + }); + + describe('when identity management is not available', () => { + const standaloneSystem = systemEntityFactory.buildWithId(); + const oidcSystem = systemEntityFactory.withOidcConfig().buildWithId(); + const setup = (system: SystemEntity) => { + systemRepoMock.findById.mockResolvedValue(system); + kcIdmOauthServiceMock.isOauthConfigAvailable.mockResolvedValue(false); + }; + + it('should return found system', async () => { + setup(standaloneSystem); + const result = await systemService.findById(standaloneSystem.id); + expect(result).toStrictEqual(SystemMapper.mapFromEntityToDto(standaloneSystem)); + }); + + it('should throw and not generate oauth config for oidc systems', async () => { + setup(oidcSystem); + await expect(systemService.findById(oidcSystem.id)).rejects.toThrow(EntityNotFoundError); + }); + }); + }); + + describe('findByType', () => { + describe('when identity management is available', () => { + const ldapSystem = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'ldapSystem' }); + const oauthSystem = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'oauthSystem' }); + const oidcSystem = systemEntityFactory.withOidcConfig().buildWithId({ alias: 'oidcSystem' }); + const setup = () => { + systemRepoMock.findAll.mockResolvedValue([ldapSystem, oauthSystem, oidcSystem]); + systemRepoMock.findByFilter.mockImplementation((type: SystemTypeEnum) => { + if (type === SystemTypeEnum.LDAP) return Promise.resolve([ldapSystem]); + if (type === SystemTypeEnum.OAUTH) return Promise.resolve([oauthSystem]); + if (type === SystemTypeEnum.OIDC) return Promise.resolve([oidcSystem]); + return Promise.resolve([]); + }); + kcIdmOauthServiceMock.isOauthConfigAvailable.mockResolvedValue(true); + kcIdmOauthServiceMock.getOauthConfig.mockResolvedValue(oauthSystem.oauthConfig as OauthConfigEntity); + }; + + it('should return all systems', async () => { + setup(); + const result = await systemService.findByType(); + expect(result).toEqual( + expect.arrayContaining([ + ...SystemMapper.mapFromEntitiesToDtos([ldapSystem, oauthSystem]), + expect.objectContaining({ + alias: oidcSystem.alias, + displayName: oidcSystem.displayName, + }), + ]) + ); + }); + + it('should return found systems', async () => { + setup(); + const result = await systemService.findByType(SystemTypeEnum.LDAP); + expect(result).toStrictEqual(SystemMapper.mapFromEntitiesToDtos([ldapSystem])); + }); + + it('should return found systems with generated oauth config for oidc systems', async () => { + setup(); + if (oauthSystem.oauthConfig === undefined) { + fail('oauth system has no oauth configuration'); + } + const resultingSystems = await systemService.findByType(SystemTypeEnum.OAUTH); + + expect(resultingSystems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: SystemTypeEnum.OAUTH, + alias: oidcSystem.alias, + displayName: oidcSystem.displayName, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + oauthConfig: expect.objectContaining({ + clientId: oauthSystem.oauthConfig.clientId, + clientSecret: oauthSystem.oauthConfig.clientSecret, + idpHint: oidcSystem.oidcConfig?.idpHint, + redirectUri: oauthSystem.oauthConfig.redirectUri + oidcSystem.id, + grantType: oauthSystem.oauthConfig.grantType, + tokenEndpoint: oauthSystem.oauthConfig.tokenEndpoint, + authEndpoint: oauthSystem.oauthConfig.authEndpoint, + responseType: oauthSystem.oauthConfig.responseType, + scope: oauthSystem.oauthConfig.scope, + provider: oauthSystem.oauthConfig.provider, + logoutEndpoint: oauthSystem.oauthConfig.logoutEndpoint, + issuer: oauthSystem.oauthConfig.issuer, + jwksEndpoint: oauthSystem.oauthConfig.jwksEndpoint, + }), + }), + ]) + ); + }); + }); + + describe('when identity management is not available', () => { + const oauthSystem = systemEntityFactory.withOauthConfig().buildWithId(); + const oidcSystem = systemEntityFactory.withOidcConfig().buildWithId(); + const setup = () => { + systemRepoMock.findByFilter.mockImplementation((type: SystemTypeEnum) => { + if (type === SystemTypeEnum.OAUTH) return Promise.resolve([oauthSystem]); + if (type === SystemTypeEnum.OIDC) return Promise.resolve([oidcSystem]); + return Promise.resolve([]); + }); + kcIdmOauthServiceMock.isOauthConfigAvailable.mockResolvedValue(false); + }; + it('should filter out oidc systems', async () => { + setup(); + const result = await systemService.findByType(SystemTypeEnum.OAUTH); + expect(result).toStrictEqual(SystemMapper.mapFromEntitiesToDtos([oauthSystem])); + }); + }); + }); + + describe('save', () => { + describe('when creating a new system', () => { + const newSystem = systemEntityFactory.build(); + const setup = () => { + systemRepoMock.save.mockResolvedValue(); + }; + + it('should save new system', async () => { + setup(); + const result = await systemService.save(newSystem); + expect(result).toStrictEqual(SystemMapper.mapFromEntityToDto(newSystem)); + }); + }); + + describe('when updating an existing system', () => { + const existingSystem = systemEntityFactory.buildWithId(); + const setup = () => { + systemRepoMock.findById.mockResolvedValue(existingSystem); + }; + + it('should update existing system', async () => { + setup(); + const result = await systemService.save(existingSystem); + expect(systemRepoMock.findById).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual(SystemMapper.mapFromEntityToDto(existingSystem)); + }); + }); + }); +}); diff --git a/apps/server/src/modules/system/service/legacy-system.service.ts b/apps/server/src/modules/system/service/legacy-system.service.ts new file mode 100644 index 00000000000..9b59baad7f5 --- /dev/null +++ b/apps/server/src/modules/system/service/legacy-system.service.ts @@ -0,0 +1,95 @@ +import { IdentityManagementOauthService } from '@infra/identity-management'; +import { Injectable } from '@nestjs/common'; +import { EntityNotFoundError } from '@shared/common'; +import { EntityId, SystemEntity, SystemTypeEnum } from '@shared/domain'; +import { LegacySystemRepo } from '@shared/repo'; +import { SystemMapper } from '../mapper'; +import { SystemDto } from './dto'; + +// TODO N21-1547: Fully replace this service with SystemService +/** + * @deprecated use {@link SystemService} + */ +@Injectable() +export class LegacySystemService { + constructor( + private readonly systemRepo: LegacySystemRepo, + private readonly idmOauthService: IdentityManagementOauthService + ) {} + + async findById(id: EntityId): Promise { + let system = await this.systemRepo.findById(id); + [system] = await this.generateBrokerSystems([system]); + if (!system) { + throw new EntityNotFoundError(SystemEntity.name, { id }); + } + return SystemMapper.mapFromEntityToDto(system); + } + + async findByType(type?: SystemTypeEnum): Promise { + let systems: SystemEntity[]; + if (type && type === SystemTypeEnum.OAUTH) { + const oauthSystems = await this.systemRepo.findByFilter(SystemTypeEnum.OAUTH); + const oidcSystems = await this.systemRepo.findByFilter(SystemTypeEnum.OIDC); + systems = [...oauthSystems, ...oidcSystems]; + } else if (type) { + systems = await this.systemRepo.findByFilter(type); + } else { + systems = await this.systemRepo.findAll(); + } + systems = await this.generateBrokerSystems(systems); + return SystemMapper.mapFromEntitiesToDtos(systems); + } + + async save(systemDto: SystemDto): Promise { + let system: SystemEntity; + if (systemDto.id) { + system = await this.systemRepo.findById(systemDto.id); + system.type = systemDto.type; + system.alias = systemDto.alias; + system.displayName = systemDto.displayName; + system.oauthConfig = systemDto.oauthConfig; + system.provisioningStrategy = systemDto.provisioningStrategy; + system.provisioningUrl = systemDto.provisioningUrl; + system.url = systemDto.url; + } else { + system = new SystemEntity({ + type: systemDto.type, + alias: systemDto.alias, + displayName: systemDto.displayName, + oauthConfig: systemDto.oauthConfig, + provisioningStrategy: systemDto.provisioningStrategy, + provisioningUrl: systemDto.provisioningUrl, + url: systemDto.url, + }); + } + await this.systemRepo.save(system); + return SystemMapper.mapFromEntityToDto(system); + } + + private async generateBrokerSystems(systems: SystemEntity[]): Promise<[] | SystemEntity[]> { + if (!(await this.idmOauthService.isOauthConfigAvailable())) { + return systems.filter((system) => !(system.oidcConfig && !system.oauthConfig)); + } + const brokerConfig = await this.idmOauthService.getOauthConfig(); + let generatedSystem: SystemEntity; + return systems.map((system) => { + if (system.oidcConfig && !system.oauthConfig) { + generatedSystem = new SystemEntity({ + type: SystemTypeEnum.OAUTH, + alias: system.alias, + displayName: system.displayName ? system.displayName : system.alias, + provisioningStrategy: system.provisioningStrategy, + provisioningUrl: system.provisioningUrl, + url: system.url, + }); + generatedSystem.id = system.id; + generatedSystem.oauthConfig = { ...brokerConfig }; + generatedSystem.oauthConfig.idpHint = system.oidcConfig.idpHint; + generatedSystem.oauthConfig.redirectUri += system.id; + return generatedSystem; + } + return system; + }); + } +} diff --git a/apps/server/src/modules/system/service/system-oidc.service.spec.ts b/apps/server/src/modules/system/service/system-oidc.service.spec.ts index 6d85d5fb730..53e73d2e780 100644 --- a/apps/server/src/modules/system/service/system-oidc.service.spec.ts +++ b/apps/server/src/modules/system/service/system-oidc.service.spec.ts @@ -2,27 +2,27 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityNotFoundError } from '@shared/common'; import { SystemEntity } from '@shared/domain'; -import { SystemRepo } from '@shared/repo'; -import { systemFactory } from '@shared/testing'; +import { LegacySystemRepo } from '@shared/repo'; +import { systemEntityFactory } from '@shared/testing'; import { SystemOidcMapper } from '../mapper/system-oidc.mapper'; import { SystemOidcService } from './system-oidc.service'; describe('SystemService', () => { let module: TestingModule; let systemService: SystemOidcService; - let systemRepoMock: DeepMocked; + let systemRepoMock: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ providers: [ SystemOidcService, { - provide: SystemRepo, - useValue: createMock(), + provide: LegacySystemRepo, + useValue: createMock(), }, ], }).compile(); - systemRepoMock = module.get(SystemRepo); + systemRepoMock = module.get(LegacySystemRepo); systemService = module.get(SystemOidcService); }); @@ -35,8 +35,8 @@ describe('SystemService', () => { }); describe('findById', () => { - const oidcSystem = systemFactory.withOidcConfig().buildWithId({ alias: 'oidcSystem' }); - const standaloneSystem = systemFactory.buildWithId({ alias: 'standaloneSystem' }); + const oidcSystem = systemEntityFactory.withOidcConfig().buildWithId({ alias: 'oidcSystem' }); + const standaloneSystem = systemEntityFactory.buildWithId({ alias: 'standaloneSystem' }); const setup = (system: SystemEntity) => { systemRepoMock.findById.mockResolvedValue(system); }; @@ -54,9 +54,9 @@ describe('SystemService', () => { }); describe('findAll', () => { - const ldapSystem = systemFactory.withLdapConfig().buildWithId({ alias: 'ldapSystem' }); - const oauthSystem = systemFactory.withOauthConfig().buildWithId({ alias: 'oauthSystem' }); - const oidcSystem = systemFactory.withOidcConfig().buildWithId({ alias: 'oidcSystem' }); + const ldapSystem = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'ldapSystem' }); + const oauthSystem = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'oauthSystem' }); + const oidcSystem = systemEntityFactory.withOidcConfig().buildWithId({ alias: 'oidcSystem' }); it('should return oidc systems only', async () => { systemRepoMock.findByFilter.mockResolvedValue([ldapSystem, oauthSystem, oidcSystem]); diff --git a/apps/server/src/modules/system/service/system-oidc.service.ts b/apps/server/src/modules/system/service/system-oidc.service.ts index c1f1cf0a4c1..c8703da8ff6 100644 --- a/apps/server/src/modules/system/service/system-oidc.service.ts +++ b/apps/server/src/modules/system/service/system-oidc.service.ts @@ -1,13 +1,13 @@ import { Injectable } from '@nestjs/common'; import { EntityNotFoundError } from '@shared/common'; import { EntityId, SystemEntity, SystemTypeEnum } from '@shared/domain'; -import { SystemRepo } from '@shared/repo'; -import { SystemOidcMapper } from '@modules/system/mapper/system-oidc.mapper'; +import { LegacySystemRepo } from '@shared/repo'; +import { SystemOidcMapper } from '../mapper'; import { OidcConfigDto } from './dto'; @Injectable() export class SystemOidcService { - constructor(private readonly systemRepo: SystemRepo) {} + constructor(private readonly systemRepo: LegacySystemRepo) {} async findById(id: EntityId): Promise { const system = await this.systemRepo.findById(id); diff --git a/apps/server/src/modules/system/service/system.service.spec.ts b/apps/server/src/modules/system/service/system.service.spec.ts index 89ef533058b..b565a74a2a2 100644 --- a/apps/server/src/modules/system/service/system.service.spec.ts +++ b/apps/server/src/modules/system/service/system.service.spec.ts @@ -1,18 +1,15 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { EntityNotFoundError } from '@shared/common'; -import { OauthConfig, SystemEntity, SystemTypeEnum } from '@shared/domain'; -import { IdentityManagementOauthService } from '@infra/identity-management'; -import { SystemRepo } from '@shared/repo'; import { systemFactory } from '@shared/testing'; -import { SystemMapper } from '../mapper/system.mapper'; +import { SystemRepo } from '../repo'; import { SystemService } from './system.service'; -describe('SystemService', () => { +describe(SystemService.name, () => { let module: TestingModule; - let systemService: SystemService; - let systemRepoMock: DeepMocked; - let kcIdmOauthServiceMock: DeepMocked; + let service: SystemService; + + let systemRepo: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -22,15 +19,11 @@ describe('SystemService', () => { provide: SystemRepo, useValue: createMock(), }, - { - provide: IdentityManagementOauthService, - useValue: createMock(), - }, ], }).compile(); - systemRepoMock = module.get(SystemRepo); - systemService = module.get(SystemService); - kcIdmOauthServiceMock = module.get(IdentityManagementOauthService); + + service = module.get(SystemService); + systemRepo = module.get(SystemRepo); }); afterAll(async () => { @@ -38,199 +31,83 @@ describe('SystemService', () => { }); afterEach(() => { - jest.clearAllMocks(); + jest.resetAllMocks(); }); describe('findById', () => { - describe('when identity management is available', () => { - const standaloneSystem = systemFactory.buildWithId({ alias: 'standaloneSystem' }); - const oidcSystem = systemFactory.withOidcConfig().buildWithId({ alias: 'oidcSystem' }); - const oauthSystem = systemFactory.withOauthConfig().buildWithId({ alias: 'oauthSystem' }); - const setup = (system: SystemEntity) => { - systemRepoMock.findById.mockResolvedValue(system); - kcIdmOauthServiceMock.isOauthConfigAvailable.mockResolvedValue(true); - kcIdmOauthServiceMock.getOauthConfig.mockResolvedValue(oauthSystem.oauthConfig as OauthConfig); - }; + describe('when the system exists', () => { + const setup = () => { + const system = systemFactory.build(); - it('should return found system', async () => { - setup(standaloneSystem); - const result = await systemService.findById(standaloneSystem.id); - expect(result).toStrictEqual(SystemMapper.mapFromEntityToDto(standaloneSystem)); - }); + systemRepo.findById.mockResolvedValueOnce(system); - it('should return found system with generated oauth config for oidc systems', async () => { - setup(oidcSystem); - if (oauthSystem.oauthConfig === undefined) { - fail('oauth system has no oauth configuration'); - } - const result = await systemService.findById(oidcSystem.id); - expect(result).toEqual( - expect.objectContaining({ - id: oidcSystem.id, - type: SystemTypeEnum.OAUTH, - alias: oidcSystem.alias, - displayName: oidcSystem.displayName, - url: oidcSystem.url, - provisioningStrategy: oidcSystem.provisioningStrategy, - provisioningUrl: oidcSystem.provisioningUrl, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - oauthConfig: expect.objectContaining({ - clientId: oauthSystem.oauthConfig.clientId, - clientSecret: oauthSystem.oauthConfig.clientSecret, - idpHint: oidcSystem.oidcConfig?.idpHint, - redirectUri: oauthSystem.oauthConfig.redirectUri + oidcSystem.id, - grantType: oauthSystem.oauthConfig.grantType, - tokenEndpoint: oauthSystem.oauthConfig.tokenEndpoint, - authEndpoint: oauthSystem.oauthConfig.authEndpoint, - responseType: oauthSystem.oauthConfig.responseType, - scope: oauthSystem.oauthConfig.scope, - provider: oauthSystem.oauthConfig.provider, - logoutEndpoint: oauthSystem.oauthConfig.logoutEndpoint, - issuer: oauthSystem.oauthConfig.issuer, - jwksEndpoint: oauthSystem.oauthConfig.jwksEndpoint, - }), - }) - ); - }); - }); - - describe('when identity management is not available', () => { - const standaloneSystem = systemFactory.buildWithId(); - const oidcSystem = systemFactory.withOidcConfig().buildWithId(); - const setup = (system: SystemEntity) => { - systemRepoMock.findById.mockResolvedValue(system); - kcIdmOauthServiceMock.isOauthConfigAvailable.mockResolvedValue(false); + return { + system, + }; }; - it('should return found system', async () => { - setup(standaloneSystem); - const result = await systemService.findById(standaloneSystem.id); - expect(result).toStrictEqual(SystemMapper.mapFromEntityToDto(standaloneSystem)); - }); + it('should return the system', async () => { + const { system } = setup(); - it('should throw and not generate oauth config for oidc systems', async () => { - setup(oidcSystem); - await expect(systemService.findById(oidcSystem.id)).rejects.toThrow(EntityNotFoundError); + const result = await service.findById(system.id); + + expect(result).toEqual(system); }); }); - }); - describe('findByType', () => { - describe('when identity management is available', () => { - const ldapSystem = systemFactory.withLdapConfig().buildWithId({ alias: 'ldapSystem' }); - const oauthSystem = systemFactory.withOauthConfig().buildWithId({ alias: 'oauthSystem' }); - const oidcSystem = systemFactory.withOidcConfig().buildWithId({ alias: 'oidcSystem' }); + describe('when the system does not exist', () => { const setup = () => { - systemRepoMock.findAll.mockResolvedValue([ldapSystem, oauthSystem, oidcSystem]); - systemRepoMock.findByFilter.mockImplementation((type: SystemTypeEnum) => { - if (type === SystemTypeEnum.LDAP) return Promise.resolve([ldapSystem]); - if (type === SystemTypeEnum.OAUTH) return Promise.resolve([oauthSystem]); - if (type === SystemTypeEnum.OIDC) return Promise.resolve([oidcSystem]); - return Promise.resolve([]); - }); - kcIdmOauthServiceMock.isOauthConfigAvailable.mockResolvedValue(true); - kcIdmOauthServiceMock.getOauthConfig.mockResolvedValue(oauthSystem.oauthConfig as OauthConfig); + systemRepo.findById.mockResolvedValueOnce(null); }; - it('should return all systems', async () => { + it('should return null', async () => { setup(); - const result = await systemService.findByType(); - expect(result).toEqual( - expect.arrayContaining([ - ...SystemMapper.mapFromEntitiesToDtos([ldapSystem, oauthSystem]), - expect.objectContaining({ - alias: oidcSystem.alias, - displayName: oidcSystem.displayName, - }), - ]) - ); - }); - it('should return found systems', async () => { - setup(); - const result = await systemService.findByType(SystemTypeEnum.LDAP); - expect(result).toStrictEqual(SystemMapper.mapFromEntitiesToDtos([ldapSystem])); - }); + const result = await service.findById(new ObjectId().toHexString()); - it('should return found systems with generated oauth config for oidc systems', async () => { - setup(); - if (oauthSystem.oauthConfig === undefined) { - fail('oauth system has no oauth configuration'); - } - const resultingSystems = await systemService.findByType(SystemTypeEnum.OAUTH); - - expect(resultingSystems).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: SystemTypeEnum.OAUTH, - alias: oidcSystem.alias, - displayName: oidcSystem.displayName, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - oauthConfig: expect.objectContaining({ - clientId: oauthSystem.oauthConfig.clientId, - clientSecret: oauthSystem.oauthConfig.clientSecret, - idpHint: oidcSystem.oidcConfig?.idpHint, - redirectUri: oauthSystem.oauthConfig.redirectUri + oidcSystem.id, - grantType: oauthSystem.oauthConfig.grantType, - tokenEndpoint: oauthSystem.oauthConfig.tokenEndpoint, - authEndpoint: oauthSystem.oauthConfig.authEndpoint, - responseType: oauthSystem.oauthConfig.responseType, - scope: oauthSystem.oauthConfig.scope, - provider: oauthSystem.oauthConfig.provider, - logoutEndpoint: oauthSystem.oauthConfig.logoutEndpoint, - issuer: oauthSystem.oauthConfig.issuer, - jwksEndpoint: oauthSystem.oauthConfig.jwksEndpoint, - }), - }), - ]) - ); - }); - }); - - describe('when identity management is not available', () => { - const oauthSystem = systemFactory.withOauthConfig().buildWithId(); - const oidcSystem = systemFactory.withOidcConfig().buildWithId(); - const setup = () => { - systemRepoMock.findByFilter.mockImplementation((type: SystemTypeEnum) => { - if (type === SystemTypeEnum.OAUTH) return Promise.resolve([oauthSystem]); - if (type === SystemTypeEnum.OIDC) return Promise.resolve([oidcSystem]); - return Promise.resolve([]); - }); - kcIdmOauthServiceMock.isOauthConfigAvailable.mockResolvedValue(false); - }; - it('should filter out oidc systems', async () => { - setup(); - const result = await systemService.findByType(SystemTypeEnum.OAUTH); - expect(result).toStrictEqual(SystemMapper.mapFromEntitiesToDtos([oauthSystem])); + expect(result).toBeNull(); }); }); }); - describe('save', () => { - describe('when creating a new system', () => { - const newSystem = systemFactory.build(); + describe('delete', () => { + describe('when the system was deleted', () => { const setup = () => { - systemRepoMock.save.mockResolvedValue(); + const system = systemFactory.build(); + + systemRepo.delete.mockResolvedValueOnce(true); + + return { + system, + }; }; - it('should save new system', async () => { - setup(); - const result = await systemService.save(newSystem); - expect(result).toStrictEqual(SystemMapper.mapFromEntityToDto(newSystem)); + it('should return true', async () => { + const { system } = setup(); + + const result = await service.delete(system); + + expect(result).toEqual(true); }); }); - describe('when updating an existing system', () => { - const existingSystem = systemFactory.buildWithId(); + describe('when the system was not deleted', () => { const setup = () => { - systemRepoMock.findById.mockResolvedValue(existingSystem); + const system = systemFactory.build(); + + systemRepo.delete.mockResolvedValueOnce(false); + + return { + system, + }; }; - it('should update existing system', async () => { - setup(); - const result = await systemService.save(existingSystem); - expect(systemRepoMock.findById).toHaveBeenCalledTimes(1); - expect(result).toStrictEqual(SystemMapper.mapFromEntityToDto(existingSystem)); + it('should return false', async () => { + const { system } = setup(); + + const result = await service.delete(system); + + expect(result).toEqual(false); }); }); }); diff --git a/apps/server/src/modules/system/service/system.service.ts b/apps/server/src/modules/system/service/system.service.ts index bfb6a2ec7bf..50ad7fadabf 100644 --- a/apps/server/src/modules/system/service/system.service.ts +++ b/apps/server/src/modules/system/service/system.service.ts @@ -1,91 +1,21 @@ import { Injectable } from '@nestjs/common'; -import { EntityNotFoundError } from '@shared/common'; -import { EntityId, SystemEntity, SystemTypeEnum } from '@shared/domain'; -import { IdentityManagementOauthService } from '@infra/identity-management/identity-management-oauth.service'; -import { SystemRepo } from '@shared/repo'; -import { SystemMapper } from '@modules/system/mapper/system.mapper'; -import { SystemDto } from '@modules/system/service/dto/system.dto'; +import { EntityId } from '@shared/domain/types'; +import { System } from '../domain'; +import { SystemRepo } from '../repo'; @Injectable() export class SystemService { - constructor( - private readonly systemRepo: SystemRepo, - private readonly idmOauthService: IdentityManagementOauthService - ) {} + constructor(private readonly systemRepo: SystemRepo) {} - async findById(id: EntityId): Promise { - let system = await this.systemRepo.findById(id); - [system] = await this.generateBrokerSystems([system]); - if (!system) { - throw new EntityNotFoundError(SystemEntity.name, { id }); - } - return SystemMapper.mapFromEntityToDto(system); - } + public async findById(id: EntityId): Promise { + const system: System | null = await this.systemRepo.findById(id); - async findByType(type?: SystemTypeEnum): Promise { - let systems: SystemEntity[]; - if (type && type === SystemTypeEnum.OAUTH) { - const oauthSystems = await this.systemRepo.findByFilter(SystemTypeEnum.OAUTH); - const oidcSystems = await this.systemRepo.findByFilter(SystemTypeEnum.OIDC); - systems = [...oauthSystems, ...oidcSystems]; - } else if (type) { - systems = await this.systemRepo.findByFilter(type); - } else { - systems = await this.systemRepo.findAll(); - } - systems = await this.generateBrokerSystems(systems); - return SystemMapper.mapFromEntitiesToDtos(systems); + return system; } - async save(systemDto: SystemDto): Promise { - let system: SystemEntity; - if (systemDto.id) { - system = await this.systemRepo.findById(systemDto.id); - system.type = systemDto.type; - system.alias = systemDto.alias; - system.displayName = systemDto.displayName; - system.oauthConfig = systemDto.oauthConfig; - system.provisioningStrategy = systemDto.provisioningStrategy; - system.provisioningUrl = systemDto.provisioningUrl; - system.url = systemDto.url; - } else { - system = new SystemEntity({ - type: systemDto.type, - alias: systemDto.alias, - displayName: systemDto.displayName, - oauthConfig: systemDto.oauthConfig, - provisioningStrategy: systemDto.provisioningStrategy, - provisioningUrl: systemDto.provisioningUrl, - url: systemDto.url, - }); - } - await this.systemRepo.save(system); - return SystemMapper.mapFromEntityToDto(system); - } + public async delete(domainObject: System): Promise { + const deleted: boolean = await this.systemRepo.delete(domainObject); - private async generateBrokerSystems(systems: SystemEntity[]): Promise<[] | SystemEntity[]> { - if (!(await this.idmOauthService.isOauthConfigAvailable())) { - return systems.filter((system) => !(system.oidcConfig && !system.oauthConfig)); - } - const brokerConfig = await this.idmOauthService.getOauthConfig(); - let generatedSystem: SystemEntity; - return systems.map((system) => { - if (system.oidcConfig && !system.oauthConfig) { - generatedSystem = new SystemEntity({ - type: SystemTypeEnum.OAUTH, - alias: system.alias, - displayName: system.displayName ? system.displayName : system.alias, - provisioningStrategy: system.provisioningStrategy, - provisioningUrl: system.provisioningUrl, - url: system.url, - }); - generatedSystem.id = system.id; - generatedSystem.oauthConfig = { ...brokerConfig }; - generatedSystem.oauthConfig.idpHint = system.oidcConfig.idpHint; - generatedSystem.oauthConfig.redirectUri += system.id; - return generatedSystem; - } - return system; - }); + return deleted; } } diff --git a/apps/server/src/modules/system/system-api.module.ts b/apps/server/src/modules/system/system-api.module.ts index e7213c1fac7..e9201f376b8 100644 --- a/apps/server/src/modules/system/system-api.module.ts +++ b/apps/server/src/modules/system/system-api.module.ts @@ -1,10 +1,11 @@ -import { Module } from '@nestjs/common'; +import { AuthorizationModule } from '@modules/authorization'; import { SystemController } from '@modules/system/controller/system.controller'; import { SystemUc } from '@modules/system/uc/system.uc'; +import { Module } from '@nestjs/common'; import { SystemModule } from './system.module'; @Module({ - imports: [SystemModule], + imports: [SystemModule, AuthorizationModule], providers: [SystemUc], controllers: [SystemController], }) diff --git a/apps/server/src/modules/system/system.module.ts b/apps/server/src/modules/system/system.module.ts index 37ca8d7a858..54c9d51224b 100644 --- a/apps/server/src/modules/system/system.module.ts +++ b/apps/server/src/modules/system/system.module.ts @@ -1,12 +1,13 @@ -import { Module } from '@nestjs/common'; import { IdentityManagementModule } from '@infra/identity-management/identity-management.module'; -import { SystemRepo } from '@shared/repo'; -import { SystemService } from '@modules/system/service/system.service'; +import { Module } from '@nestjs/common'; +import { LegacySystemRepo } from '@shared/repo'; +import { SystemRepo } from './repo'; +import { LegacySystemService, SystemService } from './service'; import { SystemOidcService } from './service/system-oidc.service'; @Module({ imports: [IdentityManagementModule], - providers: [SystemRepo, SystemService, SystemOidcService], - exports: [SystemService, SystemOidcService], + providers: [LegacySystemRepo, LegacySystemService, SystemOidcService, SystemService, SystemRepo], + exports: [LegacySystemService, SystemOidcService, SystemService], }) export class SystemModule {} diff --git a/apps/server/src/modules/system/uc/system.uc.spec.ts b/apps/server/src/modules/system/uc/system.uc.spec.ts index 45bd65694d9..e498718321a 100644 --- a/apps/server/src/modules/system/uc/system.uc.spec.ts +++ b/apps/server/src/modules/system/uc/system.uc.spec.ts @@ -1,12 +1,15 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { Test, TestingModule } from '@nestjs/testing'; -import { EntityNotFoundError } from '@shared/common'; -import { EntityId, SystemEntity, SystemTypeEnum } from '@shared/domain'; -import { systemFactory } from '@shared/testing'; -import { SystemMapper } from '@modules/system/mapper/system.mapper'; +import { ObjectId } from '@mikro-orm/mongodb'; import { SystemDto } from '@modules/system/service/dto/system.dto'; -import { SystemService } from '@modules/system/service/system.service'; import { SystemUc } from '@modules/system/uc/system.uc'; +import { Test, TestingModule } from '@nestjs/testing'; +import { EntityNotFoundError } from '@shared/common'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { EntityId, Permission, SystemEntity, SystemTypeEnum } from '@shared/domain'; +import { setupEntities, systemEntityFactory, systemFactory, userFactory } from '@shared/testing'; +import { AuthorizationContextBuilder, AuthorizationService } from '../../authorization'; +import { SystemMapper } from '../mapper'; +import { LegacySystemService, SystemService } from '../service'; describe('SystemUc', () => { let module: TestingModule; @@ -17,44 +20,63 @@ describe('SystemUc', () => { let system1: SystemEntity; let system2: SystemEntity; + let legacySystemService: DeepMocked; let systemService: DeepMocked; - - afterAll(async () => { - await module.close(); - }); + let authorizationService: DeepMocked; beforeAll(async () => { + await setupEntities(); + module = await Test.createTestingModule({ providers: [ SystemUc, + { + provide: LegacySystemService, + useValue: createMock(), + }, { provide: SystemService, useValue: createMock(), }, + { + provide: AuthorizationService, + useValue: createMock(), + }, ], }).compile(); + systemUc = module.get(SystemUc); + legacySystemService = module.get(LegacySystemService); systemService = module.get(SystemService); + authorizationService = module.get(AuthorizationService); }); - beforeEach(() => { - system1 = systemFactory.buildWithId(); - system2 = systemFactory.buildWithId(); - - mockSystem1 = SystemMapper.mapFromEntityToDto(system1); - mockSystem2 = SystemMapper.mapFromEntityToDto(system2); - mockSystems = [mockSystem1, mockSystem2]; + afterAll(async () => { + await module.close(); + }); - systemService.findByType.mockImplementation((type: string | undefined) => { - if (type === SystemTypeEnum.OAUTH) return Promise.resolve([mockSystem1]); - return Promise.resolve(mockSystems); - }); - systemService.findById.mockImplementation( - (id: EntityId): Promise => (id === system1.id ? Promise.resolve(mockSystem1) : Promise.reject()) - ); + afterEach(() => { + jest.clearAllMocks(); }); describe('findByFilter', () => { + beforeEach(() => { + system1 = systemEntityFactory.buildWithId(); + system2 = systemEntityFactory.buildWithId(); + + mockSystem1 = SystemMapper.mapFromEntityToDto(system1); + mockSystem2 = SystemMapper.mapFromEntityToDto(system2); + mockSystems = [mockSystem1, mockSystem2]; + + legacySystemService.findByType.mockImplementation((type: string | undefined) => { + if (type === SystemTypeEnum.OAUTH) return Promise.resolve([mockSystem1]); + return Promise.resolve(mockSystems); + }); + legacySystemService.findById.mockImplementation( + (id: EntityId): Promise => (id === system1.id ? Promise.resolve(mockSystem1) : Promise.reject()) + ); + }); + it('should return systems by default', async () => { const systems: SystemDto[] = await systemUc.findByFilter(); @@ -78,13 +100,30 @@ describe('SystemUc', () => { }); it('should return empty system list, because none exist', async () => { - systemService.findByType.mockResolvedValue([]); + legacySystemService.findByType.mockResolvedValue([]); const resultResponse = await systemUc.findByFilter(); expect(resultResponse).toHaveLength(0); }); }); describe('findById', () => { + beforeEach(() => { + system1 = systemEntityFactory.buildWithId(); + system2 = systemEntityFactory.buildWithId(); + + mockSystem1 = SystemMapper.mapFromEntityToDto(system1); + mockSystem2 = SystemMapper.mapFromEntityToDto(system2); + mockSystems = [mockSystem1, mockSystem2]; + + legacySystemService.findByType.mockImplementation((type: string | undefined) => { + if (type === SystemTypeEnum.OAUTH) return Promise.resolve([mockSystem1]); + return Promise.resolve(mockSystems); + }); + legacySystemService.findById.mockImplementation( + (id: EntityId): Promise => (id === system1.id ? Promise.resolve(mockSystem1) : Promise.reject()) + ); + }); + it('should return a system by id', async () => { const receivedSystem: SystemDto = await systemUc.findById(system1.id); @@ -102,7 +141,7 @@ describe('SystemUc', () => { type: 'ldap', }); - systemService.findById.mockResolvedValue(system); + legacySystemService.findById.mockResolvedValue(system); }; it('should reject promise, because ldap is not active', async () => { @@ -114,4 +153,97 @@ describe('SystemUc', () => { }); }); }); + + describe('delete', () => { + describe('when the system exists and the user can delete it', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const system = systemFactory.build(); + + systemService.findById.mockResolvedValueOnce(system); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + return { + user, + system, + }; + }; + + it('should check the permission', async () => { + const { user, system } = setup(); + + await systemUc.delete(user.id, system.id); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + user, + system, + AuthorizationContextBuilder.write([Permission.SYSTEM_CREATE]) + ); + }); + + it('should delete the system', async () => { + const { user, system } = setup(); + + await systemUc.delete(user.id, system.id); + + expect(systemService.delete).toHaveBeenCalledWith(system); + }); + }); + + describe('when the system does not exist', () => { + const setup = () => { + systemService.findById.mockResolvedValueOnce(null); + }; + + it('should throw a not found exception', async () => { + setup(); + + await expect(systemUc.delete(new ObjectId().toHexString(), new ObjectId().toHexString())).rejects.toThrow( + NotFoundLoggableException + ); + }); + + it('should not delete any system', async () => { + setup(); + + await expect(systemUc.delete(new ObjectId().toHexString(), new ObjectId().toHexString())).rejects.toThrow(); + + expect(systemService.delete).not.toHaveBeenCalled(); + }); + }); + + describe('when the user is not authorized', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const system = systemFactory.build(); + const error = new Error(); + + systemService.findById.mockResolvedValueOnce(system); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + authorizationService.checkPermission.mockImplementation(() => { + throw error; + }); + + return { + user, + system, + error, + }; + }; + + it('should throw an error', async () => { + const { user, system, error } = setup(); + + await expect(systemUc.delete(user.id, system.id)).rejects.toThrow(error); + }); + + it('should not delete any system', async () => { + const { user, system } = setup(); + + await expect(systemUc.delete(user.id, system.id)).rejects.toThrow(); + + expect(systemService.delete).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/apps/server/src/modules/system/uc/system.uc.ts b/apps/server/src/modules/system/uc/system.uc.ts index 00665191da7..4ced419519b 100644 --- a/apps/server/src/modules/system/uc/system.uc.ts +++ b/apps/server/src/modules/system/uc/system.uc.ts @@ -1,20 +1,26 @@ +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { Injectable } from '@nestjs/common'; import { EntityNotFoundError } from '@shared/common'; -import { EntityId, SystemEntity, SystemType, SystemTypeEnum } from '@shared/domain'; -import { SystemDto } from '@modules/system/service/dto/system.dto'; -import { SystemService } from '@modules/system/service/system.service'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { EntityId, Permission, SystemEntity, SystemType, SystemTypeEnum, User } from '@shared/domain'; +import { System } from '../domain'; +import { LegacySystemService, SystemDto, SystemService } from '../service'; @Injectable() export class SystemUc { - constructor(private readonly systemService: SystemService) {} + constructor( + private readonly legacySystemService: LegacySystemService, + private readonly systemService: SystemService, + private readonly authorizationService: AuthorizationService + ) {} async findByFilter(type?: SystemType, onlyOauth = false): Promise { let systems: SystemDto[]; if (onlyOauth) { - systems = await this.systemService.findByType(SystemTypeEnum.OAUTH); + systems = await this.legacySystemService.findByType(SystemTypeEnum.OAUTH); } else { - systems = await this.systemService.findByType(type); + systems = await this.legacySystemService.findByType(type); } systems = systems.filter((system: SystemDto) => system.ldapActive !== false); @@ -23,7 +29,7 @@ export class SystemUc { } async findById(id: EntityId): Promise { - const system: SystemDto = await this.systemService.findById(id); + const system: SystemDto = await this.legacySystemService.findById(id); if (system.ldapActive === false) { throw new EntityNotFoundError(SystemEntity.name, { id }); @@ -31,4 +37,21 @@ export class SystemUc { return system; } + + async delete(userId: EntityId, systemId: EntityId): Promise { + const system: System | null = await this.systemService.findById(systemId); + + if (!system) { + throw new NotFoundLoggableException(System.name, 'id', systemId); + } + + const user: User = await this.authorizationService.getUserWithPermissions(userId); + this.authorizationService.checkPermission( + user, + system, + AuthorizationContextBuilder.write([Permission.SYSTEM_CREATE]) + ); + + await this.systemService.delete(system); + } } diff --git a/apps/server/src/modules/user-import/controller/api-test/import-user.api.spec.ts b/apps/server/src/modules/user-import/controller/api-test/import-user.api.spec.ts index 51bea6b1224..5ee7db64ce0 100644 --- a/apps/server/src/modules/user-import/controller/api-test/import-user.api.spec.ts +++ b/apps/server/src/modules/user-import/controller/api-test/import-user.api.spec.ts @@ -39,7 +39,7 @@ import { mapUserToCurrentUser, roleFactory, schoolFactory, - systemFactory, + systemEntityFactory, userFactory, } from '@shared/testing'; import { Request } from 'express'; @@ -51,7 +51,7 @@ describe('ImportUser Controller (API)', () => { let currentUser: ICurrentUser; const authenticatedUser = async (permissions: Permission[] = [], features: SchoolFeatures[] = []) => { - const system = systemFactory.buildWithId(); // TODO no id? + const system = systemEntityFactory.buildWithId(); // TODO no id? const school = schoolFactory.build({ officialSchoolNumber: 'foo', features }); const roles = [roleFactory.build({ name: RoleName.ADMINISTRATOR, permissions })]; await em.persistAndFlush([school, system, ...roles]); diff --git a/apps/server/src/modules/user-import/controller/import-user.controller.spec.ts b/apps/server/src/modules/user-import/controller/import-user.controller.spec.ts index c93c7bc58a7..ab94fc43143 100644 --- a/apps/server/src/modules/user-import/controller/import-user.controller.spec.ts +++ b/apps/server/src/modules/user-import/controller/import-user.controller.spec.ts @@ -1,10 +1,10 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ImportUserRepo, SystemRepo, UserRepo } from '@shared/repo'; import { AccountService } from '@modules/account/services/account.service'; import { AuthorizationService } from '@modules/authorization'; import { LegacySchoolService } from '@modules/legacy-school'; -import { LoggerModule } from '@src/core/logger'; import { ConfigModule } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ImportUserRepo, LegacySystemRepo, UserRepo } from '@shared/repo'; +import { LoggerModule } from '@src/core/logger'; import { UserImportUc } from '../uc/user-import.uc'; import { ImportUserController } from './import-user.controller'; @@ -38,7 +38,7 @@ describe('ImportUserController', () => { useValue: {}, }, { - provide: SystemRepo, + provide: LegacySystemRepo, useValue: {}, }, { diff --git a/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts b/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts index ff4aa4c266c..b68cff8074f 100644 --- a/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts +++ b/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts @@ -1,6 +1,10 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { ObjectId } from '@mikro-orm/mongodb'; +import { AccountService } from '@modules/account/services/account.service'; +import { AuthorizationService } from '@modules/authorization'; +import { LegacySchoolService } from '@modules/legacy-school'; import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; @@ -16,14 +20,10 @@ import { SystemEntity, User, } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@infra/database'; -import { ImportUserRepo, SystemRepo, UserRepo } from '@shared/repo'; +import { ImportUserRepo, LegacySystemRepo, UserRepo } from '@shared/repo'; import { federalStateFactory, importUserFactory, schoolFactory, userFactory } from '@shared/testing'; -import { systemFactory } from '@shared/testing/factory/system.factory'; +import { systemEntityFactory } from '@shared/testing/factory/systemEntityFactory'; import { LoggerModule } from '@src/core/logger'; -import { AccountService } from '@modules/account/services/account.service'; -import { AuthorizationService } from '@modules/authorization'; -import { LegacySchoolService } from '@modules/legacy-school'; import { LdapAlreadyPersistedException, MigrationAlreadyActivatedException, @@ -38,7 +38,7 @@ describe('[ImportUserModule]', () => { let accountService: DeepMocked; let importUserRepo: DeepMocked; let schoolService: DeepMocked; - let systemRepo: DeepMocked; + let systemRepo: DeepMocked; let userRepo: DeepMocked; let authorizationService: DeepMocked; let configurationSpy: jest.SpyInstance; @@ -65,8 +65,8 @@ describe('[ImportUserModule]', () => { useValue: createMock(), }, { - provide: SystemRepo, - useValue: createMock(), + provide: LegacySystemRepo, + useValue: createMock(), }, { provide: UserRepo, @@ -82,7 +82,7 @@ describe('[ImportUserModule]', () => { accountService = module.get(AccountService); importUserRepo = module.get(ImportUserRepo); schoolService = module.get(LegacySchoolService); - systemRepo = module.get(SystemRepo); + systemRepo = module.get(LegacySystemRepo); userRepo = module.get(UserRepo); authorizationService = module.get(AuthorizationService); }); @@ -472,7 +472,7 @@ describe('[ImportUserModule]', () => { let userRepoFlushSpy: jest.SpyInstance; let accountServiceFindByUserIdSpy: jest.SpyInstance; beforeEach(() => { - system = systemFactory.buildWithId(); + system = systemEntityFactory.buildWithId(); school = schoolFactory.buildWithId({ systems: [system] }); school.externalId = 'foo'; school.inMaintenanceSince = new Date(); @@ -605,7 +605,7 @@ describe('[ImportUserModule]', () => { const currentDate = new Date('2022-03-10T00:00:00.000Z'); let dateSpy: jest.SpyInstance; beforeEach(() => { - system = systemFactory.buildWithId({ ldapConfig: {} }); + system = systemEntityFactory.buildWithId({ ldapConfig: {} }); school = schoolFactory.buildWithId(); school.officialSchoolNumber = 'foo'; currentUser = userFactory.buildWithId({ school }); diff --git a/apps/server/src/modules/user-import/uc/user-import.uc.ts b/apps/server/src/modules/user-import/uc/user-import.uc.ts index a9290bb5e42..4e3b14b6734 100644 --- a/apps/server/src/modules/user-import/uc/user-import.uc.ts +++ b/apps/server/src/modules/user-import/uc/user-import.uc.ts @@ -21,7 +21,7 @@ import { SystemEntity, User, } from '@shared/domain'; -import { ImportUserRepo, SystemRepo, UserRepo } from '@shared/repo'; +import { ImportUserRepo, LegacySystemRepo, UserRepo } from '@shared/repo'; import { Logger } from '@src/core/logger'; import { AccountSaveDto } from '../../account/services/dto'; import { @@ -50,7 +50,7 @@ export class UserImportUc { private readonly importUserRepo: ImportUserRepo, private readonly authorizationService: AuthorizationService, private readonly schoolService: LegacySchoolService, - private readonly systemRepo: SystemRepo, + private readonly systemRepo: LegacySystemRepo, private readonly userRepo: UserRepo, private readonly logger: Logger ) { diff --git a/apps/server/src/modules/user-import/user-import.module.ts b/apps/server/src/modules/user-import/user-import.module.ts index 2cf4d94704e..48fa730c61f 100644 --- a/apps/server/src/modules/user-import/user-import.module.ts +++ b/apps/server/src/modules/user-import/user-import.module.ts @@ -1,7 +1,7 @@ +import { LegacySchoolModule } from '@modules/legacy-school'; import { Module } from '@nestjs/common'; -import { ImportUserRepo, LegacySchoolRepo, SystemRepo, UserRepo } from '@shared/repo'; +import { ImportUserRepo, LegacySchoolRepo, LegacySystemRepo, UserRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; -import { LegacySchoolModule } from '@modules/legacy-school'; import { AccountModule } from '../account'; import { AuthorizationModule } from '../authorization'; import { ImportUserController } from './controller/import-user.controller'; @@ -10,7 +10,7 @@ import { UserImportUc } from './uc/user-import.uc'; @Module({ imports: [LoggerModule, AccountModule, LegacySchoolModule, AuthorizationModule], controllers: [ImportUserController], - providers: [UserImportUc, ImportUserRepo, LegacySchoolRepo, SystemRepo, UserRepo], + providers: [UserImportUc, ImportUserRepo, LegacySchoolRepo, LegacySystemRepo, UserRepo], exports: [], }) /** diff --git a/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts b/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts index f23795a35ab..d44280df28a 100644 --- a/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts +++ b/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts @@ -12,7 +12,7 @@ import { cleanupCollections, JwtTestFactory, schoolFactory, - systemFactory, + systemEntityFactory, TestApiClient, UserAndAccountTestFactory, userFactory, @@ -71,8 +71,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when data is given', () => { const setup = async () => { const date: Date = new Date(2023, 5, 4); - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], }); @@ -136,8 +136,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when a user login migration exists', () => { const setup = async () => { const date: Date = new Date(2023, 5, 4); - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], }); @@ -232,8 +232,8 @@ describe('UserLoginMigrationController (API)', () => { describe('[POST] /start', () => { describe('when current user start the migration successfully', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -316,8 +316,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when migration already started', () => { const setup = async () => { const date: Date = new Date(2023, 5, 4); - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -355,8 +355,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when migration already closed', () => { const setup = async () => { const date: Date = new Date(2023, 5, 4); - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -395,8 +395,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when official school number is not set', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], }); @@ -468,7 +468,7 @@ describe('UserLoginMigrationController (API)', () => { describe('when providing a code and being eligible to migrate', () => { const setup = async () => { - const targetSystem: SystemEntity = systemFactory + const targetSystem: SystemEntity = systemEntityFactory .withOauthConfig() .buildWithId({ provisioningStrategy: SystemProvisioningStrategy.SANIS }); @@ -478,7 +478,7 @@ describe('UserLoginMigrationController (API)', () => { query.systemId = targetSystem.id; query.redirectUri = 'redirectUri'; - const sourceSystem: SystemEntity = systemFactory.buildWithId(); + const sourceSystem: SystemEntity = systemEntityFactory.buildWithId(); const officialSchoolNumber = '12345'; const externalId = 'aef1f4fd-c323-466e-962b-a84354c0e713'; @@ -536,7 +536,7 @@ describe('UserLoginMigrationController (API)', () => { describe('when migration failed, because of schoolnumbers mismatch', () => { const setup = async () => { - const targetSystem: SystemEntity = systemFactory + const targetSystem: SystemEntity = systemEntityFactory .withOauthConfig() .buildWithId({ provisioningStrategy: SystemProvisioningStrategy.SANIS }); @@ -545,7 +545,7 @@ describe('UserLoginMigrationController (API)', () => { query.systemId = targetSystem.id; query.redirectUri = 'redirectUri'; - const sourceSystem: SystemEntity = systemFactory.buildWithId(); + const sourceSystem: SystemEntity = systemEntityFactory.buildWithId(); const officialSchoolNumber = '12345'; const externalId = 'aef1f4fd-c323-466e-962b-a84354c0e713'; @@ -622,8 +622,8 @@ describe('UserLoginMigrationController (API)', () => { describe('[POST] /restart', () => { describe('when current User restart the migration successfully', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -721,8 +721,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when migration is already started', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -759,8 +759,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when migration is finally finished', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -802,8 +802,8 @@ describe('UserLoginMigrationController (API)', () => { describe('[PUT] /mandatory', () => { describe('when migration is set from optional to mandatory', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -854,8 +854,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when migration is set from mandatory to optional', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -906,8 +906,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when migration is not started', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -936,8 +936,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when the migration is closed', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -984,8 +984,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when user has not the required permission', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -1026,8 +1026,8 @@ describe('UserLoginMigrationController (API)', () => { describe('[POST] /close', () => { describe('when the user login migration is running', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -1102,8 +1102,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when migration is not started', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -1145,8 +1145,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when the migration is already closed', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -1211,8 +1211,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when the migration is finished', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -1259,8 +1259,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when user has not the required permission', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', @@ -1298,8 +1298,8 @@ describe('UserLoginMigrationController (API)', () => { describe('when no user has migrate', () => { const setup = async () => { - const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); - const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); const school: SchoolEntity = schoolFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', diff --git a/apps/server/src/modules/user-login-migration/service/user-login-migration.service.spec.ts b/apps/server/src/modules/user-login-migration/service/user-login-migration.service.spec.ts index 0edaf9d1a38..35e9e02b473 100644 --- a/apps/server/src/modules/user-login-migration/service/user-login-migration.service.spec.ts +++ b/apps/server/src/modules/user-login-migration/service/user-login-migration.service.spec.ts @@ -2,7 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { ObjectId } from '@mikro-orm/mongodb'; import { LegacySchoolService } from '@modules/legacy-school'; -import { SystemService } from '@modules/system'; +import { LegacySystemService } from '@modules/system'; import { SystemDto } from '@modules/system/service'; import { UserService } from '@modules/user'; import { InternalServerErrorException } from '@nestjs/common'; @@ -22,7 +22,7 @@ describe(UserLoginMigrationService.name, () => { let userService: DeepMocked; let schoolService: DeepMocked; - let systemService: DeepMocked; + let systemService: DeepMocked; let userLoginMigrationRepo: DeepMocked; const mockedDate: Date = new Date('2023-05-02'); @@ -46,8 +46,8 @@ describe(UserLoginMigrationService.name, () => { useValue: createMock(), }, { - provide: SystemService, - useValue: createMock(), + provide: LegacySystemService, + useValue: createMock(), }, { provide: UserLoginMigrationRepo, @@ -59,7 +59,7 @@ describe(UserLoginMigrationService.name, () => { service = module.get(UserLoginMigrationService); userService = module.get(UserService); schoolService = module.get(LegacySchoolService); - systemService = module.get(SystemService); + systemService = module.get(LegacySystemService); userLoginMigrationRepo = module.get(UserLoginMigrationRepo); }); diff --git a/apps/server/src/modules/user-login-migration/service/user-login-migration.service.ts b/apps/server/src/modules/user-login-migration/service/user-login-migration.service.ts index 459b163f119..f9ee07a7fde 100644 --- a/apps/server/src/modules/user-login-migration/service/user-login-migration.service.ts +++ b/apps/server/src/modules/user-login-migration/service/user-login-migration.service.ts @@ -1,6 +1,6 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { LegacySchoolService } from '@modules/legacy-school'; -import { SystemDto, SystemService } from '@modules/system'; +import { LegacySystemService, SystemDto } from '@modules/system'; import { UserService } from '@modules/user'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { EntityId, LegacySchoolDo, SchoolFeatures, SystemTypeEnum, UserDO, UserLoginMigrationDO } from '@shared/domain'; @@ -16,7 +16,7 @@ export class UserLoginMigrationService { private readonly userService: UserService, private readonly userLoginMigrationRepo: UserLoginMigrationRepo, private readonly schoolService: LegacySchoolService, - private readonly systemService: SystemService + private readonly systemService: LegacySystemService ) {} public async startMigration(schoolId: string): Promise { diff --git a/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.spec.ts b/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.spec.ts index 4179505d009..59271414f1d 100644 --- a/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.spec.ts +++ b/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.spec.ts @@ -3,12 +3,12 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { AuthenticationService } from '@modules/authentication'; import { Action, AuthorizationService } from '@modules/authorization'; import { LegacySchoolService } from '@modules/legacy-school'; -import { OAuthTokenDto, OAuthService } from '@modules/oauth'; +import { OAuthService, OAuthTokenDto } from '@modules/oauth'; import { - ProvisioningService, ExternalSchoolDto, ExternalUserDto, OauthDataDto, + ProvisioningService, ProvisioningSystemDto, } from '@modules/provisioning'; import { ForbiddenException } from '@nestjs/common'; @@ -19,7 +19,7 @@ import { SystemProvisioningStrategy } from '@shared/domain/interface/system-prov import { legacySchoolDoFactory, setupEntities, - systemFactory, + systemEntityFactory, userFactory, userLoginMigrationDOFactory, } from '@shared/testing'; @@ -339,7 +339,7 @@ describe(UserLoginMigrationUc.name, () => { describe('when external school and official school number is defined and school has to be migrated', () => { const setup = () => { - const sourceSystem: SystemEntity = systemFactory + const sourceSystem: SystemEntity = systemEntityFactory .withOauthConfig() .buildWithId({ provisioningStrategy: SystemProvisioningStrategy.SANIS }); diff --git a/apps/server/src/shared/domain/entity/system.entity.spec.ts b/apps/server/src/shared/domain/entity/system.entity.spec.ts index 9b1c538673f..06c45b5c5e4 100644 --- a/apps/server/src/shared/domain/entity/system.entity.spec.ts +++ b/apps/server/src/shared/domain/entity/system.entity.spec.ts @@ -1,6 +1,5 @@ import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { setupEntities } from '@shared/testing'; -import { systemFactory } from '@shared/testing/factory/system.factory'; +import { setupEntities, systemEntityFactory } from '@shared/testing'; import { SystemEntity } from './system.entity'; describe('System Entity', () => { @@ -16,13 +15,13 @@ describe('System Entity', () => { }); it('should create a system by passing required properties', () => { - const system = systemFactory.build(); + const system = systemEntityFactory.build(); expect(system instanceof SystemEntity).toEqual(true); }); it('should create a system by passing required and optional properties', () => { - const system = systemFactory + const system = systemEntityFactory .withOauthConfig() .build({ url: 'SAMPLE_URL', alias: 'SAMPLE_ALIAS', displayName: 'SAMPLE_NAME' }); @@ -39,16 +38,16 @@ describe('System Entity', () => { clientId: '12345', clientSecret: 'mocksecret', idpHint: 'mock-oauth-idpHint', - tokenEndpoint: 'http://mock.de/mock/auth/public/mockToken', + tokenEndpoint: 'https://mock.de/mock/auth/public/mockToken', grantType: 'authorization_code', - redirectUri: 'http://mockhost:3030/api/v3/sso/oauth/', + redirectUri: 'https://mockhost:3030/api/v3/sso/oauth/', scope: 'openid uuid', responseType: 'code', - authEndpoint: 'http://mock.de/auth', + authEndpoint: 'https://mock.de/auth', provider: 'mock_type', - logoutEndpoint: 'http://mock.de/logout', + logoutEndpoint: 'https://mock.de/logout', issuer: 'mock_issuer', - jwksEndpoint: 'http://mock.de/jwks', + jwksEndpoint: 'https://mock.de/jwks', }, }) ); diff --git a/apps/server/src/shared/domain/entity/system.entity.ts b/apps/server/src/shared/domain/entity/system.entity.ts index 07cfea5bb59..3f7f1c46c88 100644 --- a/apps/server/src/shared/domain/entity/system.entity.ts +++ b/apps/server/src/shared/domain/entity/system.entity.ts @@ -3,20 +3,20 @@ import { SystemProvisioningStrategy } from '@shared/domain/interface/system-prov import { EntityId } from '../types'; import { BaseEntityWithTimestamps } from './base.entity'; -export interface SystemProperties { +export interface SystemEntityProps { type: string; url?: string; alias?: string; displayName?: string; - oauthConfig?: OauthConfig; - oidcConfig?: OidcConfig; - ldapConfig?: LdapConfig; + oauthConfig?: OauthConfigEntity; + oidcConfig?: OidcConfigEntity; + ldapConfig?: LdapConfigEntity; provisioningStrategy?: SystemProvisioningStrategy; provisioningUrl?: string; } -export class OauthConfig { - constructor(oauthConfig: OauthConfig) { +export class OauthConfigEntity { + constructor(oauthConfig: OauthConfigEntity) { this.clientId = oauthConfig.clientId; this.clientSecret = oauthConfig.clientSecret; this.idpHint = oauthConfig.idpHint; @@ -73,8 +73,8 @@ export class OauthConfig { } @Embeddable() -export class LdapConfig { - constructor(ldapConfig: Readonly) { +export class LdapConfigEntity { + constructor(ldapConfig: Readonly) { this.active = ldapConfig.active; this.federalState = ldapConfig.federalState; this.lastSyncAttempt = ldapConfig.lastSyncAttempt; @@ -150,8 +150,8 @@ export class LdapConfig { }; }; } -export class OidcConfig { - constructor(oidcConfig: OidcConfig) { +export class OidcConfigEntity { + constructor(oidcConfig: OidcConfigEntity) { this.clientId = oidcConfig.clientId; this.clientSecret = oidcConfig.clientSecret; this.idpHint = oidcConfig.idpHint; @@ -189,7 +189,7 @@ export class OidcConfig { @Entity({ tableName: 'systems' }) export class SystemEntity extends BaseEntityWithTimestamps { - constructor(props: SystemProperties) { + constructor(props: SystemEntityProps) { super(); this.type = props.type; this.url = props.url; @@ -215,17 +215,17 @@ export class SystemEntity extends BaseEntityWithTimestamps { displayName?: string; @Property({ nullable: true }) - oauthConfig?: OauthConfig; + oauthConfig?: OauthConfigEntity; @Property({ nullable: true }) @Enum() provisioningStrategy?: SystemProvisioningStrategy; @Property({ nullable: true }) - oidcConfig?: OidcConfig; + oidcConfig?: OidcConfigEntity; - @Embedded({ entity: () => LdapConfig, object: true, nullable: true }) - ldapConfig?: LdapConfig; + @Embedded({ entity: () => LdapConfigEntity, object: true, nullable: true }) + ldapConfig?: LdapConfigEntity; @Property({ nullable: true }) provisioningUrl?: string; diff --git a/apps/server/src/shared/repo/school/legacy-school.repo.integration.spec.ts b/apps/server/src/shared/repo/school/legacy-school.repo.integration.spec.ts index 097f5498685..090e9fc4fd9 100644 --- a/apps/server/src/shared/repo/school/legacy-school.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/school/legacy-school.repo.integration.spec.ts @@ -18,7 +18,7 @@ import { legacySchoolDoFactory, schoolFactory, schoolYearFactory, - systemFactory, + systemEntityFactory, userLoginMigrationFactory, } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; @@ -114,7 +114,7 @@ describe('LegacySchoolRepo', () => { describe('findByExternalId', () => { it('should find school by external ID', async () => { - const system: SystemEntity = systemFactory.buildWithId(); + const system: SystemEntity = systemEntityFactory.buildWithId(); const schoolEntity: SchoolEntity = schoolFactory.build({ externalId: 'externalId' }); schoolEntity.systems.add(system); @@ -182,7 +182,7 @@ describe('LegacySchoolRepo', () => { describe('mapEntityToDO is called', () => { it('should map school entity to school domain object', () => { - const system: SystemEntity = systemFactory.buildWithId(); + const system: SystemEntity = systemEntityFactory.buildWithId(); const schoolYear: SchoolYearEntity = schoolYearFactory.buildWithId(); const schoolEntity: SchoolEntity = schoolFactory.buildWithId({ systems: [system], features: [], schoolYear }); const userLoginMigration: UserLoginMigrationEntity = userLoginMigrationFactory.build({ school: schoolEntity }); @@ -219,8 +219,8 @@ describe('LegacySchoolRepo', () => { describe('mapDOToEntityProperties is called', () => { const setup = async () => { - const system1: SystemEntity = systemFactory.buildWithId(); - const system2: SystemEntity = systemFactory.buildWithId(); + const system1: SystemEntity = systemEntityFactory.buildWithId(); + const system2: SystemEntity = systemEntityFactory.buildWithId(); const userLoginMigration: UserLoginMigrationEntity = userLoginMigrationFactory.buildWithId(); diff --git a/apps/server/src/shared/repo/system/index.ts b/apps/server/src/shared/repo/system/index.ts index cfc3117fc5f..2c071b949c9 100644 --- a/apps/server/src/shared/repo/system/index.ts +++ b/apps/server/src/shared/repo/system/index.ts @@ -1 +1 @@ -export * from './system.repo'; +export * from './legacy-system.repo'; diff --git a/apps/server/src/shared/repo/system/system.repo.integration.spec.ts b/apps/server/src/shared/repo/system/legacy-system.repo.integration.spec.ts similarity index 82% rename from apps/server/src/shared/repo/system/system.repo.integration.spec.ts rename to apps/server/src/shared/repo/system/legacy-system.repo.integration.spec.ts index d8a5c1d87e3..bf8d9ea18fa 100644 --- a/apps/server/src/shared/repo/system/system.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/system/legacy-system.repo.integration.spec.ts @@ -1,22 +1,22 @@ +import { MongoMemoryDatabaseModule } from '@infra/database'; import { NotFoundError } from '@mikro-orm/core'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { SystemEntity, SystemTypeEnum } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@infra/database'; -import { SystemRepo } from '@shared/repo'; -import { systemFactory } from '@shared/testing/factory/system.factory'; +import { LegacySystemRepo } from '@shared/repo'; +import { systemEntityFactory } from '@shared/testing'; describe('system repo', () => { let module: TestingModule; - let repo: SystemRepo; + let repo: LegacySystemRepo; let em: EntityManager; beforeAll(async () => { module = await Test.createTestingModule({ imports: [MongoMemoryDatabaseModule.forRoot()], - providers: [SystemRepo], + providers: [LegacySystemRepo], }).compile(); - repo = module.get(SystemRepo); + repo = module.get(LegacySystemRepo); em = module.get(EntityManager); }); @@ -39,7 +39,7 @@ describe('system repo', () => { }); it('should return right keys', async () => { - const system = systemFactory.build(); + const system = systemEntityFactory.build(); await em.persistAndFlush([system]); const result = await repo.findById(system.id); expect(Object.keys(result).sort()).toEqual( @@ -61,7 +61,7 @@ describe('system repo', () => { }); it('should return a System that matched by id', async () => { - const system = systemFactory.build(); + const system = systemEntityFactory.build(); await em.persistAndFlush([system]); const result = await repo.findById(system.id); expect(result).toEqual(system); @@ -80,7 +80,7 @@ describe('system repo', () => { }); it('should return all systems', async () => { - const systems = [systemFactory.build(), systemFactory.build({ oauthConfig: undefined })]; + const systems = [systemEntityFactory.build(), systemEntityFactory.build({ oauthConfig: undefined })]; await em.persistAndFlush(systems); const result = await repo.findAll(); @@ -91,9 +91,9 @@ describe('system repo', () => { }); describe('findByFilter', () => { - const ldapSystems = systemFactory.withLdapConfig().buildListWithId(2); - const oauthSystems = systemFactory.withOauthConfig().buildListWithId(2); - const oidcSystems = systemFactory.withOidcConfig().buildListWithId(2); + const ldapSystems = systemEntityFactory.withLdapConfig().buildListWithId(2); + const oauthSystems = systemEntityFactory.withOauthConfig().buildListWithId(2); + const oidcSystems = systemEntityFactory.withOidcConfig().buildListWithId(2); beforeAll(async () => { await em.persistAndFlush([...ldapSystems, ...oauthSystems, ...oidcSystems]); diff --git a/apps/server/src/shared/repo/system/system.repo.ts b/apps/server/src/shared/repo/system/legacy-system.repo.ts similarity index 81% rename from apps/server/src/shared/repo/system/system.repo.ts rename to apps/server/src/shared/repo/system/legacy-system.repo.ts index 65fd257cc24..85a8fdbdc84 100644 --- a/apps/server/src/shared/repo/system/system.repo.ts +++ b/apps/server/src/shared/repo/system/legacy-system.repo.ts @@ -3,8 +3,12 @@ import { SystemEntity, SystemTypeEnum } from '@shared/domain'; import { BaseRepo } from '@shared/repo/base.repo'; import { SystemScope } from '@shared/repo/system/system-scope'; +// TODO N21-1547: Fully replace this service with SystemService +/** + * @deprecated use the {@link SystemRepo} from the system module instead + */ @Injectable() -export class SystemRepo extends BaseRepo { +export class LegacySystemRepo extends BaseRepo { get entityName() { return SystemEntity; } diff --git a/apps/server/src/shared/repo/user/user-do.repo.integration.spec.ts b/apps/server/src/shared/repo/user/user-do.repo.integration.spec.ts index 451285eebca..ef01a96503c 100644 --- a/apps/server/src/shared/repo/user/user-do.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/user/user-do.repo.integration.spec.ts @@ -23,7 +23,7 @@ import { cleanupCollections, roleFactory, schoolFactory, - systemFactory, + systemEntityFactory, userDoFactory, userFactory, } from '@shared/testing'; @@ -146,7 +146,7 @@ describe('UserRepo', () => { let user: User; beforeEach(async () => { - system = systemFactory.buildWithId(); + system = systemEntityFactory.buildWithId(); school = schoolFactory.buildWithId(); school.systems.add(system); user = userFactory.buildWithId({ externalId, school }); @@ -190,7 +190,7 @@ describe('UserRepo', () => { let user: User; beforeEach(async () => { - system = systemFactory.buildWithId(); + system = systemEntityFactory.buildWithId(); school = schoolFactory.buildWithId(); school.systems.add(system); user = userFactory.buildWithId({ externalId, school }); diff --git a/apps/server/src/shared/repo/user/user.repo.integration.spec.ts b/apps/server/src/shared/repo/user/user.repo.integration.spec.ts index 3d44d0edfb8..a923b8d128f 100644 --- a/apps/server/src/shared/repo/user/user.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/user/user.repo.integration.spec.ts @@ -3,8 +3,14 @@ import { NotFoundError } from '@mikro-orm/core'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { MatchCreator, SortOrder, SystemEntity, User } from '@shared/domain'; -import { cleanupCollections, importUserFactory, roleFactory, schoolFactory, userFactory } from '@shared/testing'; -import { systemFactory } from '@shared/testing/factory/system.factory'; +import { + cleanupCollections, + importUserFactory, + roleFactory, + schoolFactory, + systemEntityFactory, + userFactory, +} from '@shared/testing'; import { UserRepo } from './user.repo'; describe('user repo', () => { @@ -124,7 +130,7 @@ describe('user repo', () => { let userA: User; let userB: User; beforeEach(async () => { - sys = systemFactory.build(); + sys = systemEntityFactory.build(); await em.persistAndFlush([sys]); const school = schoolFactory.build({ systems: [sys] }); // const school = schoolFactory.withSystem().build(); diff --git a/apps/server/src/shared/repo/userloginmigration/user-login-migration.repo.integration.spec.ts b/apps/server/src/shared/repo/userloginmigration/user-login-migration.repo.integration.spec.ts index 266cd0381c1..a78424f7525 100644 --- a/apps/server/src/shared/repo/userloginmigration/user-login-migration.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/userloginmigration/user-login-migration.repo.integration.spec.ts @@ -1,10 +1,10 @@ import { createMock } from '@golevelup/ts-jest'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { EntityManager } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { SchoolEntity, SystemEntity, UserLoginMigrationDO } from '@shared/domain'; import { UserLoginMigrationEntity } from '@shared/domain/entity/user-login-migration.entity'; -import { MongoMemoryDatabaseModule } from '@infra/database'; -import { cleanupCollections, schoolFactory, systemFactory } from '@shared/testing'; +import { cleanupCollections, schoolFactory, systemEntityFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { userLoginMigrationFactory } from '../../testing/factory/user-login-migration.factory'; import { UserLoginMigrationRepo } from './user-login-migration.repo'; @@ -42,8 +42,8 @@ describe('UserLoginMigrationRepo', () => { describe('when saving a UserLoginMigrationDO', () => { const setup = async () => { const school: SchoolEntity = schoolFactory.buildWithId(); - const sourceSystem: SystemEntity = systemFactory.buildWithId(); - const targetSystem: SystemEntity = systemFactory.buildWithId(); + const sourceSystem: SystemEntity = systemEntityFactory.buildWithId(); + const targetSystem: SystemEntity = systemEntityFactory.buildWithId(); const domainObject: UserLoginMigrationDO = new UserLoginMigrationDO({ schoolId: school.id, diff --git a/apps/server/src/shared/testing/factory/domainobject/index.ts b/apps/server/src/shared/testing/factory/domainobject/index.ts index 8e02c37399c..9314c2a829e 100644 --- a/apps/server/src/shared/testing/factory/domainobject/index.ts +++ b/apps/server/src/shared/testing/factory/domainobject/index.ts @@ -7,3 +7,4 @@ export * from './domain-object.factory'; export * from './user-login-migration-do.factory'; export * from './lti-tool.factory'; export * from './pseudonym.factory'; +export { systemFactory } from './system/system.factory'; diff --git a/apps/server/src/shared/testing/factory/domainobject/system/system.factory.ts b/apps/server/src/shared/testing/factory/domainobject/system/system.factory.ts new file mode 100644 index 00000000000..35e1c3f438c --- /dev/null +++ b/apps/server/src/shared/testing/factory/domainobject/system/system.factory.ts @@ -0,0 +1,10 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { System, SystemProps } from '@modules/system/domain'; +import { DomainObjectFactory } from '../domain-object.factory'; + +export const systemFactory = DomainObjectFactory.define(System, () => { + return { + id: new ObjectId().toHexString(), + type: 'oauth2', + }; +}); diff --git a/apps/server/src/shared/testing/factory/group-entity.factory.ts b/apps/server/src/shared/testing/factory/group-entity.factory.ts index 482aca971cf..1e65d13d808 100644 --- a/apps/server/src/shared/testing/factory/group-entity.factory.ts +++ b/apps/server/src/shared/testing/factory/group-entity.factory.ts @@ -1,9 +1,9 @@ -import { ExternalSourceEntity, RoleName } from '@shared/domain'; import { GroupEntity, GroupEntityProps, GroupEntityTypes, GroupValidPeriodEntity } from '@modules/group/entity'; +import { ExternalSourceEntity, RoleName } from '@shared/domain'; import { BaseFactory } from './base.factory'; import { roleFactory } from './role.factory'; import { schoolFactory } from './school.factory'; -import { systemFactory } from './system.factory'; +import { systemEntityFactory } from './systemEntityFactory'; import { userFactory } from './user.factory'; export const groupEntityFactory = BaseFactory.define(GroupEntity, ({ sequence }) => { @@ -27,7 +27,7 @@ export const groupEntityFactory = BaseFactory.define { matched(matchedBy: MatchCreator, user: User): this { @@ -16,7 +15,7 @@ class ImportUserFactory extends BaseFactory { export const importUserFactory = ImportUserFactory.define(ImportUser, ({ sequence }) => { return { school: schoolFactory.build(), - system: systemFactory.build(), + system: systemEntityFactory.build(), ldapDn: `uid=john${sequence},cn=schueler,cn=users,ou=1,dc=training,dc=ucs`, // eslint-disable-next-line @typescript-eslint/no-unsafe-call externalId: uuidv4() as unknown as string, diff --git a/apps/server/src/shared/testing/factory/index.ts b/apps/server/src/shared/testing/factory/index.ts index 54fac672098..f4d5e0050ce 100644 --- a/apps/server/src/shared/testing/factory/index.ts +++ b/apps/server/src/shared/testing/factory/index.ts @@ -29,7 +29,7 @@ export * from './schoolyear.factory'; export * from './share-token.do.factory'; export * from './storageprovider.factory'; export * from './submission.factory'; -export * from './system.factory'; +export * from './systemEntityFactory'; export * from './task.factory'; export * from './team.factory'; export * from './teamuser.factory'; diff --git a/apps/server/src/shared/testing/factory/system.factory.ts b/apps/server/src/shared/testing/factory/systemEntityFactory.ts similarity index 58% rename from apps/server/src/shared/testing/factory/system.factory.ts rename to apps/server/src/shared/testing/factory/systemEntityFactory.ts index f686c406851..567645210af 100644 --- a/apps/server/src/shared/testing/factory/system.factory.ts +++ b/apps/server/src/shared/testing/factory/systemEntityFactory.ts @@ -1,33 +1,33 @@ -import { LdapConfig, OauthConfig, OidcConfig, SystemEntity, SystemProperties } from '@shared/domain'; +import { LdapConfigEntity, OauthConfigEntity, OidcConfigEntity, SystemEntity, SystemEntityProps } from '@shared/domain'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { DeepPartial } from 'fishery'; import { BaseFactory } from './base.factory'; -export class SystemFactory extends BaseFactory { +export class SystemEntityFactory extends BaseFactory { withOauthConfig(): this { - const params: DeepPartial = { - oauthConfig: new OauthConfig({ + const params: DeepPartial = { + oauthConfig: new OauthConfigEntity({ clientId: '12345', clientSecret: 'mocksecret', idpHint: 'mock-oauth-idpHint', - tokenEndpoint: 'http://mock.de/mock/auth/public/mockToken', + tokenEndpoint: 'https://mock.de/mock/auth/public/mockToken', grantType: 'authorization_code', - redirectUri: 'http://mockhost:3030/api/v3/sso/oauth/', + redirectUri: 'https://mockhost:3030/api/v3/sso/oauth/', scope: 'openid uuid', responseType: 'code', - authEndpoint: 'http://mock.de/auth', + authEndpoint: 'https://mock.de/auth', provider: 'mock_type', - logoutEndpoint: 'http://mock.de/logout', + logoutEndpoint: 'https://mock.de/logout', issuer: 'mock_issuer', - jwksEndpoint: 'http://mock.de/jwks', + jwksEndpoint: 'https://mock.de/jwks', }), }; return this.params(params); } - withLdapConfig(otherParams?: DeepPartial): this { - const params: DeepPartial = { - ldapConfig: new LdapConfig({ + withLdapConfig(otherParams?: DeepPartial): this { + const params: DeepPartial = { + ldapConfig: new LdapConfigEntity({ url: 'ldaps:mock.de:389', active: true, ...otherParams, @@ -39,7 +39,7 @@ export class SystemFactory extends BaseFactory { withOidcConfig(): this { const params = { - oidcConfig: new OidcConfig({ + oidcConfig: new OidcConfigEntity({ clientId: 'mock-client-id', clientSecret: 'mock-client-secret', idpHint: 'mock-oidc-idpHint', @@ -54,10 +54,10 @@ export class SystemFactory extends BaseFactory { } } -export const systemFactory = SystemFactory.define(SystemEntity, ({ sequence }) => { +export const systemEntityFactory = SystemEntityFactory.define(SystemEntity, ({ sequence }) => { return { type: 'oauth', - url: 'http://mock.de', + url: 'https://mock.de', alias: `system #${sequence}`, displayName: `system #${sequence}DisplayName`, provisioningStrategy: SystemProvisioningStrategy.OIDC, diff --git a/apps/server/src/shared/testing/factory/user-login-migration.factory.ts b/apps/server/src/shared/testing/factory/user-login-migration.factory.ts index a3759a853ca..a3feaedbd27 100644 --- a/apps/server/src/shared/testing/factory/user-login-migration.factory.ts +++ b/apps/server/src/shared/testing/factory/user-login-migration.factory.ts @@ -1,7 +1,7 @@ import { IUserLoginMigration, UserLoginMigrationEntity } from '../../domain/entity/user-login-migration.entity'; import { BaseFactory } from './base.factory'; import { schoolFactory } from './school.factory'; -import { systemFactory } from './system.factory'; +import { systemEntityFactory } from './systemEntityFactory'; export const userLoginMigrationFactory = BaseFactory.define( UserLoginMigrationEntity, @@ -9,7 +9,7 @@ export const userLoginMigrationFactory = BaseFactory.define { const currentSystem = await context.app.service('systems').get(context.id); - if (currentSystem.type === 'ldap' && currentSystem.ldapConfig && currentSystem.ldapConfig.provider === 'iserv-idm') { + + if (!context.app.service('nest-system-rule').canEdit(currentSystem)) { throw new Forbidden('Not allowed to change this system'); } diff --git a/src/services/system/model.js b/src/services/system/model.js index 9491b3cdbe2..1a4650348a4 100644 --- a/src/services/system/model.js +++ b/src/services/system/model.js @@ -18,6 +18,7 @@ const types = [ 'iserv', // SSO providers 'ldap', // general and provider-specific LDAP 'oidc', + 'oauth', tspBaseType, tspSchoolType, // Thüringer Schul-Portal ]; diff --git a/test/services/system/index.test.js b/test/services/system/index.test.js index 95e84bf15c3..985e5886851 100644 --- a/test/services/system/index.test.js +++ b/test/services/system/index.test.js @@ -132,7 +132,7 @@ describe('systemId service', () => { it('CREATE fails without the right permissions', async () => { const usersSchool = await testObjects.createTestSchool(); - const data = { type: 'ldap' }; + const data = { type: 'ldap', ldapConfig: { provider: 'general' } }; const user = await testObjects.createTestUser({ roles: ['student'], schoolId: [usersSchool._id] }); const params = await testObjects.generateRequestParamsFromUser(user); @@ -154,6 +154,7 @@ describe('systemId service', () => { type: 'ldap', ldapConfig: { searchUserPassword: 'somePassword', + provider: 'general', }, }; @@ -167,7 +168,7 @@ describe('systemId service', () => { it('CREATE system is added to the users school after its creation', async () => { const usersSchool = await testObjects.createTestSchool(); - const data = { type: 'ldap' }; + const data = { type: 'ldap', ldapConfig: { provider: 'general' } }; const user = await testObjects.createTestUser({ roles: ['administrator'], schoolId: [usersSchool._id] }); const params = await testObjects.generateRequestParamsFromUser(user); @@ -186,6 +187,7 @@ describe('systemId service', () => { type: 'ldap', ldapConfig: { searchUserPassword: 'somePassword', + provider: 'general', }, }); const usersSchool = await testObjects.createTestSchool({ systems: [usersSystem._id] }); @@ -195,6 +197,7 @@ describe('systemId service', () => { url: 'http://someurl.com', ldapConfig: { searchUserPassword: 'somePassword', + provider: 'general', }, }; @@ -215,6 +218,7 @@ describe('systemId service', () => { type: 'ldap', ldapConfig: { searchUserPassword: 'somePassword', + provider: 'general', }, }); const usersSchool = await testObjects.createTestSchool(); @@ -225,6 +229,7 @@ describe('systemId service', () => { url: 'http://someurl.com', ldapConfig: { searchUserPassword: 'somePassword', + provider: 'general', }, }; @@ -245,6 +250,7 @@ describe('systemId service', () => { type: 'ldap', ldapConfig: { searchUserPassword: 'somePassword', + provider: 'general', }, }); const usersSchool = await testObjects.createTestSchool({ systems: [usersSystem._id] }); @@ -254,6 +260,7 @@ describe('systemId service', () => { url: 'http://someurl.com', ldapConfig: { searchUserPassword: 'somePassword', + provider: 'general', }, }; @@ -263,7 +270,7 @@ describe('systemId service', () => { const result = await app.service('systems').update(usersSystem._id, data, params); expect(result.ldapConfig.searchUserPassword).to.be.undefined; }); - it('UPDATE iServ configuration should not be editable', async () => { + it('UPDATE global configuration should not be editable', async () => { const usersSystem = await testObjects.createTestSystem({ type: 'ldap', ldapConfig: { @@ -302,6 +309,7 @@ describe('systemId service', () => { type: 'ldap', ldapConfig: { searchUserPassword: 'somePassword', + provider: 'general', }, }); const usersSchool = await testObjects.createTestSchool({ systems: [usersSystem._id] }); @@ -328,6 +336,7 @@ describe('systemId service', () => { type: 'ldap', ldapConfig: { searchUserPassword: 'somePassword', + provider: 'general', }, }); const usersSchool = await testObjects.createTestSchool(); @@ -354,6 +363,7 @@ describe('systemId service', () => { type: 'ldap', ldapConfig: { searchUserPassword: 'somePassword', + provider: 'general', }, }); const usersSchool = await testObjects.createTestSchool({ systems: [usersSystem._id] }); @@ -369,7 +379,7 @@ describe('systemId service', () => { expect(result.ldapConfig.searchUserPassword).to.be.undefined; }); - it('PATCH iServ configuration should not be editable', async () => { + it('PATCH global configuration should not be editable', async () => { const usersSystem = await testObjects.createTestSystem({ type: 'ldap', ldapConfig: { @@ -403,6 +413,7 @@ describe('systemId service', () => { type: 'ldap', ldapConfig: { searchUserPassword: 'somePassword', + provider: 'general', }, }); const usersSchool = await testObjects.createTestSchool({ systems: [usersSystem._id] }); @@ -425,6 +436,7 @@ describe('systemId service', () => { type: 'ldap', ldapConfig: { searchUserPassword: 'somePassword', + provider: 'general', }, }); const usersSchool = await testObjects.createTestSchool(); @@ -447,6 +459,7 @@ describe('systemId service', () => { type: 'ldap', ldapConfig: { searchUserPassword: 'somePassword', + provider: 'general', }, }); const usersSchool = await testObjects.createTestSchool({ systems: [usersSystem._id] }); @@ -461,6 +474,9 @@ describe('systemId service', () => { it('REMOVE system is removed from the school after its removal', async () => { const usersSystem = await testObjects.createTestSystem({ type: 'ldap', + ldapConfig: { + provider: 'general', + }, }); const usersSchool = await testObjects.createTestSchool({ systems: [usersSystem._id] }); @@ -477,6 +493,9 @@ describe('systemId service', () => { it('REMOVE should remove ldapschoolidentifier from school if ldap system is removed', async () => { const usersSystem = await testObjects.createTestSystem({ type: 'ldap', + ldapConfig: { + provider: 'general', + }, }); const usersSchool = await testObjects.createTestSchool({ systems: [usersSystem._id], @@ -496,6 +515,9 @@ describe('systemId service', () => { it('REMOVE should remove ldapLastSync from school if ldap system is removed', async () => { const usersSystem = await testObjects.createTestSystem({ type: 'ldap', + ldapConfig: { + provider: 'general', + }, }); const usersSchool = await testObjects.createTestSchool({ systems: [usersSystem._id], @@ -512,7 +534,7 @@ describe('systemId service', () => { expect(usersSchoolUpdated.ldapLastSync).to.be.undefined; }); - it('REMOVE iServ configuration should not be removable', async () => { + it('REMOVE global configuration should not be removable', async () => { const usersSystem = await testObjects.createTestSystem({ type: 'ldap', ldapConfig: { @@ -533,5 +555,24 @@ describe('systemId service', () => { expect(err.message).to.equal('Not allowed to change this system'); } }); + + it('REMOVE non-ldap configuration should not be removable', async () => { + const usersSystem = await testObjects.createTestSystem({ + type: 'oauth', + }); + const usersSchool = await testObjects.createTestSchool({ systems: [usersSystem._id] }); + + const user = await testObjects.createTestUser({ roles: ['administrator'], schoolId: [usersSchool._id] }); + const params = await testObjects.generateRequestParamsFromUser(user); + + try { + await app.service('systems').remove(usersSystem._id, params); + throw new Error('should have failed'); + } catch (err) { + expect(err.message).to.not.equal('should have failed'); + expect(err.code).to.equal(403); + expect(err.message).to.equal('Not allowed to change this system'); + } + }); }); }); diff --git a/test/utils/setup.nest.services.js b/test/utils/setup.nest.services.js index 4de3bce181f..5c56311df27 100644 --- a/test/utils/setup.nest.services.js +++ b/test/utils/setup.nest.services.js @@ -15,6 +15,8 @@ const { DB_PASSWORD, DB_URL, DB_USERNAME } = require('../../dist/apps/server/con const { ALL_ENTITIES } = require('../../dist/apps/server/shared/domain/entity/all-entities'); const { TeamService } = require('../../dist/apps/server/modules/teams/service/team.service'); const { TeamsApiModule } = require('../../dist/apps/server/modules/teams/teams-api.module'); +const { AuthorizationModule } = require('../../dist/apps/server/modules/authorization'); +const { SystemRule } = require('../../dist/apps/server/modules/authorization'); const setupNestServices = async (app) => { const module = await Test.createTestingModule({ @@ -31,6 +33,7 @@ const setupNestServices = async (app) => { ConfigModule.forRoot({ ignoreEnvFile: true, ignoreEnvVars: true, isGlobal: true }), AccountApiModule, TeamsApiModule, + AuthorizationModule, ], }).compile(); const nestApp = await module.createNestApplication().init(); @@ -39,11 +42,13 @@ const setupNestServices = async (app) => { const accountService = nestApp.get(AccountService); const accountValidationService = nestApp.get(AccountValidationService); const teamService = nestApp.get(TeamService); + const systemRule = nestApp.get(SystemRule); app.services['nest-account-uc'] = accountUc; app.services['nest-account-service'] = accountService; app.services['nest-account-validation-service'] = accountValidationService; app.services['nest-team-service'] = teamService; + app.services['nest-system-rule'] = systemRule; app.services['nest-orm'] = orm; return { nestApp, orm, accountUc, accountService }; From 537571d8c19e4e8e53b3958b99fd53046a32bcdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= <103562092+MarvinOehlerkingCap@users.noreply.github.com> Date: Mon, 27 Nov 2023 09:13:39 +0100 Subject: [PATCH 2/7] N21-1512 Fix SchoolExternalTool metadata (#4580) --- ...ext-external-tool-count-per-context.response.ts | 14 ++++++++++++++ .../modules/tool/common/controller/dto/index.ts | 1 + .../response/external-tool-metadata.response.ts | 11 +++-------- .../external-tool/domain/external-tool-metadata.ts | 4 +++- .../mapper/external-tool-metadata.mapper.ts | 5 ++++- .../service/external-tool-metadata.service.ts | 6 +++--- .../tool/external-tool/uc/external-tool.uc.spec.ts | 7 ++----- .../dto/school-external-tool-metadata.response.ts | 11 +++-------- .../domain/school-external-tool-metadata.ts | 4 +++- .../mapper/school-external-tool-metadata.mapper.ts | 5 ++++- .../school-external-tool-metadata.service.ts | 4 ++-- 11 files changed, 42 insertions(+), 30 deletions(-) create mode 100644 apps/server/src/modules/tool/common/controller/dto/context-external-tool-count-per-context.response.ts create mode 100644 apps/server/src/modules/tool/common/controller/dto/index.ts diff --git a/apps/server/src/modules/tool/common/controller/dto/context-external-tool-count-per-context.response.ts b/apps/server/src/modules/tool/common/controller/dto/context-external-tool-count-per-context.response.ts new file mode 100644 index 00000000000..7e34d995267 --- /dev/null +++ b/apps/server/src/modules/tool/common/controller/dto/context-external-tool-count-per-context.response.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ContextExternalToolCountPerContextResponse { + @ApiProperty() + course: number; + + @ApiProperty() + boardElement: number; + + constructor(props: ContextExternalToolCountPerContextResponse) { + this.course = props.course; + this.boardElement = props.boardElement; + } +} diff --git a/apps/server/src/modules/tool/common/controller/dto/index.ts b/apps/server/src/modules/tool/common/controller/dto/index.ts new file mode 100644 index 00000000000..87fa450d468 --- /dev/null +++ b/apps/server/src/modules/tool/common/controller/dto/index.ts @@ -0,0 +1 @@ +export { ContextExternalToolCountPerContextResponse } from './context-external-tool-count-per-context.response'; diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool-metadata.response.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool-metadata.response.ts index d38b48cc503..745d0208a39 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool-metadata.response.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool-metadata.response.ts @@ -1,17 +1,12 @@ import { ApiProperty } from '@nestjs/swagger'; -import { ContextExternalToolType } from '../../../../context-external-tool/entity'; +import { ContextExternalToolCountPerContextResponse } from '../../../../common/controller/dto'; export class ExternalToolMetadataResponse { @ApiProperty() schoolExternalToolCount: number; - @ApiProperty({ - type: 'object', - properties: Object.fromEntries( - Object.values(ContextExternalToolType).map((key: ContextExternalToolType) => [key, { type: 'number' }]) - ), - }) - contextExternalToolCountPerContext: Record; + @ApiProperty() + contextExternalToolCountPerContext: ContextExternalToolCountPerContextResponse; constructor(externalToolMetadataResponse: ExternalToolMetadataResponse) { this.schoolExternalToolCount = externalToolMetadataResponse.schoolExternalToolCount; diff --git a/apps/server/src/modules/tool/external-tool/domain/external-tool-metadata.ts b/apps/server/src/modules/tool/external-tool/domain/external-tool-metadata.ts index 04492680ff7..5fa4b0f3f7c 100644 --- a/apps/server/src/modules/tool/external-tool/domain/external-tool-metadata.ts +++ b/apps/server/src/modules/tool/external-tool/domain/external-tool-metadata.ts @@ -1,7 +1,9 @@ +import { ContextExternalToolType } from '../../context-external-tool/entity'; + export class ExternalToolMetadata { schoolExternalToolCount: number; - contextExternalToolCountPerContext: Record; + contextExternalToolCountPerContext: Record; constructor(externalToolMetadata: ExternalToolMetadata) { this.schoolExternalToolCount = externalToolMetadata.schoolExternalToolCount; diff --git a/apps/server/src/modules/tool/external-tool/mapper/external-tool-metadata.mapper.ts b/apps/server/src/modules/tool/external-tool/mapper/external-tool-metadata.mapper.ts index b3d6555f898..f5c257b5430 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/external-tool-metadata.mapper.ts +++ b/apps/server/src/modules/tool/external-tool/mapper/external-tool-metadata.mapper.ts @@ -1,3 +1,4 @@ +import { ContextExternalToolCountPerContextResponse } from '../../common/controller/dto'; import { ExternalToolMetadataResponse } from '../controller/dto'; import { ExternalToolMetadata } from '../domain'; @@ -5,7 +6,9 @@ export class ExternalToolMetadataMapper { static mapToExternalToolMetadataResponse(externalToolMetadata: ExternalToolMetadata): ExternalToolMetadataResponse { const externalToolMetadataResponse: ExternalToolMetadataResponse = new ExternalToolMetadataResponse({ schoolExternalToolCount: externalToolMetadata.schoolExternalToolCount, - contextExternalToolCountPerContext: externalToolMetadata.contextExternalToolCountPerContext, + contextExternalToolCountPerContext: new ContextExternalToolCountPerContextResponse( + externalToolMetadata.contextExternalToolCountPerContext + ), }); return externalToolMetadataResponse; diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-metadata.service.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-metadata.service.ts index 9476e738a87..ef82629e87d 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-metadata.service.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-metadata.service.ts @@ -2,10 +2,10 @@ import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain'; import { ContextExternalToolRepo, SchoolExternalToolRepo } from '@shared/repo'; import { ToolContextType } from '../../common/enum'; +import { ToolContextMapper } from '../../common/mapper/tool-context.mapper'; import { ContextExternalToolType } from '../../context-external-tool/entity'; import { SchoolExternalTool } from '../../school-external-tool/domain'; import { ExternalToolMetadata } from '../domain'; -import { ToolContextMapper } from '../../common/mapper/tool-context.mapper'; @Injectable() export class ExternalToolMetadataService { @@ -40,11 +40,11 @@ export class ExternalToolMetadataService { ); } - const externaltoolMetadata: ExternalToolMetadata = new ExternalToolMetadata({ + const externalToolMetadata: ExternalToolMetadata = new ExternalToolMetadata({ schoolExternalToolCount: schoolExternalTools.length, contextExternalToolCountPerContext: contextExternalToolCount, }); - return externaltoolMetadata; + return externalToolMetadata; } } diff --git a/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts b/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts index ff209ac301d..35a20ca8997 100644 --- a/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts +++ b/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts @@ -7,10 +7,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { IFindOptions, Permission, Role, SortOrder, User } from '@shared/domain'; import { Page } from '@shared/domain/domainobject/page'; import { roleFactory, setupEntities, userFactory } from '@shared/testing'; -import { - externalToolFactory, - oauth2ToolConfigFactory, -} from '@shared/testing/factory/domainobject/tool/external-tool.factory'; +import { externalToolFactory, oauth2ToolConfigFactory } from '@shared/testing/factory'; import { ExternalToolSearchQuery } from '../../common/interface'; import { ExternalTool, ExternalToolMetadata, Oauth2ToolConfig } from '../domain'; import { @@ -550,7 +547,7 @@ describe('ExternalToolUc', () => { const externalToolMetadata: ExternalToolMetadata = new ExternalToolMetadata({ schoolExternalToolCount: 2, - contextExternalToolCountPerContext: { course: 3, 'board-element': 3 }, + contextExternalToolCountPerContext: { course: 3, boardElement: 3 }, }); externalToolMetadataService.getMetadata.mockResolvedValue(externalToolMetadata); diff --git a/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-metadata.response.ts b/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-metadata.response.ts index db4806d23ec..c61b24df3c9 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-metadata.response.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-metadata.response.ts @@ -1,14 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; -import { ContextExternalToolType } from '../../../context-external-tool/entity'; +import { ContextExternalToolCountPerContextResponse } from '../../../common/controller/dto'; export class SchoolExternalToolMetadataResponse { - @ApiProperty({ - type: 'object', - properties: Object.fromEntries( - Object.values(ContextExternalToolType).map((key: ContextExternalToolType) => [key, { type: 'number' }]) - ), - }) - contextExternalToolCountPerContext: Record; + @ApiProperty() + contextExternalToolCountPerContext: ContextExternalToolCountPerContextResponse; constructor(schoolExternalToolMetadataResponse: SchoolExternalToolMetadataResponse) { this.contextExternalToolCountPerContext = schoolExternalToolMetadataResponse.contextExternalToolCountPerContext; diff --git a/apps/server/src/modules/tool/school-external-tool/domain/school-external-tool-metadata.ts b/apps/server/src/modules/tool/school-external-tool/domain/school-external-tool-metadata.ts index 4cccdfe11a1..741857352d3 100644 --- a/apps/server/src/modules/tool/school-external-tool/domain/school-external-tool-metadata.ts +++ b/apps/server/src/modules/tool/school-external-tool/domain/school-external-tool-metadata.ts @@ -1,5 +1,7 @@ +import { ContextExternalToolType } from '../../context-external-tool/entity'; + export class SchoolExternalToolMetadata { - contextExternalToolCountPerContext: Record; + contextExternalToolCountPerContext: Record; constructor(schoolExternalToolMetadata: SchoolExternalToolMetadata) { this.contextExternalToolCountPerContext = schoolExternalToolMetadata.contextExternalToolCountPerContext; diff --git a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-metadata.mapper.ts b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-metadata.mapper.ts index 1e42f22ebf2..acc58dd6ef8 100644 --- a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-metadata.mapper.ts +++ b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-metadata.mapper.ts @@ -1,3 +1,4 @@ +import { ContextExternalToolCountPerContextResponse } from '../../common/controller/dto'; import { SchoolExternalToolMetadataResponse } from '../controller/dto'; import { SchoolExternalToolMetadata } from '../domain'; @@ -6,7 +7,9 @@ export class SchoolExternalToolMetadataMapper { schoolExternalToolMetadata: SchoolExternalToolMetadata ): SchoolExternalToolMetadataResponse { const externalToolMetadataResponse: SchoolExternalToolMetadataResponse = new SchoolExternalToolMetadataResponse({ - contextExternalToolCountPerContext: schoolExternalToolMetadata.contextExternalToolCountPerContext, + contextExternalToolCountPerContext: new ContextExternalToolCountPerContextResponse( + schoolExternalToolMetadata.contextExternalToolCountPerContext + ), }); return externalToolMetadataResponse; diff --git a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-metadata.service.ts b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-metadata.service.ts index fa8fd4fc926..53061147aca 100644 --- a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-metadata.service.ts +++ b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-metadata.service.ts @@ -28,10 +28,10 @@ export class SchoolExternalToolMetadataService { }) ); - const schoolExternaltoolMetadata: SchoolExternalToolMetadata = new SchoolExternalToolMetadata({ + const schoolExternalToolMetadata: SchoolExternalToolMetadata = new SchoolExternalToolMetadata({ contextExternalToolCountPerContext: contextExternalToolCount, }); - return schoolExternaltoolMetadata; + return schoolExternalToolMetadata; } } From fd8dd821af2e34872b9eea90062c4b162432097d Mon Sep 17 00:00:00 2001 From: WojciechGrancow <116577704+WojciechGrancow@users.noreply.github.com> Date: Tue, 28 Nov 2023 09:55:42 +0100 Subject: [PATCH 3/7] BC-5831-rewriting-regpin-deletion (#4570) * add registration-pin entity * create module registrationPin * changes in user repo and user service * add registrationPindeletion ti vs in deletion module * fix imports * Update apps/server/src/shared/domain/entity/all-entities.ts Co-authored-by: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> * Update apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts Co-authored-by: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> * Update apps/server/src/modules/registration-pin/entity/registration-pin.entity.ts Co-authored-by: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> * Update apps/server/src/shared/repo/user/user.repo.ts Co-authored-by: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> * fix imports * small fixes * small fixes * small fixes * remove spaces * add tests --------- Co-authored-by: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> --- .../types/deletion-domain-model.enum.ts | 1 + .../deletion/uc/deletion-request.uc.spec.ts | 37 ++++++++++- .../deletion/uc/deletion-request.uc.ts | 24 ++++++- .../modules/registration-pin/entity/index.ts | 1 + .../entity/registration-pin.entity.spec.ts | 57 ++++++++++++++++ .../entity/registration-pin.entity.ts | 40 +++++++++++ .../entity/testing/factory/index.ts | 1 + .../registration-pin.entity.factory.ts | 18 +++++ .../registration-pin/entity/testing/index.ts | 1 + .../src/modules/registration-pin/index.ts | 2 + .../registration-pin.module.ts | 11 ++++ .../modules/registration-pin/repo/index.ts | 1 + .../repo/registration-pin.repo.spec.ts | 64 ++++++++++++++++++ .../repo/registration-pin.repo.ts | 14 ++++ .../modules/registration-pin/service/index.ts | 1 + .../service/registration-pin.service.spec.ts | 66 +++++++++++++++++++ .../service/registration-pin.service.ts | 11 ++++ .../service/rocket-chat-user.service.spec.ts | 2 +- .../modules/user/service/user.service.spec.ts | 30 ++++++++- .../src/modules/user/service/user.service.ts | 6 ++ .../src/shared/domain/entity/all-entities.ts | 2 + .../domain/entity/user-parents.entity.spec.ts | 23 +++++++ .../domain/entity/user-parents.entity.ts | 25 +++++++ .../src/shared/domain/entity/user.entity.ts | 8 ++- .../repo/user/user.repo.integration.spec.ts | 35 ++++++++++ apps/server/src/shared/repo/user/user.repo.ts | 7 ++ 26 files changed, 483 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/modules/registration-pin/entity/index.ts create mode 100644 apps/server/src/modules/registration-pin/entity/registration-pin.entity.spec.ts create mode 100644 apps/server/src/modules/registration-pin/entity/registration-pin.entity.ts create mode 100644 apps/server/src/modules/registration-pin/entity/testing/factory/index.ts create mode 100644 apps/server/src/modules/registration-pin/entity/testing/factory/registration-pin.entity.factory.ts create mode 100644 apps/server/src/modules/registration-pin/entity/testing/index.ts create mode 100644 apps/server/src/modules/registration-pin/index.ts create mode 100644 apps/server/src/modules/registration-pin/registration-pin.module.ts create mode 100644 apps/server/src/modules/registration-pin/repo/index.ts create mode 100644 apps/server/src/modules/registration-pin/repo/registration-pin.repo.spec.ts create mode 100644 apps/server/src/modules/registration-pin/repo/registration-pin.repo.ts create mode 100644 apps/server/src/modules/registration-pin/service/index.ts create mode 100644 apps/server/src/modules/registration-pin/service/registration-pin.service.spec.ts create mode 100644 apps/server/src/modules/registration-pin/service/registration-pin.service.ts create mode 100644 apps/server/src/shared/domain/entity/user-parents.entity.spec.ts create mode 100644 apps/server/src/shared/domain/entity/user-parents.entity.ts diff --git a/apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts b/apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts index 1a4f3bcf425..daa4985498d 100644 --- a/apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts +++ b/apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts @@ -6,6 +6,7 @@ export const enum DeletionDomainModel { FILE = 'file', LESSONS = 'lessons', PSEUDONYMS = 'pseudonyms', + REGISTRATIONPIN = 'registrationPin', ROCKETCHATUSER = 'rocketChatUser', TEAMS = 'teams', USER = 'user', diff --git a/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts b/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts index 34c34e302f5..69ec72a0db5 100644 --- a/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts +++ b/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { DeepMocked, createMock } from '@golevelup/ts-jest'; -import { setupEntities } from '@shared/testing'; +import { setupEntities, userDoFactory } from '@shared/testing'; import { AccountService } from '@modules/account/services'; import { ClassService } from '@modules/class'; import { CourseGroupService, CourseService } from '@modules/learnroom/service'; @@ -12,6 +12,7 @@ import { UserService } from '@modules/user'; import { RocketChatService } from '@modules/rocketchat'; import { rocketChatUserFactory } from '@modules/rocketchat-user/domain/testing'; import { RocketChatUser, RocketChatUserService } from '@modules/rocketchat-user'; +import { RegistrationPinService } from '@modules/registration-pin'; import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; import { DeletionLogService } from '../services/deletion-log.service'; import { DeletionRequestService } from '../services'; @@ -37,6 +38,7 @@ describe(DeletionRequestUc.name, () => { let userService: DeepMocked; let rocketChatUserService: DeepMocked; let rocketChatService: DeepMocked; + let registrationPinService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -94,6 +96,10 @@ describe(DeletionRequestUc.name, () => { provide: RocketChatService, useValue: createMock(), }, + { + provide: RegistrationPinService, + useValue: createMock(), + }, ], }).compile(); @@ -111,6 +117,7 @@ describe(DeletionRequestUc.name, () => { userService = module.get(UserService); rocketChatUserService = module.get(RocketChatUserService); rocketChatService = module.get(RocketChatService); + registrationPinService = module.get(RegistrationPinService); await setupEntities(); }); @@ -168,10 +175,13 @@ describe(DeletionRequestUc.name, () => { const setup = () => { jest.clearAllMocks(); const deletionRequestToExecute = deletionRequestFactory.build({ deleteAfter: new Date('2023-01-01') }); + const user = userDoFactory.buildWithId(); const rocketChatUser: RocketChatUser = rocketChatUserFactory.build({ userId: deletionRequestToExecute.targetRefId, }); + const parentEmail = 'parent@parent.eu'; + registrationPinService.deleteRegistrationPinByEmail.mockResolvedValueOnce(2); classService.deleteUserDataFromClasses.mockResolvedValueOnce(1); courseGroupService.deleteUserDataFromCourseGroup.mockResolvedValueOnce(2); courseService.deleteUserDataFromCourse.mockResolvedValueOnce(2); @@ -186,6 +196,8 @@ describe(DeletionRequestUc.name, () => { return { deletionRequestToExecute, rocketChatUser, + user, + parentEmail, }; }; @@ -215,6 +227,29 @@ describe(DeletionRequestUc.name, () => { expect(accountService.deleteByUserId).toHaveBeenCalled(); }); + it('should call registrationPinService.deleteRegistrationPinByEmail to delete user data in registrationPin module', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(registrationPinService.deleteRegistrationPinByEmail).toHaveBeenCalled(); + }); + + it('should call userService.getParentEmailsFromUser to get parentEmails', async () => { + const { deletionRequestToExecute, user, parentEmail } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + userService.findById.mockResolvedValueOnce(user); + userService.getParentEmailsFromUser.mockRejectedValue([parentEmail]); + registrationPinService.deleteRegistrationPinByEmail.mockRejectedValueOnce(2); + + await uc.executeDeletionRequests(); + + expect(userService.getParentEmailsFromUser).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + it('should call classService.deleteUserDataFromClasses to delete user data in class module', async () => { const { deletionRequestToExecute } = setup(); diff --git a/apps/server/src/modules/deletion/uc/deletion-request.uc.ts b/apps/server/src/modules/deletion/uc/deletion-request.uc.ts index abea56fda96..7bacc428310 100644 --- a/apps/server/src/modules/deletion/uc/deletion-request.uc.ts +++ b/apps/server/src/modules/deletion/uc/deletion-request.uc.ts @@ -10,6 +10,7 @@ import { FilesService } from '@modules/files/service'; import { AccountService } from '@modules/account/services'; import { RocketChatUserService } from '@modules/rocketchat-user'; import { RocketChatService } from '@modules/rocketchat'; +import { RegistrationPinService } from '@modules/registration-pin'; import { DeletionRequestService } from '../services/deletion-request.service'; import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; import { DeletionLogService } from '../services/deletion-log.service'; @@ -42,7 +43,8 @@ export class DeletionRequestUc { private readonly teamService: TeamService, private readonly userService: UserService, private readonly rocketChatUserService: RocketChatUserService, - private readonly rocketChatService: RocketChatService + private readonly rocketChatService: RocketChatService, + private readonly registrationPinService: RegistrationPinService ) {} async createDeletionRequest(deletionRequest: DeletionRequestProps): Promise { @@ -101,6 +103,7 @@ export class DeletionRequestUc { this.removeUserFromTeams(deletionRequest), this.removeUser(deletionRequest), this.removeUserFromRocketChat(deletionRequest), + this.removeUserRegistrationPin(deletionRequest), ]); await this.deletionRequestService.markDeletionRequestAsExecuted(deletionRequest.id); } catch (error) { @@ -131,6 +134,25 @@ export class DeletionRequestUc { await this.logDeletion(deletionRequest, DeletionDomainModel.ACCOUNT, DeletionOperationModel.DELETE, 0, 1); } + private async removeUserRegistrationPin(deletionRequest: DeletionRequest) { + const userToDeletion = await this.userService.findById(deletionRequest.targetRefId); + const parentEmails = await this.userService.getParentEmailsFromUser(deletionRequest.targetRefId); + const emailsToDeletion: string[] = [userToDeletion.email, ...parentEmails]; + + const result = await Promise.all( + emailsToDeletion.map((email) => this.registrationPinService.deleteRegistrationPinByEmail(email)) + ); + const deletedRegistrationPin = result.filter((res) => res !== 0).length; + + await this.logDeletion( + deletionRequest, + DeletionDomainModel.REGISTRATIONPIN, + DeletionOperationModel.DELETE, + 0, + deletedRegistrationPin + ); + } + private async removeUserFromClasses(deletionRequest: DeletionRequest) { const classesUpdated: number = await this.classService.deleteUserDataFromClasses(deletionRequest.targetRefId); await this.logDeletion( diff --git a/apps/server/src/modules/registration-pin/entity/index.ts b/apps/server/src/modules/registration-pin/entity/index.ts new file mode 100644 index 00000000000..ed20550896f --- /dev/null +++ b/apps/server/src/modules/registration-pin/entity/index.ts @@ -0,0 +1 @@ +export * from './registration-pin.entity'; diff --git a/apps/server/src/modules/registration-pin/entity/registration-pin.entity.spec.ts b/apps/server/src/modules/registration-pin/entity/registration-pin.entity.spec.ts new file mode 100644 index 00000000000..c8570e8d1b2 --- /dev/null +++ b/apps/server/src/modules/registration-pin/entity/registration-pin.entity.spec.ts @@ -0,0 +1,57 @@ +import { setupEntities } from '@shared/testing'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { RegistrationPinEntity } from '.'; + +describe(RegistrationPinEntity.name, () => { + beforeAll(async () => { + await setupEntities(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const setup = () => { + const props = { + id: new ObjectId().toHexString(), + email: 'test@test.eu', + pin: 'test123', + verified: false, + importHash: '02a00804nnQbLbCDEMVuk56pzZ3A0SC2cYnmM9cyY25IVOnf0K3YCKqW6zxC', + }; + + return { props }; + }; + + describe('constructor', () => { + describe('When constructor is called', () => { + it('should throw an error by empty constructor', () => { + // @ts-expect-error: Test case + const test = () => new RegistrationPinEntity(); + expect(test).toThrow(); + }); + + it('should create a registrationPins by passing required properties', () => { + const { props } = setup(); + const entity: RegistrationPinEntity = new RegistrationPinEntity(props); + + expect(entity instanceof RegistrationPinEntity).toEqual(true); + }); + + it(`should return a valid object with fields values set from the provided complete props object`, () => { + const { props } = setup(); + const entity: RegistrationPinEntity = new RegistrationPinEntity(props); + + const entityProps = { + id: entity.id, + email: entity.email, + pin: entity.pin, + verified: entity.verified, + importHash: entity.importHash, + }; + + expect(entityProps).toEqual(props); + }); + }); + }); +}); diff --git a/apps/server/src/modules/registration-pin/entity/registration-pin.entity.ts b/apps/server/src/modules/registration-pin/entity/registration-pin.entity.ts new file mode 100644 index 00000000000..ee5ece7a421 --- /dev/null +++ b/apps/server/src/modules/registration-pin/entity/registration-pin.entity.ts @@ -0,0 +1,40 @@ +import { Entity, Index, Property } from '@mikro-orm/core'; +import { EntityId } from '@shared/domain'; +import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; + +export interface RegistrationPinEntityProps { + id?: EntityId; + email: string; + pin: string; + verified: boolean; + importHash: string; +} + +@Entity({ tableName: 'registrationpins' }) +@Index({ properties: ['email', 'pin'] }) +export class RegistrationPinEntity extends BaseEntityWithTimestamps { + @Property() + @Index() + email: string; + + @Property() + pin: string; + + @Property({ default: false }) + verified: boolean; + + @Property() + @Index() + importHash: string; + + constructor(props: RegistrationPinEntityProps) { + super(); + if (props.id !== undefined) { + this.id = props.id; + } + this.email = props.email; + this.pin = props.pin; + this.verified = props.verified; + this.importHash = props.importHash; + } +} diff --git a/apps/server/src/modules/registration-pin/entity/testing/factory/index.ts b/apps/server/src/modules/registration-pin/entity/testing/factory/index.ts new file mode 100644 index 00000000000..74b1134fc78 --- /dev/null +++ b/apps/server/src/modules/registration-pin/entity/testing/factory/index.ts @@ -0,0 +1 @@ +export * from './registration-pin.entity.factory'; diff --git a/apps/server/src/modules/registration-pin/entity/testing/factory/registration-pin.entity.factory.ts b/apps/server/src/modules/registration-pin/entity/testing/factory/registration-pin.entity.factory.ts new file mode 100644 index 00000000000..9a162147bed --- /dev/null +++ b/apps/server/src/modules/registration-pin/entity/testing/factory/registration-pin.entity.factory.ts @@ -0,0 +1,18 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BaseFactory } from '@shared/testing'; +import { RegistrationPinEntity, RegistrationPinEntityProps } from '../../registration-pin.entity'; + +export const registrationPinEntityFactory = BaseFactory.define( + RegistrationPinEntity, + ({ sequence }) => { + return { + id: new ObjectId().toHexString(), + email: `name-${sequence}@schul-cloud.org`, + pin: `123-${sequence}`, + verified: false, + importHash: `importHash-${sequence}`, + createdAt: new Date(), + updatedAt: new Date(), + }; + } +); diff --git a/apps/server/src/modules/registration-pin/entity/testing/index.ts b/apps/server/src/modules/registration-pin/entity/testing/index.ts new file mode 100644 index 00000000000..d847d7abce6 --- /dev/null +++ b/apps/server/src/modules/registration-pin/entity/testing/index.ts @@ -0,0 +1 @@ +export * from './factory'; diff --git a/apps/server/src/modules/registration-pin/index.ts b/apps/server/src/modules/registration-pin/index.ts new file mode 100644 index 00000000000..89a77b2fa2c --- /dev/null +++ b/apps/server/src/modules/registration-pin/index.ts @@ -0,0 +1,2 @@ +export * from './registration-pin.module'; +export { RegistrationPinService } from './service'; diff --git a/apps/server/src/modules/registration-pin/registration-pin.module.ts b/apps/server/src/modules/registration-pin/registration-pin.module.ts new file mode 100644 index 00000000000..76fa8716c94 --- /dev/null +++ b/apps/server/src/modules/registration-pin/registration-pin.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { LoggerModule } from '@src/core/logger'; +import { RegistrationPinService } from './service'; +import { RegistrationPinRepo } from './repo'; + +@Module({ + imports: [LoggerModule], + providers: [RegistrationPinService, RegistrationPinRepo], + exports: [RegistrationPinService], +}) +export class RegistrationPinModule {} diff --git a/apps/server/src/modules/registration-pin/repo/index.ts b/apps/server/src/modules/registration-pin/repo/index.ts new file mode 100644 index 00000000000..e32bd34f567 --- /dev/null +++ b/apps/server/src/modules/registration-pin/repo/index.ts @@ -0,0 +1 @@ +export * from './registration-pin.repo'; diff --git a/apps/server/src/modules/registration-pin/repo/registration-pin.repo.spec.ts b/apps/server/src/modules/registration-pin/repo/registration-pin.repo.spec.ts new file mode 100644 index 00000000000..c357351fa37 --- /dev/null +++ b/apps/server/src/modules/registration-pin/repo/registration-pin.repo.spec.ts @@ -0,0 +1,64 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { cleanupCollections, userFactory } from '@shared/testing'; +import { RegistrationPinRepo } from '.'; +import { registrationPinEntityFactory } from '../entity/testing'; + +describe(RegistrationPinRepo.name, () => { + let module: TestingModule; + let repo: RegistrationPinRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot()], + providers: [RegistrationPinRepo], + }).compile(); + + repo = module.get(RegistrationPinRepo); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('deleteRegistrationPinByEmail', () => { + const setup = async () => { + const user = userFactory.buildWithId(); + const userWithoutRegistrationPin = userFactory.buildWithId(); + const registrationPinForUser = registrationPinEntityFactory.buildWithId({ email: user.email }); + + await em.persistAndFlush(registrationPinForUser); + + return { + user, + userWithoutRegistrationPin, + }; + }; + + describe('when registrationPin exists', () => { + it('should delete registrationPins by email', async () => { + const { user } = await setup(); + + const result: number = await repo.deleteRegistrationPinByEmail(user.email); + + expect(result).toEqual(1); + }); + }); + + describe('when there is no registrationPin', () => { + it('should return empty array', async () => { + const { userWithoutRegistrationPin } = await setup(); + + const result: number = await repo.deleteRegistrationPinByEmail(userWithoutRegistrationPin.email); + expect(result).toEqual(0); + }); + }); + }); +}); diff --git a/apps/server/src/modules/registration-pin/repo/registration-pin.repo.ts b/apps/server/src/modules/registration-pin/repo/registration-pin.repo.ts new file mode 100644 index 00000000000..6ca68bc089d --- /dev/null +++ b/apps/server/src/modules/registration-pin/repo/registration-pin.repo.ts @@ -0,0 +1,14 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; +import { RegistrationPinEntity } from '../entity'; + +@Injectable() +export class RegistrationPinRepo { + constructor(private readonly em: EntityManager) {} + + async deleteRegistrationPinByEmail(email: string): Promise { + const promise: Promise = this.em.nativeDelete(RegistrationPinEntity, { email }); + + return promise; + } +} diff --git a/apps/server/src/modules/registration-pin/service/index.ts b/apps/server/src/modules/registration-pin/service/index.ts new file mode 100644 index 00000000000..c8eea287110 --- /dev/null +++ b/apps/server/src/modules/registration-pin/service/index.ts @@ -0,0 +1 @@ +export * from './registration-pin.service'; diff --git a/apps/server/src/modules/registration-pin/service/registration-pin.service.spec.ts b/apps/server/src/modules/registration-pin/service/registration-pin.service.spec.ts new file mode 100644 index 00000000000..b5c6a2f3296 --- /dev/null +++ b/apps/server/src/modules/registration-pin/service/registration-pin.service.spec.ts @@ -0,0 +1,66 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { setupEntities, userDoFactory } from '@shared/testing'; +import { RegistrationPinService } from '.'; +import { RegistrationPinRepo } from '../repo'; + +describe(RegistrationPinService.name, () => { + let module: TestingModule; + let service: RegistrationPinService; + let registrationPinRepo: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + RegistrationPinService, + { + provide: RegistrationPinRepo, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(RegistrationPinService); + registrationPinRepo = module.get(RegistrationPinRepo); + + await setupEntities(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('deleteRegistrationPinByEmail', () => { + describe('when deleting registrationPin', () => { + const setup = () => { + const user = userDoFactory.buildWithId(); + + registrationPinRepo.deleteRegistrationPinByEmail.mockResolvedValueOnce(1); + + return { + user, + }; + }; + + it('should call registrationPinRep', async () => { + const { user } = setup(); + + await service.deleteRegistrationPinByEmail(user.email); + + expect(registrationPinRepo.deleteRegistrationPinByEmail).toBeCalledWith(user.email); + }); + + it('should delete registrationPin by email', async () => { + const { user } = setup(); + + const result: number = await service.deleteRegistrationPinByEmail(user.email); + + expect(result).toEqual(1); + }); + }); + }); +}); diff --git a/apps/server/src/modules/registration-pin/service/registration-pin.service.ts b/apps/server/src/modules/registration-pin/service/registration-pin.service.ts new file mode 100644 index 00000000000..4681b08329c --- /dev/null +++ b/apps/server/src/modules/registration-pin/service/registration-pin.service.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { RegistrationPinRepo } from '../repo'; + +@Injectable() +export class RegistrationPinService { + constructor(private readonly registrationPinRepo: RegistrationPinRepo) {} + + async deleteRegistrationPinByEmail(email: string): Promise { + return this.registrationPinRepo.deleteRegistrationPinByEmail(email); + } +} diff --git a/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.spec.ts b/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.spec.ts index dd8ae17667c..57d7c2da254 100644 --- a/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.spec.ts +++ b/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.spec.ts @@ -62,7 +62,7 @@ describe(RocketChatUserService.name, () => { }); }); - describe('deleteUserDataFromClasses', () => { + describe('delete RocketChatUser', () => { describe('when deleting rocketChatUser', () => { const setup = () => { const userId = new ObjectId().toHexString(); 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 f65d02c13a5..044d169864d 100644 --- a/apps/server/src/modules/user/service/user.service.spec.ts +++ b/apps/server/src/modules/user/service/user.service.spec.ts @@ -361,7 +361,6 @@ describe('UserService', () => { describe('when deleting by userId', () => { const setup = () => { const user1: User = userFactory.asStudent().buildWithId(); - userFactory.asStudent().buildWithId(); userRepo.findById.mockResolvedValue(user1); userRepo.deleteUser.mockResolvedValue(1); @@ -381,4 +380,33 @@ describe('UserService', () => { }); }); }); + + describe('getParentEmailsFromUser', () => { + const setup = () => { + const user: User = userFactory.asStudent().buildWithId(); + const parentEmail = ['test@test.eu']; + + userRepo.getParentEmailsFromUser.mockResolvedValue(parentEmail); + + return { + user, + parentEmail, + }; + }; + + it('should call userRepo.getParentEmailsFromUse', async () => { + const { user } = setup(); + + await service.getParentEmailsFromUser(user.id); + + expect(userRepo.getParentEmailsFromUser).toBeCalledWith(user.id); + }); + + it('should return array with parent emails', async () => { + const { user, parentEmail } = setup(); + + const result = await service.getParentEmailsFromUser(user.id); + expect(result).toEqual(parentEmail); + }); + }); }); diff --git a/apps/server/src/modules/user/service/user.service.ts b/apps/server/src/modules/user/service/user.service.ts index 6ef014f8696..8f6feca4750 100644 --- a/apps/server/src/modules/user/service/user.service.ts +++ b/apps/server/src/modules/user/service/user.service.ts @@ -120,4 +120,10 @@ export class UserService { return deletedUserNumber; } + + async getParentEmailsFromUser(userId: EntityId): Promise { + const parentEmails = this.userRepo.getParentEmailsFromUser(userId); + + return parentEmails; + } } diff --git a/apps/server/src/shared/domain/entity/all-entities.ts b/apps/server/src/shared/domain/entity/all-entities.ts index 9dc33c55b78..a7ed0587f54 100644 --- a/apps/server/src/shared/domain/entity/all-entities.ts +++ b/apps/server/src/shared/domain/entity/all-entities.ts @@ -5,6 +5,7 @@ import { ShareToken } from '@modules/sharing/entity/share-token.entity'; import { ContextExternalToolEntity } from '@modules/tool/context-external-tool/entity'; import { ExternalToolEntity } from '@modules/tool/external-tool/entity'; import { SchoolExternalToolEntity } from '@modules/tool/school-external-tool/entity'; +import { RegistrationPinEntity } from '@modules/registration-pin/entity'; import { Account } from './account.entity'; import { BoardNode, @@ -100,4 +101,5 @@ export const ALL_ENTITIES = [ UserLoginMigrationEntity, VideoConference, GroupEntity, + RegistrationPinEntity, ]; diff --git a/apps/server/src/shared/domain/entity/user-parents.entity.spec.ts b/apps/server/src/shared/domain/entity/user-parents.entity.spec.ts new file mode 100644 index 00000000000..d5fe53251f9 --- /dev/null +++ b/apps/server/src/shared/domain/entity/user-parents.entity.spec.ts @@ -0,0 +1,23 @@ +import { UserParentsEntity } from './user-parents.entity'; + +describe(UserParentsEntity.name, () => { + describe('constructor', () => { + describe('When a contructor is called', () => { + const setup = () => { + const entity = new UserParentsEntity({ firstName: 'firstName', lastName: 'lastName', email: 'test@test.eu' }); + + return { entity }; + }; + + it('should contain valid tspUid ', () => { + const { entity } = setup(); + + const userParentsEntity: UserParentsEntity = new UserParentsEntity(entity); + + expect(userParentsEntity.firstName).toEqual(entity.firstName); + expect(userParentsEntity.lastName).toEqual(entity.lastName); + expect(userParentsEntity.email).toEqual(entity.email); + }); + }); + }); +}); diff --git a/apps/server/src/shared/domain/entity/user-parents.entity.ts b/apps/server/src/shared/domain/entity/user-parents.entity.ts new file mode 100644 index 00000000000..a0709396880 --- /dev/null +++ b/apps/server/src/shared/domain/entity/user-parents.entity.ts @@ -0,0 +1,25 @@ +import { Embeddable, Property } from '@mikro-orm/core'; + +export interface UserParentsEntityProps { + firstName: string; + lastName: string; + email: string; +} + +@Embeddable() +export class UserParentsEntity { + @Property() + firstName: string; + + @Property() + lastName: string; + + @Property() + email: string; + + constructor(props: UserParentsEntityProps) { + this.firstName = props.firstName; + this.lastName = props.lastName; + this.email = props.email; + } +} diff --git a/apps/server/src/shared/domain/entity/user.entity.ts b/apps/server/src/shared/domain/entity/user.entity.ts index c9a982c3854..dd5c0ec66b3 100644 --- a/apps/server/src/shared/domain/entity/user.entity.ts +++ b/apps/server/src/shared/domain/entity/user.entity.ts @@ -1,8 +1,9 @@ -import { Collection, Entity, Index, ManyToMany, ManyToOne, Property } from '@mikro-orm/core'; +import { Collection, Embedded, Entity, Index, ManyToMany, ManyToOne, Property } from '@mikro-orm/core'; import { EntityWithSchool } from '../interface'; import { BaseEntityWithTimestamps } from './base.entity'; import { Role } from './role.entity'; import { SchoolEntity } from './school.entity'; +import { UserParentsEntity } from './user-parents.entity'; export enum LanguageType { DE = 'de', @@ -27,6 +28,7 @@ export interface UserProperties { outdatedSince?: Date; previousExternalId?: string; birthday?: Date; + parents?: UserParentsEntity[]; } @Entity({ tableName: 'users' }) @@ -100,6 +102,9 @@ export class User extends BaseEntityWithTimestamps implements EntityWithSchool { @Property({ nullable: true }) birthday?: Date; + @Embedded(() => UserParentsEntity, { array: true, nullable: true }) + parents?: UserParentsEntity[]; + constructor(props: UserProperties) { super(); this.firstName = props.firstName; @@ -117,6 +122,7 @@ export class User extends BaseEntityWithTimestamps implements EntityWithSchool { this.outdatedSince = props.outdatedSince; this.previousExternalId = props.previousExternalId; this.birthday = props.birthday; + this.parents = props.parents; } public resolvePermissions(): string[] { diff --git a/apps/server/src/shared/repo/user/user.repo.integration.spec.ts b/apps/server/src/shared/repo/user/user.repo.integration.spec.ts index a923b8d128f..1ea116d995a 100644 --- a/apps/server/src/shared/repo/user/user.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/user/user.repo.integration.spec.ts @@ -11,6 +11,7 @@ import { systemEntityFactory, userFactory, } from '@shared/testing'; +import { UserParentsEntityProps } from '@shared/domain/entity/user-parents.entity'; import { UserRepo } from './user.repo'; describe('user repo', () => { @@ -70,6 +71,7 @@ describe('user repo', () => { 'externalId', 'forcePasswordChange', 'importHash', + 'parents', 'preferences', 'language', 'deletedAt', @@ -160,6 +162,7 @@ describe('user repo', () => { 'externalId', 'forcePasswordChange', 'importHash', + 'parents', 'preferences', 'language', 'deletedAt', @@ -449,4 +452,36 @@ describe('user repo', () => { }); }); }); + + describe('getParentEmailsFromUser', () => { + const setup = async () => { + const parentOfUser: UserParentsEntityProps = { + firstName: 'firstName', + lastName: 'lastName', + email: 'test@test.eu', + }; + const user = userFactory.asStudent().buildWithId({ + parents: [parentOfUser], + }); + + const expectedParentEmail = [parentOfUser.email]; + + await em.persistAndFlush(user); + em.clear(); + + return { + user, + expectedParentEmail, + }; + }; + + describe('when searching user parent email', () => { + it('should return array witn parent email', async () => { + const { user, expectedParentEmail } = await setup(); + const result = await repo.getParentEmailsFromUser(user.id); + + expect(result).toEqual(expectedParentEmail); + }); + }); + }); }); diff --git a/apps/server/src/shared/repo/user/user.repo.ts b/apps/server/src/shared/repo/user/user.repo.ts index 44acafe6a80..2f693ab7124 100644 --- a/apps/server/src/shared/repo/user/user.repo.ts +++ b/apps/server/src/shared/repo/user/user.repo.ts @@ -170,6 +170,13 @@ export class UserRepo extends BaseRepo { return deletedUserNumber; } + async getParentEmailsFromUser(userId: EntityId): Promise { + const user = await this._em.findOneOrFail(User, { id: userId }); + const parentsEmails = user.parents?.map((parent) => parent.email) ?? []; + + return parentsEmails; + } + private async populateRoles(roles: Role[]): Promise { for (let i = 0; i < roles.length; i += 1) { const role = roles[i]; From af700bba683047275897c2534a68bb3458d2d0c4 Mon Sep 17 00:00:00 2001 From: Bartosz Nowicki <116367402+bn-pass@users.noreply.github.com> Date: Tue, 28 Nov 2023 10:27:24 +0100 Subject: [PATCH 4/7] BC-5628 cyclic data deletion (#4557) * first commit * add some tests * add test cases and services * add new (almost empty for now) batch deletion app * refactor config vars * add optional env var for specifying delay between the API calls * add usecases and test cases * fix importing * add type in uc * fix import * add references service that'll load all the references to the data we want to delete * fix most of issue form review * add deletion API client with just a single method for now that allows for sending a deletion request * refactor the env vars for configurting the Admin API * add exporting DeletionClientConfig * move references service to the deletion module * delete unused code * add batch deletion service that makes it possible ot queue deletion for many references at once * move some parts of the interface to the interface subdir * add an interface for the batch deletion summary * move some interfaces to a separate subdir * refactor the batch deletion summary interface * add uc for the batch deletion * remove unused annotation * refactor deletion client implementation * add batch deletion service implementation * add UC for the batch deletion * add a console app for the deletion module and a console command to manage deletion requests queue * remove no longer used app, add param to make it possible to define delay between the client calls for the case one would like to queue many thousands of deletion requests at once * remove no longer used separate batch-deletion module (it became a part of the main deletion module) * fix invalid key * remove no longer used config vars * remove no longer used commands * remove no longer used Nest cli config * remove no longer used code * change name of the method that prepares default headers * add builders for most of the interfaces * add builders for the remaining interfaces * add type in catch clause * do some adjustments, move PushDeletionRequestsOptions interface to a separate file * remove unused import * rollback * remove unnecessary indent * remove unnecessary indents * remove empty line * remove repeated imports * refactor some imports to omit calling Configuration.get() on every subpackage import * add builder for the DeletionRequestOutput class * add unit tests for the batch deletion service * add unit tests for the BatchDeletionUc * modify env keys for the Admin API client configuration, refactor the way the deletion module's console is bootstrapped * fix invalid import, remove unused undefined arg * add comment to ignore console.ts file for coverage * move deletion client config interface to a separate file, refactor function that prepares current config, add unit tests for it * fix invalid import * add more test cases to the deletion client unit tests * change invalid import Co-authored-by: WojciechGrancow <116577704+WojciechGrancow@users.noreply.github.com> * fix invalid import * add builder for the PushDeletionRequestsOptions class, add unit tests for the DeletionQueueConsole * rename the file containing the deletion module console to deletion.console.ts, add coverage exclusion for it for the Sonar coverage analysis * remove deletion.console.ts from the sonar.coverage.exclusions param as it doesn't seem to work anyway * add deletion.console.ts file to the coverage exclusions (another try with different path) * change name of the file containing the deletion console app * add deletion client method that allows for triggering a deletion requests executions * fix some imports * move default value for the ADMIN_API_CLIENT object to default.schema.json * move default for the BASE_URL * move Deletion module console app to the apps/ dir * add separate functino to log error and set exit code * add handling of the case that only CR chars are used as a line separators * add use of the BatchDeletionSummaryBuilder in place of an anonymous object creation * fix some imports/exports * add an interface for the deletion execution trigger result, add builder for it as well * add use case for the deletion executions * add new interface for the deletion execution trigger options, add builder for it * add console command for triggering the deletion execution * add Admin API client secret provisioned by 1Password * add data deletion trigger cronjob * add metadata to the data deletion trigger CronJob * add task to add data deletion trigger CronJob * rewrite HTTP client execution to try/catch block, modify types of returned errors to meet current project's convention * modify the solution to not catch the client's exception in the use case, but in the console app's command * add more test cases * delete unnecessary cronjob label Co-authored-by: mamutmk5 <3045922+mamutmk5@users.noreply.github.com> * merge labels into metadata Co-authored-by: mamutmk5 <3045922+mamutmk5@users.noreply.github.com> * BC-5628 - move logic of errors * adjusted the second client method according to Sergej's proposal * Update apps/server/src/modules/deletion/client/deletion.client.ts Co-authored-by: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> * remove unnecessary label --------- Co-authored-by: WojciechGrancow Co-authored-by: WojciechGrancow <116577704+WojciechGrancow@users.noreply.github.com> Co-authored-by: mamutmk5 <3045922+mamutmk5@users.noreply.github.com> Co-authored-by: SevenWaysDP Co-authored-by: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> --- .../schulcloud-server-core/tasks/main.yml | 13 +++ .../data-deletion-trigger-cronjob.yml.j2 | 57 +++++++++ .../onepassword-admin-api-client.yml.j2 | 7 ++ .../deletion/client/deletion.client.spec.ts | 70 ++++++++++- .../deletion/client/deletion.client.ts | 93 ++++++++++----- ...n-execution-trigger-result.builder.spec.ts | 45 ++++++++ ...letion-execution-trigger-result.builder.ts | 21 ++++ .../modules/deletion/console/builder/index.ts | 2 + ...deletion-execution-options.builder.spec.ts | 24 ++++ ...gger-deletion-execution-options.builder.ts | 7 ++ .../console/deletion-console.module.ts | 12 +- .../deletion-execution.console.spec.ts | 109 ++++++++++++++++++ .../console/deletion-execution.console.ts | 38 ++++++ .../deletion-execution-trigger-result.ts | 6 + .../deletion-execution-trigger-status.enum.ts | 4 + .../deletion/console/interface/index.ts | 3 + ...er-deletion-execution-options.interface.ts | 3 + .../deletion/uc/deletion-execution.uc.spec.ts | 69 +++++++++++ .../deletion/uc/deletion-execution.uc.ts | 11 ++ apps/server/src/modules/deletion/uc/index.ts | 1 + 20 files changed, 561 insertions(+), 34 deletions(-) create mode 100644 ansible/roles/schulcloud-server-core/templates/data-deletion-trigger-cronjob.yml.j2 create mode 100644 ansible/roles/schulcloud-server-core/templates/onepassword-admin-api-client.yml.j2 create mode 100644 apps/server/src/modules/deletion/console/builder/deletion-execution-trigger-result.builder.spec.ts create mode 100644 apps/server/src/modules/deletion/console/builder/deletion-execution-trigger-result.builder.ts create mode 100644 apps/server/src/modules/deletion/console/builder/trigger-deletion-execution-options.builder.spec.ts create mode 100644 apps/server/src/modules/deletion/console/builder/trigger-deletion-execution-options.builder.ts create mode 100644 apps/server/src/modules/deletion/console/deletion-execution.console.spec.ts create mode 100644 apps/server/src/modules/deletion/console/deletion-execution.console.ts create mode 100644 apps/server/src/modules/deletion/console/interface/deletion-execution-trigger-result.ts create mode 100644 apps/server/src/modules/deletion/console/interface/deletion-execution-trigger-status.enum.ts create mode 100644 apps/server/src/modules/deletion/console/interface/trigger-deletion-execution-options.interface.ts create mode 100644 apps/server/src/modules/deletion/uc/deletion-execution.uc.spec.ts create mode 100644 apps/server/src/modules/deletion/uc/deletion-execution.uc.ts diff --git a/ansible/roles/schulcloud-server-core/tasks/main.yml b/ansible/roles/schulcloud-server-core/tasks/main.yml index 64e257c5f59..e64021b5f67 100644 --- a/ansible/roles/schulcloud-server-core/tasks/main.yml +++ b/ansible/roles/schulcloud-server-core/tasks/main.yml @@ -37,6 +37,13 @@ template: onepassword.yml.j2 when: ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool + - name: Admin API client secret (from 1Password) + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: onepassword-admin-api-client.yml.j2 + when: ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool + - name: remove old migration Job kubernetes.core.k8s: kubeconfig: ~/.kube/config @@ -108,6 +115,12 @@ namespace: "{{ NAMESPACE }}" template: api-delete-s3-files-cronjob.yml.j2 + - name: Data deletion trigger CronJob + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: data-deletion-trigger-cronjob.yml.j2 + - name: AMQPFileStorageDeployment kubernetes.core.k8s: kubeconfig: ~/.kube/config diff --git a/ansible/roles/schulcloud-server-core/templates/data-deletion-trigger-cronjob.yml.j2 b/ansible/roles/schulcloud-server-core/templates/data-deletion-trigger-cronjob.yml.j2 new file mode 100644 index 00000000000..a0807973c43 --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/data-deletion-trigger-cronjob.yml.j2 @@ -0,0 +1,57 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + namespace: {{ NAMESPACE }} + labels: + app: data-deletion-trigger + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: data-deletion-trigger + app.kubernetes.io/component: data-deletion + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} + name: data-deletion-trigger-cronjob +spec: + concurrencyPolicy: Forbid + schedule: "{{ SERVER_DATA_DELETION_TRIGGER_CRONJOB_SCHEDULE|default("@hourly", true) }}" + jobTemplate: + metadata: + labels: + app: data-deletion-trigger + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: data-deletion-trigger + app.kubernetes.io/component: data-deletion + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} + spec: + template: + spec: + containers: + - name: data-deletion-trigger-cronjob + image: {{ SCHULCLOUD_SERVER_IMAGE }}:{{ SCHULCLOUD_SERVER_IMAGE_TAG }} + envFrom: + - secretRef: + name: admin-api-client-secret + command: ['/bin/sh', '-c'] + args: ['npm run nest:start:deletion-console -- execution trigger'] + resources: + limits: + cpu: {{ API_CPU_LIMITS|default("2000m", true) }} + memory: {{ API_MEMORY_LIMITS|default("2Gi", true) }} + requests: + cpu: {{ API_CPU_REQUESTS|default("100m", true) }} + memory: {{ API_MEMORY_REQUESTS|default("150Mi", true) }} + restartPolicy: OnFailure + metadata: + labels: + app: data-deletion-trigger + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: data-deletion-trigger + app.kubernetes.io/component: data-deletion + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} \ No newline at end of file diff --git a/ansible/roles/schulcloud-server-core/templates/onepassword-admin-api-client.yml.j2 b/ansible/roles/schulcloud-server-core/templates/onepassword-admin-api-client.yml.j2 new file mode 100644 index 00000000000..fe2be1d76a8 --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/onepassword-admin-api-client.yml.j2 @@ -0,0 +1,7 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: admin-api-client-secret + namespace: {{ NAMESPACE }} +spec: + itemPath: "vaults/{{ ONEPASSWORD_OPERATOR_VAULT }}/items/admin-api-client" diff --git a/apps/server/src/modules/deletion/client/deletion.client.spec.ts b/apps/server/src/modules/deletion/client/deletion.client.spec.ts index 096b1f9b082..478f23a2348 100644 --- a/apps/server/src/modules/deletion/client/deletion.client.spec.ts +++ b/apps/server/src/modules/deletion/client/deletion.client.spec.ts @@ -1,4 +1,4 @@ -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { AxiosResponse } from 'axios'; import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; @@ -51,6 +51,23 @@ describe(DeletionClient.name, () => { }); describe('queueDeletionRequest', () => { + describe('when sending the HTTP request failed', () => { + const setup = () => { + const input = DeletionRequestInputBuilder.build('user', '652f1625e9bc1a13bdaae48b'); + + const error = new Error('unknown error'); + httpService.post.mockReturnValueOnce(throwError(() => error)); + + return { input }; + }; + + it('should catch and throw an error', async () => { + const { input } = setup(); + + await expect(client.queueDeletionRequest(input)).rejects.toThrow(Error); + }); + }); + describe('when received valid response with expected HTTP status code', () => { const setup = () => { const input = DeletionRequestInputBuilder.build('user', '652f1625e9bc1a13bdaae48b'); @@ -151,4 +168,55 @@ describe(DeletionClient.name, () => { }); }); }); + + describe('executeDeletions', () => { + describe('when sending the HTTP request failed', () => { + const setup = () => { + const error = new Error('unknown error'); + httpService.post.mockReturnValueOnce(throwError(() => error)); + }; + + it('should catch and throw an error', async () => { + setup(); + + await expect(client.executeDeletions()).rejects.toThrow(Error); + }); + }); + + describe('when received valid response with expected HTTP status code', () => { + const setup = () => { + const limit = 10; + + const response: AxiosResponse = axiosResponseFactory.build({ + status: 204, + }); + + httpService.post.mockReturnValueOnce(of(response)); + + return { limit }; + }; + + it('should return proper output', async () => { + const { limit } = setup(); + + await expect(client.executeDeletions(limit)).resolves.not.toThrow(); + }); + }); + + describe('when received invalid HTTP status code in a response', () => { + const setup = () => { + const response: AxiosResponse = axiosResponseFactory.build({ + status: 200, + }); + + httpService.post.mockReturnValueOnce(of(response)); + }; + + it('should throw an exception', async () => { + setup(); + + await expect(client.executeDeletions()).rejects.toThrow(Error); + }); + }); + }); }); diff --git a/apps/server/src/modules/deletion/client/deletion.client.ts b/apps/server/src/modules/deletion/client/deletion.client.ts index 66bb267d070..a3c47844656 100644 --- a/apps/server/src/modules/deletion/client/deletion.client.ts +++ b/apps/server/src/modules/deletion/client/deletion.client.ts @@ -1,9 +1,9 @@ -import { firstValueFrom } from 'rxjs'; -import { AxiosResponse } from 'axios'; -import { Injectable } from '@nestjs/common'; import { HttpService } from '@nestjs/axios'; +import { BadGatewayException, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { DeletionRequestInput, DeletionRequestOutput, DeletionClientConfig } from './interface'; +import { ErrorUtils } from '@src/core/error/utils'; +import { firstValueFrom } from 'rxjs'; +import { DeletionClientConfig, DeletionRequestInput, DeletionRequestOutput } from './interface'; @Injectable() export class DeletionClient { @@ -13,6 +13,8 @@ export class DeletionClient { private readonly postDeletionRequestsEndpoint: string; + private readonly postDeletionExecutionsEndpoint: string; + constructor( private readonly httpService: HttpService, private readonly configService: ConfigService @@ -22,36 +24,65 @@ export class DeletionClient { // Prepare the POST /deletionRequests endpoint beforehand to not do it on every client call. this.postDeletionRequestsEndpoint = new URL('/admin/api/v1/deletionRequests', this.baseUrl).toString(); + this.postDeletionExecutionsEndpoint = new URL('/admin/api/v1/deletionExecutions', this.baseUrl).toString(); } async queueDeletionRequest(input: DeletionRequestInput): Promise { - const request = this.httpService.post(this.postDeletionRequestsEndpoint, input, this.defaultHeaders()); - - return firstValueFrom(request) - .then((resp: AxiosResponse) => { - // Throw an error if any other status code (other than expected "202 Accepted" is returned). - if (resp.status !== 202) { - throw new Error(`invalid HTTP status code in a response from the server - ${resp.status} instead of 202`); - } - - // Throw an error if server didn't return a requestId in a response (and it is - // required as it gives client the reference to the created deletion request). - if (!resp.data.requestId) { - throw new Error('no valid requestId returned from the server'); - } - - // Throw an error if server didn't return a deletionPlannedAt timestamp so the user - // will not be aware after which date the deletion request's execution will begin. - if (!resp.data.deletionPlannedAt) { - throw new Error('no valid deletionPlannedAt returned from the server'); - } - - return resp.data; - }) - .catch((err: Error) => { - // Throw an error if sending/processing deletion request by the client failed in any way. - throw new Error(`failed to send/process a deletion request: ${err.toString()}`); - }); + try { + const request = this.httpService.post( + this.postDeletionRequestsEndpoint, + input, + this.defaultHeaders() + ); + + const resp = await firstValueFrom(request); + + // Throw an error if any other status code (other than expected "202 Accepted" is returned). + if (resp.status !== 202) { + throw new Error(`invalid HTTP status code in a response from the server - ${resp.status} instead of 202`); + } + + // Throw an error if server didn't return a requestId in a response (and it is + // required as it gives client the reference to the created deletion request). + if (!resp.data.requestId) { + throw new Error('no valid requestId returned from the server'); + } + + // Throw an error if server didn't return a deletionPlannedAt timestamp so the user + // will not be aware after which date the deletion request's execution will begin. + if (!resp.data.deletionPlannedAt) { + throw Error('no valid deletionPlannedAt returned from the server'); + } + + return resp.data; + } catch (err) { + // Throw an error if sending deletion request has failed. + throw new BadGatewayException('DeletionClient:queueDeletionRequest', ErrorUtils.createHttpExceptionOptions(err)); + } + } + + async executeDeletions(limit?: number): Promise { + let requestConfig = {}; + + if (limit && limit > 0) { + requestConfig = { ...this.defaultHeaders(), params: { limit } }; + } else { + requestConfig = { ...this.defaultHeaders() }; + } + + try { + const request = this.httpService.post(this.postDeletionExecutionsEndpoint, null, requestConfig); + + const resp = await firstValueFrom(request); + + if (resp.status !== 204) { + // Throw an error if any other status code (other than expected "204 No Content" is returned). + throw new Error(`invalid HTTP status code in a response from the server - ${resp.status} instead of 204`); + } + } catch (err) { + // Throw an error if sending deletion request(s) execution trigger has failed. + throw new BadGatewayException('DeletionClient:executeDeletions', ErrorUtils.createHttpExceptionOptions(err)); + } } private apiKeyHeader() { diff --git a/apps/server/src/modules/deletion/console/builder/deletion-execution-trigger-result.builder.spec.ts b/apps/server/src/modules/deletion/console/builder/deletion-execution-trigger-result.builder.spec.ts new file mode 100644 index 00000000000..b0217e6a2c2 --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/deletion-execution-trigger-result.builder.spec.ts @@ -0,0 +1,45 @@ +import { DeletionExecutionTriggerResult, DeletionExecutionTriggerStatus } from '../interface'; +import { DeletionExecutionTriggerResultBuilder } from './deletion-execution-trigger-result.builder'; + +describe(DeletionExecutionTriggerResultBuilder.name, () => { + describe(DeletionExecutionTriggerResultBuilder.buildSuccess.name, () => { + describe('when called', () => { + const setup = () => { + const expectedOutput: DeletionExecutionTriggerResult = { status: DeletionExecutionTriggerStatus.SUCCESS }; + + return { expectedOutput }; + }; + + it('should return valid object indicating success', () => { + const { expectedOutput } = setup(); + + const output = DeletionExecutionTriggerResultBuilder.buildSuccess(); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); + + describe(DeletionExecutionTriggerResultBuilder.buildFailure.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const error = new Error('test error message'); + + const expectedOutput: DeletionExecutionTriggerResult = { + status: DeletionExecutionTriggerStatus.FAILURE, + error: error.toString(), + }; + + return { error, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { error, expectedOutput } = setup(); + + const output = DeletionExecutionTriggerResultBuilder.buildFailure(error); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/console/builder/deletion-execution-trigger-result.builder.ts b/apps/server/src/modules/deletion/console/builder/deletion-execution-trigger-result.builder.ts new file mode 100644 index 00000000000..e660e6905ea --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/deletion-execution-trigger-result.builder.ts @@ -0,0 +1,21 @@ +import { DeletionExecutionTriggerResult, DeletionExecutionTriggerStatus } from '../interface'; + +export class DeletionExecutionTriggerResultBuilder { + private static build(status: DeletionExecutionTriggerStatus, error?: string): DeletionExecutionTriggerResult { + const output: DeletionExecutionTriggerResult = { status }; + + if (error) { + output.error = error; + } + + return output; + } + + static buildSuccess(): DeletionExecutionTriggerResult { + return this.build(DeletionExecutionTriggerStatus.SUCCESS); + } + + static buildFailure(err: Error): DeletionExecutionTriggerResult { + return this.build(DeletionExecutionTriggerStatus.FAILURE, err.toString()); + } +} diff --git a/apps/server/src/modules/deletion/console/builder/index.ts b/apps/server/src/modules/deletion/console/builder/index.ts index 12fd0997ebe..985edf66371 100644 --- a/apps/server/src/modules/deletion/console/builder/index.ts +++ b/apps/server/src/modules/deletion/console/builder/index.ts @@ -1 +1,3 @@ export * from './push-delete-requests-options.builder'; +export * from './trigger-deletion-execution-options.builder'; +export * from './deletion-execution-trigger-result.builder'; diff --git a/apps/server/src/modules/deletion/console/builder/trigger-deletion-execution-options.builder.spec.ts b/apps/server/src/modules/deletion/console/builder/trigger-deletion-execution-options.builder.spec.ts new file mode 100644 index 00000000000..21171adb405 --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/trigger-deletion-execution-options.builder.spec.ts @@ -0,0 +1,24 @@ +import { TriggerDeletionExecutionOptions } from '../interface'; +import { TriggerDeletionExecutionOptionsBuilder } from './trigger-deletion-execution-options.builder'; + +describe(TriggerDeletionExecutionOptionsBuilder.name, () => { + describe(TriggerDeletionExecutionOptionsBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const limit = 1000; + + const expectedOutput: TriggerDeletionExecutionOptions = { limit }; + + return { limit, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { limit, expectedOutput } = setup(); + + const output = TriggerDeletionExecutionOptionsBuilder.build(limit); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/console/builder/trigger-deletion-execution-options.builder.ts b/apps/server/src/modules/deletion/console/builder/trigger-deletion-execution-options.builder.ts new file mode 100644 index 00000000000..ed652006e9d --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/trigger-deletion-execution-options.builder.ts @@ -0,0 +1,7 @@ +import { TriggerDeletionExecutionOptions } from '../interface'; + +export class TriggerDeletionExecutionOptionsBuilder { + static build(limit: number): TriggerDeletionExecutionOptions { + return { limit }; + } +} 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 0585b3631da..504c1c35885 100644 --- a/apps/server/src/modules/deletion/console/deletion-console.module.ts +++ b/apps/server/src/modules/deletion/console/deletion-console.module.ts @@ -7,8 +7,9 @@ import { createConfigModuleOptions } from '@src/config'; import { DeletionClient } from '../client'; import { getDeletionClientConfig } from '../client/deletion-client.config'; import { BatchDeletionService } from '../services'; -import { BatchDeletionUc } from '../uc'; +import { BatchDeletionUc, DeletionExecutionUc } from '../uc'; import { DeletionQueueConsole } from './deletion-queue.console'; +import { DeletionExecutionConsole } from './deletion-execution.console'; @Module({ imports: [ @@ -17,6 +18,13 @@ import { DeletionQueueConsole } from './deletion-queue.console'; HttpModule, ConfigModule.forRoot(createConfigModuleOptions(getDeletionClientConfig)), ], - providers: [DeletionClient, BatchDeletionService, BatchDeletionUc, DeletionQueueConsole], + providers: [ + DeletionClient, + BatchDeletionService, + BatchDeletionUc, + DeletionExecutionUc, + DeletionQueueConsole, + DeletionExecutionConsole, + ], }) export class DeletionConsoleModule {} diff --git a/apps/server/src/modules/deletion/console/deletion-execution.console.spec.ts b/apps/server/src/modules/deletion/console/deletion-execution.console.spec.ts new file mode 100644 index 00000000000..39519bab6a9 --- /dev/null +++ b/apps/server/src/modules/deletion/console/deletion-execution.console.spec.ts @@ -0,0 +1,109 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ConsoleWriterService } from '@infra/console'; +import { DeletionExecutionUc } from '../uc'; +import { DeletionExecutionConsole } from './deletion-execution.console'; +import { DeletionExecutionTriggerResultBuilder, TriggerDeletionExecutionOptionsBuilder } from './builder'; + +describe(DeletionExecutionConsole.name, () => { + let module: TestingModule; + let console: DeletionExecutionConsole; + let deletionExecutionUc: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + DeletionExecutionConsole, + { + provide: ConsoleWriterService, + useValue: createMock(), + }, + { + provide: DeletionExecutionUc, + useValue: createMock(), + }, + ], + }).compile(); + + console = module.get(DeletionExecutionConsole); + deletionExecutionUc = module.get(DeletionExecutionUc); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + it('console should be defined', () => { + expect(console).toBeDefined(); + }); + + describe('triggerDeletionExecution', () => { + describe('when called with valid options', () => { + const setup = () => { + const limit = 1000; + + const options = TriggerDeletionExecutionOptionsBuilder.build(1000); + + return { limit, options }; + }; + + it(`should call ${DeletionExecutionUc.name} with proper arguments`, async () => { + const { limit, options } = setup(); + + const spy = jest.spyOn(deletionExecutionUc, 'triggerDeletionExecution'); + + await console.triggerDeletionExecution(options); + + expect(spy).toBeCalledWith(limit); + }); + }); + + describe(`when ${DeletionExecutionUc.name}'s triggerDeletionExecution() method doesn't throw an exception`, () => { + const setup = () => { + const options = TriggerDeletionExecutionOptionsBuilder.build(1000); + + deletionExecutionUc.triggerDeletionExecution.mockResolvedValueOnce(undefined); + + const spy = jest.spyOn(DeletionExecutionTriggerResultBuilder, 'buildSuccess'); + + return { options, spy }; + }; + + it('should prepare result indicating success', async () => { + const { options, spy } = setup(); + + await console.triggerDeletionExecution(options); + + expect(spy).toHaveBeenCalled(); + }); + }); + + describe(`when ${DeletionExecutionUc.name}'s triggerDeletionExecution() method throws an exception`, () => { + const setup = () => { + const options = TriggerDeletionExecutionOptionsBuilder.build(1000); + + const err = new Error('some error occurred...'); + + deletionExecutionUc.triggerDeletionExecution.mockRejectedValueOnce(err); + + // const spy = jest.spyOn(ErrorMapper, 'mapRpcErrorResponseToDomainError'); + + const spy = jest.spyOn(DeletionExecutionTriggerResultBuilder, 'buildFailure'); + + return { options, err, spy }; + }; + + it('should prepare result indicating failure', async () => { + const { options, err, spy } = setup(); + + await console.triggerDeletionExecution(options); + + expect(spy).toHaveBeenCalledWith(err); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/console/deletion-execution.console.ts b/apps/server/src/modules/deletion/console/deletion-execution.console.ts new file mode 100644 index 00000000000..d9fdabe0160 --- /dev/null +++ b/apps/server/src/modules/deletion/console/deletion-execution.console.ts @@ -0,0 +1,38 @@ +import { Command, Console } from 'nestjs-console'; +import { ConsoleWriterService } from '@infra/console'; +import { DeletionExecutionUc } from '../uc'; +import { DeletionExecutionTriggerResultBuilder } from './builder'; +import { DeletionExecutionTriggerResult, TriggerDeletionExecutionOptions } from './interface'; + +@Console({ command: 'execution', description: 'Console providing an access to the deletion execution(s).' }) +export class DeletionExecutionConsole { + constructor(private consoleWriter: ConsoleWriterService, private deletionExecutionUc: DeletionExecutionUc) {} + + @Command({ + command: 'trigger', + description: 'Trigger execution of deletion requests.', + options: [ + { + flags: '-l, --limit ', + description: 'Limit of the requested deletion executions that should be performed.', + required: false, + }, + ], + }) + async triggerDeletionExecution(options: TriggerDeletionExecutionOptions): Promise { + // Try to trigger the deletion execution(s) via Deletion API client, + // return successful status in case of a success, otherwise return + // a result with a failure status and a proper error message. + let result: DeletionExecutionTriggerResult; + + try { + await this.deletionExecutionUc.triggerDeletionExecution(options.limit ? Number(options.limit) : undefined); + + result = DeletionExecutionTriggerResultBuilder.buildSuccess(); + } catch (err) { + result = DeletionExecutionTriggerResultBuilder.buildFailure(err as Error); + } + + this.consoleWriter.info(JSON.stringify(result)); + } +} diff --git a/apps/server/src/modules/deletion/console/interface/deletion-execution-trigger-result.ts b/apps/server/src/modules/deletion/console/interface/deletion-execution-trigger-result.ts new file mode 100644 index 00000000000..787100ec048 --- /dev/null +++ b/apps/server/src/modules/deletion/console/interface/deletion-execution-trigger-result.ts @@ -0,0 +1,6 @@ +import { DeletionExecutionTriggerStatus } from './deletion-execution-trigger-status.enum'; + +export interface DeletionExecutionTriggerResult { + status: DeletionExecutionTriggerStatus; + error?: string; +} diff --git a/apps/server/src/modules/deletion/console/interface/deletion-execution-trigger-status.enum.ts b/apps/server/src/modules/deletion/console/interface/deletion-execution-trigger-status.enum.ts new file mode 100644 index 00000000000..2b241cf72fc --- /dev/null +++ b/apps/server/src/modules/deletion/console/interface/deletion-execution-trigger-status.enum.ts @@ -0,0 +1,4 @@ +export const enum DeletionExecutionTriggerStatus { + SUCCESS = 'success', + FAILURE = 'failure', +} diff --git a/apps/server/src/modules/deletion/console/interface/index.ts b/apps/server/src/modules/deletion/console/interface/index.ts index 2fcb281430f..b15a668b53e 100644 --- a/apps/server/src/modules/deletion/console/interface/index.ts +++ b/apps/server/src/modules/deletion/console/interface/index.ts @@ -1 +1,4 @@ export * from './push-delete-requests-options.interface'; +export * from './trigger-deletion-execution-options.interface'; +export * from './deletion-execution-trigger-status.enum'; +export * from './deletion-execution-trigger-result'; diff --git a/apps/server/src/modules/deletion/console/interface/trigger-deletion-execution-options.interface.ts b/apps/server/src/modules/deletion/console/interface/trigger-deletion-execution-options.interface.ts new file mode 100644 index 00000000000..b17aafa1112 --- /dev/null +++ b/apps/server/src/modules/deletion/console/interface/trigger-deletion-execution-options.interface.ts @@ -0,0 +1,3 @@ +export interface TriggerDeletionExecutionOptions { + limit: number; +} diff --git a/apps/server/src/modules/deletion/uc/deletion-execution.uc.spec.ts b/apps/server/src/modules/deletion/uc/deletion-execution.uc.spec.ts new file mode 100644 index 00000000000..39c8065645a --- /dev/null +++ b/apps/server/src/modules/deletion/uc/deletion-execution.uc.spec.ts @@ -0,0 +1,69 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { DeletionClient } from '../client'; +import { DeletionExecutionUc } from './deletion-execution.uc'; + +describe(DeletionExecutionUc.name, () => { + let module: TestingModule; + let uc: DeletionExecutionUc; + let deletionClient: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + DeletionExecutionUc, + { + provide: DeletionClient, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(DeletionExecutionUc); + deletionClient = module.get(DeletionClient); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + it('uc should be defined', () => { + expect(uc).toBeDefined(); + }); + + describe('triggerDeletionExecution', () => { + describe("when client doesn't throw any error", () => { + const setup = () => { + const limit = 1000; + + deletionClient.executeDeletions.mockResolvedValueOnce(undefined); + + return { limit }; + }; + + it('should also not throw an error', async () => { + const { limit } = setup(); + + await expect(uc.triggerDeletionExecution(limit)).resolves.not.toThrow(); + }); + }); + + describe('when client throws an error', () => { + const setup = () => { + const error = new Error('connection error'); + + deletionClient.executeDeletions.mockRejectedValueOnce(error); + }; + + it('should also throw an error', async () => { + setup(); + + await expect(uc.triggerDeletionExecution()).rejects.toThrow(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/uc/deletion-execution.uc.ts b/apps/server/src/modules/deletion/uc/deletion-execution.uc.ts new file mode 100644 index 00000000000..ad4c90c567d --- /dev/null +++ b/apps/server/src/modules/deletion/uc/deletion-execution.uc.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { DeletionClient } from '../client'; + +@Injectable() +export class DeletionExecutionUc { + constructor(private readonly deletionClient: DeletionClient) {} + + async triggerDeletionExecution(limit?: number): Promise { + await this.deletionClient.executeDeletions(limit); + } +} diff --git a/apps/server/src/modules/deletion/uc/index.ts b/apps/server/src/modules/deletion/uc/index.ts index cf74de969e5..4b1451b563d 100644 --- a/apps/server/src/modules/deletion/uc/index.ts +++ b/apps/server/src/modules/deletion/uc/index.ts @@ -1,2 +1,3 @@ export * from './interface'; export * from './batch-deletion.uc'; +export * from './deletion-execution.uc'; From 9f69eedc55dfa457ab89368682c762aeea7d5475 Mon Sep 17 00:00:00 2001 From: blazejpass <118356546+blazejpass@users.noreply.github.com> Date: Tue, 28 Nov 2023 11:45:58 +0100 Subject: [PATCH 5/7] Bc 4710 new tldraw manage (#4352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Introduce tldraw to sc-server --------- Co-authored-by: Błażej Szczepanowski Co-authored-by: davwas Co-authored-by: Tomasz Wiaderek Co-authored-by: wiaderwek Co-authored-by: Thomas Feldtkeller Co-authored-by: mamutmk5 <3045922+mamutmk5@users.noreply.github.com> --- .github/workflows/dependency-review.yml | 3 +- .github/workflows/push.yml | 2 +- .../schulcloud-server-core/tasks/main.yml | 21 +- .../templates/tldraw-deployment.yml.j2 | 67 +++ .../templates/tldraw-ingress.yml.j2 | 42 ++ .../templates/tldraw-server-svc.yml.j2 | 22 + apps/server/src/apps/tldraw.app.ts | 50 ++ apps/server/src/config/database.config.ts | 3 +- apps/server/src/modules/board/board.module.ts | 12 +- .../element/any-content-element.response.ts | 4 +- .../dto/element/drawing-element.response.ts | 33 ++ .../board/controller/dto/element/index.ts | 1 + .../update-element-content.body.params.ts | 7 + .../content-element-response.factory.spec.ts | 10 + .../content-element-response.factory.ts | 2 + .../mapper/drawing-element-response.mapper.ts | 32 ++ .../board/repo/board-do.builder-impl.spec.ts | 20 + .../board/repo/board-do.builder-impl.ts | 16 + .../modules/board/repo/board-do.repo.spec.ts | 2 + .../repo/recursive-delete.visitor.spec.ts | 29 ++ .../board/repo/recursive-delete.vistor.ts | 12 +- .../board/repo/recursive-save.visitor.spec.ts | 27 ++ .../board/repo/recursive-save.visitor.ts | 15 + .../board-do-copy.service.spec.ts | 59 +++ .../recursive-copy.visitor.ts | 19 + .../content-element-update.visitor.spec.ts | 18 + .../service/content-element-update.visitor.ts | 10 + .../service/content-element.service.spec.ts | 52 +- .../src/modules/board/uc/card.uc.spec.ts | 5 + .../src/modules/board/uc/element.uc.spec.ts | 10 +- .../modules/copy-helper/types/copy.types.ts | 1 + .../server/src/modules/tldraw-client/index.ts | 1 + .../drawing-element-adapter.service.spec.ts | 65 +++ .../drawing-element-adapter.service.ts | 22 + .../modules/tldraw-client/service/index.ts | 1 + .../tldraw-client/tldraw-client.module.ts | 10 + apps/server/src/modules/tldraw/config.ts | 30 ++ .../controller/api-test/tldraw.ws.api.spec.ts | 129 +++++ .../src/modules/tldraw/controller/index.ts | 1 + .../controller/tldraw.controller.spec.ts | 53 +++ .../tldraw/controller/tldraw.controller.ts | 22 + .../tldraw/controller/tldraw.params.ts | 12 + .../modules/tldraw/controller/tldraw.ws.ts | 48 ++ .../tldraw/domain/ws-shared-doc.do.spec.ts | 165 +++++++ .../modules/tldraw/domain/ws-shared-doc.do.ts | 88 ++++ .../src/modules/tldraw/entities/index.ts | 1 + .../entities/tldraw-drawing.entity.spec.ts | 29 ++ .../tldraw/entities/tldraw-drawing.entity.ts | 46 ++ .../src/modules/tldraw/factory/index.ts | 1 + .../modules/tldraw/factory/tldraw.factory.ts | 14 + apps/server/src/modules/tldraw/index.ts | 3 + apps/server/src/modules/tldraw/repo/index.ts | 1 + .../tldraw/repo/tldraw-board.repo.spec.ts | 221 +++++++++ .../modules/tldraw/repo/tldraw-board.repo.ts | 72 +++ .../modules/tldraw/repo/tldraw.repo.spec.ts | 92 ++++ .../src/modules/tldraw/repo/tldraw.repo.ts | 20 + .../src/modules/tldraw/service/index.ts | 1 + .../tldraw/service/tldraw.service.spec.ts | 53 +++ .../modules/tldraw/service/tldraw.service.ts | 12 + .../tldraw/service/tldraw.ws.service.spec.ts | 449 ++++++++++++++++++ .../tldraw/service/tldraw.ws.service.ts | 209 ++++++++ .../modules/tldraw/testing/test-connection.ts | 22 + .../src/modules/tldraw/tldraw-test.module.ts | 36 ++ .../modules/tldraw/tldraw-ws-test.module.ts | 25 + .../src/modules/tldraw/tldraw-ws.module.ts | 15 + .../src/modules/tldraw/tldraw.module.ts | 43 ++ .../modules/tldraw/types/connection-enum.ts | 9 + apps/server/src/modules/tldraw/types/index.ts | 3 + .../modules/tldraw/types/persistence-type.ts | 6 + .../tldraw/types/ws-close-code-enum.ts | 3 + apps/server/src/modules/tldraw/utils/index.ts | 1 + .../src/modules/tldraw/utils/ydoc-utils.ts | 2 + .../domain/domainobject/board/card.do.ts | 2 + .../board/content-element.factory.spec.ts | 9 + .../board/content-element.factory.ts | 16 + .../board/drawing-element.do.spec.ts | 37 ++ .../domainobject/board/drawing-element.do.ts | 32 ++ .../shared/domain/domainobject/board/index.ts | 1 + .../board/types/any-content-element-do.ts | 3 + .../board/types/board-composite-visitor.ts | 3 + .../board/types/content-elements.enum.ts | 1 + .../src/shared/domain/entity/all-entities.ts | 2 + .../drawing-element-node.entity.spec.ts | 59 +++ .../boardnode/drawing-element-node.entity.ts | 25 + .../shared/domain/entity/boardnode/index.ts | 1 + .../boardnode/types/board-do.builder.ts | 3 + .../entity/boardnode/types/board-node-type.ts | 1 + .../boardnode/drawing-element-node.factory.ts | 12 + .../board/drawing-element.do.factory.ts | 18 + .../factory/domainobject/board/index.ts | 1 + .../testing/factory/tldraw.ws.factory.ts | 12 + config/default.schema.json | 54 +++ config/globals.js | 5 + config/test.json | 10 +- nest-cli.json | 9 + package-lock.json | 320 ++++++++++++- package.json | 10 +- src/services/config/publicAppConfigService.js | 1 + 98 files changed, 3264 insertions(+), 25 deletions(-) create mode 100644 ansible/roles/schulcloud-server-core/templates/tldraw-deployment.yml.j2 create mode 100644 ansible/roles/schulcloud-server-core/templates/tldraw-ingress.yml.j2 create mode 100644 ansible/roles/schulcloud-server-core/templates/tldraw-server-svc.yml.j2 create mode 100644 apps/server/src/apps/tldraw.app.ts create mode 100644 apps/server/src/modules/board/controller/dto/element/drawing-element.response.ts create mode 100644 apps/server/src/modules/board/controller/mapper/drawing-element-response.mapper.ts create mode 100644 apps/server/src/modules/tldraw-client/index.ts create mode 100644 apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.spec.ts create mode 100644 apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.ts create mode 100644 apps/server/src/modules/tldraw-client/service/index.ts create mode 100644 apps/server/src/modules/tldraw-client/tldraw-client.module.ts create mode 100644 apps/server/src/modules/tldraw/config.ts create mode 100644 apps/server/src/modules/tldraw/controller/api-test/tldraw.ws.api.spec.ts create mode 100644 apps/server/src/modules/tldraw/controller/index.ts create mode 100644 apps/server/src/modules/tldraw/controller/tldraw.controller.spec.ts create mode 100644 apps/server/src/modules/tldraw/controller/tldraw.controller.ts create mode 100644 apps/server/src/modules/tldraw/controller/tldraw.params.ts create mode 100644 apps/server/src/modules/tldraw/controller/tldraw.ws.ts create mode 100644 apps/server/src/modules/tldraw/domain/ws-shared-doc.do.spec.ts create mode 100644 apps/server/src/modules/tldraw/domain/ws-shared-doc.do.ts create mode 100644 apps/server/src/modules/tldraw/entities/index.ts create mode 100644 apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.spec.ts create mode 100644 apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.ts create mode 100644 apps/server/src/modules/tldraw/factory/index.ts create mode 100644 apps/server/src/modules/tldraw/factory/tldraw.factory.ts create mode 100644 apps/server/src/modules/tldraw/index.ts create mode 100644 apps/server/src/modules/tldraw/repo/index.ts create mode 100644 apps/server/src/modules/tldraw/repo/tldraw-board.repo.spec.ts create mode 100644 apps/server/src/modules/tldraw/repo/tldraw-board.repo.ts create mode 100644 apps/server/src/modules/tldraw/repo/tldraw.repo.spec.ts create mode 100644 apps/server/src/modules/tldraw/repo/tldraw.repo.ts create mode 100644 apps/server/src/modules/tldraw/service/index.ts create mode 100644 apps/server/src/modules/tldraw/service/tldraw.service.spec.ts create mode 100644 apps/server/src/modules/tldraw/service/tldraw.service.ts create mode 100644 apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts create mode 100644 apps/server/src/modules/tldraw/service/tldraw.ws.service.ts create mode 100644 apps/server/src/modules/tldraw/testing/test-connection.ts create mode 100644 apps/server/src/modules/tldraw/tldraw-test.module.ts create mode 100644 apps/server/src/modules/tldraw/tldraw-ws-test.module.ts create mode 100644 apps/server/src/modules/tldraw/tldraw-ws.module.ts create mode 100644 apps/server/src/modules/tldraw/tldraw.module.ts create mode 100644 apps/server/src/modules/tldraw/types/connection-enum.ts create mode 100644 apps/server/src/modules/tldraw/types/index.ts create mode 100644 apps/server/src/modules/tldraw/types/persistence-type.ts create mode 100644 apps/server/src/modules/tldraw/types/ws-close-code-enum.ts create mode 100644 apps/server/src/modules/tldraw/utils/index.ts create mode 100644 apps/server/src/modules/tldraw/utils/ydoc-utils.ts create mode 100644 apps/server/src/shared/domain/domainobject/board/drawing-element.do.spec.ts create mode 100644 apps/server/src/shared/domain/domainobject/board/drawing-element.do.ts create mode 100644 apps/server/src/shared/domain/entity/boardnode/drawing-element-node.entity.spec.ts create mode 100644 apps/server/src/shared/domain/entity/boardnode/drawing-element-node.entity.ts create mode 100644 apps/server/src/shared/testing/factory/boardnode/drawing-element-node.factory.ts create mode 100644 apps/server/src/shared/testing/factory/domainobject/board/drawing-element.do.factory.ts create mode 100644 apps/server/src/shared/testing/factory/tldraw.ws.factory.ts diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 44a0644b141..8a8b06c219e 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -13,4 +13,5 @@ jobs: - name: 'Dependency Review' uses: actions/dependency-review-action@v3 with: - allow-licenses: AGPL-3.0-only, LGPL-3.0, MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, X11, 0BSD, GPL-3.0, Unlicense + allow-licenses: AGPL-3.0-only, LGPL-3.0, MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, X11, 0BSD, GPL-3.0 AND BSD-3-Clause-Clear, Unlicense + allow-dependencies-licenses: 'pkg:npm/parse-mongo-url' diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index a5688568696..c042be2c2a9 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -172,7 +172,7 @@ jobs: uses: github/codeql-action/upload-sarif@v2 with: sarif_file: 'trivy-results.sarif' - + end-to-end-tests: needs: - build_and_push diff --git a/ansible/roles/schulcloud-server-core/tasks/main.yml b/ansible/roles/schulcloud-server-core/tasks/main.yml index e64021b5f67..47ace3c6f85 100644 --- a/ansible/roles/schulcloud-server-core/tasks/main.yml +++ b/ansible/roles/schulcloud-server-core/tasks/main.yml @@ -65,7 +65,7 @@ kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: deployment.yml.j2 - + - name: Ingress kubernetes.core.k8s: kubeconfig: ~/.kube/config @@ -155,3 +155,22 @@ when: - KEDA_ENABLED is defined and KEDA_ENABLED|bool - SCALED_PREVIEW_GENERATOR_ENABLED is defined and SCALED_PREVIEW_GENERATOR_ENABLED|bool + + - name: TlDraw server deployment + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: tldraw-deployment.yml.j2 + + - name: TlDraw server service + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: tldraw-server-svc.yml.j2 + + - name: Tldraw ingress + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: tldraw-ingress.yml.j2 + apply: yes diff --git a/ansible/roles/schulcloud-server-core/templates/tldraw-deployment.yml.j2 b/ansible/roles/schulcloud-server-core/templates/tldraw-deployment.yml.j2 new file mode 100644 index 00000000000..f9dc4f09d9e --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/tldraw-deployment.yml.j2 @@ -0,0 +1,67 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tldraw-deployment + namespace: {{ NAMESPACE }} + labels: + app: tldraw-server + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: tldraw-server + app.kubernetes.io/component: tldraw + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} +spec: + replicas: {{ TLDRAW_SERVER_REPLICAS|default("1", true) }} + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + #maxUnavailable: 1 + revisionHistoryLimit: 4 + paused: false + selector: + matchLabels: + app: tldraw-server + template: + metadata: + labels: + app: tldraw-server + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: tldraw-server + app.kubernetes.io/component: tldraw + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} + spec: + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + runAsNonRoot: true + containers: + - name: tldraw + image: {{ SCHULCLOUD_SERVER_IMAGE }}:{{ SCHULCLOUD_SERVER_IMAGE_TAG }} + imagePullPolicy: IfNotPresent + ports: + - containerPort: 3345 + name: tldraw-ws + protocol: TCP + - containerPort: 3349 + name: tldraw-http + protocol: TCP + envFrom: + - configMapRef: + name: api-configmap + - secretRef: + name: api-secret + command: ['npm', 'run', 'nest:start:tldraw:prod'] + resources: + limits: + cpu: {{ TLDRAW_EDITOR_CPU_LIMITS|default("2000m", true) }} + memory: {{ TLDRAW_EDITOR_MEMORY_LIMITS|default("4Gi", true) }} + requests: + cpu: {{ TLDRAW_EDITOR_CPU_REQUESTS|default("100m", true) }} + memory: {{ TLDRAW_EDITOR_MEMORY_REQUESTS|default("150Mi", true) }} diff --git a/ansible/roles/schulcloud-server-core/templates/tldraw-ingress.yml.j2 b/ansible/roles/schulcloud-server-core/templates/tldraw-ingress.yml.j2 new file mode 100644 index 00000000000..e80028a5985 --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/tldraw-ingress.yml.j2 @@ -0,0 +1,42 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ NAMESPACE }}-tldraw-ingress + namespace: {{ NAMESPACE }} + annotations: + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-body-size: "{{ INGRESS_MAX_BODY_SIZE|default("2560") }}m" + nginx.org/client-max-body-size: "{{ INGRESS_MAX_BODY_SIZE|default("2560") }}m" + # The following properties added with BC-3606. + # The header size of the request is too big. For e.g. state and the permanent growing jwt. + # Nginx throws away the Location header, resulting in the 502 Bad Gateway. + nginx.ingress.kubernetes.io/client-header-buffer-size: 100k + nginx.ingress.kubernetes.io/http2-max-header-size: 96k + nginx.ingress.kubernetes.io/large-client-header-buffers: 4 100k + nginx.ingress.kubernetes.io/proxy-buffer-size: 96k +{% if CLUSTER_ISSUER is defined %} + cert-manager.io/cluster-issuer: {{ CLUSTER_ISSUER }} +{% endif %} + +spec: + ingressClassName: nginx +{% if CLUSTER_ISSUER is defined or (TLS_ENABELD is defined and TLS_ENABELD|bool) %} + tls: + - hosts: + - {{ DOMAIN }} +{% if CLUSTER_ISSUER is defined %} + secretName: {{ DOMAIN }}-tls +{% endif %} +{% endif %} + rules: + - host: {{ DOMAIN }} + http: + paths: + - path: /tldraw-server + backend: + service: + name: tldraw-server-svc + port: + number: 3345 + pathType: Prefix diff --git a/ansible/roles/schulcloud-server-core/templates/tldraw-server-svc.yml.j2 b/ansible/roles/schulcloud-server-core/templates/tldraw-server-svc.yml.j2 new file mode 100644 index 00000000000..8a1ded9a1d9 --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/tldraw-server-svc.yml.j2 @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: Service +metadata: + name: tldraw-server-svc + namespace: {{ NAMESPACE }} + labels: + app: tldraw-server +spec: + type: ClusterIP + ports: + # port for WebSocket connection + - port: 3345 + targetPort: 3345 + protocol: TCP + name: tldraw-ws + # port for http managing drawing data + - port: 3349 + targetPort: 3349 + protocol: TCP + name: tldraw-http + selector: + app: tldraw-server diff --git a/apps/server/src/apps/tldraw.app.ts b/apps/server/src/apps/tldraw.app.ts new file mode 100644 index 00000000000..a394b1e8deb --- /dev/null +++ b/apps/server/src/apps/tldraw.app.ts @@ -0,0 +1,50 @@ +/* istanbul ignore file */ +/* eslint-disable no-console */ +import { NestFactory } from '@nestjs/core'; +import { install as sourceMapInstall } from 'source-map-support'; +import { TldrawModule, TldrawWsModule } from '@modules/tldraw'; +import { LegacyLogger, Logger } from '@src/core/logger'; +import * as WebSocket from 'ws'; +import { WsAdapter } from '@nestjs/platform-ws'; +import { enableOpenApiDocs } from '@shared/controller/swagger'; +import { AppStartLoggable } from '@src/apps/helpers/app-start-loggable'; +import { ExpressAdapter } from '@nestjs/platform-express'; +import express from 'express'; + +async function bootstrap() { + sourceMapInstall(); + + const nestExpress = express(); + const nestExpressAdapter = new ExpressAdapter(nestExpress); + const nestApp = await NestFactory.create(TldrawModule, nestExpressAdapter); + nestApp.useLogger(await nestApp.resolve(LegacyLogger)); + nestApp.enableCors(); + + const nestAppWS = await NestFactory.create(TldrawWsModule); + const wss = new WebSocket.Server({ noServer: true }); + nestAppWS.useWebSocketAdapter(new WsAdapter(wss)); + nestAppWS.enableCors(); + enableOpenApiDocs(nestAppWS, 'docs'); + const logger = await nestAppWS.resolve(Logger); + + await nestAppWS.init(); + await nestApp.init(); + + // mount instances + const rootExpress = express(); + + const port = 3349; + const basePath = '/api/v3'; + + // exposed alias mounts + rootExpress.use(basePath, nestExpress); + rootExpress.listen(port); + + logger.info( + new AppStartLoggable({ + appName: 'Tldraw server app', + }) + ); +} + +void bootstrap(); diff --git a/apps/server/src/config/database.config.ts b/apps/server/src/config/database.config.ts index ad97e4c3d66..17c45dd1887 100644 --- a/apps/server/src/config/database.config.ts +++ b/apps/server/src/config/database.config.ts @@ -4,9 +4,10 @@ interface GlobalConstants { DB_URL: string; DB_PASSWORD?: string; DB_USERNAME?: string; + TLDRAW_DB_URL: string; } const usedGlobals: GlobalConstants = globals; /** Database URL */ -export const { DB_URL, DB_PASSWORD, DB_USERNAME } = usedGlobals; +export const { DB_URL, DB_PASSWORD, DB_USERNAME, TLDRAW_DB_URL } = usedGlobals; diff --git a/apps/server/src/modules/board/board.module.ts b/apps/server/src/modules/board/board.module.ts index ffa1e7ad580..880d9607cc9 100644 --- a/apps/server/src/modules/board/board.module.ts +++ b/apps/server/src/modules/board/board.module.ts @@ -6,6 +6,8 @@ import { ContentElementFactory } from '@shared/domain'; import { ConsoleWriterModule } from '@infra/console'; import { CourseRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; +import { DrawingElementAdapterService } from '@modules/tldraw-client/service/drawing-element-adapter.service'; +import { HttpModule } from '@nestjs/axios'; import { BoardDoRepo, BoardNodeRepo, RecursiveDeleteVisitor } from './repo'; import { BoardDoAuthorizableService, @@ -20,7 +22,14 @@ import { BoardDoCopyService, SchoolSpecificFileCopyServiceFactory } from './serv import { ColumnBoardCopyService } from './service/column-board-copy.service'; @Module({ - imports: [ConsoleWriterModule, FilesStorageClientModule, LoggerModule, UserModule, ContextExternalToolModule], + imports: [ + ConsoleWriterModule, + FilesStorageClientModule, + LoggerModule, + UserModule, + ContextExternalToolModule, + HttpModule, + ], providers: [ BoardDoAuthorizableService, BoardDoRepo, @@ -37,6 +46,7 @@ import { ColumnBoardCopyService } from './service/column-board-copy.service'; BoardDoCopyService, ColumnBoardCopyService, SchoolSpecificFileCopyServiceFactory, + DrawingElementAdapterService, ], exports: [ BoardDoAuthorizableService, diff --git a/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts b/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts index 84681de7691..ec3f1beb96c 100644 --- a/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts +++ b/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts @@ -1,4 +1,5 @@ import { ExternalToolElementResponse } from './external-tool-element.response'; +import { DrawingElementResponse } from './drawing-element.response'; import { FileElementResponse } from './file-element.response'; import { LinkElementResponse } from './link-element.response'; import { RichTextElementResponse } from './rich-text-element.response'; @@ -9,7 +10,8 @@ export type AnyContentElementResponse = | LinkElementResponse | RichTextElementResponse | SubmissionContainerElementResponse - | ExternalToolElementResponse; + | ExternalToolElementResponse + | DrawingElementResponse; export const isFileElementResponse = (element: AnyContentElementResponse): element is FileElementResponse => element instanceof FileElementResponse; diff --git a/apps/server/src/modules/board/controller/dto/element/drawing-element.response.ts b/apps/server/src/modules/board/controller/dto/element/drawing-element.response.ts new file mode 100644 index 00000000000..7c2b0e20852 --- /dev/null +++ b/apps/server/src/modules/board/controller/dto/element/drawing-element.response.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ContentElementType } from '@shared/domain'; +import { TimestampsResponse } from '../timestamps.response'; + +export class DrawingElementContent { + constructor({ description }: DrawingElementContent) { + this.description = description; + } + + @ApiProperty() + description: string; +} + +export class DrawingElementResponse { + constructor({ id, content, timestamps, type }: DrawingElementResponse) { + this.id = id; + this.timestamps = timestamps; + this.type = type; + this.content = content; + } + + @ApiProperty({ pattern: '[a-f0-9]{24}' }) + id: string; + + @ApiProperty({ enum: ContentElementType, enumName: 'ContentElementType' }) + type: ContentElementType.DRAWING; + + @ApiProperty() + timestamps: TimestampsResponse; + + @ApiProperty() + content: DrawingElementContent; +} diff --git a/apps/server/src/modules/board/controller/dto/element/index.ts b/apps/server/src/modules/board/controller/dto/element/index.ts index 6787c007c1b..3b85cb57f3f 100644 --- a/apps/server/src/modules/board/controller/dto/element/index.ts +++ b/apps/server/src/modules/board/controller/dto/element/index.ts @@ -1,5 +1,6 @@ export * from './any-content-element.response'; export * from './create-content-element.body.params'; +export * from './drawing-element.response'; export * from './external-tool-element.response'; export * from './file-element.response'; export * from './link-element.response'; diff --git a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts index 7d0314208c6..f6fbfd11043 100644 --- a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts +++ b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts @@ -73,6 +73,12 @@ export class RichTextContentBody { inputFormat!: InputFormat; } +export class DrawingContentBody { + @IsString() + @ApiProperty() + description!: string; +} + export class RichTextElementContentBody extends ElementContentBody { @ApiProperty({ type: ContentElementType.RICH_TEXT }) type!: ContentElementType.RICH_TEXT; @@ -118,6 +124,7 @@ export class ExternalToolElementContentBody extends ElementContentBody { export type AnyElementContentBody = | FileContentBody + | DrawingContentBody | LinkContentBody | RichTextContentBody | SubmissionContainerContentBody diff --git a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts index 2b61e273185..c4fb577f5c2 100644 --- a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts +++ b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts @@ -1,6 +1,7 @@ import { NotImplementedException } from '@nestjs/common'; import { fileElementFactory, + drawingElementFactory, linkElementFactory, richTextElementFactory, submissionContainerElementFactory, @@ -8,6 +9,7 @@ import { import { FileElementResponse, LinkElementResponse, + DrawingElementResponse, RichTextElementResponse, SubmissionContainerElementResponse, } from '../dto'; @@ -37,6 +39,14 @@ describe(ContentElementResponseFactory.name, () => { expect(result).toBeInstanceOf(RichTextElementResponse); }); + it('should return instance of DrawingElementResponse', () => { + const drawingElement = drawingElementFactory.build(); + + const result = ContentElementResponseFactory.mapToResponse(drawingElement); + + expect(result).toBeInstanceOf(DrawingElementResponse); + }); + it('should return instance of SubmissionContainerElementResponse', () => { const submissionContainerElement = submissionContainerElementFactory.build(); diff --git a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts index 8431b630be9..72311882bb8 100644 --- a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts +++ b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts @@ -8,6 +8,7 @@ import { isRichTextElementResponse, } from '../dto'; import { BaseResponseMapper } from './base-mapper.interface'; +import { DrawingElementResponseMapper } from './drawing-element-response.mapper'; import { ExternalToolElementResponseMapper } from './external-tool-element-response.mapper'; import { FileElementResponseMapper } from './file-element-response.mapper'; import { LinkElementResponseMapper } from './link-element-response.mapper'; @@ -19,6 +20,7 @@ export class ContentElementResponseFactory { FileElementResponseMapper.getInstance(), LinkElementResponseMapper.getInstance(), RichTextElementResponseMapper.getInstance(), + DrawingElementResponseMapper.getInstance(), SubmissionContainerElementResponseMapper.getInstance(), ExternalToolElementResponseMapper.getInstance(), ]; diff --git a/apps/server/src/modules/board/controller/mapper/drawing-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/drawing-element-response.mapper.ts new file mode 100644 index 00000000000..1ab07081f07 --- /dev/null +++ b/apps/server/src/modules/board/controller/mapper/drawing-element-response.mapper.ts @@ -0,0 +1,32 @@ +import { ContentElementType } from '@shared/domain'; +import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; +import { DrawingElementContent, DrawingElementResponse } from '../dto/element/drawing-element.response'; +import { TimestampsResponse } from '../dto'; +import { BaseResponseMapper } from './base-mapper.interface'; + +export class DrawingElementResponseMapper implements BaseResponseMapper { + private static instance: DrawingElementResponseMapper; + + public static getInstance(): DrawingElementResponseMapper { + if (!DrawingElementResponseMapper.instance) { + DrawingElementResponseMapper.instance = new DrawingElementResponseMapper(); + } + + return DrawingElementResponseMapper.instance; + } + + mapToResponse(element: DrawingElement): DrawingElementResponse { + const result = new DrawingElementResponse({ + id: element.id, + timestamps: new TimestampsResponse({ lastUpdatedAt: element.updatedAt, createdAt: element.createdAt }), + type: ContentElementType.DRAWING, + content: new DrawingElementContent({ description: element.description }), + }); + + return result; + } + + canMap(element: DrawingElement): boolean { + return element instanceof DrawingElement; + } +} diff --git a/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts b/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts index 8bbc859fa17..43bdc325623 100644 --- a/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts +++ b/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts @@ -10,6 +10,7 @@ import { setupEntities, submissionContainerElementNodeFactory, } from '@shared/testing'; +import { drawingElementNodeFactory } from '@shared/testing/factory/boardnode/drawing-element-node.factory'; import { BoardDoBuilderImpl } from './board-do.builder-impl'; describe(BoardDoBuilderImpl.name, () => { @@ -168,6 +169,25 @@ describe(BoardDoBuilderImpl.name, () => { }); }); + describe('when building a drawing element', () => { + it('should work without descendants', () => { + const drawingElementNode = drawingElementNodeFactory.build(); + + const domainObject = new BoardDoBuilderImpl().buildDrawingElement(drawingElementNode); + + expect(domainObject.constructor.name).toBe('DrawingElement'); + }); + + it('should throw error if drawingElement is not a leaf', () => { + const drawingElementNode = drawingElementNodeFactory.buildWithId(); + const columnNode = columnNodeFactory.buildWithId({ parent: drawingElementNode }); + + expect(() => { + new BoardDoBuilderImpl([columnNode]).buildDrawingElement(drawingElementNode); + }).toThrowError(); + }); + }); + describe('when building a submission container element', () => { it('should work without descendants', () => { const submissionContainerElementNode = submissionContainerElementNodeFactory.build(); diff --git a/apps/server/src/modules/board/repo/board-do.builder-impl.ts b/apps/server/src/modules/board/repo/board-do.builder-impl.ts index 6e2b375991e..2154264fd20 100644 --- a/apps/server/src/modules/board/repo/board-do.builder-impl.ts +++ b/apps/server/src/modules/board/repo/board-do.builder-impl.ts @@ -25,6 +25,8 @@ import { SubmissionContainerElement, SubmissionItem, } from '@shared/domain'; +import { DrawingElementNode } from '@shared/domain/entity/boardnode/drawing-element-node.entity'; +import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; export class BoardDoBuilderImpl implements BoardDoBuilder { private childrenMap: Record = {}; @@ -77,6 +79,7 @@ export class BoardDoBuilderImpl implements BoardDoBuilder { BoardNodeType.FILE_ELEMENT, BoardNodeType.LINK_ELEMENT, BoardNodeType.RICH_TEXT_ELEMENT, + BoardNodeType.DRAWING_ELEMENT, BoardNodeType.SUBMISSION_CONTAINER_ELEMENT, BoardNodeType.EXTERNAL_TOOL, ]); @@ -139,6 +142,19 @@ export class BoardDoBuilderImpl implements BoardDoBuilder { return element; } + public buildDrawingElement(boardNode: DrawingElementNode): DrawingElement { + this.ensureLeafNode(boardNode); + + const element = new DrawingElement({ + id: boardNode.id, + description: boardNode.description, + children: [], + createdAt: boardNode.createdAt, + updatedAt: boardNode.updatedAt, + }); + return element; + } + public buildSubmissionContainerElement(boardNode: SubmissionContainerElementNode): SubmissionContainerElement { this.ensureBoardNodeType(this.getChildren(boardNode), [BoardNodeType.SUBMISSION_ITEM]); const elements = this.buildChildren(boardNode); diff --git a/apps/server/src/modules/board/repo/board-do.repo.spec.ts b/apps/server/src/modules/board/repo/board-do.repo.spec.ts index 2f9a6633e69..2bf3037973d 100644 --- a/apps/server/src/modules/board/repo/board-do.repo.spec.ts +++ b/apps/server/src/modules/board/repo/board-do.repo.spec.ts @@ -28,6 +28,7 @@ import { richTextElementFactory, richTextElementNodeFactory, } from '@shared/testing'; +import { DrawingElementAdapterService } from '@modules/tldraw-client/service/drawing-element-adapter.service'; import { BoardDoRepo } from './board-do.repo'; import { BoardNodeRepo } from './board-node.repo'; import { RecursiveDeleteVisitor } from './recursive-delete.vistor'; @@ -48,6 +49,7 @@ describe(BoardDoRepo.name, () => { RecursiveDeleteVisitor, { provide: FilesStorageClientAdapterService, useValue: createMock() }, { provide: ContextExternalToolService, useValue: createMock() }, + { provide: DrawingElementAdapterService, useValue: createMock() }, ], }).compile(); repo = module.get(BoardDoRepo); diff --git a/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts b/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts index 544d492fe65..49604d6fcbf 100644 --- a/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts +++ b/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts @@ -8,6 +8,7 @@ import { columnBoardFactory, columnFactory, contextExternalToolFactory, + drawingElementFactory, externalToolElementFactory, fileElementFactory, linkElementFactory, @@ -15,6 +16,7 @@ import { submissionContainerElementFactory, submissionItemFactory, } from '@shared/testing'; +import { DrawingElementAdapterService } from '@modules/tldraw-client/service/drawing-element-adapter.service'; import { RecursiveDeleteVisitor } from './recursive-delete.vistor'; describe(RecursiveDeleteVisitor.name, () => { @@ -24,6 +26,7 @@ describe(RecursiveDeleteVisitor.name, () => { let em: DeepMocked; let filesStorageClientAdapterService: DeepMocked; let contextExternalToolService: DeepMocked; + let drawingElementAdapterService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -32,6 +35,7 @@ describe(RecursiveDeleteVisitor.name, () => { { provide: EntityManager, useValue: createMock() }, { provide: FilesStorageClientAdapterService, useValue: createMock() }, { provide: ContextExternalToolService, useValue: createMock() }, + { provide: DrawingElementAdapterService, useValue: createMock() }, ], }).compile(); @@ -39,6 +43,7 @@ describe(RecursiveDeleteVisitor.name, () => { em = module.get(EntityManager); filesStorageClientAdapterService = module.get(FilesStorageClientAdapterService); contextExternalToolService = module.get(ContextExternalToolService); + drawingElementAdapterService = module.get(DrawingElementAdapterService); await setupEntities(); }); @@ -181,6 +186,30 @@ describe(RecursiveDeleteVisitor.name, () => { }); }); + describe('visitDrawingElementAsync', () => { + const setup = () => { + const childDrawingElement = drawingElementFactory.build(); + + return { childDrawingElement }; + }; + + it('should call entity remove', async () => { + const { childDrawingElement } = setup(); + + await service.visitDrawingElementAsync(childDrawingElement); + + expect(em.remove).toHaveBeenCalledWith(em.getReference(childDrawingElement.constructor, childDrawingElement.id)); + }); + + it('should trigger deletion of tldraw data via adapter', async () => { + const { childDrawingElement } = setup(); + + await service.visitDrawingElementAsync(childDrawingElement); + + expect(drawingElementAdapterService.deleteDrawingBinData).toHaveBeenCalledWith(childDrawingElement.id); + }); + }); + describe('visitSubmissionContainerElementAsync', () => { const setup = () => { const childSubmissionContainerElement = submissionContainerElementFactory.build(); diff --git a/apps/server/src/modules/board/repo/recursive-delete.vistor.ts b/apps/server/src/modules/board/repo/recursive-delete.vistor.ts index 6c8301f6b6f..a4ba34425e0 100644 --- a/apps/server/src/modules/board/repo/recursive-delete.vistor.ts +++ b/apps/server/src/modules/board/repo/recursive-delete.vistor.ts @@ -17,13 +17,16 @@ import { SubmissionItem, } from '@shared/domain'; import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; +import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; +import { DrawingElementAdapterService } from '@modules/tldraw-client/service/drawing-element-adapter.service'; @Injectable() export class RecursiveDeleteVisitor implements BoardCompositeVisitorAsync { constructor( private readonly em: EntityManager, private readonly filesStorageClientAdapterService: FilesStorageClientAdapterService, - private readonly contextExternalToolService: ContextExternalToolService + private readonly contextExternalToolService: ContextExternalToolService, + private readonly drawingElementAdapterService: DrawingElementAdapterService ) {} async visitColumnBoardAsync(columnBoard: ColumnBoard): Promise { @@ -60,6 +63,13 @@ export class RecursiveDeleteVisitor implements BoardCompositeVisitorAsync { await this.visitChildrenAsync(richTextElement); } + async visitDrawingElementAsync(drawingElement: DrawingElement): Promise { + await this.drawingElementAdapterService.deleteDrawingBinData(drawingElement.id); + + this.deleteNode(drawingElement); + await this.visitChildrenAsync(drawingElement); + } + async visitSubmissionContainerElementAsync(submissionContainerElement: SubmissionContainerElement): Promise { this.deleteNode(submissionContainerElement); await this.visitChildrenAsync(submissionContainerElement); diff --git a/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts b/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts index 3fd95c18525..55be7d41d75 100644 --- a/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts +++ b/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts @@ -9,6 +9,7 @@ import { FileElementNode, LinkElementNode, RichTextElementNode, + DrawingElementNode, SubmissionContainerElementNode, SubmissionItemNode, } from '@shared/domain'; @@ -23,6 +24,7 @@ import { linkElementFactory, richTextElementFactory, setupEntities, + drawingElementFactory, submissionContainerElementFactory, submissionItemFactory, } from '@shared/testing'; @@ -120,6 +122,16 @@ describe(RecursiveSaveVisitor.name, () => { expect(richTextElement.accept).toHaveBeenCalledWith(visitor); }); + + it('should visit the children (drawing)', () => { + const drawingElement = drawingElementFactory.build(); + jest.spyOn(drawingElement, 'accept'); + const card = cardFactory.build({ children: [drawingElement] }); + + card.accept(visitor); + + expect(drawingElement.accept).toHaveBeenCalledWith(visitor); + }); }); describe('when visiting a file element composite', () => { @@ -171,6 +183,21 @@ describe(RecursiveSaveVisitor.name, () => { }); }); + describe('when visiting a drawing element composite', () => { + it('should create or update the node', () => { + const drawingElement = drawingElementFactory.build(); + jest.spyOn(visitor, 'createOrUpdateBoardNode'); + + visitor.visitDrawingElement(drawingElement); + + const expectedNode: Partial = { + id: drawingElement.id, + type: BoardNodeType.DRAWING_ELEMENT, + }; + expect(visitor.createOrUpdateBoardNode).toHaveBeenCalledWith(expect.objectContaining(expectedNode)); + }); + }); + describe('when visiting a submission container element composite', () => { it('should create or update the node', () => { const submissionContainerElement = submissionContainerElementFactory.build(); diff --git a/apps/server/src/modules/board/repo/recursive-save.visitor.ts b/apps/server/src/modules/board/repo/recursive-save.visitor.ts index e379cd5c788..32abcfb0747 100644 --- a/apps/server/src/modules/board/repo/recursive-save.visitor.ts +++ b/apps/server/src/modules/board/repo/recursive-save.visitor.ts @@ -25,6 +25,8 @@ import { import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; import { LinkElementNode } from '@shared/domain/entity/boardnode/link-element-node.entity'; import { ContextExternalToolEntity } from '@modules/tool/context-external-tool/entity'; +import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; +import { DrawingElementNode } from '@shared/domain/entity/boardnode/drawing-element-node.entity'; import { BoardNodeRepo } from './board-node.repo'; type ParentData = { @@ -136,6 +138,19 @@ export class RecursiveSaveVisitor implements BoardCompositeVisitor { this.saveRecursive(boardNode, richTextElement); } + visitDrawingElement(drawingElement: DrawingElement): void { + const parentData = this.parentsMap.get(drawingElement.id); + + const boardNode = new DrawingElementNode({ + id: drawingElement.id, + description: drawingElement.description ?? '', + parent: parentData?.boardNode, + position: parentData?.position, + }); + + this.saveRecursive(boardNode, drawingElement); + } + visitSubmissionContainerElement(submissionContainerElement: SubmissionContainerElement): void { const parentData = this.parentsMap.get(submissionContainerElement.id); diff --git a/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts b/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts index 40a62ede2ed..04a3e8dce3c 100644 --- a/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts +++ b/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts @@ -4,11 +4,13 @@ import { Card, Column, ColumnBoard, + DrawingElement, ExternalToolElement, FileElement, isCard, isColumn, isColumnBoard, + isDrawingElement, isExternalToolElement, isFileElement, isLinkElement, @@ -23,6 +25,7 @@ import { cardFactory, columnBoardFactory, columnFactory, + drawingElementFactory, externalToolElementFactory, fileElementFactory, linkElementFactory, @@ -437,6 +440,62 @@ describe('recursive board copy visitor', () => { }); }); + describe('when copying a drawing element', () => { + const setup = () => { + const original = drawingElementFactory.build(); + + return { original, ...setupfileCopyService() }; + }; + + const getDrawingElementFromStatus = (status: CopyStatus) => { + const copy = status.copyEntity; + expect(isDrawingElement(copy)).toEqual(true); + return copy as DrawingElement; + }; + + it('should return a drawing element as copy', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(isDrawingElement(result.copyEntity)).toEqual(true); + }); + + it('should copy description', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getDrawingElementFromStatus(result); + + expect(copy.description).toEqual(original.description); + }); + + it('should create new id', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getDrawingElementFromStatus(result); + + expect(copy.id).not.toEqual(original.id); + }); + + it('should show status successful', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.status).toEqual(CopyStatusEnum.SUCCESS); + }); + + it('should show type RichTextElement', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.type).toEqual(CopyElementType.DRAWING_ELEMENT); + }); + }); + describe('when copying a file element', () => { const setup = () => { const original = fileElementFactory.build(); diff --git a/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts index 03cdfb15b69..a70d133d324 100644 --- a/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts +++ b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts @@ -6,6 +6,7 @@ import { Card, Column, ColumnBoard, + DrawingElement, EntityId, ExternalToolElement, FileElement, @@ -123,6 +124,24 @@ export class RecursiveCopyVisitor implements BoardCompositeVisitorAsync { this.copyMap.set(original.id, copy); } + async visitDrawingElementAsync(original: DrawingElement): Promise { + const copy = new DrawingElement({ + id: new ObjectId().toHexString(), + description: original.description, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + this.resultMap.set(original.id, { + copyEntity: copy, + type: CopyElementType.DRAWING_ELEMENT, + status: CopyStatusEnum.SUCCESS, + }); + this.copyMap.set(original.id, copy); + + return Promise.resolve(); + } + async visitLinkElementAsync(original: LinkElement): Promise { const copy = new LinkElement({ id: new ObjectId().toHexString(), diff --git a/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts b/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts index 0b55dfdb1de..6061c05eb42 100644 --- a/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts +++ b/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts @@ -5,6 +5,7 @@ import { columnBoardFactory, columnFactory, externalToolElementFactory, + drawingElementFactory, fileElementFactory, linkElementFactory, richTextElementFactory, @@ -93,6 +94,23 @@ describe(ContentElementUpdateVisitor.name, () => { }); }); + describe('when visiting a drawing element using the wrong content', () => { + const setup = () => { + const drawingElement = drawingElementFactory.build(); + const content = new FileContentBody(); + content.caption = 'a caption'; + const updater = new ContentElementUpdateVisitor(content); + + return { drawingElement, updater }; + }; + + it('should throw an error', async () => { + const { drawingElement, updater } = setup(); + + await expect(() => updater.visitDrawingElementAsync(drawingElement)).rejects.toThrow(); + }); + }); + describe('when visiting a submission container element using the wrong content', () => { const setup = () => { const submissionContainerElement = submissionContainerElementFactory.build(); diff --git a/apps/server/src/modules/board/service/content-element-update.visitor.ts b/apps/server/src/modules/board/service/content-element-update.visitor.ts index 86e3fb67985..77d9581dec4 100644 --- a/apps/server/src/modules/board/service/content-element-update.visitor.ts +++ b/apps/server/src/modules/board/service/content-element-update.visitor.ts @@ -13,9 +13,11 @@ import { SubmissionContainerElement, SubmissionItem, } from '@shared/domain'; +import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; import { AnyElementContentBody, + DrawingContentBody, ExternalToolContentBody, FileContentBody, LinkContentBody, @@ -82,6 +84,14 @@ export class ContentElementUpdateVisitor implements BoardCompositeVisitorAsync { return this.rejectNotHandled(richTextElement); } + async visitDrawingElementAsync(drawingElement: DrawingElement): Promise { + if (this.content instanceof DrawingContentBody) { + drawingElement.description = this.content.description; + return Promise.resolve(); + } + return this.rejectNotHandled(drawingElement); + } + async visitSubmissionContainerElementAsync(submissionContainerElement: SubmissionContainerElement): Promise { if (this.content instanceof SubmissionContainerContentBody) { if (this.content.dueDate !== undefined) { diff --git a/apps/server/src/modules/board/service/content-element.service.spec.ts b/apps/server/src/modules/board/service/content-element.service.spec.ts index d70fe591bbb..73bbf150f80 100644 --- a/apps/server/src/modules/board/service/content-element.service.spec.ts +++ b/apps/server/src/modules/board/service/content-element.service.spec.ts @@ -1,5 +1,5 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { NotFoundException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ContentElementFactory, @@ -9,7 +9,7 @@ import { RichTextElement, SubmissionContainerElement, } from '@shared/domain'; -import { setupEntities } from '@shared/testing'; +import { drawingElementFactory, setupEntities } from '@shared/testing'; import { cardFactory, fileElementFactory, @@ -18,6 +18,7 @@ import { submissionContainerElementFactory, } from '@shared/testing/factory/domainobject'; import { + DrawingContentBody, FileContentBody, LinkContentBody, RichTextContentBody, @@ -190,6 +191,25 @@ describe(ContentElementService.name, () => { expect(boardDoRepo.save).toHaveBeenCalledWith([richTextElement], card); }); }); + + describe('when creating a drawing element multiple times', () => { + const setup = () => { + const card = cardFactory.build(); + const drawingElement = drawingElementFactory.build(); + + contentElementFactory.build.mockReturnValue(drawingElement); + + return { card, drawingElement }; + }; + + it('should return error for second creation', async () => { + const { card } = setup(); + + await service.create(card, ContentElementType.DRAWING); + + await expect(service.create(card, ContentElementType.DRAWING)).rejects.toThrow(BadRequestException); + }); + }); }); describe('delete', () => { @@ -248,6 +268,34 @@ describe(ContentElementService.name, () => { }); }); + describe('when element is a drawing element', () => { + const setup = () => { + const drawingElement = drawingElementFactory.build(); + const content = new DrawingContentBody(); + content.description = 'test-description'; + const card = cardFactory.build(); + boardDoRepo.findParentOfId.mockResolvedValue(card); + + return { drawingElement, content, card }; + }; + + it('should update the element', async () => { + const { drawingElement, content } = setup(); + + await service.update(drawingElement, content); + + expect(drawingElement.description).toEqual(content.description); + }); + + it('should persist the element', async () => { + const { drawingElement, content, card } = setup(); + + await service.update(drawingElement, content); + + expect(boardDoRepo.save).toHaveBeenCalledWith(drawingElement, card); + }); + }); + describe('when element is a file element', () => { const setup = () => { const fileElement = fileElementFactory.build(); diff --git a/apps/server/src/modules/board/uc/card.uc.spec.ts b/apps/server/src/modules/board/uc/card.uc.spec.ts index 7df87747b9c..004944f6dd5 100644 --- a/apps/server/src/modules/board/uc/card.uc.spec.ts +++ b/apps/server/src/modules/board/uc/card.uc.spec.ts @@ -6,6 +6,7 @@ import { cardFactory, richTextElementFactory } from '@shared/testing/factory/dom import { LegacyLogger } from '@src/core/logger'; import { AuthorizationService } from '@modules/authorization'; import { ObjectId } from 'bson'; +import { HttpService } from '@nestjs/axios'; import { BoardDoAuthorizableService, ContentElementService, CardService } from '../service'; import { CardUc } from './card.uc'; @@ -41,6 +42,10 @@ describe(CardUc.name, () => { provide: LegacyLogger, useValue: createMock(), }, + { + provide: HttpService, + useValue: createMock(), + }, ], }).compile(); diff --git a/apps/server/src/modules/board/uc/element.uc.spec.ts b/apps/server/src/modules/board/uc/element.uc.spec.ts index e17c20bb067..e28fb7638ec 100644 --- a/apps/server/src/modules/board/uc/element.uc.spec.ts +++ b/apps/server/src/modules/board/uc/element.uc.spec.ts @@ -3,6 +3,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BoardDoAuthorizable, InputFormat } from '@shared/domain'; import { fileElementFactory, + drawingElementFactory, richTextElementFactory, setupEntities, submissionContainerElementFactory, @@ -13,6 +14,7 @@ import { Logger } from '@src/core/logger'; import { AuthorizationService, Action } from '@modules/authorization'; import { ObjectId } from 'bson'; import { ForbiddenException } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; import { BoardDoAuthorizableService, ContentElementService, SubmissionItemService } from '../service'; import { ElementUc } from './element.uc'; @@ -47,6 +49,10 @@ describe(ElementUc.name, () => { provide: Logger, useValue: createMock(), }, + { + provide: HttpService, + useValue: createMock(), + }, ], }).compile(); @@ -179,16 +185,18 @@ describe(ElementUc.name, () => { expect(elementService.delete).toHaveBeenCalledWith(element); }); }); + describe('when deleting a content element', () => { const setup = () => { const user = userFactory.build(); const element = richTextElementFactory.build(); + const drawingElement = drawingElementFactory.build(); boardDoAuthorizableService.getBoardAuthorizable.mockResolvedValue( new BoardDoAuthorizable({ users: [], id: new ObjectId().toHexString() }) ); - return { user, element }; + return { user, element, drawingElement }; }; it('should call the service to find the element', async () => { diff --git a/apps/server/src/modules/copy-helper/types/copy.types.ts b/apps/server/src/modules/copy-helper/types/copy.types.ts index fff1f0da795..b1968ec4dee 100644 --- a/apps/server/src/modules/copy-helper/types/copy.types.ts +++ b/apps/server/src/modules/copy-helper/types/copy.types.ts @@ -22,6 +22,7 @@ export enum CopyElementType { 'EXTERNAL_TOOL_ELEMENT' = 'EXTERNAL_TOOL_ELEMENT', 'FILE' = 'FILE', 'FILE_ELEMENT' = 'FILE_ELEMENT', + 'DRAWING_ELEMENT' = 'DRAWING_ELEMENT', 'FILE_GROUP' = 'FILE_GROUP', 'LEAF' = 'LEAF', 'LESSON' = 'LESSON', diff --git a/apps/server/src/modules/tldraw-client/index.ts b/apps/server/src/modules/tldraw-client/index.ts new file mode 100644 index 00000000000..5b97403deca --- /dev/null +++ b/apps/server/src/modules/tldraw-client/index.ts @@ -0,0 +1 @@ +export * from './tldraw-client.module'; diff --git a/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.spec.ts b/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.spec.ts new file mode 100644 index 00000000000..6d738d8782d --- /dev/null +++ b/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.spec.ts @@ -0,0 +1,65 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { HttpStatus } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { axiosResponseFactory, setupEntities } from '@shared/testing'; +import { HttpService } from '@nestjs/axios'; +import { of } from 'rxjs'; +import { LegacyLogger } from '@src/core/logger'; +import { DrawingElementAdapterService } from './drawing-element-adapter.service'; + +describe(DrawingElementAdapterService.name, () => { + let module: TestingModule; + let service: DrawingElementAdapterService; + let httpService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + DrawingElementAdapterService, + { + provide: HttpService, + useValue: createMock(), + }, + { + provide: LegacyLogger, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(DrawingElementAdapterService); + httpService = module.get(HttpService); + + await setupEntities(); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('deleteDrawingBinData', () => { + describe('when calling the delete drawing method', () => { + const setup = () => { + httpService.delete.mockReturnValue( + of( + axiosResponseFactory.build({ + data: '', + status: HttpStatus.OK, + statusText: 'OK', + }) + ) + ); + }; + + it('should call axios delete method', async () => { + setup(); + await service.deleteDrawingBinData('test'); + expect(httpService.delete).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.ts b/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.ts new file mode 100644 index 00000000000..ff3f18abfb6 --- /dev/null +++ b/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; +import { LegacyLogger } from '@src/core/logger'; +import { firstValueFrom } from 'rxjs'; +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { HttpService } from '@nestjs/axios'; + +@Injectable() +export class DrawingElementAdapterService { + constructor(private logger: LegacyLogger, private readonly httpService: HttpService) { + this.logger.setContext(DrawingElementAdapterService.name); + } + + async deleteDrawingBinData(docName: string): Promise { + await firstValueFrom( + this.httpService.delete(`${Configuration.get('TLDRAW_URI') as string}/api/v3/tldraw-document/${docName}`, { + headers: { + Accept: 'Application/json', + }, + }) + ); + } +} diff --git a/apps/server/src/modules/tldraw-client/service/index.ts b/apps/server/src/modules/tldraw-client/service/index.ts new file mode 100644 index 00000000000..10a16c9972a --- /dev/null +++ b/apps/server/src/modules/tldraw-client/service/index.ts @@ -0,0 +1 @@ +export * from './drawing-element-adapter.service'; diff --git a/apps/server/src/modules/tldraw-client/tldraw-client.module.ts b/apps/server/src/modules/tldraw-client/tldraw-client.module.ts new file mode 100644 index 00000000000..e015715b208 --- /dev/null +++ b/apps/server/src/modules/tldraw-client/tldraw-client.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { LoggerModule } from '@src/core/logger'; +import { DrawingElementAdapterService } from './service'; + +@Module({ + imports: [LoggerModule], + providers: [DrawingElementAdapterService], + exports: [], +}) +export class TldrawClientModule {} diff --git a/apps/server/src/modules/tldraw/config.ts b/apps/server/src/modules/tldraw/config.ts new file mode 100644 index 00000000000..a892ee6c843 --- /dev/null +++ b/apps/server/src/modules/tldraw/config.ts @@ -0,0 +1,30 @@ +import { Configuration } from '@hpi-schul-cloud/commons'; + +export interface TldrawConfig { + NEST_LOG_LEVEL: string; + INCOMING_REQUEST_TIMEOUT: number; + TLDRAW_DB_COLLECTION_NAME: string; + TLDRAW_DB_FLUSH_SIZE: string; + TLDRAW_DB_MULTIPLE_COLLECTIONS: boolean; + CONNECTION_STRING: string; + FEATURE_TLDRAW_ENABLED: boolean; + TLDRAW_PING_TIMEOUT: number; + TLDRAW_GC_ENABLED: number; +} + +const tldrawConnectionString: string = Configuration.get('TLDRAW_DB_URL') as string; + +const tldrawConfig = { + NEST_LOG_LEVEL: Configuration.get('NEST_LOG_LEVEL') as string, + INCOMING_REQUEST_TIMEOUT: Configuration.get('INCOMING_REQUEST_TIMEOUT_API') as number, + TLDRAW_DB_COLLECTION_NAME: Configuration.get('TLDRAW__DB_COLLECTION_NAME') as string, + TLDRAW_DB_FLUSH_SIZE: Configuration.get('TLDRAW__DB_FLUSH_SIZE') as number, + TLDRAW_DB_MULTIPLE_COLLECTIONS: Configuration.get('TLDRAW__DB_MULTIPLE_COLLECTIONS') as boolean, + FEATURE_TLDRAW_ENABLED: Configuration.get('FEATURE_TLDRAW_ENABLED') as boolean, + CONNECTION_STRING: tldrawConnectionString, + TLDRAW_PING_TIMEOUT: Configuration.get('TLDRAW__PING_TIMEOUT') as number, + TLDRAW_GC_ENABLED: Configuration.get('TLDRAW__GC_ENABLED') as boolean, +}; + +export const SOCKET_PORT = Configuration.get('TLDRAW__SOCKET_PORT') as number; +export const config = () => tldrawConfig; diff --git a/apps/server/src/modules/tldraw/controller/api-test/tldraw.ws.api.spec.ts b/apps/server/src/modules/tldraw/controller/api-test/tldraw.ws.api.spec.ts new file mode 100644 index 00000000000..ade447b127c --- /dev/null +++ b/apps/server/src/modules/tldraw/controller/api-test/tldraw.ws.api.spec.ts @@ -0,0 +1,129 @@ +import { WsAdapter } from '@nestjs/platform-ws'; +import { Test } from '@nestjs/testing'; +import WebSocket from 'ws'; +import { TextEncoder } from 'util'; +import { INestApplication } from '@nestjs/common'; +import { TldrawWsTestModule } from '@src/modules/tldraw/tldraw-ws-test.module'; +import { TldrawWs } from '../tldraw.ws'; +import { TestConnection } from '../../testing/test-connection'; + +describe('WebSocketController (WsAdapter)', () => { + let app: INestApplication; + let gateway: TldrawWs; + let ws: WebSocket; + + const gatewayPort = 3346; + const wsUrl = TestConnection.getWsUrl(gatewayPort); + const clientMessageMock = 'test-message'; + + const getMessage = () => new TextEncoder().encode(clientMessageMock); + + beforeAll(async () => { + const testingModule = await Test.createTestingModule({ + imports: [TldrawWsTestModule], + }).compile(); + gateway = testingModule.get(TldrawWs); + app = testingModule.createNestApplication(); + app.useWebSocketAdapter(new WsAdapter(app)); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(() => { + jest.useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when tldraw is correctly setup', () => { + const setup = async () => { + const handleConnectionSpy = jest.spyOn(gateway, 'handleConnection'); + jest.spyOn(Uint8Array.prototype, 'reduce').mockReturnValueOnce(1); + + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + + const { buffer } = getMessage(); + + return { handleConnectionSpy, buffer }; + }; + + it(`should handle connection and data transfer`, async () => { + const { handleConnectionSpy, buffer } = await setup(); + ws.send(buffer, () => {}); + + expect(handleConnectionSpy).toHaveBeenCalledTimes(1); + ws.close(); + }); + + it(`check if client will receive message`, async () => { + const { buffer } = await setup(); + ws.send(buffer, () => {}); + + gateway.server.on('connection', (client) => { + client.on('message', (payload) => { + expect(payload).toBeInstanceOf(ArrayBuffer); + }); + }); + + ws.close(); + }); + }); + + describe('when tldraw doc has multiple clients', () => { + const setup = async () => { + const handleConnectionSpy = jest.spyOn(gateway, 'handleConnection'); + ws = await TestConnection.setupWs(wsUrl, 'TEST2'); + const ws2 = await TestConnection.setupWs(wsUrl, 'TEST2'); + + const { buffer } = getMessage(); + + return { + handleConnectionSpy, + ws2, + buffer, + }; + }; + + it(`should handle 2 connections at same doc and data transfer`, async () => { + const { handleConnectionSpy, ws2, buffer } = await setup(); + ws.send(buffer); + ws2.send(buffer); + + expect(handleConnectionSpy).toHaveBeenCalled(); + expect(handleConnectionSpy).toHaveBeenCalledTimes(2); + + ws.close(); + ws2.close(); + }); + }); + + describe('when tldraw is not correctly setup', () => { + const setup = async () => { + const handleConnectionSpy = jest.spyOn(gateway, 'handleConnection'); + + ws = await TestConnection.setupWs(wsUrl); + + return { + handleConnectionSpy, + }; + }; + + it(`should refuse connection if there is no docName`, async () => { + const { handleConnectionSpy } = await setup(); + + const { buffer } = getMessage(); + ws.send(buffer); + + expect(gateway.server).toBeDefined(); + expect(handleConnectionSpy).toHaveBeenCalled(); + expect(handleConnectionSpy).toHaveBeenCalledTimes(1); + + ws.close(); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/controller/index.ts b/apps/server/src/modules/tldraw/controller/index.ts new file mode 100644 index 00000000000..0b0cf7d103b --- /dev/null +++ b/apps/server/src/modules/tldraw/controller/index.ts @@ -0,0 +1 @@ +export * from './tldraw.ws'; diff --git a/apps/server/src/modules/tldraw/controller/tldraw.controller.spec.ts b/apps/server/src/modules/tldraw/controller/tldraw.controller.spec.ts new file mode 100644 index 00000000000..2528fd8c4d7 --- /dev/null +++ b/apps/server/src/modules/tldraw/controller/tldraw.controller.spec.ts @@ -0,0 +1,53 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { TldrawController } from './tldraw.controller'; +import { TldrawService } from '../service/tldraw.service'; +import { TldrawDeleteParams } from './tldraw.params'; + +describe('TldrawController', () => { + let module: TestingModule; + let controller: TldrawController; + let service: TldrawService; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + { + provide: TldrawService, + useValue: createMock(), + }, + ], + controllers: [TldrawController], + }).compile(); + + controller = module.get(TldrawController); + service = module.get(TldrawService); + }); + + afterAll(async () => { + await module.close(); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('delete', () => { + describe('when task should be copied via API call', () => { + const setup = () => { + const params: TldrawDeleteParams = { + docName: 'test-name', + }; + + const ucSpy = jest.spyOn(service, 'deleteByDocName').mockImplementation(() => Promise.resolve()); + return { params, ucSpy }; + }; + + it('should call service with parentIds', async () => { + const { params, ucSpy } = setup(); + await controller.deleteByDocName(params); + expect(ucSpy).toHaveBeenCalledWith('test-name'); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/controller/tldraw.controller.ts b/apps/server/src/modules/tldraw/controller/tldraw.controller.ts new file mode 100644 index 00000000000..3bc7137f5ec --- /dev/null +++ b/apps/server/src/modules/tldraw/controller/tldraw.controller.ts @@ -0,0 +1,22 @@ +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Controller, Delete, ForbiddenException, HttpCode, NotFoundException, Param } from '@nestjs/common'; +import { ApiValidationError } from '@shared/common'; +import { TldrawService } from '../service/tldraw.service'; +import { TldrawDeleteParams } from './tldraw.params'; + +@ApiTags('Tldraw Document') +@Controller('tldraw-document') +export class TldrawController { + constructor(private readonly tldrawService: TldrawService) {} + + @ApiOperation({ summary: 'Delete every element of tldraw drawing by its docName.' }) + @ApiResponse({ status: 204 }) + @ApiResponse({ status: 400, type: ApiValidationError }) + @ApiResponse({ status: 403, type: ForbiddenException }) + @ApiResponse({ status: 404, type: NotFoundException }) + @HttpCode(204) + @Delete(':docName') + async deleteByDocName(@Param() urlParams: TldrawDeleteParams) { + await this.tldrawService.deleteByDocName(urlParams.docName); + } +} diff --git a/apps/server/src/modules/tldraw/controller/tldraw.params.ts b/apps/server/src/modules/tldraw/controller/tldraw.params.ts new file mode 100644 index 00000000000..860b46332bf --- /dev/null +++ b/apps/server/src/modules/tldraw/controller/tldraw.params.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class TldrawDeleteParams { + @IsString() + @ApiProperty({ + description: 'The name of drawing that should be deleted.', + required: true, + nullable: false, + }) + docName!: string; +} diff --git a/apps/server/src/modules/tldraw/controller/tldraw.ws.ts b/apps/server/src/modules/tldraw/controller/tldraw.ws.ts new file mode 100644 index 00000000000..343997b2aba --- /dev/null +++ b/apps/server/src/modules/tldraw/controller/tldraw.ws.ts @@ -0,0 +1,48 @@ +import { WebSocketGateway, WebSocketServer, OnGatewayInit, OnGatewayConnection } from '@nestjs/websockets'; +import { Server, WebSocket } from 'ws'; +import { ConfigService } from '@nestjs/config'; +import { TldrawConfig, SOCKET_PORT } from '../config'; +import { WsCloseCodeEnum } from '../types'; +import { TldrawWsService } from '../service'; + +@WebSocketGateway(SOCKET_PORT) +export class TldrawWs implements OnGatewayInit, OnGatewayConnection { + @WebSocketServer() + server!: Server; + + constructor( + private readonly configService: ConfigService, + private readonly tldrawWsService: TldrawWsService + ) {} + + public handleConnection(client: WebSocket, request: Request): void { + const docName = this.getDocNameFromRequest(request); + + if (docName.length > 0 && this.configService.get('FEATURE_TLDRAW_ENABLED')) { + this.tldrawWsService.setupWSConnection(client, docName); + } else { + client.close( + WsCloseCodeEnum.WS_CLIENT_BAD_REQUEST_CODE, + 'Document name is mandatory in url or Tldraw Tool is turned off.' + ); + } + } + + public afterInit(): void { + this.tldrawWsService.setPersistence({ + bindState: async (docName, ydoc) => { + await this.tldrawWsService.updateDocument(docName, ydoc); + }, + writeState: async (docName) => { + // This is called when all connections to the document are closed. + // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access + await this.tldrawWsService.flushDocument(docName); + }, + }); + } + + private getDocNameFromRequest(request: Request): string { + const urlStripped = request.url.replace(/(\/)|(tldraw-server)/g, ''); + return urlStripped; + } +} diff --git a/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.spec.ts b/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.spec.ts new file mode 100644 index 00000000000..78cf9ea9428 --- /dev/null +++ b/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.spec.ts @@ -0,0 +1,165 @@ +import { Test } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import WebSocket from 'ws'; +import { WsAdapter } from '@nestjs/platform-ws'; +import { CoreModule } from '@src/core'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; +import { createMock } from '@golevelup/ts-jest'; +import * as AwarenessProtocol from 'y-protocols/awareness'; +import { config } from '../config'; +import { TldrawBoardRepo } from '../repo/tldraw-board.repo'; +import { TldrawWsService } from '../service'; +import { WsSharedDocDo } from './ws-shared-doc.do'; +import { TldrawWs } from '../controller'; +import { TestConnection } from '../testing/test-connection'; + +describe('WsSharedDocDo', () => { + let app: INestApplication; + let ws: WebSocket; + let service: TldrawWsService; + + const gatewayPort = 3346; + const wsUrl = TestConnection.getWsUrl(gatewayPort); + + jest.useFakeTimers(); + + beforeAll(async () => { + const imports = [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))]; + const testingModule = await Test.createTestingModule({ + imports, + providers: [ + TldrawWs, + TldrawBoardRepo, + { + provide: TldrawWsService, + useValue: createMock(), + }, + ], + }).compile(); + + service = testingModule.get(TldrawWsService); + app = testingModule.createNestApplication(); + app.useWebSocketAdapter(new WsAdapter(app)); + jest.useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('ydoc client awareness change handler', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + + class MockAwareness { + on = jest.fn(); + } + const doc = new WsSharedDocDo('TEST', service); + doc.awareness = new MockAwareness() as unknown as AwarenessProtocol.Awareness; + const awarenessMetaMock = new Map(); + awarenessMetaMock.set(1, { clock: 11, lastUpdated: 21 }); + awarenessMetaMock.set(2, { clock: 12, lastUpdated: 22 }); + awarenessMetaMock.set(3, { clock: 13, lastUpdated: 23 }); + const awarenessStatesMock = new Map(); + awarenessStatesMock.set(1, { updating: '21' }); + awarenessStatesMock.set(2, { updating: '22' }); + awarenessStatesMock.set(3, { updating: '23' }); + doc.awareness.states = awarenessStatesMock; + doc.awareness.meta = awarenessMetaMock; + + const sendSpy = jest.spyOn(service, 'send').mockReturnValue(); + + const mockIDs = new Set(); + const mockConns = new Map>(); + mockConns.set(ws, mockIDs); + doc.conns = mockConns; + + return { + sendSpy, + doc, + mockIDs, + mockConns, + }; + }; + + describe('when adding two clients states', () => { + it('should have two registered clients states', async () => { + const { sendSpy, doc, mockIDs } = await setup(); + const awarenessUpdate = { + added: [1, 3], + updated: [], + removed: [], + }; + doc.awarenessChangeHandler(awarenessUpdate, ws); + + expect(mockIDs.size).toBe(2); + expect(mockIDs.has(1)).toBe(true); + expect(mockIDs.has(3)).toBe(true); + expect(mockIDs.has(2)).toBe(false); + expect(sendSpy).toBeCalled(); + + ws.close(); + sendSpy.mockRestore(); + }); + }); + + describe('when removing one of two existing clients states', () => { + it('should have one registered client state', async () => { + const { sendSpy, doc, mockIDs } = await setup(); + let awarenessUpdate: { added: number[]; updated: number[]; removed: number[] } = { + added: [1, 3], + updated: [], + removed: [], + }; + + doc.awarenessChangeHandler(awarenessUpdate, ws); + + awarenessUpdate = { + added: [], + updated: [], + removed: [1], + }; + + doc.awarenessChangeHandler(awarenessUpdate, ws); + + expect(mockIDs.size).toBe(1); + expect(mockIDs.has(1)).toBe(false); + expect(mockIDs.has(3)).toBe(true); + expect(sendSpy).toBeCalled(); + + ws.close(); + sendSpy.mockRestore(); + }); + }); + + describe('when updating client state', () => { + it('should not change number of states', async () => { + const { sendSpy, doc, mockIDs } = await setup(); + let awarenessUpdate: { added: number[]; updated: number[]; removed: number[] } = { + added: [1], + updated: [], + removed: [], + }; + + doc.awarenessChangeHandler(awarenessUpdate, ws); + + awarenessUpdate = { + added: [], + updated: [1], + removed: [], + }; + + doc.awarenessChangeHandler(awarenessUpdate, ws); + + expect(mockIDs.size).toBe(1); + expect(mockIDs.has(1)).toBe(true); + expect(sendSpy).toBeCalled(); + + ws.close(); + sendSpy.mockRestore(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.ts b/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.ts new file mode 100644 index 00000000000..a7084ada0da --- /dev/null +++ b/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.ts @@ -0,0 +1,88 @@ +import { Doc } from 'yjs'; +import WebSocket from 'ws'; +import { Awareness, encodeAwarenessUpdate } from 'y-protocols/awareness'; +import { encoding } from 'lib0'; +import { TldrawWsService } from '@modules/tldraw/service'; +import { WSMessageType } from '../types/connection-enum'; + +export class WsSharedDocDo extends Doc { + public name: string; + + public conns: Map>; + + public awareness: Awareness; + + /** + * @param {string} name + * @param {TldrawWsService} tldrawService + * @param {boolean} gcEnabled + */ + constructor(name: string, private tldrawService: TldrawWsService, gcEnabled = true) { + super({ gc: gcEnabled }); + this.name = name; + this.conns = new Map(); + this.awareness = new Awareness(this); + this.awareness.setLocalState(null); + + this.awareness.on('update', this.awarenessChangeHandler); + this.on('update', (update: Uint8Array, origin, doc: WsSharedDocDo) => { + this.tldrawService.updateHandler(update, origin, doc); + }); + } + + /** + * @param {{ added: Array, updated: Array, removed: Array }} changes + * @param {WebSocket | null} wsConnection Origin is the connection that made the change + */ + public awarenessChangeHandler = ( + { added, updated, removed }: { added: Array; updated: Array; removed: Array }, + wsConnection: WebSocket | null + ): void => { + const changedClients = this.manageClientsConnections({ added, updated, removed }, wsConnection); + const buff = this.prepareAwarenessMessage(changedClients); + this.sendAwarenessMessage(buff); + }; + + /** + * @param {{ added: Array, updated: Array, removed: Array }} changes + * @param {WebSocket | null} wsConnection Origin is the connection that made the change + */ + private manageClientsConnections( + { added, updated, removed }: { added: Array; updated: Array; removed: Array }, + wsConnection: WebSocket | null + ): number[] { + const changedClients = added.concat(updated, removed); + if (wsConnection !== null) { + const connControlledIDs = this.conns.get(wsConnection); + if (connControlledIDs !== undefined) { + added.forEach((clientID) => { + connControlledIDs.add(clientID); + }); + removed.forEach((clientID) => { + connControlledIDs.delete(clientID); + }); + } + } + return changedClients; + } + + /** + * @param changedClients array of changed clients + */ + private prepareAwarenessMessage(changedClients: number[]): Uint8Array { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, WSMessageType.AWARENESS); + encoding.writeVarUint8Array(encoder, encodeAwarenessUpdate(this.awareness, changedClients)); + const message = encoding.toUint8Array(encoder); + return message; + } + + /** + * @param {{ Uint8Array }} buff encoded message about changes + */ + private sendAwarenessMessage(buff: Uint8Array): void { + this.conns.forEach((_, c) => { + this.tldrawService.send(this, c, buff); + }); + } +} diff --git a/apps/server/src/modules/tldraw/entities/index.ts b/apps/server/src/modules/tldraw/entities/index.ts new file mode 100644 index 00000000000..2e9bb23bb67 --- /dev/null +++ b/apps/server/src/modules/tldraw/entities/index.ts @@ -0,0 +1 @@ +export * from './tldraw-drawing.entity'; diff --git a/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.spec.ts b/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.spec.ts new file mode 100644 index 00000000000..a85ae26319c --- /dev/null +++ b/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.spec.ts @@ -0,0 +1,29 @@ +import { setupEntities } from '@shared/testing'; +import { TldrawDrawing } from './tldraw-drawing.entity'; + +describe('tldraw entity', () => { + beforeAll(async () => { + await setupEntities(); + }); + + describe('constructor', () => { + describe('when creating a tldraw doc', () => { + it('should create drawing', () => { + const tldraw = new TldrawDrawing({ + docName: 'test', + version: 'v1_tst', + value: 'bindatamock', + _id: 'test-id', + clock: 0, + action: 'update', + }); + expect(tldraw).toBeInstanceOf(TldrawDrawing); + }); + + it('should throw with empty docName', () => { + const call = () => new TldrawDrawing({ docName: '', version: 'v1_tst', value: 'bindatamock', _id: 'test-id' }); + expect(call).toThrow(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.ts b/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.ts new file mode 100644 index 00000000000..b6db76a3f2e --- /dev/null +++ b/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.ts @@ -0,0 +1,46 @@ +import { Entity, PrimaryKey, Property } from '@mikro-orm/core'; +import { BadRequestException } from '@nestjs/common'; +import { ObjectId } from '@mikro-orm/mongodb'; + +@Entity({ tableName: 'drawings' }) +export class TldrawDrawing { + constructor(props: TldrawDrawingProps) { + if (!props.docName) throw new BadRequestException('Tldraw element should have name.'); + this.docName = props.docName; + this.version = props.version; + this.value = props.value; + if (typeof props.clock === 'number') { + this.clock = props.clock; + } + if (props.action) { + this.action = props.action; + } + } + + @PrimaryKey() + _id!: ObjectId; + + @Property({ nullable: false }) + docName: string; + + @Property({ nullable: false }) + version: string; + + @Property({ nullable: false }) + value: string; + + @Property({ nullable: true }) + clock?: number; + + @Property({ nullable: true }) + action?: string; +} + +export interface TldrawDrawingProps { + _id?: string; + docName: string; + version: string; + clock?: number; + action?: string; + value: string; +} diff --git a/apps/server/src/modules/tldraw/factory/index.ts b/apps/server/src/modules/tldraw/factory/index.ts new file mode 100644 index 00000000000..7a5f39169bf --- /dev/null +++ b/apps/server/src/modules/tldraw/factory/index.ts @@ -0,0 +1 @@ +export * from './tldraw.factory'; diff --git a/apps/server/src/modules/tldraw/factory/tldraw.factory.ts b/apps/server/src/modules/tldraw/factory/tldraw.factory.ts new file mode 100644 index 00000000000..3cb63e9418b --- /dev/null +++ b/apps/server/src/modules/tldraw/factory/tldraw.factory.ts @@ -0,0 +1,14 @@ +import { BaseFactory } from '@shared/testing/factory/base.factory'; +import { TldrawDrawing, TldrawDrawingProps } from '../entities'; + +export const tldrawEntityFactory = BaseFactory.define( + TldrawDrawing, + ({ sequence }) => { + return { + _id: 'test-id', + docName: 'test-name', + value: 'test-value', + version: `test-version-${sequence}`, + }; + } +); diff --git a/apps/server/src/modules/tldraw/index.ts b/apps/server/src/modules/tldraw/index.ts new file mode 100644 index 00000000000..8966e72549e --- /dev/null +++ b/apps/server/src/modules/tldraw/index.ts @@ -0,0 +1,3 @@ +export * from './tldraw.module'; +export * from './tldraw-test.module'; +export * from './tldraw-ws.module'; diff --git a/apps/server/src/modules/tldraw/repo/index.ts b/apps/server/src/modules/tldraw/repo/index.ts new file mode 100644 index 00000000000..0c1ae29e62f --- /dev/null +++ b/apps/server/src/modules/tldraw/repo/index.ts @@ -0,0 +1 @@ +export * from './tldraw-board.repo'; diff --git a/apps/server/src/modules/tldraw/repo/tldraw-board.repo.spec.ts b/apps/server/src/modules/tldraw/repo/tldraw-board.repo.spec.ts new file mode 100644 index 00000000000..6d9b3c799bb --- /dev/null +++ b/apps/server/src/modules/tldraw/repo/tldraw-board.repo.spec.ts @@ -0,0 +1,221 @@ +import { Test } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import WebSocket from 'ws'; +import { WsAdapter } from '@nestjs/platform-ws'; +import { CoreModule } from '@src/core'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; +import { createMock } from '@golevelup/ts-jest'; +import { Doc } from 'yjs'; +import * as YjsUtils from '../utils/ydoc-utils'; +import { config } from '../config'; +import { TldrawBoardRepo } from './tldraw-board.repo'; +import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; +import { TldrawWsService } from '../service'; +import { TldrawWs } from '../controller'; +import { TestConnection } from '../testing/test-connection'; + +describe('TldrawBoardRepo', () => { + let app: INestApplication; + let repo: TldrawBoardRepo; + let ws: WebSocket; + let service: TldrawWsService; + + const gatewayPort = 3346; + const wsUrl = TestConnection.getWsUrl(gatewayPort); + + jest.useFakeTimers(); + + beforeAll(async () => { + const imports = [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))]; + const testingModule = await Test.createTestingModule({ + imports, + providers: [ + TldrawWs, + TldrawBoardRepo, + { + provide: TldrawWsService, + useValue: createMock(), + }, + ], + }).compile(); + + service = testingModule.get(TldrawWsService); + repo = testingModule.get(TldrawBoardRepo); + app = testingModule.createNestApplication(); + app.useWebSocketAdapter(new WsAdapter(app)); + jest.useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('should check if repo and its properties are set correctly', () => { + expect(repo).toBeDefined(); + expect(repo.mdb).toBeDefined(); + expect(repo.configService).toBeDefined(); + expect(repo.flushSize).toBeDefined(); + expect(repo.multipleCollections).toBeDefined(); + expect(repo.connectionString).toBeDefined(); + expect(repo.collectionName).toBeDefined(); + }); + + describe('updateDocument', () => { + describe('when document receives empty update', () => { + const setup = async () => { + const doc = new WsSharedDocDo('TEST2', service); + ws = await TestConnection.setupWs(wsUrl, 'TEST2'); + const wsSet = new Set(); + wsSet.add(ws); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + doc.conns.set(ws, wsSet); + const storeGetYDocSpy = jest + .spyOn(repo.mdb, 'getYDoc') + .mockImplementation(() => Promise.resolve(new WsSharedDocDo('TEST', service))); + const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdate').mockImplementation(() => Promise.resolve(1)); + + return { + doc, + storeUpdateSpy, + storeGetYDocSpy, + }; + }; + + it('should not update db with diff', async () => { + const { doc, storeUpdateSpy, storeGetYDocSpy } = await setup(); + + await repo.updateDocument('TEST2', doc); + expect(storeUpdateSpy).toHaveBeenCalledTimes(0); + storeUpdateSpy.mockRestore(); + storeGetYDocSpy.mockRestore(); + ws.close(); + }); + }); + + describe('when document receive update', () => { + const setup = async () => { + const clientMessageMock = 'test-message'; + const doc = new WsSharedDocDo('TEST', service); + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + const wsSet = new Set(); + wsSet.add(ws); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + doc.conns.set(ws, wsSet); + const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdate').mockImplementation(() => Promise.resolve(1)); + const storeGetYDocSpy = jest + .spyOn(repo.mdb, 'getYDoc') + .mockImplementation(() => Promise.resolve(new WsSharedDocDo('TEST', service))); + const byteArray = new TextEncoder().encode(clientMessageMock); + + return { + doc, + byteArray, + storeUpdateSpy, + storeGetYDocSpy, + }; + }; + + it('should update db with diff', async () => { + const { doc, byteArray, storeUpdateSpy, storeGetYDocSpy } = await setup(); + + await repo.updateDocument('TEST', doc); + doc.emit('update', [byteArray, undefined, doc]); + expect(storeUpdateSpy).toHaveBeenCalled(); + expect(storeUpdateSpy).toHaveBeenCalledTimes(1); + storeUpdateSpy.mockRestore(); + storeGetYDocSpy.mockRestore(); + ws.close(); + }); + }); + }); + + describe('getYDocFromMdb', () => { + describe('when taking doc data from db', () => { + const setup = () => { + const storeGetYDocSpy = jest + .spyOn(repo.mdb, 'getYDoc') + .mockImplementation(() => Promise.resolve(new WsSharedDocDo('TEST', service))); + + return { + storeGetYDocSpy, + }; + }; + + it('should return ydoc', async () => { + const { storeGetYDocSpy } = setup(); + expect(await repo.getYDocFromMdb('test')).toBeInstanceOf(Doc); + + storeGetYDocSpy.mockRestore(); + }); + }); + }); + + describe('updateStoredDocWithDiff', () => { + describe('when the difference between update and current drawing is more than 0', () => { + const setup = () => { + const calculateDiffSpy = jest.spyOn(YjsUtils, 'calculateDiff').mockImplementationOnce(() => 1); + const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdate').mockResolvedValueOnce(Promise.resolve(1)); + + return { + calculateDiffSpy, + storeUpdateSpy, + }; + }; + + it('should call store update method', () => { + const { storeUpdateSpy, calculateDiffSpy } = setup(); + const diffArray = new Uint8Array(); + repo.updateStoredDocWithDiff('test', diffArray); + + expect(storeUpdateSpy).toHaveBeenCalled(); + + calculateDiffSpy.mockRestore(); + storeUpdateSpy.mockRestore(); + }); + }); + + describe('when the difference between update and current drawing is 0', () => { + const setup = () => { + const calculateDiffSpy = jest.spyOn(YjsUtils, 'calculateDiff').mockImplementationOnce(() => 0); + const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdate'); + + return { + calculateDiffSpy, + storeUpdateSpy, + }; + }; + + it('should not call store update method', () => { + const { storeUpdateSpy, calculateDiffSpy } = setup(); + const diffArray = new Uint8Array(); + repo.updateStoredDocWithDiff('test', diffArray); + + expect(storeUpdateSpy).not.toHaveBeenCalled(); + + calculateDiffSpy.mockRestore(); + storeUpdateSpy.mockRestore(); + }); + }); + }); + + describe('flushDocument', () => { + const setup = () => { + const flushDocumentSpy = jest.spyOn(repo.mdb, 'flushDocument').mockResolvedValueOnce(Promise.resolve()); + + return { flushDocumentSpy }; + }; + + it('should call flush method on mdbPersistence', async () => { + const { flushDocumentSpy } = setup(); + await repo.flushDocument('test'); + + expect(flushDocumentSpy).toHaveBeenCalled(); + + flushDocumentSpy.mockRestore(); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/repo/tldraw-board.repo.ts b/apps/server/src/modules/tldraw/repo/tldraw-board.repo.ts new file mode 100644 index 00000000000..ce3a124f7f0 --- /dev/null +++ b/apps/server/src/modules/tldraw/repo/tldraw-board.repo.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@nestjs/common'; +import { MongodbPersistence } from 'y-mongodb-provider'; +import { ConfigService } from '@nestjs/config'; +import { applyUpdate, Doc, encodeStateAsUpdate, encodeStateVector } from 'yjs'; +import { TldrawConfig } from '../config'; +import { calculateDiff } from '../utils'; +import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; + +@Injectable() +export class TldrawBoardRepo { + public connectionString: string; + + public collectionName: string; + + public flushSize: number; + + public multipleCollections: boolean; + + public mdb: MongodbPersistence; + + constructor(public readonly configService: ConfigService) { + this.connectionString = this.configService.get('CONNECTION_STRING'); + this.collectionName = this.configService.get('TLDRAW_DB_COLLECTION_NAME') ?? 'drawings'; + this.flushSize = this.configService.get('TLDRAW_DB_FLUSH_SIZE') ?? 400; + this.multipleCollections = this.configService.get('TLDRAW_DB_MULTIPLE_COLLECTIONS'); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-assignment + this.mdb = new MongodbPersistence(this.connectionString, { + collectionName: this.collectionName, + flushSize: this.flushSize, + multipleCollections: this.multipleCollections, + }); + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // eslint-disable-next-line consistent-return + public async getYDocFromMdb(docName: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access + const yDoc = await this.mdb.getYDoc(docName); + if (yDoc instanceof Doc) { + return yDoc; + } + } + + public updateStoredDocWithDiff(docName: string, diff: Uint8Array): void { + const calc = calculateDiff(diff); + if (calc > 0) { + void this.mdb.storeUpdate(docName, diff); + } + } + + public async updateDocument(docName: string, ydoc: WsSharedDocDo): Promise { + const persistedYdoc = await this.getYDocFromMdb(docName); + const persistedStateVector = encodeStateVector(persistedYdoc); + const diff = encodeStateAsUpdate(ydoc, persistedStateVector); + this.updateStoredDocWithDiff(docName, diff); + + applyUpdate(ydoc, encodeStateAsUpdate(persistedYdoc)); + + ydoc.on('update', (update: Uint8Array) => { + void this.mdb.storeUpdate(docName, update); + }); + + persistedYdoc.destroy(); + } + + public async flushDocument(docName: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access + await this.mdb.flushDocument(docName); + } +} diff --git a/apps/server/src/modules/tldraw/repo/tldraw.repo.spec.ts b/apps/server/src/modules/tldraw/repo/tldraw.repo.spec.ts new file mode 100644 index 00000000000..9e6f5eabb14 --- /dev/null +++ b/apps/server/src/modules/tldraw/repo/tldraw.repo.spec.ts @@ -0,0 +1,92 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { cleanupCollections } from '@shared/testing'; +import { tldrawEntityFactory } from '@src/modules/tldraw/factory'; +import { TldrawDrawing } from '@src/modules/tldraw/entities'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { TldrawRepo } from './tldraw.repo'; + +describe(TldrawRepo.name, () => { + let module: TestingModule; + let repo: TldrawRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] })], + providers: [TldrawRepo], + }).compile(); + repo = module.get(TldrawRepo); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('create', () => { + describe('when called', () => { + it('should create new drawing node', async () => { + const drawing = tldrawEntityFactory.build(); + + await repo.create(drawing); + em.clear(); + + const result = await em.find(TldrawDrawing, {}); + expect(result[0]._id).toEqual(drawing._id); + }); + + it('should flush the changes', async () => { + const drawing = tldrawEntityFactory.build(); + jest.spyOn(em, 'flush'); + + await repo.create(drawing); + + expect(em.flush).toHaveBeenCalled(); + }); + }); + }); + + describe('findByDocName', () => { + describe('when finding by docName', () => { + const setup = async () => { + const drawing = tldrawEntityFactory.build(); + await em.persistAndFlush(drawing); + em.clear(); + + return { drawing }; + }; + + it('should return the object', async () => { + const { drawing } = await setup(); + const result = await repo.findByDocName(drawing.docName); + expect(result[0].docName).toEqual(drawing.docName); + expect(result[0]._id).toEqual(drawing._id); + }); + + it('should not find any record giving wrong docName', async () => { + const result = await repo.findByDocName('invalid-name'); + expect(result.length).toEqual(0); + }); + }); + }); + + describe('delete', () => { + describe('when finding by docName and deleting all records', () => { + it('should delete all records', async () => { + const drawing = tldrawEntityFactory.build(); + await repo.create(drawing); + + const results = await repo.findByDocName(drawing.docName); + await repo.delete(results); + + const emptyResults = await repo.findByDocName(drawing.docName); + expect(emptyResults.length).toEqual(0); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/repo/tldraw.repo.ts b/apps/server/src/modules/tldraw/repo/tldraw.repo.ts new file mode 100644 index 00000000000..d826b2876ff --- /dev/null +++ b/apps/server/src/modules/tldraw/repo/tldraw.repo.ts @@ -0,0 +1,20 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; +import { TldrawDrawing } from '../entities'; + +@Injectable() +export class TldrawRepo { + constructor(private readonly _em: EntityManager) {} + + async create(entity: TldrawDrawing): Promise { + await this._em.persistAndFlush(entity); + } + + async findByDocName(docName: string): Promise { + return this._em.find(TldrawDrawing, { docName }); + } + + async delete(entity: TldrawDrawing | TldrawDrawing[]): Promise { + await this._em.removeAndFlush(entity); + } +} diff --git a/apps/server/src/modules/tldraw/service/index.ts b/apps/server/src/modules/tldraw/service/index.ts new file mode 100644 index 00000000000..a056b2ece10 --- /dev/null +++ b/apps/server/src/modules/tldraw/service/index.ts @@ -0,0 +1 @@ +export * from './tldraw.ws.service'; diff --git a/apps/server/src/modules/tldraw/service/tldraw.service.spec.ts b/apps/server/src/modules/tldraw/service/tldraw.service.spec.ts new file mode 100644 index 00000000000..cc3a317ec3c --- /dev/null +++ b/apps/server/src/modules/tldraw/service/tldraw.service.spec.ts @@ -0,0 +1,53 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { cleanupCollections } from '@shared/testing'; +import { TldrawDrawing } from '../entities'; +import { tldrawEntityFactory } from '../factory'; +import { TldrawRepo } from '../repo/tldraw.repo'; +import { TldrawService } from './tldraw.service'; + +describe(TldrawService.name, () => { + let module: TestingModule; + let service: TldrawService; + let repo: TldrawRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] })], + providers: [TldrawService, TldrawRepo], + }).compile(); + + repo = module.get(TldrawRepo); + service = module.get(TldrawService); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + jest.resetAllMocks(); + }); + + describe('delete', () => { + describe('when deleting all collection connected to one drawing', () => { + it('should remove all collections giving drawing name', async () => { + const drawing = tldrawEntityFactory.build(); + + await repo.create(drawing); + const result = await repo.findByDocName(drawing.docName); + + expect(result.length).toEqual(1); + + await service.deleteByDocName(drawing.docName); + const emptyResult = await repo.findByDocName(drawing.docName); + + expect(emptyResult.length).toEqual(0); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/service/tldraw.service.ts b/apps/server/src/modules/tldraw/service/tldraw.service.ts new file mode 100644 index 00000000000..4e0aa3db8db --- /dev/null +++ b/apps/server/src/modules/tldraw/service/tldraw.service.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { TldrawRepo } from '../repo/tldraw.repo'; + +@Injectable() +export class TldrawService { + constructor(private readonly tldrawRepo: TldrawRepo) {} + + async deleteByDocName(docName: string): Promise { + const drawings = await this.tldrawRepo.findByDocName(docName); + await this.tldrawRepo.delete(drawings); + } +} diff --git a/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts b/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts new file mode 100644 index 00000000000..ddd186fed0a --- /dev/null +++ b/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts @@ -0,0 +1,449 @@ +import { Test } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import WebSocket from 'ws'; +import { WsAdapter } from '@nestjs/platform-ws'; +import { CoreModule } from '@src/core'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; +import { TextEncoder } from 'util'; +import * as SyncProtocols from 'y-protocols/sync'; +import * as AwarenessProtocol from 'y-protocols/awareness'; +import { encoding } from 'lib0'; +import { TldrawWsFactory } from '@shared/testing/factory/tldraw.ws.factory'; +import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; +import { config } from '../config'; +import { TldrawBoardRepo } from '../repo'; +import { TldrawWs } from '../controller'; +import { TldrawWsService } from '.'; +import { TestConnection } from '../testing/test-connection'; + +jest.mock('y-protocols/awareness', () => { + const moduleMock: unknown = { + __esModule: true, + ...jest.requireActual('y-protocols/awareness'), + }; + return moduleMock; +}); +jest.mock('y-protocols/sync', () => { + const moduleMock: unknown = { + __esModule: true, + ...jest.requireActual('y-protocols/sync'), + }; + return moduleMock; +}); + +describe('TldrawWSService', () => { + let app: INestApplication; + let ws: WebSocket; + let service: TldrawWsService; + + const gatewayPort = 3346; + const wsUrl = TestConnection.getWsUrl(gatewayPort); + + const delay = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + + jest.useFakeTimers(); + + beforeAll(async () => { + const imports = [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))]; + const testingModule = await Test.createTestingModule({ + imports, + providers: [TldrawWs, TldrawBoardRepo, TldrawWsService], + }).compile(); + + service = testingModule.get(TldrawWsService); + app = testingModule.createNestApplication(); + app.useWebSocketAdapter(new WsAdapter(app)); + jest.useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + const createMessage = (values: number[]) => { + const encoder = encoding.createEncoder(); + values.forEach((val) => { + encoding.writeVarUint(encoder, val); + }); + encoding.writeVarUint(encoder, 0); + encoding.writeVarUint(encoder, 1); + const msg = encoding.toUint8Array(encoder); + return { + msg, + }; + }; + + it('should chcek if service properties are set correctly', () => { + expect(service).toBeDefined(); + expect(service.pingTimeout).toBeDefined(); + expect(service.persistence).toBeDefined(); + }); + + describe('send', () => { + describe('when client is not connected to WS', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + const clientMessageMock = 'test-message'; + + const closeConSpy = jest.spyOn(service, 'closeConn').mockImplementationOnce(() => {}); + const sendSpy = jest.spyOn(service, 'send'); + const doc = TldrawWsFactory.createWsSharedDocDo(); + const byteArray = new TextEncoder().encode(clientMessageMock); + + return { + closeConSpy, + sendSpy, + doc, + byteArray, + }; + }; + + it('should throw error for send message', async () => { + const { closeConSpy, sendSpy, doc, byteArray } = await setup(); + + service.send(doc, ws, byteArray); + + expect(sendSpy).toThrow(); + expect(sendSpy).toHaveBeenCalledWith(doc, ws, byteArray); + expect(closeConSpy).toHaveBeenCalled(); + + ws.close(); + sendSpy.mockRestore(); + }); + }); + + describe('when websocket has ready state different than 0 or 1', () => { + const setup = () => { + const clientMessageMock = 'test-message'; + const closeConSpy = jest.spyOn(service, 'closeConn'); + const sendSpy = jest.spyOn(service, 'send'); + const doc = TldrawWsFactory.createWsSharedDocDo(); + const socketMock = TldrawWsFactory.createWebsocket(3); + const byteArray = new TextEncoder().encode(clientMessageMock); + + return { + closeConSpy, + sendSpy, + doc, + socketMock, + byteArray, + }; + }; + + it('should close connection', () => { + const { closeConSpy, sendSpy, doc, socketMock, byteArray } = setup(); + + service.send(doc, socketMock, byteArray); + + expect(sendSpy).toHaveBeenCalledWith(doc, socketMock, byteArray); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(closeConSpy).toHaveBeenCalled(); + + closeConSpy.mockRestore(); + sendSpy.mockRestore(); + }); + }); + + describe('when websocket has ready state 0', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + const clientMessageMock = 'test-message'; + + const sendSpy = jest.spyOn(service, 'send'); + const doc = TldrawWsFactory.createWsSharedDocDo(); + const socketMock = TldrawWsFactory.createWebsocket(0); + doc.conns.set(socketMock, new Set()); + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, 2); + const updateByteArray = new TextEncoder().encode(clientMessageMock); + encoding.writeVarUint8Array(encoder, updateByteArray); + const msg = encoding.toUint8Array(encoder); + return { + sendSpy, + doc, + msg, + }; + }; + + it('should call send in updateHandler', async () => { + const { sendSpy, doc, msg } = await setup(); + + service.updateHandler(msg, {}, doc); + + expect(sendSpy).toHaveBeenCalled(); + + ws.close(); + sendSpy.mockRestore(); + }); + }); + + describe('when received message of specific type', () => { + const setup = async (messageValues: number[]) => { + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + + const sendSpy = jest.spyOn(service, 'send'); + const applyAwarenessUpdateSpy = jest.spyOn(AwarenessProtocol, 'applyAwarenessUpdate'); + const syncProtocolUpdateSpy = jest + .spyOn(SyncProtocols, 'readSyncMessage') + .mockImplementationOnce((dec, enc) => { + enc.bufs = [new Uint8Array(2), new Uint8Array(2)]; + return 1; + }); + const doc = new WsSharedDocDo('TEST', service); + const { msg } = createMessage(messageValues); + + return { + sendSpy, + applyAwarenessUpdateSpy, + syncProtocolUpdateSpy, + doc, + msg, + }; + }; + + it('should call send method when received message of type SYNC', async () => { + const { sendSpy, applyAwarenessUpdateSpy, syncProtocolUpdateSpy, doc, msg } = await setup([0, 1]); + + service.messageHandler(ws, doc, msg); + + expect(sendSpy).toHaveBeenCalledTimes(1); + + ws.close(); + sendSpy.mockRestore(); + applyAwarenessUpdateSpy.mockRestore(); + syncProtocolUpdateSpy.mockRestore(); + }); + + it('should not call send method when received message of type AWARENESS', async () => { + const { sendSpy, applyAwarenessUpdateSpy, syncProtocolUpdateSpy, doc, msg } = await setup([1, 1, 0]); + service.messageHandler(ws, doc, msg); + + expect(sendSpy).toHaveBeenCalledTimes(0); + expect(applyAwarenessUpdateSpy).toHaveBeenCalledTimes(1); + + ws.close(); + sendSpy.mockRestore(); + applyAwarenessUpdateSpy.mockRestore(); + syncProtocolUpdateSpy.mockRestore(); + }); + + it('should do nothing when received message unknown type', async () => { + const { sendSpy, applyAwarenessUpdateSpy, syncProtocolUpdateSpy, doc, msg } = await setup([2]); + service.messageHandler(ws, doc, msg); + + expect(sendSpy).toHaveBeenCalledTimes(0); + expect(applyAwarenessUpdateSpy).toHaveBeenCalledTimes(0); + + ws.close(); + sendSpy.mockRestore(); + applyAwarenessUpdateSpy.mockRestore(); + syncProtocolUpdateSpy.mockRestore(); + }); + }); + + describe('when error is thrown during receiving message', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + + const sendSpy = jest.spyOn(service, 'send'); + jest.spyOn(SyncProtocols, 'readSyncMessage').mockImplementationOnce(() => { + throw new Error('error'); + }); + const doc = new WsSharedDocDo('TEST', service); + const { msg } = createMessage([0]); + + return { + sendSpy, + doc, + msg, + }; + }; + + it('should not call send method', async () => { + const { sendSpy, doc, msg } = await setup(); + + service.messageHandler(ws, doc, msg); + + expect(sendSpy).toHaveBeenCalledTimes(0); + + ws.close(); + sendSpy.mockRestore(); + }); + }); + + describe('when awareness states (clients) size is greater then one', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + + const doc = new WsSharedDocDo('TEST', service); + doc.awareness.states = new Map(); + doc.awareness.states.set(1, ['test1']); + doc.awareness.states.set(2, ['test2']); + + const messageHandlerSpy = jest.spyOn(service, 'messageHandler').mockImplementationOnce(() => {}); + const sendSpy = jest.spyOn(service, 'send'); + const getYDocSpy = jest.spyOn(service, 'getYDoc').mockImplementationOnce(() => doc); + const { msg } = createMessage([0]); + jest.spyOn(AwarenessProtocol, 'encodeAwarenessUpdate').mockImplementationOnce(() => msg); + + return { + messageHandlerSpy, + sendSpy, + getYDocSpy, + }; + }; + + it('should send to every client', async () => { + const { messageHandlerSpy, sendSpy, getYDocSpy } = await setup(); + + service.setupWSConnection(ws); + + expect(sendSpy).toHaveBeenCalledTimes(2); + + ws.close(); + messageHandlerSpy.mockRestore(); + sendSpy.mockRestore(); + getYDocSpy.mockRestore(); + }); + }); + }); + + describe('closeConn', () => { + describe('when trying to close already closed connection', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + + jest.spyOn(ws, 'close').mockImplementationOnce(() => { + throw new Error('some error'); + }); + }; + + it('should throw error', async () => { + await setup(); + try { + const doc = TldrawWsFactory.createWsSharedDocDo(); + service.closeConn(doc, ws); + } catch (err) { + expect(err).toBeDefined(); + } + + ws.close(); + }); + }); + + describe('when ping failed', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + + const messageHandlerSpy = jest.spyOn(service, 'messageHandler').mockImplementationOnce(() => {}); + const closeConnSpy = jest.spyOn(service, 'closeConn'); + jest.spyOn(ws, 'ping').mockImplementationOnce(() => { + throw new Error('error'); + }); + + return { + messageHandlerSpy, + closeConnSpy, + }; + }; + + it('should close connection', async () => { + const { messageHandlerSpy, closeConnSpy } = await setup(); + + service.setupWSConnection(ws); + + await delay(10); + + expect(closeConnSpy).toHaveBeenCalled(); + + ws.close(); + messageHandlerSpy.mockRestore(); + closeConnSpy.mockRestore(); + }); + }); + }); + + describe('messageHandler', () => { + describe('when message is received', () => { + const setup = async (messageValues: number[]) => { + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + + const messageHandlerSpy = jest.spyOn(service, 'messageHandler'); + const readSyncMessageSpy = jest.spyOn(SyncProtocols, 'readSyncMessage').mockImplementationOnce((dec, enc) => { + enc.bufs = [new Uint8Array(2), new Uint8Array(2)]; + return 1; + }); + const { msg } = createMessage(messageValues); + + return { + messageHandlerSpy, + msg, + readSyncMessageSpy, + }; + }; + + it('should handle message', async () => { + const { messageHandlerSpy, msg, readSyncMessageSpy } = await setup([0, 1]); + + service.setupWSConnection(ws); + ws.emit('message', msg); + + expect(messageHandlerSpy).toHaveBeenCalledTimes(1); + + ws.close(); + messageHandlerSpy.mockRestore(); + readSyncMessageSpy.mockRestore(); + }); + }); + }); + + describe('getYDoc', () => { + describe('when getting yDoc by name', () => { + it('should assign to service.doc and return instance', () => { + const docName = 'get-test'; + const doc = service.getYDoc(docName); + expect(doc).toBeInstanceOf(WsSharedDocDo); + expect(service.docs.get(docName)).not.toBeUndefined(); + }); + }); + }); + + describe('updateDocument', () => { + const setup = () => { + const updateDocumentSpy = jest.spyOn(service, 'updateDocument').mockImplementation(() => Promise.resolve()); + + return { updateDocumentSpy }; + }; + + it('should call update method', async () => { + const { updateDocumentSpy } = setup(); + await service.updateDocument('test', TldrawWsFactory.createWsSharedDocDo()); + + expect(updateDocumentSpy).toHaveBeenCalled(); + + updateDocumentSpy.mockRestore(); + }); + }); + + describe('flushDocument', () => { + const setup = () => { + const flushDocumentSpy = jest.spyOn(service, 'flushDocument').mockResolvedValueOnce(Promise.resolve()); + + return { flushDocumentSpy }; + }; + + it('should call flush method', async () => { + const { flushDocumentSpy } = setup(); + await service.flushDocument('test'); + + expect(flushDocumentSpy).toHaveBeenCalled(); + + flushDocumentSpy.mockRestore(); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts b/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts new file mode 100644 index 00000000000..660f5258fa8 --- /dev/null +++ b/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts @@ -0,0 +1,209 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import WebSocket from 'ws'; +import { applyAwarenessUpdate, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness'; +import { encoding, decoding, map } from 'lib0'; +import { readSyncMessage, writeSyncStep1, writeUpdate } from 'y-protocols/sync'; +import { Persitence, WSConnectionState, WSMessageType } from '../types'; +import { TldrawConfig } from '../config'; +import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; +import { TldrawBoardRepo } from '../repo'; + +@Injectable() +export class TldrawWsService { + public pingTimeout: number; + + public persistence: Persitence | null = null; + + public docs = new Map(); + + constructor( + private readonly configService: ConfigService, + private readonly tldrawBoardRepo: TldrawBoardRepo + ) { + this.pingTimeout = this.configService.get('TLDRAW_PING_TIMEOUT'); + } + + public setPersistence(persistence_: Persitence): void { + this.persistence = persistence_; + } + + /** + * @param {WsSharedDocDo} doc + * @param {WebSocket} ws + */ + public closeConn(doc: WsSharedDocDo, ws: WebSocket): void { + if (doc.conns.has(ws)) { + const controlledIds = doc.conns.get(ws) as Set; + doc.conns.delete(ws); + removeAwarenessStates(doc.awareness, Array.from(controlledIds), null); + if (doc.conns.size === 0 && this.persistence !== null) { + // if persisted, we store state and destroy ydocument + this.persistence + .writeState(doc.name, doc) + .then(() => { + doc.destroy(); + return null; + }) + .catch(() => {}); + this.docs.delete(doc.name); + } + } + + try { + ws.close(); + } catch (err) { + throw new Error('Cannot close the connection. It is possible that connection is already closed.'); + } + } + + /** + * @param {WsSharedDocDo} doc + * @param {WebSocket} conn + * @param {Uint8Array} message + */ + public send(doc: WsSharedDocDo, conn: WebSocket, message: Uint8Array): void { + if (conn.readyState !== WSConnectionState.CONNECTING && conn.readyState !== WSConnectionState.OPEN) { + this.closeConn(doc, conn); + } + try { + conn.send(message, (err: Error | undefined) => { + if (err != null) { + this.closeConn(doc, conn); + } + }); + } catch (e) { + this.closeConn(doc, conn); + } + } + + /** + * @param {Uint8Array} update + * @param {any} origin + * @param {WsSharedDocDo} doc + */ + public updateHandler(update: Uint8Array, origin, doc: WsSharedDocDo): void { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, WSMessageType.SYNC); + writeUpdate(encoder, update); + const message = encoding.toUint8Array(encoder); + doc.conns.forEach((_, conn) => { + this.send(doc, conn, message); + }); + } + + /** + * Gets a Y.Doc by name, whether in memory or on disk + * + * @param {string} docName - the name of the Y.Doc to find or create + * @param {boolean} gc - whether to allow gc on the doc (applies only when created) + * @return {WsSharedDocDo} + */ + getYDoc(docName: string, gc = true): WsSharedDocDo { + return map.setIfUndefined(this.docs, docName, () => { + const doc = new WsSharedDocDo(docName, this, gc); + if (this.persistence !== null) { + this.persistence.bindState(docName, doc).catch(() => {}); + } + this.docs.set(docName, doc); + return doc; + }); + } + + public messageHandler(conn: WebSocket, doc: WsSharedDocDo, message: Uint8Array): void { + try { + const encoder = encoding.createEncoder(); + const decoder = decoding.createDecoder(message); + const messageType = decoding.readVarUint(decoder); + switch (messageType) { + case WSMessageType.SYNC: + encoding.writeVarUint(encoder, WSMessageType.SYNC); + readSyncMessage(decoder, encoder, doc, conn); + + // If the `encoder` only contains the type of reply message and no + // message, there is no need to send the message. When `encoder` only + // contains the type of reply, its length is 1. + if (encoding.length(encoder) > 1) { + this.send(doc, conn, encoding.toUint8Array(encoder)); + } + break; + case WSMessageType.AWARENESS: { + applyAwarenessUpdate(doc.awareness, decoding.readVarUint8Array(decoder), conn); + break; + } + default: + break; + } + } catch (err) { + doc.emit('error', [err]); + } + } + + /** + * @param {WebSocket} ws + * @param {string} docName + */ + public setupWSConnection(ws: WebSocket, docName = 'GLOBAL'): void { + ws.binaryType = 'arraybuffer'; + // get doc, initialize if it does not exist yet + const doc = this.getYDoc(docName, true); + doc.conns.set(ws, new Set()); + + // listen and reply to events + ws.on('message', (message: ArrayBufferLike) => { + this.messageHandler(ws, doc, new Uint8Array(message)); + }); + + // Check if connection is still alive + let pongReceived = true; + const pingInterval = setInterval(() => { + const hasConn = doc.conns.has(ws); + + if (pongReceived) { + if (!hasConn) return; + pongReceived = false; + + try { + ws.ping(); + } catch (e) { + this.closeConn(doc, ws); + clearInterval(pingInterval); + } + return; + } + + if (hasConn) { + this.closeConn(doc, ws); + } + + clearInterval(pingInterval); + }, this.pingTimeout); + ws.on('close', () => { + this.closeConn(doc, ws); + clearInterval(pingInterval); + }); + ws.on('pong', () => { + pongReceived = true; + }); + { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, WSMessageType.SYNC); + writeSyncStep1(encoder, doc); + this.send(doc, ws, encoding.toUint8Array(encoder)); + const awarenessStates = doc.awareness.getStates(); + if (awarenessStates.size > 0) { + encoding.writeVarUint(encoder, WSMessageType.AWARENESS); + encoding.writeVarUint8Array(encoder, encodeAwarenessUpdate(doc.awareness, Array.from(awarenessStates.keys()))); + this.send(doc, ws, encoding.toUint8Array(encoder)); + } + } + } + + public async updateDocument(docName: string, ydoc: WsSharedDocDo): Promise { + await this.tldrawBoardRepo.updateDocument(docName, ydoc); + } + + public async flushDocument(docName: string): Promise { + await this.tldrawBoardRepo.flushDocument(docName); + } +} diff --git a/apps/server/src/modules/tldraw/testing/test-connection.ts b/apps/server/src/modules/tldraw/testing/test-connection.ts new file mode 100644 index 00000000000..638c219ea18 --- /dev/null +++ b/apps/server/src/modules/tldraw/testing/test-connection.ts @@ -0,0 +1,22 @@ +import WebSocket from 'ws'; + +export class TestConnection { + public static getWsUrl = (gatewayPort: number): string => { + const wsUrl = `ws://localhost:${gatewayPort}`; + return wsUrl; + }; + + public static setupWs = async (wsUrl: string, docName?: string): Promise => { + let ws: WebSocket; + if (docName) { + ws = new WebSocket(`${wsUrl}/${docName}`); + } else { + ws = new WebSocket(`${wsUrl}`); + } + await new Promise((resolve) => { + ws.on('open', resolve); + }); + + return ws; + }; +} diff --git a/apps/server/src/modules/tldraw/tldraw-test.module.ts b/apps/server/src/modules/tldraw/tldraw-test.module.ts new file mode 100644 index 00000000000..19c38171b88 --- /dev/null +++ b/apps/server/src/modules/tldraw/tldraw-test.module.ts @@ -0,0 +1,36 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { MongoMemoryDatabaseModule, MongoDatabaseModuleOptions } from '@infra/database'; +import { CoreModule } from '@src/core'; +import { LoggerModule } from '@src/core/logger'; +import { AuthenticationModule } from '@modules/authentication/authentication.module'; +import { AuthorizationModule } from '@modules/authorization'; +import { Course, User } from '@shared/domain'; +import { AuthenticationApiModule } from '../authentication/authentication-api.module'; +import { TldrawWsModule } from './tldraw-ws.module'; +import { TldrawWs } from './controller'; +import { TldrawBoardRepo } from './repo'; +import { TldrawWsService } from './service'; + +const imports = [ + TldrawWsModule, + MongoMemoryDatabaseModule.forRoot({ entities: [User, Course] }), + AuthenticationApiModule, + AuthorizationModule, + AuthenticationModule, + CoreModule, + LoggerModule, +]; +const providers = [TldrawWs, TldrawBoardRepo, TldrawWsService]; +@Module({ + imports, + providers, +}) +export class TldrawTestModule { + static forRoot(options?: MongoDatabaseModuleOptions): DynamicModule { + return { + module: TldrawTestModule, + imports: [...imports, MongoMemoryDatabaseModule.forRoot({ ...options })], + providers, + }; + } +} diff --git a/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts b/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts new file mode 100644 index 00000000000..6e3c5a58479 --- /dev/null +++ b/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts @@ -0,0 +1,25 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { MongoMemoryDatabaseModule, MongoDatabaseModuleOptions } from '@infra/database'; +import { CoreModule } from '@src/core'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; +import { TldrawBoardRepo } from './repo'; +import { TldrawWsService } from './service'; +import { config } from './config'; +import { TldrawWs } from './controller'; + +const imports = [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))]; +const providers = [TldrawWs, TldrawBoardRepo, TldrawWsService]; +@Module({ + imports, + providers, +}) +export class TldrawWsTestModule { + static forRoot(options?: MongoDatabaseModuleOptions): DynamicModule { + return { + module: TldrawWsTestModule, + imports: [...imports, MongoMemoryDatabaseModule.forRoot({ ...options })], + providers, + }; + } +} diff --git a/apps/server/src/modules/tldraw/tldraw-ws.module.ts b/apps/server/src/modules/tldraw/tldraw-ws.module.ts new file mode 100644 index 00000000000..98e91b5b3e6 --- /dev/null +++ b/apps/server/src/modules/tldraw/tldraw-ws.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; +import { CoreModule } from '@src/core'; +import { Logger } from '@src/core/logger'; +import { TldrawBoardRepo } from './repo'; +import { TldrawWsService } from './service'; +import { TldrawWs } from './controller'; +import { config } from './config'; + +@Module({ + imports: [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))], + providers: [Logger, TldrawWs, TldrawWsService, TldrawBoardRepo], +}) +export class TldrawWsModule {} diff --git a/apps/server/src/modules/tldraw/tldraw.module.ts b/apps/server/src/modules/tldraw/tldraw.module.ts new file mode 100644 index 00000000000..fa5ebf59d02 --- /dev/null +++ b/apps/server/src/modules/tldraw/tldraw.module.ts @@ -0,0 +1,43 @@ +import { Module, NotFoundException } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions, DB_PASSWORD, DB_USERNAME, TLDRAW_DB_URL } from '@src/config'; +import { CoreModule } from '@src/core'; +import { Logger } from '@src/core/logger'; +import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; +import { AuthenticationModule } from '@modules/authentication/authentication.module'; +import { RabbitMQWrapperTestModule } from '@infra/rabbitmq'; +import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; +import { AuthorizationModule } from '@modules/authorization'; +import { TldrawDrawing } from './entities'; +import { config } from './config'; +import { TldrawService } from './service/tldraw.service'; +import { TldrawBoardRepo } from './repo'; +import { TldrawController } from './controller/tldraw.controller'; +import { TldrawRepo } from './repo/tldraw.repo'; + +const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { + findOneOrFailHandler: (entityName: string, where: Dictionary | IPrimaryKey) => + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + new NotFoundException(`The requested ${entityName}: ${where} has not been found.`), +}; + +@Module({ + imports: [ + AuthorizationModule, + AuthenticationModule, + CoreModule, + RabbitMQWrapperTestModule, + MikroOrmModule.forRoot({ + ...defaultMikroOrmOptions, + type: 'mongo', + clientUrl: TLDRAW_DB_URL, + password: DB_PASSWORD, + user: DB_USERNAME, + entities: [TldrawDrawing], + }), + ConfigModule.forRoot(createConfigModuleOptions(config)), + ], + providers: [Logger, TldrawService, TldrawBoardRepo, TldrawRepo], + controllers: [TldrawController], +}) +export class TldrawModule {} diff --git a/apps/server/src/modules/tldraw/types/connection-enum.ts b/apps/server/src/modules/tldraw/types/connection-enum.ts new file mode 100644 index 00000000000..6a9a4692e03 --- /dev/null +++ b/apps/server/src/modules/tldraw/types/connection-enum.ts @@ -0,0 +1,9 @@ +export enum WSConnectionState { + CONNECTING = 0, + OPEN = 1, +} + +export enum WSMessageType { + SYNC = 0, + AWARENESS = 1, +} diff --git a/apps/server/src/modules/tldraw/types/index.ts b/apps/server/src/modules/tldraw/types/index.ts new file mode 100644 index 00000000000..0579e4b8c79 --- /dev/null +++ b/apps/server/src/modules/tldraw/types/index.ts @@ -0,0 +1,3 @@ +export * from './connection-enum'; +export * from './ws-close-code-enum'; +export * from './persistence-type'; diff --git a/apps/server/src/modules/tldraw/types/persistence-type.ts b/apps/server/src/modules/tldraw/types/persistence-type.ts new file mode 100644 index 00000000000..ee8d4510275 --- /dev/null +++ b/apps/server/src/modules/tldraw/types/persistence-type.ts @@ -0,0 +1,6 @@ +import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; + +export type Persitence = { + bindState: (docName: string, ydoc: WsSharedDocDo) => Promise; + writeState: (docName: string, ydoc: WsSharedDocDo) => Promise; +}; diff --git a/apps/server/src/modules/tldraw/types/ws-close-code-enum.ts b/apps/server/src/modules/tldraw/types/ws-close-code-enum.ts new file mode 100644 index 00000000000..274fa99a6ae --- /dev/null +++ b/apps/server/src/modules/tldraw/types/ws-close-code-enum.ts @@ -0,0 +1,3 @@ +export enum WsCloseCodeEnum { + WS_CLIENT_BAD_REQUEST_CODE = 4400, +} diff --git a/apps/server/src/modules/tldraw/utils/index.ts b/apps/server/src/modules/tldraw/utils/index.ts new file mode 100644 index 00000000000..a51b9059bc1 --- /dev/null +++ b/apps/server/src/modules/tldraw/utils/index.ts @@ -0,0 +1 @@ +export * from './ydoc-utils'; diff --git a/apps/server/src/modules/tldraw/utils/ydoc-utils.ts b/apps/server/src/modules/tldraw/utils/ydoc-utils.ts new file mode 100644 index 00000000000..6d0817ecc9d --- /dev/null +++ b/apps/server/src/modules/tldraw/utils/ydoc-utils.ts @@ -0,0 +1,2 @@ +export const calculateDiff = (diff: Uint8Array): number => + diff.reduce((previousValue, currentValue) => previousValue + currentValue, 0); diff --git a/apps/server/src/shared/domain/domainobject/board/card.do.ts b/apps/server/src/shared/domain/domainobject/board/card.do.ts index 62931e418dd..bba64a9dd9b 100644 --- a/apps/server/src/shared/domain/domainobject/board/card.do.ts +++ b/apps/server/src/shared/domain/domainobject/board/card.do.ts @@ -1,3 +1,4 @@ +import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; import { BoardComposite, BoardCompositeProps } from './board-composite.do'; import { ExternalToolElement } from './external-tool-element.do'; import { FileElement } from './file-element.do'; @@ -26,6 +27,7 @@ export class Card extends BoardComposite { isAllowedAsChild(domainObject: AnyBoardDo): boolean { const allowed = domainObject instanceof FileElement || + domainObject instanceof DrawingElement || domainObject instanceof LinkElement || domainObject instanceof RichTextElement || domainObject instanceof SubmissionContainerElement || diff --git a/apps/server/src/shared/domain/domainobject/board/content-element.factory.spec.ts b/apps/server/src/shared/domain/domainobject/board/content-element.factory.spec.ts index 89d1c297399..352bbaaa293 100644 --- a/apps/server/src/shared/domain/domainobject/board/content-element.factory.spec.ts +++ b/apps/server/src/shared/domain/domainobject/board/content-element.factory.spec.ts @@ -1,4 +1,5 @@ import { NotImplementedException } from '@nestjs/common'; +import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; import { ContentElementFactory } from './content-element.factory'; import { ExternalToolElement } from './external-tool-element.do'; import { FileElement } from './file-element.do'; @@ -30,6 +31,14 @@ describe(ContentElementFactory.name, () => { expect(element).toBeInstanceOf(RichTextElement); }); + it('should return element of DRAWING', () => { + const { contentElementFactory } = setup(); + + const element = contentElementFactory.build(ContentElementType.DRAWING); + + expect(element).toBeInstanceOf(DrawingElement); + }); + it('should return element of SUBMISSION_CONTAINER', () => { const { contentElementFactory } = setup(); diff --git a/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts b/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts index 8c34ca54b56..4f71b96bf8f 100644 --- a/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts +++ b/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts @@ -2,6 +2,7 @@ import { Injectable, NotImplementedException } from '@nestjs/common'; import { InputFormat } from '@shared/domain/types'; import { ObjectId } from 'bson'; import { ExternalToolElement } from './external-tool-element.do'; +import { DrawingElement } from './drawing-element.do'; import { FileElement } from './file-element.do'; import { LinkElement } from './link-element.do'; import { RichTextElement } from './rich-text-element.do'; @@ -23,6 +24,9 @@ export class ContentElementFactory { case ContentElementType.RICH_TEXT: element = this.buildRichText(); break; + case ContentElementType.DRAWING: + element = this.buildDrawing(); + break; case ContentElementType.SUBMISSION_CONTAINER: element = this.buildSubmissionContainer(); break; @@ -78,6 +82,18 @@ export class ContentElementFactory { return element; } + private buildDrawing() { + const element = new DrawingElement({ + id: new ObjectId().toHexString(), + description: '', + children: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + return element; + } + private buildSubmissionContainer() { const element = new SubmissionContainerElement({ id: new ObjectId().toHexString(), diff --git a/apps/server/src/shared/domain/domainobject/board/drawing-element.do.spec.ts b/apps/server/src/shared/domain/domainobject/board/drawing-element.do.spec.ts new file mode 100644 index 00000000000..b8876c7c0b1 --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/board/drawing-element.do.spec.ts @@ -0,0 +1,37 @@ +import { createMock } from '@golevelup/ts-jest'; +import { drawingElementFactory } from '@shared/testing/factory/domainobject/board/drawing-element.do.factory'; +import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; +import { BoardCompositeVisitor, BoardCompositeVisitorAsync } from './types'; + +describe(DrawingElement.name, () => { + describe('when trying to add a child to a drawing element', () => { + it('should throw an error ', () => { + const drawingElement = drawingElementFactory.build(); + const drawingElementChild = drawingElementFactory.build(); + + expect(() => drawingElement.addChild(drawingElementChild)).toThrow(); + }); + }); + + describe('accept', () => { + it('should call the right visitor method', () => { + const visitor = createMock(); + const drawingElement = drawingElementFactory.build(); + + drawingElement.accept(visitor); + + expect(visitor.visitDrawingElement).toHaveBeenCalledWith(drawingElement); + }); + }); + + describe('acceptAsync', () => { + it('should call the right async visitor method', async () => { + const visitor = createMock(); + const drawingElement = drawingElementFactory.build(); + + await drawingElement.acceptAsync(visitor); + + expect(visitor.visitDrawingElementAsync).toHaveBeenCalledWith(drawingElement); + }); + }); +}); diff --git a/apps/server/src/shared/domain/domainobject/board/drawing-element.do.ts b/apps/server/src/shared/domain/domainobject/board/drawing-element.do.ts new file mode 100644 index 00000000000..e4bf11936e8 --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/board/drawing-element.do.ts @@ -0,0 +1,32 @@ +import { BoardComposite, BoardCompositeProps } from './board-composite.do'; +import type { BoardCompositeVisitor, BoardCompositeVisitorAsync } from './types'; + +export class DrawingElement extends BoardComposite { + get description(): string { + return this.props.description; + } + + set description(value: string) { + this.props.description = value; + } + + isAllowedAsChild(): boolean { + return false; + } + + accept(visitor: BoardCompositeVisitor): void { + visitor.visitDrawingElement(this); + } + + async acceptAsync(visitor: BoardCompositeVisitorAsync): Promise { + await visitor.visitDrawingElementAsync(this); + } +} + +export interface DrawingElementProps extends BoardCompositeProps { + description: string; +} + +export function isDrawingElement(reference: unknown): reference is DrawingElement { + return reference instanceof DrawingElement; +} diff --git a/apps/server/src/shared/domain/domainobject/board/index.ts b/apps/server/src/shared/domain/domainobject/board/index.ts index 9701ba40099..bb82ee91e7c 100644 --- a/apps/server/src/shared/domain/domainobject/board/index.ts +++ b/apps/server/src/shared/domain/domainobject/board/index.ts @@ -3,6 +3,7 @@ export * from './card.do'; export * from './column-board.do'; export * from './column.do'; export * from './content-element.factory'; +export * from './drawing-element.do'; export * from './external-tool-element.do'; export * from './file-element.do'; export * from './link-element.do'; diff --git a/apps/server/src/shared/domain/domainobject/board/types/any-content-element-do.ts b/apps/server/src/shared/domain/domainobject/board/types/any-content-element-do.ts index 614071e658c..14239363aaf 100644 --- a/apps/server/src/shared/domain/domainobject/board/types/any-content-element-do.ts +++ b/apps/server/src/shared/domain/domainobject/board/types/any-content-element-do.ts @@ -1,4 +1,5 @@ import { ExternalToolElement } from '../external-tool-element.do'; +import { DrawingElement } from '../drawing-element.do'; import { FileElement } from '../file-element.do'; import { LinkElement } from '../link-element.do'; import { RichTextElement } from '../rich-text-element.do'; @@ -6,6 +7,7 @@ import { SubmissionContainerElement } from '../submission-container-element.do'; import type { AnyBoardDo } from './any-board-do'; export type AnyContentElementDo = + | DrawingElement | ExternalToolElement | FileElement | LinkElement @@ -14,6 +16,7 @@ export type AnyContentElementDo = export const isAnyContentElement = (element: AnyBoardDo): element is AnyContentElementDo => { const result = + element instanceof DrawingElement || element instanceof ExternalToolElement || element instanceof FileElement || element instanceof LinkElement || diff --git a/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts b/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts index 3fbd4abdd96..5e2547bbf6b 100644 --- a/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts +++ b/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts @@ -1,3 +1,4 @@ +import { DrawingElement } from '../drawing-element.do'; import type { Card } from '../card.do'; import type { ColumnBoard } from '../column-board.do'; import type { Column } from '../column.do'; @@ -15,6 +16,7 @@ export interface BoardCompositeVisitor { visitFileElement(fileElement: FileElement): void; visitLinkElement(linkElement: LinkElement): void; visitRichTextElement(richTextElement: RichTextElement): void; + visitDrawingElement(drawingElement: DrawingElement): void; visitSubmissionContainerElement(submissionContainerElement: SubmissionContainerElement): void; visitSubmissionItem(submissionItem: SubmissionItem): void; visitExternalToolElement(externalToolElement: ExternalToolElement): void; @@ -27,6 +29,7 @@ export interface BoardCompositeVisitorAsync { visitFileElementAsync(fileElement: FileElement): Promise; visitLinkElementAsync(linkElement: LinkElement): Promise; visitRichTextElementAsync(richTextElement: RichTextElement): Promise; + visitDrawingElementAsync(drawingElement: DrawingElement): Promise; visitSubmissionContainerElementAsync(submissionContainerElement: SubmissionContainerElement): Promise; visitSubmissionItemAsync(submissionItem: SubmissionItem): Promise; visitExternalToolElementAsync(externalToolElement: ExternalToolElement): Promise; diff --git a/apps/server/src/shared/domain/domainobject/board/types/content-elements.enum.ts b/apps/server/src/shared/domain/domainobject/board/types/content-elements.enum.ts index b8d4e166e25..151e7666c7f 100644 --- a/apps/server/src/shared/domain/domainobject/board/types/content-elements.enum.ts +++ b/apps/server/src/shared/domain/domainobject/board/types/content-elements.enum.ts @@ -1,5 +1,6 @@ export enum ContentElementType { FILE = 'file', + DRAWING = 'drawing', LINK = 'link', RICH_TEXT = 'richText', SUBMISSION_CONTAINER = 'submissionContainer', diff --git a/apps/server/src/shared/domain/entity/all-entities.ts b/apps/server/src/shared/domain/entity/all-entities.ts index a7ed0587f54..eb4b2478379 100644 --- a/apps/server/src/shared/domain/entity/all-entities.ts +++ b/apps/server/src/shared/domain/entity/all-entities.ts @@ -12,6 +12,7 @@ import { CardNode, ColumnBoardNode, ColumnNode, + DrawingElementNode, ExternalToolElementNodeEntity, FileElementNode, LinkElementNode, @@ -62,6 +63,7 @@ export const ALL_ENTITIES = [ FileElementNode, LinkElementNode, RichTextElementNode, + DrawingElementNode, SubmissionContainerElementNode, SubmissionItemNode, ExternalToolElementNodeEntity, diff --git a/apps/server/src/shared/domain/entity/boardnode/drawing-element-node.entity.spec.ts b/apps/server/src/shared/domain/entity/boardnode/drawing-element-node.entity.spec.ts new file mode 100644 index 00000000000..ce868baff25 --- /dev/null +++ b/apps/server/src/shared/domain/entity/boardnode/drawing-element-node.entity.spec.ts @@ -0,0 +1,59 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { DrawingElementNode } from '@shared/domain/entity/boardnode/drawing-element-node.entity'; +import { drawingElementFactory } from '@shared/testing/factory/domainobject/board/drawing-element.do.factory'; +import { BoardDoBuilder, BoardNodeType } from './types'; + +describe(DrawingElementNode.name, () => { + describe('when trying to create a drawing element', () => { + const setup = () => { + const elementProps = { description: '' }; + const builder: DeepMocked = createMock(); + + return { elementProps, builder }; + }; + + it('should create a DrawingElementNode', () => { + const { elementProps } = setup(); + + const element = new DrawingElementNode(elementProps); + + expect(element.type).toEqual(BoardNodeType.DRAWING_ELEMENT); + }); + }); + + describe('useDoBuilder()', () => { + const setup = () => { + const element = new DrawingElementNode({ description: '' }); + const builder: DeepMocked = createMock(); + const elementDo = drawingElementFactory.build(); + + builder.buildDrawingElement.mockReturnValue(elementDo); + + return { element, builder, elementDo }; + }; + + it('should call the specific builder method', () => { + const { element, builder } = setup(); + + element.useDoBuilder(builder); + + expect(builder.buildDrawingElement).toHaveBeenCalledWith(element); + }); + + it('should call the specific builder method', () => { + const { element, builder } = setup(); + + element.useDoBuilder(builder); + + expect(builder.buildDrawingElement).toHaveBeenCalledWith(element); + }); + + it('should return DrawingElementDo', () => { + const { element, builder, elementDo } = setup(); + + const result = element.useDoBuilder(builder); + + expect(result).toEqual(elementDo); + }); + }); +}); diff --git a/apps/server/src/shared/domain/entity/boardnode/drawing-element-node.entity.ts b/apps/server/src/shared/domain/entity/boardnode/drawing-element-node.entity.ts new file mode 100644 index 00000000000..471e2290220 --- /dev/null +++ b/apps/server/src/shared/domain/entity/boardnode/drawing-element-node.entity.ts @@ -0,0 +1,25 @@ +import { Entity, Property } from '@mikro-orm/core'; +import { AnyBoardDo } from '@shared/domain/domainobject'; +import { BoardNode, BoardNodeProps } from './boardnode.entity'; +import { BoardDoBuilder, BoardNodeType } from './types'; + +@Entity({ discriminatorValue: BoardNodeType.DRAWING_ELEMENT }) +export class DrawingElementNode extends BoardNode { + @Property() + description: string; + + constructor(props: DrawingElementNodeProps) { + super(props); + this.type = BoardNodeType.DRAWING_ELEMENT; + this.description = props.description; + } + + useDoBuilder(builder: BoardDoBuilder): AnyBoardDo { + const domainObject = builder.buildDrawingElement(this); + return domainObject; + } +} + +export interface DrawingElementNodeProps extends BoardNodeProps { + description: string; +} diff --git a/apps/server/src/shared/domain/entity/boardnode/index.ts b/apps/server/src/shared/domain/entity/boardnode/index.ts index a3a56e6dfe0..85b74b0adb9 100644 --- a/apps/server/src/shared/domain/entity/boardnode/index.ts +++ b/apps/server/src/shared/domain/entity/boardnode/index.ts @@ -5,6 +5,7 @@ export * from './column-node.entity'; export * from './external-tool-element-node.entity'; export * from './file-element-node.entity'; export * from './link-element-node.entity'; +export * from './drawing-element-node.entity'; export * from './rich-text-element-node.entity'; export * from './submission-container-element-node.entity'; export * from './submission-item-node.entity'; diff --git a/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts b/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts index 1b759a41180..1b61566d442 100644 --- a/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts +++ b/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts @@ -2,6 +2,7 @@ import type { Card, Column, ColumnBoard, + DrawingElement, ExternalToolElement, FileElement, LinkElement, @@ -12,6 +13,7 @@ import type { import type { CardNode } from '../card-node.entity'; import type { ColumnBoardNode } from '../column-board-node.entity'; import type { ColumnNode } from '../column-node.entity'; +import type { DrawingElementNode } from '../drawing-element-node.entity'; import type { ExternalToolElementNodeEntity } from '../external-tool-element-node.entity'; import type { FileElementNode } from '../file-element-node.entity'; import type { LinkElementNode } from '../link-element-node.entity'; @@ -23,6 +25,7 @@ export interface BoardDoBuilder { buildColumnBoard(boardNode: ColumnBoardNode): ColumnBoard; buildColumn(boardNode: ColumnNode): Column; buildCard(boardNode: CardNode): Card; + buildDrawingElement(boardNode: DrawingElementNode): DrawingElement; buildFileElement(boardNode: FileElementNode): FileElement; buildLinkElement(boardNode: LinkElementNode): LinkElement; buildRichTextElement(boardNode: RichTextElementNode): RichTextElement; diff --git a/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts b/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts index 0b25a81b053..f76f5330d5e 100644 --- a/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts +++ b/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts @@ -5,6 +5,7 @@ export enum BoardNodeType { FILE_ELEMENT = 'file-element', LINK_ELEMENT = 'link-element', RICH_TEXT_ELEMENT = 'rich-text-element', + DRAWING_ELEMENT = 'drawing-element', SUBMISSION_CONTAINER_ELEMENT = 'submission-container-element', SUBMISSION_ITEM = 'submission-item', EXTERNAL_TOOL = 'external-tool', diff --git a/apps/server/src/shared/testing/factory/boardnode/drawing-element-node.factory.ts b/apps/server/src/shared/testing/factory/boardnode/drawing-element-node.factory.ts new file mode 100644 index 00000000000..0298bee52b9 --- /dev/null +++ b/apps/server/src/shared/testing/factory/boardnode/drawing-element-node.factory.ts @@ -0,0 +1,12 @@ +/* istanbul ignore file */ +import { DrawingElementNode, DrawingElementNodeProps } from '@shared/domain'; +import { BaseFactory } from '../base.factory'; + +export const drawingElementNodeFactory = BaseFactory.define( + DrawingElementNode, + ({ sequence }) => { + return { + description: `test-description-${sequence}`, + }; + } +); diff --git a/apps/server/src/shared/testing/factory/domainobject/board/drawing-element.do.factory.ts b/apps/server/src/shared/testing/factory/domainobject/board/drawing-element.do.factory.ts new file mode 100644 index 00000000000..526dbfe2869 --- /dev/null +++ b/apps/server/src/shared/testing/factory/domainobject/board/drawing-element.do.factory.ts @@ -0,0 +1,18 @@ +/* istanbul ignore file */ +import { ObjectId } from 'bson'; +import { DrawingElement, DrawingElementProps } from '@shared/domain/domainobject/board/drawing-element.do'; +import { BaseFactory } from '../../base.factory'; + +export const drawingElementFactory = BaseFactory.define( + DrawingElement, + ({ sequence }) => { + return { + id: new ObjectId().toHexString(), + title: `element #${sequence}`, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + description: '', + }; + } +); diff --git a/apps/server/src/shared/testing/factory/domainobject/board/index.ts b/apps/server/src/shared/testing/factory/domainobject/board/index.ts index 802dcf744f3..cff2ebf8833 100644 --- a/apps/server/src/shared/testing/factory/domainobject/board/index.ts +++ b/apps/server/src/shared/testing/factory/domainobject/board/index.ts @@ -1,6 +1,7 @@ export * from './card.do.factory'; export * from './column-board.do.factory'; export * from './column.do.factory'; +export * from './drawing-element.do.factory'; export * from './external-tool-element.do.factory'; export * from './file-element.do.factory'; export * from './link-element.do.factory'; diff --git a/apps/server/src/shared/testing/factory/tldraw.ws.factory.ts b/apps/server/src/shared/testing/factory/tldraw.ws.factory.ts new file mode 100644 index 00000000000..d5059777cef --- /dev/null +++ b/apps/server/src/shared/testing/factory/tldraw.ws.factory.ts @@ -0,0 +1,12 @@ +import { WsSharedDocDo } from '@modules/tldraw/domain/ws-shared-doc.do'; +import WebSocket from 'ws'; + +export class TldrawWsFactory { + public static createWsSharedDocDo(): WsSharedDocDo { + return { conns: new Map(), destroy: () => {} } as WsSharedDocDo; + } + + public static createWebsocket(readyState: number): WebSocket { + return { readyState, close: () => {} } as WebSocket; + } +} diff --git a/config/default.schema.json b/config/default.schema.json index d57355c8c0f..b27ea565dba 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1399,6 +1399,60 @@ "default": { "BASE_URL": "http://localhost:4030" } + }, + "TLDRAW": { + "type": "object", + "description": "Tldraw managing variables.", + "required": ["PING_TIMEOUT", "SOCKET_PORT","GC_ENABLED", "DB_COLLECTION_NAME", "DB_FLUSH_SIZE", "DB_MULTIPLE_COLLECTIONS"], + "properties": { + "SOCKET_PORT": { + "type": "number", + "description": "Web socket port for tldraw" + }, + "PING_TIMEOUT": { + "type": "number", + "description": "Max time for waiting between calls for tldraw" + }, + "GC_ENABLED": { + "type": "boolean", + "description": "If tldraw garbage collector should be enabled" + }, + "DB_COLLECTION_NAME": { + "type": "string", + "description": "Collection name in which tldraw drawing are stored" + }, + "DB_FLUSH_SIZE": { + "type": "integer", + "description": "DB collection flushing size" + }, + "DB_MULTIPLE_COLLECTIONS": { + "type": "boolean", + "description": "DB collection allowing multiple collections for drawing" + } + }, + "default": { + "SOCKET_PORT": 3345, + "PING_TIMEOUT": 10000, + "GC_ENABLED": true, + "DB_COLLECTION_NAME": "drawings", + "DB_FLUSH_SIZE": 400, + "DB_MULTIPLE_COLLECTIONS": false + } + }, + "TLDRAW_DB_URL": { + "type": "string", + "default": "mongodb://127.0.0.1:27017/tldraw", + "description": "DB connection url" + }, + "FEATURE_TLDRAW_ENABLED": { + "type": "boolean", + "default": true, + "description": "Tldraw feature enabled" + }, + "TLDRAW_URI": { + "type": "string", + "default": "http://localhost:3349", + "description": "Address for tldraw management app" } }, "required": [], diff --git a/config/globals.js b/config/globals.js index c9275419bcf..633878d1b3f 100644 --- a/config/globals.js +++ b/config/globals.js @@ -24,12 +24,14 @@ switch (NODE_ENV) { } let defaultDbUrl = null; +let defaultTldrawDbUrl = null; switch (NODE_ENV) { case ENVIRONMENTS.TEST: defaultDbUrl = 'mongodb://127.0.0.1:27017/schulcloud-test'; break; default: defaultDbUrl = 'mongodb://127.0.0.1:27017/schulcloud'; + defaultTldrawDbUrl = 'mongodb://127.0.0.1:27017/tldraw'; } const globals = { @@ -104,6 +106,9 @@ const globals = { // calendar CALENDAR_URI: process.env.CALENDAR_URI, + + // tldraw + TLDRAW_DB_URL: process.env.TLDRAW_DB_URL || defaultTldrawDbUrl, }; // validation ///////////////////////////////////////////////// diff --git a/config/test.json b/config/test.json index c8b82b383ba..cf7e080ce15 100644 --- a/config/test.json +++ b/config/test.json @@ -68,5 +68,13 @@ }, "FEATURE_VIDEOCONFERENCE_ENABLED": true, "VIDEOCONFERENCE_HOST": "https://bigbluebutton.schul-cloud.org/bigbluebutton", - "VIDEOCONFERENCE_SALT": "ThisIsNOTaRealSaltThisIsNOTaRealSaltThisIsNOTaRealSalt1234567890" + "VIDEOCONFERENCE_SALT": "ThisIsNOTaRealSaltThisIsNOTaRealSaltThisIsNOTaRealSalt1234567890", + "TLDRAW": { + "SOCKET_PORT": 3346, + "PING_TIMEOUT": 1, + "GC_ENABLED": true, + "DB_COLLECTION_NAME": "drawings", + "DB_FLUSH_SIZE": 400, + "DB_MULTIPLE_COLLECTIONS": false + } } diff --git a/nest-cli.json b/nest-cli.json index b3fd935b81f..a90498a7327 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -107,6 +107,15 @@ "compilerOptions": { "tsConfigPath": "apps/server/tsconfig.app.json" } + }, + "tldraw": { + "type": "application", + "root": "apps/server", + "entryFile": "apps/tldraw.app", + "sourceRoot": "apps/server/src", + "compilerOptions": { + "tsConfigPath": "apps/server/tsconfig.app.json" + } } } } diff --git a/package-lock.json b/package-lock.json index bf63523c91a..7ef70fd9a77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,9 @@ "@nestjs/microservices": "^10.2.4", "@nestjs/passport": "^10.0.1", "@nestjs/platform-express": "^10.2.4", + "@nestjs/platform-ws": "^10.2.4", "@nestjs/swagger": "^7.1.10", + "@nestjs/websockets": "^10.2.4", "@types/cache-manager-redis-store": "^2.0.1", "@types/connect-redis": "^0.0.19", "@types/gm": "^1.25.1", @@ -132,7 +134,10 @@ "universal-analytics": "^0.5.1", "urlsafe-base64": "^1.0.0", "uuid": "^8.3.0", - "winston": "^3.8.2" + "winston": "^3.8.2", + "y-mongodb-provider": "^0.1.7", + "y-protocols": "^1.0.5", + "yjs": "^13.6.7" }, "devDependencies": { "@aws-sdk/client-s3": "^3.352.0", @@ -581,7 +586,6 @@ "version": "3.352.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.352.0.tgz", "integrity": "sha512-qXqg7V/DpHu8oyEq22LMskCoHYZU6+ds9gaArwc3SjPwQN/UM6CpIUHtTtxevLEYr7nI5iMIPBBrEcoKOJefxg==", - "dev": true, "optional": true, "dependencies": { "@aws-crypto/sha256-browser": "3.0.0", @@ -798,7 +802,6 @@ "version": "3.352.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.352.0.tgz", "integrity": "sha512-395bdedGD0pangBT6dyyrTvtDRxr3lqbi8lfuJR/+7bpMIEJKVhF5D6IAgdjRDpASDRHUPhHuWzR3Qa9RHAcNA==", - "dev": true, "optional": true, "dependencies": { "@aws-sdk/client-cognito-identity": "3.352.0", @@ -982,7 +985,6 @@ "version": "3.352.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.352.0.tgz", "integrity": "sha512-hV6NO7+xzf3CPEsKZRsYflR05eNMvgVvOXFgQnOucUc85Kxt2XTSoH/HFtkolXDbxjA2Hku1pdaRG7qBzbiJHg==", - "dev": true, "optional": true, "dependencies": { "@aws-sdk/client-cognito-identity": "3.352.0", @@ -4462,6 +4464,14 @@ "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0" } }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.0.tgz", + "integrity": "sha512-Xfijy7HvfzzqiOAhAepF4SGN5e9leLkMvg/OPOF97XemjfVCYN/oWa75wnkc6mltMSTwY+XlbhWgUOJmkFspSw==", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@nestjs/axios": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.0.0.tgz", @@ -4941,6 +4951,44 @@ "@nestjs/core": "^10.0.0" } }, + "node_modules/@nestjs/platform-ws": { + "version": "10.2.7", + "resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-10.2.7.tgz", + "integrity": "sha512-4H4AeCQgM29Dju+zQb70Jt0JgWhQssOB8mh9n9icsSJ4B/joa+X7OiBBSjn72HZelj0tvX1gal6PaAhEaOdmGQ==", + "dependencies": { + "tslib": "2.6.2", + "ws": "8.14.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/websockets": "^10.0.0", + "rxjs": "^7.1.0" + } + }, + "node_modules/@nestjs/platform-ws/node_modules/ws": { + "version": "8.14.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", + "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@nestjs/schematics": { "version": "10.0.2", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.0.2.tgz", @@ -5101,6 +5149,28 @@ } } }, + "node_modules/@nestjs/websockets": { + "version": "10.2.7", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.2.7.tgz", + "integrity": "sha512-NKJMubkwpUBsudbiyjuLZDT/W68K+fS/pe3vG5Ur8QoPn+fkI9SFCiQw27Cv4K0qVX2eGJ41yNmVfu61zGa4CQ==", + "dependencies": { + "iterare": "1.2.1", + "object-hash": "3.0.0", + "tslib": "2.6.2" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/platform-socket.io": "^10.0.0", + "reflect-metadata": "^0.1.12", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/platform-socket.io": { + "optional": true + } + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -13634,6 +13704,15 @@ "ws": "*" } }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", @@ -16159,6 +16238,25 @@ "node": ">= 0.8.0" } }, + "node_modules/lib0": { + "version": "0.2.87", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.87.tgz", + "integrity": "sha512-TbB63XJixvNToW2IHWAFsCJj9tVnajmwjE14p69i51Rx8byOQd2IP4ourE8v4d7vhyO++nVm1sQk3ePslfbucg==", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/libphonenumber-js": { "version": "1.10.24", "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.10.24.tgz", @@ -16704,8 +16802,7 @@ "node_modules/memory-pager": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", - "optional": true + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" }, "node_modules/memory-stream": { "version": "0.0.3", @@ -18789,6 +18886,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", @@ -22338,7 +22443,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", "integrity": "sha1-/0rm5oZWBWuks+eSqzM004JzyhE=", - "optional": true, "dependencies": { "memory-pager": "^1.0.2" } @@ -25008,6 +25112,90 @@ "node": ">=0.4" } }, + "node_modules/y-mongodb-provider": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/y-mongodb-provider/-/y-mongodb-provider-0.1.8.tgz", + "integrity": "sha512-yV+rtS9nBEMqb6fG6sqyWNpMGzmTYe7hPiwWwWyrzK4frjMnkrQvJvyUiWjZI7eFFSKYzxYHucGEFA0j3QJEgA==", + "dependencies": { + "lib0": "^0.2.85", + "mongodb": "^6.1.0" + }, + "peerDependencies": { + "yjs": "^13.6.8" + } + }, + "node_modules/y-mongodb-provider/node_modules/bson": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.1.0.tgz", + "integrity": "sha512-yiQ3KxvpVoRpx1oD1uPz4Jit9tAVTJgjdmjDKtUErkOoL9VNoF8Dd58qtAOL5E40exx2jvAT9sqdRSK/r+SHlA==", + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/y-mongodb-provider/node_modules/mongodb": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.1.0.tgz", + "integrity": "sha512-AvzNY0zMkpothZ5mJAaIo2bGDjlJQqqAbn9fvtVgwIIUPEfdrqGxqNjjbuKyrgQxg2EvCmfWdjq+4uj96c0YPw==", + "dependencies": { + "@mongodb-js/saslprep": "^1.1.0", + "bson": "^6.1.0", + "mongodb-connection-string-url": "^2.6.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/y-protocols": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", + "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, "node_modules/y18n": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.2.tgz", @@ -25196,6 +25384,22 @@ "buffer-crc32": "~0.2.3" } }, + "node_modules/yjs": { + "version": "13.6.8", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.8.tgz", + "integrity": "sha512-ZPq0hpJQb6f59B++Ngg4cKexDJTvfOgeiv0sBc4sUm8CaBWH7OQC4kcCgrqbjJ/B2+6vO49exvTmYfdlPtcjbg==", + "dependencies": { + "lib0": "^0.2.74" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", @@ -25543,7 +25747,6 @@ "version": "3.352.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.352.0.tgz", "integrity": "sha512-qXqg7V/DpHu8oyEq22LMskCoHYZU6+ds9gaArwc3SjPwQN/UM6CpIUHtTtxevLEYr7nI5iMIPBBrEcoKOJefxg==", - "dev": true, "optional": true, "requires": { "@aws-crypto/sha256-browser": "3.0.0", @@ -25745,7 +25948,6 @@ "version": "3.352.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.352.0.tgz", "integrity": "sha512-395bdedGD0pangBT6dyyrTvtDRxr3lqbi8lfuJR/+7bpMIEJKVhF5D6IAgdjRDpASDRHUPhHuWzR3Qa9RHAcNA==", - "dev": true, "optional": true, "requires": { "@aws-sdk/client-cognito-identity": "3.352.0", @@ -25901,7 +26103,6 @@ "version": "3.352.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.352.0.tgz", "integrity": "sha512-hV6NO7+xzf3CPEsKZRsYflR05eNMvgVvOXFgQnOucUc85Kxt2XTSoH/HFtkolXDbxjA2Hku1pdaRG7qBzbiJHg==", - "dev": true, "optional": true, "requires": { "@aws-sdk/client-cognito-identity": "3.352.0", @@ -28429,6 +28630,14 @@ "integrity": "sha512-TrCdPsM7DApxrK3avBbijT6/6Er4TZhtiQ+qlMqtqva13vMCG4HiF2vIWGrKJbFukkLRuhOfZlES+KZ9Y1Lx2A==", "requires": {} }, + "@mongodb-js/saslprep": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.0.tgz", + "integrity": "sha512-Xfijy7HvfzzqiOAhAepF4SGN5e9leLkMvg/OPOF97XemjfVCYN/oWa75wnkc6mltMSTwY+XlbhWgUOJmkFspSw==", + "requires": { + "sparse-bitfield": "^3.0.3" + } + }, "@nestjs/axios": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.0.0.tgz", @@ -28695,6 +28904,23 @@ "tslib": "2.6.2" } }, + "@nestjs/platform-ws": { + "version": "10.2.7", + "resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-10.2.7.tgz", + "integrity": "sha512-4H4AeCQgM29Dju+zQb70Jt0JgWhQssOB8mh9n9icsSJ4B/joa+X7OiBBSjn72HZelj0tvX1gal6PaAhEaOdmGQ==", + "requires": { + "tslib": "2.6.2", + "ws": "8.14.2" + }, + "dependencies": { + "ws": { + "version": "8.14.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", + "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", + "requires": {} + } + } + }, "@nestjs/schematics": { "version": "10.0.2", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.0.2.tgz", @@ -28792,6 +29018,16 @@ "tslib": "2.6.2" } }, + "@nestjs/websockets": { + "version": "10.2.7", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.2.7.tgz", + "integrity": "sha512-NKJMubkwpUBsudbiyjuLZDT/W68K+fS/pe3vG5Ur8QoPn+fkI9SFCiQw27Cv4K0qVX2eGJ41yNmVfu61zGa4CQ==", + "requires": { + "iterare": "1.2.1", + "object-hash": "3.0.0", + "tslib": "2.6.2" + } + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -35293,6 +35529,11 @@ "peer": true, "requires": {} }, + "isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==" + }, "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", @@ -37247,6 +37488,14 @@ "type-check": "~0.4.0" } }, + "lib0": { + "version": "0.2.87", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.87.tgz", + "integrity": "sha512-TbB63XJixvNToW2IHWAFsCJj9tVnajmwjE14p69i51Rx8byOQd2IP4ourE8v4d7vhyO++nVm1sQk3ePslfbucg==", + "requires": { + "isomorphic.js": "^0.2.4" + } + }, "libphonenumber-js": { "version": "1.10.24", "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.10.24.tgz", @@ -37698,8 +37947,7 @@ "memory-pager": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", - "optional": true + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" }, "memory-stream": { "version": "0.0.3", @@ -39345,6 +39593,11 @@ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, + "object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==" + }, "object-inspect": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", @@ -42007,7 +42260,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", "integrity": "sha1-/0rm5oZWBWuks+eSqzM004JzyhE=", - "optional": true, "requires": { "memory-pager": "^1.0.2" } @@ -44048,6 +44300,40 @@ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" }, + "y-mongodb-provider": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/y-mongodb-provider/-/y-mongodb-provider-0.1.8.tgz", + "integrity": "sha512-yV+rtS9nBEMqb6fG6sqyWNpMGzmTYe7hPiwWwWyrzK4frjMnkrQvJvyUiWjZI7eFFSKYzxYHucGEFA0j3QJEgA==", + "requires": { + "lib0": "^0.2.85", + "mongodb": "^6.1.0" + }, + "dependencies": { + "bson": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.1.0.tgz", + "integrity": "sha512-yiQ3KxvpVoRpx1oD1uPz4Jit9tAVTJgjdmjDKtUErkOoL9VNoF8Dd58qtAOL5E40exx2jvAT9sqdRSK/r+SHlA==" + }, + "mongodb": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.1.0.tgz", + "integrity": "sha512-AvzNY0zMkpothZ5mJAaIo2bGDjlJQqqAbn9fvtVgwIIUPEfdrqGxqNjjbuKyrgQxg2EvCmfWdjq+4uj96c0YPw==", + "requires": { + "@mongodb-js/saslprep": "^1.1.0", + "bson": "^6.1.0", + "mongodb-connection-string-url": "^2.6.0" + } + } + } + }, + "y-protocols": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", + "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", + "requires": { + "lib0": "^0.2.85" + } + }, "y18n": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.2.tgz", @@ -44198,6 +44484,14 @@ "buffer-crc32": "~0.2.3" } }, + "yjs": { + "version": "13.6.8", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.8.tgz", + "integrity": "sha512-ZPq0hpJQb6f59B++Ngg4cKexDJTvfOgeiv0sBc4sUm8CaBWH7OQC4kcCgrqbjJ/B2+6vO49exvTmYfdlPtcjbg==", + "requires": { + "lib0": "^0.2.74" + } + }, "yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index 6e179f67264..854cb849b0e 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,9 @@ "nest:start:h5p:library-management": "nest start h5p-library-management", "nest:start:h5p:library-management:dev": "nest start h5p-library-management --debug --watch", "nest:start:h5p:library-management:prod": "node dist/apps/server/apps/h5p-library-management.app", + "nest:start:tldraw": "nest start tldraw", + "nest:start:tldraw:dev": "nest start tldraw --debug --watch", + "nest:start:tldraw:prod": "node dist/apps/server/apps/tldraw.app", "nest:start:console": "nest start console --", "nest:start:console:dev": "nest start console --watch --", "nest:start:console:debug": "nest start console --debug --watch --", @@ -121,7 +124,9 @@ "@nestjs/microservices": "^10.2.4", "@nestjs/passport": "^10.0.1", "@nestjs/platform-express": "^10.2.4", + "@nestjs/platform-ws": "^10.2.4", "@nestjs/swagger": "^7.1.10", + "@nestjs/websockets": "^10.2.4", "@types/cache-manager-redis-store": "^2.0.1", "@types/connect-redis": "^0.0.19", "@types/gm": "^1.25.1", @@ -221,7 +226,10 @@ "universal-analytics": "^0.5.1", "urlsafe-base64": "^1.0.0", "uuid": "^8.3.0", - "winston": "^3.8.2" + "winston": "^3.8.2", + "y-mongodb-provider": "^0.1.7", + "y-protocols": "^1.0.5", + "yjs": "^13.6.7" }, "devDependencies": { "@aws-sdk/client-s3": "^3.352.0", diff --git a/src/services/config/publicAppConfigService.js b/src/services/config/publicAppConfigService.js index 62615f0efb1..018722039b5 100644 --- a/src/services/config/publicAppConfigService.js +++ b/src/services/config/publicAppConfigService.js @@ -63,6 +63,7 @@ const exposedVars = [ 'FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION', 'FEATURE_CTL_CONTEXT_CONFIGURATION_ENABLED', 'FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED', + 'FEATURE_TLDRAW_ENABLED', ]; /** From 9ca2b717b93621abcd97bedeaafb915bb55c948d Mon Sep 17 00:00:00 2001 From: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> Date: Tue, 28 Nov 2023 14:04:23 +0100 Subject: [PATCH 6/7] Bump @babel/traverse from 7.17.0 to 7.23.4 (#4586) Bumps [@babel/traverse](https://github.com/babel/babel/tree/HEAD/packages/babel-traverse) from 7.17.0 to 7.23.4. - [Release notes](https://github.com/babel/babel/releases) - [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md) - [Commits](https://github.com/babel/babel/commits/v7.23.4/packages/babel-traverse) --- updated-dependencies: - dependency-name: "@babel/traverse" dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 408 +++++++++++++++++++++++++++------------------- 1 file changed, 241 insertions(+), 167 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7ef70fd9a77..5fb17d023cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1844,17 +1844,74 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", - "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.4.tgz", + "integrity": "sha512-r1IONyb6Ia+jYR2vvIDhdWdlTGhqbBoFqLTQidzZ4kepUFH15ejXvFHxCVbtl7BOXIudsIubf4E81xeA3h3IXA==", "dev": true, "dependencies": { - "@babel/highlight": "^7.16.7" + "@babel/highlight": "^7.23.4", + "chalk": "^2.4.2" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/code-frame/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/code-frame/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/compat-data": { "version": "7.17.0", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.17.0.tgz", @@ -1904,28 +1961,20 @@ } }, "node_modules/@babel/generator": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.17.0.tgz", - "integrity": "sha512-I3Omiv6FGOC29dtlZhkfXO6pgkmukJSlT26QjVvS1DGZe/NzSVCPG41X0tS21oZkJYlovfj9qDWgKP+Cn4bXxw==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.4.tgz", + "integrity": "sha512-esuS49Cga3HcThFNebGhlgsrVLkvhqvYDTzgjfFFlHJcIfLe5jFmRRfCQ1KuBfc4Jrtn3ndLgKWAKjBE+IraYQ==", "dev": true, "dependencies": { - "@babel/types": "^7.17.0", - "jsesc": "^2.5.1", - "source-map": "^0.5.0" + "@babel/types": "^7.23.4", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/generator/node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/@babel/helper-compilation-targets": { "version": "7.16.7", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.16.7.tgz", @@ -1954,50 +2003,34 @@ } }, "node_modules/@babel/helper-environment-visitor": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.16.7.tgz", - "integrity": "sha512-SLLb0AAn6PkUeAfKJCCOl9e1R53pQlGAfc4y4XuMRZfqeMYLE0dM1LMhqbGAlGQY0lfw5/ohoYWAe9V1yibRag==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", "dev": true, - "dependencies": { - "@babel/types": "^7.16.7" - }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-function-name": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.16.7.tgz", - "integrity": "sha512-QfDfEnIUyyBSR3HtrtGECuZ6DAyCkYFp7GHl75vFtTnn6pjKeK0T1DB5lLkFvBea8MdaiUABx3osbgLyInoejA==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", "dev": true, "dependencies": { - "@babel/helper-get-function-arity": "^7.16.7", - "@babel/template": "^7.16.7", - "@babel/types": "^7.16.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-get-function-arity": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.16.7.tgz", - "integrity": "sha512-flc+RLSOBXzNzVhcLu6ujeHUrD6tANAOU5ojrRx/as+tbzf8+stUCj7+IfRRoAbEZqj/ahXEMsjhOhgeZsrnTw==", - "dev": true, - "dependencies": { - "@babel/types": "^7.16.7" + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-hoist-variables": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.16.7.tgz", - "integrity": "sha512-m04d/0Op34H5v7pbZw6pSKP7weA6lsMvfiIAMeIvkY/R4xQtBSMFEigu9QTZ2qB/9l22vsxtM8a+Q8CzD255fg==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", "dev": true, "dependencies": { - "@babel/types": "^7.16.7" + "@babel/types": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -2056,21 +2089,30 @@ } }, "node_modules/@babel/helper-split-export-declaration": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.16.7.tgz", - "integrity": "sha512-xbWoy/PFoxSWazIToT9Sif+jJTlrMcndIsaOKvTA6u7QEo7ilkRZpjew18/W3c7nm8fXdUDXh02VXTbZ0pGDNw==", + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", "dev": true, "dependencies": { - "@babel/types": "^7.16.7" + "@babel/types": "^7.22.5" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", - "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", "dev": true, "engines": { "node": ">=6.9.0" @@ -2100,13 +2142,13 @@ } }, "node_modules/@babel/highlight": { - "version": "7.16.10", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.16.10.tgz", - "integrity": "sha512-5FnTQLSLswEj6IkgVw5KusNUUFY9ZGqe/TRFnP/BKYHYgfh7tc+C7mwiy95/yNP7Dh9x580Vv8r7u7ZfTBFxdw==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", + "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.16.7", - "chalk": "^2.0.0", + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", "js-tokens": "^4.0.0" }, "engines": { @@ -2170,9 +2212,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.17.0.tgz", - "integrity": "sha512-VKXSCQx5D8S04ej+Dqsr1CzYvvWgf20jIw2D+YhQCrIlr2UZGaDds23Y0xg75/skOxpLCRpUZvk/1EAVkGoDOw==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.4.tgz", + "integrity": "sha512-vf3Xna6UEprW+7t6EtOmFpHNAuxw3xqPZghy+brsnusscJRW5BMUzzHZc5ICjULee81WeUV2jjakG09MDglJXQ==", "dev": true, "bin": { "parser": "bin/babel-parser.js" @@ -2383,33 +2425,33 @@ } }, "node_modules/@babel/template": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz", - "integrity": "sha512-I8j/x8kHUrbYRTUxXrrMbfCa7jxkE7tZre39x3kjr9hvI82cK1FfqLygotcWN5kdPGWcLdWMHpSBavse5tWw3w==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.16.7", - "@babel/parser": "^7.16.7", - "@babel/types": "^7.16.7" + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.17.0.tgz", - "integrity": "sha512-fpFIXvqD6kC7c7PUNnZ0Z8cQXlarCLtCUpt2S1Dx7PjoRtCFffvOkHHSom+m5HIxMZn5bIBVb71lhabcmjEsqg==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.16.7", - "@babel/generator": "^7.17.0", - "@babel/helper-environment-visitor": "^7.16.7", - "@babel/helper-function-name": "^7.16.7", - "@babel/helper-hoist-variables": "^7.16.7", - "@babel/helper-split-export-declaration": "^7.16.7", - "@babel/parser": "^7.17.0", - "@babel/types": "^7.17.0", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.4.tgz", + "integrity": "sha512-IYM8wSUwunWTB6tFC2dkKZhxbIjHoWemdK+3f8/wq8aKhbUscxD5MX72ubd90fxvFknaLPeGw5ycU84V1obHJg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.23.4", + "@babel/generator": "^7.23.4", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.4", + "@babel/types": "^7.23.4", "debug": "^4.1.0", "globals": "^11.1.0" }, @@ -2418,12 +2460,13 @@ } }, "node_modules/@babel/types": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", - "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.4.tgz", + "integrity": "sha512-7uIFwVYpoplT5jp/kVv6EF93VaJ8H+Yn5IczYiaAi98ajzjfoZfslet/e0sLh+wVBjb2qqIut1b0S26VSafsSQ==", "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.16.7", + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" }, "engines": { @@ -26776,12 +26819,56 @@ } }, "@babel/code-frame": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", - "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.4.tgz", + "integrity": "sha512-r1IONyb6Ia+jYR2vvIDhdWdlTGhqbBoFqLTQidzZ4kepUFH15ejXvFHxCVbtl7BOXIudsIubf4E81xeA3h3IXA==", "dev": true, "requires": { - "@babel/highlight": "^7.16.7" + "@babel/highlight": "^7.23.4", + "chalk": "^2.4.2" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } } }, "@babel/compat-data": { @@ -26822,22 +26909,15 @@ } }, "@babel/generator": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.17.0.tgz", - "integrity": "sha512-I3Omiv6FGOC29dtlZhkfXO6pgkmukJSlT26QjVvS1DGZe/NzSVCPG41X0tS21oZkJYlovfj9qDWgKP+Cn4bXxw==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.4.tgz", + "integrity": "sha512-esuS49Cga3HcThFNebGhlgsrVLkvhqvYDTzgjfFFlHJcIfLe5jFmRRfCQ1KuBfc4Jrtn3ndLgKWAKjBE+IraYQ==", "dev": true, "requires": { - "@babel/types": "^7.17.0", - "jsesc": "^2.5.1", - "source-map": "^0.5.0" - }, - "dependencies": { - "source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", - "dev": true - } + "@babel/types": "^7.23.4", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" } }, "@babel/helper-compilation-targets": { @@ -26861,41 +26941,28 @@ } }, "@babel/helper-environment-visitor": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.16.7.tgz", - "integrity": "sha512-SLLb0AAn6PkUeAfKJCCOl9e1R53pQlGAfc4y4XuMRZfqeMYLE0dM1LMhqbGAlGQY0lfw5/ohoYWAe9V1yibRag==", - "dev": true, - "requires": { - "@babel/types": "^7.16.7" - } + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true }, "@babel/helper-function-name": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.16.7.tgz", - "integrity": "sha512-QfDfEnIUyyBSR3HtrtGECuZ6DAyCkYFp7GHl75vFtTnn6pjKeK0T1DB5lLkFvBea8MdaiUABx3osbgLyInoejA==", - "dev": true, - "requires": { - "@babel/helper-get-function-arity": "^7.16.7", - "@babel/template": "^7.16.7", - "@babel/types": "^7.16.7" - } - }, - "@babel/helper-get-function-arity": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.16.7.tgz", - "integrity": "sha512-flc+RLSOBXzNzVhcLu6ujeHUrD6tANAOU5ojrRx/as+tbzf8+stUCj7+IfRRoAbEZqj/ahXEMsjhOhgeZsrnTw==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", "dev": true, "requires": { - "@babel/types": "^7.16.7" + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" } }, "@babel/helper-hoist-variables": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.16.7.tgz", - "integrity": "sha512-m04d/0Op34H5v7pbZw6pSKP7weA6lsMvfiIAMeIvkY/R4xQtBSMFEigu9QTZ2qB/9l22vsxtM8a+Q8CzD255fg==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", "dev": true, "requires": { - "@babel/types": "^7.16.7" + "@babel/types": "^7.22.5" } }, "@babel/helper-module-imports": { @@ -26939,18 +27006,24 @@ } }, "@babel/helper-split-export-declaration": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.16.7.tgz", - "integrity": "sha512-xbWoy/PFoxSWazIToT9Sif+jJTlrMcndIsaOKvTA6u7QEo7ilkRZpjew18/W3c7nm8fXdUDXh02VXTbZ0pGDNw==", + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", "dev": true, "requires": { - "@babel/types": "^7.16.7" + "@babel/types": "^7.22.5" } }, + "@babel/helper-string-parser": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "dev": true + }, "@babel/helper-validator-identifier": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", - "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", "dev": true }, "@babel/helper-validator-option": { @@ -26971,13 +27044,13 @@ } }, "@babel/highlight": { - "version": "7.16.10", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.16.10.tgz", - "integrity": "sha512-5FnTQLSLswEj6IkgVw5KusNUUFY9ZGqe/TRFnP/BKYHYgfh7tc+C7mwiy95/yNP7Dh9x580Vv8r7u7ZfTBFxdw==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", + "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", "dev": true, "requires": { - "@babel/helper-validator-identifier": "^7.16.7", - "chalk": "^2.0.0", + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", "js-tokens": "^4.0.0" }, "dependencies": { @@ -27025,9 +27098,9 @@ } }, "@babel/parser": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.17.0.tgz", - "integrity": "sha512-VKXSCQx5D8S04ej+Dqsr1CzYvvWgf20jIw2D+YhQCrIlr2UZGaDds23Y0xg75/skOxpLCRpUZvk/1EAVkGoDOw==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.4.tgz", + "integrity": "sha512-vf3Xna6UEprW+7t6EtOmFpHNAuxw3xqPZghy+brsnusscJRW5BMUzzHZc5ICjULee81WeUV2jjakG09MDglJXQ==", "dev": true }, "@babel/plugin-syntax-async-generators": { @@ -27175,41 +27248,42 @@ } }, "@babel/template": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz", - "integrity": "sha512-I8j/x8kHUrbYRTUxXrrMbfCa7jxkE7tZre39x3kjr9hvI82cK1FfqLygotcWN5kdPGWcLdWMHpSBavse5tWw3w==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", "dev": true, "requires": { - "@babel/code-frame": "^7.16.7", - "@babel/parser": "^7.16.7", - "@babel/types": "^7.16.7" + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" } }, "@babel/traverse": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.17.0.tgz", - "integrity": "sha512-fpFIXvqD6kC7c7PUNnZ0Z8cQXlarCLtCUpt2S1Dx7PjoRtCFffvOkHHSom+m5HIxMZn5bIBVb71lhabcmjEsqg==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.16.7", - "@babel/generator": "^7.17.0", - "@babel/helper-environment-visitor": "^7.16.7", - "@babel/helper-function-name": "^7.16.7", - "@babel/helper-hoist-variables": "^7.16.7", - "@babel/helper-split-export-declaration": "^7.16.7", - "@babel/parser": "^7.17.0", - "@babel/types": "^7.17.0", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.4.tgz", + "integrity": "sha512-IYM8wSUwunWTB6tFC2dkKZhxbIjHoWemdK+3f8/wq8aKhbUscxD5MX72ubd90fxvFknaLPeGw5ycU84V1obHJg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.23.4", + "@babel/generator": "^7.23.4", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.4", + "@babel/types": "^7.23.4", "debug": "^4.1.0", "globals": "^11.1.0" } }, "@babel/types": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", - "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.4.tgz", + "integrity": "sha512-7uIFwVYpoplT5jp/kVv6EF93VaJ8H+Yn5IczYiaAi98ajzjfoZfslet/e0sLh+wVBjb2qqIut1b0S26VSafsSQ==", "dev": true, "requires": { - "@babel/helper-validator-identifier": "^7.16.7", + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" } }, From e07f6ac71197d32bdbfe80cca12e9e58bc423d26 Mon Sep 17 00:00:00 2001 From: blazejpass <118356546+blazejpass@users.noreply.github.com> Date: Tue, 28 Nov 2023 16:08:06 +0100 Subject: [PATCH 7/7] BC 4710 - Add when WITH_TLDRAW rule (#4590) --- ansible/roles/schulcloud-server-core/tasks/main.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ansible/roles/schulcloud-server-core/tasks/main.yml b/ansible/roles/schulcloud-server-core/tasks/main.yml index 47ace3c6f85..4840ec78474 100644 --- a/ansible/roles/schulcloud-server-core/tasks/main.yml +++ b/ansible/roles/schulcloud-server-core/tasks/main.yml @@ -161,12 +161,14 @@ kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: tldraw-deployment.yml.j2 + when: WITH_TLDRAW is defined and WITH_TLDRAW|bool - name: TlDraw server service kubernetes.core.k8s: kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: tldraw-server-svc.yml.j2 + when: WITH_TLDRAW is defined and WITH_TLDRAW|bool - name: Tldraw ingress kubernetes.core.k8s: @@ -174,3 +176,4 @@ namespace: "{{ NAMESPACE }}" template: tldraw-ingress.yml.j2 apply: yes + when: WITH_TLDRAW is defined and WITH_TLDRAW|bool