hedgedoc/backend/src/realtime/websocket/websocket.gateway.spec.ts
Erik Michelson 2c6717e1ee refactor(api-token): drop passport, rename to ApiToken
We don't need a library that requires as much boilerplate code as
writing the AuthGuard ourselves, especially since the token validation
was already custom code by us.

The previous name PublicAuthToken was a bit misleading, since PublicAuth
 could also be interpreted as being used for the public frontend in
contrast to the API. The old name before that (AuthToken) wasn't better
since it wasn't clear what type of auth is meant. I know, this is the
second renaming of the same module in less than a month. However, I
would say the name ApiToken seems rather reasonable and understandable.

Signed-off-by: Erik Michelson <github@erik.michelson.eu>
2024-09-18 19:14:32 +02:00

410 lines
14 KiB
TypeScript

/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Optional } from '@mrdrogdrog/optional';
import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { IncomingMessage } from 'http';
import { Mock } from 'ts-mockery';
import { Repository } from 'typeorm';
import WebSocket from 'ws';
import { ApiToken } from '../../api-token/api-token.entity';
import { Author } from '../../authors/author.entity';
import appConfigMock from '../../config/mock/app.config.mock';
import authConfigMock from '../../config/mock/auth.config.mock';
import databaseConfigMock from '../../config/mock/database.config.mock';
import noteConfigMock from '../../config/mock/note.config.mock';
import { eventModuleConfig } from '../../events';
import { Group } from '../../groups/group.entity';
import { Identity } from '../../identity/identity.entity';
import { LoggerModule } from '../../logger/logger.module';
import { Alias } from '../../notes/alias.entity';
import { Note } from '../../notes/note.entity';
import { NotesModule } from '../../notes/notes.module';
import { NotesService } from '../../notes/notes.service';
import { Tag } from '../../notes/tag.entity';
import { NoteGroupPermission } from '../../permissions/note-group-permission.entity';
import { NotePermission } from '../../permissions/note-permission.enum';
import { NoteUserPermission } from '../../permissions/note-user-permission.entity';
import { PermissionsModule } from '../../permissions/permissions.module';
import { PermissionsService } from '../../permissions/permissions.service';
import { Edit } from '../../revisions/edit.entity';
import { Revision } from '../../revisions/revision.entity';
import { Session } from '../../sessions/session.entity';
import { SessionModule } from '../../sessions/session.module';
import { SessionService } from '../../sessions/session.service';
import { User } from '../../users/user.entity';
import { UsersModule } from '../../users/users.module';
import { UsersService } from '../../users/users.service';
import { Username } from '../../utils/username';
import * as websocketConnectionModule from '../realtime-note/realtime-connection';
import { RealtimeConnection } from '../realtime-note/realtime-connection';
import { RealtimeNote } from '../realtime-note/realtime-note';
import { RealtimeNoteModule } from '../realtime-note/realtime-note.module';
import { RealtimeNoteService } from '../realtime-note/realtime-note.service';
import * as extractNoteIdFromRequestUrlModule from './utils/extract-note-id-from-request-url';
import { WebsocketGateway } from './websocket.gateway';
jest.mock('@hedgedoc/commons');
describe('Websocket gateway', () => {
let gateway: WebsocketGateway;
let sessionService: SessionService;
let usersService: UsersService;
let notesService: NotesService;
let realtimeNoteService: RealtimeNoteService;
let permissionsService: PermissionsService;
let mockedWebsocketConnection: RealtimeConnection;
let mockedWebsocket: WebSocket;
let mockedWebsocketCloseSpy: jest.SpyInstance;
let addClientSpy: jest.SpyInstance;
const mockedValidSessionCookie = 'mockedValidSessionCookie';
const mockedSessionIdWithUser = 'mockedSessionIdWithUser';
const mockedValidUrl = 'mockedValidUrl';
const mockedValidGuestUrl = 'mockedValidGuestUrl';
const mockedValidNoteId = 'mockedValidNoteId';
const mockedValidGuestNoteId = 'mockedValidGuestNoteId';
let sessionExistsForUser = true;
let noteExistsForNoteId = true;
let userExistsForUsername = true;
let userHasReadPermissions = true;
beforeEach(async () => {
jest.resetAllMocks();
jest.resetModules();
sessionExistsForUser = true;
noteExistsForNoteId = true;
userExistsForUsername = true;
userHasReadPermissions = true;
const module: TestingModule = await Test.createTestingModule({
providers: [
WebsocketGateway,
{
provide: getRepositoryToken(Note),
useClass: Repository,
},
{
provide: getRepositoryToken(Group),
useClass: Repository,
},
{
provide: getRepositoryToken(User),
useClass: Repository,
},
],
imports: [
LoggerModule,
NotesModule,
PermissionsModule,
RealtimeNoteModule,
UsersModule,
SessionModule,
ConfigModule.forRoot({
isGlobal: true,
load: [
appConfigMock,
databaseConfigMock,
authConfigMock,
noteConfigMock,
],
}),
EventEmitterModule.forRoot(eventModuleConfig),
],
})
.overrideProvider(getRepositoryToken(User))
.useClass(Repository)
.overrideProvider(getRepositoryToken(ApiToken))
.useValue({})
.overrideProvider(getRepositoryToken(Identity))
.useValue({})
.overrideProvider(getRepositoryToken(Edit))
.useValue({})
.overrideProvider(getRepositoryToken(Revision))
.useValue({})
.overrideProvider(getRepositoryToken(Note))
.useClass(Repository)
.overrideProvider(getRepositoryToken(Tag))
.useValue({})
.overrideProvider(getRepositoryToken(NoteGroupPermission))
.useValue({})
.overrideProvider(getRepositoryToken(NoteUserPermission))
.useValue({})
.overrideProvider(getRepositoryToken(Group))
.useClass(Repository)
.overrideProvider(getRepositoryToken(Session))
.useValue({})
.overrideProvider(getRepositoryToken(Author))
.useValue({})
.overrideProvider(getRepositoryToken(Alias))
.useValue({})
.compile();
gateway = module.get<WebsocketGateway>(WebsocketGateway);
sessionService = module.get<SessionService>(SessionService);
usersService = module.get<UsersService>(UsersService);
notesService = module.get<NotesService>(NotesService);
realtimeNoteService = module.get<RealtimeNoteService>(RealtimeNoteService);
permissionsService = module.get<PermissionsService>(PermissionsService);
jest
.spyOn(sessionService, 'extractSessionIdFromRequest')
.mockImplementation(
(request: IncomingMessage): Optional<string> =>
Optional.ofNullable(
request.headers?.cookie === mockedValidSessionCookie
? mockedSessionIdWithUser
: null,
),
);
const mockUsername: Username = 'mock-username';
jest
.spyOn(sessionService, 'fetchUsernameForSessionId')
.mockImplementation((sessionId: string) =>
sessionExistsForUser && sessionId === mockedSessionIdWithUser
? Promise.resolve(mockUsername)
: Promise.reject('no user for session id found'),
);
const mockUser = Mock.of<User>({ username: mockUsername });
jest
.spyOn(usersService, 'getUserByUsername')
.mockImplementation(
(username: string): Promise<User> =>
userExistsForUsername && username === mockUsername
? Promise.resolve(mockUser)
: Promise.reject('user not found'),
);
jest
.spyOn(extractNoteIdFromRequestUrlModule, 'extractNoteIdFromRequestUrl')
.mockImplementation((request: IncomingMessage): string => {
if (request.url === mockedValidUrl) {
return mockedValidNoteId;
} else if (request.url === mockedValidGuestUrl) {
return mockedValidGuestNoteId;
} else {
throw new Error('no valid note id found');
}
});
const mockedNote = Mock.of<Note>({
id: 4711,
owner: Promise.resolve(mockUser),
userPermissions: Promise.resolve([]),
groupPermissions: Promise.resolve([]),
});
const mockedGuestNote = Mock.of<Note>({
id: 1235,
owner: Promise.resolve(null),
userPermissions: Promise.resolve([]),
groupPermissions: Promise.resolve([]),
});
jest
.spyOn(notesService, 'getNoteByIdOrAlias')
.mockImplementation((noteId: string) => {
if (noteExistsForNoteId && noteId === mockedValidNoteId) {
return Promise.resolve(mockedNote);
}
if (noteId === mockedValidGuestNoteId) {
return Promise.resolve(mockedGuestNote);
} else {
return Promise.reject('no note found');
}
});
jest
.spyOn(permissionsService, 'determinePermission')
.mockImplementation(
async (user: User | null, note: Note): Promise<NotePermission> =>
(user === mockUser &&
note === mockedNote &&
userHasReadPermissions) ||
(user === null && note === mockedGuestNote)
? NotePermission.READ
: NotePermission.DENY,
);
const mockedRealtimeNote = Mock.of<RealtimeNote>({
addClient() {
//intentionally left blank
},
});
jest
.spyOn(realtimeNoteService, 'getOrCreateRealtimeNote')
.mockReturnValue(Promise.resolve(mockedRealtimeNote));
mockedWebsocketConnection = Mock.of<RealtimeConnection>();
jest
.spyOn(websocketConnectionModule, 'RealtimeConnection')
.mockReturnValue(mockedWebsocketConnection);
mockedWebsocket = Mock.of<WebSocket>({
close() {
//intentionally left blank
},
});
mockedWebsocketCloseSpy = jest.spyOn(mockedWebsocket, 'close');
addClientSpy = jest.spyOn(mockedRealtimeNote, 'addClient');
});
it('adds a valid connection request without a session', async () => {
const request = Mock.of<IncomingMessage>({
socket: {
remoteAddress: 'mockHost',
},
url: mockedValidGuestUrl,
headers: {},
});
await expect(
gateway.handleConnection(mockedWebsocket, request),
).resolves.not.toThrow();
expect(addClientSpy).toHaveBeenCalledWith(mockedWebsocketConnection);
expect(mockedWebsocketCloseSpy).not.toHaveBeenCalled();
});
it('adds a valid connection request', async () => {
const request = Mock.of<IncomingMessage>({
socket: {
remoteAddress: 'mockHost',
},
url: mockedValidUrl,
headers: {
cookie: mockedValidSessionCookie,
},
});
await expect(
gateway.handleConnection(mockedWebsocket, request),
).resolves.not.toThrow();
expect(addClientSpy).toHaveBeenCalledWith(mockedWebsocketConnection);
expect(mockedWebsocketCloseSpy).not.toHaveBeenCalled();
});
it('closes the connection if invalid session cookie', async () => {
const request = Mock.of<IncomingMessage>({
socket: {
remoteAddress: 'mockHost',
},
url: mockedValidUrl,
headers: {
cookie: 'invalid session cookie',
},
});
await expect(
gateway.handleConnection(mockedWebsocket, request),
).resolves.not.toThrow();
expect(addClientSpy).not.toHaveBeenCalled();
expect(mockedWebsocketCloseSpy).toHaveBeenCalled();
});
it("closes the connection if session doesn't exist", async () => {
sessionExistsForUser = false;
const request = Mock.of<IncomingMessage>({
socket: {
remoteAddress: 'mockHost',
},
url: mockedValidUrl,
headers: {
cookie: mockedValidSessionCookie,
},
});
await expect(
gateway.handleConnection(mockedWebsocket, request),
).resolves.not.toThrow();
expect(addClientSpy).not.toHaveBeenCalled();
expect(mockedWebsocketCloseSpy).toHaveBeenCalled();
});
it("closes the connection if user doesn't exist for username", async () => {
userExistsForUsername = false;
const request = Mock.of<IncomingMessage>({
socket: {
remoteAddress: 'mockHost',
},
url: mockedValidUrl,
headers: {
cookie: mockedValidSessionCookie,
},
});
await expect(
gateway.handleConnection(mockedWebsocket, request),
).resolves.not.toThrow();
expect(addClientSpy).not.toHaveBeenCalled();
expect(mockedWebsocketCloseSpy).toHaveBeenCalled();
});
it("closes the connection if url doesn't contain a valid note id", async () => {
const request = Mock.of<IncomingMessage>({
socket: {
remoteAddress: 'mockHost',
},
url: 'invalid url',
headers: {
cookie: mockedValidSessionCookie,
},
});
await expect(
gateway.handleConnection(mockedWebsocket, request),
).resolves.not.toThrow();
expect(addClientSpy).not.toHaveBeenCalled();
expect(mockedWebsocketCloseSpy).toHaveBeenCalled();
});
it('closes the connection if url contains an invalid note id', async () => {
noteExistsForNoteId = false;
const request = Mock.of<IncomingMessage>({
socket: {
remoteAddress: 'mockHost',
},
url: mockedValidUrl,
headers: {
cookie: mockedValidSessionCookie,
},
});
await expect(
gateway.handleConnection(mockedWebsocket, request),
).resolves.not.toThrow();
expect(addClientSpy).not.toHaveBeenCalled();
expect(mockedWebsocketCloseSpy).toHaveBeenCalled();
});
it('closes the connection if user has no read permissions', async () => {
userHasReadPermissions = false;
const request = Mock.of<IncomingMessage>({
socket: {
remoteAddress: 'mockHost',
},
url: mockedValidUrl,
headers: {
cookie: mockedValidSessionCookie,
},
});
await expect(
gateway.handleConnection(mockedWebsocket, request),
).resolves.not.toThrow();
expect(addClientSpy).not.toHaveBeenCalled();
expect(mockedWebsocketCloseSpy).toHaveBeenCalled();
});
});