diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 7fa92508759..00000000000 --- a/TODO.md +++ /dev/null @@ -1,91 +0,0 @@ -# Technical TODO around Nest Introduction - -## SUGGESTED - -- filter logs by request with reflect-metadata (see mikroorm em setup) -- disable Document from window -- find a name for base entity id type -- find a name for base entity class -- decide if we want to use our entity id type in all layers (also in dtos etc.) -- use index.ts files to bundle exports - we could use path names for imports then, e.g. @shared/domain -- check how we can implement mandatory/optional fields in dtos -- should we use Expose() as default in dtos? -- in the controller we have to prohibit serialization of properties that have no @EXPOSE -- find the best way ORM entity discovery -- decide where to put domain interfaces (directory) -- how can we log validation errors during development? -- sanitizer -- remove non-node async library -- fix async cleanup & remove timeout in tests -- test object creator for nest entities -- enable log only for failed tests: https://stackoverflow.com/a/61909588 -- remove mongoose history (keep one) -- remove custom npm packages (ldap, ...) -- API default tests to extend: auth required, fails without/succeeds with - -## ACCEPTED - -- documentation - - entity constructor - - em to be used in repositories only (!!!) - -- load/perf test - -- disable legacy ts support (app, tests) - -- fix .env/config for windows - -## MERGE - -- api path prefix cleanup: remove middleware and multiple path mounts, sync with nest -- user module stucture -- single domain: shared entity (main.ts), shared repository -- request.user.user in jwt strategy -- remove outdated sorting.ts -- remove default launch/settings json files, apply them -- fix https://github.com/hpi-schul-cloud/schulcloud-server/pull/2729#pullrequestreview-699615164 - - -## SELECTED - -- test shared / core module -- async test fixes (remove this.timeout and red promise chains) - -- db configuration - - - keep mongoose options as mongo options - - povider for mikroorm options and db url - - test db provider - - entity discovery - - check indexes in mikroorm: when are they updated? - - teardown (test, server module, main.ts) - - replikaset for test module - - entity discovery - -- news - - - uc cleanup: 2auth, visibilities - - document best practices/layers/orm - -- context: user-/request-context (see mikroorm/asynclocalstorage) - - -## DONE - -- check build & start for production with ops -- fix jest, linter, ... -- inject APP_FILTER (exception handler) and APP_INTERCEPTOR (logger), see core module -- custom error handling (log/response), see global-error.filter.ts -- watch docs should hot reload on md file change -- 404 error handling in feathers has to be replaced (tests too). better: have nest before feathers... but seems not to be working -- remove mongoose -- publish documentation, see https://hpi-schul-cloud.github.io/schulcloud-server/overview.html -- fix all tests (nest/legacy) -- remove legacy scripts from package json (except tests) goal: have separated tests (legacy/nest) but only execute the nest app -- using legacy database connection string -- v3 with/-out slash: diffenrent routes should respond with different result (/v3 is a resssource, /v3/ === /v3/index) -- vscode/lauch files: we put only default files into the repo -- naming of dtos and dto-files: api vs domain, we leave out "dto" suffix for simplicity (we know that they are dtos) and instead append a specific suffix: - e.g. - api: , , - domain: , diff --git a/apps/server/src/modules/board/board-api.module.ts b/apps/server/src/modules/board/board-api.module.ts index 3bc604d9ecf..98d6de3acfc 100644 --- a/apps/server/src/modules/board/board-api.module.ts +++ b/apps/server/src/modules/board/board-api.module.ts @@ -12,9 +12,10 @@ import { import { BoardModule } from './board.module'; import { BoardNodePermissionService } from './service'; import { BoardUc, CardUc, ColumnUc, ElementUc, SubmissionItemUc } from './uc'; +import { RoomMemberModule } from '../room-member'; @Module({ - imports: [BoardModule, LoggerModule, forwardRef(() => AuthorizationModule)], + imports: [BoardModule, LoggerModule, RoomMemberModule, forwardRef(() => AuthorizationModule)], controllers: [BoardController, ColumnController, CardController, ElementController, BoardSubmissionController], providers: [BoardUc, BoardNodePermissionService, ColumnUc, CardUc, ElementUc, SubmissionItemUc, CourseRepo], }) diff --git a/apps/server/src/modules/board/board-ws-api.module.ts b/apps/server/src/modules/board/board-ws-api.module.ts index 74b5a2b45b2..4a0bfe63cd4 100644 --- a/apps/server/src/modules/board/board-ws-api.module.ts +++ b/apps/server/src/modules/board/board-ws-api.module.ts @@ -1,16 +1,17 @@ +import { AuthorizationModule } from '@modules/authorization'; +import { UserModule } from '@modules/user'; import { forwardRef, Module } from '@nestjs/common'; import { CourseRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; -import { AuthorizationModule } from '@modules/authorization'; -import { UserModule } from '@modules/user'; +import { RoomMemberModule } from '../room-member'; import { BoardModule } from './board.module'; import { BoardCollaborationGateway } from './gateway/board-collaboration.gateway'; import { MetricsService } from './metrics/metrics.service'; -import { BoardUc, CardUc, ColumnUc, ElementUc } from './uc'; import { BoardNodePermissionService } from './service'; +import { BoardUc, CardUc, ColumnUc, ElementUc } from './uc'; @Module({ - imports: [BoardModule, forwardRef(() => AuthorizationModule), LoggerModule, UserModule], + imports: [BoardModule, forwardRef(() => AuthorizationModule), LoggerModule, UserModule, RoomMemberModule], providers: [ BoardCollaborationGateway, BoardNodePermissionService, diff --git a/apps/server/src/modules/board/board.module.ts b/apps/server/src/modules/board/board.module.ts index 86f8e2671b3..48a6296959c 100644 --- a/apps/server/src/modules/board/board.module.ts +++ b/apps/server/src/modules/board/board.module.ts @@ -9,6 +9,9 @@ import { Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { CourseRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; +import { AuthorizationModule } from '../authorization'; +import { RoomMemberModule } from '../room-member'; +import { BoardNodeRule } from './authorisation/board-node.rule'; import { BoardNodeFactory } from './domain'; import { BoardNodeRepo } from './repo'; import { @@ -30,8 +33,6 @@ import { ColumnBoardTitleService, ContentElementUpdateService, } from './service/internal'; -import { BoardNodeRule } from './authorisation/board-node.rule'; -import { AuthorizationModule } from '../authorization'; @Module({ imports: [ @@ -45,6 +46,7 @@ import { AuthorizationModule } from '../authorization'; CqrsModule, CollaborativeTextEditorModule, AuthorizationModule, + RoomMemberModule, ], providers: [ // TODO: move BoardDoAuthorizableService, BoardDoRepo, BoardDoService, BoardNodeRepo in separate module and move mediaboard related services in mediaboard module diff --git a/apps/server/src/modules/board/controller/api-test/board-context.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-context-in-course.api.spec.ts similarity index 97% rename from apps/server/src/modules/board/controller/api-test/board-context.api.spec.ts rename to apps/server/src/modules/board/controller/api-test/board-context-in-course.api.spec.ts index 9cf69fa7e76..1fc0caf98bc 100644 --- a/apps/server/src/modules/board/controller/api-test/board-context.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/board-context-in-course.api.spec.ts @@ -8,7 +8,7 @@ import { BoardExternalReferenceType } from '../../domain'; const baseRouteName = '/boards'; -describe('board get context (api)', () => { +describe('board get context in course (api)', () => { let app: INestApplication; let em: EntityManager; let testApiClient: TestApiClient; diff --git a/apps/server/src/modules/board/controller/api-test/board-context-in-rooms.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-context-in-rooms.api.spec.ts new file mode 100644 index 00000000000..1d701b7a157 --- /dev/null +++ b/apps/server/src/modules/board/controller/api-test/board-context-in-rooms.api.spec.ts @@ -0,0 +1,141 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { ServerTestModule } from '@modules/server'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { TestApiClient, cleanupCollections, groupEntityFactory, roleFactory, userFactory } from '@shared/testing'; +import { Permission, RoleName } from '@shared/domain/interface'; +import { accountFactory } from '@src/modules/account/testing'; +import { GroupEntityTypes } from '@src/modules/group/entity'; +import { roomMemberEntityFactory } from '@src/modules/room-member/testing'; +import { roomEntityFactory } from '@src/modules/room/testing'; +import { columnBoardEntityFactory } from '../../testing'; +import { BoardExternalReferenceType } from '../../domain'; + +const baseRouteName = '/boards'; + +describe('board get context in room (api)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, baseRouteName); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + await cleanupCollections(em); + }); + + const setup = async () => { + const userWithEditRole = userFactory.buildWithId(); + const accountWithEditRole = accountFactory.withUser(userWithEditRole).build(); + + const userWithViewRole = userFactory.buildWithId(); + const accountWithViewRole = accountFactory.withUser(userWithViewRole).build(); + + const noAccessUser = userFactory.buildWithId(); + const noAccessAccount = accountFactory.withUser(noAccessUser).build(); + + const roleRoomEdit = roleFactory.buildWithId({ + name: RoleName.ROOM_EDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleRoomView = roleFactory.buildWithId({ + name: RoleName.ROOM_VIEWER, + permissions: [Permission.ROOM_VIEW], + }); + + const userGroup = groupEntityFactory.buildWithId({ + type: GroupEntityTypes.ROOM, + users: [ + { user: userWithEditRole, role: roleRoomEdit }, + { user: userWithViewRole, role: roleRoomView }, + ], + }); + + const room = roomEntityFactory.buildWithId(); + + const roomMember = roomMemberEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + + await em.persistAndFlush([ + accountWithEditRole, + accountWithViewRole, + noAccessAccount, + userWithEditRole, + userWithViewRole, + noAccessUser, + roleRoomEdit, + roleRoomView, + userGroup, + room, + roomMember, + ]); + + const columnBoardNode = columnBoardEntityFactory.build({ + isVisible: false, + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + + await em.persistAndFlush([columnBoardNode]); + em.clear(); + + return { accountWithEditRole, accountWithViewRole, noAccessAccount, columnBoardNode }; + }; + + describe('with user who has edit role in room', () => { + it('should return status 200', async () => { + const { accountWithEditRole, columnBoardNode } = await setup(); + + const loggedInClient = await testApiClient.login(accountWithEditRole); + + const response = await loggedInClient.get(`${columnBoardNode.id}/context`); + + expect(response.status).toEqual(200); + }); + + it('should return the context', async () => { + const { accountWithEditRole, columnBoardNode } = await setup(); + + const loggedInClient = await testApiClient.login(accountWithEditRole); + + const response = await loggedInClient.get(`${columnBoardNode.id}/context`); + + expect(response.body).toEqual({ id: columnBoardNode.context?.id, type: columnBoardNode.context?.type }); + }); + }); + + describe('with user who has only view role in room', () => { + it('should return status 403', async () => { + const { accountWithViewRole, columnBoardNode } = await setup(); + + const loggedInClient = await testApiClient.login(accountWithViewRole); + + const response = await loggedInClient.get(`${columnBoardNode.id}/context`); + + expect(response.status).toEqual(403); + }); + }); + + describe('with user who is not part of the room', () => { + it('should return status 403', async () => { + const { noAccessAccount, columnBoardNode } = await setup(); + + const loggedInClient = await testApiClient.login(noAccessAccount); + + const response = await loggedInClient.get(`${columnBoardNode.id}/context`); + + expect(response.status).toEqual(403); + }); + }); +}); diff --git a/apps/server/src/modules/board/controller/api-test/board-copy.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-copy-in-course.api.spec.ts similarity index 95% rename from apps/server/src/modules/board/controller/api-test/board-copy.api.spec.ts rename to apps/server/src/modules/board/controller/api-test/board-copy-in-course.api.spec.ts index 236b2d49bd1..51a5cf227c1 100644 --- a/apps/server/src/modules/board/controller/api-test/board-copy.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/board-copy-in-course.api.spec.ts @@ -10,7 +10,7 @@ import { BoardExternalReferenceType } from '../../domain'; const baseRouteName = '/boards'; -describe(`board copy (api)`, () => { +describe(`board copy with course relation (api)`, () => { let app: INestApplication; let em: EntityManager; let testApiClient: TestApiClient; @@ -39,13 +39,13 @@ describe(`board copy (api)`, () => { const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); const course = courseFactory.build({ teachers: [teacherUser] }); - await em.persistAndFlush([teacherUser, course]); + await em.persistAndFlush([teacherAccount, teacherUser, course]); const columnBoardNode = columnBoardEntityFactory.build({ context: { id: course.id, type: BoardExternalReferenceType.Course }, }); - await em.persistAndFlush([teacherAccount, teacherUser, columnBoardNode]); + await em.persistAndFlush([columnBoardNode]); em.clear(); const loggedInClient = await testApiClient.login(teacherAccount); diff --git a/apps/server/src/modules/board/controller/api-test/board-create.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-create-in-course.api.spec.ts similarity index 99% rename from apps/server/src/modules/board/controller/api-test/board-create.api.spec.ts rename to apps/server/src/modules/board/controller/api-test/board-create-in-course.api.spec.ts index 63e184fa898..1ca293aad55 100644 --- a/apps/server/src/modules/board/controller/api-test/board-create.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/board-create-in-course.api.spec.ts @@ -9,7 +9,7 @@ import { CreateBoardBodyParams } from '../dto'; const baseRouteName = '/boards'; -describe(`create board (api)`, () => { +describe(`create board in course (api)`, () => { let app: INestApplication; let em: EntityManager; let testApiClient: TestApiClient; diff --git a/apps/server/src/modules/board/controller/api-test/board-create-in-room.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-create-in-room.api.spec.ts new file mode 100644 index 00000000000..50d573674ef --- /dev/null +++ b/apps/server/src/modules/board/controller/api-test/board-create-in-room.api.spec.ts @@ -0,0 +1,294 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { ServerTestModule } from '@modules/server/server.module'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Permission } from '@shared/domain/interface'; +import { RoleName } from '@shared/domain/interface/rolename.enum'; +import { cleanupCollections, groupEntityFactory, roleFactory, TestApiClient, userFactory } from '@shared/testing'; +import { accountFactory } from '@src/modules/account/testing'; +import { GroupEntityTypes } from '@src/modules/group/entity'; +import { roomMemberEntityFactory } from '@src/modules/room-member/testing'; +import { roomEntityFactory } from '@src/modules/room/testing'; +import { BoardExternalReferenceType, BoardLayout } from '../../domain'; +import { BoardNodeEntity } from '../../repo'; +import { CreateBoardBodyParams } from '../dto'; + +const baseRouteName = '/boards'; + +describe(`create board in room (api)`, () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, baseRouteName); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + await cleanupCollections(em); + }); + + describe('When request is valid', () => { + describe('When user is allowed to edit the room', () => { + const setup = async () => { + const user = userFactory.buildWithId(); + const account = accountFactory.withUser(user).build(); + + const role = roleFactory.buildWithId({ name: RoleName.ROOM_EDITOR, permissions: [Permission.ROOM_EDIT] }); + + const userGroup = groupEntityFactory.buildWithId({ + type: GroupEntityTypes.ROOM, + users: [{ user, role }], + }); + + const room = roomEntityFactory.buildWithId(); + + const roomMember = roomMemberEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + + await em.persistAndFlush([account, user, role, userGroup, room, roomMember]); + em.clear(); + + const loggedInClient = await testApiClient.login(account); + + return { loggedInClient, room }; + }; + + it('should return status 201 and board', async () => { + const { loggedInClient, room } = await setup(); + const title = 'new board'; + + const response = await loggedInClient.post(undefined, { + title, + parentId: room.id, + parentType: BoardExternalReferenceType.Room, + layout: BoardLayout.COLUMNS, + }); + + const boardId = (response.body as { id: string }).id; + expect(response.status).toEqual(201); + expect(boardId).toBeDefined(); + + const dbResult = await em.findOneOrFail(BoardNodeEntity, boardId); + expect(dbResult.title).toEqual(title); + }); + + describe('Board layout', () => { + describe(`When layout is set to "${BoardLayout.COLUMNS}"`, () => { + it('should create a column board', async () => { + const { loggedInClient, room } = await setup(); + + const response = await loggedInClient.post(undefined, { + title: 'new board', + parentId: room.id, + parentType: BoardExternalReferenceType.Room, + layout: BoardLayout.COLUMNS, + }); + + const boardId = (response.body as { id: string }).id; + expect(response.status).toEqual(201); + expect(boardId).toBeDefined(); + + const dbResult = await em.findOneOrFail(BoardNodeEntity, boardId); + expect(dbResult.layout).toEqual(BoardLayout.COLUMNS); + }); + }); + + describe(`When layout is set to "${BoardLayout.LIST}"`, () => { + it('should create a list board', async () => { + const { loggedInClient, room } = await setup(); + + const response = await loggedInClient.post(undefined, { + title: 'new board', + parentId: room.id, + parentType: BoardExternalReferenceType.Room, + layout: BoardLayout.LIST, + }); + + const boardId = (response.body as { id: string }).id; + expect(response.status).toEqual(201); + expect(boardId).toBeDefined(); + + const dbResult = await em.findOneOrFail(BoardNodeEntity, boardId); + expect(dbResult.layout).toEqual(BoardLayout.LIST); + }); + }); + }); + + describe('When layout is omitted', () => { + it('should return status 400', async () => { + const { loggedInClient, room } = await setup(); + + const response = await loggedInClient.post(undefined, >{ + title: 'new board', + parentId: room.id, + parentType: BoardExternalReferenceType.Room, + layout: undefined, + }); + + expect(response.status).toEqual(400); + }); + }); + + describe('When layout is invalid', () => { + it('should return status 400', async () => { + const { loggedInClient, room } = await setup(); + + const response = await loggedInClient.post(undefined, >{ + title: 'new board', + parentId: room.id, + parentType: BoardExternalReferenceType.Room, + layout: 'invalid', + }); + + expect(response.status).toEqual(400); + }); + }); + }); + + describe('When user is only allowed to view the room', () => { + const setup = async () => { + const user = userFactory.buildWithId(); + const account = accountFactory.withUser(user).build(); + + const role = roleFactory.buildWithId({ name: RoleName.ROOM_EDITOR, permissions: [Permission.ROOM_VIEW] }); + + const userGroup = groupEntityFactory.buildWithId({ + type: GroupEntityTypes.ROOM, + users: [{ user, role }], + }); + + const room = roomEntityFactory.buildWithId(); + + const roomMember = roomMemberEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + + await em.persistAndFlush([account, user, role, userGroup, room, roomMember]); + em.clear(); + + const loggedInClient = await testApiClient.login(account); + + return { loggedInClient, room }; + }; + + it('should return status 403', async () => { + const { loggedInClient, room } = await setup(); + + const response = await loggedInClient.post(undefined, { + title: 'new board', + parentId: room.id, + parentType: BoardExternalReferenceType.Room, + layout: BoardLayout.COLUMNS, + }); + + expect(response.status).toEqual(403); + }); + }); + + describe('When user is not allowed in the room at all', () => { + const setup = async () => { + const user = userFactory.buildWithId(); + const account = accountFactory.withUser(user).build(); + + const room = roomEntityFactory.buildWithId(); + + await em.persistAndFlush([account, user, room]); + em.clear(); + + const loggedInClient = await testApiClient.login(account); + + return { loggedInClient, room }; + }; + + it('should return status 403', async () => { + const { loggedInClient, room } = await setup(); + + const response = await loggedInClient.post(undefined, { + title: 'new board', + parentId: room.id, + parentType: BoardExternalReferenceType.Room, + layout: BoardLayout.COLUMNS, + }); + + expect(response.status).toEqual(403); + }); + }); + }); + + describe('When request is invalid', () => { + const setup = async () => { + const user = userFactory.buildWithId(); + const account = accountFactory.withUser(user).build(); + + const role = roleFactory.buildWithId({ name: RoleName.ROOM_EDITOR, permissions: [Permission.ROOM_EDIT] }); + + const userGroup = groupEntityFactory.buildWithId({ + type: GroupEntityTypes.ROOM, + users: [{ user, role }], + }); + + const room = roomEntityFactory.buildWithId(); + + const roomMember = roomMemberEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + + await em.persistAndFlush([account, user, role, userGroup, room, roomMember]); + em.clear(); + + const loggedInClient = await testApiClient.login(account); + + return { loggedInClient, room }; + }; + + describe('When title is empty', () => { + it('should return status 400', async () => { + const { loggedInClient, room } = await setup(); + + const response = await loggedInClient.post(undefined, { + title: '', + parentId: room.id, + parentType: BoardExternalReferenceType.Room, + }); + + expect(response.status).toEqual(400); + }); + }); + + describe('When title is too long', () => { + it('should return status 400', async () => { + const { loggedInClient, room } = await setup(); + + const response = await loggedInClient.post(undefined, { + title: 'a'.repeat(101), + parentId: room.id, + parentType: BoardExternalReferenceType.Room, + }); + + expect(response.status).toEqual(400); + }); + }); + + describe('When parent type is invalid', () => { + it('should return status 400', async () => { + const { loggedInClient, room } = await setup(); + + const response = await loggedInClient.post(undefined, >{ + title: 'new board', + parentId: room.id, + parentType: 'invalid', + layout: BoardLayout.COLUMNS, + }); + + expect(response.status).toEqual(400); + }); + }); + }); +}); diff --git a/apps/server/src/modules/board/controller/api-test/board-delete.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-delete-in-course.api.spec.ts similarity index 98% rename from apps/server/src/modules/board/controller/api-test/board-delete.api.spec.ts rename to apps/server/src/modules/board/controller/api-test/board-delete-in-course.api.spec.ts index cb71b2a3863..7bcc32a50bf 100644 --- a/apps/server/src/modules/board/controller/api-test/board-delete.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/board-delete-in-course.api.spec.ts @@ -9,7 +9,7 @@ import { columnBoardEntityFactory, columnEntityFactory } from '../../testing'; const baseRouteName = '/boards'; -describe(`board delete (api)`, () => { +describe(`board delete in course (api)`, () => { let app: INestApplication; let em: EntityManager; let testApiClient: TestApiClient; diff --git a/apps/server/src/modules/board/controller/api-test/board-delete-in-room.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-delete-in-room.api.spec.ts new file mode 100644 index 00000000000..59d819352c8 --- /dev/null +++ b/apps/server/src/modules/board/controller/api-test/board-delete-in-room.api.spec.ts @@ -0,0 +1,146 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { ServerTestModule } from '@modules/server'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { cleanupCollections, groupEntityFactory, roleFactory, TestApiClient, userFactory } from '@shared/testing'; +import { accountFactory } from '@src/modules/account/testing'; +import { Permission, RoleName } from '@shared/domain/interface'; +import { GroupEntityTypes } from '@src/modules/group/entity'; +import { roomEntityFactory } from '@src/modules/room/testing'; +import { roomMemberEntityFactory } from '@src/modules/room-member/testing'; +import { columnBoardEntityFactory, columnEntityFactory } from '../../testing'; +import { BoardNodeEntity } from '../../repo'; +import { BoardExternalReferenceType } from '../../domain'; + +const baseRouteName = '/boards'; + +describe(`board delete in room (api)`, () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, baseRouteName); + }); + + beforeEach(async () => { + await cleanupCollections(em); + }); + + afterAll(async () => { + await app.close(); + }); + + const setup = async () => { + const userWithEditRole = userFactory.buildWithId(); + const accountWithEditRole = accountFactory.withUser(userWithEditRole).build(); + + const userWithViewRole = userFactory.buildWithId(); + const accountWithViewRole = accountFactory.withUser(userWithViewRole).build(); + + const noAccessUser = userFactory.buildWithId(); + const noAccessAccount = accountFactory.withUser(noAccessUser).build(); + + const roleRoomEdit = roleFactory.buildWithId({ name: RoleName.ROOM_EDITOR, permissions: [Permission.ROOM_EDIT] }); + const roleRoomView = roleFactory.buildWithId({ name: RoleName.ROOM_VIEWER, permissions: [Permission.ROOM_VIEW] }); + + const userGroup = groupEntityFactory.buildWithId({ + type: GroupEntityTypes.ROOM, + users: [ + { user: userWithEditRole, role: roleRoomEdit }, + { user: userWithViewRole, role: roleRoomView }, + ], + }); + + const room = roomEntityFactory.buildWithId(); + + const roomMember = roomMemberEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + + await em.persistAndFlush([ + accountWithEditRole, + accountWithViewRole, + noAccessAccount, + userWithEditRole, + userWithViewRole, + noAccessUser, + roleRoomEdit, + roleRoomView, + userGroup, + room, + roomMember, + ]); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + + await em.persistAndFlush([columnBoardNode, columnNode]); + em.clear(); + + return { accountWithEditRole, accountWithViewRole, noAccessAccount, columnBoardNode, columnNode }; + }; + + describe('with valid user which is allowed to edit room', () => { + it('should return status 204', async () => { + const { accountWithEditRole, columnBoardNode } = await setup(); + + const loggedInClient = await testApiClient.login(accountWithEditRole); + + const response = await loggedInClient.delete(columnBoardNode.id); + + expect(response.status).toEqual(204); + }); + + it('should actually delete the board', async () => { + const { accountWithEditRole, columnBoardNode } = await setup(); + + const loggedInClient = await testApiClient.login(accountWithEditRole); + + await loggedInClient.delete(columnBoardNode.id); + + await expect(em.findOneOrFail(BoardNodeEntity, columnBoardNode.id)).rejects.toThrow(); + }); + + it('should actually delete columns of the board', async () => { + const { accountWithEditRole, columnNode, columnBoardNode } = await setup(); + + const loggedInClient = await testApiClient.login(accountWithEditRole); + + await loggedInClient.delete(columnBoardNode.id); + + await expect(em.findOneOrFail(BoardNodeEntity, columnNode.id)).rejects.toThrow(); + }); + }); + + describe('with invalid user who has only view rights to the room', () => { + it('should return status 403', async () => { + const { accountWithViewRole, columnBoardNode } = await setup(); + + const loggedInClient = await testApiClient.login(accountWithViewRole); + + const response = await loggedInClient.delete(columnBoardNode.id); + + expect(response.status).toEqual(403); + }); + }); + + describe('with invalid user who has no access to the room', () => { + it('should return status 403', async () => { + const { noAccessAccount, columnBoardNode } = await setup(); + + const loggedInClient = await testApiClient.login(noAccessAccount); + + const response = await loggedInClient.delete(columnBoardNode.id); + + expect(response.status).toEqual(403); + }); + }); +}); diff --git a/apps/server/src/modules/board/controller/api-test/board-lookup.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-lookup-in-course.api.spec.ts similarity index 98% rename from apps/server/src/modules/board/controller/api-test/board-lookup.api.spec.ts rename to apps/server/src/modules/board/controller/api-test/board-lookup-in-course.api.spec.ts index 25ae2ea4866..b6e0419ace9 100644 --- a/apps/server/src/modules/board/controller/api-test/board-lookup.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/board-lookup-in-course.api.spec.ts @@ -10,7 +10,7 @@ import { BoardResponse } from '../dto'; const baseRouteName = '/boards'; -describe(`board lookup (api)`, () => { +describe(`board lookup in course (api)`, () => { let app: INestApplication; let em: EntityManager; let testApiClient: TestApiClient; diff --git a/apps/server/src/modules/board/controller/api-test/board-lookup-in-room.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-lookup-in-room.api.spec.ts new file mode 100644 index 00000000000..c6701f81c94 --- /dev/null +++ b/apps/server/src/modules/board/controller/api-test/board-lookup-in-room.api.spec.ts @@ -0,0 +1,182 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { ServerTestModule } from '@modules/server/server.module'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { cleanupCollections, groupEntityFactory, roleFactory, TestApiClient, userFactory } from '@shared/testing'; + +import { Permission, RoleName } from '@shared/domain/interface'; +import { accountFactory } from '@src/modules/account/testing'; +import { GroupEntityTypes } from '@src/modules/group/entity'; +import { roomMemberEntityFactory } from '@src/modules/room-member/testing'; +import { roomEntityFactory } from '@src/modules/room/testing'; +import { BoardExternalReferenceType, BoardLayout } from '../../domain'; +import { BoardResponse } from '../dto'; +import { cardEntityFactory, columnEntityFactory, columnBoardEntityFactory } from '../../testing'; + +const baseRouteName = '/boards'; + +describe(`board lookup in room (api)`, () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, baseRouteName); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + await cleanupCollections(em); + }); + + const setup = async () => { + const userWithEditRole = userFactory.buildWithId(); + const accountWithEditRole = accountFactory.withUser(userWithEditRole).build(); + + const userWithViewRole = userFactory.buildWithId(); + const accountWithViewRole = accountFactory.withUser(userWithViewRole).build(); + + const noAccessUser = userFactory.buildWithId(); + const noAccessAccount = accountFactory.withUser(noAccessUser).build(); + + const roleRoomEdit = roleFactory.buildWithId({ name: RoleName.ROOM_EDITOR, permissions: [Permission.ROOM_EDIT] }); + const roleRoomView = roleFactory.buildWithId({ name: RoleName.ROOM_VIEWER, permissions: [Permission.ROOM_VIEW] }); + + const userGroup = groupEntityFactory.buildWithId({ + type: GroupEntityTypes.ROOM, + users: [ + { user: userWithEditRole, role: roleRoomEdit }, + { user: userWithViewRole, role: roleRoomView }, + ], + }); + + const room = roomEntityFactory.buildWithId(); + + const roomMember = roomMemberEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + + await em.persistAndFlush([ + accountWithEditRole, + accountWithViewRole, + noAccessAccount, + userWithEditRole, + userWithViewRole, + noAccessUser, + roleRoomEdit, + roleRoomView, + userGroup, + room, + roomMember, + ]); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode1 = cardEntityFactory.withParent(columnNode).build(); + const cardNode2 = cardEntityFactory.withParent(columnNode).build(); + const cardNode3 = cardEntityFactory.withParent(columnNode).build(); + const notOfThisBoardCardNode = cardEntityFactory.build(); + + await em.persistAndFlush([columnBoardNode, columnNode, cardNode1, cardNode2, cardNode3, notOfThisBoardCardNode]); + em.clear(); + + return { + accountWithEditRole, + accountWithViewRole, + noAccessAccount, + columnBoardNode, + columnNode, + card1: cardNode1, + card2: cardNode2, + card3: cardNode3, + }; + }; + + describe('When user has edit rights in room', () => { + describe('with valid board id', () => { + it('should return status 200', async () => { + const { accountWithEditRole, columnBoardNode } = await setup(); + + const loggedInClient = await testApiClient.login(accountWithEditRole); + + const response = await loggedInClient.get(columnBoardNode.id); + + expect(response.status).toEqual(200); + }); + + it('should return the correct board', async () => { + const { accountWithEditRole, columnBoardNode, columnNode } = await setup(); + + const loggedInClient = await testApiClient.login(accountWithEditRole); + + const response = await loggedInClient.get(columnBoardNode.id); + const result = response.body as BoardResponse; + + expect(result.id).toEqual(columnBoardNode.id); + expect(result.columns).toHaveLength(1); + expect(result.columns[0].id).toEqual(columnNode.id); + expect(result.columns[0].cards).toHaveLength(3); + }); + }); + + describe('board layout', () => { + it(`should default to ${BoardLayout.COLUMNS}`, async () => { + const { accountWithEditRole, columnBoardNode } = await setup(); + + const loggedInClient = await testApiClient.login(accountWithEditRole); + + const response = await loggedInClient.get(columnBoardNode.id); + const result = response.body as BoardResponse; + + expect(result.layout).toEqual(BoardLayout.COLUMNS); + }); + }); + + describe('with invalid board id', () => { + it('should return status 404', async () => { + const { accountWithEditRole } = await setup(); + const notExistingBoardId = new ObjectId().toString(); + + const loggedInClient = await testApiClient.login(accountWithEditRole); + + const response = await loggedInClient.get(notExistingBoardId); + + expect(response.status).toEqual(404); + }); + }); + }); + + describe('When user has only view rights in room', () => { + it('should return status 200', async () => { + const { accountWithViewRole, columnBoardNode } = await setup(); + + const loggedInClient = await testApiClient.login(accountWithViewRole); + + const response = await loggedInClient.get(columnBoardNode.id); + + expect(response.status).toEqual(200); + }); + }); + + describe('When user does not belong to room', () => { + it('should return status 403', async () => { + const { noAccessAccount, columnBoardNode } = await setup(); + + const loggedInClient = await testApiClient.login(noAccessAccount); + + const response = await loggedInClient.get(columnBoardNode.id); + + expect(response.status).toEqual(403); + }); + }); +}); diff --git a/apps/server/src/modules/board/controller/api-test/board-update-title.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-update-title-in-course.api.spec.ts similarity index 96% rename from apps/server/src/modules/board/controller/api-test/board-update-title.api.spec.ts rename to apps/server/src/modules/board/controller/api-test/board-update-title-in-course.api.spec.ts index 93254af4884..57b5489e707 100644 --- a/apps/server/src/modules/board/controller/api-test/board-update-title.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/board-update-title-in-course.api.spec.ts @@ -10,7 +10,7 @@ import { BoardExternalReferenceType } from '../../domain'; const baseRouteName = '/boards'; -describe(`board update title (api)`, () => { +describe(`board update title with course relation (api)`, () => { let app: INestApplication; let em: EntityManager; let testApiClient: TestApiClient; @@ -39,13 +39,13 @@ describe(`board update title (api)`, () => { const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); const course = courseFactory.build({ teachers: [teacherUser] }); - await em.persistAndFlush([teacherUser, course]); + await em.persistAndFlush([teacherAccount, teacherUser, course]); const columnBoardNode = columnBoardEntityFactory.build({ context: { id: course.id, type: BoardExternalReferenceType.Course }, }); - await em.persistAndFlush([teacherAccount, teacherUser, columnBoardNode]); + await em.persistAndFlush([columnBoardNode]); em.clear(); const loggedInClient = await testApiClient.login(teacherAccount); diff --git a/apps/server/src/modules/board/controller/api-test/board-update-title-in-room.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-update-title-in-room.api.spec.ts new file mode 100644 index 00000000000..d5988437838 --- /dev/null +++ b/apps/server/src/modules/board/controller/api-test/board-update-title-in-room.api.spec.ts @@ -0,0 +1,210 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { ServerTestModule } from '@modules/server/server.module'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ApiValidationError } from '@shared/common'; +import { TestApiClient, cleanupCollections, groupEntityFactory, roleFactory, userFactory } from '@shared/testing'; +import { Permission, RoleName } from '@shared/domain/interface'; +import { accountFactory } from '@src/modules/account/testing'; +import { GroupEntityTypes } from '@src/modules/group/entity'; +import { roomMemberEntityFactory } from '@src/modules/room-member/testing'; +import { roomEntityFactory } from '@src/modules/room/testing'; +import { BoardNodeEntity } from '../../repo'; +import { columnBoardEntityFactory } from '../../testing'; +import { BoardExternalReferenceType } from '../../domain'; + +const baseRouteName = '/boards'; + +describe(`board update title with room relation (api)`, () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, baseRouteName); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + await cleanupCollections(em); + }); + + const setup = async () => { + const userWithEditRole = userFactory.buildWithId(); + const accountWithEditRole = accountFactory.withUser(userWithEditRole).build(); + + const userWithViewRole = userFactory.buildWithId(); + const accountWithViewRole = accountFactory.withUser(userWithViewRole).build(); + + const noAccessUser = userFactory.buildWithId(); + const noAccessAccount = accountFactory.withUser(noAccessUser).build(); + + const roleRoomEdit = roleFactory.buildWithId({ + name: RoleName.ROOM_EDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleRoomView = roleFactory.buildWithId({ + name: RoleName.ROOM_VIEWER, + permissions: [Permission.ROOM_VIEW], + }); + + const userGroup = groupEntityFactory.buildWithId({ + type: GroupEntityTypes.ROOM, + users: [ + { user: userWithEditRole, role: roleRoomEdit }, + { user: userWithViewRole, role: roleRoomView }, + ], + }); + + const room = roomEntityFactory.buildWithId(); + + const roomMember = roomMemberEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + + await em.persistAndFlush([ + accountWithEditRole, + accountWithViewRole, + noAccessAccount, + userWithEditRole, + userWithViewRole, + noAccessUser, + roleRoomEdit, + roleRoomView, + userGroup, + room, + roomMember, + ]); + + const originalTitle = 'old title'; + const columnBoardNode = columnBoardEntityFactory.build({ + title: originalTitle, + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + + await em.persistAndFlush([columnBoardNode]); + em.clear(); + + return { accountWithEditRole, accountWithViewRole, noAccessAccount, columnBoardNode, originalTitle }; + }; + + describe('with user who has edit role in room', () => { + it('should return status 204', async () => { + const { accountWithEditRole, columnBoardNode } = await setup(); + + const loggedInClient = await testApiClient.login(accountWithEditRole); + + const newTitle = 'new title'; + + const response = await loggedInClient.patch(`${columnBoardNode.id}/title`, { title: newTitle }); + + expect(response.status).toEqual(204); + }); + + it('should actually change the board title', async () => { + const { accountWithEditRole, columnBoardNode } = await setup(); + + const loggedInClient = await testApiClient.login(accountWithEditRole); + + const newTitle = 'new title'; + + await loggedInClient.patch(`${columnBoardNode.id}/title`, { title: newTitle }); + + const result = await em.findOneOrFail(BoardNodeEntity, columnBoardNode.id); + + expect(result.title).toEqual(newTitle); + }); + + it('should sanitize the title', async () => { + const { accountWithEditRole, columnBoardNode } = await setup(); + + const loggedInClient = await testApiClient.login(accountWithEditRole); + + const unsanitizedTitle = ' bar'; + const sanitizedTitle = 'foo bar'; + + await loggedInClient.patch(`${columnBoardNode.id}/title`, { title: unsanitizedTitle }); + const result = await em.findOneOrFail(BoardNodeEntity, columnBoardNode.id); + + expect(result.title).toEqual(sanitizedTitle); + }); + + it('should return status 400 when title is too long', async () => { + const { accountWithEditRole, columnBoardNode } = await setup(); + + const loggedInClient = await testApiClient.login(accountWithEditRole); + + const newTitle = 'a'.repeat(101); + + const response = await loggedInClient.patch(`${columnBoardNode.id}/title`, { title: newTitle }); + + expect((response.body as ApiValidationError).validationErrors).toEqual([ + { + errors: ['title must be shorter than or equal to 100 characters'], + field: ['title'], + }, + ]); + expect(response.status).toEqual(400); + }); + + it('should return status 400 when title is empty string', async () => { + const { accountWithEditRole, columnBoardNode } = await setup(); + + const loggedInClient = await testApiClient.login(accountWithEditRole); + + const newTitle = ''; + + const response = await loggedInClient.patch(`${columnBoardNode.id}/title`, { title: newTitle }); + + expect((response.body as ApiValidationError).validationErrors).toEqual([ + { + errors: ['title must be longer than or equal to 1 characters'], + field: ['title'], + }, + ]); + expect(response.status).toEqual(400); + }); + }); + + describe('with user who has only view role in room', () => { + it('should return status 403', async () => { + const { accountWithViewRole, columnBoardNode, originalTitle } = await setup(); + + const loggedInClient = await testApiClient.login(accountWithViewRole); + + const newTitle = 'new title'; + + const response = await loggedInClient.patch(`${columnBoardNode.id}/title`, { title: newTitle }); + + expect(response.status).toEqual(403); + + const result = await em.findOneOrFail(BoardNodeEntity, columnBoardNode.id); + expect(result.title).toEqual(originalTitle); + }); + }); + + describe('with user who is not part of the room', () => { + it('should return status 403', async () => { + const { noAccessAccount, columnBoardNode, originalTitle } = await setup(); + + const loggedInClient = await testApiClient.login(noAccessAccount); + + const newTitle = 'new title'; + + const response = await loggedInClient.patch(`${columnBoardNode.id}/title`, { title: newTitle }); + + expect(response.status).toEqual(403); + + const result = await em.findOneOrFail(BoardNodeEntity, columnBoardNode.id); + expect(result.title).toEqual(originalTitle); + }); + }); +}); diff --git a/apps/server/src/modules/board/controller/api-test/board-visibility.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-visibility-in-course.api.spec.ts similarity index 94% rename from apps/server/src/modules/board/controller/api-test/board-visibility.api.spec.ts rename to apps/server/src/modules/board/controller/api-test/board-visibility-in-course.api.spec.ts index 5af52adbc7b..36126152bb7 100644 --- a/apps/server/src/modules/board/controller/api-test/board-visibility.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/board-visibility-in-course.api.spec.ts @@ -9,7 +9,7 @@ import { BoardExternalReferenceType } from '../../domain'; const baseRouteName = '/boards'; -describe(`board update visibility (api)`, () => { +describe(`board update visibility with course relation (api)`, () => { let app: INestApplication; let em: EntityManager; let testApiClient: TestApiClient; @@ -38,14 +38,14 @@ describe(`board update visibility (api)`, () => { const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); const course = courseFactory.build({ teachers: [teacherUser] }); - await em.persistAndFlush([teacherUser, course]); + await em.persistAndFlush([teacherAccount, teacherUser, course]); const columnBoardNode = columnBoardEntityFactory.build({ isVisible: false, context: { id: course.id, type: BoardExternalReferenceType.Course }, }); - await em.persistAndFlush([teacherAccount, teacherUser, columnBoardNode]); + await em.persistAndFlush([columnBoardNode]); em.clear(); const loggedInClient = await testApiClient.login(teacherAccount); diff --git a/apps/server/src/modules/board/controller/api-test/board-visibility-in-room.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-visibility-in-room.api.spec.ts new file mode 100644 index 00000000000..c9a6aec631a --- /dev/null +++ b/apps/server/src/modules/board/controller/api-test/board-visibility-in-room.api.spec.ts @@ -0,0 +1,172 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { ServerTestModule } from '@modules/server/server.module'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { TestApiClient, cleanupCollections, groupEntityFactory, roleFactory, userFactory } from '@shared/testing'; +import { Permission, RoleName } from '@shared/domain/interface'; +import { accountFactory } from '@src/modules/account/testing'; +import { GroupEntityTypes } from '@src/modules/group/entity'; +import { roomMemberEntityFactory } from '@src/modules/room-member/testing'; +import { roomEntityFactory } from '@src/modules/room/testing'; +import { BoardExternalReferenceType } from '../../domain'; +import { columnBoardEntityFactory } from '../../testing'; +import { BoardNodeEntity } from '../../repo'; + +const baseRouteName = '/boards'; + +describe(`board update visibility with room relation (api)`, () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, baseRouteName); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + await cleanupCollections(em); + }); + + const setup = async () => { + const userWithEditRole = userFactory.buildWithId(); + const accountWithEditRole = accountFactory.withUser(userWithEditRole).build(); + + const userWithViewRole = userFactory.buildWithId(); + const accountWithViewRole = accountFactory.withUser(userWithViewRole).build(); + + const noAccessUser = userFactory.buildWithId(); + const noAccessAccount = accountFactory.withUser(noAccessUser).build(); + + const roleRoomEdit = roleFactory.buildWithId({ + name: RoleName.ROOM_EDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleRoomView = roleFactory.buildWithId({ + name: RoleName.ROOM_VIEWER, + permissions: [Permission.ROOM_VIEW], + }); + + const userGroup = groupEntityFactory.buildWithId({ + type: GroupEntityTypes.ROOM, + users: [ + { user: userWithEditRole, role: roleRoomEdit }, + { user: userWithViewRole, role: roleRoomView }, + ], + }); + + const room = roomEntityFactory.buildWithId(); + + const roomMember = roomMemberEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + + await em.persistAndFlush([ + accountWithEditRole, + accountWithViewRole, + noAccessAccount, + userWithEditRole, + userWithViewRole, + noAccessUser, + roleRoomEdit, + roleRoomView, + userGroup, + room, + roomMember, + ]); + + const columnBoardNode = columnBoardEntityFactory.build({ + isVisible: false, + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + + await em.persistAndFlush([columnBoardNode]); + em.clear(); + + return { accountWithEditRole, accountWithViewRole, noAccessAccount, columnBoardNode }; + }; + + describe('with user who has edit role in room', () => { + it('should return status 204', async () => { + const { accountWithEditRole, columnBoardNode } = await setup(); + + const loggedInClient = await testApiClient.login(accountWithEditRole); + + const isVisible = true; + + const response = await loggedInClient.patch(`${columnBoardNode.id}/visibility`, { isVisible }); + + expect(response.status).toEqual(204); + }); + + it('should actually change the board visibility', async () => { + const { accountWithEditRole, columnBoardNode } = await setup(); + + const loggedInClient = await testApiClient.login(accountWithEditRole); + + const isVisible = true; + + await loggedInClient.patch(`${columnBoardNode.id}/visibility`, { isVisible }); + + const result = await em.findOneOrFail(BoardNodeEntity, columnBoardNode.id); + + expect(result.isVisible).toEqual(isVisible); + }); + }); + + describe('with user who has only view role in room', () => { + it('should return status 403', async () => { + const { accountWithViewRole, columnBoardNode } = await setup(); + + const loggedInClient = await testApiClient.login(accountWithViewRole); + + const isVisible = true; + + const response = await loggedInClient.patch(`${columnBoardNode.id}/visibility`, { isVisible }); + + expect(response.status).toEqual(403); + }); + it('should not change the board visibility', async () => { + const { accountWithViewRole, columnBoardNode } = await setup(); + + const loggedInClient = await testApiClient.login(accountWithViewRole); + + const isVisible = true; + await loggedInClient.patch(`${columnBoardNode.id}/visibility`, { isVisible }); + const result = await em.findOneOrFail(BoardNodeEntity, columnBoardNode.id); + expect(result.isVisible).toEqual(false); + }); + }); + + describe('with user who is not part of the room', () => { + it('should return status 403', async () => { + const { noAccessAccount, columnBoardNode } = await setup(); + + const loggedInClient = await testApiClient.login(noAccessAccount); + + const isVisible = true; + + const response = await loggedInClient.patch(`${columnBoardNode.id}/visibility`, { isVisible }); + + expect(response.status).toEqual(403); + }); + it('should not change the board visibility', async () => { + const { noAccessAccount, columnBoardNode } = await setup(); + + const loggedInClient = await testApiClient.login(noAccessAccount); + + const isVisible = true; + await loggedInClient.patch(`${columnBoardNode.id}/visibility`, { isVisible }); + const result = await em.findOneOrFail(BoardNodeEntity, columnBoardNode.id); + expect(result.isVisible).toEqual(false); + }); + }); +}); diff --git a/apps/server/src/modules/board/domain/types/board-external-reference.ts b/apps/server/src/modules/board/domain/types/board-external-reference.ts index 5d9fd94a599..a195d6b1578 100644 --- a/apps/server/src/modules/board/domain/types/board-external-reference.ts +++ b/apps/server/src/modules/board/domain/types/board-external-reference.ts @@ -1,10 +1,11 @@ import type { EntityId } from '@shared/domain/types'; export enum BoardExternalReferenceType { - 'Course' = 'course', - 'User' = 'user', + Course = 'course', + Room = 'room', + User = 'user', // TODO - // 'ExternalTool' = 'external-tool', + // ExternalTool = 'external-tool', } export interface BoardExternalReference { diff --git a/apps/server/src/modules/board/service/internal/board-context.service.spec.ts b/apps/server/src/modules/board/service/internal/board-context.service.spec.ts index 46e09f3598e..c14ec70c1e1 100644 --- a/apps/server/src/modules/board/service/internal/board-context.service.spec.ts +++ b/apps/server/src/modules/board/service/internal/board-context.service.spec.ts @@ -2,7 +2,12 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { CourseRepo } from '@shared/repo'; -import { courseFactory, setupEntities, userFactory } from '@shared/testing'; +import { courseFactory, groupFactory, roleFactory, setupEntities, userFactory } from '@shared/testing'; +import { RoomMemberService } from '@src/modules/room-member'; +import { roomFactory } from '@src/modules/room/testing'; +import { Permission, RoleName } from '@shared/domain/interface'; +import { GroupTypes } from '@src/modules/group'; +import { roomMemberFactory } from '@src/modules/room-member/testing'; import { columnFactory, columnBoardFactory } from '../../testing'; import { BoardExternalReferenceType, BoardRoles, UserWithBoardRoles } from '../../domain'; import { BoardContextService } from './board-context.service'; @@ -11,11 +16,16 @@ describe(`${BoardContextService.name}`, () => { let module: TestingModule; let service: BoardContextService; let courseRepo: DeepMocked; + let roomMemberService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ providers: [ BoardContextService, + { + provide: RoomMemberService, + useValue: createMock(), + }, { provide: CourseRepo, useValue: createMock(), @@ -24,6 +34,7 @@ describe(`${BoardContextService.name}`, () => { }).compile(); service = module.get(BoardContextService); + roomMemberService = module.get(RoomMemberService); courseRepo = module.get(CourseRepo); await setupEntities(); @@ -202,5 +213,112 @@ describe(`${BoardContextService.name}`, () => { }); }); }); + + describe('when node has a room context', () => { + describe('when user with editor role is associated with the room', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const role = roleFactory.build({ name: RoleName.ROOM_EDITOR, permissions: [Permission.ROOM_EDIT] }); + const group = groupFactory.build({ type: GroupTypes.ROOM, users: [{ userId: user.id, roleId: role.id }] }); + const room = roomFactory.build(); + roomMemberFactory.build({ roomId: room.id, userGroupId: group.id }); + const columnBoard = columnBoardFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + + return { columnBoard, role, user }; + }; + + it('should return their information + editor role', async () => { + const { columnBoard, role, user } = setup(); + + roomMemberService.getRoomMemberAuthorizable.mockResolvedValue({ + id: 'foo', + roomId: columnBoard.context.id, + members: [{ userId: user.id, roles: [role] }], + }); + + const result = await service.getUsersWithBoardRoles(columnBoard); + const expected: UserWithBoardRoles[] = [ + { + userId: user.id, + roles: [BoardRoles.EDITOR], + }, + ]; + + expect(result).toEqual(expected); + }); + }); + + describe('when user with view role is associated with the room', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const role = roleFactory.build({ name: RoleName.ROOM_VIEWER, permissions: [Permission.ROOM_VIEW] }); + const group = groupFactory.build({ type: GroupTypes.ROOM, users: [{ userId: user.id, roleId: role.id }] }); + const room = roomFactory.build(); + roomMemberFactory.build({ roomId: room.id, userGroupId: group.id }); + const columnBoard = columnBoardFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + + return { columnBoard, role, user }; + }; + + it('should return their information + reader role', async () => { + const { columnBoard, role, user } = setup(); + + roomMemberService.getRoomMemberAuthorizable.mockResolvedValue({ + id: 'foo', + roomId: columnBoard.context.id, + members: [{ userId: user.id, roles: [role] }], + }); + + const result = await service.getUsersWithBoardRoles(columnBoard); + const expected: UserWithBoardRoles[] = [ + { + userId: user.id, + roles: [BoardRoles.READER], + }, + ]; + + expect(result).toEqual(expected); + }); + }); + + describe('when user with not-matching role is associated with the room', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const role = roleFactory.build(); + const group = groupFactory.build({ type: GroupTypes.ROOM, users: [{ userId: user.id, roleId: role.id }] }); + const room = roomFactory.build(); + roomMemberFactory.build({ roomId: room.id, userGroupId: group.id }); + const columnBoard = columnBoardFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + + return { columnBoard, role, user }; + }; + + it('should return their information + no role', async () => { + const { columnBoard, role, user } = setup(); + + roomMemberService.getRoomMemberAuthorizable.mockResolvedValue({ + id: 'foo', + roomId: columnBoard.context.id, + members: [{ userId: user.id, roles: [role] }], + }); + + const result = await service.getUsersWithBoardRoles(columnBoard); + const expected: UserWithBoardRoles[] = [ + { + userId: user.id, + roles: [], + }, + ]; + + expect(result).toEqual(expected); + }); + }); + }); }); }); diff --git a/apps/server/src/modules/board/service/internal/board-context.service.ts b/apps/server/src/modules/board/service/internal/board-context.service.ts index 42725a45c36..99f5c346cad 100644 --- a/apps/server/src/modules/board/service/internal/board-context.service.ts +++ b/apps/server/src/modules/board/service/internal/board-context.service.ts @@ -1,11 +1,14 @@ import { Injectable } from '@nestjs/common'; +import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { CourseRepo } from '@shared/repo'; +import { RoomMemberService } from '@src/modules/room-member'; +import { UserWithRoomRoles } from '@src/modules/room-member/do/room-member-authorizable.do'; import { AnyBoardNode, BoardExternalReferenceType, BoardRoles, UserWithBoardRoles } from '../../domain'; @Injectable() export class BoardContextService { - constructor(private readonly courseRepo: CourseRepo) {} + constructor(private readonly courseRepo: CourseRepo, private readonly roomMemberService: RoomMemberService) {} async getUsersWithBoardRoles(rootNode: AnyBoardNode): Promise { if (!('context' in rootNode)) { @@ -14,9 +17,11 @@ export class BoardContextService { let usersWithRoles: UserWithBoardRoles[] = []; - if (rootNode.context.type === BoardExternalReferenceType.Course) + if (rootNode.context.type === BoardExternalReferenceType.Room) { + usersWithRoles = await this.getFromRoom(rootNode.context.id); + } else if (rootNode.context.type === BoardExternalReferenceType.Course) { usersWithRoles = await this.getFromCourse(rootNode.context.id); - else if (rootNode.context.type === BoardExternalReferenceType.User) { + } else if (rootNode.context.type === BoardExternalReferenceType.User) { usersWithRoles = this.getFromUser(rootNode.context.id); } else { throw new Error(`Unknown context type: '${rootNode.context.type as string}'`); @@ -25,6 +30,18 @@ export class BoardContextService { return usersWithRoles; } + private async getFromRoom(roomId: EntityId): Promise { + const roomMemberAuthorizable = await this.roomMemberService.getRoomMemberAuthorizable(roomId); + const usersWithRoles: UserWithBoardRoles[] = roomMemberAuthorizable.members.map((member) => { + const roles = this.getBoardRolesFromRoomMember(member); + return { + userId: member.userId, + roles, + }; + }); + return usersWithRoles; + } + private async getFromCourse(courseId: EntityId): Promise { const course = await this.courseRepo.findById(courseId); const usersWithRoles: UserWithBoardRoles[] = [ @@ -67,4 +84,17 @@ export class BoardContextService { return usersWithRoles; } + + private getBoardRolesFromRoomMember(member: UserWithRoomRoles): BoardRoles[] { + const isReader = member.roles.flatMap((role) => role.permissions ?? []).includes(Permission.ROOM_VIEW); + const isEditor = member.roles.flatMap((role) => role.permissions ?? []).includes(Permission.ROOM_EDIT); + + if (isEditor) { + return [BoardRoles.EDITOR]; + } + if (isReader) { + return [BoardRoles.READER]; + } + return []; + } } diff --git a/apps/server/src/modules/board/uc/board.uc.spec.ts b/apps/server/src/modules/board/uc/board.uc.spec.ts index 26e8b220931..84e64021289 100644 --- a/apps/server/src/modules/board/uc/board.uc.spec.ts +++ b/apps/server/src/modules/board/uc/board.uc.spec.ts @@ -7,6 +7,7 @@ import { CourseRepo } from '@shared/repo'; import { setupEntities, userFactory } from '@shared/testing'; import { courseFactory } from '@shared/testing/factory'; import { LegacyLogger } from '@src/core/logger'; +import { RoomMemberService } from '@src/modules/room-member'; import { CopyElementType, CopyStatus, CopyStatusEnum } from '../../copy-helper'; import { BoardExternalReferenceType, BoardLayout, BoardNodeFactory, Column, ColumnBoard } from '../domain'; import { BoardNodePermissionService, BoardNodeService, ColumnBoardService } from '../service'; @@ -51,6 +52,10 @@ describe(BoardUc.name, () => { provide: BoardNodeFactory, useValue: createMock(), }, + { + provide: RoomMemberService, + useValue: createMock(), + }, { provide: LegacyLogger, useValue: createMock(), @@ -188,6 +193,20 @@ describe(BoardUc.name, () => { expect(result).toEqual(board); }); + describe('when context type is not supported', () => { + it('should throw an error', async () => { + const { user } = setup(); + + await expect( + uc.createBoard(user.id, { + title: 'new board', + layout: BoardLayout.COLUMNS, + parentId: new ObjectId().toHexString(), + parentType: BoardExternalReferenceType.User, + }) + ).rejects.toThrowError('Unsupported context type user'); + }); + }); }); }); diff --git a/apps/server/src/modules/board/uc/board.uc.ts b/apps/server/src/modules/board/uc/board.uc.ts index a48be369c1c..6a766370f74 100644 --- a/apps/server/src/modules/board/uc/board.uc.ts +++ b/apps/server/src/modules/board/uc/board.uc.ts @@ -5,8 +5,9 @@ import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { CourseRepo } from '@shared/repo'; import { LegacyLogger } from '@src/core/logger'; +import { RoomMemberService } from '@src/modules/room-member'; import { CreateBoardBodyParams } from '../controller/dto'; -import { BoardExternalReference, BoardNodeFactory, Column, ColumnBoard } from '../domain'; +import { BoardExternalReference, BoardExternalReferenceType, BoardNodeFactory, Column, ColumnBoard } from '../domain'; import { BoardNodePermissionService, BoardNodeService, ColumnBoardService } from '../service'; @Injectable() @@ -15,6 +16,7 @@ export class BoardUc { @Inject(forwardRef(() => AuthorizationService)) // TODO is this needed? private readonly authorizationService: AuthorizationService, private readonly boardPermissionService: BoardNodePermissionService, + private readonly roomMemberService: RoomMemberService, private readonly boardNodeService: BoardNodeService, private readonly columnBoardService: ColumnBoardService, private readonly logger: LegacyLogger, @@ -27,13 +29,7 @@ export class BoardUc { async createBoard(userId: EntityId, params: CreateBoardBodyParams): Promise { this.logger.debug({ action: 'createBoard', userId, title: params.title }); - const user = await this.authorizationService.getUserWithPermissions(userId); - const course = await this.courseRepo.findById(params.parentId); - - this.authorizationService.checkPermission(user, course, { - action: Action.write, - requiredPermissions: [Permission.COURSE_EDIT], - }); + await this.checkParentWritePermission(userId, { type: params.parentType, id: params.parentId }); const board = this.boardNodeFactory.buildColumnBoard({ context: { type: params.parentType, id: params.parentId }, @@ -147,4 +143,26 @@ export class BoardUc { await this.boardNodeService.updateVisibility(board, isVisible); return board; } + + private async checkParentWritePermission(userId: EntityId, context: BoardExternalReference) { + const user = await this.authorizationService.getUserWithPermissions(userId); + + if (context.type === BoardExternalReferenceType.Course) { + const course = await this.courseRepo.findById(context.id); + + this.authorizationService.checkPermission(user, course, { + action: Action.write, + requiredPermissions: [Permission.COURSE_EDIT], + }); + } else if (context.type === BoardExternalReferenceType.Room) { + const roomMemberAuthorizable = await this.roomMemberService.getRoomMemberAuthorizable(context.id); + + this.authorizationService.checkPermission(user, roomMemberAuthorizable, { + action: Action.write, + requiredPermissions: [], + }); + } else { + throw new Error(`Unsupported context type ${context.type as string}`); + } + } } diff --git a/apps/server/src/modules/learnroom/controller/dto/single-column-board/board-column-board.response.ts b/apps/server/src/modules/learnroom/controller/dto/single-column-board/board-column-board.response.ts index a27ad9cc356..3a18b5f1a8a 100644 --- a/apps/server/src/modules/learnroom/controller/dto/single-column-board/board-column-board.response.ts +++ b/apps/server/src/modules/learnroom/controller/dto/single-column-board/board-column-board.response.ts @@ -32,6 +32,6 @@ export class BoardColumnBoardResponse { @ApiProperty() columnBoardId: string; - @ApiProperty() + @ApiProperty({ enum: BoardLayout, enumName: 'BoardLayout' }) layout: BoardLayout; } diff --git a/apps/server/src/modules/room-member/service/room-member.service.ts b/apps/server/src/modules/room-member/service/room-member.service.ts index 45f37bff1e3..4fdd5933e16 100644 --- a/apps/server/src/modules/room-member/service/room-member.service.ts +++ b/apps/server/src/modules/room-member/service/room-member.service.ts @@ -38,11 +38,7 @@ export class RoomMemberService { return roomMember; } - private static buildRoomMemberAuthorizable( - roomId: EntityId, - group: Group, - roleSet: RoleDto[] - ): RoomMemberAuthorizable { + private buildRoomMemberAuthorizable(roomId: EntityId, group: Group, roleSet: RoleDto[]): RoomMemberAuthorizable { const members = group.users.map((groupUser): UserWithRoomRoles => { const roleDto = roleSet.find((role) => role.id === groupUser.roleId); if (roleDto === undefined) throw new BadRequestException('Role not found'); @@ -106,7 +102,7 @@ export class RoomMemberService { .map((item) => { const group = groupPage.data.find((g) => g.id === item.userGroupId); if (!group) return null; - return RoomMemberService.buildRoomMemberAuthorizable(item.roomId, group, roleSet); + return this.buildRoomMemberAuthorizable(item.roomId, group, roleSet); }) .filter((item): item is RoomMemberAuthorizable => item !== null); diff --git a/apps/server/src/modules/room/api/dto/response/room-board-item.response.ts b/apps/server/src/modules/room/api/dto/response/room-board-item.response.ts new file mode 100644 index 00000000000..0840c1c1d87 --- /dev/null +++ b/apps/server/src/modules/room/api/dto/response/room-board-item.response.ts @@ -0,0 +1,31 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { BoardLayout } from '@src/modules/board'; + +export class RoomBoardItemResponse { + @ApiProperty() + id: string; + + @ApiProperty() + title: string; + + @ApiProperty({ enum: BoardLayout, enumName: 'BoardLayout' }) + layout: BoardLayout; + + @ApiProperty({ type: Boolean }) + isVisible: boolean; + + @ApiProperty({ type: Date }) + createdAt: Date; + + @ApiProperty({ type: Date }) + updatedAt: Date; + + constructor(item: RoomBoardItemResponse) { + this.id = item.id; + this.title = item.title; + this.layout = item.layout; + this.isVisible = item.isVisible; + this.createdAt = item.createdAt; + this.updatedAt = item.updatedAt; + } +} diff --git a/apps/server/src/modules/room/api/dto/response/room-board-list.response.ts b/apps/server/src/modules/room/api/dto/response/room-board-list.response.ts new file mode 100644 index 00000000000..60d8510709b --- /dev/null +++ b/apps/server/src/modules/room/api/dto/response/room-board-list.response.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { PaginationResponse } from '@shared/controller'; +import { RoomBoardItemResponse } from './room-board-item.response'; + +export class RoomBoardListResponse extends PaginationResponse { + constructor(data: RoomBoardItemResponse[], total: number, skip?: number, limit?: number) { + super(total, skip, limit); + this.data = data; + } + + @ApiProperty({ type: [RoomBoardItemResponse] }) + data: RoomBoardItemResponse[]; +} diff --git a/apps/server/src/modules/room/api/mapper/room.mapper.ts b/apps/server/src/modules/room/api/mapper/room.mapper.ts index 3b559182926..02fd43a659f 100644 --- a/apps/server/src/modules/room/api/mapper/room.mapper.ts +++ b/apps/server/src/modules/room/api/mapper/room.mapper.ts @@ -1,6 +1,9 @@ import { Page } from '@shared/domain/domainobject'; +import { ColumnBoard } from '@src/modules/board'; import { Room } from '../../domain/do/room.do'; import { RoomPaginationParams } from '../dto/request/room-pagination.params'; +import { RoomBoardItemResponse } from '../dto/response/room-board-item.response'; +import { RoomBoardListResponse } from '../dto/response/room-board-list.response'; import { RoomDetailsResponse } from '../dto/response/room-details.response'; import { RoomItemResponse } from '../dto/response/room-item.response'; import { RoomListResponse } from '../dto/response/room-list.response'; @@ -42,4 +45,25 @@ export class RoomMapper { return response; } + + static mapToRoomBoardItemReponse(board: ColumnBoard): RoomBoardItemResponse { + const response = new RoomBoardItemResponse({ + id: board.id, + title: board.title, + layout: board.layout, + isVisible: board.isVisible, + createdAt: board.createdAt, + updatedAt: board.updatedAt, + }); + + return response; + } + + static mapToRoomBoardListResponse(columnBoards: ColumnBoard[]): RoomBoardListResponse { + const itemData = columnBoards.map((board) => this.mapToRoomBoardItemReponse(board)); + + const response = new RoomBoardListResponse(itemData, columnBoards.length); + + return response; + } } diff --git a/apps/server/src/modules/room/api/room.controller.ts b/apps/server/src/modules/room/api/room.controller.ts index 08896135b1b..2f54d0f65a8 100644 --- a/apps/server/src/modules/room/api/room.controller.ts +++ b/apps/server/src/modules/room/api/room.controller.ts @@ -19,18 +19,19 @@ import { ApiValidationError } from '@shared/common'; import { IFindOptions } from '@shared/domain/interface'; import { ErrorResponse } from '@src/core/error/dto'; import { Room } from '../domain'; +import { AddRoomMembersBodyParams } from './dto/request/add-room-members.body.params'; import { CreateRoomBodyParams } from './dto/request/create-room.body.params'; +import { RemoveRoomMembersBodyParams } from './dto/request/remove-room-members.body.params'; import { RoomPaginationParams } from './dto/request/room-pagination.params'; import { RoomUrlParams } from './dto/request/room.url.params'; import { UpdateRoomBodyParams } from './dto/request/update-room.body.params'; +import { RoomBoardListResponse } from './dto/response/room-board-list.response'; import { RoomDetailsResponse } from './dto/response/room-details.response'; import { RoomItemResponse } from './dto/response/room-item.response'; import { RoomListResponse } from './dto/response/room-list.response'; import { RoomMemberListResponse } from './dto/response/room-member.response'; import { RoomMapper } from './mapper/room.mapper'; import { RoomUc } from './room.uc'; -import { AddRoomMembersBodyParams } from './dto/request/add-room-members.body.params'; -import { RemoveRoomMembersBodyParams } from './dto/request/remove-room-members.body.params'; @ApiTags('Room') @JwtAuthentication() @@ -95,6 +96,25 @@ export class RoomController { return response; } + @Get(':roomId/boards') + @ApiOperation({ summary: 'Get the boards of a room' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Returns the boards of a room', type: RoomBoardListResponse }) + @ApiResponse({ status: HttpStatus.BAD_REQUEST, type: ApiValidationError }) + @ApiResponse({ status: HttpStatus.UNAUTHORIZED, type: UnauthorizedException }) + @ApiResponse({ status: HttpStatus.FORBIDDEN, type: ForbiddenException }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, type: NotFoundException }) + @ApiResponse({ status: '5XX', type: ErrorResponse }) + async getRoomBoards( + @CurrentUser() currentUser: ICurrentUser, + @Param() urlParams: RoomUrlParams + ): Promise { + const boards = await this.roomUc.getRoomBoards(currentUser.userId, urlParams.roomId); + + const response = RoomMapper.mapToRoomBoardListResponse(boards); + + return response; + } + @Patch(':roomId') @ApiOperation({ summary: 'Create a new room' }) @ApiResponse({ status: HttpStatus.OK, description: 'Returns the details of a room', type: RoomDetailsResponse }) diff --git a/apps/server/src/modules/room/api/room.uc.spec.ts b/apps/server/src/modules/room/api/room.uc.spec.ts index cafa81465eb..ab0e958ae6c 100644 --- a/apps/server/src/modules/room/api/room.uc.spec.ts +++ b/apps/server/src/modules/room/api/room.uc.spec.ts @@ -1,4 +1,6 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AuthorizationService } from '@modules/authorization'; +import { RoomMemberRepo, RoomMemberService } from '@modules/room-member'; import { UserService } from '@modules/user'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; @@ -6,8 +8,7 @@ import { FeatureDisabledLoggableException } from '@shared/common/loggable-except import { Page } from '@shared/domain/domainobject'; import { IFindOptions } from '@shared/domain/interface'; import { setupEntities, userFactory } from '@shared/testing'; -import { AuthorizationService } from '@modules/authorization'; -import { RoomMemberRepo, RoomMemberService } from '@modules/room-member'; +import { ColumnBoardService } from '@src/modules/board'; import { Room, RoomService } from '../domain'; import { RoomColor } from '../domain/type'; import { roomFactory } from '../testing'; @@ -36,6 +37,10 @@ describe('RoomUc', () => { provide: RoomMemberService, useValue: createMock(), }, + { + provide: ColumnBoardService, + useValue: createMock(), + }, { provide: AuthorizationService, useValue: createMock(), diff --git a/apps/server/src/modules/room/api/room.uc.ts b/apps/server/src/modules/room/api/room.uc.ts index 4cb6eb6e811..1691df897a9 100644 --- a/apps/server/src/modules/room/api/room.uc.ts +++ b/apps/server/src/modules/room/api/room.uc.ts @@ -7,6 +7,7 @@ import { FeatureDisabledLoggableException } from '@shared/common/loggable-except import { Page, UserDO } from '@shared/domain/domainobject'; import { IFindOptions, Permission, RoleName, RoomRole } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; +import { BoardExternalReferenceType, ColumnBoard, ColumnBoardService } from '@src/modules/board'; import { Room, RoomCreateProps, RoomService, RoomUpdateProps } from '../domain'; import { RoomConfig } from '../room.config'; import { RoomMemberResponse } from './dto/response/room-member.response'; @@ -17,6 +18,7 @@ export class RoomUc { private readonly configService: ConfigService, private readonly roomService: RoomService, private readonly roomMemberService: RoomMemberService, + private readonly columnBoardService: ColumnBoardService, private readonly userService: UserService, private readonly authorizationService: AuthorizationService ) {} @@ -53,6 +55,23 @@ export class RoomUc { return room; } + public async getRoomBoards(userId: EntityId, roomId: EntityId): Promise { + this.checkFeatureEnabled(); + + await this.roomService.getSingleRoom(roomId); + await this.checkRoomAuthorization(userId, roomId, Action.read); + + const boards = await this.columnBoardService.findByExternalReference( + { + type: BoardExternalReferenceType.Room, + id: roomId, + }, + 0 + ); + + return boards; + } + public async updateRoom(userId: EntityId, roomId: EntityId, props: RoomUpdateProps): Promise { this.checkFeatureEnabled(); const room = await this.roomService.getSingleRoom(roomId); diff --git a/apps/server/src/modules/room/api/test/room-get-boards.api.spec.ts b/apps/server/src/modules/room/api/test/room-get-boards.api.spec.ts new file mode 100644 index 00000000000..b727b66355a --- /dev/null +++ b/apps/server/src/modules/room/api/test/room-get-boards.api.spec.ts @@ -0,0 +1,173 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { Permission, RoleName } from '@shared/domain/interface'; +import { + cleanupCollections, + groupEntityFactory, + roleFactory, + TestApiClient, + UserAndAccountTestFactory, +} from '@shared/testing'; +import { BoardExternalReferenceType } from '@src/modules/board'; +import { columnBoardEntityFactory } from '@src/modules/board/testing'; +import { GroupEntityTypes } from '@src/modules/group/entity'; +import { roomMemberEntityFactory } from '@src/modules/room-member/testing'; +import { serverConfig, ServerConfig, ServerTestModule } from '@src/modules/server'; +import { roomEntityFactory } from '../../testing'; + +describe('Room Controller (API)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + let config: ServerConfig; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + em = app.get(EntityManager); + testApiClient = new TestApiClient(app, 'rooms'); + + config = serverConfig(); + }); + + beforeEach(async () => { + await cleanupCollections(em); + config.FEATURE_ROOMS_ENABLED = true; + }); + + afterAll(async () => { + await app.close(); + }); + + describe('GET /rooms/:id/boards', () => { + describe('when the user is not authenticated', () => { + it('should return a 401 error', async () => { + const someId = new ObjectId().toHexString(); + const response = await testApiClient.get(someId); + expect(response.status).toBe(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when the feature is disabled', () => { + const setup = async () => { + config.FEATURE_ROOMS_ENABLED = false; + + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient }; + }; + + it('should return a 403 error', async () => { + const { loggedInClient } = await setup(); + const someId = new ObjectId().toHexString(); + const response = await loggedInClient.get(`${someId}/boards`); + expect(response.status).toBe(HttpStatus.FORBIDDEN); + }); + }); + + describe('when id is not a valid mongo id', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient }; + }; + + it('should return a 400 error', async () => { + const { loggedInClient } = await setup(); + const response = await loggedInClient.get('42/boards'); + expect(response.status).toBe(HttpStatus.BAD_REQUEST); + }); + }); + + describe('when the user has the required permissions', () => { + const setup = async () => { + const room = roomEntityFactory.build(); + const board = columnBoardEntityFactory.build({ + context: { type: BoardExternalReferenceType.Room, id: room.id }, + }); + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + const role = roleFactory.buildWithId({ + name: RoleName.ROOM_VIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const userGroupEntity = groupEntityFactory.buildWithId({ + type: GroupEntityTypes.ROOM, + users: [{ role, user: studentUser }], + organization: studentUser.school, + externalSource: undefined, + }); + const roomMember = roomMemberEntityFactory.build({ userGroupId: userGroupEntity.id, roomId: room.id }); + await em.persistAndFlush([room, board, studentAccount, studentUser, role, userGroupEntity, roomMember]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient, room, board }; + }; + + describe('when the room exists', () => { + it('should return the room boards', async () => { + const { loggedInClient, room, board } = await setup(); + + const response = await loggedInClient.get(`${room.id}/boards`); + expect(response.status).toBe(HttpStatus.OK); + expect((response.body as { data: Record }).data[0]).toEqual({ + id: board.id, + title: board.title, + layout: board.layout, + isVisible: board.isVisible, + createdAt: board.createdAt.toISOString(), + updatedAt: board.updatedAt.toISOString(), + }); + }); + }); + + describe('when the room does not exist', () => { + it('should return a 404 error', async () => { + const { loggedInClient } = await setup(); + const someId = new ObjectId().toHexString(); + + const response = await loggedInClient.get(someId); + + expect(response.status).toBe(HttpStatus.NOT_FOUND); + }); + }); + }); + + describe('when the user has not the required permissions', () => { + const setup = async () => { + const room = roomEntityFactory.build(); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + await em.persistAndFlush([room, teacherAccount, teacherUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, room }; + }; + + describe('when the room exists', () => { + it('should return 403', async () => { + const { loggedInClient, room } = await setup(); + + const response = await loggedInClient.get(room.id); + + expect(response.status).toBe(HttpStatus.FORBIDDEN); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/room/room-api.module.ts b/apps/server/src/modules/room/room-api.module.ts index 141200a6686..7405a83afe8 100644 --- a/apps/server/src/modules/room/room-api.module.ts +++ b/apps/server/src/modules/room/room-api.module.ts @@ -1,13 +1,14 @@ import { AuthorizationModule } from '@modules/authorization'; import { Module } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; +import { BoardModule } from '../board'; import { RoomMemberModule } from '../room-member/room-member.module'; +import { UserModule } from '../user'; import { RoomController, RoomUc } from './api'; import { RoomModule } from './room.module'; -import { UserModule } from '../user'; @Module({ - imports: [RoomModule, AuthorizationModule, LoggerModule, RoomMemberModule, UserModule], + imports: [RoomModule, AuthorizationModule, LoggerModule, RoomMemberModule, BoardModule, UserModule], controllers: [RoomController], providers: [RoomUc], })