diff --git a/nestjs-BE/server/package-lock.json b/nestjs-BE/server/package-lock.json index eacf71a3..65779b59 100644 --- a/nestjs-BE/server/package-lock.json +++ b/nestjs-BE/server/package-lock.json @@ -51,6 +51,7 @@ "jest": "^29.5.0", "prettier": "^3.0.0", "prisma": "^5.6.0", + "socket.io-client": "^4.8.1", "source-map-support": "^0.5.21", "supertest": "^6.3.3", "ts-jest": "^29.1.0", @@ -5926,6 +5927,20 @@ "node": ">=10.2.0" } }, + "node_modules/engine.io-client": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", + "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, "node_modules/engine.io-parser": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.1.tgz", @@ -9901,6 +9916,22 @@ "ws": "~8.17.1" } }, + "node_modules/socket.io-client": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", + "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/socket.io-parser": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", @@ -11009,6 +11040,15 @@ } } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/nestjs-BE/server/package.json b/nestjs-BE/server/package.json index 53e99d80..f35f96cb 100644 --- a/nestjs-BE/server/package.json +++ b/nestjs-BE/server/package.json @@ -62,6 +62,7 @@ "jest": "^29.5.0", "prettier": "^3.0.0", "prisma": "^5.6.0", + "socket.io-client": "^4.8.1", "source-map-support": "^0.5.21", "supertest": "^6.3.3", "ts-jest": "^29.1.0", diff --git a/nestjs-BE/server/src/board-trees/board-trees.gateway.ts b/nestjs-BE/server/src/board-trees/board-trees.gateway.ts index 6d8a896a..72eabc92 100644 --- a/nestjs-BE/server/src/board-trees/board-trees.gateway.ts +++ b/nestjs-BE/server/src/board-trees/board-trees.gateway.ts @@ -1,78 +1,67 @@ -import { UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { OnGatewayConnection, + OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer, + WsException, } from '@nestjs/websockets'; +import { ConfigService } from '@nestjs/config'; import { Server, Socket } from 'socket.io'; import { BoardTreesService } from './board-trees.service'; -import { - OperationAdd, - OperationDelete, - OperationMove, - OperationUpdate, -} from '../crdt/operation'; +import type { BoardOperation } from './schemas/board-operation.schema'; @WebSocketGateway({ namespace: 'board' }) -export class BoardTreesGateway implements OnGatewayConnection { +export class BoardTreesGateway implements OnGatewayInit, OnGatewayConnection { constructor( private boardTreesService: BoardTreesService, private jwtService: JwtService, + private configService: ConfigService, ) {} @WebSocketServer() server: Server; - handleConnection(client: Socket, token: string) { - if (!token) { - client.disconnect(); - throw new UnauthorizedException(); - } - try { - this.jwtService.verify(token); - } catch (error) { - client.disconnect(); - throw new UnauthorizedException(); - } + afterInit(server: Server) { + server.use((socket, next) => { + const token = socket.handshake.auth.token; + if (!token) { + next(new WsException('access token required')); + } + try { + this.jwtService.verify(token, { + secret: this.configService.get('JWT_ACCESS_SECRET'), + }); + next(); + } catch (error) { + next(new WsException('token is invalid')); + } + }); } - @SubscribeMessage('joinBoard') - async handleJoinBoard(client: Socket, payload: string) { - const payloadObject = JSON.parse(payload); - if (!this.boardTreesService.hasTree(payloadObject.boardId)) { - await this.boardTreesService.initBoardTree( - payloadObject.boardId, - payloadObject.boardName, - ); + handleConnection(client: Socket) { + const query = client.handshake.query; + const boardId = query.boardId; + + if (!boardId) { + client.emit('board_id_required', new WsException('board id required')); + client.disconnect(); } - client.join(payloadObject.boardId); - client.emit( - 'initTree', - this.boardTreesService.getTreeData(payloadObject.boardId), - ); + client.join(boardId); + client.emit('board_joined', boardId); } - @SubscribeMessage('updateMindmap') - handleUpdateMindmap(client: Socket, payload: string) { - const payloadObject = JSON.parse(payload); - const { boardId, operation: serializedOperation } = payloadObject; - - const operationTypeMap = { - add: OperationAdd.parse, - delete: OperationDelete.parse, - move: OperationMove.parse, - update: OperationUpdate.parse, - }; - - const operation = - operationTypeMap[serializedOperation.operationType](serializedOperation); - this.boardTreesService.applyOperation(boardId, operation); - this.boardTreesService.updateTreeData(boardId); + @SubscribeMessage('createOperation') + async handleCreateOperation(client: Socket, operation: BoardOperation) { + await this.boardTreesService.createOperationLog(operation); + client.broadcast.to(operation.boardId).emit('operation', operation); + client.emit('operationCreated'); + } - client.broadcast - .to(boardId) - .emit('operationFromServer', serializedOperation); + @SubscribeMessage('getOperations') + async handleGetOperations(client: Socket, boardId: string) { + const operations = await this.boardTreesService.getOperationLogs(boardId); + client.emit('getOperations', operations); } } diff --git a/nestjs-BE/server/src/board-trees/board-trees.module.ts b/nestjs-BE/server/src/board-trees/board-trees.module.ts index 211084d2..d7e47a91 100644 --- a/nestjs-BE/server/src/board-trees/board-trees.module.ts +++ b/nestjs-BE/server/src/board-trees/board-trees.module.ts @@ -4,11 +4,16 @@ import { MongooseModule } from '@nestjs/mongoose'; import { BoardTreesService } from './board-trees.service'; import { BoardTreesGateway } from './board-trees.gateway'; import { BoardTree, BoardTreeSchema } from './schemas/board-tree.schema'; +import { + BoardOperation, + BoardOperationSchema, +} from './schemas/board-operation.schema'; @Module({ imports: [ MongooseModule.forFeature([ { name: BoardTree.name, schema: BoardTreeSchema }, + { name: BoardOperation.name, schema: BoardOperationSchema }, ]), JwtModule, ], diff --git a/nestjs-BE/server/src/board-trees/board-trees.service.ts b/nestjs-BE/server/src/board-trees/board-trees.service.ts index d8f39d1e..1fe2c693 100644 --- a/nestjs-BE/server/src/board-trees/board-trees.service.ts +++ b/nestjs-BE/server/src/board-trees/board-trees.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; import { BoardTree } from './schemas/board-tree.schema'; +import { BoardOperation } from './schemas/board-operation.schema'; import { CrdtTree } from '../crdt/crdt-tree'; import { Operation } from '../crdt/operation'; @@ -9,6 +10,8 @@ import { Operation } from '../crdt/operation'; export class BoardTreesService { constructor( @InjectModel(BoardTree.name) private boardTreeModel: Model, + @InjectModel(BoardOperation.name) + private boardOperationModel: Model, ) {} private boardTrees = new Map(); @@ -57,4 +60,15 @@ export class BoardTreesService { .updateOne({ boardId }, { tree: JSON.stringify(tree) }) .exec(); } + + async createOperationLog(operation: BoardOperation) { + return this.boardOperationModel.create(operation); + } + + async getOperationLogs(boardId: string) { + return this.boardOperationModel + .find({ boardId }) + .select('-_id -__v') + .lean(); + } } diff --git a/nestjs-BE/server/src/board-trees/schemas/board-operation.schema.ts b/nestjs-BE/server/src/board-trees/schemas/board-operation.schema.ts new file mode 100644 index 00000000..5261a03b --- /dev/null +++ b/nestjs-BE/server/src/board-trees/schemas/board-operation.schema.ts @@ -0,0 +1,28 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { HydratedDocument } from 'mongoose'; + +export type BoardOperationDocument = HydratedDocument; + +@Schema() +export class BoardOperation { + @Prop({ required: true }) + boardId: string; + + @Prop({ required: true }) + type: string; + + @Prop() + parentId: string; + + @Prop() + oldParentId: string; + + @Prop() + content: string; + + @Prop() + oldContent: string; +} + +export const BoardOperationSchema = + SchemaFactory.createForClass(BoardOperation); diff --git a/nestjs-BE/server/test/board-trees.e2e-spec.ts b/nestjs-BE/server/test/board-trees.e2e-spec.ts new file mode 100644 index 00000000..8e7b89b9 --- /dev/null +++ b/nestjs-BE/server/test/board-trees.e2e-spec.ts @@ -0,0 +1,278 @@ +import { INestApplication } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { MongooseModule } from '@nestjs/mongoose'; +import { sign } from 'jsonwebtoken'; +import { io, Socket } from 'socket.io-client'; +import { v4 as uuid } from 'uuid'; +import { BoardTreesModule } from '../src/board-trees/board-trees.module'; +import { BoardTreesService } from '../src/board-trees/board-trees.service'; +import { PrismaModule } from '../src/prisma/prisma.module'; +import { PrismaService } from '../src/prisma/prisma.service'; + +import type { ManagerOptions, SocketOptions } from 'socket.io-client'; +import type { BoardOperation } from '../src/board-trees/schemas/board-operation.schema'; + +const PORT = 3000; + +describe('BoardTreesGateway (e2e)', () => { + const serverUrl = `ws://localhost:${PORT}/board`; + let app: INestApplication; + let prisma: PrismaService; + let config: ConfigService; + let boardTreesService: BoardTreesService; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + MongooseModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: async (configService: ConfigService) => ({ + uri: configService.get('MONGODB_DATABASE_URI'), + }), + }), + BoardTreesModule, + PrismaModule, + ], + }).compile(); + + app = module.createNestApplication(); + + await app.init(); + await app.listen(PORT); + + prisma = module.get(PrismaService); + config = module.get(ConfigService); + boardTreesService = module.get(BoardTreesService); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('socket connection authentication', () => { + it('fail when access token is not included', (done) => { + const socket = io(serverUrl); + + socket.on('connect_error', (error) => { + expect(error.message).toBe('access token required'); + done(); + }); + }); + + it('fail when access token is invalid', async () => { + const testUser = await prisma.user.create({ data: { uuid: uuid() } }); + const testToken = sign( + { sub: testUser.uuid }, + config.get('JWT_ACCESS_SECRET'), + { expiresIn: '-5m' }, + ); + + const error = new Promise((resolve, reject) => { + const socket = io(serverUrl, { auth: { token: testToken } }); + socket.on('connect_error', (error) => { + reject(error); + }); + }); + + await expect(error).rejects.toHaveProperty('message', 'token is invalid'); + }); + + it('success', async () => { + const testToken = await createUserToken(prisma, config); + + const connected = await new Promise((resolve) => { + const socket = io(serverUrl, { auth: { token: testToken } }); + socket.on('connect', () => { + const connected = socket.connected; + socket.disconnect(); + resolve(connected); + }); + }); + + expect(connected).toBeTruthy(); + }); + }); + + describe('join board on connection', () => { + let testToken: string; + + beforeEach(async () => { + testToken = await createUserToken(prisma, config); + }); + + it('board_id_required error when board id not included', async () => { + const error = new Promise((resolve, reject) => { + const socket = io(serverUrl, { + auth: { token: testToken }, + }); + socket.on('board_id_required', (error) => { + reject(error); + }); + }); + + await expect(error).rejects.toHaveProperty( + 'message', + 'board id required', + ); + }); + + it('join board', async () => { + const boardId = 'board id'; + + const response = await new Promise((resolve) => { + const socket = io(serverUrl, { + auth: { token: testToken }, + query: { boardId }, + }); + socket.on('board_joined', (boardId) => { + socket.disconnect(); + resolve(boardId); + }); + }); + + expect(response).toBe(boardId); + }); + }); + + describe('createOperation', () => { + const boardId = 'board id'; + let testToken: string; + let client: Socket; + + beforeEach(async () => { + testToken = await createUserToken(prisma, config); + client = await createClientSocket(serverUrl, { + auth: { token: testToken }, + query: { boardId }, + }); + }); + + afterEach(() => { + if (client.connected) { + client.disconnect(); + } + }); + + it('create operation', async () => { + const testOperation = { + boardId: uuid(), + type: 'add', + parentId: 'root', + content: 'new node', + }; + + await new Promise((resolve) => { + client.on('operationCreated', () => { + resolve(null); + }); + + client.emit('createOperation', testOperation); + }); + + const operations = await boardTreesService.getOperationLogs( + testOperation.boardId, + ); + expect(operations).toContainEqual(testOperation); + }); + + it('other client received operation', async () => { + const otherToken = await createUserToken(prisma, config); + const otherClient = await createClientSocket(serverUrl, { + auth: { token: otherToken }, + query: { boardId }, + }); + + const testOperation = { + boardId, + type: 'add', + parentId: 'root', + content: 'new node', + }; + + const response = await new Promise((resolve) => { + otherClient.on('operation', (operation) => { + otherClient.disconnect(); + resolve(operation); + }); + + client.emit('createOperation', testOperation); + }); + + expect(response).toEqual(testOperation); + }); + }); + + describe('getOperations', () => { + const boardId = uuid(); + let testToken: string; + let testOperations: BoardOperation[]; + let client: Socket; + + beforeEach(async () => { + testToken = await createUserToken(prisma, config); + client = await createClientSocket(serverUrl, { + auth: { token: testToken }, + query: { boardId }, + }); + + testOperations = Array.from({ length: 5 }, () => { + return { + boardId, + type: 'add', + parentId: 'root', + content: 'new node', + } as BoardOperation; + }); + await Promise.all( + testOperations.map((operation) => + boardTreesService.createOperationLog(operation as BoardOperation), + ), + ); + }); + + afterEach(() => { + if (client.connected) { + client.disconnect(); + } + }); + + it('get operation logs', async () => { + const response = await new Promise((resolve) => { + client.on('getOperations', (operationLogs) => { + resolve(operationLogs); + }); + + client.emit('getOperations', boardId); + }); + + expect(response).toEqual(expect.arrayContaining(testOperations)); + }); + }); +}); + +async function createUserToken(prisma: PrismaService, config: ConfigService) { + const user = await prisma.user.create({ data: { uuid: uuid() } }); + const token = sign( + { sub: user.uuid }, + config.get('JWT_ACCESS_SECRET'), + { expiresIn: '5m' }, + ); + return token; +} + +async function createClientSocket( + uri: string, + opts: Partial, +) { + let client: Socket; + await new Promise((resolve) => { + client = io(uri, opts); + client.on('board_joined', () => { + resolve(null); + }); + }); + return client; +}