diff --git a/apps/server/src/modules/board/controller/api-test/drawing-item-check-permission.api.spec.ts b/apps/server/src/modules/board/controller/api-test/drawing-item-check-permission.api.spec.ts new file mode 100644 index 00000000000..25d05911069 --- /dev/null +++ b/apps/server/src/modules/board/controller/api-test/drawing-item-check-permission.api.spec.ts @@ -0,0 +1,133 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { ServerTestModule } from '@modules/server'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { BoardExternalReferenceType } from '@shared/domain/domainobject'; +import { + TestApiClient, + UserAndAccountTestFactory, + cardNodeFactory, + cleanupCollections, + columnBoardNodeFactory, + columnNodeFactory, + courseFactory, +} from '@shared/testing'; +import { drawingElementNodeFactory } from '@shared/testing/factory/boardnode/drawing-element-node.factory'; + +const baseRouteName = '/elements'; +describe('drawing permission check (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(); + }); + + describe('when user is a valid teacher who is part of course', () => { + const setup = async () => { + await cleanupCollections(em); + + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const course = courseFactory.build({ teachers: [teacherUser] }); + await em.persistAndFlush([teacherAccount, teacherUser, course]); + + const columnBoardNode = columnBoardNodeFactory.buildWithId({ + context: { id: course.id, type: BoardExternalReferenceType.Course }, + }); + + const columnNode = columnNodeFactory.buildWithId({ parent: columnBoardNode }); + + const cardNode = cardNodeFactory.buildWithId({ parent: columnNode }); + + const drawingItemNode = drawingElementNodeFactory.buildWithId({ parent: cardNode }); + + await em.persistAndFlush([columnBoardNode, columnNode, cardNode, drawingItemNode]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, teacherUser, columnBoardNode, columnNode, cardNode, drawingItemNode }; + }; + + it('should return status 200', async () => { + const { loggedInClient, drawingItemNode } = await setup(); + + const response = await loggedInClient.get(`${drawingItemNode.id}/permission`); + + expect(response.status).toEqual(200); + }); + }); + + describe('when only teacher is part of course', () => { + const setup = async () => { + await cleanupCollections(em); + + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + const course = courseFactory.build({ students: [teacherUser] }); + await em.persistAndFlush([teacherAccount, teacherUser, course, studentAccount, studentUser]); + + const columnBoardNode = columnBoardNodeFactory.buildWithId({ + context: { id: course.id, type: BoardExternalReferenceType.Course }, + }); + + const columnNode = columnNodeFactory.buildWithId({ parent: columnBoardNode }); + + const cardNode = cardNodeFactory.buildWithId({ parent: columnNode }); + + const drawingItemNode = drawingElementNodeFactory.buildWithId({ parent: cardNode }); + + await em.persistAndFlush([columnBoardNode, columnNode, cardNode, drawingItemNode]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient, studentUser, columnBoardNode, columnNode, cardNode, drawingItemNode }; + }; + + it('should return status 403 for student not assigned to course', async () => { + const { loggedInClient, drawingItemNode } = await setup(); + + const response = await loggedInClient.get(`${drawingItemNode.id}/permission`); + + expect(response.status).toEqual(403); + }); + }); + + describe('when asking for non-existing resource', () => { + const setup = async () => { + await cleanupCollections(em); + + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + await em.persistAndFlush([teacherAccount, teacherUser]); + + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient }; + }; + + it('should return status 404 for wrong id', async () => { + const { loggedInClient } = await setup(); + const wrongRandomId = '655b048616056135293d1e63'; + + const response = await loggedInClient.get(`${wrongRandomId}/permission`); + + expect(response.status).toEqual(404); + }); + }); +}); diff --git a/apps/server/src/modules/board/controller/element.controller.ts b/apps/server/src/modules/board/controller/element.controller.ts index 73eb9848774..2f766ea1d5b 100644 --- a/apps/server/src/modules/board/controller/element.controller.ts +++ b/apps/server/src/modules/board/controller/element.controller.ts @@ -4,6 +4,7 @@ import { Controller, Delete, ForbiddenException, + Get, HttpCode, NotFoundException, Param, @@ -141,4 +142,17 @@ export class ElementController { return response; } + + @ApiOperation({ summary: 'Check if user has read permission for any board element.' }) + @ApiResponse({ status: 200 }) + @ApiResponse({ status: 400, type: ApiValidationError }) + @ApiResponse({ status: 403, type: ForbiddenException }) + @ApiResponse({ status: 404, type: NotFoundException }) + @Get(':contentElementId/permission') + async readPermission( + @Param() urlParams: ContentElementUrlParams, + @CurrentUser() currentUser: ICurrentUser + ): Promise { + await this.elementUc.checkElementReadPermission(currentUser.userId, urlParams.contentElementId); + } } 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 cb2e7846118..f520fc6444d 100644 --- a/apps/server/src/modules/board/uc/element.uc.spec.ts +++ b/apps/server/src/modules/board/uc/element.uc.spec.ts @@ -3,9 +3,11 @@ import { Action, AuthorizationService } from '@modules/authorization'; import { HttpService } from '@nestjs/axios'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { BoardDoAuthorizable } from '@shared/domain/domainobject'; +import { BoardDoAuthorizable, BoardRoles, UserRoleEnum } from '@shared/domain/domainobject'; import { InputFormat } from '@shared/domain/types'; import { + cardFactory, + columnBoardFactory, drawingElementFactory, fileElementFactory, richTextElementFactory, @@ -79,7 +81,7 @@ describe(ElementUc.name, () => { const richTextElement = richTextElementFactory.build(); const content = { text: 'this has been updated', inputFormat: InputFormat.RICH_TEXT_CK5 }; - const elementSpy = elementService.findById.mockResolvedValue(richTextElement); + const elementSpy = elementService.findById.mockResolvedValueOnce(richTextElement); return { richTextElement, user, content, elementSpy }; }; @@ -107,7 +109,7 @@ describe(ElementUc.name, () => { const fileElement = fileElementFactory.build(); const content = { caption: 'this has been updated', alternativeText: 'this altText has been updated' }; - const elementSpy = elementService.findById.mockResolvedValue(fileElement); + const elementSpy = elementService.findById.mockResolvedValueOnce(fileElement); return { fileElement, user, content, elementSpy }; }; @@ -225,7 +227,7 @@ describe(ElementUc.name, () => { const user = userFactory.build(); const fileElement = fileElementFactory.build(); - elementService.findById.mockResolvedValue(fileElement); + elementService.findById.mockResolvedValueOnce(fileElement); return { fileElement, user }; }; @@ -246,7 +248,7 @@ describe(ElementUc.name, () => { const submissionContainer = submissionContainerElementFactory.build({ children: [fileElement] }); - elementService.findById.mockResolvedValue(submissionContainer); + elementService.findById.mockResolvedValueOnce(submissionContainer); return { submissionContainer, fileElement, user }; }; @@ -267,7 +269,7 @@ describe(ElementUc.name, () => { const submissionItem = submissionItemFactory.build({ userId: user.id }); const submissionContainer = submissionContainerElementFactory.build({ children: [submissionItem] }); - elementService.findById.mockResolvedValue(submissionContainer); + elementService.findById.mockResolvedValueOnce(submissionContainer); return { submissionContainer, submissionItem, user }; }; @@ -281,4 +283,56 @@ describe(ElementUc.name, () => { }); }); }); + + describe('checkElementReadPermission', () => { + const setup = () => { + const user = userFactory.build(); + const drawingElement = drawingElementFactory.build(); + const card = cardFactory.build({ children: [drawingElement] }); + const columnBoard = columnBoardFactory.build({ children: [card] }); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + const authorizableMock: BoardDoAuthorizable = new BoardDoAuthorizable({ + users: [{ userId: user.id, roles: [BoardRoles.EDITOR], userRoleEnum: UserRoleEnum.TEACHER }], + id: columnBoard.id, + }); + + boardDoAuthorizableService.findById.mockResolvedValueOnce(authorizableMock); + + return { drawingElement, user }; + }; + + it('should properly find the element', async () => { + const { drawingElement, user } = setup(); + elementService.findById.mockResolvedValueOnce(drawingElement); + + await uc.checkElementReadPermission(user.id, drawingElement.id); + + expect(elementService.findById).toHaveBeenCalledWith(drawingElement.id); + }); + + it('should properly check element permission and not throw', async () => { + const { drawingElement, user } = setup(); + elementService.findById.mockResolvedValueOnce(drawingElement); + + await expect(uc.checkElementReadPermission(user.id, drawingElement.id)).resolves.not.toThrow(); + }); + + it('should throw at find element by Id', async () => { + const { drawingElement, user } = setup(); + elementService.findById.mockRejectedValueOnce(new Error()); + + await expect(uc.checkElementReadPermission(user.id, drawingElement.id)).rejects.toThrow(); + }); + + it('should throw at check permission', async () => { + const { user } = setup(); + const testElementId = 'wrongTestId123'; + authorizationService.checkPermission.mockImplementationOnce(() => { + throw new Error(); + }); + + await expect(uc.checkElementReadPermission(user.id, testElementId)).rejects.toThrow(); + }); + }); }); diff --git a/apps/server/src/modules/board/uc/element.uc.ts b/apps/server/src/modules/board/uc/element.uc.ts index b9043cd71f0..a7f978a1fb3 100644 --- a/apps/server/src/modules/board/uc/element.uc.ts +++ b/apps/server/src/modules/board/uc/element.uc.ts @@ -60,6 +60,11 @@ export class ElementUc extends BaseUc { return element; } + async checkElementReadPermission(userId: EntityId, elementId: EntityId): Promise { + const element = await this.elementService.findById(elementId); + await this.checkPermission(userId, element, Action.read); + } + async createSubmissionItem( userId: EntityId, contentElementId: EntityId, diff --git a/apps/server/src/modules/tldraw/config.ts b/apps/server/src/modules/tldraw/config.ts index a892ee6c843..50219da6516 100644 --- a/apps/server/src/modules/tldraw/config.ts +++ b/apps/server/src/modules/tldraw/config.ts @@ -10,6 +10,7 @@ export interface TldrawConfig { FEATURE_TLDRAW_ENABLED: boolean; TLDRAW_PING_TIMEOUT: number; TLDRAW_GC_ENABLED: number; + API_HOST: number; } const tldrawConnectionString: string = Configuration.get('TLDRAW_DB_URL') as string; @@ -24,6 +25,7 @@ const tldrawConfig = { CONNECTION_STRING: tldrawConnectionString, TLDRAW_PING_TIMEOUT: Configuration.get('TLDRAW__PING_TIMEOUT') as number, TLDRAW_GC_ENABLED: Configuration.get('TLDRAW__GC_ENABLED') as boolean, + API_HOST: Configuration.get('API_HOST') as string, }; export const SOCKET_PORT = Configuration.get('TLDRAW__SOCKET_PORT') as number; diff --git a/apps/server/src/modules/tldraw/controller/api-test/tldraw.controller.api.spec.ts b/apps/server/src/modules/tldraw/controller/api-test/tldraw.controller.api.spec.ts new file mode 100644 index 00000000000..6d04d1d5871 --- /dev/null +++ b/apps/server/src/modules/tldraw/controller/api-test/tldraw.controller.api.spec.ts @@ -0,0 +1,69 @@ +import { INestApplication } from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { cleanupCollections, courseFactory, TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ServerTestModule } from '@modules/server'; +import { Logger } from '@src/core/logger'; +import { TldrawService } from '../../service'; +import { TldrawController } from '..'; +import { TldrawRepo } from '../../repo'; +import { tldrawEntityFactory } from '../../factory'; + +const baseRouteName = '/tldraw-document'; +describe('tldraw controller (api)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + controllers: [TldrawController], + providers: [Logger, TldrawService, TldrawRepo], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, baseRouteName); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('with valid user', () => { + const setup = async () => { + await cleanupCollections(em); + + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const course = courseFactory.build({ teachers: [teacherUser] }); + await em.persistAndFlush([teacherAccount, teacherUser, course]); + + const drawingItemData = tldrawEntityFactory.build(); + + await em.persistAndFlush([drawingItemData]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, teacherUser, drawingItemData }; + }; + + it('should return status 200 for delete', async () => { + const { loggedInClient, drawingItemData } = await setup(); + + const response = await loggedInClient.delete(`${drawingItemData.docName}`); + + expect(response.status).toEqual(204); + }); + + it('should return status 404 for delete with wrong id', async () => { + const { loggedInClient } = await setup(); + + const response = await loggedInClient.delete(`testID123`); + + expect(response.status).toEqual(404); + }); + }); +}); 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 index ade447b127c..6a0a3cc4fc3 100644 --- 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 @@ -3,14 +3,22 @@ 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 { throwError } from 'rxjs'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { HttpService } from '@nestjs/axios'; +import { AxiosError, AxiosRequestHeaders } from 'axios'; +import { WsCloseCodeEnum, WsCloseMessageEnum } from '../../types'; +import { TldrawWsTestModule } from '../../tldraw-ws-test.module'; +import { TldrawWsService } from '../../service'; import { TestConnection } from '../../testing/test-connection'; +import { TldrawWs } from '../tldraw.ws'; describe('WebSocketController (WsAdapter)', () => { let app: INestApplication; let gateway: TldrawWs; let ws: WebSocket; + let wsService: TldrawWsService; + let httpService: DeepMocked; const gatewayPort = 3346; const wsUrl = TestConnection.getWsUrl(gatewayPort); @@ -21,8 +29,16 @@ describe('WebSocketController (WsAdapter)', () => { beforeAll(async () => { const testingModule = await Test.createTestingModule({ imports: [TldrawWsTestModule], + providers: [ + { + provide: HttpService, + useValue: createMock(), + }, + ], }).compile(); gateway = testingModule.get(TldrawWs); + wsService = testingModule.get(TldrawWsService); + httpService = testingModule.get(HttpService); app = testingModule.createNestApplication(); app.useWebSocketAdapter(new WsAdapter(app)); await app.init(); @@ -40,7 +56,7 @@ describe('WebSocketController (WsAdapter)', () => { jest.clearAllMocks(); }); - describe('when tldraw is correctly setup', () => { + describe('when tldraw connection is established', () => { const setup = async () => { const handleConnectionSpy = jest.spyOn(gateway, 'handleConnection'); jest.spyOn(Uint8Array.prototype, 'reduce').mockReturnValueOnce(1); @@ -52,16 +68,17 @@ describe('WebSocketController (WsAdapter)', () => { return { handleConnectionSpy, buffer }; }; - it(`should handle connection and data transfer`, async () => { + it(`should handle connection`, async () => { const { handleConnectionSpy, buffer } = await setup(); ws.send(buffer, () => {}); expect(handleConnectionSpy).toHaveBeenCalledTimes(1); + handleConnectionSpy.mockRestore(); ws.close(); }); it(`check if client will receive message`, async () => { - const { buffer } = await setup(); + const { handleConnectionSpy, buffer } = await setup(); ws.send(buffer, () => {}); gateway.server.on('connection', (client) => { @@ -70,6 +87,7 @@ describe('WebSocketController (WsAdapter)', () => { }); }); + handleConnectionSpy.mockRestore(); ws.close(); }); }); @@ -77,8 +95,8 @@ describe('WebSocketController (WsAdapter)', () => { 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'); + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + const ws2 = await TestConnection.setupWs(wsUrl, 'TEST'); const { buffer } = getMessage(); @@ -97,32 +115,174 @@ describe('WebSocketController (WsAdapter)', () => { expect(handleConnectionSpy).toHaveBeenCalled(); expect(handleConnectionSpy).toHaveBeenCalledTimes(2); + handleConnectionSpy.mockRestore(); ws.close(); ws2.close(); }); }); - describe('when tldraw is not correctly setup', () => { - const setup = async () => { - const handleConnectionSpy = jest.spyOn(gateway, 'handleConnection'); + describe('when checking cookie', () => { + const setup = () => { + const httpGetCallSpy = jest.spyOn(httpService, 'get'); + const wsCloseSpy = jest.spyOn(WebSocket.prototype, 'close'); + + return { + httpGetCallSpy, + wsCloseSpy, + }; + }; + + it(`should refuse connection if there is no jwt in cookie`, async () => { + const { httpGetCallSpy, wsCloseSpy } = setup(); + + ws = await TestConnection.setupWs(wsUrl, 'TEST', {}); + + expect(wsCloseSpy).toHaveBeenCalledWith( + WsCloseCodeEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_CODE, + WsCloseMessageEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_MESSAGE + ); + + httpGetCallSpy.mockRestore(); + wsCloseSpy.mockRestore(); + ws.close(); + }); - ws = await TestConnection.setupWs(wsUrl); + it(`should refuse connection if jwt is wrong`, async () => { + const { wsCloseSpy, httpGetCallSpy } = setup(); + const error = new Error('unknown error'); + + httpGetCallSpy.mockReturnValueOnce(throwError(() => error)); + ws = await TestConnection.setupWs(wsUrl, 'TEST', { headers: { cookie: { jwt: 'jwt-mocked' } } }); + + expect(wsCloseSpy).toHaveBeenCalledWith( + WsCloseCodeEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_CODE, + WsCloseMessageEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_MESSAGE + ); + + httpGetCallSpy.mockRestore(); + wsCloseSpy.mockRestore(); + ws.close(); + }); + }); + + describe('when checking docName and cookie', () => { + const setup = () => { + const setupConnectionSpy = jest.spyOn(wsService, 'setupWSConnection'); + const wsCloseSpy = jest.spyOn(WebSocket.prototype, 'close'); return { - handleConnectionSpy, + setupConnectionSpy, + wsCloseSpy, }; }; - it(`should refuse connection if there is no docName`, async () => { - const { handleConnectionSpy } = await setup(); + it(`should close for existing cookie and not existing docName`, async () => { + const { setupConnectionSpy, wsCloseSpy } = setup(); + const { buffer } = getMessage(); + + ws = await TestConnection.setupWs(wsUrl, '', { headers: { cookie: { jwt: 'jwt-mocked' } } }); + ws.send(buffer); + + expect(wsCloseSpy).toHaveBeenCalledWith( + WsCloseCodeEnum.WS_CLIENT_BAD_REQUEST_CODE, + WsCloseMessageEnum.WS_CLIENT_BAD_REQUEST_MESSAGE + ); + + wsCloseSpy.mockRestore(); + setupConnectionSpy.mockRestore(); + ws.close(); + }); + it(`should close for not existing docName resource`, async () => { + const { setupConnectionSpy, wsCloseSpy } = setup(); + const authorizeConnectionSpy = jest.spyOn(wsService, 'authorizeConnection'); + authorizeConnectionSpy.mockImplementationOnce(() => { + throw new AxiosError('Resource not found', '404', undefined, undefined, { + config: { headers: {} as AxiosRequestHeaders }, + data: undefined, + request: undefined, + statusText: '', + status: 404, + headers: {}, + }); + }); + ws = await TestConnection.setupWs(wsUrl, 'GLOBAL', { headers: { cookie: { jwt: 'jwt-mocked' } } }); + + expect(wsCloseSpy).toHaveBeenCalledWith( + WsCloseCodeEnum.WS_CLIENT_NOT_FOUND_CODE, + WsCloseMessageEnum.WS_CLIENT_NOT_FOUND_MESSAGE + ); + + authorizeConnectionSpy.mockRestore(); + wsCloseSpy.mockRestore(); + setupConnectionSpy.mockRestore(); + ws.close(); + }); + + it(`should close for not authorizing connection`, async () => { + const { setupConnectionSpy, wsCloseSpy } = setup(); const { buffer } = getMessage(); + + const httpGetCallSpy = jest.spyOn(httpService, 'get'); + const error = new Error('unknown error'); + httpGetCallSpy.mockReturnValueOnce(throwError(() => error)); + + ws = await TestConnection.setupWs(wsUrl, 'TEST', { headers: { cookie: { jwt: 'jwt-mocked' } } }); ws.send(buffer); - expect(gateway.server).toBeDefined(); - expect(handleConnectionSpy).toHaveBeenCalled(); - expect(handleConnectionSpy).toHaveBeenCalledTimes(1); + expect(wsCloseSpy).toHaveBeenCalledWith( + WsCloseCodeEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_CODE, + WsCloseMessageEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_MESSAGE + ); + + wsCloseSpy.mockRestore(); + setupConnectionSpy.mockRestore(); + httpGetCallSpy.mockRestore(); + ws.close(); + }); + + it(`should setup connection for proper data`, async () => { + const { setupConnectionSpy, wsCloseSpy } = setup(); + const { buffer } = getMessage(); + + const httpGetCallSpy = jest + .spyOn(wsService, 'authorizeConnection') + .mockImplementationOnce(() => Promise.resolve()); + + ws = await TestConnection.setupWs(wsUrl, 'TEST', { headers: { cookie: { jwt: 'jwt-mocked' } } }); + ws.send(buffer); + + expect(setupConnectionSpy).toHaveBeenCalledWith(expect.anything(), 'TEST'); + + wsCloseSpy.mockRestore(); + setupConnectionSpy.mockRestore(); + httpGetCallSpy.mockRestore(); + ws.close(); + }); + + it(`should close after throw at setup connection`, async () => { + const { setupConnectionSpy, wsCloseSpy } = setup(); + const { buffer } = getMessage(); + + const httpGetCallSpy = jest + .spyOn(wsService, 'authorizeConnection') + .mockImplementationOnce(() => Promise.resolve()); + setupConnectionSpy.mockImplementationOnce(() => { + throw new Error('unknown error'); + }); + + ws = await TestConnection.setupWs(wsUrl, 'TEST', { headers: { cookie: { jwt: 'jwt-mocked' } } }); + ws.send(buffer); + + expect(setupConnectionSpy).toHaveBeenCalledWith(expect.anything(), 'TEST'); + expect(wsCloseSpy).toHaveBeenCalledWith( + WsCloseCodeEnum.WS_CLIENT_ESTABLISHING_CONNECTION_CODE, + WsCloseMessageEnum.WS_CLIENT_ESTABLISHING_CONNECTION_MESSAGE + ); + wsCloseSpy.mockRestore(); + setupConnectionSpy.mockRestore(); + httpGetCallSpy.mockRestore(); ws.close(); }); }); diff --git a/apps/server/src/modules/tldraw/controller/index.ts b/apps/server/src/modules/tldraw/controller/index.ts index 0b0cf7d103b..38a96a42a75 100644 --- a/apps/server/src/modules/tldraw/controller/index.ts +++ b/apps/server/src/modules/tldraw/controller/index.ts @@ -1 +1,2 @@ export * from './tldraw.ws'; +export * from './tldraw.controller'; diff --git a/apps/server/src/modules/tldraw/controller/tldraw.controller.spec.ts b/apps/server/src/modules/tldraw/controller/tldraw.controller.spec.ts deleted file mode 100644 index 2528fd8c4d7..00000000000 --- a/apps/server/src/modules/tldraw/controller/tldraw.controller.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -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.ws.ts b/apps/server/src/modules/tldraw/controller/tldraw.ws.ts index 343997b2aba..4aad2c404b3 100644 --- a/apps/server/src/modules/tldraw/controller/tldraw.ws.ts +++ b/apps/server/src/modules/tldraw/controller/tldraw.ws.ts @@ -1,8 +1,14 @@ import { WebSocketGateway, WebSocketServer, OnGatewayInit, OnGatewayConnection } from '@nestjs/websockets'; import { Server, WebSocket } from 'ws'; +import { Request } from 'express'; import { ConfigService } from '@nestjs/config'; +import cookie from 'cookie'; +import { BadRequestException } from '@nestjs/common'; +import { Logger } from '@src/core/logger'; +import { AxiosError } from 'axios'; +import { WebsocketCloseErrorLoggable } from '../loggable/websocket-close-error.loggable'; import { TldrawConfig, SOCKET_PORT } from '../config'; -import { WsCloseCodeEnum } from '../types'; +import { WsCloseCodeEnum, WsCloseMessageEnum } from '../types'; import { TldrawWsService } from '../service'; @WebSocketGateway(SOCKET_PORT) @@ -12,18 +18,50 @@ export class TldrawWs implements OnGatewayInit, OnGatewayConnection { constructor( private readonly configService: ConfigService, - private readonly tldrawWsService: TldrawWsService + private readonly tldrawWsService: TldrawWsService, + private readonly logger: Logger ) {} - public handleConnection(client: WebSocket, request: Request): void { + async handleConnection(client: WebSocket, request: Request): Promise { const docName = this.getDocNameFromRequest(request); - if (docName.length > 0 && this.configService.get('FEATURE_TLDRAW_ENABLED')) { - this.tldrawWsService.setupWSConnection(client, docName); + const cookies = this.parseCookiesFromHeader(request); + try { + await this.tldrawWsService.authorizeConnection(docName, cookies?.jwt); + } catch (err) { + if ((err as AxiosError).response?.status === 404 || (err as AxiosError).response?.status === 400) { + this.closeClientAndLogError( + client, + WsCloseCodeEnum.WS_CLIENT_NOT_FOUND_CODE, + WsCloseMessageEnum.WS_CLIENT_NOT_FOUND_MESSAGE, + err as Error + ); + } else { + this.closeClientAndLogError( + client, + WsCloseCodeEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_CODE, + WsCloseMessageEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_MESSAGE, + err as Error + ); + } + return; + } + try { + this.tldrawWsService.setupWSConnection(client, docName); + } catch (err) { + this.closeClientAndLogError( + client, + WsCloseCodeEnum.WS_CLIENT_ESTABLISHING_CONNECTION_CODE, + WsCloseMessageEnum.WS_CLIENT_ESTABLISHING_CONNECTION_MESSAGE, + err as Error + ); + } } else { - client.close( + this.closeClientAndLogError( + client, WsCloseCodeEnum.WS_CLIENT_BAD_REQUEST_CODE, - 'Document name is mandatory in url or Tldraw Tool is turned off.' + WsCloseMessageEnum.WS_CLIENT_BAD_REQUEST_MESSAGE, + new BadRequestException() ); } } @@ -45,4 +83,14 @@ export class TldrawWs implements OnGatewayInit, OnGatewayConnection { const urlStripped = request.url.replace(/(\/)|(tldraw-server)/g, ''); return urlStripped; } + + private parseCookiesFromHeader(request: Request): { [p: string]: string } { + const parsedCookies: { [p: string]: string } = cookie.parse(request.headers.cookie || ''); + return parsedCookies; + } + + private closeClientAndLogError(client: WebSocket, code: WsCloseCodeEnum, data: string, err: Error): void { + client.close(code, data); + this.logger.warning(new WebsocketCloseErrorLoggable(err, `(${code}) ${data}`)); + } } 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 index 78cf9ea9428..7b0b0d8c60c 100644 --- 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 @@ -51,7 +51,7 @@ describe('WsSharedDocDo', () => { describe('ydoc client awareness change handler', () => { const setup = async () => { - ws = await TestConnection.setupWs(wsUrl); + ws = await TestConnection.setupWs(wsUrl, 'TEST'); class MockAwareness { on = jest.fn(); diff --git a/apps/server/src/modules/tldraw/factory/tldraw.factory.ts b/apps/server/src/modules/tldraw/factory/tldraw.factory.ts index 3cb63e9418b..c6e80ec2329 100644 --- a/apps/server/src/modules/tldraw/factory/tldraw.factory.ts +++ b/apps/server/src/modules/tldraw/factory/tldraw.factory.ts @@ -6,6 +6,7 @@ export const tldrawEntityFactory = BaseFactory.define { return { _id: 'test-id', + id: 'test-id', docName: 'test-name', value: 'test-value', version: `test-version-${sequence}`, diff --git a/apps/server/src/modules/tldraw/loggable/websocket-close-error.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/websocket-close-error.loggable.spec.ts new file mode 100644 index 00000000000..ba0b21c9714 --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/websocket-close-error.loggable.spec.ts @@ -0,0 +1,22 @@ +import { WebsocketCloseErrorLoggable } from './websocket-close-error.loggable'; + +describe('WebsocketCloseErrorLoggable', () => { + describe('getLogMessage', () => { + const setup = () => { + const error = new Error('test'); + const errorMessage = 'message'; + + const loggable = new WebsocketCloseErrorLoggable(error, errorMessage); + + return { loggable, error, errorMessage }; + }; + + it('should return a loggable message', () => { + const { loggable, error, errorMessage } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ message: errorMessage, error, type: 'WEBSOCKET_CLOSE_ERROR' }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/loggable/websocket-close-error.loggable.ts b/apps/server/src/modules/tldraw/loggable/websocket-close-error.loggable.ts new file mode 100644 index 00000000000..6da84c3699f --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/websocket-close-error.loggable.ts @@ -0,0 +1,13 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class WebsocketCloseErrorLoggable implements Loggable { + constructor(private readonly error: Error, private readonly message: string) {} + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: this.message, + type: 'WEBSOCKET_CLOSE_ERROR', + error: this.error, + }; + } +} diff --git a/apps/server/src/modules/tldraw/repo/index.ts b/apps/server/src/modules/tldraw/repo/index.ts index 0c1ae29e62f..3cc9ad02bf7 100644 --- a/apps/server/src/modules/tldraw/repo/index.ts +++ b/apps/server/src/modules/tldraw/repo/index.ts @@ -1 +1,2 @@ export * from './tldraw-board.repo'; +export * from './tldraw.repo'; diff --git a/apps/server/src/modules/tldraw/repo/tldraw-board.repo.ts b/apps/server/src/modules/tldraw/repo/tldraw-board.repo.ts index ce3a124f7f0..8f3b3187158 100644 --- a/apps/server/src/modules/tldraw/repo/tldraw-board.repo.ts +++ b/apps/server/src/modules/tldraw/repo/tldraw-board.repo.ts @@ -26,9 +26,9 @@ export class TldrawBoardRepo { // 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, + collectionName: 'drawings', + flushSize: 400, + multipleCollections: false, }); } diff --git a/apps/server/src/modules/tldraw/repo/tldraw.repo.spec.ts b/apps/server/src/modules/tldraw/repo/tldraw.repo.spec.ts index 9e6f5eabb14..5c075669431 100644 --- a/apps/server/src/modules/tldraw/repo/tldraw.repo.spec.ts +++ b/apps/server/src/modules/tldraw/repo/tldraw.repo.spec.ts @@ -1,9 +1,10 @@ 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 { NotFoundException } from '@nestjs/common'; +import { tldrawEntityFactory } from '../factory'; +import { TldrawDrawing } from '../entities'; import { TldrawRepo } from './tldraw.repo'; describe(TldrawRepo.name, () => { @@ -68,9 +69,8 @@ describe(TldrawRepo.name, () => { 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); + it('should throw NotFoundException for wrong docName', async () => { + await expect(repo.findByDocName('invalid-name')).rejects.toThrow(NotFoundException); }); }); }); @@ -84,8 +84,8 @@ describe(TldrawRepo.name, () => { const results = await repo.findByDocName(drawing.docName); await repo.delete(results); - const emptyResults = await repo.findByDocName(drawing.docName); - expect(emptyResults.length).toEqual(0); + expect(results.length).not.toEqual(0); + await expect(repo.findByDocName(drawing.docName)).rejects.toThrow(NotFoundException); }); }); }); diff --git a/apps/server/src/modules/tldraw/repo/tldraw.repo.ts b/apps/server/src/modules/tldraw/repo/tldraw.repo.ts index d826b2876ff..d8eb4330bd2 100644 --- a/apps/server/src/modules/tldraw/repo/tldraw.repo.ts +++ b/apps/server/src/modules/tldraw/repo/tldraw.repo.ts @@ -1,5 +1,5 @@ import { EntityManager } from '@mikro-orm/mongodb'; -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { TldrawDrawing } from '../entities'; @Injectable() @@ -11,7 +11,11 @@ export class TldrawRepo { } async findByDocName(docName: string): Promise { - return this._em.find(TldrawDrawing, { docName }); + const domainObject = await this._em.find(TldrawDrawing, { docName }); + if (domainObject.length === 0) { + throw new NotFoundException(`There is no '${docName}' for this docName`); + } + return domainObject; } async delete(entity: TldrawDrawing | TldrawDrawing[]): Promise { diff --git a/apps/server/src/modules/tldraw/service/index.ts b/apps/server/src/modules/tldraw/service/index.ts index a056b2ece10..2bc9f981432 100644 --- a/apps/server/src/modules/tldraw/service/index.ts +++ b/apps/server/src/modules/tldraw/service/index.ts @@ -1 +1,2 @@ export * from './tldraw.ws.service'; +export * from './tldraw.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 index cc3a317ec3c..546ab739bb0 100644 --- a/apps/server/src/modules/tldraw/service/tldraw.service.spec.ts +++ b/apps/server/src/modules/tldraw/service/tldraw.service.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { EntityManager } from '@mikro-orm/mongodb'; import { MongoMemoryDatabaseModule } from '@infra/database'; import { cleanupCollections } from '@shared/testing'; +import { NotFoundException } from '@nestjs/common'; import { TldrawDrawing } from '../entities'; import { tldrawEntityFactory } from '../factory'; import { TldrawRepo } from '../repo/tldraw.repo'; @@ -44,9 +45,12 @@ describe(TldrawService.name, () => { expect(result.length).toEqual(1); await service.deleteByDocName(drawing.docName); - const emptyResult = await repo.findByDocName(drawing.docName); - expect(emptyResult.length).toEqual(0); + await expect(repo.findByDocName(drawing.docName)).rejects.toThrow(NotFoundException); + }); + + it('should throw when cannot find drawing', async () => { + await expect(service.deleteByDocName('nonExistingName')).rejects.toThrow(NotFoundException); }); }); }); 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 index 1199bf217cc..04ac871d428 100644 --- a/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts +++ b/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts @@ -10,6 +10,11 @@ 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 { HttpService } from '@nestjs/axios'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { of, throwError } from 'rxjs'; +import { AxiosResponse } from 'axios'; +import { axiosResponseFactory } from '@shared/testing'; import { MetricsService } from '@modules/tldraw/metrics'; import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; import { config } from '../config'; @@ -37,6 +42,7 @@ describe('TldrawWSService', () => { let app: INestApplication; let ws: WebSocket; let service: TldrawWsService; + let httpService: DeepMocked; const gatewayPort = 3346; const wsUrl = TestConnection.getWsUrl(gatewayPort); @@ -52,10 +58,20 @@ describe('TldrawWSService', () => { const imports = [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))]; const testingModule = await Test.createTestingModule({ imports, - providers: [TldrawWs, TldrawBoardRepo, TldrawWsService, MetricsService], + providers: [ + TldrawWs, + TldrawBoardRepo, + TldrawWsService, + MetricsService, + { + provide: HttpService, + useValue: createMock(), + }, + ], }).compile(); service = testingModule.get(TldrawWsService); + httpService = testingModule.get(HttpService); app = testingModule.createNestApplication(); app.useWebSocketAdapter(new WsAdapter(app)); jest.useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }); @@ -88,7 +104,7 @@ describe('TldrawWSService', () => { describe('send', () => { describe('when client is not connected to WS', () => { const setup = async () => { - ws = await TestConnection.setupWs(wsUrl); + ws = await TestConnection.setupWs(wsUrl, 'TEST'); const clientMessageMock = 'test-message'; const closeConSpy = jest.spyOn(service, 'closeConn').mockImplementationOnce(() => {}); @@ -152,7 +168,7 @@ describe('TldrawWSService', () => { describe('when websocket has ready state 0', () => { const setup = async () => { - ws = await TestConnection.setupWs(wsUrl); + ws = await TestConnection.setupWs(wsUrl, 'TEST'); const clientMessageMock = 'test-message'; const sendSpy = jest.spyOn(service, 'send'); @@ -447,4 +463,54 @@ describe('TldrawWSService', () => { flushDocumentSpy.mockRestore(); }); }); + + describe('authorizeConnection', () => { + it('should call properly method', async () => { + const params = { drawingName: 'drawingName', token: 'token' }; + const response: AxiosResponse = axiosResponseFactory.build({ + status: 200, + }); + + httpService.get.mockReturnValueOnce(of(response)); + + await expect(service.authorizeConnection(params.drawingName, params.token)).resolves.not.toThrow(); + httpService.get.mockRestore(); + }); + + it('should properly setup REST GET call params', async () => { + const params = { drawingName: 'drawingName', token: 'token' }; + const response: AxiosResponse = axiosResponseFactory.build({ + status: 200, + }); + const expectedUrl = 'http://localhost:3030/api/v3/elements/drawingName/permission'; + const expectedHeaders = { + headers: { + Accept: 'Application/json', + Authorization: `Bearer ${params.token}`, + }, + }; + httpService.get.mockReturnValueOnce(of(response)); + + await service.authorizeConnection(params.drawingName, params.token); + + expect(httpService.get).toHaveBeenCalledWith(expectedUrl, expectedHeaders); + httpService.get.mockRestore(); + }); + + it('should throw error for http response', async () => { + const params = { drawingName: 'drawingName', token: 'token' }; + const error = new Error('unknown error'); + httpService.get.mockReturnValueOnce(throwError(() => error)); + + await expect(service.authorizeConnection(params.drawingName, params.token)).rejects.toThrow(); + httpService.get.mockRestore(); + }); + + it('should throw error for lack of token', async () => { + const params = { drawingName: 'drawingName', token: 'token' }; + + await expect(service.authorizeConnection(params.drawingName, '')).rejects.toThrow(); + httpService.get.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 index ff455454d8b..f1ef8744c44 100644 --- a/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts +++ b/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts @@ -1,9 +1,11 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } 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 { firstValueFrom } from 'rxjs'; +import { HttpService } from '@nestjs/axios'; import { Persitence, WSConnectionState, WSMessageType } from '../types'; import { TldrawConfig } from '../config'; import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; @@ -21,6 +23,7 @@ export class TldrawWsService { constructor( private readonly configService: ConfigService, private readonly tldrawBoardRepo: TldrawBoardRepo, + private readonly httpService: HttpService, private readonly metricsService: MetricsService ) { this.pingTimeout = this.configService.get('TLDRAW_PING_TIMEOUT'); @@ -212,4 +215,20 @@ export class TldrawWsService { public async flushDocument(docName: string): Promise { await this.tldrawBoardRepo.flushDocument(docName); } + + public async authorizeConnection(drawingName: string, token: string) { + if (!token) { + throw new UnauthorizedException('Token was not given'); + } + const headers = { + Accept: 'Application/json', + Authorization: `Bearer ${token}`, + }; + + await firstValueFrom( + this.httpService.get(`${this.configService.get('API_HOST')}/v3/elements/${drawingName}/permission`, { + headers, + }) + ); + } } diff --git a/apps/server/src/modules/tldraw/testing/test-connection.ts b/apps/server/src/modules/tldraw/testing/test-connection.ts index 638c219ea18..4231acbb286 100644 --- a/apps/server/src/modules/tldraw/testing/test-connection.ts +++ b/apps/server/src/modules/tldraw/testing/test-connection.ts @@ -6,12 +6,12 @@ export class TestConnection { return wsUrl; }; - public static setupWs = async (wsUrl: string, docName?: string): Promise => { + public static setupWs = async (wsUrl: string, docName?: string, headers?: object): Promise => { let ws: WebSocket; if (docName) { - ws = new WebSocket(`${wsUrl}/${docName}`); + ws = new WebSocket(`${wsUrl}/${docName}`, headers); } else { - ws = new WebSocket(`${wsUrl}`); + ws = new WebSocket(`${wsUrl}`, headers); } await new Promise((resolve) => { ws.on('open', resolve); diff --git a/apps/server/src/modules/tldraw/tldraw-test.module.ts b/apps/server/src/modules/tldraw/tldraw-test.module.ts index 3e3cd60396e..59c8af72f74 100644 --- a/apps/server/src/modules/tldraw/tldraw-test.module.ts +++ b/apps/server/src/modules/tldraw/tldraw-test.module.ts @@ -1,27 +1,25 @@ 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/entity'; -import { MetricsService } from '@modules/tldraw/metrics'; -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'; +import { Logger, LoggerModule } from '@src/core/logger'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; +import { RedisModule } from '@infra/redis'; +import { defaultMikroOrmOptions } from '@modules/server'; +import { HttpModule } from '@nestjs/axios'; +import { MetricsService } from './metrics'; +import { config } from './config'; +import { TldrawController } from './controller/tldraw.controller'; +import { TldrawService } from './service/tldraw.service'; +import { TldrawRepo } from './repo/tldraw.repo'; const imports = [ - TldrawWsModule, - MongoMemoryDatabaseModule.forRoot({ entities: [User, Course] }), - AuthenticationApiModule, - AuthorizationModule, - AuthenticationModule, - CoreModule, + MongoMemoryDatabaseModule.forRoot({ ...defaultMikroOrmOptions }), LoggerModule, + ConfigModule.forRoot(createConfigModuleOptions(config)), + RedisModule, + HttpModule, ]; -const providers = [TldrawWs, TldrawBoardRepo, TldrawWsService, MetricsService]; +const providers = [Logger, TldrawService, TldrawRepo, MetricsService]; @Module({ imports, providers, @@ -30,7 +28,8 @@ export class TldrawTestModule { static forRoot(options?: MongoDatabaseModuleOptions): DynamicModule { return { module: TldrawTestModule, - imports: [...imports, MongoMemoryDatabaseModule.forRoot({ ...options })], + imports: [...imports, MongoMemoryDatabaseModule.forRoot({ ...defaultMikroOrmOptions, ...options })], + controllers: [TldrawController], 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 index 815f09cbccd..7a80aac20de 100644 --- a/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts +++ b/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts @@ -3,13 +3,14 @@ import { MongoMemoryDatabaseModule, MongoDatabaseModuleOptions } from '@infra/da import { CoreModule } from '@src/core'; import { ConfigModule } from '@nestjs/config'; import { createConfigModuleOptions } from '@src/config'; -import { MetricsService } from '@modules/tldraw/metrics'; +import { HttpModule } from '@nestjs/axios'; +import { MetricsService } from './metrics'; import { TldrawBoardRepo } from './repo'; import { TldrawWsService } from './service'; import { config } from './config'; import { TldrawWs } from './controller'; -const imports = [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))]; +const imports = [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config)), HttpModule]; const providers = [TldrawWs, TldrawBoardRepo, TldrawWsService, MetricsService]; @Module({ imports, diff --git a/apps/server/src/modules/tldraw/tldraw-ws.module.ts b/apps/server/src/modules/tldraw/tldraw-ws.module.ts index 183c579296f..8ed614a510e 100644 --- a/apps/server/src/modules/tldraw/tldraw-ws.module.ts +++ b/apps/server/src/modules/tldraw/tldraw-ws.module.ts @@ -3,14 +3,15 @@ import { ConfigModule } from '@nestjs/config'; import { createConfigModuleOptions } from '@src/config'; import { CoreModule } from '@src/core'; import { Logger } from '@src/core/logger'; -import { MetricsService } from '@modules/tldraw/metrics'; +import { HttpModule } from '@nestjs/axios'; +import { MetricsService } from './metrics'; import { TldrawBoardRepo } from './repo'; import { TldrawWsService } from './service'; import { TldrawWs } from './controller'; import { config } from './config'; @Module({ - imports: [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))], + imports: [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config)), HttpModule], providers: [Logger, TldrawWs, TldrawWsService, TldrawBoardRepo, MetricsService], }) export class TldrawWsModule {} diff --git a/apps/server/src/modules/tldraw/types/index.ts b/apps/server/src/modules/tldraw/types/index.ts index 0579e4b8c79..957e55aab3f 100644 --- a/apps/server/src/modules/tldraw/types/index.ts +++ b/apps/server/src/modules/tldraw/types/index.ts @@ -1,3 +1,3 @@ export * from './connection-enum'; -export * from './ws-close-code-enum'; +export * from './ws-close-enum'; export * from './persistence-type'; 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 deleted file mode 100644 index 274fa99a6ae..00000000000 --- a/apps/server/src/modules/tldraw/types/ws-close-code-enum.ts +++ /dev/null @@ -1,3 +0,0 @@ -export enum WsCloseCodeEnum { - WS_CLIENT_BAD_REQUEST_CODE = 4400, -} diff --git a/apps/server/src/modules/tldraw/types/ws-close-enum.ts b/apps/server/src/modules/tldraw/types/ws-close-enum.ts new file mode 100644 index 00000000000..0cbf8021e84 --- /dev/null +++ b/apps/server/src/modules/tldraw/types/ws-close-enum.ts @@ -0,0 +1,12 @@ +export enum WsCloseCodeEnum { + WS_CLIENT_BAD_REQUEST_CODE = 4400, + WS_CLIENT_UNAUTHORISED_CONNECTION_CODE = 4401, + WS_CLIENT_NOT_FOUND_CODE = 4404, + WS_CLIENT_ESTABLISHING_CONNECTION_CODE = 4500, +} +export enum WsCloseMessageEnum { + WS_CLIENT_BAD_REQUEST_MESSAGE = 'Document name is mandatory in url or Tldraw Tool is turned off.', + WS_CLIENT_UNAUTHORISED_CONNECTION_MESSAGE = "Unauthorised connection - you don't have permission to this drawing.", + WS_CLIENT_NOT_FOUND_MESSAGE = 'Drawing not found.', + WS_CLIENT_ESTABLISHING_CONNECTION_MESSAGE = 'Unable to establish websocket connection. Try again later.', +} diff --git a/apps/server/src/shared/domain/entity/all-entities.ts b/apps/server/src/shared/domain/entity/all-entities.ts index 94d38d08346..7163ffb9f12 100644 --- a/apps/server/src/shared/domain/entity/all-entities.ts +++ b/apps/server/src/shared/domain/entity/all-entities.ts @@ -9,6 +9,7 @@ import { ExternalToolEntity } from '@modules/tool/external-tool/entity'; import { SchoolExternalToolEntity } from '@modules/tool/school-external-tool/entity'; import { DeletionLogEntity, DeletionRequestEntity } from '@src/modules/deletion/entity'; import { RocketChatUserEntity } from '@src/modules/rocketchat-user/entity'; +import { TldrawDrawing } from '@modules/tldraw/entities'; import { Account } from './account.entity'; import { BoardNode, @@ -112,4 +113,5 @@ export const ALL_ENTITIES = [ VideoConference, GroupEntity, RegistrationPinEntity, + TldrawDrawing, ]; diff --git a/config/default.schema.json b/config/default.schema.json index 9774124f911..c11a49968ba 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -534,6 +534,7 @@ "API_HOST": { "type": "string", "format": "uri", + "default": "http://localhost:3030/api", "pattern": ".*(?