diff --git a/apps/server/src/infra/etherpad-client/etherpad-client.adapter.spec.ts b/apps/server/src/infra/etherpad-client/etherpad-client.adapter.spec.ts index 4e1edb628f5..31d9c25ca02 100644 --- a/apps/server/src/infra/etherpad-client/etherpad-client.adapter.spec.ts +++ b/apps/server/src/infra/etherpad-client/etherpad-client.adapter.spec.ts @@ -61,8 +61,9 @@ describe(EtherpadClientAdapter.name, () => { await module.close(); }); - afterEach(() => { + beforeEach(() => { jest.resetAllMocks(); + jest.useFakeTimers(); }); it('should be defined', () => { @@ -85,20 +86,40 @@ describe(EtherpadClientAdapter.name, () => { return { userId, username }; }; - it('should return author id', async () => { - const { userId, username } = setup(); + describe('When user name parameter is set', () => { + it('should return author id', async () => { + const { userId, username } = setup(); + + const result = await service.getOrCreateAuthorId(userId, username); + + expect(result).toBe('authorId'); + }); + + it('should call createAuthorIfNotExistsForUsingGET with correct params', async () => { + const { userId, username } = setup(); - const result = await service.getOrCreateAuthorId(userId, username); + await service.getOrCreateAuthorId(userId, username); - expect(result).toBe('authorId'); + expect(authorApi.createAuthorIfNotExistsForUsingGET).toBeCalledWith(userId, username); + }); }); - it('should call createAuthorIfNotExistsForUsingGET with correct params', async () => { - const { userId, username } = setup(); + describe('When user name parameter is not set', () => { + it('should return author id', async () => { + const { userId } = setup(); + + const result = await service.getOrCreateAuthorId(userId); + + expect(result).toBe('authorId'); + }); + + it('should call createAuthorIfNotExistsForUsingGET with correct params', async () => { + const { userId } = setup(); - await service.getOrCreateAuthorId(userId, username); + await service.getOrCreateAuthorId(userId); - expect(authorApi.createAuthorIfNotExistsForUsingGET).toBeCalledWith(userId, username); + expect(authorApi.createAuthorIfNotExistsForUsingGET).toBeCalledWith(userId, undefined); + }); }); }); @@ -146,6 +167,127 @@ describe(EtherpadClientAdapter.name, () => { describe('getOrCreateSessionId', () => { describe('when session already exists', () => { + describe('when session duration is sufficient', () => { + const setup = () => { + const groupId = 'groupId'; + const authorId = 'authorId'; + const parentId = 'parentId'; + const sessionCookieExpire = new Date(); + + const listSessionsResponse = createMock>({ + data: { + code: EtherpadResponseCode.OK, + data: { + // @ts-expect-error wrong type mapping + 'session-id-1': { groupID: groupId, authorID: authorId, validUntil: 20 }, + 'session-id-2': { groupID: 'other-group-id', authorID: 'other-author-id', validUntil: 20 }, + }, + }, + }); + + const ETHERPAD_COOKIE_RELEASE_THRESHOLD = 5; + jest.setSystemTime(10 * 1000); + + authorApi.listSessionsOfAuthorUsingGET.mockResolvedValue(listSessionsResponse); + + return { groupId, authorId, parentId, sessionCookieExpire, ETHERPAD_COOKIE_RELEASE_THRESHOLD }; + }; + + it('should return session id', async () => { + const { groupId, authorId, parentId, sessionCookieExpire, ETHERPAD_COOKIE_RELEASE_THRESHOLD } = setup(); + + const result = await service.getOrCreateSessionId( + groupId, + authorId, + parentId, + sessionCookieExpire, + ETHERPAD_COOKIE_RELEASE_THRESHOLD + ); + + expect(result).toBe('session-id-1'); + }); + + it('should not call createSessionUsingGET', async () => { + const { groupId, authorId, parentId, sessionCookieExpire, ETHERPAD_COOKIE_RELEASE_THRESHOLD } = setup(); + + await service.getOrCreateSessionId( + groupId, + authorId, + parentId, + sessionCookieExpire, + ETHERPAD_COOKIE_RELEASE_THRESHOLD + ); + + expect(sessionApi.createSessionUsingGET).not.toBeCalled(); + }); + }); + + describe('when session duration is not sufficient', () => { + const setup = () => { + const groupId = 'groupId'; + const authorId = 'authorId'; + const parentId = 'parentId'; + const sessionCookieExpire = new Date(); + const response = createMock>({ + data: { + code: EtherpadResponseCode.OK, + data: { sessionID: 'sessionId' }, + }, + }); + + const listSessionsResponse = createMock>({ + data: { + code: EtherpadResponseCode.OK, + data: { + // @ts-expect-error wrong type mapping + 'session-id-1': { groupID: groupId, authorID: authorId, validUntil: 20 }, + 'session-id-2': { groupID: 'other-group-id', authorID: 'other-author-id', validUntil: 20 }, + }, + }, + }); + + const ETHERPAD_COOKIE_RELEASE_THRESHOLD = 15; + jest.setSystemTime(10 * 1000); + + authorApi.listSessionsOfAuthorUsingGET.mockResolvedValue(listSessionsResponse); + + sessionApi.createSessionUsingGET.mockResolvedValue(response); + + return { groupId, authorId, parentId, sessionCookieExpire, ETHERPAD_COOKIE_RELEASE_THRESHOLD }; + }; + + it('should return session id', async () => { + const { groupId, authorId, parentId, sessionCookieExpire, ETHERPAD_COOKIE_RELEASE_THRESHOLD } = setup(); + + const result = await service.getOrCreateSessionId( + groupId, + authorId, + parentId, + sessionCookieExpire, + ETHERPAD_COOKIE_RELEASE_THRESHOLD + ); + + expect(result).toBe('sessionId'); + }); + + it('should call createSessionUsingGET with correct params', async () => { + const { groupId, authorId, parentId, sessionCookieExpire, ETHERPAD_COOKIE_RELEASE_THRESHOLD } = setup(); + + await service.getOrCreateSessionId( + groupId, + authorId, + parentId, + sessionCookieExpire, + ETHERPAD_COOKIE_RELEASE_THRESHOLD + ); + + const unixTimeInSeconds = Math.floor(sessionCookieExpire.getTime() / 1000).toString(); + expect(sessionApi.createSessionUsingGET).toBeCalledWith(groupId, authorId, unixTimeInSeconds); + }); + }); + }); + + describe('when two sessions already exist', () => { const setup = () => { const groupId = 'groupId'; const authorId = 'authorId'; @@ -163,32 +305,33 @@ describe(EtherpadClientAdapter.name, () => { code: EtherpadResponseCode.OK, data: { // @ts-expect-error wrong type mapping - 'session-id-1': { groupID: groupId, authorID: authorId }, - 'session-id-2': { groupID: 'other-group-id', authorID: 'other-author-id' }, + 'session-id-1': { groupID: groupId, authorID: authorId, validUntil: 20 }, + 'session-id-2': { groupID: groupId, authorID: authorId, validUntil: 30 }, }, }, }); - authorApi.listSessionsOfAuthorUsingGET.mockResolvedValue(listSessionsResponse); + const ETHERPAD_COOKIE_RELEASE_THRESHOLD = 15; + jest.setSystemTime(10 * 1000); + authorApi.listSessionsOfAuthorUsingGET.mockResolvedValue(listSessionsResponse); sessionApi.createSessionUsingGET.mockResolvedValue(response); - return { groupId, authorId, parentId, sessionCookieExpire }; - }; - it('should return session id', async () => { - const { groupId, authorId, parentId, sessionCookieExpire } = setup(); - - const result = await service.getOrCreateSessionId(groupId, authorId, parentId, sessionCookieExpire); - - expect(result).toBe('session-id-1'); - }); + return { groupId, authorId, parentId, sessionCookieExpire, ETHERPAD_COOKIE_RELEASE_THRESHOLD }; + }; - it('should not call createSessionUsingGET', async () => { - const { groupId, authorId, parentId, sessionCookieExpire } = setup(); + it('should return the session with longer validUntil', async () => { + const { groupId, authorId, parentId, sessionCookieExpire, ETHERPAD_COOKIE_RELEASE_THRESHOLD } = setup(); - await service.getOrCreateSessionId(groupId, authorId, parentId, sessionCookieExpire); + const result = await service.getOrCreateSessionId( + groupId, + authorId, + parentId, + sessionCookieExpire, + ETHERPAD_COOKIE_RELEASE_THRESHOLD + ); - expect(sessionApi.createSessionUsingGET).not.toBeCalled(); + expect(result).toBe('session-id-2'); }); }); @@ -212,29 +355,41 @@ describe(EtherpadClientAdapter.name, () => { }, }); + const ETHERPAD_COOKIE_RELEASE_THRESHOLD = 15; + authorApi.listSessionsOfAuthorUsingGET.mockResolvedValue(listSessionsResponse); sessionApi.createSessionUsingGET.mockResolvedValue(response); - return { groupId, authorId, parentId, sessionCookieExpire }; + + return { groupId, authorId, parentId, sessionCookieExpire, ETHERPAD_COOKIE_RELEASE_THRESHOLD }; }; it('should return session id', async () => { - const { groupId, authorId, parentId, sessionCookieExpire } = setup(); + const { groupId, authorId, parentId, sessionCookieExpire, ETHERPAD_COOKIE_RELEASE_THRESHOLD } = setup(); - const result = await service.getOrCreateSessionId(groupId, authorId, parentId, sessionCookieExpire); + const result = await service.getOrCreateSessionId( + groupId, + authorId, + parentId, + sessionCookieExpire, + ETHERPAD_COOKIE_RELEASE_THRESHOLD + ); expect(result).toBe('sessionId'); }); it('should call createSessionUsingGET with correct params', async () => { - const { groupId, authorId, parentId, sessionCookieExpire } = setup(); + const { groupId, authorId, parentId, sessionCookieExpire, ETHERPAD_COOKIE_RELEASE_THRESHOLD } = setup(); - await service.getOrCreateSessionId(groupId, authorId, parentId, sessionCookieExpire); - - expect(sessionApi.createSessionUsingGET).toBeCalledWith( + await service.getOrCreateSessionId( groupId, authorId, - sessionCookieExpire.getTime().toString() + parentId, + sessionCookieExpire, + ETHERPAD_COOKIE_RELEASE_THRESHOLD ); + + const unixTimeInSeconds = Math.floor(sessionCookieExpire.getTime() / 1000).toString(); + expect(sessionApi.createSessionUsingGET).toBeCalledWith(groupId, authorId, unixTimeInSeconds); }); }); @@ -259,16 +414,24 @@ describe(EtherpadClientAdapter.name, () => { data: {}, }, }); + const ETHERPAD_COOKIE_RELEASE_THRESHOLD = 15; sessionApi.createSessionUsingGET.mockResolvedValue(response); - return { groupId, authorId, parentId, sessionCookieExpire }; + + return { groupId, authorId, parentId, sessionCookieExpire, ETHERPAD_COOKIE_RELEASE_THRESHOLD }; }; it('should throw an error', async () => { - const { groupId, authorId, parentId, sessionCookieExpire } = setup(); + const { groupId, authorId, parentId, sessionCookieExpire, ETHERPAD_COOKIE_RELEASE_THRESHOLD } = setup(); await expect( - service.getOrCreateSessionId(groupId, authorId, parentId, sessionCookieExpire) + service.getOrCreateSessionId( + groupId, + authorId, + parentId, + sessionCookieExpire, + ETHERPAD_COOKIE_RELEASE_THRESHOLD + ) ).rejects.toThrowError('Session could not be created'); }); }); @@ -285,18 +448,25 @@ describe(EtherpadClientAdapter.name, () => { data: {}, }, }); + const ETHERPAD_COOKIE_RELEASE_THRESHOLD = 15; authorApi.listSessionsOfAuthorUsingGET.mockResolvedValue(listSessionsResponse); sessionApi.createSessionUsingGET.mockRejectedValueOnce(new Error('error')); - return { groupId, authorId, parentId, sessionCookieExpire }; + return { groupId, authorId, parentId, sessionCookieExpire, ETHERPAD_COOKIE_RELEASE_THRESHOLD }; }; it('should throw EtherpadErrorLoggableException', async () => { - const { groupId, authorId, parentId, sessionCookieExpire } = setup(); + const { groupId, authorId, parentId, sessionCookieExpire, ETHERPAD_COOKIE_RELEASE_THRESHOLD } = setup(); await expect( - service.getOrCreateSessionId(groupId, authorId, parentId, sessionCookieExpire) + service.getOrCreateSessionId( + groupId, + authorId, + parentId, + sessionCookieExpire, + ETHERPAD_COOKIE_RELEASE_THRESHOLD + ) ).rejects.toThrowError(EtherpadErrorLoggableException); }); }); @@ -310,7 +480,7 @@ describe(EtherpadClientAdapter.name, () => { data: { code: EtherpadResponseCode.OK, // @ts-expect-error wrong type mapping - data: { 'session-id-1': { groupID: 'groupId', authorID: authorId } }, + data: { 'session-id-1': { groupID: 'groupId', authorID: authorId, validUntil: 10 }, 'session-id-2': null }, }, }); @@ -381,12 +551,12 @@ describe(EtherpadClientAdapter.name, () => { return authorId; }; - it('should throw an error', async () => { + it('should return empty array', async () => { const authorId = setup(); - await expect(service.listSessionIdsOfAuthor(authorId)).rejects.toThrowError( - 'Etherpad session ids response is not an object' - ); + const result = await service.listSessionIdsOfAuthor(authorId); + + expect(result).toEqual([]); }); }); }); diff --git a/apps/server/src/infra/etherpad-client/etherpad-client.adapter.ts b/apps/server/src/infra/etherpad-client/etherpad-client.adapter.ts index ca1a19cd78f..404a544a0ae 100644 --- a/apps/server/src/infra/etherpad-client/etherpad-client.adapter.ts +++ b/apps/server/src/infra/etherpad-client/etherpad-client.adapter.ts @@ -1,4 +1,5 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import { TypeGuard } from '@shared/common'; import { EntityId } from '@shared/domain/types'; import { AxiosResponse } from 'axios'; import { @@ -9,7 +10,6 @@ import { InlineResponse2003, InlineResponse2004, InlineResponse2006, - InlineResponse2006Data, } from './etherpad-api-client'; import { AuthorApi, GroupApi, PadApi, SessionApi } from './etherpad-api-client/api'; import { @@ -20,6 +20,7 @@ import { EtherpadResponseCode, GroupId, PadId, + Session, SessionId, } from './interface'; import { EtherpadResponseMapper } from './mappers'; @@ -33,7 +34,7 @@ export class EtherpadClientAdapter { private readonly padApi: PadApi ) {} - public async getOrCreateAuthorId(userId: EntityId, username: string): Promise { + public async getOrCreateAuthorId(userId: EntityId, username?: string): Promise { const response = await this.tryCreateAuthor(userId, username); const user = this.handleEtherpadResponse(response, { userId }); @@ -42,7 +43,7 @@ export class EtherpadClientAdapter { return authorId; } - private async tryCreateAuthor(userId: string, username: string): Promise> { + private async tryCreateAuthor(userId: string, username?: string): Promise> { try { const response = await this.authorApi.createAuthorIfNotExistsForUsingGET(userId, username); @@ -56,7 +57,7 @@ export class EtherpadClientAdapter { const response = await this.tryGetPadsOfAuthor(authorId); const pads = this.handleEtherpadResponse(response, { authorId }); - if (!this.isObject(pads)) { + if (!TypeGuard.isObject(pads)) { throw new InternalServerErrorException('Etherpad listPadsOfAuthor response is not an object'); } @@ -79,34 +80,39 @@ export class EtherpadClientAdapter { groupId: GroupId, authorId: AuthorId, parentId: EntityId, - sessionCookieExpire: Date + sessionCookieExpire: Date, + durationThreshold: number ): Promise { - let sessionId: SessionId | undefined; - sessionId = await this.getSessionIdByGroupAndAuthor(groupId, authorId); + const session = await this.getSessionByGroupAndAuthor(groupId, authorId); - if (sessionId) { - return sessionId; + if (session && this.isSessionDurationSufficient(session, durationThreshold)) { + return session.id; } const response = await this.tryCreateSession(groupId, authorId, sessionCookieExpire); - const session = this.handleEtherpadResponse(response, { parentId }); + const newSession = this.handleEtherpadResponse(response, { parentId }); - sessionId = EtherpadResponseMapper.mapToSessionResponse(session); + const sessionId = EtherpadResponseMapper.mapToSessionResponse(newSession); return sessionId; } + private isSessionDurationSufficient(session: Session, durationThreshold: number): boolean { + const nowUnixTimestampInSeconds = Math.floor(new Date(Date.now()).getTime() / 1000); + const timeDiff = session.validUntil - nowUnixTimestampInSeconds; + const isDurationSufficient = timeDiff > durationThreshold; + + return isDurationSufficient; + } + private async tryCreateSession( groupId: string, authorId: string, sessionCookieExpire: Date ): Promise> { try { - const response = await this.sessionApi.createSessionUsingGET( - groupId, - authorId, - sessionCookieExpire.getTime().toString() - ); + const unixTimeInSeconds = Math.floor(sessionCookieExpire.getTime() / 1000); + const response = await this.sessionApi.createSessionUsingGET(groupId, authorId, unixTimeInSeconds.toString()); return response; } catch (error) { @@ -114,51 +120,37 @@ export class EtherpadClientAdapter { } } - private async getSessionIdByGroupAndAuthor(groupId: GroupId, authorId: AuthorId): Promise { - let sessionId: SessionId | undefined; - + private async getSessionByGroupAndAuthor(groupId: GroupId, authorId: AuthorId): Promise { const response = await this.tryListSessionsOfAuthor(authorId); - const sessions = this.handleEtherpadResponse(response, { groupId, authorId }); + const etherpadSessions = this.handleEtherpadResponse(response, { authorId }); + const sessions = EtherpadResponseMapper.mapEtherpadSessionsToSessions(etherpadSessions); - if (sessions) { - sessionId = this.findSessionId(sessions, groupId, authorId); - } + const session = this.findSession(sessions, groupId, authorId); - return sessionId; + return session; } - private findSessionId(sessions: InlineResponse2006Data, groupId: string, authorId: string): string | undefined { - const sessionEntries = Object.entries(sessions); - const sessionId = sessionEntries.map(([key, value]: [string, { groupID: string; authorID: string }]) => { - if (value?.groupID === groupId && value?.authorID === authorId) { - return key; - } + private findSession(sessions: Session[], groupId: string, authorId: string): Session | undefined { + const filteredAndSortedSessions = sessions + .filter((session) => session.groupId === groupId && session.authorId === authorId) + .sort((sessionA, sessionB) => sessionB.validUntil - sessionA.validUntil); - return undefined; - }); + const newestSessionByGroupAndAuthor = filteredAndSortedSessions[0]; - return sessionId[0]; + return newestSessionByGroupAndAuthor; } public async listSessionIdsOfAuthor(authorId: AuthorId): Promise { const response = await this.tryListSessionsOfAuthor(authorId); - const sessions = this.handleEtherpadResponse(response, { authorId }); + const etherpadSessions = this.handleEtherpadResponse(response, { authorId }); + const sessions = EtherpadResponseMapper.mapEtherpadSessionsToSessions(etherpadSessions); - // InlineResponse2006Data has the wrong type definition. Therefore we savely cast it to an object. - if (!this.isObject(sessions)) { - throw new InternalServerErrorException('Etherpad session ids response is not an object'); - } - - const sessionIds = Object.keys(sessions); + const sessionIds = sessions.map((session) => session.id); return sessionIds; } - private isObject(value: any): value is object { - return typeof value === 'object' && !Array.isArray(value) && value !== null; - } - - private async tryListSessionsOfAuthor(authorId: string): Promise> { + private async tryListSessionsOfAuthor(authorId: AuthorId): Promise> { try { const response = await this.authorApi.listSessionsOfAuthorUsingGET(authorId); @@ -250,7 +242,7 @@ export class EtherpadClientAdapter { const response = await this.tryGetAuthorsOfPad(padId); const authors = this.handleEtherpadResponse(response, { padId }); - if (!this.isObject(authors)) { + if (!TypeGuard.isObject(authors)) { throw new InternalServerErrorException('Etherpad listAuthorsOfPad response is not an object'); } @@ -269,11 +261,9 @@ export class EtherpadClientAdapter { } } - public async deleteSession(sessionId: SessionId): Promise { + public async deleteSession(sessionId: SessionId): Promise { const response = await this.tryDeleteSession(sessionId); - const responseData = this.handleEtherpadResponse(response, { sessionId }); - - return responseData; + this.handleEtherpadResponse(response, { sessionId }); } private async tryDeleteSession(sessionId: SessionId): Promise> { diff --git a/apps/server/src/infra/etherpad-client/interface/payloads.interface.ts b/apps/server/src/infra/etherpad-client/interface/payloads.interface.ts index 83a96b59834..88c2094a419 100644 --- a/apps/server/src/infra/etherpad-client/interface/payloads.interface.ts +++ b/apps/server/src/infra/etherpad-client/interface/payloads.interface.ts @@ -19,3 +19,10 @@ export interface EtherpadResponse { message?: string; data?: unknown; } + +export interface Session { + id: string; + groupId: string; + authorId: string; + validUntil: number; +} diff --git a/apps/server/src/infra/etherpad-client/mappers/etherpad-response.mapper.spec.ts b/apps/server/src/infra/etherpad-client/mappers/etherpad-response.mapper.spec.ts new file mode 100644 index 00000000000..387d1ad3fc4 --- /dev/null +++ b/apps/server/src/infra/etherpad-client/mappers/etherpad-response.mapper.spec.ts @@ -0,0 +1,248 @@ +import { EtherpadResponseMapper } from './etherpad-response.mapper'; + +describe('EtherpadResponseMapper', () => { + const errorMessage = new Error('Etherpad session is missing required properties'); + + describe('mapEtherpadSessionToSession', () => { + describe('when etherpadSession is valid', () => { + it('should return session', () => { + const etherpadId = 'etherpadId'; + const etherpadSession = { groupID: 'groupID', authorID: 'authorID', validUntil: 123456789 }; + + const result = EtherpadResponseMapper.mapEtherpadSessionToSession([etherpadId, etherpadSession]); + + expect(result).toEqual({ + id: etherpadId, + groupId: 'groupID', + authorId: 'authorID', + validUntil: 123456789, + }); + }); + }); + + describe('when etherpadSession is undefined', () => { + it('should throw error', () => { + const etherpadId = 'etherpadId'; + const etherpadSession = undefined; + + expect(() => EtherpadResponseMapper.mapEtherpadSessionToSession([etherpadId, etherpadSession])).toThrowError( + errorMessage + ); + }); + }); + + describe('when etherpadSession is null', () => { + it('should throw error', () => { + const etherpadId = 'etherpadId'; + const etherpadSession = null; + + expect(() => EtherpadResponseMapper.mapEtherpadSessionToSession([etherpadId, etherpadSession])).toThrowError( + errorMessage + ); + }); + }); + + describe('when etherpadSession is empty object', () => { + it('should throw error', () => { + const etherpadId = 'etherpadId'; + const etherpadSession = {}; + + expect(() => EtherpadResponseMapper.mapEtherpadSessionToSession([etherpadId, etherpadSession])).toThrowError( + errorMessage + ); + }); + }); + + describe('when etherpadSession is not an object', () => { + it('should throw error', () => { + const etherpadId = 'etherpadId'; + const etherpadSession = 'etherpadSession'; + + expect(() => EtherpadResponseMapper.mapEtherpadSessionToSession([etherpadId, etherpadSession])).toThrowError( + errorMessage + ); + }); + }); + + describe('when etherpadSession is missing required properties', () => { + it('should throw error', () => { + const etherpadId = 'etherpadId'; + const etherpadSession = { groupID: 'groupID', authorID: 'authorID' }; + + expect(() => EtherpadResponseMapper.mapEtherpadSessionToSession([etherpadId, etherpadSession])).toThrowError( + errorMessage + ); + }); + }); + + describe('when etherpadSession is missing required property valid until', () => { + it('should throw error', () => { + const etherpadId = 'etherpadId'; + const etherpadSession = { groupID: 'groupID', authorID: 'authorID' }; + + expect(() => EtherpadResponseMapper.mapEtherpadSessionToSession([etherpadId, etherpadSession])).toThrowError( + errorMessage + ); + }); + }); + + describe('when etherpadSession is missing required property authorID', () => { + it('should throw error', () => { + const etherpadId = 'etherpadId'; + const etherpadSession = { groupID: 'groupID', validUntil: 123456789 }; + + expect(() => EtherpadResponseMapper.mapEtherpadSessionToSession([etherpadId, etherpadSession])).toThrowError( + errorMessage + ); + }); + }); + + describe('when etherpadSession is missing required property groupID', () => { + it('should throw error', () => { + const etherpadId = 'etherpadId'; + const etherpadSession = { authorID: 'authorID', validUntil: 123456789 }; + + expect(() => EtherpadResponseMapper.mapEtherpadSessionToSession([etherpadId, etherpadSession])).toThrowError( + errorMessage + ); + }); + }); + + describe('when groupId is not a string', () => { + it('should throw error', () => { + const etherpadId = 'etherpadId'; + const etherpadSession = { groupID: 123, authorID: 'authorID', validUntil: 123456789 }; + const error = new Error('Type is not a string'); + + expect(() => EtherpadResponseMapper.mapEtherpadSessionToSession([etherpadId, etherpadSession])).toThrowError( + error + ); + }); + }); + + describe('when authorId is not a string', () => { + it('should throw error', () => { + const etherpadId = 'etherpadId'; + const etherpadSession = { groupID: 'groupID', authorID: 123, validUntil: 123456789 }; + const error = new Error('Type is not a string'); + + expect(() => EtherpadResponseMapper.mapEtherpadSessionToSession([etherpadId, etherpadSession])).toThrowError( + error + ); + }); + }); + + describe('when validUntil is not a number', () => { + it('should throw error', () => { + const etherpadId = 'etherpadId'; + const etherpadSession = { groupID: 'groupID', authorID: 'authorID', validUntil: '123456789' }; + const error = new Error('Type is not a number'); + + expect(() => EtherpadResponseMapper.mapEtherpadSessionToSession([etherpadId, etherpadSession])).toThrowError( + error + ); + }); + }); + }); + + describe('mapEtherpadSessionsToSessions', () => { + describe('when etherpadSessions is valid', () => { + it('should return sessions', () => { + const etherpadSessions = { + etherpadId1: { groupID: 'groupID1', authorID: 'authorID1', validUntil: 123456789 }, + etherpadId2: { groupID: 'groupID2', authorID: 'authorID2', validUntil: 123456789 }, + }; + + const result = EtherpadResponseMapper.mapEtherpadSessionsToSessions(etherpadSessions); + + expect(result).toEqual([ + { + id: 'etherpadId1', + groupId: 'groupID1', + authorId: 'authorID1', + validUntil: 123456789, + }, + { + id: 'etherpadId2', + groupId: 'groupID2', + authorId: 'authorID2', + validUntil: 123456789, + }, + ]); + }); + }); + + describe('when etherpadSessions is not an object', () => { + it('should return empty array', () => { + const etherpadSessions = 'etherpadSessions'; + + const result = EtherpadResponseMapper.mapEtherpadSessionsToSessions(etherpadSessions); + + expect(result).toEqual([]); + }); + }); + + describe('when etherpadSessions is empty object', () => { + it('should return empty array', () => { + const etherpadSessions = {}; + + const result = EtherpadResponseMapper.mapEtherpadSessionsToSessions(etherpadSessions); + + expect(result).toEqual([]); + }); + }); + + describe('when etherpadSessions is null', () => { + it('should throw error', () => { + const etherpadSessions = null; + + const result = EtherpadResponseMapper.mapEtherpadSessionsToSessions(etherpadSessions); + + expect(result).toEqual([]); + }); + }); + + describe('when etherpadSessions is undefined', () => { + it('should throw error', () => { + const etherpadSessions = undefined; + + const result = EtherpadResponseMapper.mapEtherpadSessionsToSessions(etherpadSessions); + + expect(result).toEqual([]); + }); + }); + + describe('when etherpadSession value is null', () => { + it('should not include session in result', () => { + const etherpadSessions = { + etherpadId1: { groupID: 'groupID1', authorID: 'authorID1', validUntil: 123456789 }, + etherpadId2: null, + }; + + const result = EtherpadResponseMapper.mapEtherpadSessionsToSessions(etherpadSessions); + + expect(result).toEqual([ + { + id: 'etherpadId1', + groupId: 'groupID1', + authorId: 'authorID1', + validUntil: 123456789, + }, + ]); + }); + }); + + describe('when mapEtherpadSessionToSession throws an error', () => { + it('should throw error', () => { + const etherpadSessions = { + etherpadId1: { groupID: 'groupID1', authorID: 'authorID1', validUntil: 123456789 }, + etherpadId2: { groupID: 'groupID2', authorID: 'authorID2' }, + }; + + expect(() => EtherpadResponseMapper.mapEtherpadSessionsToSessions(etherpadSessions)).toThrowError( + 'Etherpad session data is not valid' + ); + }); + }); + }); +}); diff --git a/apps/server/src/infra/etherpad-client/mappers/etherpad-response.mapper.ts b/apps/server/src/infra/etherpad-client/mappers/etherpad-response.mapper.ts index a0dcf5d508f..bb714f42ad3 100644 --- a/apps/server/src/infra/etherpad-client/mappers/etherpad-response.mapper.ts +++ b/apps/server/src/infra/etherpad-client/mappers/etherpad-response.mapper.ts @@ -1,6 +1,17 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { TypeGuard } from '@shared/common'; import { ErrorUtils } from '@src/core/error/utils'; import { InlineResponse2003Data, InlineResponse2004Data, InlineResponse200Data } from '../etherpad-api-client'; -import { AuthorId, EtherpadErrorType, EtherpadParams, EtherpadResponse, GroupId, PadId, SessionId } from '../interface'; +import { + AuthorId, + EtherpadErrorType, + EtherpadParams, + EtherpadResponse, + GroupId, + PadId, + Session, + SessionId, +} from '../interface'; import { EtherpadErrorLoggableException } from '../loggable'; export class EtherpadResponseMapper { @@ -49,4 +60,43 @@ export class EtherpadResponseMapper { ): EtherpadErrorLoggableException { return new EtherpadErrorLoggableException(type, payload, ErrorUtils.createHttpExceptionOptions(response.message)); } + + static mapEtherpadSessionsToSessions(etherpadSessions: unknown): Session[] { + try { + const isObject = TypeGuard.isObject(etherpadSessions); + if (!isObject) return []; + + const sessions = Object.entries(etherpadSessions) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .filter(([key, value]) => value !== null) + .map(([key, value]) => this.mapEtherpadSessionToSession([key, value])); + + return sessions; + } catch (error) { + throw new InternalServerErrorException('Etherpad session data is not valid', { cause: error }); + } + } + + static mapEtherpadSessionToSession([etherpadId, etherpadSession]: [string, unknown | undefined]): Session { + if ( + !TypeGuard.isObject(etherpadSession) || + !('groupID' in etherpadSession) || + !('authorID' in etherpadSession) || + !('validUntil' in etherpadSession) + ) + throw new Error('Etherpad session is missing required properties'); + + const groupId = TypeGuard.checkString(etherpadSession.groupID); + const authorId = TypeGuard.checkString(etherpadSession.authorID); + const validUntil = TypeGuard.checkNumber(etherpadSession.validUntil); + + const session: Session = { + id: etherpadId, + groupId, + authorId, + validUntil, + }; + + return session; + } } diff --git a/apps/server/src/modules/collaborative-text-editor/api/collaborative-text-editor.controller.ts b/apps/server/src/modules/collaborative-text-editor/api/collaborative-text-editor.controller.ts index febd0c555d4..c6e51966877 100644 --- a/apps/server/src/modules/collaborative-text-editor/api/collaborative-text-editor.controller.ts +++ b/apps/server/src/modules/collaborative-text-editor/api/collaborative-text-editor.controller.ts @@ -1,5 +1,5 @@ import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; -import { Controller, ForbiddenException, Get, NotFoundException, Param, Res } from '@nestjs/common'; +import { Controller, Delete, ForbiddenException, Get, NotFoundException, Param, Res } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiValidationError } from '@shared/common'; import { Response } from 'express'; @@ -30,13 +30,22 @@ export class CollaborativeTextEditorController { getCollaborativeTextEditorForParentParams ); - res.cookie('sessionID', textEditor.sessions.toString(), { + res.cookie('sessionID', textEditor.sessionId, { expires: textEditor.sessionExpiryDate, secure: true, + path: textEditor.path, }); const dto = CollaborativeTextEditorMapper.mapCollaborativeTextEditorToResponse(textEditor); return dto; } + + @ApiOperation({ summary: 'Delete all etherpad sessions for user' }) + @ApiResponse({ status: 204 }) + @ApiResponse({ status: 403, type: ForbiddenException }) + @Delete('/delete-sessions') + async deleteSessionsByUser(@CurrentUser() currentUser: ICurrentUser): Promise { + await this.collaborativeTextEditorUc.deleteSessionsByUser(currentUser.userId); + } } diff --git a/apps/server/src/modules/collaborative-text-editor/api/collaborative-text-editor.uc.ts b/apps/server/src/modules/collaborative-text-editor/api/collaborative-text-editor.uc.ts index 2f45c89c2f6..9d0f4d0d1bb 100644 --- a/apps/server/src/modules/collaborative-text-editor/api/collaborative-text-editor.uc.ts +++ b/apps/server/src/modules/collaborative-text-editor/api/collaborative-text-editor.uc.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; +import { EntityId } from '@shared/domain/types'; import { AuthorizationContextBuilder, AuthorizationService } from '@src/modules/authorization'; import { BoardDoAuthorizableService, ContentElementService } from '@src/modules/board'; import { CollaborativeTextEditor } from '../domain/do/collaborative-text-editor'; @@ -36,6 +37,12 @@ export class CollaborativeTextEditorUc { return textEditor; } + async deleteSessionsByUser(userId: EntityId): Promise { + const user = await this.authorizationService.getUserWithPermissions(userId); + + await this.collaborativeTextEditorService.deleteSessionsByUser(user.id); + } + private async authorizeByParentType(params: GetCollaborativeTextEditorForParentParams, user: User) { if (params.parentType === CollaborativeTextEditorParentType.BOARD_CONTENT_ELEMENT) { await this.authorizeForContentElement(params, user); diff --git a/apps/server/src/modules/collaborative-text-editor/api/tests/delete-sessions.api.spec.ts b/apps/server/src/modules/collaborative-text-editor/api/tests/delete-sessions.api.spec.ts new file mode 100644 index 00000000000..b60a42c91a1 --- /dev/null +++ b/apps/server/src/modules/collaborative-text-editor/api/tests/delete-sessions.api.spec.ts @@ -0,0 +1,77 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { cleanupCollections, TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; +import { EtherpadClientAdapter } from '@src/infra/etherpad-client'; +import { ServerTestModule } from '@src/modules/server'; + +describe('Collaborative Text Editor Controller (API)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + let etherpadClientAdapter: DeepMocked; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + imports: [ServerTestModule], + }) + .overrideProvider(EtherpadClientAdapter) + .useValue(createMock()) + .compile(); + + app = module.createNestApplication(); + await app.init(); + em = app.get(EntityManager); + testApiClient = new TestApiClient(app, 'collaborative-text-editor'); + etherpadClientAdapter = module.get(EtherpadClientAdapter); + }); + + beforeEach(async () => { + await cleanupCollections(em); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('delete sessions by user', () => { + describe('when no user is logged in', () => { + it('should return 401', async () => { + const response = await testApiClient.delete(`delete-sessions`); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when user is logged in', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + const authorId = 'authorId'; + etherpadClientAdapter.getOrCreateAuthorId.mockResolvedValueOnce(authorId); + + const otherSessionIds = ['otherSessionId1', 'otherSessionId2']; + etherpadClientAdapter.listSessionIdsOfAuthor.mockResolvedValueOnce(otherSessionIds); + + etherpadClientAdapter.deleteSession.mockResolvedValueOnce(); + etherpadClientAdapter.deleteSession.mockResolvedValueOnce(); + + return { + loggedInClient, + }; + }; + + it('should resolve successfully', async () => { + const { loggedInClient } = await setup(); + + await loggedInClient.delete(`delete-sessions`); + }); + }); + }); +}); diff --git a/apps/server/src/modules/collaborative-text-editor/api/tests/get-collaborative-text-editor.api.spec.ts b/apps/server/src/modules/collaborative-text-editor/api/tests/get-collaborative-text-editor.api.spec.ts new file mode 100644 index 00000000000..27c9648f5b2 --- /dev/null +++ b/apps/server/src/modules/collaborative-text-editor/api/tests/get-collaborative-text-editor.api.spec.ts @@ -0,0 +1,322 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { BoardExternalReferenceType } from '@shared/domain/domainobject'; +import { + cardNodeFactory, + cleanupCollections, + collaborativeTextEditorNodeFactory, + columnBoardNodeFactory, + columnNodeFactory, + courseFactory, + TestApiClient, + UserAndAccountTestFactory, +} from '@shared/testing'; +import { EtherpadClientAdapter } from '@src/infra/etherpad-client'; +import { ServerTestModule } from '@src/modules/server'; + +describe('Collaborative Text Editor Controller (API)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + let etherpadClientAdapter: DeepMocked; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + imports: [ServerTestModule], + }) + .overrideProvider(EtherpadClientAdapter) + .useValue(createMock()) + .compile(); + + app = module.createNestApplication(); + await app.init(); + em = app.get(EntityManager); + testApiClient = new TestApiClient(app, 'collaborative-text-editor'); + etherpadClientAdapter = module.get(EtherpadClientAdapter); + }); + + beforeEach(async () => { + await cleanupCollections(em); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('getCollaborativeTextEditorForParent', () => { + describe('when request is invalid', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient }; + }; + + describe('when no user is logged in', () => { + it('should return 401', async () => { + const someId = new ObjectId().toHexString(); + + const response = await testApiClient.get(`content-element/${someId}}`); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when id in params is not a mongo id', () => { + it('should return 400', async () => { + const { loggedInClient } = await setup(); + + const response = await loggedInClient.get(`content-element/123`); + + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); + expect(response.body).toEqual( + expect.objectContaining({ + validationErrors: [{ errors: ['parentId must be a mongodb id'], field: ['parentId'] }], + }) + ); + }); + }); + + describe('when parentType in params is not correct', () => { + it('should return 400', async () => { + const { loggedInClient } = await setup(); + + const someId = new ObjectId().toHexString(); + + const response = await loggedInClient.get(`other-element/${someId}`); + + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); + expect(response.body).toEqual( + expect.objectContaining({ + validationErrors: [ + { errors: ['parentType must be one of the following values: content-element'], field: ['parentType'] }, + ], + }) + ); + }); + }); + }); + + describe('when request is valid', () => { + describe('when no session for user exists', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + const course = courseFactory.build({ students: [studentUser] }); + + await em.persistAndFlush([studentUser, 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 collaborativeTextEditorElement = collaborativeTextEditorNodeFactory.build({ parent: cardNode }); + + await em.persistAndFlush([ + studentAccount, + collaborativeTextEditorElement, + columnBoardNode, + columnNode, + cardNode, + ]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + const editorId = 'editorId'; + etherpadClientAdapter.getOrCreateEtherpadId.mockResolvedValueOnce(editorId); + const otherSessionIds = []; + etherpadClientAdapter.listSessionIdsOfAuthor.mockResolvedValueOnce(otherSessionIds); + const sessionId = 'sessionId'; + etherpadClientAdapter.getOrCreateSessionId.mockResolvedValueOnce(sessionId); + + const basePath = Configuration.get('ETHERPAD__PAD_URI') as string; + const expectedPath = `${basePath}/${editorId}`; + + const cookieExpiresMilliseconds = Number(Configuration.get('ETHERPAD_COOKIE__EXPIRES_SECONDS')) * 1000; + // Remove the last 8 characters from the string to prevent conflict between time of test and code execution + const sessionCookieExpiryDate = new Date(Date.now() + cookieExpiresMilliseconds).toUTCString().slice(0, -8); + + return { + loggedInClient, + collaborativeTextEditorElement, + expectedPath, + sessionId, + sessionCookieExpiryDate, + editorId, + }; + }; + + it('should return response and set cookie', async () => { + const { + loggedInClient, + collaborativeTextEditorElement, + expectedPath, + sessionId, + sessionCookieExpiryDate, + editorId, + } = await setup(); + + const response = await loggedInClient.get(`content-element/${collaborativeTextEditorElement.id}`); + + expect(response.status).toEqual(HttpStatus.OK); + // eslint-disable-next-line @typescript-eslint/dot-notation, @typescript-eslint/no-unsafe-member-access + expect(response.body['url']).toEqual(expectedPath); + // eslint-disable-next-line @typescript-eslint/dot-notation, @typescript-eslint/no-unsafe-member-access + expect(response.headers['set-cookie'][0]).toContain( + `sessionID=${sessionId}; Path=/p/${editorId}; Expires=${sessionCookieExpiryDate}` + ); + }); + }); + + describe('when other sessions for user already exists', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + const course = courseFactory.build({ students: [studentUser] }); + + await em.persistAndFlush([studentUser, 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 collaborativeTextEditorElement = collaborativeTextEditorNodeFactory.build({ parent: cardNode }); + + await em.persistAndFlush([ + studentAccount, + collaborativeTextEditorElement, + columnBoardNode, + columnNode, + cardNode, + ]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + const editorId = 'editorId'; + etherpadClientAdapter.getOrCreateEtherpadId.mockResolvedValueOnce(editorId); + const otherSessionIds = ['otherSessionId1', 'otherSessionId2']; + etherpadClientAdapter.listSessionIdsOfAuthor.mockResolvedValueOnce(otherSessionIds); + const sessionId = 'sessionId'; + etherpadClientAdapter.getOrCreateSessionId.mockResolvedValueOnce(sessionId); + + const basePath = Configuration.get('ETHERPAD__PAD_URI') as string; + const expectedPath = `${basePath}/${editorId}`; + + const cookieExpiresMilliseconds = Number(Configuration.get('ETHERPAD_COOKIE__EXPIRES_SECONDS')) * 1000; + // Remove the last 8 characters from the string to prevent conflict between time of test and code execution + const sessionCookieExpiryDate = new Date(Date.now() + cookieExpiresMilliseconds).toUTCString().slice(0, -8); + + return { + loggedInClient, + collaborativeTextEditorElement, + expectedPath, + sessionId, + sessionCookieExpiryDate, + editorId, + }; + }; + + it('should return response and set cookie', async () => { + const { + loggedInClient, + collaborativeTextEditorElement, + expectedPath, + sessionId, + sessionCookieExpiryDate, + editorId, + } = await setup(); + + const response = await loggedInClient.get(`content-element/${collaborativeTextEditorElement.id}`); + + expect(response.status).toEqual(HttpStatus.OK); + // eslint-disable-next-line @typescript-eslint/dot-notation, @typescript-eslint/no-unsafe-member-access + expect(response.body['url']).toEqual(expectedPath); + // eslint-disable-next-line @typescript-eslint/dot-notation, @typescript-eslint/no-unsafe-member-access + expect(response.headers['set-cookie'][0]).toContain( + `sessionID=${sessionId}; Path=/p/${editorId}; Expires=${sessionCookieExpiryDate}` + ); + }); + }); + + describe('when session for user already exists', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + const course = courseFactory.build({ students: [studentUser] }); + + await em.persistAndFlush([studentUser, 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 collaborativeTextEditorElement = collaborativeTextEditorNodeFactory.build({ parent: cardNode }); + + await em.persistAndFlush([ + studentAccount, + collaborativeTextEditorElement, + columnBoardNode, + columnNode, + cardNode, + ]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + const editorId = 'editorId'; + etherpadClientAdapter.getOrCreateEtherpadId.mockResolvedValueOnce(editorId); + const sessionId = 'sessionId'; + const otherSessionIds = ['sessionId']; + etherpadClientAdapter.listSessionIdsOfAuthor.mockResolvedValueOnce(otherSessionIds); + etherpadClientAdapter.getOrCreateSessionId.mockResolvedValueOnce(sessionId); + + const basePath = Configuration.get('ETHERPAD__PAD_URI') as string; + const expectedPath = `${basePath}/${editorId}`; + + const cookieExpiresMilliseconds = Number(Configuration.get('ETHERPAD_COOKIE__EXPIRES_SECONDS')) * 1000; + // Remove the last 8 characters from the string to prevent conflict between time of test and code execution + const sessionCookieExpiryDate = new Date(Date.now() + cookieExpiresMilliseconds).toUTCString().slice(0, -8); + + return { + loggedInClient, + collaborativeTextEditorElement, + expectedPath, + sessionId, + sessionCookieExpiryDate, + editorId, + }; + }; + + it('should return response and set cookie', async () => { + const { + loggedInClient, + collaborativeTextEditorElement, + expectedPath, + sessionId, + sessionCookieExpiryDate, + editorId, + } = await setup(); + + const response = await loggedInClient.get(`content-element/${collaborativeTextEditorElement.id}`); + + expect(response.status).toEqual(HttpStatus.OK); + // eslint-disable-next-line @typescript-eslint/dot-notation, @typescript-eslint/no-unsafe-member-access + expect(response.body['url']).toEqual(expectedPath); + // eslint-disable-next-line @typescript-eslint/dot-notation, @typescript-eslint/no-unsafe-member-access + expect(response.headers['set-cookie'][0]).toContain( + `sessionID=${sessionId}; Path=/p/${editorId}; Expires=${sessionCookieExpiryDate}` + ); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/collaborative-text-editor/api/tests/get.api.spec.ts b/apps/server/src/modules/collaborative-text-editor/api/tests/get.api.spec.ts deleted file mode 100644 index bcab3061685..00000000000 --- a/apps/server/src/modules/collaborative-text-editor/api/tests/get.api.spec.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; -import { HttpStatus, INestApplication } from '@nestjs/common'; -import { Test } from '@nestjs/testing'; -import { BoardExternalReferenceType } from '@shared/domain/domainobject'; -import { - cardNodeFactory, - cleanupCollections, - collaborativeTextEditorNodeFactory, - columnBoardNodeFactory, - columnNodeFactory, - courseFactory, - TestApiClient, - UserAndAccountTestFactory, -} from '@shared/testing'; -import { EtherpadClientAdapter } from '@src/infra/etherpad-client'; -import { ServerTestModule } from '@src/modules/server'; - -describe('Collaborative Text Editor Controller (API)', () => { - let app: INestApplication; - let em: EntityManager; - let testApiClient: TestApiClient; - let etherpadClientAdapter: DeepMocked; - - beforeAll(async () => { - const module = await Test.createTestingModule({ - imports: [ServerTestModule], - }) - .overrideProvider(EtherpadClientAdapter) - .useValue(createMock()) - .compile(); - - app = module.createNestApplication(); - await app.init(); - em = app.get(EntityManager); - testApiClient = new TestApiClient(app, 'collaborative-text-editor'); - etherpadClientAdapter = module.get(EtherpadClientAdapter); - }); - - beforeEach(async () => { - await cleanupCollections(em); - }); - - afterAll(async () => { - await app.close(); - }); - - describe('getCollaborativeTextEditorForParent', () => { - describe('when request is invalid', () => { - const setup = async () => { - const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); - - await em.persistAndFlush([studentAccount, studentUser]); - em.clear(); - - const loggedInClient = await testApiClient.login(studentAccount); - - return { loggedInClient }; - }; - - describe('when no user is logged in', () => { - it('should return 401', async () => { - const someId = new ObjectId().toHexString(); - - const response = await testApiClient.get(`content-element/${someId}}`); - - expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); - }); - }); - - describe('when id in params is not a mongo id', () => { - it('should return 400', async () => { - const { loggedInClient } = await setup(); - - const response = await loggedInClient.get(`content-element/123`); - - expect(response.status).toEqual(HttpStatus.BAD_REQUEST); - expect(response.body).toEqual( - expect.objectContaining({ - validationErrors: [{ errors: ['parentId must be a mongodb id'], field: ['parentId'] }], - }) - ); - }); - }); - - describe('when parentType in params is not correct', () => { - it('should return 400', async () => { - const { loggedInClient } = await setup(); - - const someId = new ObjectId().toHexString(); - - const response = await loggedInClient.get(`other-element/${someId}`); - - expect(response.status).toEqual(HttpStatus.BAD_REQUEST); - expect(response.body).toEqual( - expect.objectContaining({ - validationErrors: [ - { errors: ['parentType must be one of the following values: content-element'], field: ['parentType'] }, - ], - }) - ); - }); - }); - }); - - describe('when request is valid', () => { - const setup = async () => { - const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); - const course = courseFactory.build({ students: [studentUser] }); - - await em.persistAndFlush([studentUser, 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 collaborativeTextEditorElement = collaborativeTextEditorNodeFactory.build({ parent: cardNode }); - - await em.persistAndFlush([ - studentAccount, - collaborativeTextEditorElement, - columnBoardNode, - columnNode, - cardNode, - ]); - em.clear(); - - const loggedInClient = await testApiClient.login(studentAccount); - - const editorId = 'editorId'; - etherpadClientAdapter.getOrCreateEtherpadId.mockResolvedValueOnce(editorId); - const otherSessionIds = ['otherSessionId1', 'otherSessionId2']; - etherpadClientAdapter.listSessionIdsOfAuthor.mockResolvedValueOnce(otherSessionIds); - const sessionId = 'sessionId'; - etherpadClientAdapter.getOrCreateSessionId.mockResolvedValueOnce(sessionId); - - const basePath = Configuration.get('ETHERPAD__PAD_URI') as string; - const expectedPath = `${basePath}/${editorId}`; - - const expectedSessions = encodeURIComponent([...otherSessionIds, sessionId].join(',')); - - const cookieExpiresMilliseconds = Number(Configuration.get('ETHERPAD_COOKIE__EXPIRES_SECONDS')) * 1000; - // Remove the last 8 characters from the string to prevent conflict between time of test and code execution - const sessionCookieExpiryDate = new Date(Date.now() + cookieExpiresMilliseconds).toUTCString().slice(0, -8); - - return { - loggedInClient, - collaborativeTextEditorElement, - expectedPath, - expectedSessions, - sessionCookieExpiryDate, - }; - }; - - it('should return response and set cookie', async () => { - const { - loggedInClient, - collaborativeTextEditorElement, - expectedPath, - expectedSessions, - sessionCookieExpiryDate, - } = await setup(); - - const response = await loggedInClient.get(`content-element/${collaborativeTextEditorElement.id}`); - - expect(response.status).toEqual(HttpStatus.OK); - // eslint-disable-next-line @typescript-eslint/dot-notation, @typescript-eslint/no-unsafe-member-access - expect(response.body['url']).toEqual(expectedPath); - // eslint-disable-next-line @typescript-eslint/dot-notation, @typescript-eslint/no-unsafe-member-access - expect(response.headers['set-cookie'][0]).toContain( - `sessionID=${expectedSessions}; Path=/; Expires=${sessionCookieExpiryDate}` - ); - }); - }); - }); -}); diff --git a/apps/server/src/modules/collaborative-text-editor/collaborative-text-editor.config.ts b/apps/server/src/modules/collaborative-text-editor/collaborative-text-editor.config.ts index 2317a0fedce..fb32c3ba793 100644 --- a/apps/server/src/modules/collaborative-text-editor/collaborative-text-editor.config.ts +++ b/apps/server/src/modules/collaborative-text-editor/collaborative-text-editor.config.ts @@ -1,4 +1,5 @@ export interface CollaborativeTextEditorConfig { ETHERPAD_COOKIE__EXPIRES_SECONDS: number; + ETHERPAD_COOKIE_RELEASE_THRESHOLD: number; ETHERPAD__PAD_URI: string; } diff --git a/apps/server/src/modules/collaborative-text-editor/domain/do/collaborative-text-editor.ts b/apps/server/src/modules/collaborative-text-editor/domain/do/collaborative-text-editor.ts index c1b58baa071..66e3a86329c 100644 --- a/apps/server/src/modules/collaborative-text-editor/domain/do/collaborative-text-editor.ts +++ b/apps/server/src/modules/collaborative-text-editor/domain/do/collaborative-text-editor.ts @@ -1,5 +1,6 @@ export interface CollaborativeTextEditor { url: string; - sessions: string[]; + path: string; + sessionId: string; sessionExpiryDate: Date; } diff --git a/apps/server/src/modules/collaborative-text-editor/service/collaborative-text-editor.service.spec.ts b/apps/server/src/modules/collaborative-text-editor/service/collaborative-text-editor.service.spec.ts index a3df8602161..c063de3b820 100644 --- a/apps/server/src/modules/collaborative-text-editor/service/collaborative-text-editor.service.spec.ts +++ b/apps/server/src/modules/collaborative-text-editor/service/collaborative-text-editor.service.spec.ts @@ -50,8 +50,9 @@ describe('CollaborativeTextEditorService', () => { const authorId = 'authorId'; const sessionId = 'sessionId1'; const authorsSessionIds = ['sessionId1', 'sessionId2']; - const url = 'url'; + const url = 'http://localhost:9001/p'; const cookieExpiresSeconds = 2; + const releaseThreshold = 5; const dateMock = new Date(2022, 1, 22); const sessionExpiryDate = new Date(dateMock.getTime() + cookieExpiresSeconds * 1000); @@ -66,6 +67,7 @@ describe('CollaborativeTextEditorService', () => { authorsSessionIds, url, cookieExpiresSeconds, + releaseThreshold, sessionExpiryDate, dateMock, }; @@ -86,9 +88,11 @@ describe('CollaborativeTextEditorService', () => { cookieExpiresSeconds, sessionExpiryDate, dateMock, + releaseThreshold, } = buildParameter(); configService.get.mockReturnValueOnce(cookieExpiresSeconds); + configService.get.mockReturnValueOnce(releaseThreshold); configService.get.mockReturnValueOnce(url); etherpadClientAdapter.getOrCreateGroupId.mockResolvedValueOnce(groupId); etherpadClientAdapter.getOrCreateEtherpadId.mockResolvedValueOnce(padId); @@ -111,6 +115,7 @@ describe('CollaborativeTextEditorService', () => { url, cookieExpiresSeconds, sessionExpiryDate, + releaseThreshold, dateMock, }; }; @@ -121,14 +126,15 @@ describe('CollaborativeTextEditorService', () => { const result = await service.getOrCreateCollaborativeTextEditor(userId, userName, params); expect(result).toEqual({ - sessions: ['sessionId1', 'sessionId2'], + sessionId: 'sessionId1', url: `${url}/${padId}`, + path: `/p/${padId}`, sessionExpiryDate, }); }); it('should call etherpadClientAdapter methods with correct parameter', async () => { - const { userId, userName, params, groupId, authorId, sessionExpiryDate } = setup(); + const { userId, userName, params, groupId, authorId, sessionExpiryDate, releaseThreshold } = setup(); await service.getOrCreateCollaborativeTextEditor(userId, userName, params); @@ -139,18 +145,20 @@ describe('CollaborativeTextEditorService', () => { groupId, authorId, params.parentId, - sessionExpiryDate + sessionExpiryDate, + releaseThreshold ); - expect(etherpadClientAdapter.listSessionIdsOfAuthor).toHaveBeenCalledWith(authorId); }); }); describe('WHEN etherpadClientAdapter.getOrCreateGroup throws an error', () => { const setup = () => { - const { userId, userName, params, dateMock, cookieExpiresSeconds } = buildParameter(); + const { userId, userName, params, dateMock, cookieExpiresSeconds, releaseThreshold } = buildParameter(); const error = new Error('error'); configService.get.mockReturnValueOnce(cookieExpiresSeconds); + configService.get.mockReturnValueOnce(releaseThreshold); + etherpadClientAdapter.getOrCreateGroupId.mockRejectedValueOnce(error); jest.useFakeTimers(); @@ -234,32 +242,6 @@ describe('CollaborativeTextEditorService', () => { await expect(service.getOrCreateCollaborativeTextEditor(userId, userName, params)).rejects.toThrowError(error); }); }); - - describe('WHEN etherpadClientAdapter.listSessionsOfAuthor throws an error', () => { - const setup = () => { - const { userId, userName, params, dateMock, cookieExpiresSeconds, groupId, padId, authorId, sessionId } = - buildParameter(); - const error = new Error('error'); - - configService.get.mockReturnValueOnce(cookieExpiresSeconds); - etherpadClientAdapter.getOrCreateGroupId.mockResolvedValueOnce(groupId); - etherpadClientAdapter.getOrCreateEtherpadId.mockResolvedValueOnce(padId); - etherpadClientAdapter.getOrCreateAuthorId.mockResolvedValueOnce(authorId); - etherpadClientAdapter.getOrCreateSessionId.mockResolvedValueOnce(sessionId); - etherpadClientAdapter.listSessionIdsOfAuthor.mockRejectedValueOnce(error); - - jest.useFakeTimers(); - jest.setSystemTime(dateMock); - - return { userId, userName, params, error }; - }; - - it('should throw an error', async () => { - const { userId, userName, params, error } = setup(); - - await expect(service.getOrCreateCollaborativeTextEditor(userId, userName, params)).rejects.toThrowError(error); - }); - }); }); describe('deleteCollaborativeTextEditorByParentId', () => { @@ -318,4 +300,104 @@ describe('CollaborativeTextEditorService', () => { }); }); }); + + describe('deleteSessionsByUser', () => { + describe('WHEN sessions are deleted successfully', () => { + const setup = () => { + const userId = 'userId'; + const authorId = 'authorId'; + const sessionIds = ['sessionId1', 'sessionId2']; + + etherpadClientAdapter.getOrCreateAuthorId.mockResolvedValueOnce(authorId); + etherpadClientAdapter.listSessionIdsOfAuthor.mockResolvedValueOnce(sessionIds); + etherpadClientAdapter.deleteSession.mockResolvedValueOnce(); + etherpadClientAdapter.deleteSession.mockResolvedValueOnce(); + + return { userId, authorId, sessionIds }; + }; + + it('should call etherpadClientAdapter.getOrCreateAuthorId with correct parameter', async () => { + const { userId } = setup(); + + await service.deleteSessionsByUser(userId); + + expect(etherpadClientAdapter.getOrCreateAuthorId).toHaveBeenCalledWith(userId); + }); + + it('should call etherpadClientAdapter.listSessionIdsOfAuthor with correct parameter', async () => { + const { userId, authorId } = setup(); + + await service.deleteSessionsByUser(userId); + + expect(etherpadClientAdapter.listSessionIdsOfAuthor).toHaveBeenCalledWith(authorId); + }); + + it('should call etherpadClientAdapter.deleteSession with correct parameter', async () => { + const { userId, sessionIds } = setup(); + + await service.deleteSessionsByUser(userId); + + expect(etherpadClientAdapter.deleteSession).toHaveBeenNthCalledWith(1, sessionIds[0]); + expect(etherpadClientAdapter.deleteSession).toHaveBeenNthCalledWith(2, sessionIds[1]); + }); + }); + + describe('WHEN etherpadClientAdapter.getOrCreateAuthorId throws an error', () => { + const setup = () => { + const userId = 'userId'; + const error = new Error('error'); + + etherpadClientAdapter.getOrCreateAuthorId.mockRejectedValueOnce(error); + + return { userId, error }; + }; + + it('should throw an error', async () => { + const { userId, error } = setup(); + + await expect(service.deleteSessionsByUser(userId)).rejects.toThrowError(error); + }); + }); + + describe('WHEN etherpadClientAdapter.listSessionIdsOfAuthor throws an error', () => { + const setup = () => { + const userId = 'userId'; + const authorId = 'authorId'; + const error = new Error('error'); + + etherpadClientAdapter.getOrCreateAuthorId.mockResolvedValueOnce(authorId); + etherpadClientAdapter.listSessionIdsOfAuthor.mockRejectedValueOnce(error); + + return { userId, authorId, error }; + }; + + it('should throw an error', async () => { + const { userId, error } = setup(); + + await expect(service.deleteSessionsByUser(userId)).rejects.toThrowError(error); + }); + }); + + describe('WHEN etherpadClientAdapter.deleteSession throws an error', () => { + const setup = () => { + const userId = 'userId'; + const authorId = 'authorId'; + const sessionIds = ['sessionId1', 'sessionId2']; + const error = new Error('error'); + + etherpadClientAdapter.getOrCreateAuthorId.mockResolvedValueOnce(authorId); + etherpadClientAdapter.listSessionIdsOfAuthor.mockResolvedValueOnce(sessionIds); + etherpadClientAdapter.deleteSession.mockResolvedValueOnce(); + etherpadClientAdapter.deleteSession.mockRejectedValueOnce(error); + + return { userId, authorId, sessionIds, error }; + }; + + it('should throw an error', async () => { + const { userId, error } = setup(); + + await expect(service.deleteSessionsByUser(userId)).rejects.toThrowError(error); + }); + }); + }); }); diff --git a/apps/server/src/modules/collaborative-text-editor/service/collaborative-text-editor.service.ts b/apps/server/src/modules/collaborative-text-editor/service/collaborative-text-editor.service.ts index 38aae97cff5..28a58a38cbd 100644 --- a/apps/server/src/modules/collaborative-text-editor/service/collaborative-text-editor.service.ts +++ b/apps/server/src/modules/collaborative-text-editor/service/collaborative-text-editor.service.ts @@ -22,6 +22,7 @@ export class CollaborativeTextEditorService { params: GetCollaborativeTextEditorForParentParams ): Promise { const sessionExpiryDate = this.buildSessionExpiryDate(); + const durationThreshold = Number(this.configService.get('ETHERPAD_COOKIE_RELEASE_THRESHOLD')); const { parentId } = params; const groupId = await this.collaborativeTextEditorAdapter.getOrCreateGroupId(parentId); @@ -31,16 +32,16 @@ export class CollaborativeTextEditorService { groupId, authorId, parentId, - sessionExpiryDate + sessionExpiryDate, + durationThreshold ); - const authorsSessionIds = await this.collaborativeTextEditorAdapter.listSessionIdsOfAuthor(authorId); const url = this.buildPath(padId); - const uniqueSessionIds = this.removeDuplicateSessions([...authorsSessionIds, sessionId]); return { - sessions: uniqueSessionIds, - url, + sessionId, + url: url.toString(), + path: url.pathname, sessionExpiryDate, }; } @@ -51,10 +52,13 @@ export class CollaborativeTextEditorService { await this.collaborativeTextEditorAdapter.deleteGroup(groupId); } - private removeDuplicateSessions(sessions: string[]): string[] { - const uniqueSessions = [...new Set(sessions)]; + async deleteSessionsByUser(userId: string): Promise { + const authorId = await this.collaborativeTextEditorAdapter.getOrCreateAuthorId(userId); + const sessionIds = await this.collaborativeTextEditorAdapter.listSessionIdsOfAuthor(authorId); + + const promises = sessionIds.map((sessionId) => this.collaborativeTextEditorAdapter.deleteSession(sessionId)); - return uniqueSessions; + await Promise.all(promises); } private buildSessionExpiryDate(): Date { @@ -64,9 +68,9 @@ export class CollaborativeTextEditorService { return sessionCookieExpiryDate; } - private buildPath(editorId: string): string { + private buildPath(editorId: string): URL { const basePath = this.configService.get('ETHERPAD__PAD_URI'); - const url = `${basePath}/${editorId}`; + const url = new URL(`${basePath}/${editorId}`); return url; } diff --git a/apps/server/src/modules/server/server.config.ts b/apps/server/src/modules/server/server.config.ts index a45594a3d25..ecd8f4363d8 100644 --- a/apps/server/src/modules/server/server.config.ts +++ b/apps/server/src/modules/server/server.config.ts @@ -16,11 +16,11 @@ import { ProvisioningConfig } from '@modules/provisioning'; import type { SchoolConfig } from '@modules/school'; import type { SharingConfig } from '@modules/sharing'; import { getTldrawClientConfig, type TldrawClientConfig } from '@modules/tldraw-client'; -import { type IToolFeatures, ToolConfiguration } from '@modules/tool'; +import { ToolConfiguration, type IToolFeatures } from '@modules/tool'; import type { UserConfig } from '@modules/user'; -import { type IUserImportFeatures, UserImportConfiguration } from '@modules/user-import'; +import { UserImportConfiguration, type IUserImportFeatures } from '@modules/user-import'; import type { UserLoginMigrationConfig } from '@modules/user-login-migration'; -import { type IVideoConferenceSettings, VideoConferenceConfiguration } from '@modules/video-conference'; +import { VideoConferenceConfiguration, type IVideoConferenceSettings } from '@modules/video-conference'; import { LanguageType } from '@shared/domain/interface'; import { SchulcloudTheme } from '@shared/domain/types'; import type { CoreModuleConfig } from '@src/core'; @@ -227,6 +227,7 @@ const config: ServerConfig = { FEATURE_ETHERPAD_ENABLED: Configuration.get('FEATURE_ETHERPAD_ENABLED') as boolean, ETHERPAD__PAD_URI: Configuration.get('ETHERPAD__PAD_URI') as string, ETHERPAD_COOKIE__EXPIRES_SECONDS: Configuration.get('ETHERPAD_COOKIE__EXPIRES_SECONDS') as number, + ETHERPAD_COOKIE_RELEASE_THRESHOLD: Configuration.get('ETHERPAD_COOKIE_RELEASE_THRESHOLD') as number, I18N__AVAILABLE_LANGUAGES: (Configuration.get('I18N__AVAILABLE_LANGUAGES') as string).split(',') as LanguageType[], I18N__DEFAULT_LANGUAGE: Configuration.get('I18N__DEFAULT_LANGUAGE') as unknown as LanguageType, I18N__FALLBACK_LANGUAGE: Configuration.get('I18N__FALLBACK_LANGUAGE') as unknown as LanguageType, diff --git a/apps/server/src/shared/common/guards/type.guard.spec.ts b/apps/server/src/shared/common/guards/type.guard.spec.ts index 26617fb71a1..9f129244685 100644 --- a/apps/server/src/shared/common/guards/type.guard.spec.ts +++ b/apps/server/src/shared/common/guards/type.guard.spec.ts @@ -37,16 +37,16 @@ describe('TypeGuard', () => { describe('checkNumber', () => { describe('when passing type of value is a number', () => { - it('should be return true', () => { - expect(TypeGuard.checkNumber(123)).toEqual(undefined); + it('should be return value', () => { + expect(TypeGuard.checkNumber(123)).toEqual(123); }); - it('should be return true', () => { - expect(TypeGuard.checkNumber(-1)).toEqual(undefined); + it('should be return value', () => { + expect(TypeGuard.checkNumber(-1)).toEqual(-1); }); - it('should be return true', () => { - expect(TypeGuard.checkNumber(NaN)).toEqual(undefined); + it('should be return value', () => { + expect(TypeGuard.checkNumber(NaN)).toEqual(NaN); }); }); @@ -84,4 +84,132 @@ describe('TypeGuard', () => { }); }); }); + + describe('isObject', () => { + describe('when passing type of value is an object', () => { + it('should be return true', () => { + expect(TypeGuard.isObject({})).toBe(true); + }); + + it('should be return true', () => { + expect(TypeGuard.isObject({ a: 1 })).toBe(true); + }); + + it('should be return true', () => { + expect(TypeGuard.isObject({ a: { b: 1 } })).toBe(true); + }); + }); + + describe('when passing type of value is NOT an object', () => { + it('should be return false', () => { + expect(TypeGuard.isObject(undefined)).toBe(false); + }); + + it('should be return false', () => { + expect(TypeGuard.isObject(null)).toBe(false); + }); + + it('should be return false', () => { + expect(TypeGuard.isObject([])).toBe(false); + }); + + it('should be return false', () => { + expect(TypeGuard.isObject('string')).toBe(false); + }); + }); + }); + + describe('checkObject', () => { + describe('when passing type of value is an object', () => { + it('should be return value', () => { + expect(TypeGuard.checkObject({})).toEqual({}); + }); + + it('should be return value', () => { + expect(TypeGuard.checkObject({ a: 1 })).toEqual({ a: 1 }); + }); + + it('should be return value', () => { + expect(TypeGuard.checkObject({ a: { b: 1 } })).toEqual({ a: { b: 1 } }); + }); + }); + + describe('when passing type of value is NOT an object', () => { + it('should be return false', () => { + expect(() => TypeGuard.checkObject(undefined)).toThrowError('Type is not an object'); + }); + + it('should be return false', () => { + expect(() => TypeGuard.checkObject(null)).toThrowError('Type is not an object'); + }); + + it('should be return false', () => { + expect(() => TypeGuard.checkObject([])).toThrowError('Type is not an object'); + }); + + it('should be return false', () => { + expect(() => TypeGuard.checkObject('string')).toThrowError('Type is not an object'); + }); + }); + }); + + describe('isString', () => { + describe('when passing type of value is a string', () => { + it('should be return true', () => { + expect(TypeGuard.isString('string')).toBe(true); + }); + + it('should be return true', () => { + expect(TypeGuard.isString('')).toBe(true); + }); + }); + + describe('when passing type of value is NOT a string', () => { + it('should be return false', () => { + expect(TypeGuard.isString(undefined)).toBe(false); + }); + + it('should be return false', () => { + expect(TypeGuard.isString(null)).toBe(false); + }); + + it('should be return false', () => { + expect(TypeGuard.isString({})).toBe(false); + }); + + it('should be return false', () => { + expect(TypeGuard.isString(1)).toBe(false); + }); + }); + }); + + describe('checkString', () => { + describe('when passing type of value is a string', () => { + it('should be return value', () => { + expect(TypeGuard.checkString('string')).toEqual('string'); + }); + + it('should be return value', () => { + expect(TypeGuard.checkString('')).toEqual(''); + }); + }); + + describe('when passing type of value is NOT a string', () => { + it('should be return false', () => { + expect(() => TypeGuard.checkString(undefined)).toThrowError('Type is not a string'); + }); + + it('should be return false', () => { + expect(() => TypeGuard.checkString(null)).toThrowError('Type is not a string'); + }); + + it('should be return false', () => { + expect(() => TypeGuard.checkString({})).toThrowError('Type is not a string'); + }); + + it('should be return false', () => { + expect(() => TypeGuard.checkString(1)).toThrowError('Type is not a string'); + }); + }); + }); }); diff --git a/apps/server/src/shared/common/guards/type.guard.ts b/apps/server/src/shared/common/guards/type.guard.ts index 2d93c223614..171ca40b596 100644 --- a/apps/server/src/shared/common/guards/type.guard.ts +++ b/apps/server/src/shared/common/guards/type.guard.ts @@ -1,19 +1,49 @@ export class TypeGuard { - static checkNumber(value: unknown): void { + static checkNumber(value: unknown): number { if (!TypeGuard.isNumber(value)) { throw new Error('Type is not a number'); } + + return value; } - static isNumber(value: unknown): boolean { + static isNumber(value: unknown): value is number { const isNumber = typeof value === 'number'; return isNumber; } + static checkString(value: unknown): string { + if (!TypeGuard.isString(value)) { + throw new Error('Type is not a string'); + } + + return value; + } + + static isString(value: unknown): value is string { + const isString = typeof value === 'string'; + + return isString; + } + static isArrayWithElements(value: unknown): value is [] { const isArrayWithElements = Array.isArray(value) && value.length > 0; return isArrayWithElements; } + + static checkObject(value: unknown): object { + if (!TypeGuard.isObject(value)) { + throw new Error('Type is not an object'); + } + + return value; + } + + static isObject(value: unknown): value is object { + const isObject = typeof value === 'object' && !Array.isArray(value) && value !== null; + + return isObject; + } } diff --git a/src/services/etherpad/hooks/Session.js b/src/services/etherpad/hooks/Session.js index eae30edd9fc..ffdfdf4aa32 100644 --- a/src/services/etherpad/hooks/Session.js +++ b/src/services/etherpad/hooks/Session.js @@ -45,16 +45,18 @@ const getSessionInformation = async (context) => { try { const response = await sessionListPromise; // Return existing session from hooks - if (typeof response.data !== 'undefined' && response.data !== null) { + if (response && typeof response.data !== 'undefined' && response.data !== null) { const responseData = response.data; const unixTimestamp = parseInt(new Date(Date.now()).getTime() / 1000, 10); - const foundSessionID = Object.keys(responseData).find((sessionID) => { - const diffSeconds = responseData[sessionID].validUntil - unixTimestamp; - return ( - responseData[sessionID].groupID === context.data.groupID && - diffSeconds >= EtherpadClient.cookieReleaseThreshold - ); - }); + const foundSessionID = Object.keys(responseData) + .filter((sessionID) => responseData[sessionID] !== null && typeof responseData[sessionID] !== 'undefined') + .find((sessionID) => { + const diffSeconds = responseData[sessionID].validUntil - unixTimestamp; + return ( + responseData[sessionID].groupID === context.data.groupID && + diffSeconds >= EtherpadClient.cookieReleaseThreshold + ); + }); let validUntil; if (typeof foundSessionID !== 'undefined' && foundSessionID !== null) { const respData = responseData[foundSessionID];