diff --git a/backend/.eslintrc.js b/backend/.eslintrc.js index e36689c99..4264f0dca 100644 --- a/backend/.eslintrc.js +++ b/backend/.eslintrc.js @@ -1,6 +1,7 @@ -/* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) +/* + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * - * SPDX-License-Identifier: CC0-1.0 + * SPDX-License-Identifier: AGPL-3.0-only */ module.exports = { parser: '@typescript-eslint/parser', @@ -19,12 +20,15 @@ module.exports = { '@typescript-eslint/no-unsafe-member-access': 'off', '@typescript-eslint/require-await': 'off', '@typescript-eslint/explicit-function-return-type': 'off', + // This rule seems to create trouble with our tests and mock-knex-client + '@darraghor/nestjs-typed/provided-injected-should-match-factory-parameters': + 'off', 'jest/unbound-method': 'error', 'jest/expect-expect': [ 'error', { assertFunctionNames: [ - 'expect', + 'expect**', 'request.**.expect', 'agent[0-9]?.**.expect', ], diff --git a/backend/package.json b/backend/package.json index 514765ac6..5c382c53f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -102,6 +102,7 @@ "eslint-plugin-local-rules": "3.0.2", "eslint-plugin-prettier": "5.2.3", "jest": "29.7.0", + "knex-mock-client": "3.0.2", "mocked-env": "1.3.5", "prettier": "3.3.3", "source-map-support": "0.5.21", diff --git a/backend/src/alias/alias.service.spec.ts b/backend/src/alias/alias.service.spec.ts index afa5a9b2a..367ae845d 100644 --- a/backend/src/alias/alias.service.spec.ts +++ b/backend/src/alias/alias.service.spec.ts @@ -3,80 +3,53 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ +import { FieldNameAlias, TableAlias } from '@hedgedoc/database'; +import { Provider } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; -import { EventEmitterModule } from '@nestjs/event-emitter'; import { Test, TestingModule } from '@nestjs/testing'; -import { Mock } from 'ts-mockery'; +import type { Tracker } from 'knex-mock-client'; 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 { expectBindings } from '../database/mock/expect-bindings'; +import { + mockDelete, + mockInsert, + mockSelect, + mockUpdate, +} from '../database/mock/mock-queries'; +import { mockKnexDb } from '../database/mock/provider'; import { AlreadyInDBError, ForbiddenIdError, + GenericDBError, NotInDBError, PrimaryAliasDeletionForbiddenError, } from '../errors/errors'; -import { eventModuleConfig } from '../events'; -import { GroupsModule } from '../groups/groups.module'; import { LoggerModule } from '../logger/logger.module'; -import { NoteService } from '../notes/note.service'; -import { RealtimeNoteModule } from '../realtime/realtime-note/realtime-note.module'; -import { RevisionsModule } from '../revisions/revisions.module'; -import { UsersModule } from '../users/users.module'; -import { AliasModule } from './alias.module'; import { AliasService } from './alias.service'; describe('AliasService', () => { + const alias1 = 'testAlias1'; + const alias2 = 'testAlias2'; + const noteId1 = 1; + let service: AliasService; - let noteRepo: Repository; - let aliasRepo: Repository; let forbiddenNoteId: string; - beforeEach(async () => { - noteRepo = new Repository( - '', - new EntityManager( - new DataSource({ - type: 'sqlite', - database: ':memory:', - }), - ), - undefined, - ); - aliasRepo = new Repository( - '', - new EntityManager( - new DataSource({ - type: 'sqlite', - database: ':memory:', - }), - ), - undefined, - ); + + let knexProvider: Provider; + let tracker: Tracker; + + beforeAll(async () => { + [tracker, knexProvider] = mockKnexDb(); + const module: TestingModule = await Test.createTestingModule({ - providers: [ - AliasService, - NoteService, - { - provide: getRepositoryToken(Note), - useValue: noteRepo, - }, - { - provide: getRepositoryToken(Alias), - useValue: aliasRepo, - }, - { - provide: getRepositoryToken(Tag), - useClass: Repository, - }, - { - provide: getRepositoryToken(User), - useClass: Repository, - }, - ], + providers: [AliasService, knexProvider], imports: [ - ConfigModule.forRoot({ + LoggerModule, + await ConfigModule.forRoot({ isGlobal: true, load: [ appConfigMock, @@ -85,213 +58,296 @@ describe('AliasService', () => { noteConfigMock, ], }), - LoggerModule, - UsersModule, - GroupsModule, - RevisionsModule, - AliasModule, - RealtimeNoteModule, - EventEmitterModule.forRoot(eventModuleConfig), ], - }) - .overrideProvider(getRepositoryToken(Note)) - .useValue(noteRepo) - .overrideProvider(getRepositoryToken(Tag)) - .useClass(Repository) - .overrideProvider(getRepositoryToken(Alias)) - .useValue(aliasRepo) - .overrideProvider(getRepositoryToken(User)) - .useClass(Repository) - .overrideProvider(getRepositoryToken(ApiToken)) - .useValue({}) - .overrideProvider(getRepositoryToken(Identity)) - .useValue({}) - .overrideProvider(getRepositoryToken(Edit)) - .useValue({}) - .overrideProvider(getRepositoryToken(Revision)) - .useClass(Repository) - .overrideProvider(getRepositoryToken(NoteGroupPermission)) - .useValue({}) - .overrideProvider(getRepositoryToken(NoteUserPermission)) - .useValue({}) - .overrideProvider(getRepositoryToken(Group)) - .useClass(Repository) - .overrideProvider(getRepositoryToken(Session)) - .useValue({}) - .overrideProvider(getRepositoryToken(Author)) - .useValue({}) - .compile(); + }).compile(); const config = module.get(ConfigService); forbiddenNoteId = config.get('noteConfig').forbiddenNoteIds[0]; service = module.get(AliasService); - noteRepo = module.get>(getRepositoryToken(Note)); - aliasRepo = module.get>(getRepositoryToken(Alias)); }); - describe('addAlias', () => { - const alias = 'testAlias'; - const alias2 = 'testAlias2'; - const user = User.create('hardcoded', 'Testy') as User; - describe('creates', () => { - it('an primary aliases if no aliases is already present', async () => { - const note = Note.create(user) as Note; - jest - .spyOn(noteRepo, 'save') - .mockImplementationOnce(async (note: Note): Promise => note); - jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(false); - jest.spyOn(aliasRepo, 'existsBy').mockResolvedValueOnce(false); - const savedAlias = await service.addAlias(note, alias); - expect(savedAlias.name).toEqual(alias); - expect(savedAlias.primary).toBeTruthy(); - }); - it('an non-primary aliases if an primary aliases is already present', async () => { - const note = Note.create(user, alias) as Note; - jest - .spyOn(noteRepo, 'save') - .mockImplementationOnce(async (note: Note): Promise => note); - jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(false); - jest.spyOn(aliasRepo, 'existsBy').mockResolvedValueOnce(false); - const savedAlias = await service.addAlias(note, alias2); - expect(savedAlias.name).toEqual(alias2); - expect(savedAlias.primary).toBeFalsy(); - }); - }); - describe('does not create an aliases', () => { - const note = Note.create(user, alias2) as Note; - it('with an already used name', async () => { - jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(false); - jest.spyOn(aliasRepo, 'existsBy').mockResolvedValueOnce(true); - await expect(service.addAlias(note, alias2)).rejects.toThrow( - AlreadyInDBError, - ); - }); - it('with a forbidden name', async () => { - await expect(service.addAlias(note, forbiddenNoteId)).rejects.toThrow( - ForbiddenIdError, - ); - }); + + afterEach(() => { + tracker.reset(); + }); + + describe('generateRandomAlias', () => { + it('generates a 26 character long string', () => { + const randomId = service.generateRandomAlias(); + // 16 bytes encoded as base32 with Crockford results in 26 characters + expect(randomId).toHaveLength(26); }); }); - describe('removeAlias', () => { - const alias = 'testAlias'; - const alias2 = 'testAlias2'; - const user = User.create('hardcoded', 'Testy') as User; - describe('removes one aliases correctly', () => { - let note: Note; - beforeAll(async () => { - note = Note.create(user, alias) as Note; - (await note.aliases).push(Alias.create(alias2, note, false) as Alias); - }); - it('with two aliases', async () => { - jest - .spyOn(noteRepo, 'save') - .mockImplementationOnce(async (note: Note): Promise => note); - jest - .spyOn(aliasRepo, 'remove') - .mockImplementationOnce( - async (alias: Alias): Promise => alias, - ); - const savedNote = await service.removeAlias(note, alias2); - const aliases = await savedNote.aliases; - expect(aliases).toHaveLength(1); - expect(aliases[0].name).toEqual(alias); - expect(aliases[0].primary).toBeTruthy(); - }); - it('with one aliases, that is primary', async () => { - jest - .spyOn(noteRepo, 'save') - .mockImplementationOnce(async (note: Note): Promise => note); - jest - .spyOn(aliasRepo, 'remove') - .mockImplementationOnce( - async (alias: Alias): Promise => alias, - ); - const savedNote = await service.removeAlias(note, alias); - expect(await savedNote.aliases).toHaveLength(0); - }); - }); - describe('does not remove one aliases', () => { - let note: Note; - beforeEach(async () => { - note = Note.create(user, alias) as Note; - (await note.aliases).push(Alias.create(alias2, note, false) as Alias); - }); - it('if the aliases is unknown', async () => { - await expect(service.removeAlias(note, 'non existent')).rejects.toThrow( - NotInDBError, + describe('addAlias', () => { + describe('creates', () => { + it('a primary alias if no aliases are already present', async () => { + mockSelect( + tracker, + [FieldNameAlias.alias], + TableAlias, + FieldNameAlias.noteId, + [], ); + mockInsert(tracker, TableAlias, [ + FieldNameAlias.alias, + FieldNameAlias.isPrimary, + FieldNameAlias.noteId, + ]); + await service.addAlias(noteId1, alias1); + expectBindings(tracker, 'select', [[noteId1]]); + expectBindings(tracker, 'insert', [[alias1, true, noteId1]]); }); - it('if it is primary and not the last one', async () => { - await expect(service.removeAlias(note, alias)).rejects.toThrow( - PrimaryAliasDeletionForbiddenError, + + it('a non-primary alias if a primary alias is already present', async () => { + mockSelect( + tracker, + [FieldNameAlias.alias], + TableAlias, + FieldNameAlias.noteId, + [alias2], ); + mockInsert(tracker, TableAlias, [ + FieldNameAlias.alias, + FieldNameAlias.isPrimary, + FieldNameAlias.noteId, + ]); + await service.addAlias(noteId1, alias2); + expectBindings(tracker, 'select', [[noteId1]]); + expectBindings(tracker, 'insert', [[alias2, false, noteId1]]); }); }); }); describe('makeAliasPrimary', () => { - const user = User.create('hardcoded', 'Testy') as User; - const aliasName = 'testAlias'; - let note: Note; - let alias: Alias; - let alias2: Alias; - beforeEach(async () => { - note = Note.create(user, aliasName) as Note; - alias = Alias.create(aliasName, note, true) as Alias; - alias2 = Alias.create('testAlias2', note, false) as Alias; - (await note.aliases).push( - Alias.create('testAlias2', note, false) as Alias, - ); - }); - - it('mark the aliases as primary', async () => { - jest - .spyOn(aliasRepo, 'findOneByOrFail') - .mockResolvedValueOnce(alias) - .mockResolvedValueOnce(alias2); - jest - .spyOn(aliasRepo, 'save') - .mockImplementationOnce(async (alias: Alias): Promise => alias) - .mockImplementationOnce(async (alias: Alias): Promise => alias); - - mockSelectQueryBuilderInRepo( - noteRepo, - Mock.of({ - ...note, - aliases: Promise.resolve( - (await note.aliases).map((anAlias) => { - if (anAlias.primary) { - anAlias.primary = false; - } - if (anAlias.name === alias2.name) { - anAlias.primary = true; - } - return anAlias; - }), - ), - }), + it('marks the alias as primary', async () => { + mockUpdate( + tracker, + TableAlias, + [FieldNameAlias.isPrimary], + FieldNameAlias.noteId, ); - const savedAlias = await service.makeAliasPrimary(note, alias2.name); - expect(savedAlias.name).toEqual(alias2.name); - expect(savedAlias.primary).toBeTruthy(); + mockUpdate( + tracker, + TableAlias, + [FieldNameAlias.isPrimary], + FieldNameAlias.noteId, + ); + + await service.makeAliasPrimary(noteId1, alias2); + + expectBindings(tracker, 'update', [ + [null, noteId1], + [true, noteId1, alias2], + ]); }); - it('does not mark the aliases as primary, if the aliases does not exist', async () => { + it('does not mark the aliases as primary, if the alias does not exist', async () => { + mockUpdate( + tracker, + TableAlias, + [FieldNameAlias.isPrimary], + FieldNameAlias.noteId, + [], + ); await expect( - service.makeAliasPrimary(note, 'i_dont_exist'), + service.makeAliasPrimary(noteId1, 'i_dont_exist'), + ).rejects.toThrow(GenericDBError); + expectBindings(tracker, 'update', [[null, noteId1]]); + }); + it("does not mark the aliases as primary, if the alias can't be made primary", async () => { + mockUpdate( + tracker, + TableAlias, + [FieldNameAlias.isPrimary], + FieldNameAlias.noteId, + [ + { + [FieldNameAlias.isPrimary]: null, + }, + ], + ); + await expect( + service.makeAliasPrimary(noteId1, 'i_dont_exist'), ).rejects.toThrow(NotInDBError); + expectBindings(tracker, 'update', [ + [null, noteId1], + [true, noteId1, 'i_dont_exist'], + ]); }); }); - it('toAliasDto correctly creates an AliasDto', () => { - const aliasName = 'testAlias'; - const user = User.create('hardcoded', 'Testy') as User; - const note = Note.create(user, aliasName) as Note; - const alias = Alias.create(aliasName, note, true) as Alias; - const aliasDto = service.toAliasDto(alias, note); - expect(aliasDto.name).toEqual(aliasName); - expect(aliasDto.primaryAlias).toBeTruthy(); - expect(aliasDto.noteId).toEqual(note.publicId); + describe('removeAlias', () => { + it('fails if alias does not exist', async () => { + mockSelect(tracker, [], TableAlias, FieldNameAlias.alias); + await expect(service.removeAlias(alias1)).rejects.toThrow(NotInDBError); + expectBindings(tracker, 'select', [[alias1]]); + }); + + it('fails if alias is primary', async () => { + mockSelect(tracker, [], TableAlias, FieldNameAlias.alias, [ + { + [FieldNameAlias.alias]: alias1, + [FieldNameAlias.noteId]: noteId1, + [FieldNameAlias.isPrimary]: true, + }, + ]); + mockDelete( + tracker, + TableAlias, + [FieldNameAlias.alias, FieldNameAlias.noteId, FieldNameAlias.isPrimary], + 0, + ); + await expect(service.removeAlias(alias1)).rejects.toThrow( + PrimaryAliasDeletionForbiddenError, + ); + expectBindings(tracker, 'select', [[alias1]]); + expectBindings(tracker, 'delete', [[alias1, noteId1]]); + }); + + it('correctly deletes the alias', async () => { + mockSelect(tracker, [], TableAlias, FieldNameAlias.alias, [ + { + [FieldNameAlias.alias]: alias2, + [FieldNameAlias.noteId]: noteId1, + [FieldNameAlias.isPrimary]: false, + }, + ]); + mockDelete(tracker, TableAlias, [ + FieldNameAlias.alias, + FieldNameAlias.noteId, + FieldNameAlias.isPrimary, + ]); + await service.removeAlias(alias2); + expectBindings(tracker, 'select', [[alias2]]); + expectBindings(tracker, 'delete', [[alias2, noteId1]]); + }); + }); + + describe('getPrimaryAliasByNoteId', () => { + it('does not return alias if note does not exits', async () => { + mockSelect( + tracker, + [FieldNameAlias.alias], + TableAlias, + [FieldNameAlias.noteId, FieldNameAlias.isPrimary], + [], + ); + await expect(service.getPrimaryAliasByNoteId(noteId1)).rejects.toThrow( + NotInDBError, + ); + expectBindings(tracker, 'select', [[noteId1, true]], true); + }); + + it('return primary alias', async () => { + mockSelect( + tracker, + [FieldNameAlias.alias], + TableAlias, + [FieldNameAlias.noteId, FieldNameAlias.isPrimary], + { + [FieldNameAlias.alias]: alias1, + }, + ); + const result = await service.getPrimaryAliasByNoteId(noteId1); + expect(result).toEqual(alias1); + expectBindings(tracker, 'select', [[noteId1, true]], true); + }); + }); + + describe('getAllAliases', () => { + it('throws an error if a note has no aliases', async () => { + mockSelect( + tracker, + [FieldNameAlias.alias], + TableAlias, + [FieldNameAlias.noteId, FieldNameAlias.isPrimary], + [], + ); + await expect(service.getPrimaryAliasByNoteId(noteId1)).rejects.toThrow( + NotInDBError, + ); + }); + + it('returns all aliases for a note', async () => { + const alias1Object = { + [FieldNameAlias.alias]: alias1, + [FieldNameAlias.noteId]: noteId1, + [FieldNameAlias.isPrimary]: true, + }; + const alias2Object = { + [FieldNameAlias.alias]: alias2, + [FieldNameAlias.noteId]: noteId1, + [FieldNameAlias.isPrimary]: null, + }; + mockSelect( + tracker, + [FieldNameAlias.alias, FieldNameAlias.isPrimary], + TableAlias, + [FieldNameAlias.noteId], + [alias1Object, alias2Object], + ); + const aliases = await service.getAllAliases(1); + expect(aliases).toEqual([alias1Object, alias2Object]); + }); + }); + + describe('ensureAliasIsAvailable', () => { + it('throws ForbiddenIdError for forbidden aliases', async () => { + await expect( + service.ensureAliasIsAvailable(forbiddenNoteId), + ).rejects.toThrow(ForbiddenIdError); + }); + it('throws AlreadyInDBError for already used aliases', async () => { + mockSelect( + tracker, + [FieldNameAlias.alias], + TableAlias, + FieldNameAlias.alias, + [ + { + [FieldNameAlias.alias]: alias1, + }, + ], + ); + await expect(service.ensureAliasIsAvailable(alias1)).rejects.toThrow( + AlreadyInDBError, + ); + expectBindings(tracker, 'select', [[alias1]]); + }); + it('returns void if alias can be used', async () => { + mockSelect( + tracker, + [FieldNameAlias.alias], + TableAlias, + FieldNameAlias.alias, + [], + ); + await service.ensureAliasIsAvailable(alias1); + expectBindings(tracker, 'select', [[alias1]]); + }); + }); + + describe('isAliasForbidden', () => { + it('returns true for forbidden aliases', async () => { + const result = service.isAliasForbidden(forbiddenNoteId); + expect(result).toBe(true); + }); + it('return false for other aliases', async () => { + const result = service.isAliasForbidden(alias1); + expect(result).toBe(false); + }); + }); + + describe('toAliasDto correctly creates an AliasDto', () => { + it('with a primary alias', () => { + const primaryAliasDto = service.toAliasDto(alias1, true); + expect(primaryAliasDto.name).toEqual(alias1); + expect(primaryAliasDto.isPrimaryAlias).toBe(true); + }); + it('with a non-primary alias', () => { + const nonPrimaryAliasDto = service.toAliasDto(alias2, false); + expect(nonPrimaryAliasDto.name).toEqual(alias2); + expect(nonPrimaryAliasDto.isPrimaryAlias).toBe(false); + }); }); }); diff --git a/backend/src/alias/alias.service.ts b/backend/src/alias/alias.service.ts index cb94b3b7d..563315130 100644 --- a/backend/src/alias/alias.service.ts +++ b/backend/src/alias/alias.service.ts @@ -12,9 +12,9 @@ import { } from '@hedgedoc/database'; import { Inject, Injectable } from '@nestjs/common'; import base32Encode from 'base32-encode'; -import { randomBytes } from 'crypto'; import { Knex } from 'knex'; import { InjectConnection } from 'nest-knexjs'; +import { randomBytes } from 'node:crypto'; import noteConfiguration, { NoteConfig } from '../config/note.config'; import { @@ -97,9 +97,9 @@ export class AliasService { const numberOfUpdatedEntries = await transaction(TableAlias) // This needs to be NULL in the database, as the constraints forbid multiple "false" values for the same note. // These are the same constraints that also ensure only one alias is primary ("true"). - .update(FieldNameAlias.isPrimary, null) - .where(FieldNameAlias.noteId, noteId); - if (numberOfUpdatedEntries === 0) { + .where(FieldNameAlias.noteId, noteId) + .update(FieldNameAlias.isPrimary, null, [FieldNameAlias.alias]); + if (numberOfUpdatedEntries.length === 0) { throw new GenericDBError( 'The note does not exist or has no primary alias. This should never happen', this.logger.getContext(), @@ -264,7 +264,10 @@ export class AliasService { * @param transaction The optional transaction to access the db * @returns true if the alias is already used, false otherwise */ - async isAliasUsed(alias: string, transaction?: Knex): Promise { + private async isAliasUsed( + alias: string, + transaction?: Knex, + ): Promise { const dbActor = transaction ? transaction : this.knex; const result = await dbActor(TableAlias) .select(FieldNameAlias.alias) diff --git a/backend/src/api-token/api-token.module.ts b/backend/src/api-token/api-token.module.ts index 38b6fec8e..74529209b 100644 --- a/backend/src/api-token/api-token.module.ts +++ b/backend/src/api-token/api-token.module.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -9,11 +9,10 @@ import { KnexModule } from 'nest-knexjs'; import { ApiTokenGuard } from '../api/utils/guards/api-token.guard'; import { MockApiTokenGuard } from '../api/utils/guards/mock-api-token.guard'; import { LoggerModule } from '../logger/logger.module'; -import { UsersModule } from '../users/users.module'; import { ApiTokenService } from './api-token.service'; @Module({ - imports: [UsersModule, LoggerModule, KnexModule], + imports: [LoggerModule, KnexModule], providers: [ApiTokenService, ApiTokenGuard, MockApiTokenGuard], exports: [ApiTokenService, ApiTokenGuard], }) diff --git a/backend/src/api-token/api-token.service.spec.ts b/backend/src/api-token/api-token.service.spec.ts index e0fc87ebb..7032a8b7a 100644 --- a/backend/src/api-token/api-token.service.spec.ts +++ b/backend/src/api-token/api-token.service.spec.ts @@ -3,374 +3,424 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ +import { ApiTokenWithSecretDto } from '@hedgedoc/commons'; +import { FieldNameApiToken, TableApiToken } from '@hedgedoc/database'; +import { Provider } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import crypto from 'crypto'; -import { Repository } from 'typeorm'; +import type { Tracker } from 'knex-mock-client'; -import { Identity } from '../auth/identity.entity'; import appConfigMock from '../config/mock/app.config.mock'; import authConfigMock from '../config/mock/auth.config.mock'; -import { User } from '../database/user.entity'; +import { expectBindings } from '../database/mock/expect-bindings'; +import { + mockDelete, + mockInsert, + mockSelect, + mockUpdate, +} from '../database/mock/mock-queries'; +import { mockKnexDb } from '../database/mock/provider'; import { NotInDBError, TokenNotValidError, TooManyTokensError, } from '../errors/errors'; import { LoggerModule } from '../logger/logger.module'; -import { Session } from '../sessions/session.entity'; -import { UsersModule } from '../users/users.module'; -import { ApiToken } from './api-token.entity'; -import { ApiTokenService } from './api-token.service'; +import * as passwordUtils from '../utils/password'; +import { ApiTokenService, AUTH_TOKEN_PREFIX } from './api-token.service'; + +jest.mock('../utils/password'); describe('ApiTokenService', () => { + const validSecret = + 'gNrv_NJ4FHZ0UFZJQu_q_3i3-GP_d6tELVtkYiMFLkLWNl_dxEmPVAsCNKxP3N3DB9aGBVFYE1iptvw7hFMJvA'; + const validKeyId = '12345678901'; + const userId = 1; + const label = 'test token'; + let service: ApiTokenService; - let user: User; - let apiToken: ApiToken; - let userRepo: Repository; - let apiTokenRepo: Repository; - class CreateQueryBuilderClass { - leftJoinAndSelect: () => CreateQueryBuilderClass; - where: () => CreateQueryBuilderClass; - orWhere: () => CreateQueryBuilderClass; - setParameter: () => CreateQueryBuilderClass; - getOne: () => ApiToken; - getMany: () => ApiToken[]; - } + let knexProvider: Provider; + let tracker: Tracker; - let createQueryBuilderFunc: CreateQueryBuilderClass; - - beforeEach(async () => { + beforeAll(async () => { + [tracker, knexProvider] = mockKnexDb(); const module: TestingModule = await Test.createTestingModule({ - providers: [ - ApiTokenService, - { - provide: getRepositoryToken(ApiToken), - useClass: Repository, - }, - ], + providers: [ApiTokenService, knexProvider], imports: [ ConfigModule.forRoot({ isGlobal: true, load: [appConfigMock, authConfigMock], }), - UsersModule, LoggerModule, ], - }) - .overrideProvider(getRepositoryToken(Identity)) - .useValue({}) - .overrideProvider(getRepositoryToken(User)) - .useClass(Repository) - .overrideProvider(getRepositoryToken(Session)) - .useValue({}) - .compile(); + }).compile(); service = module.get(ApiTokenService); - userRepo = module.get>(getRepositoryToken(User)); - apiTokenRepo = module.get>( - getRepositoryToken(ApiToken), - ); - - user = User.create('hardcoded', 'Testy') as User; - apiToken = ApiToken.create( - 'testKeyId', - user, - 'testToken', - 'abc', - new Date(new Date().getTime() + 60000), // make this AuthToken valid for 1min - ) as ApiToken; - - const createQueryBuilder = { - leftJoinAndSelect: () => createQueryBuilder, - where: () => createQueryBuilder, - orWhere: () => createQueryBuilder, - setParameter: () => createQueryBuilder, - getOne: () => apiToken, - getMany: () => [apiToken], - }; - createQueryBuilderFunc = createQueryBuilder; - jest - .spyOn(apiTokenRepo, 'createQueryBuilder') - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - .mockImplementation(() => createQueryBuilder); - - jest.spyOn(apiTokenRepo, 'find').mockResolvedValue([apiToken]); }); - describe('getTokensByUser', () => { - it('works', async () => { - createQueryBuilderFunc.getMany = () => [apiToken]; - const tokens = await service.getTokensOfUserById(user); - expect(tokens).toHaveLength(1); - expect(tokens).toEqual([apiToken]); - }); - it('should return empty array if token for user do not exists', async () => { - jest.spyOn(apiTokenRepo, 'find').mockImplementationOnce(async () => []); - const tokens = await service.getTokensOfUserById(user); - expect(tokens).toHaveLength(0); - expect(tokens).toEqual([]); - }); + afterEach(() => { + tracker.reset(); + jest.restoreAllMocks(); }); - describe('getToken', () => { - const token = 'testToken'; - it('works', async () => { - const accessTokenHash = crypto - .createHash('sha512') - .update(token) - .digest('hex'); - jest.spyOn(apiTokenRepo, 'findOne').mockResolvedValueOnce({ - ...apiToken, - user: Promise.resolve(user), - hash: accessTokenHash, - }); - const authTokenFromCall = await service.getToken(apiToken.keyId); - expect(authTokenFromCall).toEqual({ - ...apiToken, - user: Promise.resolve(user), - hash: accessTokenHash, - }); - }); - describe('fails:', () => { - it('AuthToken could not be found', async () => { - jest.spyOn(apiTokenRepo, 'findOne').mockResolvedValueOnce(null); - await expect(service.getToken(apiToken.keyId)).rejects.toThrow( - NotInDBError, - ); - }); - }); - }); - describe('checkToken', () => { - it('works', () => { - const [accessToken, secret] = service.createToken( - user, - 'TestToken', - null, - ); - - expect(() => - service.ensureTokenIsValid(secret, accessToken as ApiToken), - ).not.toThrow(); - }); - it('AuthToken has wrong hash', () => { - const [accessToken] = service.createToken(user, 'TestToken', null); - expect(() => - service.ensureTokenIsValid('secret', accessToken as ApiToken), - ).toThrow(TokenNotValidError); - }); - it('AuthToken has wrong validUntil Date', () => { - const [accessToken, secret] = service.createToken( - user, - 'Test', - new Date(1549312452000), - ); - expect(() => - service.ensureTokenIsValid(secret, accessToken as ApiToken), - ).toThrow(TokenNotValidError); - }); - }); - - describe('setLastUsedToken', () => { - it('works', async () => { - jest.spyOn(apiTokenRepo, 'findOne').mockResolvedValueOnce({ - ...apiToken, - user: Promise.resolve(user), - lastUsedAt: new Date(1549312452000), - }); - jest - .spyOn(apiTokenRepo, 'save') - .mockImplementationOnce( - async (authTokenSaved, _): Promise => { - expect(authTokenSaved.keyId).toEqual(apiToken.keyId); - expect(authTokenSaved.lastUsedAt).not.toEqual(1549312452000); - return apiToken; - }, - ); - await service.setLastUsedToken(apiToken.keyId); - }); - it('throws if the token is not in the database', async () => { - jest.spyOn(apiTokenRepo, 'findOne').mockResolvedValueOnce(null); - await expect(service.setLastUsedToken(apiToken.keyId)).rejects.toThrow( - NotInDBError, - ); - }); - }); - - describe('validateToken', () => { - it('works', async () => { - const testSecret = - 'gNrv_NJ4FHZ0UFZJQu_q_3i3-GP_d6tELVtkYiMFLkLWNl_dxEmPVAsCNKxP3N3DB9aGBVFYE1iptvw7hFMJvA'; - const accessTokenHash = crypto - .createHash('sha512') - .update(testSecret) - .digest('hex'); - jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce({ - ...user, - apiTokens: Promise.resolve([apiToken]), - }); - jest.spyOn(apiTokenRepo, 'findOne').mockResolvedValue({ - ...apiToken, - user: Promise.resolve(user), - hash: accessTokenHash, - }); - jest - .spyOn(apiTokenRepo, 'save') - .mockImplementationOnce(async (_, __): Promise => { - return apiToken; - }); - const userByToken = await service.getUserIdForToken( - `hd2.${apiToken.keyId}.${testSecret}`, - ); - expect(userByToken).toEqual({ - ...user, - apiTokens: Promise.resolve([apiToken]), - }); - }); - describe('fails:', () => { - it('the prefix is missing', async () => { + describe('getUserIdForToken', () => { + describe('fails if', () => { + it('the keyId has an invalid length', async () => { await expect( - service.getUserIdForToken(`${apiToken.keyId}.${'a'.repeat(73)}`), - ).rejects.toThrow(TokenNotValidError); - }); - it('the prefix is wrong', async () => { - await expect( - service.getUserIdForToken(`hd1.${apiToken.keyId}.${'a'.repeat(73)}`), + service.getUserIdForToken( + `${AUTH_TOKEN_PREFIX}.123456789.${validSecret}`, + ), ).rejects.toThrow(TokenNotValidError); }); it('the secret is missing', async () => { await expect( - service.getUserIdForToken(`hd2.${apiToken.keyId}`), + service.getUserIdForToken(`${AUTH_TOKEN_PREFIX}.${validKeyId}`), ).rejects.toThrow(TokenNotValidError); }); - it('the secret is too long', async () => { + it('the secret has an invalid length', async () => { await expect( - service.getUserIdForToken(`hd2.${apiToken.keyId}.${'a'.repeat(73)}`), + service.getUserIdForToken( + `${AUTH_TOKEN_PREFIX}.${validKeyId}.${'a'.repeat(73)}`, + ), + ).rejects.toThrow(TokenNotValidError); + }); + it('the prefix is wrong', async () => { + await expect( + service.getUserIdForToken(`hd1.${validKeyId}.${validSecret}`), ).rejects.toThrow(TokenNotValidError); }); it('the token contains sections after the secret', async () => { await expect( service.getUserIdForToken( - `hd2.${apiToken.keyId}.${'a'.repeat(73)}.extra`, + `${AUTH_TOKEN_PREFIX}.${validKeyId}.${validSecret}.extra`, ), ).rejects.toThrow(TokenNotValidError); }); + it('the token does not exist in the database', async () => { + mockSelect( + tracker, + [ + FieldNameApiToken.secretHash, + FieldNameApiToken.userId, + FieldNameApiToken.validUntil, + ], + TableApiToken, + FieldNameApiToken.id, + [], + ); + await expect( + service.getUserIdForToken( + `${AUTH_TOKEN_PREFIX}.${validKeyId}.${validSecret}`, + ), + ).rejects.toThrow(TokenNotValidError); + expectBindings(tracker, 'select', [[validKeyId]], true); + }); + it('ensureTokenIsValid does throw error', async () => { + mockSelect( + tracker, + [ + FieldNameApiToken.secretHash, + FieldNameApiToken.userId, + FieldNameApiToken.validUntil, + ], + TableApiToken, + FieldNameApiToken.id, + [ + { + [FieldNameApiToken.secretHash]: 'foo', + [FieldNameApiToken.userId]: userId, + [FieldNameApiToken.validUntil]: 1, + }, + ], + ); + jest.spyOn(service, 'ensureTokenIsValid').mockImplementation(() => { + throw new TokenNotValidError(); + }); + await expect( + service.getUserIdForToken( + `${AUTH_TOKEN_PREFIX}.${validKeyId}.${validSecret}`, + ), + ).rejects.toThrow(TokenNotValidError); + expectBindings(tracker, 'select', [[validKeyId]], true); + }); + }); + it('works', async () => { + mockSelect( + tracker, + [ + FieldNameApiToken.secretHash, + FieldNameApiToken.userId, + FieldNameApiToken.validUntil, + ], + TableApiToken, + FieldNameApiToken.id, + [ + { + [FieldNameApiToken.secretHash]: 'foo', + [FieldNameApiToken.userId]: userId, + [FieldNameApiToken.validUntil]: 1, + }, + ], + ); + mockUpdate( + tracker, + TableApiToken, + [FieldNameApiToken.lastUsedAt], + FieldNameApiToken.id, + 1, + ); + jest.spyOn(service, 'ensureTokenIsValid').mockImplementation(() => {}); + const userByToken = await service.getUserIdForToken( + `${AUTH_TOKEN_PREFIX}.${validKeyId}.${validSecret}`, + ); + expect(userByToken).toEqual(userId); + expectBindings(tracker, 'select', [[validKeyId]], true); + }); + }); + + describe('createToken', () => { + const twoYearsMilliseconds = 2 * 365 * 24 * 60 * 60 * 1000; + const validUntil = new Date(Date.now() + 3600 * 1000); + describe('fails if', () => { + it('user has more than 200 tokens', async () => { + mockSelect( + tracker, + [FieldNameApiToken.id], + TableApiToken, + FieldNameApiToken.userId, + Array(201).fill({ + [FieldNameApiToken.id]: '1', + }), + ); + await expect( + service.createToken(userId, label, validUntil), + ).rejects.toThrow(TooManyTokensError); + }); + }); + describe('works', () => { + let token: ApiTokenWithSecretDto; + let timeToCheckinMilliseconds: number; + const mockSecretHash = 'a'.repeat(20); + const mockTime = new Date(); + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(mockTime); + jest + .spyOn(passwordUtils, 'bufferToBase64Url') + .mockReturnValue(validSecret) + .mockReturnValue(validKeyId); + jest + .spyOn(passwordUtils, 'hashApiToken') + .mockReturnValue(mockSecretHash); + token = {} as ApiTokenWithSecretDto; + timeToCheckinMilliseconds = twoYearsMilliseconds; + mockSelect( + tracker, + [FieldNameApiToken.id], + TableApiToken, + FieldNameApiToken.userId, + [], + ); + mockInsert(tracker, TableApiToken, [ + FieldNameApiToken.createdAt, + FieldNameApiToken.id, + FieldNameApiToken.label, + FieldNameApiToken.secretHash, + FieldNameApiToken.userId, + FieldNameApiToken.validUntil, + ]); + }); + afterEach(() => { + expect(token.label).toEqual(label); + expect( + new Date(token.validUntil).getTime() - + (new Date().getTime() + timeToCheckinMilliseconds), + ).toBeLessThanOrEqual(10000); + expect(token.lastUsedAt).toBeNull(); + expect( + token.secret.startsWith(AUTH_TOKEN_PREFIX + '.' + token.keyId), + ).toBe(true); + expectBindings(tracker, 'select', [[userId]]); + expectBindings(tracker, 'insert', [ + [ + mockTime, + validKeyId, + label, + mockSecretHash, + userId, + new Date(mockTime.getTime() + timeToCheckinMilliseconds), + ], + ]); + jest.useRealTimers(); + }); + + // expect is common in this test group, and therefore called in afterEach instead of each test + // eslint-disable-next-line jest/expect-expect + it('without validUntil', async () => { + token = await service.createToken(userId, label); + }); + + // eslint-disable-next-line jest/expect-expect + it('with validUntil more than two years in the future', async () => { + token = await service.createToken( + userId, + label, + new Date(Date.now() + twoYearsMilliseconds + 1000 * 3600 * 24), + ); + }); + + // eslint-disable-next-line jest/expect-expect + it('with validUntil less than two years in the future', async () => { + token = await service.createToken( + userId, + label, + new Date(Date.now() + twoYearsMilliseconds / 2), + ); + timeToCheckinMilliseconds = twoYearsMilliseconds / 2; + }); + }); + }); + + describe('ensureTokenIsValid', () => { + describe('fails if', () => { + it('validUntil is in the past', () => { + expect(() => + service.ensureTokenIsValid( + validSecret, + '', + new Date(Date.now() - 1000 * 3600 * 24), + ), + ).toThrow(TokenNotValidError); + }); + it('if checkTokenEquality returns false', () => { + jest.spyOn(passwordUtils, 'checkTokenEquality').mockReturnValue(false); + expect(() => + service.ensureTokenIsValid( + validSecret, + '', + new Date(Date.now() - 1000 * 3600 * 24), + ), + ).toThrow(TokenNotValidError); + }); + }); + + it('works', () => { + jest.spyOn(passwordUtils, 'checkTokenEquality').mockReturnValue(true); + expect(() => + service.ensureTokenIsValid( + validSecret, + '', + new Date(Date.now() + 1000 * 3600 * 24), + ), + ).not.toThrow(); + }); + }); + describe('getTokensOfUserById', () => { + const validUntil = new Date(Date.now() + 3600 * 1000 * 2); + const createdAt = new Date(Date.now() - 3600 * 1000); + const lastUsedAt = new Date(Date.now() + 3600 * 1000); + + it('works', async () => { + mockSelect( + tracker, + [ + FieldNameApiToken.createdAt, + FieldNameApiToken.id, + FieldNameApiToken.label, + FieldNameApiToken.lastUsedAt, + FieldNameApiToken.validUntil, + FieldNameApiToken.userId, + ], + TableApiToken, + FieldNameApiToken.userId, + [ + { + [FieldNameApiToken.id]: validKeyId, + [FieldNameApiToken.userId]: userId, + [FieldNameApiToken.label]: label, + [FieldNameApiToken.validUntil]: validUntil, + [FieldNameApiToken.createdAt]: createdAt, + [FieldNameApiToken.lastUsedAt]: lastUsedAt, + }, + ], + ); + const tokens = await service.getTokensOfUserById(userId); + expect(tokens).toHaveLength(1); + expect(tokens).toEqual([ + { + label: label, + userId: userId, + validUntil: validUntil.toISOString(), + keyId: validKeyId, + createdAt: createdAt.toISOString(), + lastUsedAt: lastUsedAt.toISOString(), + }, + ]); + expectBindings(tracker, 'select', [[userId]]); + }); + it('should return empty array if token for user do not exists', async () => { + mockSelect( + tracker, + [ + FieldNameApiToken.createdAt, + FieldNameApiToken.id, + FieldNameApiToken.label, + FieldNameApiToken.lastUsedAt, + FieldNameApiToken.validUntil, + FieldNameApiToken.userId, + ], + TableApiToken, + FieldNameApiToken.userId, + [], + ); + const tokens = await service.getTokensOfUserById(userId); + expect(tokens).toHaveLength(0); + expect(tokens).toEqual([]); + expectBindings(tracker, 'select', [[userId]]); }); }); describe('removeToken', () => { - it('works', async () => { - jest.spyOn(apiTokenRepo, 'findOne').mockResolvedValue({ - ...apiToken, - user: Promise.resolve(user), - }); - jest - .spyOn(apiTokenRepo, 'remove') - .mockImplementationOnce(async (token, __): Promise => { - expect(token).toEqual({ - ...apiToken, - user: Promise.resolve(user), - }); - return apiToken; - }); - await service.removeToken(apiToken.keyId); - }); it('throws if the token is not in the database', async () => { - jest.spyOn(apiTokenRepo, 'findOne').mockResolvedValueOnce(null); - await expect(service.removeToken(apiToken.keyId)).rejects.toThrow( + mockDelete( + tracker, + TableApiToken, + [FieldNameApiToken.id, FieldNameApiToken.userId], + 0, + ); + await expect(service.removeToken(validKeyId, userId)).rejects.toThrow( NotInDBError, ); }); - }); - - describe('addToken', () => { - describe('works', () => { - const identifier = 'testIdentifier'; - it('with validUntil 0', async () => { - jest.spyOn(apiTokenRepo, 'find').mockResolvedValueOnce([apiToken]); - jest - .spyOn(apiTokenRepo, 'save') - .mockImplementationOnce( - async (apiTokenSaved: ApiToken, _): Promise => { - expect(apiTokenSaved.lastUsedAt).toBeNull(); - apiTokenSaved.createdAt = new Date(1); - return apiTokenSaved; - }, - ); - const token = await service.addToken(user, identifier, new Date(0)); - expect(token.label).toEqual(identifier); - expect( - new Date(token.validUntil).getTime() - - (new Date().getTime() + 2 * 365 * 24 * 60 * 60 * 1000), - ).toBeLessThanOrEqual(10000); - expect(token.lastUsedAt).toBeNull(); - expect(token.secret.startsWith('hd2.' + token.keyId)).toBeTruthy(); - }); - it('with validUntil not 0', async () => { - jest.spyOn(apiTokenRepo, 'find').mockResolvedValueOnce([apiToken]); - jest - .spyOn(apiTokenRepo, 'save') - .mockImplementationOnce( - async (apiTokenSaved: ApiToken, _): Promise => { - expect(apiTokenSaved.lastUsedAt).toBeNull(); - apiTokenSaved.createdAt = new Date(1); - return apiTokenSaved; - }, - ); - const validUntil = new Date(); - validUntil.setTime(validUntil.getTime() + 30000); - const token = await service.addToken(user, identifier, validUntil); - expect(token.label).toEqual(identifier); - expect(new Date(token.validUntil)).toEqual(validUntil); - expect(token.lastUsedAt).toBeNull(); - expect(token.secret.startsWith('hd2.' + token.keyId)).toBeTruthy(); - }); - it('should throw TooManyTokensError when number of tokens >= 200', async () => { - jest - .spyOn(apiTokenRepo, 'find') - .mockImplementationOnce(async (): Promise => { - const inValidToken = [apiToken]; - inValidToken.length = 201; - return inValidToken; - }); - const validUntil = new Date(); - validUntil.setTime(validUntil.getTime() + 30000); - await expect( - service.addToken(user, identifier, validUntil), - ).rejects.toThrow(TooManyTokensError); - }); + it('works', async () => { + mockDelete( + tracker, + TableApiToken, + [FieldNameApiToken.id, FieldNameApiToken.userId], + 1, + ); + await service.removeToken(validKeyId, userId); + expectBindings(tracker, 'delete', [[validKeyId, userId]]); }); }); describe('removeInvalidTokens', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - it('works', async () => { - const expiredDate = new Date().getTime() - 30000; - const expiredToken = { ...apiToken, validUntil: new Date(expiredDate) }; - jest - .spyOn(apiTokenRepo, 'find') - .mockResolvedValueOnce([expiredToken, apiToken]); - jest - .spyOn(apiTokenRepo, 'remove') - .mockImplementationOnce(async (token): Promise => { - expect(token).toEqual(expiredToken); - expect(token).not.toBe(apiToken); - return apiToken; - }); - + const mockTime = new Date(); + jest.useFakeTimers().setSystemTime(mockTime); + mockDelete(tracker, TableApiToken, [FieldNameApiToken.validUntil], 1); await service.removeInvalidTokens(); + expectBindings(tracker, 'delete', [[mockTime]]); + jest.useRealTimers(); }); }); describe('auto remove invalid tokens', () => { beforeEach(() => { - jest.spyOn(service, 'removeInvalidTokens'); + jest + .spyOn(service, 'removeInvalidTokens') + .mockImplementation(async () => {}); }); it('handleCron should call removeInvalidTokens', async () => { @@ -383,31 +433,4 @@ describe('ApiTokenService', () => { expect(service.removeInvalidTokens).toHaveBeenCalledTimes(1); }); }); - - describe('toAuthTokenDto', () => { - const apiToken = new ApiToken(); - apiToken.keyId = 'testKeyId'; - apiToken.label = 'testLabel'; - const date = new Date(); - date.setHours(date.getHours() - 1); - apiToken.createdAt = date; - apiToken.validUntil = new Date(); - it('works', () => { - const tokenDto = service.toAuthTokenDto(apiToken); - expect(tokenDto.keyId).toEqual(apiToken.keyId); - expect(tokenDto.lastUsedAt).toBeNull(); - expect(tokenDto.label).toEqual(apiToken.label); - expect(new Date(tokenDto.validUntil).getTime()).toEqual( - apiToken.validUntil.getTime(), - ); - expect(new Date(tokenDto.createdAt).getTime()).toEqual( - apiToken.createdAt.getTime(), - ); - }); - it('should have lastUsedAt', () => { - apiToken.lastUsedAt = new Date(); - const tokenDto = service.toAuthTokenDto(apiToken); - expect(tokenDto.lastUsedAt).toEqual(apiToken.lastUsedAt.toISOString()); - }); - }); }); diff --git a/backend/src/api-token/api-token.service.ts b/backend/src/api-token/api-token.service.ts index de4a01bcd..8a44ee5cf 100644 --- a/backend/src/api-token/api-token.service.ts +++ b/backend/src/api-token/api-token.service.ts @@ -4,12 +4,17 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { ApiTokenDto, ApiTokenWithSecretDto } from '@hedgedoc/commons'; -import { ApiToken, FieldNameApiToken, TableApiToken } from '@hedgedoc/database'; +import { + ApiToken, + FieldNameApiToken, + TableApiToken, + TypeInsertApiToken, +} from '@hedgedoc/database'; import { Injectable } from '@nestjs/common'; import { Cron, Timeout } from '@nestjs/schedule'; -import { randomBytes } from 'crypto'; import { Knex } from 'knex'; import { InjectConnection } from 'nest-knexjs'; +import { randomBytes } from 'node:crypto'; import { NotInDBError, @@ -23,7 +28,7 @@ import { hashApiToken, } from '../utils/password'; -const AUTH_TOKEN_PREFIX = 'hd2'; +export const AUTH_TOKEN_PREFIX = 'hd2'; const MESSAGE_TOKEN_INVALID = 'API token is invalid, expired or not found'; @Injectable() @@ -126,14 +131,15 @@ export class ApiTokenService { ? maximumTokenValidity : userDefinedValidUntil; const createdAt = new Date(); - await this.knex(TableApiToken).insert({ + const insertApiToken: TypeInsertApiToken = { + [FieldNameApiToken.createdAt]: createdAt, [FieldNameApiToken.id]: keyId, [FieldNameApiToken.label]: label, - [FieldNameApiToken.userId]: userId, [FieldNameApiToken.secretHash]: secretHash, + [FieldNameApiToken.userId]: userId, [FieldNameApiToken.validUntil]: validUntil, - [FieldNameApiToken.createdAt]: createdAt, - }); + }; + await transaction(TableApiToken).insert(insertApiToken); return { label, keyId, @@ -176,10 +182,33 @@ export class ApiTokenService { * @param userId The id of the user to get the tokens for * @returns A list of the user's tokens as ApiToken objects */ - getTokensOfUserById(userId: number): Promise { - return this.knex(TableApiToken) - .select() + async getTokensOfUserById(userId: number): Promise { + const apiTokens = await this.knex(TableApiToken) + .select([ + FieldNameApiToken.createdAt, + FieldNameApiToken.id, + FieldNameApiToken.label, + FieldNameApiToken.lastUsedAt, + FieldNameApiToken.validUntil, + FieldNameApiToken.userId, + ]) .where(FieldNameApiToken.userId, userId); + return apiTokens.map( + (apiToken: Omit) => ({ + label: apiToken[FieldNameApiToken.label], + userId: apiToken[FieldNameApiToken.userId], + keyId: apiToken[FieldNameApiToken.id], + createdAt: new Date( + apiToken[FieldNameApiToken.createdAt], + ).toISOString(), + validUntil: new Date( + apiToken[FieldNameApiToken.validUntil], + ).toISOString(), + lastUsedAt: apiToken[FieldNameApiToken.lastUsedAt] + ? new Date(apiToken[FieldNameApiToken.lastUsedAt]).toISOString() + : null, + }), + ); } /** @@ -199,26 +228,6 @@ export class ApiTokenService { } } - /** - * Formats an ApiToken object from the database to an ApiTokenDto - * - * @param apiToken The token object to convert - * @returns The built ApiTokenDto - */ - toAuthTokenDto(apiToken: ApiToken): ApiTokenDto { - return { - label: apiToken[FieldNameApiToken.label], - keyId: apiToken[FieldNameApiToken.id], - createdAt: new Date(apiToken[FieldNameApiToken.createdAt]).toISOString(), - validUntil: new Date( - apiToken[FieldNameApiToken.validUntil], - ).toISOString(), - lastUsedAt: apiToken[FieldNameApiToken.lastUsedAt] - ? new Date(apiToken[FieldNameApiToken.lastUsedAt]).toISOString() - : null, - }; - } - // Deletes all invalid tokens every sunday on 3:00 AM @Cron('0 0 3 * * 0') async handleCron(): Promise { diff --git a/backend/src/api/private/api-tokens/api-tokens.controller.ts b/backend/src/api/private/api-tokens/api-tokens.controller.ts index 98c0e5d34..980c42f53 100644 --- a/backend/src/api/private/api-tokens/api-tokens.controller.ts +++ b/backend/src/api/private/api-tokens/api-tokens.controller.ts @@ -40,12 +40,10 @@ export class ApiTokensController { @Get() @OpenApi(200) - async getUserTokens( + getUserTokens( @RequestUserId({ forbidGuests: true }) userId: number, ): Promise { - return (await this.apiTokenService.getTokensOfUserById(userId)).map( - (token) => this.apiTokenService.toAuthTokenDto(token), - ); + return this.apiTokenService.getTokensOfUserById(userId); } @Post() diff --git a/backend/src/api/utils/extract-note-from-request.spec.ts b/backend/src/api/utils/extract-note-from-request.spec.ts index d9cb68037..246f6ab7c 100644 --- a/backend/src/api/utils/extract-note-from-request.spec.ts +++ b/backend/src/api/utils/extract-note-from-request.spec.ts @@ -40,7 +40,7 @@ describe('extract note from request', () => { return Mock.of({ params: parameterValue ? { - noteIdOrAlias: parameterValue, + noteAlias: parameterValue, } : {}, headers: headerValue diff --git a/backend/src/api/utils/interceptors/get-note-id.interceptor.spec.ts b/backend/src/api/utils/interceptors/get-note-id.interceptor.spec.ts index 41627ac9f..ff984b449 100644 --- a/backend/src/api/utils/interceptors/get-note-id.interceptor.spec.ts +++ b/backend/src/api/utils/interceptors/get-note-id.interceptor.spec.ts @@ -57,7 +57,7 @@ describe('get note interceptor', () => { it('extracts the note from the request parameters', async () => { const request = Mock.of({ - params: { noteIdOrAlias: mockNoteId }, + params: { noteAlias: mockNoteId }, }); const context = mockExecutionContext(request); const sut: GetNoteIdInterceptor = new GetNoteIdInterceptor(notesService); diff --git a/backend/src/config/database.config.spec.ts b/backend/src/config/database.config.spec.ts index fffd53f3c..45573b1ba 100644 --- a/backend/src/config/database.config.spec.ts +++ b/backend/src/config/database.config.spec.ts @@ -7,14 +7,12 @@ import mockedEnv from 'mocked-env'; import databaseConfig, { MariadbDatabaseConfig, - MySQLDatabaseConfig, PostgresDatabaseConfig, SqliteDatabaseConfig, } from './database.config'; describe('databaseConfig', () => { const databaseTypeSqlite = 'sqlite'; - const databaseTypeMysql = 'mysql'; const databaseTypeMariadb = 'mariadb'; const databaseTypePostgres = 'postgres'; const databaseName = 'test-db'; @@ -45,32 +43,6 @@ describe('databaseConfig', () => { restore(); }); - it('MySQL config', () => { - const restore = mockedEnv( - { - /* eslint-disable @typescript-eslint/naming-convention */ - HD_DATABASE_TYPE: databaseTypeMysql, - HD_DATABASE_NAME: databaseName, - HD_DATABASE_USERNAME: databaseUser, - HD_DATABASE_PASSWORD: databasePass, - HD_DATABASE_HOST: databaseHost, - HD_DATABASE_PORT: String(databasePort), - /* eslint-enable @typescript-eslint/naming-convention */ - }, - { - clear: true, - }, - ); - const config = databaseConfig() as MySQLDatabaseConfig; - expect(config.type).toEqual(databaseTypeMysql); - expect(config.name).toEqual(databaseName); - expect(config.username).toEqual(databaseUser); - expect(config.password).toEqual(databasePass); - expect(config.host).toEqual(databaseHost); - expect(config.port).toEqual(databasePort); - restore(); - }); - it('MariaDB config', () => { const restore = mockedEnv( { diff --git a/backend/src/config/default-access-level.enum.ts b/backend/src/config/default-access-level.enum.ts deleted file mode 100644 index 102c4f6dc..000000000 --- a/backend/src/config/default-access-level.enum.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export enum DefaultAccessLevel { - NONE = 'none', - READ = 'read', - WRITE = 'write', -} - -export function getDefaultAccessLevelOrdinal( - permission: DefaultAccessLevel, -): number { - switch (permission) { - case DefaultAccessLevel.NONE: - return 0; - case DefaultAccessLevel.READ: - return 1; - case DefaultAccessLevel.WRITE: - return 2; - default: - throw Error('Unknown permission'); - } -} diff --git a/backend/src/config/note.config.spec.ts b/backend/src/config/note.config.spec.ts index 9e0511bcc..ce50b3070 100644 --- a/backend/src/config/note.config.spec.ts +++ b/backend/src/config/note.config.spec.ts @@ -3,10 +3,9 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { PermissionLevel } from '@hedgedoc/commons'; +import { PermissionLevel, PermissionLevelNames } from '@hedgedoc/commons'; import mockedEnv from 'mocked-env'; -import { DefaultAccessLevel } from './default-access-level.enum'; import noteConfig from './note.config'; describe('noteConfig', () => { @@ -17,7 +16,7 @@ describe('noteConfig', () => { const negativeMaxDocumentLength = -123; const floatMaxDocumentLength = 2.71; const invalidMaxDocumentLength = 'not-a-max-document-length'; - const guestAccess = PermissionLevel.CREATE; + const guestAccess = PermissionLevel.FULL; const wrongDefaultPermission = 'wrong'; const retentionDays = 30; @@ -28,9 +27,10 @@ describe('noteConfig', () => { /* eslint-disable @typescript-eslint/naming-convention */ HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), - HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ, - HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, - HD_GUEST_ACCESS: guestAccess, + HD_PERMISSIONS_DEFAULT_EVERYONE: + PermissionLevelNames[PermissionLevel.READ], + HD_PERMISSIONS_DEFAULT_LOGGED_IN: + PermissionLevelNames[PermissionLevel.READ], HD_REVISION_RETENTION_DAYS: retentionDays.toString(), /* eslint-enable @typescript-eslint/naming-convention */ }, @@ -42,13 +42,9 @@ describe('noteConfig', () => { expect(config.forbiddenNoteIds).toHaveLength(forbiddenNoteIds.length); expect(config.forbiddenNoteIds).toEqual(forbiddenNoteIds); expect(config.maxDocumentLength).toEqual(maxDocumentLength); - expect(config.permissions.default.everyone).toEqual( - DefaultAccessLevel.READ, - ); - expect(config.permissions.default.loggedIn).toEqual( - DefaultAccessLevel.READ, - ); - expect(config.guestAccess).toEqual(guestAccess); + expect(config.permissions.default.everyone).toEqual(PermissionLevel.READ); + expect(config.permissions.default.loggedIn).toEqual(PermissionLevel.READ); + expect(config.permissions.maxGuestLevel).toEqual(guestAccess); expect(config.revisionRetentionDays).toEqual(retentionDays); restore(); }); @@ -58,9 +54,10 @@ describe('noteConfig', () => { { /* eslint-disable @typescript-eslint/naming-convention */ HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), - HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ, - HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, - HD_GUEST_ACCESS: guestAccess, + HD_PERMISSIONS_DEFAULT_EVERYONE: + PermissionLevelNames[PermissionLevel.READ], + HD_PERMISSIONS_DEFAULT_LOGGED_IN: + PermissionLevelNames[PermissionLevel.READ], /* eslint-enable @typescript-eslint/naming-convention */ }, { @@ -70,13 +67,9 @@ describe('noteConfig', () => { const config = noteConfig(); expect(config.forbiddenNoteIds).toHaveLength(0); expect(config.maxDocumentLength).toEqual(maxDocumentLength); - expect(config.permissions.default.everyone).toEqual( - DefaultAccessLevel.READ, - ); - expect(config.permissions.default.loggedIn).toEqual( - DefaultAccessLevel.READ, - ); - expect(config.guestAccess).toEqual(guestAccess); + expect(config.permissions.default.everyone).toEqual(PermissionLevel.READ); + expect(config.permissions.default.loggedIn).toEqual(PermissionLevel.READ); + expect(config.permissions.maxGuestLevel).toEqual(guestAccess); restore(); }); @@ -86,9 +79,10 @@ describe('noteConfig', () => { /* eslint-disable @typescript-eslint/naming-convention */ HD_FORBIDDEN_NOTE_IDS: forbiddenNoteId, HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), - HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ, - HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, - HD_GUEST_ACCESS: guestAccess, + HD_PERMISSIONS_DEFAULT_EVERYONE: + PermissionLevelNames[PermissionLevel.READ], + HD_PERMISSIONS_DEFAULT_LOGGED_IN: + PermissionLevelNames[PermissionLevel.READ], /* eslint-enable @typescript-eslint/naming-convention */ }, { @@ -99,14 +93,10 @@ describe('noteConfig', () => { expect(config.forbiddenNoteIds).toHaveLength(1); expect(config.forbiddenNoteIds[0]).toEqual(forbiddenNoteId); expect(config.maxDocumentLength).toEqual(maxDocumentLength); - expect(config.permissions.default.everyone).toEqual( - DefaultAccessLevel.READ, - ); - expect(config.permissions.default.loggedIn).toEqual( - DefaultAccessLevel.READ, - ); + expect(config.permissions.default.everyone).toEqual(PermissionLevel.READ); + expect(config.permissions.default.loggedIn).toEqual(PermissionLevel.READ); - expect(config.guestAccess).toEqual(guestAccess); + expect(config.permissions.maxGuestLevel).toEqual(guestAccess); restore(); }); @@ -115,9 +105,10 @@ describe('noteConfig', () => { { /* eslint-disable @typescript-eslint/naming-convention */ HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), - HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ, - HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, - HD_GUEST_ACCESS: guestAccess, + HD_PERMISSIONS_DEFAULT_EVERYONE: + PermissionLevelNames[PermissionLevel.READ], + HD_PERMISSIONS_DEFAULT_LOGGED_IN: + PermissionLevelNames[PermissionLevel.READ], /* eslint-enable @typescript-eslint/naming-convention */ }, { @@ -128,14 +119,10 @@ describe('noteConfig', () => { expect(config.forbiddenNoteIds).toHaveLength(forbiddenNoteIds.length); expect(config.forbiddenNoteIds).toEqual(forbiddenNoteIds); expect(config.maxDocumentLength).toEqual(100000); - expect(config.permissions.default.everyone).toEqual( - DefaultAccessLevel.READ, - ); - expect(config.permissions.default.loggedIn).toEqual( - DefaultAccessLevel.READ, - ); + expect(config.permissions.default.everyone).toEqual(PermissionLevel.READ); + expect(config.permissions.default.loggedIn).toEqual(PermissionLevel.READ); - expect(config.guestAccess).toEqual(guestAccess); + expect(config.permissions.maxGuestLevel).toEqual(guestAccess); restore(); }); @@ -145,8 +132,8 @@ describe('noteConfig', () => { /* eslint-disable @typescript-eslint/naming-convention */ HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), - HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, - HD_GUEST_ACCESS: guestAccess, + HD_PERMISSIONS_DEFAULT_LOGGED_IN: + PermissionLevelNames[PermissionLevel.READ], /* eslint-enable @typescript-eslint/naming-convention */ }, { @@ -157,14 +144,10 @@ describe('noteConfig', () => { expect(config.forbiddenNoteIds).toHaveLength(forbiddenNoteIds.length); expect(config.forbiddenNoteIds).toEqual(forbiddenNoteIds); expect(config.maxDocumentLength).toEqual(maxDocumentLength); - expect(config.permissions.default.everyone).toEqual( - DefaultAccessLevel.READ, - ); - expect(config.permissions.default.loggedIn).toEqual( - DefaultAccessLevel.READ, - ); + expect(config.permissions.default.everyone).toEqual(PermissionLevel.READ); + expect(config.permissions.default.loggedIn).toEqual(PermissionLevel.READ); - expect(config.guestAccess).toEqual(guestAccess); + expect(config.permissions.maxGuestLevel).toEqual(guestAccess); restore(); }); @@ -174,8 +157,8 @@ describe('noteConfig', () => { /* eslint-disable @typescript-eslint/naming-convention */ HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), - HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ, - HD_GUEST_ACCESS: guestAccess, + HD_PERMISSIONS_DEFAULT_EVERYONE: + PermissionLevelNames[PermissionLevel.READ], /* eslint-enable @typescript-eslint/naming-convention */ }, { @@ -186,24 +169,23 @@ describe('noteConfig', () => { expect(config.forbiddenNoteIds).toHaveLength(forbiddenNoteIds.length); expect(config.forbiddenNoteIds).toEqual(forbiddenNoteIds); expect(config.maxDocumentLength).toEqual(maxDocumentLength); - expect(config.permissions.default.everyone).toEqual( - DefaultAccessLevel.READ, - ); + expect(config.permissions.default.everyone).toEqual(PermissionLevel.READ); expect(config.permissions.default.loggedIn).toEqual( - DefaultAccessLevel.WRITE, + PermissionLevel.WRITE, ); - expect(config.guestAccess).toEqual(guestAccess); + expect(config.permissions.maxGuestLevel).toEqual(guestAccess); restore(); }); - it('when no HD_GUEST_ACCESS is set', () => { + it('when no HD_PERMISSIONS_MAX_GUEST_LEVEL is set', () => { const restore = mockedEnv( { /* eslint-disable @typescript-eslint/naming-convention */ HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), - HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ, + HD_PERMISSIONS_DEFAULT_EVERYONE: + PermissionLevelNames[PermissionLevel.READ], /* eslint-enable @typescript-eslint/naming-convention */ }, { @@ -214,14 +196,11 @@ describe('noteConfig', () => { expect(config.forbiddenNoteIds).toHaveLength(forbiddenNoteIds.length); expect(config.forbiddenNoteIds).toEqual(forbiddenNoteIds); expect(config.maxDocumentLength).toEqual(maxDocumentLength); - expect(config.permissions.default.everyone).toEqual( - DefaultAccessLevel.READ, - ); + expect(config.permissions.default.everyone).toEqual(PermissionLevel.READ); expect(config.permissions.default.loggedIn).toEqual( - DefaultAccessLevel.WRITE, + PermissionLevel.WRITE, ); - - expect(config.guestAccess).toEqual(PermissionLevel.WRITE); + expect(config.permissions.maxGuestLevel).toEqual(PermissionLevel.FULL); restore(); }); @@ -231,9 +210,10 @@ describe('noteConfig', () => { /* eslint-disable @typescript-eslint/naming-convention */ HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), - HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ, - HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, - HD_GUEST_ACCESS: guestAccess, + HD_PERMISSIONS_DEFAULT_EVERYONE: + PermissionLevelNames[PermissionLevel.READ], + HD_PERMISSIONS_DEFAULT_LOGGED_IN: + PermissionLevelNames[PermissionLevel.READ], /* eslint-enable @typescript-eslint/naming-convention */ }, { @@ -244,13 +224,9 @@ describe('noteConfig', () => { expect(config.forbiddenNoteIds).toHaveLength(forbiddenNoteIds.length); expect(config.forbiddenNoteIds).toEqual(forbiddenNoteIds); expect(config.maxDocumentLength).toEqual(maxDocumentLength); - expect(config.permissions.default.everyone).toEqual( - DefaultAccessLevel.READ, - ); - expect(config.permissions.default.loggedIn).toEqual( - DefaultAccessLevel.READ, - ); - expect(config.guestAccess).toEqual(guestAccess); + expect(config.permissions.default.everyone).toEqual(PermissionLevel.READ); + expect(config.permissions.default.loggedIn).toEqual(PermissionLevel.READ); + expect(config.permissions.maxGuestLevel).toEqual(guestAccess); expect(config.revisionRetentionDays).toEqual(0); restore(); }); @@ -263,9 +239,10 @@ describe('noteConfig', () => { /* eslint-disable @typescript-eslint/naming-convention */ HD_FORBIDDEN_NOTE_IDS: invalidforbiddenNoteIds.join(' , '), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), - HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ, - HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, - HD_GUEST_ACCESS: guestAccess, + HD_PERMISSIONS_DEFAULT_EVERYONE: + PermissionLevelNames[PermissionLevel.READ], + HD_PERMISSIONS_DEFAULT_LOGGED_IN: + PermissionLevelNames[PermissionLevel.READ], /* eslint-enable @typescript-eslint/naming-convention */ }, { @@ -284,9 +261,10 @@ describe('noteConfig', () => { /* eslint-disable @typescript-eslint/naming-convention */ HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_MAX_DOCUMENT_LENGTH: negativeMaxDocumentLength.toString(), - HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ, - HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, - HD_GUEST_ACCESS: guestAccess, + HD_PERMISSIONS_DEFAULT_EVERYONE: + PermissionLevelNames[PermissionLevel.READ], + HD_PERMISSIONS_DEFAULT_LOGGED_IN: + PermissionLevelNames[PermissionLevel.READ], /* eslint-enable @typescript-eslint/naming-convention */ }, { @@ -305,9 +283,10 @@ describe('noteConfig', () => { /* eslint-disable @typescript-eslint/naming-convention */ HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_MAX_DOCUMENT_LENGTH: floatMaxDocumentLength.toString(), - HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ, - HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, - HD_GUEST_ACCESS: guestAccess, + HD_PERMISSIONS_DEFAULT_EVERYONE: + PermissionLevelNames[PermissionLevel.READ], + HD_PERMISSIONS_DEFAULT_LOGGED_IN: + PermissionLevelNames[PermissionLevel.READ], /* eslint-enable @typescript-eslint/naming-convention */ }, { @@ -326,9 +305,10 @@ describe('noteConfig', () => { /* eslint-disable @typescript-eslint/naming-convention */ HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_MAX_DOCUMENT_LENGTH: invalidMaxDocumentLength, - HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ, - HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, - HD_GUEST_ACCESS: guestAccess, + HD_PERMISSIONS_DEFAULT_EVERYONE: + PermissionLevelNames[PermissionLevel.READ], + HD_PERMISSIONS_DEFAULT_LOGGED_IN: + PermissionLevelNames[PermissionLevel.READ], /* eslint-enable @typescript-eslint/naming-convention */ }, { @@ -348,8 +328,8 @@ describe('noteConfig', () => { HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), HD_PERMISSIONS_DEFAULT_EVERYONE: wrongDefaultPermission, - HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, - HD_GUEST_ACCESS: guestAccess, + HD_PERMISSIONS_DEFAULT_LOGGED_IN: + PermissionLevelNames[PermissionLevel.READ], /* eslint-enable @typescript-eslint/naming-convention */ }, { @@ -357,7 +337,7 @@ describe('noteConfig', () => { }, ); expect(() => noteConfig()).toThrow( - "HD_PERMISSIONS_DEFAULT_EVERYONE: Invalid enum value. Expected 'none' | 'read' | 'write', received 'wrong'", + `HD_PERMISSIONS_DEFAULT_EVERYONE: Invalid enum value. Expected '${PermissionLevelNames[PermissionLevel.DENY]}' | '${PermissionLevelNames[PermissionLevel.READ]}' | '${PermissionLevelNames[PermissionLevel.WRITE]}' | '${PermissionLevelNames[PermissionLevel.FULL]}', received 'wrong'`, ); restore(); }); @@ -368,9 +348,9 @@ describe('noteConfig', () => { /* eslint-disable @typescript-eslint/naming-convention */ HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), - HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ, + HD_PERMISSIONS_DEFAULT_EVERYONE: + PermissionLevelNames[PermissionLevel.READ], HD_PERMISSIONS_DEFAULT_LOGGED_IN: wrongDefaultPermission, - HD_GUEST_ACCESS: guestAccess, /* eslint-enable @typescript-eslint/naming-convention */ }, { @@ -378,20 +358,22 @@ describe('noteConfig', () => { }, ); expect(() => noteConfig()).toThrow( - "HD_PERMISSIONS_DEFAULT_LOGGED_IN: Invalid enum value. Expected 'none' | 'read' | 'write', received 'wrong'", + `HD_PERMISSIONS_DEFAULT_LOGGED_IN: Invalid enum value. Expected '${PermissionLevelNames[PermissionLevel.DENY]}' | '${PermissionLevelNames[PermissionLevel.READ]}' | '${PermissionLevelNames[PermissionLevel.WRITE]}' | '${PermissionLevelNames[PermissionLevel.FULL]}', received 'wrong'`, ); restore(); }); - it('when given a non-valid HD_GUEST_ACCESS', async () => { + it('when given a non-valid HD_PERMISSIONS_MAX_GUEST_LEVEL', async () => { const restore = mockedEnv( { /* eslint-disable @typescript-eslint/naming-convention */ HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), - HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ, - HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, - HD_GUEST_ACCESS: wrongDefaultPermission, + HD_PERMISSION_DEFAULT_EVERYONE: + PermissionLevelNames[PermissionLevel.READ], + HD_PERMISSION_DEFAULT_LOGGED_IN: + PermissionLevelNames[PermissionLevel.READ], + HD_PERMISSIONS_MAX_GUEST_LEVEL: wrongDefaultPermission, /* eslint-enable @typescript-eslint/naming-convention */ }, { @@ -399,20 +381,22 @@ describe('noteConfig', () => { }, ); expect(() => noteConfig()).toThrow( - "HD_GUEST_ACCESS: Invalid enum value. Expected 'deny' | 'read' | 'write' | 'create', received 'wrong'", + `HD_PERMISSIONS_MAX_GUEST_LEVEL: Invalid enum value. Expected '${PermissionLevelNames[PermissionLevel.DENY]}' | '${PermissionLevelNames[PermissionLevel.READ]}' | '${PermissionLevelNames[PermissionLevel.WRITE]}' | '${PermissionLevelNames[PermissionLevel.FULL]}', received 'wrong'`, ); restore(); }); - it('when HD_GUEST_ACCESS is set to deny and HD_PERMISSION_DEFAULT_EVERYONE is set', async () => { + it('when HD_PERMISSIONS_MAX_GUEST_LEVEL is set to deny and HD_PERMISSION_DEFAULT_EVERYONE is set', async () => { const restore = mockedEnv( { /* eslint-disable @typescript-eslint/naming-convention */ HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), - HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ, - HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, - HD_GUEST_ACCESS: 'deny', + HD_PERMISSIONS_DEFAULT_EVERYONE: + PermissionLevelNames[PermissionLevel.READ], + HD_PERMISSIONS_DEFAULT_LOGGED_IN: + PermissionLevelNames[PermissionLevel.READ], + HD_PERMISSIONS_MAX_GUEST_LEVEL: 'deny', /* eslint-enable @typescript-eslint/naming-convention */ }, { @@ -420,7 +404,7 @@ describe('noteConfig', () => { }, ); expect(() => noteConfig()).toThrow( - `'HD_GUEST_ACCESS' is set to 'deny', but 'HD_PERMISSIONS_DEFAULT_EVERYONE' is also configured. Please remove 'HD_PERMISSIONS_DEFAULT_EVERYONE'.`, + `'HD_PERMISSIONS_DEFAULT_EVERYONE' is set to '${PermissionLevelNames[PermissionLevel.READ]}', but 'HD_PERMISSIONS_MAX_GUEST_LEVEL' is set to '${PermissionLevelNames[PermissionLevel.DENY]}'. This does not work since the default level may not be higher than the maximum guest level.`, ); restore(); }); @@ -431,9 +415,10 @@ describe('noteConfig', () => { /* eslint-disable @typescript-eslint/naming-convention */ HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), - HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.WRITE, - HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, - HD_GUEST_ACCESS: guestAccess, + HD_PERMISSIONS_DEFAULT_EVERYONE: + PermissionLevelNames[PermissionLevel.WRITE], + HD_PERMISSIONS_DEFAULT_LOGGED_IN: + PermissionLevelNames[PermissionLevel.READ], /* eslint-enable @typescript-eslint/naming-convention */ }, { @@ -441,7 +426,7 @@ describe('noteConfig', () => { }, ); expect(() => noteConfig()).toThrow( - `'HD_PERMISSIONS_DEFAULT_EVERYONE' is set to '${DefaultAccessLevel.WRITE}', but 'HD_PERMISSIONS_DEFAULT_LOGGED_IN' is set to '${DefaultAccessLevel.READ}'. This gives everyone greater permissions than logged-in users which is not allowed.`, + `'HD_PERMISSIONS_DEFAULT_EVERYONE' is set to '${PermissionLevelNames[PermissionLevel.WRITE]}', but 'HD_PERMISSIONS_DEFAULT_LOGGED_IN' is set to '${PermissionLevelNames[PermissionLevel.READ]}'. This would give everyone greater permissions than logged-in users, and is not allowed since it doesn't make sense.`, ); restore(); }); @@ -452,9 +437,10 @@ describe('noteConfig', () => { /* eslint-disable @typescript-eslint/naming-convention */ HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), - HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.WRITE, - HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.NONE, - HD_GUEST_ACCESS: guestAccess, + HD_PERMISSIONS_DEFAULT_EVERYONE: + PermissionLevelNames[PermissionLevel.WRITE], + HD_PERMISSIONS_DEFAULT_LOGGED_IN: + PermissionLevelNames[PermissionLevel.DENY], /* eslint-enable @typescript-eslint/naming-convention */ }, { @@ -462,7 +448,7 @@ describe('noteConfig', () => { }, ); expect(() => noteConfig()).toThrow( - `'HD_PERMISSIONS_DEFAULT_EVERYONE' is set to '${DefaultAccessLevel.WRITE}', but 'HD_PERMISSIONS_DEFAULT_LOGGED_IN' is set to '${DefaultAccessLevel.NONE}'. This gives everyone greater permissions than logged-in users which is not allowed.`, + `'HD_PERMISSIONS_DEFAULT_EVERYONE' is set to '${PermissionLevelNames[PermissionLevel.WRITE]}', but 'HD_PERMISSIONS_DEFAULT_LOGGED_IN' is set to '${PermissionLevelNames[PermissionLevel.DENY]}'. This would give everyone greater permissions than logged-in users, and is not allowed since it doesn't make sense.`, ); restore(); }); @@ -473,9 +459,10 @@ describe('noteConfig', () => { /* eslint-disable @typescript-eslint/naming-convention */ HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), - HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ, - HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.NONE, - HD_GUEST_ACCESS: guestAccess, + HD_PERMISSIONS_DEFAULT_EVERYONE: + PermissionLevelNames[PermissionLevel.READ], + HD_PERMISSIONS_DEFAULT_LOGGED_IN: + PermissionLevelNames[PermissionLevel.DENY], /* eslint-enable @typescript-eslint/naming-convention */ }, { @@ -483,7 +470,7 @@ describe('noteConfig', () => { }, ); expect(() => noteConfig()).toThrow( - `'HD_PERMISSIONS_DEFAULT_EVERYONE' is set to '${DefaultAccessLevel.READ}', but 'HD_PERMISSIONS_DEFAULT_LOGGED_IN' is set to '${DefaultAccessLevel.NONE}'. This gives everyone greater permissions than logged-in users which is not allowed.`, + `'HD_PERMISSIONS_DEFAULT_EVERYONE' is set to '${PermissionLevelNames[PermissionLevel.READ]}', but 'HD_PERMISSIONS_DEFAULT_LOGGED_IN' is set to '${PermissionLevelNames[PermissionLevel.DENY]}'. This would give everyone greater permissions than logged-in users, and is not allowed since it doesn't make sense.`, ); restore(); }); @@ -494,9 +481,10 @@ describe('noteConfig', () => { /* eslint-disable @typescript-eslint/naming-convention */ HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), - HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ, - HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, - HD_GUEST_ACCESS: guestAccess, + HD_PERMISSIONS_DEFAULT_EVERYONE: + PermissionLevelNames[PermissionLevel.READ], + HD_PERMISSIONS_DEFAULT_LOGGED_IN: + PermissionLevelNames[PermissionLevel.READ], HD_REVISION_RETENTION_DAYS: (-1).toString(), /* eslint-enable @typescript-eslint/naming-convention */ }, diff --git a/backend/src/database/mock/expect-bindings.ts b/backend/src/database/mock/expect-bindings.ts new file mode 100644 index 000000000..b31248bfa --- /dev/null +++ b/backend/src/database/mock/expect-bindings.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import type { Tracker } from 'knex-mock-client'; + +export const IS_FIRST = 1; + +/** + * Asserts that the mock db tracker contains change SQL entries with the specified bindings. + * The method iterates through the complete change history of the tracker and compares all bindings of the specified SQL method. + * + * @param tracker The mock db tracker to check + * @param method The SQL method used for the change + * @param bindings The bindings to verify per SQL entry + * @param usesFirst The call to select uses .first() + * @param expectNotToBeCalled Ensures that a specific method was not called + */ +export function expectBindings( + tracker: Tracker, + method: 'insert' | 'update' | 'delete' | 'select', + bindings: unknown[][], + usesFirst: boolean = false, + expectNotToBeCalled: boolean = false, +): void { + const history = tracker.history[method]; + if (expectNotToBeCalled) { + expect(history).toHaveLength(0); + return; + } + if (usesFirst) { + if (method !== 'select') { + throw new Error( + 'Expected `select` as method if `usesFirst` is set to true', + ); + } + bindings[0].push(IS_FIRST); + } + expect(history).toHaveLength(bindings.length); + for (let i = 0; i < bindings.length; i++) { + expect(history[i].method).toBe(method); + expect(history[i].bindings).toEqual(bindings[i]); + } +} diff --git a/backend/src/database/mock/mock-queries.ts b/backend/src/database/mock/mock-queries.ts new file mode 100644 index 000000000..2dff28e18 --- /dev/null +++ b/backend/src/database/mock/mock-queries.ts @@ -0,0 +1,93 @@ +/* + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import type { Tracker } from 'knex-mock-client'; + +interface JoinDefinition { + joinTable: string; + otherTable?: string; + keyLeft: string; + keyRight?: string; +} + +/** + * Pre-registers a mocked SELECT SQL query for the tracker db. + * When the tested method runs a matching query, the pre-registered response + * value will be returned and the query will be stored in the tracker's history. + * + * @param tracker The mock db tracker + * @param variables A list of selected database columns + * @param table The table from which data is selected + * @param where Either a list of + * @param returnValue + * @param joins + */ +export function mockSelect( + tracker: Tracker, + variables: string[], + table: string, + where: string | string[], + returnValue: unknown = [], + joins: JoinDefinition[] = [], +): void { + const selection = + variables.length > 0 ? variables.map((v) => `"${v}"`).join(', ') : '\\*'; + const joinStatement = + joins.length > 0 + ? joins + .map(({ joinTable, otherTable, keyLeft, keyRight }) => { + const leftStatement = `"${joinTable}"."${keyLeft}"`; + const rightStatement = `"${otherTable ?? table}"."${keyRight ?? keyLeft}"`; + return `\\w+ join "${joinTable}" on (:?${leftStatement}|${rightStatement}) = (:?${leftStatement}|${rightStatement})`; + }) + .join(' ') + ' ' + : ''; + const whereClause = Array.isArray(where) + ? where.map((w) => `"${w}"`).join('.*') + : `"${where}"`; + const regex = `select(?: distinct)? ${selection} from "${table}" ${joinStatement}where .*${whereClause}.*`; + console.debug(regex); + const selectRegex = new RegExp(regex); + tracker.on.select(selectRegex).response(returnValue); +} + +export function mockInsert( + tracker: Tracker, + table: string, + variables: string[], + returnValue: unknown = null, +): void { + const insertRegex = new RegExp( + `insert into "${table}" \\(${variables.map((v) => `"${v}"`).join(', ')}\\) values .*`, + ); + //console.debug(insertRegex); + tracker.on.insert(insertRegex).response(returnValue); +} + +export function mockUpdate( + tracker: Tracker, + table: string, + variables: string[], + where: string, + numberUpdatedEntries: number | unknown[] = 1, +): void { + const regex = `update "${table}" set ${variables.map((v) => `"${v}" = (?:CURRENT_TIMESTAMP|\\$\\d+)`).join(', ')} where.*${where}.*`; + //console.debug(regex); + const updateRegex = new RegExp(regex); + tracker.on.update(updateRegex).response(numberUpdatedEntries); +} + +export function mockDelete( + tracker: Tracker, + table: string, + wheres: string[], + numberDeletedEntries: number | unknown[] = 1, +): void { + const deleteRegex = new RegExp( + `delete from "${table}" where ${wheres.map((w) => `"${w}"`).join('.*')}.*`, + ); + //console.debug(deleteRegex); + tracker.on.delete(deleteRegex).response(numberDeletedEntries); +} diff --git a/backend/src/database/mock/provider.ts b/backend/src/database/mock/provider.ts new file mode 100644 index 000000000..87fb9d68e --- /dev/null +++ b/backend/src/database/mock/provider.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Provider } from '@nestjs/common'; +import knex from 'knex'; +import { createTracker, MockClient, Tracker } from 'knex-mock-client'; + +export function mockKnexDb(): [Tracker, Provider] { + const db = knex({ client: MockClient, dialect: 'pg' }); + const tracker = createTracker(db); + const provider = { + provide: 'default', + useValue: db, + }; + return [tracker, provider]; +} diff --git a/backend/src/frontend-config/frontend-config.service.spec.ts b/backend/src/frontend-config/frontend-config.service.spec.ts index 016cd4837..0fdb644ac 100644 --- a/backend/src/frontend-config/frontend-config.service.spec.ts +++ b/backend/src/frontend-config/frontend-config.service.spec.ts @@ -3,7 +3,11 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { AuthProviderType, PermissionLevel } from '@hedgedoc/commons'; +import { + AuthProviderType, + PermissionLevel, + PermissionLevelNames, +} from '@hedgedoc/commons'; import { ConfigModule, registerAs } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { URL } from 'url'; @@ -11,7 +15,6 @@ import { URL } from 'url'; import { AppConfig } from '../config/app.config'; import { AuthConfig } from '../config/auth.config'; import { CustomizationConfig } from '../config/customization.config'; -import { DefaultAccessLevel } from '../config/default-access-level.enum'; import { ExternalServicesConfig } from '../config/external-services.config'; import { Loglevel } from '../config/loglevel.enum'; import { NoteConfig } from '../config/note.config'; @@ -19,10 +22,7 @@ import { LoggerModule } from '../logger/logger.module'; import { getServerVersionFromPackageJson } from '../utils/server-version'; import { FrontendConfigService } from './frontend-config.service'; -/* eslint-disable - jest/no-conditional-expect - */ - +/* eslint-disable jest/no-conditional-expect */ describe('FrontendConfigService', () => { const domain = 'http://md.example.com'; const emptyAuthConfig: AuthConfig = { @@ -60,6 +60,7 @@ describe('FrontendConfigService', () => { displayNameField: 'ldapTestDisplayName', profilePictureField: 'ldapTestProfilePicture', tlsCaCerts: ['ldapTestTlsCa'], + tlsRejectUnauthorized: false, }, ]; const oidc: AuthConfig['oidc'] = [ @@ -75,6 +76,7 @@ describe('FrontendConfigService', () => { displayNameField: '', profilePictureField: '', emailField: '', + enableRegistration: true, }, ]; for (const authConfigConfigured of [ldap, oidc]) { @@ -108,15 +110,15 @@ describe('FrontendConfigService', () => { return { forbiddenNoteIds: [], maxDocumentLength: 200, - guestAccess: PermissionLevel.CREATE, + guestAccess: PermissionLevel.FULL, permissions: { default: { - everyone: DefaultAccessLevel.READ, - loggedIn: DefaultAccessLevel.WRITE, + everyone: PermissionLevelNames[PermissionLevel.READ], + loggedIn: PermissionLevelNames[PermissionLevel.WRITE], }, }, revisionRetentionDays: 0, - } as NoteConfig; + } as unknown as NoteConfig; }), ], }), @@ -213,12 +215,12 @@ describe('FrontendConfigService', () => { const noteConfig: NoteConfig = { forbiddenNoteIds: [], maxDocumentLength: maxDocumentLength, - guestAccess: PermissionLevel.CREATE, permissions: { default: { - everyone: DefaultAccessLevel.READ, - loggedIn: DefaultAccessLevel.WRITE, + everyone: PermissionLevel.READ, + loggedIn: PermissionLevel.WRITE, }, + maxGuestLevel: PermissionLevel.FULL, }, revisionRetentionDays: 0, }; @@ -248,7 +250,9 @@ describe('FrontendConfigService', () => { const service = module.get(FrontendConfigService); const config = await service.getFrontendConfig(); expect(config.allowRegister).toEqual(enableRegister); - expect(config.guestAccess).toEqual(noteConfig.guestAccess); + expect(config.guestAccess).toEqual( + noteConfig.permissions.maxGuestLevel, + ); expect(config.branding.name).toEqual(customName); expect(config.branding.logo).toEqual( customLogo !== null ? new URL(customLogo).toString() : null, diff --git a/backend/src/groups/groups.service.spec.ts b/backend/src/groups/groups.service.spec.ts index 696758a67..69527744e 100644 --- a/backend/src/groups/groups.service.spec.ts +++ b/backend/src/groups/groups.service.spec.ts @@ -3,3 +3,129 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ +import { FieldNameGroup, TableGroup } from '@hedgedoc/database'; +import { Provider } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import type { Tracker } from 'knex-mock-client'; + +import appConfigMock from '../config/mock/app.config.mock'; +import databaseConfigMock from '../config/mock/database.config.mock'; +import { expectBindings } from '../database/mock/expect-bindings'; +import { mockInsert, mockSelect } from '../database/mock/mock-queries'; +import { mockKnexDb } from '../database/mock/provider'; +import { AlreadyInDBError, NotInDBError } from '../errors/errors'; +import { LoggerModule } from '../logger/logger.module'; +import { GroupsService } from './groups.service'; + +describe('GroupsService', () => { + const groupName = 'test_group'; + const groupDisplayName = 'Test Group'; + const groupId = 42; + + let service: GroupsService; + let tracker: Tracker; + let knexProvider: Provider; + + beforeAll(async () => { + [tracker, knexProvider] = mockKnexDb(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [GroupsService, knexProvider], + imports: [ + LoggerModule, + await ConfigModule.forRoot({ + isGlobal: true, + load: [appConfigMock, databaseConfigMock], + }), + ], + }).compile(); + + service = module.get(GroupsService); + }); + + afterEach(() => { + tracker.reset(); + }); + + describe('createGroup', () => { + it('inserts a new group', async () => { + mockInsert(tracker, TableGroup, [ + FieldNameGroup.displayName, + FieldNameGroup.isSpecial, + FieldNameGroup.name, + ]); + await service.createGroup(groupName, groupDisplayName); + expectBindings(tracker, 'insert', [[groupDisplayName, false, groupName]]); + }); + + it('throws AlreadyInDBError if group already exists', async () => { + tracker.on + .insert( + /^insert into "group" \("display_name", "is_special", "name"\) values .*/, + ) + .simulateError('duplicate key value violates unique constraint'); + await expect( + service.createGroup(groupName, groupDisplayName), + ).rejects.toThrow(AlreadyInDBError); + }); + }); + + describe('getGroupInfoDtoByName', () => { + it('returns group info if found', async () => { + const groupRow = { + [FieldNameGroup.name]: groupName, + [FieldNameGroup.displayName]: groupDisplayName, + [FieldNameGroup.isSpecial]: false, + }; + mockSelect(tracker, [], TableGroup, FieldNameGroup.name, groupRow); + const result = await service.getGroupInfoDtoByName(groupName); + expect(result).toEqual({ + name: groupName, + displayName: groupDisplayName, + special: false, + }); + expectBindings(tracker, 'select', [[groupName]], true); + }); + + it('throws NotInDBError if group not found', async () => { + mockSelect(tracker, [], TableGroup, FieldNameGroup.name, undefined); + await expect(service.getGroupInfoDtoByName(groupName)).rejects.toThrow( + NotInDBError, + ); + expectBindings(tracker, 'select', [[groupName]], true); + }); + }); + + describe('getGroupIdByName', () => { + it('returns group id if found', async () => { + const groupRow = { + [FieldNameGroup.id]: groupId, + }; + mockSelect( + tracker, + [FieldNameGroup.id], + TableGroup, + FieldNameGroup.name, + groupRow, + ); + const result = await service.getGroupIdByName(groupName); + expect(result).toBe(groupId); + expectBindings(tracker, 'select', [[groupName]], true); + }); + + it('throws NotInDBError if group not found', async () => { + mockSelect( + tracker, + [FieldNameGroup.id], + TableGroup, + FieldNameGroup.name, + undefined, + ); + await expect(service.getGroupIdByName(groupName)).rejects.toThrow( + NotInDBError, + ); + expectBindings(tracker, 'select', [[groupName]], true); + }); + }); +}); diff --git a/backend/src/media/media.service.spec.ts b/backend/src/media/media.service.spec.ts index 696758a67..78f95c2e4 100644 --- a/backend/src/media/media.service.spec.ts +++ b/backend/src/media/media.service.spec.ts @@ -3,3 +3,359 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ +import { + FieldNameAlias, + FieldNameMediaUpload, + FieldNameUser, + MediaBackendType, + TableAlias, + TableMediaUpload, + TableUser, +} from '@hedgedoc/database'; +import { Provider } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import * as fileTypeModule from 'file-type'; +import type { Tracker } from 'knex-mock-client'; +import * as uuidModule from 'uuid'; + +import appConfigMock from '../config/mock/app.config.mock'; +import databaseConfigMock from '../config/mock/database.config.mock'; +import mediaConfigMock from '../config/mock/media.config.mock'; +import { expectBindings } from '../database/mock/expect-bindings'; +import { + mockDelete, + mockInsert, + mockSelect, + mockUpdate, +} from '../database/mock/mock-queries'; +import { mockKnexDb } from '../database/mock/provider'; +import { ClientError, NotInDBError } from '../errors/errors'; +import { LoggerModule } from '../logger/logger.module'; +import { FilesystemBackend } from './backends/filesystem-backend'; +import { MediaService } from './media.service'; + +jest.mock('file-type'); +jest.mock('uuid'); + +describe('MediaService', () => { + const userId = 1; + const noteId = 2; + const uuid = '0198c9b6-117f-7215-93e2-5ca4b718225f'; + const fileName = 'test.png'; + const backendType = MediaBackendType.FILESYSTEM; + const backendData = JSON.stringify({ ext: 'png' }); + const fileBuffer = Buffer.from('test'); + const username = 'testuser'; + const alias = 'note-alias'; + const createdAt = new Date().toISOString(); + + let service: MediaService; + let fileSystemBackend: FilesystemBackend; + let tracker: Tracker; + let knexProvider: Provider; + + beforeAll(async () => { + [tracker, knexProvider] = mockKnexDb(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [MediaService, knexProvider, FilesystemBackend], + imports: [ + LoggerModule, + await ConfigModule.forRoot({ + isGlobal: true, + load: [appConfigMock, databaseConfigMock, mediaConfigMock], + }), + ], + }).compile(); + + service = module.get(MediaService); + fileSystemBackend = module.get(FilesystemBackend); + }); + + afterEach(() => { + tracker.reset(); + jest.clearAllMocks(); + }); + + describe('isAllowedMimeType', () => { + // ToDo: Add this test later + // This is currently so trivial it isn't really worth it. + }); + + describe('saveFile', () => { + it('inserts a new media upload and returns uuid', async () => { + jest + .spyOn(fileTypeModule, 'fromBuffer') + .mockResolvedValue({ mime: 'image/png', ext: 'png' }); + // This invalid Uint8Array typecast is required as TypeScript does not accept + // that uuid.v7 can return either a string or Uint8Array based on the options. + jest + .spyOn(uuidModule, 'v7') + .mockReturnValue(uuid as unknown as Uint8Array); + mockInsert( + tracker, + TableMediaUpload, + [ + FieldNameMediaUpload.backendData, + FieldNameMediaUpload.backendType, + FieldNameMediaUpload.fileName, + FieldNameMediaUpload.noteId, + FieldNameMediaUpload.userId, + ], + [{ [FieldNameMediaUpload.uuid]: uuid }], + ); + jest + .spyOn(service.mediaBackend, 'saveFile') + .mockImplementationOnce( + async ( + givenUuid: string, + buffer: Buffer, + fileType?: fileTypeModule.FileTypeResult, + ): Promise => { + expect(givenUuid).toBe(uuid); + expect(buffer).toEqual(fileBuffer); + expect(fileType).toBeDefined(); + expect(fileType!.ext).toEqual('png'); + return JSON.stringify({ ext: fileType!.ext }); + }, + ); + const result = await service.saveFile( + fileName, + fileBuffer, + userId, + noteId, + ); + expect(result).toBe(uuid); + expectBindings(tracker, 'insert', [ + [backendData, backendType, fileName, noteId, userId], + ]); + }); + + it('throws ClientError if file type is not detected', async () => { + jest.spyOn(fileTypeModule, 'fromBuffer').mockResolvedValue(undefined); + await expect( + service.saveFile(fileName, fileBuffer, userId, noteId), + ).rejects.toThrow(ClientError); + }); + + it('throws ClientError if mime type is not allowed', async () => { + jest.spyOn(fileTypeModule, 'fromBuffer').mockResolvedValue({ + // correct MIME type for Windows exe would be + // application/vnd.microsoft.portable-executable according to IANA, + // but file-type detects it as the following + mime: 'application/x-msdownload', + ext: 'exe', + }); + await expect( + service.saveFile(fileName, fileBuffer, userId, noteId), + ).rejects.toThrow(ClientError); + }); + }); + + describe('deleteFile', () => { + it('deletes a file if found', async () => { + mockSelect( + tracker, + [FieldNameMediaUpload.backendData], + TableMediaUpload, + FieldNameMediaUpload.uuid, + { [FieldNameMediaUpload.backendData]: backendData }, + ); + mockDelete(tracker, TableMediaUpload, [FieldNameMediaUpload.uuid]); + jest + .spyOn(service.mediaBackend, 'deleteFile') + .mockImplementationOnce( + async (givenUuid: string, givenBackendData: string | null) => { + expect(givenUuid).toBe(uuid); + expect(givenBackendData).toBe(backendData); + }, + ); + await service.deleteFile(uuid); + expectBindings(tracker, 'select', [[uuid]], true); + expectBindings(tracker, 'delete', [[uuid]]); + }); + + it('throws NotInDBError if file not found', async () => { + mockSelect( + tracker, + [FieldNameMediaUpload.backendData], + TableMediaUpload, + FieldNameMediaUpload.uuid, + undefined, + ); + await expect(service.deleteFile(uuid)).rejects.toThrow(NotInDBError); + expectBindings(tracker, 'select', [[uuid]], true); + }); + }); + + describe('getFileUrl', () => { + it('returns file url if found', async () => { + mockSelect( + tracker, + [FieldNameMediaUpload.backendType, FieldNameMediaUpload.backendData], + TableMediaUpload, + FieldNameMediaUpload.uuid, + { + [FieldNameMediaUpload.backendType]: backendType, + [FieldNameMediaUpload.backendData]: backendData, + }, + ); + // As the media service loads the used backend dynamically, we need to + // spy on fileSystemBackend here instead of service.mediaBackend + jest + .spyOn(fileSystemBackend, 'getFileUrl') + .mockImplementationOnce( + async ( + givenUuid: string, + givenBackendData: string | null, + ): Promise => { + expect(givenUuid).toBe(uuid); + expect(givenBackendData).toBe(backendData); + return `http://example.com/${fileName}`; + }, + ); + const result = await service.getFileUrl(uuid); + expect(result).toBe(`http://example.com/${fileName}`); + expectBindings(tracker, 'select', [[uuid]], true); + }); + + it('throws NotInDBError if not found', async () => { + mockSelect( + tracker, + [FieldNameMediaUpload.backendType, FieldNameMediaUpload.backendData], + TableMediaUpload, + FieldNameMediaUpload.uuid, + undefined, + ); + await expect(service.getFileUrl(uuid)).rejects.toThrow(NotInDBError); + expectBindings(tracker, 'select', [[uuid]], true); + }); + }); + + describe('findUploadByUuid', () => { + it('returns media upload if found', async () => { + const row = { [FieldNameMediaUpload.uuid]: uuid }; + mockSelect(tracker, [], TableMediaUpload, FieldNameMediaUpload.uuid, row); + const result = await service.findUploadByUuid(uuid); + expect(result).toEqual(row); + expectBindings(tracker, 'select', [[uuid]], true); + }); + + it('throws NotInDBError if not found', async () => { + mockSelect( + tracker, + [], + TableMediaUpload, + FieldNameMediaUpload.uuid, + undefined, + ); + await expect(service.findUploadByUuid(uuid)).rejects.toThrow( + NotInDBError, + ); + expectBindings(tracker, 'select', [[uuid]], true); + }); + }); + + describe('getMediaUploadUuidsByUserId', () => { + it('returns uuids for user', async () => { + const rows = [{ [FieldNameMediaUpload.uuid]: uuid }]; + mockSelect( + tracker, + [FieldNameMediaUpload.uuid], + TableMediaUpload, + FieldNameMediaUpload.userId, + rows, + ); + const result = await service.getMediaUploadUuidsByUserId(userId); + expect(result).toEqual([uuid]); + expectBindings(tracker, 'select', [[userId]], false); + }); + }); + + describe('getMediaUploadUuidsByNoteId', () => { + it('returns uuids for note', async () => { + const rows = [{ [FieldNameMediaUpload.uuid]: uuid }]; + mockSelect( + tracker, + [FieldNameMediaUpload.uuid], + TableMediaUpload, + FieldNameMediaUpload.noteId, + rows, + ); + const result = await service.getMediaUploadUuidsByNoteId(noteId); + expect(result).toEqual([uuid]); + }); + }); + + describe('removeNoteFromMediaUpload', () => { + it('updates noteId to null', async () => { + mockUpdate( + tracker, + TableMediaUpload, + [FieldNameMediaUpload.noteId], + FieldNameMediaUpload.uuid, + ); + await service.removeNoteFromMediaUpload(uuid); + expectBindings(tracker, 'update', [[null, uuid]]); + }); + }); + + describe('chooseBackendType', () => { + // ToDo: Add this test later + // This is currently so trivial it isn't really worth it. + }); + + describe('getBackendFromType', () => { + // ToDo: Add this test later + // This is currently so trivial it isn't really worth it. + }); + + describe('getMediaUploadDtosByUuids', () => { + it('returns media upload dtos', async () => { + const rows = [ + { + [FieldNameMediaUpload.uuid]: uuid, + [FieldNameMediaUpload.fileName]: fileName, + [FieldNameMediaUpload.createdAt]: createdAt, + [FieldNameUser.username]: username, + [FieldNameAlias.alias]: alias, + }, + ]; + mockSelect( + tracker, + [ + `${TableMediaUpload}"."${FieldNameMediaUpload.uuid}`, + `${TableMediaUpload}"."${FieldNameMediaUpload.fileName}`, + `${TableMediaUpload}"."${FieldNameMediaUpload.createdAt}`, + `${TableUser}"."${FieldNameUser.username}`, + `${TableAlias}"."${FieldNameAlias.alias}`, + ], + TableMediaUpload, + FieldNameMediaUpload.uuid, + rows, + [ + { + joinTable: TableAlias, + keyLeft: FieldNameAlias.noteId, + }, + { + joinTable: TableUser, + keyLeft: FieldNameUser.id, + keyRight: FieldNameMediaUpload.userId, + }, + ], + ); + const result = await service.getMediaUploadDtosByUuids([uuid]); + expect(result).toEqual([ + { + uuid, + fileName, + noteId: alias, + createdAt: new Date(createdAt).toISOString(), + username, + }, + ]); + }); + }); +}); diff --git a/backend/src/notes/note.service.spec.ts b/backend/src/notes/note.service.spec.ts index e2d02dabf..142840918 100644 --- a/backend/src/notes/note.service.spec.ts +++ b/backend/src/notes/note.service.spec.ts @@ -3,723 +3,698 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { EventEmitter2, EventEmitterModule } from '@nestjs/event-emitter'; -import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { Mock } from 'ts-mockery'; +import { NoteMetadataDto, PermissionLevel } from '@hedgedoc/commons'; import { - DataSource, - EntityManager, - FindOptionsWhere, - Repository, -} from 'typeorm'; + FieldNameAlias, + FieldNameGroup, + FieldNameNote, + FieldNameNoteGroupPermission, + FieldNameNoteUserPermission, + FieldNameRevision, + FieldNameUser, + NoteType, + TableAlias, + TableGroup, + TableNote, + TableNoteGroupPermission, + TableNoteUserPermission, + TableUser, +} from '@hedgedoc/database'; +import { Provider } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Test, TestingModule } from '@nestjs/testing'; +import type { Tracker } from 'knex-mock-client'; import { AliasService } from '../alias/alias.service'; -import { ApiToken } from '../api-token/api-token.entity'; -import { Identity } from '../auth/identity.entity'; -import { Author } from '../authors/author.entity'; -import { DefaultAccessLevel } from '../config/default-access-level.enum'; 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 { createDefaultMockNoteConfig, registerNoteConfig, } from '../config/mock/note.config.mock'; import { NoteConfig } from '../config/note.config'; -import { User } from '../database/user.entity'; +import { expectBindings, IS_FIRST } from '../database/mock/expect-bindings'; +import { + mockDelete, + mockInsert, + mockSelect, +} from '../database/mock/mock-queries'; +import { mockKnexDb } from '../database/mock/provider'; import { - AlreadyInDBError, ForbiddenIdError, + GenericDBError, MaximumDocumentLengthExceededError, NotInDBError, } from '../errors/errors'; -import { eventModuleConfig, NoteEvent } from '../events'; -import { Group } from '../groups/group.entity'; -import { GroupsModule } from '../groups/groups.module'; -import { SpecialGroup } from '../groups/groups.special'; +import { NoteEvent, NoteEventMap } from '../events'; +import { GroupsService } from '../groups/groups.service'; import { LoggerModule } from '../logger/logger.module'; -import { NoteGroupPermission } from '../permissions/note-group-permission.entity'; -import { NoteUserPermission } from '../permissions/note-user-permission.entity'; -import { RealtimeNoteModule } from '../realtime/realtime-note/realtime-note.module'; -import { Edit } from '../revisions/edit.entity'; -import { Revision } from '../revisions/revision.entity'; -import { RevisionsModule } from '../revisions/revisions.module'; +import { PermissionService } from '../permissions/permission.service'; +import { RealtimeNoteStore } from '../realtime/realtime-note/realtime-note-store'; import { RevisionsService } from '../revisions/revisions.service'; -import { Session } from '../sessions/session.entity'; -import { UsersModule } from '../users/users.module'; -import { mockSelectQueryBuilderInRepo } from '../utils/test-utils/mockSelectQueryBuilder'; -import { Alias } from './aliases.entity'; -import { Note } from './note.entity'; +import { UsersService } from '../users/users.service'; import { NoteService } from './note.service'; -import { Tag } from './tag.entity'; -jest.mock('../revisions/revisions.service'); - -describe('NotesService', () => { +describe('NoteService', () => { let service: NoteService; - let revisionsService: RevisionsService; const noteMockConfig: NoteConfig = createDefaultMockNoteConfig(); - let noteRepo: Repository; - let userRepo: Repository; - let aliasRepo: Repository; - let groupRepo: Repository; - let forbiddenNoteId: string; - let everyoneDefaultAccessPermission: string; - let loggedinDefaultAccessPermission: string; - let eventEmitter: EventEmitter2; - const everyone = Group.create( - SpecialGroup.EVERYONE, - SpecialGroup.EVERYONE, - true, - ); - const loggedin = Group.create( - SpecialGroup.LOGGED_IN, - SpecialGroup.LOGGED_IN, - true, - ); + let aliasService: AliasService; + let eventEmitter: EventEmitter2; + let revisionService: RevisionsService; + let realtimeNoteStore: RealtimeNoteStore; + let groupsService: GroupsService; + let permissionService: PermissionService; + let tracker: Tracker; + let knexProvider: Provider; - beforeEach(async () => { - jest.resetAllMocks(); - jest.resetModules(); + const mockNoteId = 42; + const mockOwnerUserId = 7; + const mockNoteContent = 'Hello world!'; + const mockAliasCustom = 'my-alias'; + const mockAliasRandom = 'random-alias'; + const everyoneGroupId = 1; + const loggedInGroupId = 1; + const mockUsername = 'TestyMcTestface'; + const mockGroupName = 'Testers-Group'; + const mockRevisionUuid = '0199110d-076f-7724-9229-bbeb32b53592'; + const mockCreatedAt = new Date().toISOString(); + const mockUpdatedAt = new Date(2025, 9, 3, 21, 35).toISOString(); + const mockNoteType = NoteType.DOCUMENT; + const mockPatch = 'mockPatch'; + const mockNoteTitle = 'mockNoteTitle'; + const mockNoteDescription = 'mockNoteDescription'; + const mockTags = ['tag1', 'tag2']; + const mockPermissions = { + owner: mockUsername, + sharedToUsers: [], + sharedToGroups: [], + }; - /** - * We need to have *one* userRepo for both the providers array and - * the overrideProvider call, as otherwise we have two instances - * and the mock of createQueryBuilder replaces the wrong one - * **/ - userRepo = new Repository( - '', - new EntityManager( - new DataSource({ - type: 'sqlite', - database: ':memory:', - }), - ), - undefined, - ); - noteRepo = new Repository( - '', - new EntityManager( - new DataSource({ - type: 'sqlite', - database: ':memory:', - }), - ), - undefined, - ); - aliasRepo = new Repository( - '', - new EntityManager( - new DataSource({ - type: 'sqlite', - database: ':memory:', - }), - ), - undefined, - ); - groupRepo = new Repository( - '', - new EntityManager( - new DataSource({ - type: 'sqlite', - database: ':memory:', - }), - ), - undefined, - ); - - revisionsService = Mock.of({ - getLatestRevision: jest.fn(), - createRevision: jest.fn(), - }); + beforeAll(async () => { + [tracker, knexProvider] = mockKnexDb(); const module: TestingModule = await Test.createTestingModule({ providers: [ NoteService, - { - provide: RevisionsService, - useValue: revisionsService, - }, + knexProvider, + GroupsService, + RevisionsService, AliasService, - { - provide: getRepositoryToken(Note), - useValue: noteRepo, - }, - { - provide: getRepositoryToken(Tag), - useClass: Repository, - }, - { - provide: getRepositoryToken(Revision), - useClass: Repository, - }, - { - provide: getRepositoryToken(Alias), - useValue: aliasRepo, - }, - { - provide: getRepositoryToken(User), - useValue: userRepo, - }, - { - provide: getRepositoryToken(Group), - useValue: groupRepo, - }, + PermissionService, + RealtimeNoteStore, + EventEmitter2, + UsersService, ], imports: [ LoggerModule, - UsersModule, - GroupsModule, - RevisionsModule, - RealtimeNoteModule, - ConfigModule.forRoot({ + await ConfigModule.forRoot({ isGlobal: true, load: [ appConfigMock, databaseConfigMock, - authConfigMock, registerNoteConfig(noteMockConfig), ], }), - EventEmitterModule.forRoot(eventModuleConfig), ], - }) - .overrideProvider(getRepositoryToken(Note)) - .useValue(noteRepo) - .overrideProvider(getRepositoryToken(Tag)) - .useClass(Repository) - .overrideProvider(getRepositoryToken(Alias)) - .useValue(aliasRepo) - .overrideProvider(getRepositoryToken(User)) - .useValue(userRepo) - .overrideProvider(getRepositoryToken(ApiToken)) - .useValue({}) - .overrideProvider(getRepositoryToken(Identity)) - .useValue({}) - .overrideProvider(getRepositoryToken(Edit)) - .useValue({}) - .overrideProvider(getRepositoryToken(Revision)) - .useClass(Repository) - .overrideProvider(getRepositoryToken(NoteGroupPermission)) - .useValue({}) - .overrideProvider(getRepositoryToken(NoteUserPermission)) - .useValue({}) - .overrideProvider(getRepositoryToken(Group)) - .useValue(groupRepo) - .overrideProvider(getRepositoryToken(Session)) - .useValue({}) - .overrideProvider(getRepositoryToken(Author)) - .useValue({}) - .compile(); + }).compile(); - const config = module.get(ConfigService); - const noteConfig = config.get('noteConfig') as NoteConfig; - forbiddenNoteId = noteConfig.forbiddenNoteIds[0]; - everyoneDefaultAccessPermission = noteConfig.permissions.default.everyone; - loggedinDefaultAccessPermission = noteConfig.permissions.default.loggedIn; service = module.get(NoteService); - noteRepo = module.get>(getRepositoryToken(Note)); - aliasRepo = module.get>(getRepositoryToken(Alias)); - eventEmitter = module.get(EventEmitter2); + aliasService = module.get(AliasService); + eventEmitter = module.get>( + EventEmitter2, + ); + revisionService = module.get(RevisionsService); + realtimeNoteStore = module.get(RealtimeNoteStore); + groupsService = module.get(GroupsService); + permissionService = module.get(PermissionService); }); - /** - * Creates a Note and a corresponding User and Group for testing. - * The Note does not have any aliases. - */ - async function getMockData(): Promise<[Note, User, Group, Revision]> { - const user = User.create('hardcoded', 'Testy') as User; - const author = Author.create(1); - author.user = Promise.resolve(user); - const group = Group.create('testGroup', 'testGroup', false) as Group; - jest - .spyOn(noteRepo, 'save') - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - .mockImplementation(async (note: Note): Promise => note); - mockGroupRepo(); - - const revision = Mock.of({ - edits: Promise.resolve([ - { - startPos: 0, - endPos: 1, - updatedAt: new Date(1549312452000), - author: Promise.resolve(author), - } as Edit, - { - startPos: 0, - endPos: 1, - updatedAt: new Date(1549312452001), - author: Promise.resolve(author), - } as Edit, - ]), - createdAt: new Date(1549312452000), - tags: Promise.resolve([ - { - id: 0, - name: 'tag1', - } as Tag, - ]), - content: 'mockContent', - description: 'mockDescription', - title: 'mockTitle', - }); - - const note = Mock.of({ - revisions: Promise.resolve([revision]), - aliases: Promise.resolve([]), - createdAt: new Date(1549312452000), - }); - - mockRevisionService(note, revision); - - mockSelectQueryBuilderInRepo(userRepo, user); - note.publicId = 'testId'; - note.owner = Promise.resolve(user); - note.userPermissions = Promise.resolve([ - { - id: 1, - note: Promise.resolve(note), - user: Promise.resolve(user), - canEdit: true, - }, - ]); - note.groupPermissions = Promise.resolve([ - { - id: 1, - note: Promise.resolve(note), - group: Promise.resolve(group), - canEdit: true, - }, - ]); - note.viewCount = 1337; - - return [note, user, group, revision]; - } - - function mockRevisionService(note: Note, revision: Revision) { - jest - .spyOn(revisionsService, 'getLatestRevision') - .mockImplementation((requestedNote) => { - expect(requestedNote).toBe(note); - return Promise.resolve(revision); - }); - } - - function mockGroupRepo() { - jest.spyOn(groupRepo, 'findOne').mockReset(); - jest.spyOn(groupRepo, 'findOne').mockImplementation((args) => { - const groupName = (args.where as FindOptionsWhere).name; - if (groupName === loggedin.name) { - return Promise.resolve(loggedin as Group); - } else if (groupName === everyone.name) { - return Promise.resolve(everyone as Group); - } else { - return Promise.resolve(null); - } - }); - } - - it('should be defined', () => { - expect(service).toBeDefined(); + afterEach(() => { + tracker.reset(); + jest.restoreAllMocks(); }); - describe('getUserNotes', () => { - describe('works', () => { - const user = User.create('hardcoded', 'Testy') as User; - const alias = 'alias'; - const note = Note.create(user, alias) as Note; - - it('with no note', async () => { - mockSelectQueryBuilderInRepo(noteRepo, null); - const notes = await service.getUserNoteIds(user); - expect(notes).toEqual([]); - }); - - it('with one note', async () => { - mockSelectQueryBuilderInRepo(noteRepo, note); - const notes = await service.getUserNoteIds(user); - expect(notes).toEqual([note]); - }); - - it('with multiple note', async () => { - mockSelectQueryBuilderInRepo(noteRepo, [note, note]); - const notes = await service.getUserNoteIds(user); - expect(notes).toEqual([note, note]); - }); + describe('getUserNoteIds', () => { + it('correctly returns the note ids', async () => { + const rows = [ + { + [FieldNameNote.id]: mockNoteId, + }, + ]; + mockSelect( + tracker, + [FieldNameNote.id], + TableNote, + FieldNameNote.ownerId, + rows, + ); + const result = await service.getUserNoteIds(mockOwnerUserId); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(mockNoteId); + expectBindings(tracker, 'select', [[mockOwnerUserId]]); }); }); describe('createNote', () => { - const user = User.create('hardcoded', 'Testy') as User; - const alias = 'alias'; - const content = 'testContent'; - const newRevision = Mock.of({}); - let createRevisionSpy: jest.SpyInstance; - - describe('works', () => { - beforeEach(() => { - jest - .spyOn(noteRepo, 'save') - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - .mockImplementation(async (note: Note): Promise => note); - jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(false); - jest.spyOn(aliasRepo, 'existsBy').mockResolvedValueOnce(false); - mockGroupRepo(); - - createRevisionSpy = jest - .spyOn(revisionsService, 'createRevision') - .mockResolvedValue(newRevision); - - mockSelectQueryBuilderInRepo(noteRepo, null); - }); - it('without aliases, without owner', async () => { - const newNote = await service.createNote(content, null); - - expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content); - expect(await newNote.revisions).toStrictEqual([newRevision]); - expect(await newNote.historyEntries).toHaveLength(0); - expect(await newNote.userPermissions).toHaveLength(0); - const groupPermissions = await newNote.groupPermissions; - expect(groupPermissions).toHaveLength(2); - expect(groupPermissions[0].canEdit).toEqual( - everyoneDefaultAccessPermission !== - (DefaultAccessLevel.WRITE as string), - ); - expect((await groupPermissions[0].group).name).toEqual( - SpecialGroup.EVERYONE, - ); - expect(groupPermissions[1].canEdit).toEqual( - loggedinDefaultAccessPermission === - (DefaultAccessLevel.WRITE as string), - ); - expect((await groupPermissions[1].group).name).toEqual( - SpecialGroup.LOGGED_IN, - ); - expect(await newNote.owner).toBeNull(); - expect(await newNote.aliases).toHaveLength(0); - }); - it('without aliases, with owner', async () => { - const newNote = await service.createNote(content, user); - expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content); - expect(await newNote.revisions).toStrictEqual([newRevision]); - expect(await newNote.historyEntries).toHaveLength(1); - expect(await (await newNote.historyEntries)[0].user).toEqual(user); - expect(await newNote.userPermissions).toHaveLength(0); - const groupPermissions = await newNote.groupPermissions; - expect(groupPermissions).toHaveLength(2); - expect(groupPermissions[0].canEdit).toEqual( - everyoneDefaultAccessPermission === - (DefaultAccessLevel.WRITE as string), - ); - expect((await groupPermissions[0].group).name).toEqual( - SpecialGroup.EVERYONE, - ); - expect(groupPermissions[1].canEdit).toEqual( - loggedinDefaultAccessPermission === - (DefaultAccessLevel.WRITE as string), - ); - expect((await groupPermissions[1].group).name).toEqual( - SpecialGroup.LOGGED_IN, - ); - expect(await newNote.owner).toEqual(user); - expect(await newNote.aliases).toHaveLength(0); - }); - it('with aliases, without owner', async () => { - const newNote = await service.createNote(content, null, alias); - expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content); - expect(await newNote.revisions).toStrictEqual([newRevision]); - expect(await newNote.historyEntries).toHaveLength(0); - expect(await newNote.userPermissions).toHaveLength(0); - const groupPermissions = await newNote.groupPermissions; - expect(groupPermissions).toHaveLength(2); - expect(groupPermissions[0].canEdit).toEqual( - everyoneDefaultAccessPermission !== - (DefaultAccessLevel.WRITE as string), - ); - expect((await groupPermissions[0].group).name).toEqual( - SpecialGroup.EVERYONE, - ); - expect(groupPermissions[1].canEdit).toEqual( - loggedinDefaultAccessPermission === - (DefaultAccessLevel.WRITE as string), - ); - expect((await groupPermissions[1].group).name).toEqual( - SpecialGroup.LOGGED_IN, - ); - expect(await newNote.owner).toBeNull(); - expect(await newNote.aliases).toHaveLength(1); - }); - it('with aliases, with owner', async () => { - const newNote = await service.createNote(content, user, alias); - - expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content); - expect(await newNote.revisions).toStrictEqual([newRevision]); - expect(await newNote.historyEntries).toHaveLength(1); - expect(await (await newNote.historyEntries)[0].user).toEqual(user); - expect(await newNote.userPermissions).toHaveLength(0); - const groupPermissions = await newNote.groupPermissions; - expect(groupPermissions).toHaveLength(2); - expect(groupPermissions[0].canEdit).toEqual( - everyoneDefaultAccessPermission === - (DefaultAccessLevel.WRITE as string), - ); - expect((await groupPermissions[0].group).name).toEqual( - SpecialGroup.EVERYONE, - ); - expect(groupPermissions[1].canEdit).toEqual( - loggedinDefaultAccessPermission === - (DefaultAccessLevel.WRITE as string), - ); - expect((await groupPermissions[1].group).name).toEqual( - SpecialGroup.LOGGED_IN, - ); - expect(await newNote.owner).toEqual(user); - expect(await newNote.aliases).toHaveLength(1); - expect((await newNote.aliases)[0].name).toEqual(alias); - }); - describe('with maxDocumentLength 1000', () => { - beforeEach(() => (noteMockConfig.maxDocumentLength = 1000)); - it('and content has length maxDocumentLength', async () => { - const content = 'x'.repeat(noteMockConfig.maxDocumentLength); - const newNote = await service.createNote(content, user, alias); - - expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content); - expect(await newNote.revisions).toStrictEqual([newRevision]); - expect(await newNote.historyEntries).toHaveLength(1); - expect(await (await newNote.historyEntries)[0].user).toEqual(user); - expect(await newNote.userPermissions).toHaveLength(0); - const groupPermissions = await newNote.groupPermissions; - expect(groupPermissions).toHaveLength(2); - expect(groupPermissions[0].canEdit).toEqual( - everyoneDefaultAccessPermission === - (DefaultAccessLevel.WRITE as string), - ); - expect((await groupPermissions[0].group).name).toEqual( - SpecialGroup.EVERYONE, - ); - expect(groupPermissions[1].canEdit).toEqual( - loggedinDefaultAccessPermission === - (DefaultAccessLevel.WRITE as string), - ); - expect((await groupPermissions[1].group).name).toEqual( - SpecialGroup.LOGGED_IN, - ); - expect(await newNote.owner).toEqual(user); - expect(await newNote.aliases).toHaveLength(1); - expect((await newNote.aliases)[0].name).toEqual(alias); - }); - }); - describe('with other', () => { - beforeEach( - () => - (noteMockConfig.permissions.default.everyone = - DefaultAccessLevel.NONE), - ); - it('default permissions', async () => { - mockGroupRepo(); - const newNote = await service.createNote(content, user, alias); - - expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content); - expect(await newNote.revisions).toStrictEqual([newRevision]); - expect(await newNote.historyEntries).toHaveLength(1); - expect(await (await newNote.historyEntries)[0].user).toEqual(user); - expect(await newNote.userPermissions).toHaveLength(0); - const groupPermissions = await newNote.groupPermissions; - expect(groupPermissions).toHaveLength(1); - expect(groupPermissions[0].canEdit).toEqual( - loggedinDefaultAccessPermission === - (DefaultAccessLevel.WRITE as string), - ); - expect((await groupPermissions[0].group).name).toEqual( - SpecialGroup.LOGGED_IN, - ); - expect(await newNote.owner).toEqual(user); - expect(await newNote.aliases).toHaveLength(1); - expect((await newNote.aliases)[0].name).toEqual(alias); - }); - }); + it('throws a MaximumDocumentLengthExceededError', async () => { + const tooLongContent = 'a'.repeat(noteMockConfig.maxDocumentLength + 1); + await expect( + service.createNote(tooLongContent, mockOwnerUserId), + ).rejects.toThrow(MaximumDocumentLengthExceededError); }); - describe('fails:', () => { - beforeEach(() => { - mockSelectQueryBuilderInRepo(noteRepo, null); - }); - it('aliases is forbidden', async () => { - jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(false); - jest.spyOn(aliasRepo, 'existsBy').mockResolvedValueOnce(false); - await expect( - service.createNote(content, null, forbiddenNoteId), - ).rejects.toThrow(ForbiddenIdError); - }); - - it('aliases is already used (as another aliases)', async () => { - mockGroupRepo(); - jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(false); - jest.spyOn(aliasRepo, 'existsBy').mockResolvedValueOnce(true); - jest.spyOn(noteRepo, 'save').mockImplementationOnce(async () => { - throw new Error(); - }); - await expect(service.createNote(content, null, alias)).rejects.toThrow( - AlreadyInDBError, - ); - }); - - it('aliases is already used (as publicId)', async () => { - mockGroupRepo(); - jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(true); - jest.spyOn(aliasRepo, 'existsBy').mockResolvedValueOnce(false); - jest.spyOn(noteRepo, 'save').mockImplementationOnce(async () => { - throw new Error(); - }); - await expect(service.createNote(content, null, alias)).rejects.toThrow( - AlreadyInDBError, - ); - }); - describe('with maxDocumentLength 1000', () => { - beforeEach(() => (noteMockConfig.maxDocumentLength = 1000)); - it('document is too long', async () => { - mockGroupRepo(); - jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(false); - jest.spyOn(aliasRepo, 'existsBy').mockResolvedValueOnce(false); - jest.spyOn(noteRepo, 'save').mockImplementationOnce(async () => { - throw new Error(); - }); - const content = 'x'.repeat(noteMockConfig.maxDocumentLength + 1); - await expect( - service.createNote(content, user, alias), - ).rejects.toThrow(MaximumDocumentLengthExceededError); - }); - }); + it('throws GenericDBError if insert fails', async () => { + mockInsert( + tracker, + TableNote, + [FieldNameNote.ownerId, FieldNameNote.version], + [], + ); + await expect( + service.createNote(mockNoteContent, mockOwnerUserId, mockAliasCustom), + ).rejects.toThrow(GenericDBError); + expectBindings(tracker, 'insert', [[mockOwnerUserId, 2]]); }); + + /* eslint-disable jest/no-conditional-expect */ + describe.each([ + [ + PermissionLevel.READ, + PermissionLevel.WRITE, + undefined, + mockAliasRandom, + 'everyone read, loggedIn write, without alias', + ], + [ + PermissionLevel.DENY, + PermissionLevel.READ, + undefined, + mockAliasRandom, + 'everyone denied, loggedIn read, without alias', + ], + [ + PermissionLevel.WRITE, + PermissionLevel.DENY, + undefined, + mockAliasRandom, + 'everyone write, loggedIn denied, without alias', + ], + [ + PermissionLevel.READ, + PermissionLevel.DENY, + mockAliasCustom, + mockAliasCustom, + 'everyone read, loggedIn denied, with alias', + ], + ])( + 'inserts a new note', + (everyoneLevel, loggedInLevel, inputAlias, outputAlias, descr) => { + let result: number; + let mockEnsureAliasIsAvailable: jest.SpyInstance; + let mockGenerateRandomAlias: jest.SpyInstance; + let mockAddAlias: jest.SpyInstance; + let mockCreateRevision: jest.SpyInstance; + let mockGetGroupIdByName: jest.SpyInstance; + let mockSetGroupPermission: jest.SpyInstance; + beforeEach(() => { + console.log('beforeEach'); + mockEnsureAliasIsAvailable = jest + .spyOn(aliasService, 'ensureAliasIsAvailable') + .mockImplementation(async () => {}); + mockGenerateRandomAlias = jest + .spyOn(aliasService, 'generateRandomAlias') + .mockImplementation(() => mockAliasRandom); + mockAddAlias = jest + .spyOn(aliasService, 'addAlias') + .mockImplementation(async () => {}); + mockCreateRevision = jest + .spyOn(revisionService, 'createRevision') + .mockImplementation(async () => {}); + mockGetGroupIdByName = jest.spyOn(groupsService, 'getGroupIdByName'); + mockSetGroupPermission = jest + .spyOn(permissionService, 'setGroupPermission') + .mockImplementation(async () => {}); + mockInsert( + tracker, + TableNote, + [FieldNameNote.ownerId, FieldNameNote.version], + [{ [FieldNameNote.id]: mockNoteId }], + ); + }); + afterEach(() => { + expect(mockCreateRevision).toHaveBeenCalledWith( + mockNoteId, + mockNoteContent, + true, + expect.anything(), + ); + expect(result).toBe(mockNoteId); + expectBindings(tracker, 'insert', [[mockOwnerUserId, 2]]); + }); + + it(`with settings: ${descr}`, async () => { + noteMockConfig.permissions.default.everyone = everyoneLevel as + | PermissionLevel.DENY + | PermissionLevel.READ + | PermissionLevel.WRITE; + noteMockConfig.permissions.default.loggedIn = loggedInLevel as + | PermissionLevel.DENY + | PermissionLevel.READ + | PermissionLevel.WRITE; + let numberOfGroupIdByNameCalls = 0; + if (everyoneLevel !== PermissionLevel.DENY) { + mockGetGroupIdByName.mockImplementationOnce( + async () => everyoneGroupId, + ); + numberOfGroupIdByNameCalls++; + } + if (loggedInLevel !== PermissionLevel.DENY) { + mockGetGroupIdByName.mockImplementationOnce( + async () => loggedInGroupId, + ); + numberOfGroupIdByNameCalls++; + } + result = await service.createNote( + mockNoteContent, + mockOwnerUserId, + inputAlias, + ); + if (inputAlias === undefined) { + expect(mockEnsureAliasIsAvailable).not.toHaveBeenCalled(); + expect(mockGenerateRandomAlias).toHaveBeenCalled(); + } else { + expect(mockEnsureAliasIsAvailable).toHaveBeenCalledWith( + outputAlias, + expect.anything(), + ); + expect(mockGenerateRandomAlias).not.toHaveBeenCalled(); + } + expect(mockAddAlias).toHaveBeenCalledWith( + mockNoteId, + outputAlias, + expect.anything(), + ); + expect(mockGetGroupIdByName).toHaveBeenCalledTimes( + numberOfGroupIdByNameCalls, + ); + if (everyoneLevel !== PermissionLevel.DENY) { + expect(mockSetGroupPermission).toHaveBeenCalledWith( + mockNoteId, + everyoneGroupId, + everyoneLevel === PermissionLevel.WRITE, + expect.anything(), + ); + } + if (loggedInLevel !== PermissionLevel.DENY) { + expect(mockSetGroupPermission).toHaveBeenCalledWith( + mockNoteId, + loggedInGroupId, + loggedInLevel === PermissionLevel.WRITE, + expect.anything(), + ); + } + }); + }, + ); }); + /* eslint-enable jest/no-conditional-expect */ describe('getNoteContent', () => { - it('works', async () => { - const content = 'testContent'; - const revision = Mock.of({ content: content }); - const newNote = Mock.of(); - mockRevisionService(newNote, revision); - const result = await service.getNoteContent(newNote); - expect(result).toEqual(content); + let realtimeNoteStoreSpy: jest.SpyInstance; + let revsisionServiceSpy: jest.SpyInstance; + + beforeEach(() => { + realtimeNoteStoreSpy = jest.spyOn(realtimeNoteStore, 'find'); + revsisionServiceSpy = jest.spyOn(revisionService, 'getLatestRevision'); + }); + it('returns content from RealtimeNoteStore if note is active', async () => { + realtimeNoteStoreSpy.mockReturnValue({ + getRealtimeDoc: () => ({ + getCurrentContent: () => mockNoteContent, + }), + }); + const result = await service.getNoteContent(mockNoteId); + expect(result).toEqual(mockNoteContent); + }); + + it('returns latest revision otherwise', async () => { + realtimeNoteStoreSpy.mockReturnValue(undefined); + revsisionServiceSpy.mockReturnValue({ + content: mockNoteContent, + }); + const result = await service.getNoteContent(mockNoteId); + expect(result).toEqual(mockNoteContent); }); }); - describe('getNoteByIdOrAlias', () => { - it('works', async () => { - const user = User.create('hardcoded', 'Testy') as User; - const note = Note.create(user) as Note; - mockSelectQueryBuilderInRepo(noteRepo, note); - const foundNote = await service.getNoteIdByAlias('noteThatExists'); - expect(foundNote).toEqual(note); + describe('getNoteIdByAlias', () => { + let aliasServiceSpy: jest.SpyInstance; + // eslint-disable-next-line func-style + const buildMockSelect = (returnValues: unknown) => { + mockSelect( + tracker, + [`${TableNote}"."${FieldNameNote.id}`], + TableAlias, + FieldNameAlias.alias, + returnValues, + [ + { + joinTable: TableNote, + keyLeft: FieldNameNote.id, + keyRight: FieldNameAlias.noteId, + }, + ], + ); + }; + + beforeEach(() => { + aliasServiceSpy = jest.spyOn(aliasService, 'isAliasForbidden'); }); - describe('fails:', () => { - it('no note found', async () => { - mockSelectQueryBuilderInRepo(noteRepo, null); - await expect( - service.getNoteIdByAlias('noteThatDoesNoteExist'), - ).rejects.toThrow(NotInDBError); - }); - it('id is forbidden', async () => { - await expect(service.getNoteIdByAlias(forbiddenNoteId)).rejects.toThrow( - ForbiddenIdError, - ); - }); + + it('throws a ForbiddenIdError if the alias is forbidden', async () => { + aliasServiceSpy.mockReturnValue(true); + await expect(service.getNoteIdByAlias(mockAliasRandom)).rejects.toThrow( + ForbiddenIdError, + ); + }); + + it('throws a NotInDBError if the note is not found', async () => { + aliasServiceSpy.mockReturnValue(false); + buildMockSelect([]); + await expect(service.getNoteIdByAlias(mockAliasRandom)).rejects.toThrow( + NotInDBError, + ); + expectBindings(tracker, 'select', [[mockAliasRandom]], true); + }); + + it('returns the note id on success', async () => { + aliasServiceSpy.mockReturnValue(false); + buildMockSelect([ + { + [FieldNameNote.id]: mockNoteId, + }, + ]); + const result = await service.getNoteIdByAlias(mockAliasRandom); + expect(result).toEqual(mockNoteId); + expectBindings(tracker, 'select', [[mockAliasRandom]], true); }); }); describe('deleteNote', () => { - it('works', async () => { - const user = User.create('hardcoded', 'Testy') as User; - const note = Note.create(user) as Note; - jest - .spyOn(noteRepo, 'remove') - .mockImplementationOnce(async (entry, _) => { - expect(entry).toEqual(note); - return entry; - }); - const mockedEventEmitter = jest - .spyOn(eventEmitter, 'emit') - .mockImplementationOnce((event) => { - expect(event).toEqual(NoteEvent.DELETION); - return true; - }); - expect(mockedEventEmitter).not.toHaveBeenCalled(); - await service.deleteNote(note); - expect(mockedEventEmitter).toHaveBeenCalled(); + let eventEmitterSpy: jest.SpyInstance; + beforeEach(() => { + eventEmitterSpy = jest.spyOn(eventEmitter, 'emit').mockReturnValue(true); + }); + afterEach(() => { + expect(eventEmitterSpy).toHaveBeenCalledWith( + NoteEvent.DELETION, + mockNoteId, + ); + }); + it('throws NotInDBError if note not found', async () => { + mockDelete(tracker, TableNote, [FieldNameNote.id], 0); + await expect(service.deleteNote(mockNoteId)).rejects.toThrow( + NotInDBError, + ); + expectBindings(tracker, 'delete', [[mockNoteId]]); + }); + + it('deletes a note by id', async () => { + mockDelete(tracker, TableNote, [FieldNameNote.id], 1); + await service.deleteNote(mockNoteId); + expectBindings(tracker, 'delete', [[mockNoteId]]); }); }); describe('updateNote', () => { - it('adds a new revision if content is different', async () => { - const [note, , , revision] = await getMockData(); - - const mockRevision = Mock.of({}); - const createRevisionSpy = jest - .spyOn(revisionsService, 'createRevision') - .mockReturnValue(Promise.resolve(mockRevision)); - - const newContent = 'newContent'; - const updatedNote = await service.updateNote(note, newContent); - expect(await updatedNote.revisions).toStrictEqual([ - revision, - mockRevision, - ]); - expect(createRevisionSpy).toHaveBeenCalledWith(note, newContent); + let eventEmitterSpy: jest.SpyInstance; + let revisionServiceSpy: jest.SpyInstance; + beforeEach(() => { + eventEmitterSpy = jest.spyOn(eventEmitter, 'emit').mockReturnValue(true); + revisionServiceSpy = jest + .spyOn(revisionService, 'createRevision') + .mockImplementation(async () => {}); }); - - it("won't create a new revision if content is same", async () => { - const [note, , , revision] = await getMockData(); - const createRevisionSpy = jest - .spyOn(revisionsService, 'createRevision') - .mockReturnValue(Promise.resolve(undefined)); - - const newContent = 'newContent'; - const updatedNote = await service.updateNote(note, newContent); - expect(await updatedNote.revisions).toStrictEqual([revision]); - expect(createRevisionSpy).toHaveBeenCalledWith(note, newContent); + afterEach(() => { + expect(eventEmitterSpy).toHaveBeenCalledWith( + NoteEvent.CLOSE_REALTIME, + mockNoteId, + ); + }); + it('creates a new revision', async () => { + await service.updateNote(mockNoteId, mockNoteContent); + expect(revisionServiceSpy).toHaveBeenCalledWith( + mockNoteId, + mockNoteContent, + ); }); }); describe('toNotePermissionsDto', () => { - it('works', async () => { - const [note] = await getMockData(); - const permissions = await service.toNotePermissionsDto(note); - expect(permissions).toMatchSnapshot(); + it('throws NotInDBError if a note does not exist', async () => { + mockSelect( + tracker, + [`${TableUser}"."${FieldNameUser.username}`], + TableNote, + `${TableNote}"."${FieldNameNote.id}`, + [], + [ + { + joinTable: TableUser, + keyLeft: FieldNameUser.id, + keyRight: FieldNameNote.ownerId, + }, + ], + ); + await expect(service.toNotePermissionsDto(mockNoteId)).rejects.toThrow( + NotInDBError, + ); + }); + + it('returns correct NotePermissionDto', async () => { + mockSelect( + tracker, + [`${TableUser}"."${FieldNameUser.username}`], + TableNote, + `${TableNote}"."${FieldNameNote.id}`, + [ + { + [FieldNameUser.username]: mockUsername, + }, + ], + [ + { + joinTable: TableUser, + keyLeft: FieldNameUser.id, + keyRight: FieldNameNote.ownerId, + }, + ], + ); + mockSelect( + tracker, + [ + `${TableUser}"."${FieldNameUser.username}`, + `${TableNoteUserPermission}"."${FieldNameNoteUserPermission.canEdit}`, + ], + TableNoteUserPermission, + [ + `${TableUser}"."${FieldNameUser.username}`, + `${TableNoteUserPermission}"."${FieldNameNoteUserPermission.noteId}`, + ], + [ + { + [FieldNameUser.username]: mockUsername, + [FieldNameNoteUserPermission.canEdit]: true, + }, + ], + [ + { + joinTable: TableUser, + keyLeft: FieldNameUser.id, + keyRight: FieldNameNoteUserPermission.userId, + }, + ], + ); + mockSelect( + tracker, + [ + `${TableGroup}"."${FieldNameGroup.name}`, + `${TableNoteGroupPermission}"."${FieldNameNoteGroupPermission.canEdit}`, + ], + TableNoteGroupPermission, + `${TableNoteGroupPermission}"."${FieldNameNoteGroupPermission.noteId}`, + [ + { + [FieldNameGroup.name]: mockGroupName, + [FieldNameNoteGroupPermission.canEdit]: false, + }, + ], + [ + { + joinTable: TableGroup, + keyLeft: FieldNameGroup.id, + keyRight: FieldNameNoteGroupPermission.groupId, + }, + ], + ); + const result = await service.toNotePermissionsDto(mockNoteId); + expectBindings(tracker, 'select', [ + [mockNoteId, IS_FIRST], + [mockNoteId], + [mockNoteId], + ]); + expect(result).toEqual({ + owner: mockUsername, + sharedToUsers: [ + { + username: mockUsername, + canEdit: true, + }, + ], + sharedToGroups: [ + { + groupName: mockGroupName, + canEdit: false, + }, + ], + }); }); }); - describe('toNoteMetadataDto', () => { - it('works', async () => { - const [note] = await getMockData(); - note.aliases = Promise.resolve([ - Alias.create('testAlias', note, true) as Alias, - ]); + let spyAliasService: jest.SpyInstance; - const metadataDto = await service.toNoteMetadataDto(note); - expect(metadataDto).toMatchSnapshot(); + beforeEach(() => { + jest.useFakeTimers(); + spyAliasService = jest.spyOn(aliasService, 'getAllAliases'); }); - it('returns publicId if no aliases exists', async () => { - const [note, ,] = await getMockData(); - const metadataDto = await service.toNoteMetadataDto(note); - expect(metadataDto.primaryAlias).toEqual(note.publicId); + afterEach(() => { + jest.useRealTimers(); + }); + + it('throws NotInDBError if the note does not have a primary alias', async () => { + spyAliasService.mockReturnValue([]); + await expect(service.toNoteMetadataDto(mockNoteId)).rejects.toThrow( + NotInDBError, + ); + }); + it('throws NotInDBError if the note does not exist', async () => { + spyAliasService.mockReturnValue([ + { + [FieldNameAlias.alias]: mockAliasRandom, + [FieldNameAlias.isPrimary]: true, + }, + { + [FieldNameAlias.alias]: mockAliasCustom, + [FieldNameAlias.isPrimary]: false, + }, + ]); + mockSelect( + tracker, + [FieldNameNote.createdAt, FieldNameNote.version], + TableNote, + FieldNameNote.id, + [], + ); + await expect(service.toNoteMetadataDto(mockNoteId)).rejects.toThrow( + NotInDBError, + ); + expectBindings(tracker, 'select', [[mockNoteId]], true); + }); + it('returns correct NoteMetadataDto', async () => { + spyAliasService.mockReturnValue([ + { + [FieldNameAlias.alias]: mockAliasRandom, + [FieldNameAlias.isPrimary]: true, + }, + { + [FieldNameAlias.alias]: mockAliasCustom, + [FieldNameAlias.isPrimary]: false, + }, + ]); + + jest.spyOn(revisionService, 'getLatestRevision').mockResolvedValue({ + [FieldNameRevision.content]: mockNoteContent, + [FieldNameRevision.uuid]: mockRevisionUuid, + [FieldNameRevision.createdAt]: mockUpdatedAt, + [FieldNameRevision.noteId]: mockNoteId, + [FieldNameRevision.noteType]: mockNoteType, + [FieldNameRevision.patch]: mockPatch, + [FieldNameRevision.title]: mockNoteTitle, + [FieldNameRevision.description]: mockNoteDescription, + [FieldNameRevision.yjsStateVector]: null, + }); + jest + .spyOn(revisionService, 'getTagsByRevisionUuid') + .mockResolvedValue(mockTags); + jest + .spyOn(service, 'toNotePermissionsDto') + .mockResolvedValue(mockPermissions); + jest.spyOn(revisionService, 'getRevisionUserInfo').mockResolvedValue({ + users: [ + { + username: mockUsername, + createdAt: mockUpdatedAt, + }, + ], + guestUserCount: 0, + }); + + mockSelect( + tracker, + [FieldNameNote.createdAt, FieldNameNote.version], + TableNote, + FieldNameNote.id, + [ + { + [FieldNameNote.version]: 2, + [FieldNameNote.createdAt]: mockCreatedAt, + }, + ], + ); + const result = await service.toNoteMetadataDto(mockNoteId); + expectBindings(tracker, 'select', [[mockNoteId]], true); + expect(result).toEqual({ + aliases: [mockAliasRandom, mockAliasCustom], + primaryAlias: mockAliasRandom, + title: mockNoteTitle, + description: mockNoteDescription, + tags: mockTags, + createdAt: mockCreatedAt, + editedBy: [mockUsername], + permissions: mockPermissions, + version: 2, + updatedAt: mockUpdatedAt, + lastUpdatedBy: mockUsername, + }); }); }); - describe('toNoteDto', () => { - it('works', async () => { - const [note] = await getMockData(); - note.aliases = Promise.resolve([ - Alias.create('testAlias', note, true) as Alias, - ]); - - const noteDto = await service.toNoteDto(note); - expect(noteDto).toMatchSnapshot(); + it('correctly calls other methods', async () => { + const mockNoteMetadata: NoteMetadataDto = { + aliases: [mockAliasRandom, mockAliasCustom], + primaryAlias: mockAliasRandom, + title: mockNoteTitle, + description: mockNoteDescription, + tags: mockTags, + createdAt: mockCreatedAt, + editedBy: [mockUsername], + permissions: mockPermissions, + version: 2, + updatedAt: mockUpdatedAt, + lastUpdatedBy: mockUsername, + }; + jest.spyOn(service, 'getNoteContent').mockResolvedValue(mockNoteContent); + jest + .spyOn(service, 'toNoteMetadataDto') + .mockResolvedValue(mockNoteMetadata); + const result = await service.toNoteDto(mockNoteId); + expect(result).toEqual({ + content: mockNoteContent, + metadata: mockNoteMetadata, + editedByAtPosition: [], + }); }); }); }); diff --git a/backend/src/notes/note.service.ts b/backend/src/notes/note.service.ts index 32e3ec57a..ff7302123 100644 --- a/backend/src/notes/note.service.ts +++ b/backend/src/notes/note.service.ts @@ -224,8 +224,8 @@ export class NoteService { .where(FieldNameAlias.alias, alias) .join( TableNote, - `${TableAlias}.${FieldNameAlias.noteId}`, `${TableNote}.${FieldNameNote.id}`, + `${TableAlias}.${FieldNameAlias.noteId}`, ) .first(); @@ -302,14 +302,16 @@ export class NoteService { const ownerUsername = await transaction(TableNote) .join( TableUser, - `${TableNote}.${FieldNameNote.ownerId}`, `${TableUser}.${FieldNameUser.id}`, + `${TableNote}.${FieldNameNote.ownerId}`, ) .select< Pick >(`${TableUser}.${FieldNameUser.username}`) .where(`${TableNote}.${FieldNameNote.id}`, noteId) .first(); + // As FieldNameUser.username is string (for registered users) or null (for guests), + // undefined indicates a missing entry here if (ownerUsername === undefined) { throw new NotInDBError( `The note does not exist.`, @@ -320,8 +322,8 @@ export class NoteService { const userPermissions = await transaction(TableNoteUserPermission) .join( TableUser, - `${TableNoteUserPermission}.${FieldNameNoteUserPermission.userId}`, `${TableUser}.${FieldNameUser.id}`, + `${TableNoteUserPermission}.${FieldNameNoteUserPermission.userId}`, ) .select< ({ [FieldNameUser.username]: string } & Pick< @@ -337,8 +339,8 @@ export class NoteService { const groupPermissions = await transaction(TableNoteGroupPermission) .join( TableGroup, - `${TableNoteGroupPermission}.${FieldNameNoteGroupPermission.groupId}`, `${TableGroup}.${FieldNameGroup.id}`, + `${TableNoteGroupPermission}.${FieldNameNoteGroupPermission.groupId}`, ) .select< (Pick & @@ -418,8 +420,6 @@ export class NoteService { } const createdAtString = note[FieldNameNote.createdAt]; const version = note[FieldNameNote.version]; - this.logger.debug(`createdAt: ${createdAtString}`); - this.logger.debug(`createversion: ${version}`); const createdAt = new Date(createdAtString).toISOString(); const latestRevision = await this.revisionsService.getLatestRevision( diff --git a/backend/src/permissions/permission.service.ts b/backend/src/permissions/permission.service.ts index 344dadc58..294fb247b 100644 --- a/backend/src/permissions/permission.service.ts +++ b/backend/src/permissions/permission.service.ts @@ -68,9 +68,9 @@ export class PermissionService { const dbResult = await this.knex(TableMediaUpload) .join( TableNote, - `${TableMediaUpload}.${FieldNameMediaUpload.noteId}`, - '=', `${TableNote}.${FieldNameNote.id}`, + '=', + `${TableMediaUpload}.${FieldNameMediaUpload.noteId}`, ) .select<{ [FieldNameMediaUpload.userId]: number; @@ -109,13 +109,10 @@ export class PermissionService { * @throws NotInDBError if the note does not exist */ async isOwner( - userId: number | null, + userId: number, noteId: number, transaction?: Knex, ): Promise { - if (userId === null) { - return false; - } const dbActor = transaction ? transaction : this.knex; const dbResult = await dbActor(TableNote) .select(FieldNameNote.ownerId) @@ -173,21 +170,60 @@ export class PermissionService { // If the user is the owner of the note, they have full permissions return PermissionLevel.FULL; } - const userPermission = await this.determineNotePermissionLevelForUser( - userId, - noteId, - transaction, - ); + + // Determine UserPermission + let userPermission: PermissionLevel; + const userPermissionDbResult = await transaction(TableNoteUserPermission) + .select(FieldNameNoteUserPermission.canEdit) + .where(FieldNameNoteUserPermission.noteId, noteId) + .andWhere(FieldNameNoteUserPermission.userId, userId) + .first(); + if (userPermissionDbResult === undefined) { + userPermission = PermissionLevel.DENY; + } else { + userPermission = convertEditabilityToPermissionLevel( + userPermissionDbResult[FieldNameNoteUserPermission.canEdit], + ); + } + + // If the user is not the owner but has write permissions, this is already the highest permission level if (userPermission === PermissionLevel.WRITE) { - // If the user is not the owner but has write permissions, this is already the highest permission level return userPermission; } - const groupPermission = - await this.determineHighestNotePermissionLevelOfGroups( - userId, - noteId, - transaction, + + // Determine GroupPermission + let groupPermission: PermissionLevel; + + // 1. Get all groups the user is member of + const groupsOfUser = await transaction(TableGroupUser) + .select(FieldNameGroupUser.groupId) + .where(FieldNameGroupUser.userId, userId); + if (groupsOfUser === undefined) { + // If the user is not a member of any group, they cannot have permissions + groupPermission = PermissionLevel.DENY; + } else { + const groupIds = groupsOfUser.map( + (groupOfUser) => groupOfUser[FieldNameGroupUser.groupId], ); + + // 2. Get all permissions on the note for groups the user is member of + const groupPermissions = await transaction(TableNoteGroupPermission) + .select(FieldNameNoteGroupPermission.canEdit) + .whereIn(FieldNameNoteGroupPermission.groupId, groupIds) + .andWhere(FieldNameNoteGroupPermission.noteId, noteId); + if (groupPermissions === undefined) { + // If there are no permissions for the groups, the user cannot have permissions + groupPermission = PermissionLevel.DENY; + } else { + const permissionLevels = groupPermissions.map((permission) => + convertEditabilityToPermissionLevel( + permission[FieldNameNoteGroupPermission.canEdit], + ), + ); + groupPermission = Math.max(...permissionLevels); + } + } + const isRegisteredUser = await this.userService.isRegisteredUser( userId, transaction, @@ -204,78 +240,6 @@ export class PermissionService { }); } - /** - * Determines the access level for a given user to a given note - * - * @param userId The id of the user who wants access - * @param noteId The id of the note for which access is checked - * @param transaction The optional database transaction to use - * @returns The permission level of the user on the note - */ - private async determineNotePermissionLevelForUser( - userId: number, - noteId: number, - transaction?: Knex, - ): Promise { - const dbActor = transaction ? transaction : this.knex; - const userPermissions = await dbActor(TableNoteUserPermission) - .select(FieldNameNoteUserPermission.canEdit) - .where(FieldNameNoteUserPermission.noteId, noteId) - .andWhere(FieldNameNoteUserPermission.userId, userId) - .first(); - if (userPermissions === undefined) { - return PermissionLevel.DENY; - } - return convertEditabilityToPermissionLevel( - userPermissions[FieldNameNoteUserPermission.canEdit], - ); - } - - /** - * Determines the access level for the groups of a given user to a given note - * - * @param userId The id of the user who wants access - * @param noteId The id of the note for which access is checked - * @param transaction The optional database transaction to use - * @returns The highest permission level of the groups of the user on the note - */ - private async determineHighestNotePermissionLevelOfGroups( - userId: number, - noteId: number, - transaction?: Knex, - ): Promise { - const dbActor = transaction ? transaction : this.knex; - - // 1. Get all groups the user is member of - const groupsOfUser = await dbActor(TableGroupUser) - .select(FieldNameGroupUser.groupId) - .where(FieldNameGroupUser.userId, userId); - if (groupsOfUser === undefined) { - // If the user is not a member of any group, they cannot have permissions - return PermissionLevel.DENY; - } - const groupIds = groupsOfUser.map( - (groupOfUser) => groupOfUser[FieldNameGroupUser.groupId], - ); - - // 2. Get all permissions on the note for groups the user is member of - const groupPermissions = await dbActor(TableNoteGroupPermission) - .select(FieldNameNoteGroupPermission.canEdit) - .whereIn(FieldNameNoteGroupPermission.groupId, groupIds) - .andWhere(FieldNameNoteGroupPermission.noteId, noteId); - if (groupPermissions === undefined) { - // If there are no permissions for the groups, the user cannot have permissions - return PermissionLevel.DENY; - } - - const permissionLevels = groupPermissions.map((permission) => - convertEditabilityToPermissionLevel( - permission[FieldNameNoteGroupPermission.canEdit], - ), - ); - return Math.max(...permissionLevels); - } - /** * Broadcasts a permission change event for the given note id * @@ -292,7 +256,7 @@ export class PermissionService { * @param userId the user for which the permission should be set * @param canEdit specifies if the user can edit the note */ - async setUserPermission( + public async setUserPermission( noteId: number, userId: number, canEdit: boolean, @@ -336,7 +300,10 @@ export class PermissionService { * @param userId the userId for which the permission should be removed * @throws NotInDBError if the user did not have the permission already */ - async removeUserPermission(noteId: number, userId: number): Promise { + public async removeUserPermission( + noteId: number, + userId: number, + ): Promise { const result = await this.knex(TableNoteUserPermission) .where(FieldNameNoteUserPermission.noteId, noteId) .andWhere(FieldNameNoteUserPermission.userId, userId) @@ -359,7 +326,7 @@ export class PermissionService { * @param canEdit specifies if the group can edit the note * @param transaction The optional transaction for the database */ - async setGroupPermission( + public async setGroupPermission( noteId: number, groupId: number, canEdit: boolean, @@ -368,9 +335,9 @@ export class PermissionService { const dbActor = transaction ?? this.knex; await dbActor(TableNoteGroupPermission) .insert({ + [FieldNameNoteGroupPermission.canEdit]: canEdit, [FieldNameNoteGroupPermission.groupId]: groupId, [FieldNameNoteGroupPermission.noteId]: noteId, - [FieldNameNoteGroupPermission.canEdit]: canEdit, }) .onConflict([ FieldNameNoteGroupPermission.noteId, @@ -387,7 +354,10 @@ export class PermissionService { * @param groupId the group for which the permission should be removed * @returns the note with the new permission */ - async removeGroupPermission(noteId: number, groupId: number): Promise { + public async removeGroupPermission( + noteId: number, + groupId: number, + ): Promise { const result = await this.knex(TableNoteGroupPermission) .where(FieldNameNoteGroupPermission.noteId, noteId) .andWhere(FieldNameNoteGroupPermission.groupId, groupId) @@ -409,7 +379,7 @@ export class PermissionService { * @param newOwnerId the id of the new owner * @throws NotInDBError if the new owner or the note does not exist */ - async changeOwner(noteId: number, newOwnerId: number): Promise { + public async changeOwner(noteId: number, newOwnerId: number): Promise { const result = await this.knex(TableNote) .update({ [FieldNameNote.ownerId]: newOwnerId, @@ -430,7 +400,9 @@ export class PermissionService { * @returns a NotePermissionsDto containing the permissions for the note * @throws GenericDBError if the database state is invalid */ - async getPermissionsDtoForNote(noteId: number): Promise { + public async getPermissionsDtoForNote( + noteId: number, + ): Promise { return await this.knex.transaction(async (transaction) => { const owner = await transaction(TableNote) .join( @@ -441,7 +413,7 @@ export class PermissionService { .select<{ [FieldNameUser.username]: string; }>(`${TableUser}.${FieldNameUser.username}`) - .where(FieldNameNote.id, noteId) + .where(`${TableNote}.${FieldNameNote.id}`, noteId) .first(); const userPermissions = await transaction(TableNoteUserPermission) @@ -459,7 +431,10 @@ export class PermissionService { `${TableUser}.${FieldNameUser.username}`, `${TableNoteUserPermission}.${FieldNameNoteUserPermission.canEdit}`, ) - .where(FieldNameNoteUserPermission.noteId, noteId); + .where( + `${TableNoteUserPermission}.${FieldNameNoteUserPermission.noteId}`, + noteId, + ); const groupPermissions = await transaction(TableNoteGroupPermission) .join( @@ -476,13 +451,12 @@ export class PermissionService { `${TableGroup}.${FieldNameGroup.name}`, `${TableNoteGroupPermission}.${FieldNameNoteGroupPermission.canEdit}`, ) - .where(FieldNameNoteGroupPermission.noteId, noteId); + .where( + `${TableNoteGroupPermission}.${FieldNameNoteGroupPermission.noteId}`, + noteId, + ); - if ( - owner === undefined || - userPermissions === undefined || - groupPermissions === undefined - ) { + if (owner === undefined) { throw new GenericDBError( 'Invalid database state. This should not happen.', this.logger.getContext(), diff --git a/backend/src/permissions/permissions.guard.spec.ts b/backend/src/permissions/permissions.guard.spec.ts index 5762daca2..0e7256213 100644 --- a/backend/src/permissions/permissions.guard.spec.ts +++ b/backend/src/permissions/permissions.guard.spec.ts @@ -3,38 +3,48 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ +import { PermissionLevel, PermissionLevelNames } from '@hedgedoc/commons'; import { ExecutionContext } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { Mock } from 'ts-mockery'; import * as ExtractNoteIdOrAliasModule from '../api/utils/extract-note-id-from-request'; import { CompleteRequest } from '../api/utils/request.type'; -import { User } from '../database/user.entity'; import { ConsoleLoggerService } from '../logger/console-logger.service'; -import { Note } from '../notes/note.entity'; -import { - getNotePermissionLevelDisplayName, - NotePermissionLevel, -} from './note-permission.enum'; import { PermissionService } from './permission.service'; import { PermissionsGuard } from './permissions.guard'; import { PERMISSION_METADATA_KEY } from './require-permission.decorator'; -import { RequiredPermission } from './required-permission.enum'; jest.mock('../api/utils/extract-note-id-from-request'); -describe('permissions guard', () => { +// eslint-disable-next-line func-style +const buildContext = ( + userId: number | undefined, + handler: () => void, +): ExecutionContext => { + const request = Mock.of({ + userId: userId, + }); + + return Mock.of({ + getHandler: () => handler, + switchToHttp: () => + Mock.of({ + getRequest: () => request, + }), + }); +}; + +describe('PermissionsGuard', () => { let loggerService: ConsoleLoggerService; let reflector: Reflector; let handler: () => void; let permissionsService: PermissionService; - let requiredPermission: RequiredPermission | undefined; - let createAllowed = false; - let requestUser: User | undefined; - let context: ExecutionContext; let permissionGuard: PermissionsGuard; - let determinedPermission: NotePermissionLevel; - let mockedNote: Note; + let spyOnExtractNoteId: jest.SpyInstance; + + const mockUserId = 42; + const mockNoteId = 23; beforeEach(() => { loggerService = Mock.of({ @@ -43,33 +53,20 @@ describe('permissions guard', () => { }); reflector = Mock.of({ - get: jest.fn(() => requiredPermission), + get: jest.fn(), }); handler = jest.fn(); permissionsService = Mock.of({ - mayCreate: jest.fn(() => createAllowed), - determinePermission: jest.fn(() => Promise.resolve(determinedPermission)), + checkIfUserMayCreateNote: jest.fn(), + determinePermission: jest.fn(), }); - requestUser = Mock.of({}); - - const request = Mock.of({ - user: requestUser, - }); - - context = Mock.of({ - getHandler: () => handler, - switchToHttp: () => - Mock.of({ - getRequest: () => request, - }), - }); - mockedNote = Mock.of({}); - jest - .spyOn(ExtractNoteIdOrAliasModule, 'extractNoteIdFromRequest') - .mockReturnValue(Promise.resolve(mockedNote)); + spyOnExtractNoteId = jest.spyOn( + ExtractNoteIdOrAliasModule, + 'extractNoteIdFromRequest', + ); permissionGuard = new PermissionsGuard( loggerService, @@ -77,8 +74,6 @@ describe('permissions guard', () => { permissionsService, Mock.of({}), ); - - createAllowed = false; }); it('sets the correct logger context', () => { @@ -87,11 +82,10 @@ describe('permissions guard', () => { ); }); - it('deny fail with no required permission', async () => { - requiredPermission = undefined; - requestUser = undefined; - - expect(await permissionGuard.canActivate(context)).toBe(false); + it('fails with no required permission', async () => { + jest.spyOn(reflector, 'get').mockReturnValue(undefined); + const context = buildContext(undefined, handler); + await expect(permissionGuard.canActivate(context)).rejects.toThrow(Error); expect(loggerService.error).toHaveBeenCalledTimes(1); expect(reflector.get).toHaveBeenNthCalledWith( 1, @@ -100,15 +94,32 @@ describe('permissions guard', () => { ); }); - describe('with create permission required', () => { - it('will allow if user is allowed to create a note', async () => { - createAllowed = true; - requiredPermission = RequiredPermission.CREATE; - + describe('with FULL permission required', () => { + beforeEach(() => { + jest.spyOn(reflector, 'get').mockReturnValue(PermissionLevel.FULL); + spyOnExtractNoteId.mockResolvedValue(undefined); + }); + it('will allow if the user is allowed to create a note', async () => { + const context = buildContext(mockUserId, handler); + jest + .spyOn(permissionsService, 'checkIfUserMayCreateNote') + .mockResolvedValue(true); expect(await permissionGuard.canActivate(context)).toBe(true); - expect(permissionsService.mayCreate).toHaveBeenNthCalledWith( + expect( + permissionsService.checkIfUserMayCreateNote, + ).toHaveBeenNthCalledWith(1, mockUserId); + expect(reflector.get).toHaveBeenNthCalledWith( 1, - requestUser, + PERMISSION_METADATA_KEY, + handler, + ); + }); + + it('will deny if the user does not exist', async () => { + const context = buildContext(undefined, handler); + expect(await permissionGuard.canActivate(context)).toBe(false); + expect(permissionsService.checkIfUserMayCreateNote).toHaveBeenCalledTimes( + 0, ); expect(reflector.get).toHaveBeenNthCalledWith( 1, @@ -118,13 +129,14 @@ describe('permissions guard', () => { }); it("will deny if user isn't allowed to create a note", async () => { - requiredPermission = RequiredPermission.CREATE; - + const context = buildContext(mockUserId, handler); + jest + .spyOn(permissionsService, 'checkIfUserMayCreateNote') + .mockResolvedValue(false); expect(await permissionGuard.canActivate(context)).toBe(false); - expect(permissionsService.mayCreate).toHaveBeenNthCalledWith( - 1, - requestUser, - ); + expect( + permissionsService.checkIfUserMayCreateNote, + ).toHaveBeenNthCalledWith(1, mockUserId); expect(reflector.get).toHaveBeenNthCalledWith( 1, PERMISSION_METADATA_KEY, @@ -133,15 +145,17 @@ describe('permissions guard', () => { }); }); - it('will deny if no note aliases is present', async () => { + it('will deny if no note alias is present and required permission is not FULL', async () => { + jest.spyOn(reflector, 'get').mockReturnValue(PermissionLevel.WRITE); + spyOnExtractNoteId.mockResolvedValue(undefined); jest .spyOn(ExtractNoteIdOrAliasModule, 'extractNoteIdFromRequest') - .mockReturnValue(Promise.resolve(undefined)); - - requiredPermission = RequiredPermission.READ; - + .mockResolvedValue(undefined); + const context = buildContext(mockUserId, handler); expect(await permissionGuard.canActivate(context)).toBe(false); - expect(permissionsService.mayCreate).toHaveBeenCalledTimes(0); + expect(permissionsService.checkIfUserMayCreateNote).toHaveBeenCalledTimes( + 0, + ); expect(reflector.get).toHaveBeenNthCalledWith( 1, PERMISSION_METADATA_KEY, @@ -151,21 +165,9 @@ describe('permissions guard', () => { }); describe.each([ - [ - RequiredPermission.READ, - NotePermissionLevel.READ, - NotePermissionLevel.DENY, - ], - [ - RequiredPermission.WRITE, - NotePermissionLevel.WRITE, - NotePermissionLevel.READ, - ], - [ - RequiredPermission.OWNER, - NotePermissionLevel.OWNER, - NotePermissionLevel.WRITE, - ], + [PermissionLevel.READ, PermissionLevel.READ, PermissionLevel.DENY], + [PermissionLevel.WRITE, PermissionLevel.WRITE, PermissionLevel.READ], + [PermissionLevel.FULL, PermissionLevel.FULL, PermissionLevel.WRITE], ])( 'with required permission %s', ( @@ -174,18 +176,25 @@ describe('permissions guard', () => { notEnoughNotePermission, ) => { const sufficientNotePermissionDisplayName = - getNotePermissionLevelDisplayName(sufficientNotePermission); + PermissionLevelNames[sufficientNotePermission]; const notEnoughNotePermissionDisplayName = - getNotePermissionLevelDisplayName(notEnoughNotePermission); + PermissionLevelNames[notEnoughNotePermission]; + let context: ExecutionContext; beforeEach(() => { - requiredPermission = shouldRequiredPermission; + jest.spyOn(reflector, 'get').mockReturnValue(shouldRequiredPermission); + spyOnExtractNoteId.mockResolvedValue(mockNoteId); + context = buildContext(mockUserId, handler); }); it(`will allow for note permission ${sufficientNotePermissionDisplayName}`, async () => { - determinedPermission = sufficientNotePermission; + jest + .spyOn(permissionsService, 'determinePermission') + .mockResolvedValue(sufficientNotePermission); expect(await permissionGuard.canActivate(context)).toBe(true); - expect(permissionsService.mayCreate).toHaveBeenCalledTimes(0); + expect( + permissionsService.checkIfUserMayCreateNote, + ).toHaveBeenCalledTimes(0); expect(reflector.get).toHaveBeenNthCalledWith( 1, PERMISSION_METADATA_KEY, @@ -193,15 +202,19 @@ describe('permissions guard', () => { ); expect(permissionsService.determinePermission).toHaveBeenNthCalledWith( 1, - requestUser, - mockedNote, + mockUserId, + mockNoteId, ); }); it(`will deny for note permission ${notEnoughNotePermissionDisplayName}`, async () => { - determinedPermission = notEnoughNotePermission; + jest + .spyOn(permissionsService, 'determinePermission') + .mockResolvedValue(notEnoughNotePermission); expect(await permissionGuard.canActivate(context)).toBe(false); - expect(permissionsService.mayCreate).toHaveBeenCalledTimes(0); + expect( + permissionsService.checkIfUserMayCreateNote, + ).toHaveBeenCalledTimes(0); expect(reflector.get).toHaveBeenNthCalledWith( 1, PERMISSION_METADATA_KEY, @@ -209,8 +222,8 @@ describe('permissions guard', () => { ); expect(permissionsService.determinePermission).toHaveBeenNthCalledWith( 1, - requestUser, - mockedNote, + mockUserId, + mockNoteId, ); }); }, diff --git a/backend/src/permissions/permissions.service.spec.ts b/backend/src/permissions/permissions.service.spec.ts index c1055c2f6..145fa2c12 100644 --- a/backend/src/permissions/permissions.service.spec.ts +++ b/backend/src/permissions/permissions.service.spec.ts @@ -3,23 +3,30 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ +import { PermissionLevel } from '@hedgedoc/commons'; import { - NoteGroupPermissionUpdateDto, - NoteUserPermissionUpdateDto, - PermissionLevel, -} from '@hedgedoc/commons'; + FieldNameGroup, + FieldNameGroupUser, + FieldNameMediaUpload, + FieldNameNote, + FieldNameNoteGroupPermission, + FieldNameNoteUserPermission, + FieldNameUser, + TableGroup, + TableGroupUser, + TableMediaUpload, + TableNote, + TableNoteGroupPermission, + TableNoteUserPermission, + TableUser, +} from '@hedgedoc/database'; +import { Provider } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { EventEmitter2, EventEmitterModule } from '@nestjs/event-emitter'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { Mock } from 'ts-mockery'; -import { DataSource, EntityManager, Repository } from 'typeorm'; +import type { Tracker } from 'knex-mock-client'; -import { AliasModule } from '../alias/alias.module'; -import { ApiToken } from '../api-token/api-token.entity'; -import { Identity } from '../auth/identity.entity'; -import { Author } from '../authors/author.entity'; -import { DefaultAccessLevel } from '../config/default-access-level.enum'; +import { AliasService } from '../alias/alias.service'; import appConfigMock from '../config/mock/app.config.mock'; import authConfigMock from '../config/mock/auth.config.mock'; import databaseConfigMock from '../config/mock/database.config.mock'; @@ -28,126 +35,62 @@ import { registerNoteConfig, } from '../config/mock/note.config.mock'; import { NoteConfig } from '../config/note.config'; -import { User } from '../database/user.entity'; -import { PermissionsUpdateInconsistentError } from '../errors/errors'; -import { eventModuleConfig, NoteEvent } from '../events'; -import { Group } from '../groups/group.entity'; -import { GroupsModule } from '../groups/groups.module'; +import { expectBindings, IS_FIRST } from '../database/mock/expect-bindings'; +import { + mockDelete, + mockInsert, + mockSelect, + mockUpdate, +} from '../database/mock/mock-queries'; +import { mockKnexDb } from '../database/mock/provider'; +import { + GenericDBError, + NotInDBError, + PermissionError, +} from '../errors/errors'; +import { NoteEventMap } from '../events'; import { GroupsService } from '../groups/groups.service'; import { LoggerModule } from '../logger/logger.module'; -import { MediaUpload } from '../media/media-upload.entity'; -import { Alias } from '../notes/aliases.entity'; -import { Note } from '../notes/note.entity'; -import { Tag } from '../notes/tag.entity'; -import { Edit } from '../revisions/edit.entity'; -import { Revision } from '../revisions/revision.entity'; -import { Session } from '../sessions/session.entity'; -import { UsersModule } from '../users/users.module'; -import { NoteGroupPermission } from './note-group-permission.entity'; -import { - getNotePermissionLevelDisplayName, - NotePermissionLevel, -} from './note-permission.enum'; -import { NoteUserPermission } from './note-user-permission.entity'; +import { NoteService } from '../notes/note.service'; +import { RealtimeNoteStore } from '../realtime/realtime-note/realtime-note-store'; +import { RevisionsService } from '../revisions/revisions.service'; +import { UsersService } from '../users/users.service'; import { PermissionService } from './permission.service'; -import { PermissionsModule } from './permissions.module'; -import { convertPermissionLevelToNotePermissionLevel } from './utils/convert-guest-access-to-note-permission-level'; -import * as FindHighestNotePermissionByGroupModule from './utils/find-highest-note-permission-by-group'; -import * as FindHighestNotePermissionByUserModule from './utils/find-highest-note-permission-by-user'; - -jest.mock( - './utils/find-highest-note-permission-by-user', - () => - jest.requireActual( - './utils/find-highest-note-permission-by-user', - ) as unknown, -); - -jest.mock( - './utils/find-highest-note-permission-by-group', - () => - jest.requireActual( - './utils/find-highest-note-permission-by-group', - ) as unknown, -); - -function mockedEventEmitter(eventEmitter: EventEmitter2) { - return jest.spyOn(eventEmitter, 'emit').mockImplementationOnce((event) => { - expect(event).toEqual(NoteEvent.PERMISSION_CHANGE); - return true; - }); -} - -function mockNoteRepo(noteRepo: Repository) { - jest - .spyOn(noteRepo, 'save') - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - .mockImplementationOnce(async (entry: Note) => { - return entry; - }); -} describe('PermissionsService', () => { let service: PermissionService; - let groupService: GroupsService; - let noteRepo: Repository; - let userRepo: Repository; - let groupRepo: Repository; - let eventEmitter: EventEmitter2; - let eventEmitterEmitSpy: jest.SpyInstance; + let userService: UsersService; const noteMockConfig: NoteConfig = createDefaultMockNoteConfig(); + let tracker: Tracker; + let knexProvider: Provider; + + const mockUserId1 = 1; + const mockUserName1 = 'TestUser1'; + const mockUserId2 = 2; + const mockUserName2 = 'TestUser2'; + const mockGroupId1 = 23; + const mockGroupName1 = 'TestGroup1'; + const mockMediaUploadUuid = '15207877-0780-4567-9f39-1082e6391afb'; + const mockNoteId = 42; beforeAll(async () => { - /** - * We need to have *one* userRepo and *one* noteRepo for both the providers - * array and the overrideProvider call, as otherwise we have two instances - * and the mock of createQueryBuilder replaces the wrong one - * **/ - - userRepo = new Repository( - '', - new EntityManager( - new DataSource({ - type: 'sqlite', - database: ':memory:', - }), - ), - undefined, - ); - noteRepo = new Repository( - '', - new EntityManager( - new DataSource({ - type: 'sqlite', - database: ':memory:', - }), - ), - undefined, - ); + [tracker, knexProvider] = mockKnexDb(); const module: TestingModule = await Test.createTestingModule({ providers: [ PermissionService, - { - provide: getRepositoryToken(Note), - useValue: noteRepo, - }, - { - provide: getRepositoryToken(Group), - useClass: Repository, - }, - { - provide: getRepositoryToken(User), - useValue: userRepo, - }, + knexProvider, + UsersService, + EventEmitter2, + GroupsService, + NoteService, + RevisionsService, + AliasService, + RealtimeNoteStore, ], imports: [ LoggerModule, - PermissionsModule, - UsersModule, - AliasModule, - ConfigModule.forRoot({ + await ConfigModule.forRoot({ isGlobal: true, load: [ appConfigMock, @@ -156,845 +99,633 @@ describe('PermissionsService', () => { registerNoteConfig(noteMockConfig), ], }), - GroupsModule, - EventEmitterModule.forRoot(eventModuleConfig), ], - }) - .overrideProvider(getRepositoryToken(User)) - .useValue(userRepo) - .overrideProvider(getRepositoryToken(ApiToken)) - .useValue({}) - .overrideProvider(getRepositoryToken(Identity)) - .useValue({}) - .overrideProvider(getRepositoryToken(Edit)) - .useValue({}) - .overrideProvider(getRepositoryToken(Revision)) - .useValue({}) - .overrideProvider(getRepositoryToken(Note)) - .useValue(noteRepo) - .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(); + }).compile(); + service = module.get(PermissionService); - groupService = module.get(GroupsService); - groupRepo = module.get>(getRepositoryToken(Group)); - noteRepo = module.get>(getRepositoryToken(Note)); - eventEmitter = module.get(EventEmitter2); + userService = module.get(UsersService); }); - beforeEach(() => { - mockNoteRepo(noteRepo); - eventEmitterEmitSpy = mockedEventEmitter(eventEmitter); - }); + beforeEach(() => {}); afterEach(() => { + tracker.reset(); jest.resetModules(); jest.restoreAllMocks(); }); - // The two users we test with: - const user1 = Mock.of({ id: 1 }); - const user2 = Mock.of({ id: 2 }); - - function mockNote( - owner: User, - userPermissions: NoteUserPermission[] = [], - groupPermissions: NoteGroupPermission[] = [], - ): Note { - return Mock.of({ - owner: Promise.resolve(owner), - userPermissions: Promise.resolve(userPermissions), - groupPermissions: Promise.resolve(groupPermissions), + describe('checkMediaDeletePermission', () => { + afterEach(() => { + expectBindings(tracker, 'select', [[mockMediaUploadUuid]], true); }); - } - it('should be defined', () => { - expect(service).toBeDefined(); - }); + // eslint-disable-next-line func-style + const buildMockSelect = (returnValues: unknown) => { + mockSelect( + tracker, + [ + `${TableMediaUpload}"."${FieldNameMediaUpload.userId}`, + `${TableNote}"."${FieldNameNote.ownerId}`, + ], + TableMediaUpload, + FieldNameMediaUpload.uuid, + returnValues, + [ + { + joinTable: TableNote, + keyLeft: FieldNameNote.id, + keyRight: FieldNameMediaUpload.noteId, + }, + ], + ); + }; - describe('mayCreate', () => { - it('allows creation for logged in', () => { - expect(service.mayCreate(user1)).toBeTruthy(); + it('throws NotInDBError if dbResult is undefined', async () => { + buildMockSelect([]); + await expect( + service.checkMediaDeletePermission(mockUserId1, mockMediaUploadUuid), + ).rejects.toThrow(NotInDBError); + expectBindings(tracker, 'select', [[mockMediaUploadUuid]], true); }); - it('allows creation of notes for guests with permission', () => { - noteMockConfig.guestAccess = PermissionLevel.CREATE; - noteMockConfig.permissions.default.loggedIn = DefaultAccessLevel.WRITE; - noteMockConfig.permissions.default.everyone = DefaultAccessLevel.WRITE; - expect(service.mayCreate(null)).toBeTruthy(); + + describe('return true', () => { + it('for media owner', async () => { + buildMockSelect([ + { + [FieldNameMediaUpload.userId]: mockUserId1, + [FieldNameNote.ownerId]: mockUserId2, + }, + ]); + expect( + await service.checkMediaDeletePermission( + mockUserId1, + mockMediaUploadUuid, + ), + ).toBeTruthy(); + }); + it('for note owner', async () => { + buildMockSelect([ + { + [FieldNameMediaUpload.userId]: mockUserId2, + [FieldNameNote.ownerId]: mockUserId1, + }, + ]); + expect( + await service.checkMediaDeletePermission( + mockUserId1, + mockMediaUploadUuid, + ), + ).toBeTruthy(); + }); }); - it('denies creation of notes for guests without permission', () => { - noteMockConfig.guestAccess = PermissionLevel.WRITE; - noteMockConfig.permissions.default.loggedIn = DefaultAccessLevel.WRITE; - noteMockConfig.permissions.default.everyone = DefaultAccessLevel.WRITE; - expect(service.mayCreate(null)).toBeFalsy(); + + it('returns false for a non-owner', async () => { + buildMockSelect([ + { + [FieldNameMediaUpload.userId]: mockUserId2, + [FieldNameNote.ownerId]: mockUserId2, + }, + ]); + expect( + await service.checkMediaDeletePermission( + mockUserId1, + mockMediaUploadUuid, + ), + ).toBeFalsy(); }); }); describe('isOwner', () => { - it('works correctly if user is owner', async () => { - const note = mockNote(user1); - expect(await service.isOwner(user1, note)).toBeTruthy(); + // eslint-disable-next-line func-style + const buildMockSelect = (returnValues: unknown) => { + mockSelect( + tracker, + [FieldNameNote.ownerId], + TableNote, + FieldNameNote.id, + returnValues, + ); + }; + it('throws NotInDBError if there is note in the db', async () => { + buildMockSelect([]); + await expect(service.isOwner(mockUserId1, mockNoteId)).rejects.toThrow( + NotInDBError, + ); + expectBindings(tracker, 'select', [[mockNoteId]], true); }); - it("works correctly if user isn't the owner", async () => { - const note = mockNote(user2); - expect(await service.isOwner(user1, note)).toBeFalsy(); - }); - it('works correctly if no user is provided', async () => { - const note = mockNote(user2); - expect(await service.isOwner(null, note)).toBeFalsy(); + describe('if a database entry is found', () => { + it('return true if user is owner', async () => { + buildMockSelect([ + { + [FieldNameNote.ownerId]: mockUserId1, + }, + ]); + expect(await service.isOwner(mockUserId1, mockNoteId)).toBeTruthy(); + expectBindings(tracker, 'select', [[mockNoteId]], true); + }); + it("returns false if user isn't the owner", async () => { + buildMockSelect([ + { + [FieldNameNote.ownerId]: mockUserId1, + }, + ]); + expect(await service.isOwner(mockUserId2, mockNoteId)).toBeFalsy(); + expectBindings(tracker, 'select', [[mockNoteId]], true); + }); }); }); - describe('checkMediaDeletePermission', () => { - const noteUserPermission1 = Mock.of({ - user: Promise.resolve(user1), - canEdit: false, + describe('checkIfUserMayCreateNote', () => { + let spyUserServiceIsRegisteredUser: jest.SpyInstance; + beforeEach(() => { + spyUserServiceIsRegisteredUser = jest.spyOn( + userService, + 'isRegisteredUser', + ); }); - - const noteOfUser2 = mockNote(user2, [noteUserPermission1]); - - describe('accepts', () => { - it('for media owner', async () => { - const mediaUpload = {} as MediaUpload; - mediaUpload.note = Promise.resolve(noteOfUser2); - mediaUpload.user = Promise.resolve(user1); - expect( - service.checkMediaDeletePermission(user1, mediaUpload), - ).toBeTruthy(); - }); - it('for note owner', async () => { - const mediaUpload = {} as MediaUpload; - mediaUpload.note = Promise.resolve(noteOfUser2); - mediaUpload.user = Promise.resolve(user1); - expect( - service.checkMediaDeletePermission(user2, mediaUpload), - ).toBeTruthy(); - }); + it('allows creation for logged in users', async () => { + spyUserServiceIsRegisteredUser.mockResolvedValue(true); + expect(await service.checkIfUserMayCreateNote(mockUserId1)).toBeTruthy(); }); - describe('denies', () => { - it('for not owner', async () => { - const user3 = Mock.of({ id: 3 }); - const mediaUpload = Mock.of(); - mediaUpload.note = Promise.resolve(noteOfUser2); - mediaUpload.user = Promise.resolve(user1); - expect( - await service.checkMediaDeletePermission(user3, mediaUpload), - ).toBeFalsy(); - }); + it('allows creation of notes for guests with permission', async () => { + spyUserServiceIsRegisteredUser.mockResolvedValue(false); + noteMockConfig.permissions.maxGuestLevel = PermissionLevel.FULL; + expect(await service.checkIfUserMayCreateNote(mockUserId2)).toBeTruthy(); + }); + it('denies creation of notes for guests without permission', async () => { + spyUserServiceIsRegisteredUser.mockResolvedValue(false); + noteMockConfig.permissions.maxGuestLevel = PermissionLevel.WRITE; + expect(await service.checkIfUserMayCreateNote(mockUserId2)).toBeFalsy(); }); }); describe('determinePermission', () => { - const everyoneGroup = Mock.of({ id: 99 }); - const loggedInGroup = Mock.of({ id: 98 }); + let spyOnPermissionsServiceIsOwner: jest.SpyInstance; + let spyOnUserServerIsRegisteredUser: jest.SpyInstance; beforeEach(() => { - jest - .spyOn(groupService, 'getEveryoneGroup') - .mockImplementation(() => Promise.resolve(everyoneGroup)); - jest - .spyOn(groupService, 'getLoggedInGroup') - .mockImplementation(() => Promise.resolve(loggedInGroup)); + spyOnPermissionsServiceIsOwner = jest.spyOn(service, 'isOwner'); + spyOnUserServerIsRegisteredUser = jest.spyOn( + userService, + 'isRegisteredUser', + ); }); - describe('with guest user', () => { - const loggedInReadPermission = Mock.of({ - canEdit: false, - group: Promise.resolve(loggedInGroup), + it('for the owner', async () => { + spyOnPermissionsServiceIsOwner.mockResolvedValue(true); + expect( + await service.determinePermission(mockUserId1, mockNoteId), + ).toEqual(PermissionLevel.FULL); + }); + + // eslint-disable-next-line func-style + const buildUserPermissionsMockSelect = (returnValues: unknown) => { + mockSelect( + tracker, + [FieldNameNoteUserPermission.canEdit], + TableNoteUserPermission, + [ + FieldNameNoteUserPermission.noteId, + FieldNameNoteUserPermission.userId, + ], + returnValues, + ); + }; + + // eslint-disable-next-line func-style + const buildGroupPermissionsMockSelect = ( + groupIds: number[], + returnValues: unknown, + ) => { + const groupIdRows = groupIds.map((value) => ({ + [FieldNameGroupUser.groupId]: value, + })); + mockSelect( + tracker, + [FieldNameGroupUser.groupId], + TableGroupUser, + [FieldNameGroupUser.userId], + groupIdRows, + ); + mockSelect( + tracker, + [FieldNameNoteGroupPermission.canEdit], + TableNoteGroupPermission, + [ + FieldNameNoteGroupPermission.groupId, + FieldNameNoteGroupPermission.noteId, + ], + returnValues, + ); + }; + + // eslint-disable-next-line func-style + const calculateExpectedPermission = ( + userPermission: PermissionLevel | undefined, + groupPermission: PermissionLevel | undefined, + ) => { + const expectedUserPermissionLevel = + userPermission ?? PermissionLevel.DENY; + const expectedGroupPermissionLevel = + groupPermission ?? PermissionLevel.DENY; + console.debug( + `expectedUserPermissionLevel ${expectedUserPermissionLevel}`, + ); + console.debug( + `expectedGroupPermissionLevel ${expectedGroupPermissionLevel}`, + ); + if (expectedUserPermissionLevel >= expectedGroupPermissionLevel) { + return expectedUserPermissionLevel; + } else { + return expectedGroupPermissionLevel; + } + }; + + describe('for non-owner', () => { + beforeEach(() => { + spyOnPermissionsServiceIsOwner.mockResolvedValue(false); + spyOnUserServerIsRegisteredUser.mockResolvedValue(true); }); - - it(`with no everyone permission will deny`, async () => { - const note = mockNote(user1, [], [loggedInReadPermission]); - const foundPermission = await service.determinePermission(null, note); - expect(foundPermission).toBe(NotePermissionLevel.DENY); - }); - - describe.each([ - PermissionLevel.DENY, - PermissionLevel.READ, - PermissionLevel.WRITE, - PermissionLevel.CREATE, - ])('with configured guest access %s', (guestAccess) => { - beforeEach(() => { - noteMockConfig.guestAccess = guestAccess; - }); - - const guestAccessNotePermission = - convertPermissionLevelToNotePermissionLevel(guestAccess); - - describe.each([false, true])( - 'with everybody group permission with edit set to %s', - (canEdit) => { - const editPermission = canEdit - ? NotePermissionLevel.WRITE - : NotePermissionLevel.READ; - const expectedLimitedPermission = - guestAccessNotePermission >= editPermission - ? editPermission - : guestAccessNotePermission; - - const permissionDisplayName = getNotePermissionLevelDisplayName( - expectedLimitedPermission, - ); - it(`will ${permissionDisplayName}`, async () => { - const everybodyPermission = Mock.of({ - group: Promise.resolve(everyoneGroup), - canEdit: canEdit, + describe.each([undefined, PermissionLevel.READ, PermissionLevel.WRITE])( + 'as user with user permission level %s', + (userPermissionLevel: number | undefined) => { + beforeEach(() => { + if (userPermissionLevel === undefined) { + buildUserPermissionsMockSelect(userPermissionLevel); + } else { + buildUserPermissionsMockSelect([ + { + [FieldNameNoteUserPermission.canEdit]: + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + userPermissionLevel >= PermissionLevel.WRITE, + }, + ]); + } + }); + describe.each([ + undefined, + PermissionLevel.READ, + PermissionLevel.WRITE, + ])( + 'with group permission level %s', + (groupPermissionLevel: number | undefined) => { + beforeEach(() => { + if (groupPermissionLevel === undefined) { + buildGroupPermissionsMockSelect( + [mockGroupId1], + groupPermissionLevel, + ); + } else { + buildGroupPermissionsMockSelect( + [mockGroupId1], + [ + { + [FieldNameNoteGroupPermission.canEdit]: + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + groupPermissionLevel >= PermissionLevel.WRITE, + }, + ], + ); + } }); - - const note = mockNote( - user1, - [], - [everybodyPermission, loggedInReadPermission], + const expectedResult = calculateExpectedPermission( + userPermissionLevel, + groupPermissionLevel, ); + it(`will result into ${PermissionLevel[expectedResult]}`, async () => { + expect( + await service.determinePermission(mockUserId1, mockNoteId), + ).toEqual(expectedResult); + const bindings = [[mockNoteId, mockUserId1, IS_FIRST]]; + if (userPermissionLevel !== PermissionLevel.WRITE) { + // eslint-disable-next-line jest/no-conditional-expect + expect(spyOnUserServerIsRegisteredUser).toHaveBeenCalledWith( + mockUserId1, + // eslint-disable-next-line jest/no-conditional-expect + expect.anything(), + ); + bindings.push([mockUserId1], [mockGroupId1, mockNoteId]); + } + expectBindings(tracker, 'select', bindings); + }); + }, + ); + }, + ); + }); - const foundPermission = await service.determinePermission( - null, - note, + describe('for non-registered user', () => { + beforeEach(() => { + spyOnPermissionsServiceIsOwner.mockResolvedValue(false); + spyOnUserServerIsRegisteredUser.mockResolvedValue(false); + noteMockConfig.permissions.maxGuestLevel = PermissionLevel.READ; + }); + describe.each([undefined, PermissionLevel.READ, PermissionLevel.WRITE])( + 'with group permission level %s', + (groupPermissionLevel: number | undefined) => { + beforeEach(() => { + buildUserPermissionsMockSelect([undefined]); + if (groupPermissionLevel === undefined) { + buildGroupPermissionsMockSelect( + [mockGroupId1], + groupPermissionLevel, ); - expect(foundPermission).toBe(expectedLimitedPermission); - }); - }, - ); - }); - }); - - describe('with logged in user', () => { - describe('as owner will be OWNER permission', () => { - it('without other permissions', async () => { - const note = mockNote(user1); - - const foundPermission = await service.determinePermission( - user1, - note, - ); - - expect(foundPermission).toBe(NotePermissionLevel.OWNER); - }); - it('with other lower permissions', async () => { - const userPermission = Mock.of({ - user: Promise.resolve(user1), - canEdit: true, + } else { + buildGroupPermissionsMockSelect( + [mockGroupId1], + [ + { + [FieldNameNoteGroupPermission.canEdit]: + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + groupPermissionLevel >= PermissionLevel.WRITE, + }, + ], + ); + } }); - - const group1 = Mock.of({ - name: 'mockGroup', - id: 99, - members: Promise.resolve([user1]), + it('and maxGuestPermission READ', async () => { + expect( + await service.determinePermission(mockUserId1, mockNoteId), + ).toEqual(groupPermissionLevel ?? PermissionLevel.READ); }); - - const groupPermission = Mock.of({ - group: Promise.resolve(group1), - canEdit: true, - }); - - const note = mockNote(user1, [userPermission], [groupPermission]); - - const foundPermission = await service.determinePermission( - user1, - note, - ); - - expect(foundPermission).toBe(NotePermissionLevel.OWNER); - }); - }); - describe('as non owner', () => { - it('with user permission higher than group permission', async () => { - jest - .spyOn( - FindHighestNotePermissionByUserModule, - 'findHighestNotePermissionByUser', - ) - .mockReturnValue(Promise.resolve(NotePermissionLevel.DENY)); - jest - .spyOn( - FindHighestNotePermissionByGroupModule, - 'findHighestNotePermissionByGroup', - ) - .mockReturnValue(Promise.resolve(NotePermissionLevel.WRITE)); - - const note = mockNote(user2); - - const foundPermission = await service.determinePermission( - user1, - note, - ); - expect(foundPermission).toBe(NotePermissionLevel.WRITE); - }); - - it('with group permission higher than user permission', async () => { - jest - .spyOn( - FindHighestNotePermissionByUserModule, - 'findHighestNotePermissionByUser', - ) - .mockReturnValue(Promise.resolve(NotePermissionLevel.WRITE)); - jest - .spyOn( - FindHighestNotePermissionByGroupModule, - 'findHighestNotePermissionByGroup', - ) - .mockReturnValue(Promise.resolve(NotePermissionLevel.DENY)); - - const note = mockNote(user2); - - const foundPermission = await service.determinePermission( - user1, - note, - ); - expect(foundPermission).toBe(NotePermissionLevel.WRITE); - }); - }); - }); - }); - - describe('updateNotePermissions', () => { - const userPermissionUpdate: NoteUserPermissionUpdateDto = { - username: 'hardcoded', - canEdit: true, - }; - - const groupPermissionUpdate: NoteGroupPermissionUpdateDto = { - groupName: 'testGroup', - canEdit: false, - }; - const user = User.create(userPermissionUpdate.username, 'Testy') as User; - const group = Group.create( - groupPermissionUpdate.groupName, - groupPermissionUpdate.groupName, - false, - ) as Group; - const note = Note.create(user) as Note; - it('emits PERMISSION_CHANGE event', async () => { - expect(eventEmitterEmitSpy).not.toHaveBeenCalled(); - await service.replaceNotePermissions(note, { - sharedToUsers: [], - sharedToGroups: [], - }); - expect(eventEmitterEmitSpy).toHaveBeenCalled(); - }); - describe('works', () => { - it('with empty GroupPermissions and with empty UserPermissions', async () => { - const savedNote = await service.replaceNotePermissions(note, { - sharedToUsers: [], - sharedToGroups: [], - }); - expect(await savedNote.userPermissions).toHaveLength(0); - expect(await savedNote.groupPermissions).toHaveLength(0); - }); - it('with empty GroupPermissions and with new UserPermissions', async () => { - jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user); - const savedNote = await service.replaceNotePermissions(note, { - sharedToUsers: [userPermissionUpdate], - sharedToGroups: [], - }); - expect(await savedNote.userPermissions).toHaveLength(1); - expect( - (await (await savedNote.userPermissions)[0].user).username, - ).toEqual(userPermissionUpdate.username); - expect((await savedNote.userPermissions)[0].canEdit).toEqual( - userPermissionUpdate.canEdit, - ); - expect(await savedNote.groupPermissions).toHaveLength(0); - }); - it('with empty GroupPermissions and with existing UserPermissions', async () => { - const noteWithPreexistingPermissions: Note = { ...note }; - noteWithPreexistingPermissions.userPermissions = Promise.resolve([ - { - id: 1, - note: Promise.resolve(noteWithPreexistingPermissions), - user: Promise.resolve(user), - canEdit: !userPermissionUpdate.canEdit, - }, - ]); - - jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user); - const savedNote = await service.replaceNotePermissions(note, { - sharedToUsers: [userPermissionUpdate], - sharedToGroups: [], - }); - expect(await savedNote.userPermissions).toHaveLength(1); - expect( - (await (await savedNote.userPermissions)[0].user).username, - ).toEqual(userPermissionUpdate.username); - expect((await savedNote.userPermissions)[0].canEdit).toEqual( - userPermissionUpdate.canEdit, - ); - expect(await savedNote.groupPermissions).toHaveLength(0); - }); - it('with new GroupPermissions and with empty UserPermissions', async () => { - jest.spyOn(groupRepo, 'findOne').mockResolvedValueOnce(group); - const savedNote = await service.replaceNotePermissions(note, { - sharedToUsers: [], - sharedToGroups: [groupPermissionUpdate], - }); - expect(await savedNote.userPermissions).toHaveLength(0); - expect( - (await (await savedNote.groupPermissions)[0].group).name, - ).toEqual(groupPermissionUpdate.groupName); - expect((await savedNote.groupPermissions)[0].canEdit).toEqual( - groupPermissionUpdate.canEdit, - ); - }); - it('with new GroupPermissions and with new UserPermissions', async () => { - jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user); - jest.spyOn(groupRepo, 'findOne').mockResolvedValueOnce(group); - const savedNote = await service.replaceNotePermissions(note, { - sharedToUsers: [userPermissionUpdate], - sharedToGroups: [groupPermissionUpdate], - }); - expect( - (await (await savedNote.userPermissions)[0].user).username, - ).toEqual(userPermissionUpdate.username); - expect((await savedNote.userPermissions)[0].canEdit).toEqual( - userPermissionUpdate.canEdit, - ); - expect( - (await (await savedNote.groupPermissions)[0].group).name, - ).toEqual(groupPermissionUpdate.groupName); - expect((await savedNote.groupPermissions)[0].canEdit).toEqual( - groupPermissionUpdate.canEdit, - ); - }); - it('with new GroupPermissions and with existing UserPermissions', async () => { - const noteWithUserPermission: Note = { ...note }; - noteWithUserPermission.userPermissions = Promise.resolve([ - { - id: 1, - note: Promise.resolve(noteWithUserPermission), - user: Promise.resolve(user), - canEdit: !userPermissionUpdate.canEdit, - }, - ]); - - jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user); - jest.spyOn(groupRepo, 'findOne').mockResolvedValueOnce(group); - const savedNote = await service.replaceNotePermissions( - noteWithUserPermission, - { - sharedToUsers: [userPermissionUpdate], - sharedToGroups: [groupPermissionUpdate], - }, - ); - expect( - (await (await savedNote.userPermissions)[0].user).username, - ).toEqual(userPermissionUpdate.username); - expect((await savedNote.userPermissions)[0].canEdit).toEqual( - userPermissionUpdate.canEdit, - ); - expect( - (await (await savedNote.groupPermissions)[0].group).name, - ).toEqual(groupPermissionUpdate.groupName); - expect((await savedNote.groupPermissions)[0].canEdit).toEqual( - groupPermissionUpdate.canEdit, - ); - }); - it('with existing GroupPermissions and with empty UserPermissions', async () => { - const noteWithPreexistingPermissions: Note = { ...note }; - noteWithPreexistingPermissions.groupPermissions = Promise.resolve([ - { - id: 1, - note: Promise.resolve(noteWithPreexistingPermissions), - group: Promise.resolve(group), - canEdit: !groupPermissionUpdate.canEdit, - }, - ]); - jest.spyOn(groupRepo, 'findOne').mockResolvedValueOnce(group); - const savedNote = await service.replaceNotePermissions( - noteWithPreexistingPermissions, - { - sharedToUsers: [], - sharedToGroups: [groupPermissionUpdate], - }, - ); - expect(await savedNote.userPermissions).toHaveLength(0); - expect( - (await (await savedNote.groupPermissions)[0].group).name, - ).toEqual(groupPermissionUpdate.groupName); - expect((await savedNote.groupPermissions)[0].canEdit).toEqual( - groupPermissionUpdate.canEdit, - ); - }); - it('with existing GroupPermissions and with new UserPermissions', async () => { - const noteWithPreexistingPermissions: Note = { ...note }; - noteWithPreexistingPermissions.groupPermissions = Promise.resolve([ - { - id: 1, - note: Promise.resolve(noteWithPreexistingPermissions), - group: Promise.resolve(group), - canEdit: !groupPermissionUpdate.canEdit, - }, - ]); - - jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user); - jest.spyOn(groupRepo, 'findOne').mockResolvedValueOnce(group); - const savedNote = await service.replaceNotePermissions( - noteWithPreexistingPermissions, - { - sharedToUsers: [userPermissionUpdate], - sharedToGroups: [groupPermissionUpdate], - }, - ); - expect( - (await (await savedNote.userPermissions)[0].user).username, - ).toEqual(userPermissionUpdate.username); - expect((await savedNote.userPermissions)[0].canEdit).toEqual( - userPermissionUpdate.canEdit, - ); - expect( - (await (await savedNote.groupPermissions)[0].group).name, - ).toEqual(groupPermissionUpdate.groupName); - expect((await savedNote.groupPermissions)[0].canEdit).toEqual( - groupPermissionUpdate.canEdit, - ); - }); - it('with existing GroupPermissions and with existing UserPermissions', async () => { - const noteWithPreexistingPermissions: Note = { ...note }; - noteWithPreexistingPermissions.groupPermissions = Promise.resolve([ - { - id: 1, - note: Promise.resolve(noteWithPreexistingPermissions), - group: Promise.resolve(group), - canEdit: !groupPermissionUpdate.canEdit, - }, - ]); - noteWithPreexistingPermissions.userPermissions = Promise.resolve([ - { - id: 1, - note: Promise.resolve(noteWithPreexistingPermissions), - user: Promise.resolve(user), - canEdit: !userPermissionUpdate.canEdit, - }, - ]); - - jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user); - jest.spyOn(groupRepo, 'findOne').mockResolvedValueOnce(group); - const savedNote = await service.replaceNotePermissions( - noteWithPreexistingPermissions, - { - sharedToUsers: [userPermissionUpdate], - sharedToGroups: [groupPermissionUpdate], - }, - ); - expect( - (await (await savedNote.userPermissions)[0].user).username, - ).toEqual(userPermissionUpdate.username); - expect((await savedNote.userPermissions)[0].canEdit).toEqual( - userPermissionUpdate.canEdit, - ); - expect( - (await (await savedNote.groupPermissions)[0].group).name, - ).toEqual(groupPermissionUpdate.groupName); - expect((await savedNote.groupPermissions)[0].canEdit).toEqual( - groupPermissionUpdate.canEdit, - ); - }); - }); - describe('fails:', () => { - it('userPermissions has duplicate entries', async () => { - await expect( - service.replaceNotePermissions(note, { - sharedToUsers: [userPermissionUpdate, userPermissionUpdate], - sharedToGroups: [], - }), - ).rejects.toThrow(PermissionsUpdateInconsistentError); - }); - - it('groupPermissions has duplicate entries', async () => { - await expect( - service.replaceNotePermissions(note, { - sharedToUsers: [], - sharedToGroups: [groupPermissionUpdate, groupPermissionUpdate], - }), - ).rejects.toThrow(PermissionsUpdateInconsistentError); - }); - - it('userPermissions and groupPermissions have duplicate entries', async () => { - await expect( - service.replaceNotePermissions(note, { - sharedToUsers: [userPermissionUpdate, userPermissionUpdate], - sharedToGroups: [groupPermissionUpdate, groupPermissionUpdate], - }), - ).rejects.toThrow(PermissionsUpdateInconsistentError); - }); + }, + ); }); }); describe('setUserPermission', () => { - it('emits PERMISSION_CHANGE event', async () => { - const note = Note.create(null) as Note; - const user = User.create('test', 'Testy') as User; - - expect(eventEmitterEmitSpy).not.toHaveBeenCalled(); - await service.setUserPermission(note, user, true); - expect(eventEmitterEmitSpy).toHaveBeenCalled(); + let spyOnIsOwner: jest.SpyInstance; + beforeEach(() => { + spyOnIsOwner = jest.spyOn(service, 'isOwner'); }); - describe('works', () => { - it('with user not added if owner', async () => { - const user = User.create('test', 'Testy') as User; - const note = Note.create(user) as Note; - const resultNote = await service.setUserPermission(note, user, true); - expect(await resultNote.userPermissions).toHaveLength(0); + it('directly returns if user is owner', async () => { + spyOnIsOwner.mockResolvedValue(true); + await service.setUserPermission(mockNoteId, mockUserId1, true); + expect(spyOnIsOwner).toHaveBeenCalledTimes(1); + }); + describe('user is not owner', () => { + let spyOnIsRegisteredUser: jest.SpyInstance; + beforeEach(() => { + spyOnIsOwner.mockResolvedValue(false); + spyOnIsRegisteredUser = jest.spyOn(userService, 'isRegisteredUser'); }); - - it('with user not added before and editable', async () => { - const note = Note.create(null) as Note; - const user = User.create('test', 'Testy') as User; - const resultNote = await service.setUserPermission(note, user, true); - const noteUserPermission = NoteUserPermission.create(user, note, true); - expect((await resultNote.userPermissions)[0]).toStrictEqual( - noteUserPermission, + it('and not a registered user', async () => { + spyOnIsRegisteredUser.mockResolvedValue(false); + await expect( + service.setUserPermission(mockNoteId, mockUserId1, true), + ).rejects.toThrow(PermissionError); + }); + it('and user is registered', async () => { + const spyOneNotifyOthers = jest.spyOn( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + service as any, + 'notifyOthers', ); - }); - it('with user not added before and not editable', async () => { - const note = Note.create(null) as Note; - const user = User.create('test', 'Testy') as User; - const resultNote = await service.setUserPermission(note, user, false); - const noteUserPermission = NoteUserPermission.create(user, note, false); - expect((await resultNote.userPermissions)[0]).toStrictEqual( - noteUserPermission, - ); - }); - it('with user added before and editable', async () => { - const note = Note.create(null) as Note; - const user = User.create('test', 'Testy') as User; - note.userPermissions = Promise.resolve([ - NoteUserPermission.create(user, note, false), + spyOnIsRegisteredUser.mockResolvedValue(true); + mockInsert(tracker, TableNoteUserPermission, [ + FieldNameNoteUserPermission.canEdit, + FieldNameNoteUserPermission.noteId, + FieldNameNoteUserPermission.userId, ]); - - const resultNote = await service.setUserPermission(note, user, true); - const noteUserPermission = NoteUserPermission.create(user, note, true); - expect((await resultNote.userPermissions)[0]).toStrictEqual( - noteUserPermission, - ); - }); - it('with user added before and not editable', async () => { - const note = Note.create(null) as Note; - const user = User.create('test', 'Testy') as User; - note.userPermissions = Promise.resolve([ - NoteUserPermission.create(user, note, true), - ]); - const resultNote = await service.setUserPermission(note, user, false); - const noteUserPermission = NoteUserPermission.create(user, note, false); - expect((await resultNote.userPermissions)[0]).toStrictEqual( - noteUserPermission, - ); + await service.setUserPermission(mockNoteId, mockUserId1, true); + expect(spyOneNotifyOthers).toHaveBeenCalledWith(mockNoteId); + expectBindings(tracker, 'insert', [[true, mockNoteId, mockUserId1]]); }); }); }); describe('removeUserPermission', () => { - it('emits PERMISSION_CHANGE event', async () => { - const note = Note.create(null) as Note; - const user = User.create('test', 'Testy') as User; - note.userPermissions = Promise.resolve([ - NoteUserPermission.create(user, note, true), - ]); - - expect(eventEmitterEmitSpy).not.toHaveBeenCalled(); - await service.removeUserPermission(note, user); - expect(eventEmitterEmitSpy).toHaveBeenCalled(); + let spyOneNotifyOthers: jest.SpyInstance; + beforeEach(() => { + spyOneNotifyOthers = jest.spyOn( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + service as any, + 'notifyOthers', + ); }); - describe('works', () => { - const note = Note.create(null) as Note; - const user1 = Mock.of({ id: 1 }); - const user2 = Mock.of({ id: 2 }); - - it('with user added before and editable', async () => { - const noteUserPermission1 = NoteUserPermission.create( - user1, - note, - true, - ); - const noteUserPermission2 = NoteUserPermission.create( - user2, - note, - true, - ); - note.userPermissions = Promise.resolve([ - noteUserPermission1, - noteUserPermission2, - ]); - const resultNote = await service.removeUserPermission(note, user1); - expect(await resultNote.userPermissions).toStrictEqual([ - noteUserPermission2, - ]); - }); - it('with user added before and not editable', async () => { - const noteUserPermission1 = NoteUserPermission.create( - user1, - note, - false, - ); - const noteUserPermission2 = NoteUserPermission.create( - user2, - note, - false, - ); - note.userPermissions = Promise.resolve([ - noteUserPermission1, - noteUserPermission2, - ]); - const resultNote = await service.removeUserPermission(note, user1); - expect(await resultNote.userPermissions).toStrictEqual([ - noteUserPermission2, - ]); - }); + function buildMockDelete(deletedEntries: number): void { + mockDelete( + tracker, + TableNoteUserPermission, + [ + FieldNameNoteUserPermission.noteId, + FieldNameNoteUserPermission.userId, + ], + deletedEntries, + ); + } + it('correctly deletes the user permissions and notifies others', async () => { + buildMockDelete(1); + await service.removeUserPermission(mockNoteId, mockUserId1); + expect(spyOneNotifyOthers).toHaveBeenCalledWith(mockNoteId); + expectBindings(tracker, 'delete', [[mockNoteId, mockUserId1]]); + }); + it('throws NotInDBError if user does not exist', async () => { + buildMockDelete(0); + await expect( + service.removeUserPermission(mockNoteId, mockUserId1), + ).rejects.toThrow(NotInDBError); + expect(spyOneNotifyOthers).toHaveBeenCalledTimes(0); + expectBindings(tracker, 'delete', [[mockNoteId, mockUserId1]]); }); }); describe('setGroupPermission', () => { - it('emits PERMISSION_CHANGE event', async () => { - const note = Note.create(null) as Note; - const group = Group.create('test', 'Testy', false) as Group; - - expect(eventEmitterEmitSpy).not.toHaveBeenCalled(); - await service.setGroupPermission(note, group, true); - expect(eventEmitterEmitSpy).toHaveBeenCalled(); - }); - describe('works', () => { - it('with group not added before and editable', async () => { - const note = Note.create(null) as Note; - const group = Group.create('test', 'Testy', false) as Group; - const resultNote = await service.setGroupPermission(note, group, true); - const noteGroupPermission = NoteGroupPermission.create( - group, - note, - true, - ); - expect((await resultNote.groupPermissions)[0]).toStrictEqual( - noteGroupPermission, - ); - }); - it('with group not added before and not editable', async () => { - const note = Note.create(null) as Note; - const group = Group.create('test', 'Testy', false) as Group; - const resultNote = await service.setGroupPermission(note, group, false); - const noteGroupPermission = NoteGroupPermission.create( - group, - note, - false, - ); - expect((await resultNote.groupPermissions)[0]).toStrictEqual( - noteGroupPermission, - ); - }); - it('with group added before and editable', async () => { - const note = Note.create(null) as Note; - const group = Group.create('test', 'Testy', false) as Group; - note.groupPermissions = Promise.resolve([ - NoteGroupPermission.create(group, note, false), - ]); - - const resultNote = await service.setGroupPermission(note, group, true); - const noteGroupPermission = NoteGroupPermission.create( - group, - note, - true, - ); - expect((await resultNote.groupPermissions)[0]).toStrictEqual( - noteGroupPermission, - ); - }); - it('with group added before and not editable', async () => { - const note = Note.create(null) as Note; - const group = Group.create('test', 'Testy', false) as Group; - note.groupPermissions = Promise.resolve([ - NoteGroupPermission.create(group, note, true), - ]); - const resultNote = await service.setGroupPermission(note, group, false); - const noteGroupPermission = NoteGroupPermission.create( - group, - note, - false, - ); - expect((await resultNote.groupPermissions)[0]).toStrictEqual( - noteGroupPermission, - ); - }); + it('correctly sets group permissions and notifies other user', async () => { + const spyOneNotifyOthers = jest.spyOn( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + service as any, + 'notifyOthers', + ); + mockInsert(tracker, TableNoteGroupPermission, [ + FieldNameNoteGroupPermission.canEdit, + FieldNameNoteGroupPermission.groupId, + FieldNameNoteGroupPermission.noteId, + ]); + await service.setGroupPermission(mockNoteId, mockGroupId1, true); + expect(spyOneNotifyOthers).toHaveBeenCalledWith(mockNoteId); + expectBindings(tracker, 'insert', [[true, mockGroupId1, mockNoteId]]); }); }); describe('removeGroupPermission', () => { - it('emits PERMISSION_CHANGE event', async () => { - const note = Note.create(null) as Note; - const group = Group.create('test', 'Testy', false) as Group; - note.groupPermissions = Promise.resolve([ - NoteGroupPermission.create(group, note, true), - ]); - - expect(eventEmitterEmitSpy).not.toHaveBeenCalled(); - await service.removeGroupPermission(note, group); - expect(eventEmitterEmitSpy).toHaveBeenCalled(); + let spyOneNotifyOthers: jest.SpyInstance; + beforeEach(() => { + spyOneNotifyOthers = jest.spyOn( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + service as any, + 'notifyOthers', + ); }); - describe('works', () => { - const note = Note.create(null) as Note; - const group1 = Mock.of({ id: 1 }); - const group2 = Mock.of({ id: 2 }); - - it('with editable group', async () => { - const noteGroupPermission1 = NoteGroupPermission.create( - group1, - note, - true, - ); - const noteGroupPermission2 = NoteGroupPermission.create( - group2, - note, - true, - ); - note.groupPermissions = Promise.resolve([ - noteGroupPermission1, - noteGroupPermission2, - ]); - - const resultNote = await service.removeGroupPermission(note, group1); - expect(await resultNote.groupPermissions).toStrictEqual([ - noteGroupPermission2, - ]); - }); - it('with not editable group', async () => { - const noteGroupPermission1 = NoteGroupPermission.create( - group1, - note, - false, - ); - const noteGroupPermission2 = NoteGroupPermission.create( - group2, - note, - false, - ); - note.groupPermissions = Promise.resolve([ - noteGroupPermission1, - noteGroupPermission2, - ]); - const resultNote = await service.removeGroupPermission(note, group1); - expect(await resultNote.groupPermissions).toStrictEqual([ - noteGroupPermission2, - ]); - }); + // eslint-disable-next-line func-style + const buildMockDelete = (deletedEntries: number) => { + mockDelete( + tracker, + TableNoteGroupPermission, + [ + FieldNameNoteGroupPermission.noteId, + FieldNameNoteGroupPermission.groupId, + ], + deletedEntries, + ); + }; + it('correctly deletes the user permissions and notifies others', async () => { + buildMockDelete(1); + await service.removeGroupPermission(mockNoteId, mockGroupId1); + expect(spyOneNotifyOthers).toHaveBeenCalledWith(mockNoteId); + expectBindings(tracker, 'delete', [[mockNoteId, mockGroupId1]]); + }); + it('throws NotInDBError if user does not exist', async () => { + buildMockDelete(0); + await expect( + service.removeGroupPermission(mockNoteId, mockGroupId1), + ).rejects.toThrow(NotInDBError); + expect(spyOneNotifyOthers).toHaveBeenCalledTimes(0); + expectBindings(tracker, 'delete', [[mockNoteId, mockGroupId1]]); }); }); describe('changeOwner', () => { - it('works', async () => { - const note = Note.create(null) as Note; - const user = User.create('test', 'Testy') as User; + let spyOneNotifyOthers: jest.SpyInstance; + beforeEach(() => { + spyOneNotifyOthers = jest.spyOn( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + service as any, + 'notifyOthers', + ); + }); + // eslint-disable-next-line func-style + const buildMockUpdate = (updatedEntries: number) => { + mockUpdate( + tracker, + TableNote, + [FieldNameNote.ownerId], + FieldNameNote.id, + updatedEntries, + ); + }; + it('throws NotInDBError when the update does not succed', async () => { + buildMockUpdate(0); + await expect( + service.changeOwner(mockNoteId, mockUserId2), + ).rejects.toThrow(NotInDBError); + expect(spyOneNotifyOthers).toHaveBeenCalledTimes(0); + expectBindings(tracker, 'update', [[mockUserId2, mockNoteId]]); + }); + it('correctly notifies others', async () => { + buildMockUpdate(1); + await service.changeOwner(mockNoteId, mockUserId2); + expect(spyOneNotifyOthers).toHaveBeenCalledWith(mockNoteId); + expectBindings(tracker, 'update', [[mockUserId2, mockNoteId]]); + }); + }); - const resultNote = await service.changeOwner(note, user); - expect(await resultNote.owner).toStrictEqual(user); + describe('getPermissionsDtoForNote', () => { + // eslint-disable-next-line func-style + const buildMockOwnerSelect = (returnValues: unknown) => { + mockSelect( + tracker, + [`${TableUser}"."${FieldNameUser.username}`], + TableNote, + `${TableNote}"."${FieldNameNote.id}`, + returnValues, + [ + { + joinTable: TableUser, + keyLeft: FieldNameUser.id, + keyRight: FieldNameNote.ownerId, + }, + ], + ); + }; + // eslint-disable-next-line func-style + const buildMockUserPermissionsSelect = (returnValues: unknown) => { + mockSelect( + tracker, + [ + `${TableUser}"."${FieldNameUser.username}`, + `${TableNoteUserPermission}"."${FieldNameNoteUserPermission.canEdit}`, + ], + TableNoteUserPermission, + `${TableNoteUserPermission}"."${FieldNameNoteUserPermission.noteId}`, + returnValues, + [ + { + joinTable: TableUser, + keyLeft: FieldNameUser.id, + keyRight: FieldNameNoteUserPermission.userId, + }, + ], + ); + }; + // eslint-disable-next-line func-style + const buildMockGroupPermissionsSelect = (returnValues: unknown) => { + mockSelect( + tracker, + [ + `${TableGroup}"."${FieldNameGroup.name}`, + `${TableNoteGroupPermission}"."${FieldNameNoteGroupPermission.canEdit}`, + ], + TableNoteGroupPermission, + `${TableNoteGroupPermission}"."${FieldNameNoteGroupPermission.noteId}`, + returnValues, + [ + { + joinTable: TableGroup, + keyLeft: FieldNameGroup.id, + keyRight: FieldNameNoteGroupPermission.groupId, + }, + ], + ); + }; + beforeEach(() => { + buildMockUserPermissionsSelect([ + { + [FieldNameUser.username]: mockUserName1, + [FieldNameNoteUserPermission.canEdit]: true, + }, + ]); + buildMockGroupPermissionsSelect([ + { + [FieldNameGroup.name]: mockGroupName1, + [FieldNameNoteGroupPermission.canEdit]: true, + }, + ]); + }); + it('throws GenericDBError if note has no owner', async () => { + buildMockOwnerSelect(undefined); + await expect( + service.getPermissionsDtoForNote(mockNoteId), + ).rejects.toThrow(GenericDBError); + expectBindings(tracker, 'select', [ + [mockNoteId, IS_FIRST], + [mockNoteId], + [mockNoteId], + ]); + }); + it('correctly returns Dto', async () => { + buildMockOwnerSelect([ + { + [FieldNameUser.username]: mockUserName2, + }, + ]); + const results = await service.getPermissionsDtoForNote(mockNoteId); + expect(results.owner).toEqual(mockUserName2); + expect(results.sharedToUsers).toHaveLength(1); + expect(results.sharedToUsers[0]).toEqual({ + username: mockUserName1, + canEdit: true, + }); + expect(results.sharedToGroups).toHaveLength(1); + expect(results.sharedToGroups[0]).toEqual({ + groupName: mockGroupName1, + canEdit: true, + }); + expectBindings(tracker, 'select', [ + [mockNoteId, IS_FIRST], + [mockNoteId], + [mockNoteId], + ]); }); }); }); diff --git a/backend/src/realtime/realtime-note/realtime-connection.spec.ts b/backend/src/realtime/realtime-note/realtime-connection.spec.ts index 087e27185..928d4ec93 100644 --- a/backend/src/realtime/realtime-note/realtime-connection.spec.ts +++ b/backend/src/realtime/realtime-note/realtime-connection.spec.ts @@ -12,7 +12,6 @@ import * as HedgeDocCommonsModule from '@hedgedoc/commons'; import { FieldNameUser, User } from '@hedgedoc/database'; import { Mock } from 'ts-mockery'; -import * as NameRandomizerModule from '../../users/random-word-lists/name-randomizer'; import { RealtimeConnection } from './realtime-connection'; import { RealtimeNote } from './realtime-note'; import { @@ -147,6 +146,7 @@ describe('websocket connection', () => { expect(constructor).toHaveBeenCalledWith( mockedUserName, mockedDisplayName, + mockedAuthorStyle, expect.anything(), mockedMessageTransporter, expect.anything(), diff --git a/backend/src/realtime/realtime-note/realtime-note-store.spec.ts b/backend/src/realtime/realtime-note/realtime-note-store.spec.ts index 4ebf7343d..1d656f50a 100644 --- a/backend/src/realtime/realtime-note/realtime-note-store.spec.ts +++ b/backend/src/realtime/realtime-note/realtime-note-store.spec.ts @@ -3,16 +3,12 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { Mock } from 'ts-mockery'; - -import { Note } from '../../notes/note.entity'; import * as realtimeNoteModule from './realtime-note'; import { RealtimeNote } from './realtime-note'; import { RealtimeNoteStore } from './realtime-note-store'; describe('RealtimeNoteStore', () => { let realtimeNoteStore: RealtimeNoteStore; - let mockedNote: Note; let mockedRealtimeNote: RealtimeNote; let realtimeNoteConstructorSpy: jest.SpyInstance; const mockedContent = 'mockedContent'; @@ -21,8 +17,7 @@ describe('RealtimeNoteStore', () => { beforeEach(async () => { realtimeNoteStore = new RealtimeNoteStore(); - mockedNote = Mock.of({ id: mockedNoteId }); - mockedRealtimeNote = new RealtimeNote(mockedNote, ''); + mockedRealtimeNote = new RealtimeNote(mockedNoteId, ''); realtimeNoteConstructorSpy = jest .spyOn(realtimeNoteModule, 'RealtimeNote') .mockReturnValue(mockedRealtimeNote); @@ -35,11 +30,11 @@ describe('RealtimeNoteStore', () => { }); it("can create a new realtime note if it doesn't exist yet", () => { - expect(realtimeNoteStore.create(mockedNote, mockedContent)).toBe( + expect(realtimeNoteStore.create(mockedNoteId, mockedContent)).toBe( mockedRealtimeNote, ); expect(realtimeNoteConstructorSpy).toHaveBeenCalledWith( - mockedNote, + mockedNoteId, mockedContent, undefined, ); @@ -50,12 +45,16 @@ describe('RealtimeNoteStore', () => { }); it("can create a new realtime note with a yjs state if it doesn't exist yet", () => { - const initialYjsState = [1, 2, 3]; + const initialYjsState = [0]; expect( - realtimeNoteStore.create(mockedNote, mockedContent, initialYjsState), + realtimeNoteStore.create( + mockedNoteId, + mockedContent, + new Uint8Array(initialYjsState).buffer, + ), ).toBe(mockedRealtimeNote); expect(realtimeNoteConstructorSpy).toHaveBeenCalledWith( - mockedNote, + mockedNoteId, mockedContent, initialYjsState, ); @@ -66,14 +65,16 @@ describe('RealtimeNoteStore', () => { }); it('throws if a realtime note has already been created for the given note', () => { - expect(realtimeNoteStore.create(mockedNote, mockedContent)).toBe( + expect(realtimeNoteStore.create(mockedNoteId, mockedContent)).toBe( mockedRealtimeNote, ); - expect(() => realtimeNoteStore.create(mockedNote, mockedContent)).toThrow(); + expect(() => + realtimeNoteStore.create(mockedNoteId, mockedContent), + ).toThrow(); }); it('deletes a note if it gets destroyed', () => { - expect(realtimeNoteStore.create(mockedNote, mockedContent)).toBe( + expect(realtimeNoteStore.create(mockedNoteId, mockedContent)).toBe( mockedRealtimeNote, ); mockedRealtimeNote.emit('destroy'); diff --git a/backend/src/realtime/realtime-note/realtime-note.service.spec.ts b/backend/src/realtime/realtime-note/realtime-note.service.spec.ts index 875d3eaf3..8484e7b80 100644 --- a/backend/src/realtime/realtime-note/realtime-note.service.spec.ts +++ b/backend/src/realtime/realtime-note/realtime-note.service.spec.ts @@ -3,16 +3,14 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ +import { PermissionLevel } from '@hedgedoc/commons'; +import { FieldNameRevision, Revision } from '@hedgedoc/database'; import { SchedulerRegistry } from '@nestjs/schedule'; import { Mock } from 'ts-mockery'; import { AppConfig } from '../../config/app.config'; -import { User } from '../../database/user.entity'; import { ConsoleLoggerService } from '../../logger/console-logger.service'; -import { Note } from '../../notes/note.entity'; -import { NotePermissionLevel } from '../../permissions/note-permission.enum'; import { PermissionService } from '../../permissions/permission.service'; -import { Revision } from '../../revisions/revision.entity'; import { RevisionsService } from '../../revisions/revisions.service'; import { RealtimeConnection } from './realtime-connection'; import { RealtimeNote } from './realtime-note'; @@ -24,7 +22,7 @@ describe('RealtimeNoteService', () => { const mockedContent = 'mockedContent'; const mockedYjsState = [1, 2, 3]; const mockedNoteId = 4711; - let note: Note; + let realtimeNote: RealtimeNote; let realtimeNoteService: RealtimeNoteService; let revisionsService: RevisionsService; @@ -40,9 +38,9 @@ describe('RealtimeNoteService', () => { let clientWithoutReadWrite: RealtimeConnection; let deleteIntervalSpy: jest.SpyInstance; - const readWriteUsername = 'can-read-write-user'; - const onlyReadUsername = 'can-only-read-user'; - const noAccessUsername = 'no-read-write-user'; + const readWriteUserId = 2; + const onlyReadUserId = 1; + const noAccessUserId = 0; afterAll(() => { jest.useRealTimers(); @@ -57,12 +55,14 @@ describe('RealtimeNoteService', () => { ) { jest .spyOn(revisionsService, 'getLatestRevision') - .mockImplementation((note: Note) => - note.id === mockedNoteId && latestRevisionExists + .mockImplementation((noteId: number) => + noteId === mockedNoteId && latestRevisionExists ? Promise.resolve( Mock.of({ - content: mockedContent, - ...(hasYjsState ? { yjsStateVector: mockedYjsState } : {}), + [FieldNameRevision.content]: mockedContent, + ...(hasYjsState + ? { [FieldNameRevision.yjsStateVector]: mockedYjsState } + : {}), }), ) : Promise.reject('Revision for note mockedNoteId not found.'), @@ -73,12 +73,11 @@ describe('RealtimeNoteService', () => { jest.resetAllMocks(); jest.resetModules(); - note = Mock.of({ id: mockedNoteId }); - realtimeNote = new RealtimeNote(note, mockedContent); + realtimeNote = new RealtimeNote(mockedNoteId, mockedContent); revisionsService = Mock.of({ getLatestRevision: jest.fn(), - createAndSaveRevision: jest.fn(), + createRevision: jest.fn(), }); consoleLoggerService = Mock.of({ @@ -92,13 +91,13 @@ describe('RealtimeNoteService', () => { mockedAppConfig = Mock.of({ persistInterval: 0 }); mockedPermissionService = Mock.of({ - determinePermission: async (user: User | null) => { - if (user?.username === readWriteUsername) { - return NotePermissionLevel.WRITE; - } else if (user?.username === onlyReadUsername) { - return NotePermissionLevel.READ; + determinePermission: async (userId: number): Promise => { + if (userId === readWriteUserId) { + return PermissionLevel.WRITE; + } else if (userId === onlyReadUserId) { + return PermissionLevel.READ; } else { - return NotePermissionLevel.DENY; + return PermissionLevel.DENY; } }, }); @@ -115,17 +114,17 @@ describe('RealtimeNoteService', () => { clientWithReadWrite = new MockConnectionBuilder(realtimeNote) .withAcceptingRealtimeUserStatus() - .withLoggedInUser(readWriteUsername) + .withLoggedInUser(readWriteUserId) .build(); clientWithRead = new MockConnectionBuilder(realtimeNote) .withDecliningRealtimeUserStatus() - .withLoggedInUser(onlyReadUsername) + .withLoggedInUser(onlyReadUserId) .build(); clientWithoutReadWrite = new MockConnectionBuilder(realtimeNote) .withDecliningRealtimeUserStatus() - .withGuestUser(noAccessUsername) + .withGuestUser(noAccessUserId) .build(); realtimeNoteService = new RealtimeNoteService( @@ -149,7 +148,7 @@ describe('RealtimeNoteService', () => { jest.spyOn(loggedUserTransporter, 'disconnect'); - await realtimeNoteService.handleNotePermissionChanged(note); + await realtimeNoteService.handleNotePermissionChanged(mockedNoteId); expect(loggedUserTransporter.disconnect).toHaveBeenCalledTimes(0); }); @@ -158,24 +157,24 @@ describe('RealtimeNoteService', () => { const guestUserTransporter = clientWithoutReadWrite.getTransporter(); jest.spyOn(guestUserTransporter, 'disconnect'); - await realtimeNoteService.handleNotePermissionChanged(note); + await realtimeNoteService.handleNotePermissionChanged(mockedNoteId); expect(guestUserTransporter.disconnect).toHaveBeenCalledTimes(1); }); it('should change acceptEdits to true', async () => { - await realtimeNoteService.handleNotePermissionChanged(note); + await realtimeNoteService.handleNotePermissionChanged(mockedNoteId); expect(clientWithReadWrite.acceptEdits).toBeTruthy(); }); it('should change acceptEdits to false', async () => { clientWithRead.acceptEdits = true; - await realtimeNoteService.handleNotePermissionChanged(note); + await realtimeNoteService.handleNotePermissionChanged(mockedNoteId); expect(clientWithRead.acceptEdits).toBeFalsy(); }); }); it("creates a new realtime note if it doesn't exist yet", async () => { - mockGetLatestRevision(true); + mockGetLatestRevision(true, false); jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined); jest .spyOn(realtimeNoteStore, 'create') @@ -183,12 +182,12 @@ describe('RealtimeNoteService', () => { mockedAppConfig.persistInterval = 0; await expect( - realtimeNoteService.getOrCreateRealtimeNote(note), + realtimeNoteService.getOrCreateRealtimeNote(mockedNoteId), ).resolves.toBe(realtimeNote); expect(realtimeNoteStore.find).toHaveBeenCalledWith(mockedNoteId); expect(realtimeNoteStore.create).toHaveBeenCalledWith( - note, + mockedNoteId, mockedContent, undefined, ); @@ -204,12 +203,12 @@ describe('RealtimeNoteService', () => { mockedAppConfig.persistInterval = 0; await expect( - realtimeNoteService.getOrCreateRealtimeNote(note), + realtimeNoteService.getOrCreateRealtimeNote(mockedNoteId), ).resolves.toBe(realtimeNote); expect(realtimeNoteStore.find).toHaveBeenCalledWith(mockedNoteId); expect(realtimeNoteStore.create).toHaveBeenCalledWith( - note, + mockedNoteId, mockedContent, mockedYjsState, ); @@ -225,7 +224,7 @@ describe('RealtimeNoteService', () => { .mockImplementation(() => realtimeNote); mockedAppConfig.persistInterval = 10; - await realtimeNoteService.getOrCreateRealtimeNote(note); + await realtimeNoteService.getOrCreateRealtimeNote(mockedNoteId); expect(setIntervalSpy).toHaveBeenCalledWith( expect.any(Function), @@ -242,7 +241,7 @@ describe('RealtimeNoteService', () => { .mockImplementation(() => realtimeNote); mockedAppConfig.persistInterval = 10; - await realtimeNoteService.getOrCreateRealtimeNote(note); + await realtimeNoteService.getOrCreateRealtimeNote(mockedNoteId); realtimeNote.emit('destroy'); expect(deleteIntervalSpy).toHaveBeenCalled(); expect(clearIntervalSpy).toHaveBeenCalled(); @@ -255,7 +254,7 @@ describe('RealtimeNoteService', () => { jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined); await expect( - realtimeNoteService.getOrCreateRealtimeNote(note), + realtimeNoteService.getOrCreateRealtimeNote(mockedNoteId), ).rejects.toBe(`Revision for note mockedNoteId not found.`); expect(realtimeNoteStore.create).not.toHaveBeenCalled(); expect(realtimeNoteStore.find).toHaveBeenCalledWith(mockedNoteId); @@ -270,7 +269,7 @@ describe('RealtimeNoteService', () => { .mockImplementation(() => realtimeNote); await expect( - realtimeNoteService.getOrCreateRealtimeNote(note), + realtimeNoteService.getOrCreateRealtimeNote(mockedNoteId), ).resolves.toBe(realtimeNote); jest @@ -278,7 +277,7 @@ describe('RealtimeNoteService', () => { .mockImplementation(() => realtimeNote); await expect( - realtimeNoteService.getOrCreateRealtimeNote(note), + realtimeNoteService.getOrCreateRealtimeNote(mockedNoteId), ).resolves.toBe(realtimeNote); expect(realtimeNoteStore.create).toHaveBeenCalledTimes(1); }); @@ -291,17 +290,19 @@ describe('RealtimeNoteService', () => { .spyOn(realtimeNoteStore, 'create') .mockImplementation(() => realtimeNote); - await realtimeNoteService.getOrCreateRealtimeNote(note); + await realtimeNoteService.getOrCreateRealtimeNote(mockedNoteId); const createRevisionSpy = jest - .spyOn(revisionsService, 'createAndSaveRevision') + .spyOn(revisionsService, 'createRevision') .mockResolvedValue(); realtimeNote.emit('beforeDestroy'); expect(createRevisionSpy).toHaveBeenCalledWith( - note, + mockedNoteId, mockedContent, - expect.any(Array), + false, // this cannot be an initial revision, since this is created during note creation + undefined, // the test doesn't use knex transactions + expect.any(Uint8Array), ); }); diff --git a/backend/src/realtime/realtime-note/realtime-note.service.ts b/backend/src/realtime/realtime-note/realtime-note.service.ts index 233b5108c..4748cf895 100644 --- a/backend/src/realtime/realtime-note/realtime-note.service.ts +++ b/backend/src/realtime/realtime-note/realtime-note.service.ts @@ -87,7 +87,7 @@ export class RealtimeNoteService implements BeforeApplicationShutdown { const lastRevision = await this.revisionsService.getLatestRevision(noteId); const realtimeNote = this.realtimeNoteStore.create( noteId, - lastRevision.content, + lastRevision[FieldNameRevision.content], lastRevision[FieldNameRevision.yjsStateVector] ?? undefined, ); realtimeNote.on('beforeDestroy', () => { diff --git a/backend/src/realtime/realtime-note/realtime-note.spec.ts b/backend/src/realtime/realtime-note/realtime-note.spec.ts index 09ad742fc..04dc0baeb 100644 --- a/backend/src/realtime/realtime-note/realtime-note.spec.ts +++ b/backend/src/realtime/realtime-note/realtime-note.spec.ts @@ -4,23 +4,18 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { MessageType, RealtimeDoc } from '@hedgedoc/commons'; -import { Mock } from 'ts-mockery'; -import { Note } from '../../notes/note.entity'; import { RealtimeNote } from './realtime-note'; import { MockConnectionBuilder } from './test-utils/mock-connection'; describe('realtime note', () => { - let mockedNote: Note; + const mockUserId = 42; + const mockedNoteId = 23; beforeAll(() => { jest.useFakeTimers(); }); - beforeEach(() => { - mockedNote = Mock.of({ id: 4711 }); - }); - afterAll(() => { jest.useRealTimers(); jest.resetAllMocks(); @@ -28,13 +23,15 @@ describe('realtime note', () => { }); it('can return the given note', () => { - const sut = new RealtimeNote(mockedNote, 'nothing'); - expect(sut.getNoteId()).toBe(mockedNote); + const sut = new RealtimeNote(mockedNoteId, 'nothing'); + expect(sut.getNoteId()).toBe(mockedNoteId); }); it('can connect and disconnect clients', () => { - const sut = new RealtimeNote(mockedNote, 'nothing'); - const client1 = new MockConnectionBuilder(sut).withLoggedInUser().build(); + const sut = new RealtimeNote(mockedNoteId, 'nothing'); + const client1 = new MockConnectionBuilder(sut) + .withLoggedInUser(mockUserId) + .build(); expect(sut.getConnections()).toStrictEqual([client1]); expect(sut.hasConnections()).toBeTruthy(); sut.removeClient(client1); @@ -44,19 +41,19 @@ describe('realtime note', () => { it('creates a realtime doc', () => { const initialContent = 'nothing'; - const sut = new RealtimeNote(mockedNote, initialContent); + const sut = new RealtimeNote(mockedNoteId, initialContent); expect(sut.getRealtimeDoc() instanceof RealtimeDoc).toBeTruthy(); }); it('destroys realtime doc on self-destruction', () => { - const sut = new RealtimeNote(mockedNote, 'nothing'); + const sut = new RealtimeNote(mockedNoteId, 'nothing'); const docDestroy = jest.spyOn(sut.getRealtimeDoc(), 'destroy'); sut.destroy(); expect(docDestroy).toHaveBeenCalled(); }); it('emits destroy event on destruction', async () => { - const sut = new RealtimeNote(mockedNote, 'nothing'); + const sut = new RealtimeNote(mockedNoteId, 'nothing'); const destroyPromise = new Promise((resolve) => { sut.once('destroy', () => { resolve(); @@ -67,16 +64,20 @@ describe('realtime note', () => { }); it("doesn't destroy a destroyed note", () => { - const sut = new RealtimeNote(mockedNote, 'nothing'); + const sut = new RealtimeNote(mockedNoteId, 'nothing'); sut.destroy(); expect(() => sut.destroy()).toThrow(); }); it('announcePermissionChange to all clients', () => { - const sut = new RealtimeNote(mockedNote, 'nothing'); + const sut = new RealtimeNote(mockedNoteId, 'nothing'); - const client1 = new MockConnectionBuilder(sut).withLoggedInUser().build(); - const client2 = new MockConnectionBuilder(sut).withLoggedInUser().build(); + const client1 = new MockConnectionBuilder(sut) + .withLoggedInUser(mockUserId) + .build(); + const client2 = new MockConnectionBuilder(sut) + .withLoggedInUser(mockUserId) + .build(); const sendMessage1Spy = jest.spyOn(client1.getTransporter(), 'sendMessage'); const sendMessage2Spy = jest.spyOn(client2.getTransporter(), 'sendMessage'); @@ -92,9 +93,13 @@ describe('realtime note', () => { }); it('announceNoteDeletion to all clients', () => { - const sut = new RealtimeNote(mockedNote, 'nothing'); - const client1 = new MockConnectionBuilder(sut).withLoggedInUser().build(); - const client2 = new MockConnectionBuilder(sut).withLoggedInUser().build(); + const sut = new RealtimeNote(mockedNoteId, 'nothing'); + const client1 = new MockConnectionBuilder(sut) + .withLoggedInUser(mockUserId) + .build(); + const client2 = new MockConnectionBuilder(sut) + .withLoggedInUser(mockUserId) + .build(); const sendMessage1Spy = jest.spyOn(client1.getTransporter(), 'sendMessage'); const sendMessage2Spy = jest.spyOn(client2.getTransporter(), 'sendMessage'); @@ -111,8 +116,10 @@ describe('realtime note', () => { describe('removeClient', () => { it('destory if the number of connected clients reaches zero and the lifetime is exceeded', () => { - const sut = new RealtimeNote(mockedNote, 'nothing'); - const client1 = new MockConnectionBuilder(sut).withLoggedInUser().build(); + const sut = new RealtimeNote(mockedNoteId, 'nothing'); + const client1 = new MockConnectionBuilder(sut) + .withLoggedInUser(mockUserId) + .build(); const docDestroy = jest.spyOn(sut, 'destroy'); sut.addClient(client1); @@ -127,8 +134,10 @@ describe('realtime note', () => { }); it("doesn't destory when a client reconnects quickly", () => { - const sut = new RealtimeNote(mockedNote, 'nothing'); - const client1 = new MockConnectionBuilder(sut).withLoggedInUser().build(); + const sut = new RealtimeNote(mockedNoteId, 'nothing'); + const client1 = new MockConnectionBuilder(sut) + .withLoggedInUser(mockUserId) + .build(); const docDestroy = jest.spyOn(sut, 'destroy'); // Assuming the case where the only connected user reloads the browser diff --git a/backend/src/realtime/realtime-note/realtime-user-status-adapter.spec.ts b/backend/src/realtime/realtime-note/realtime-user-status-adapter.spec.ts index 47ac4757c..b6b201d3f 100644 --- a/backend/src/realtime/realtime-note/realtime-user-status-adapter.spec.ts +++ b/backend/src/realtime/realtime-note/realtime-user-status-adapter.spec.ts @@ -31,8 +31,13 @@ describe('realtime user status adapter', () => { const clientLoggedIn2Username = 'logged.in2'; const clientNotReadyUsername = 'not.ready'; const clientDeclineUsername = 'read.only'; + const clientLoggedIn1StyleIndex = 1; + const clientLoggedIn2StyleIndex = 2; + const clientNotReadyStyleIndex = 3; + const clientDeclineStyleIndex = 4; const guestDisplayName = 'Virtuous Mockingbird'; + const guestStyleIndex = 5; let messageTransporterLoggedIn1: MessageTransporter; let messageTransporterLoggedIn2: MessageTransporter; @@ -92,6 +97,7 @@ describe('realtime user status adapter', () => { clientLoggedIn1 = new RealtimeUserStatusAdapter( clientLoggedIn1Username, clientLoggedIn1Username, + clientLoggedIn1StyleIndex, otherAdapterCollector, messageTransporterLoggedIn1, () => true, @@ -99,6 +105,7 @@ describe('realtime user status adapter', () => { clientLoggedIn2 = new RealtimeUserStatusAdapter( clientLoggedIn2Username, clientLoggedIn2Username, + clientLoggedIn2StyleIndex, otherAdapterCollector, messageTransporterLoggedIn2, () => true, @@ -106,6 +113,7 @@ describe('realtime user status adapter', () => { clientGuest = new RealtimeUserStatusAdapter( null, guestDisplayName, + guestStyleIndex, otherAdapterCollector, messageTransporterGuest, () => true, @@ -113,6 +121,7 @@ describe('realtime user status adapter', () => { clientNotReady = new RealtimeUserStatusAdapter( clientNotReadyUsername, clientNotReadyUsername, + clientNotReadyStyleIndex, otherAdapterCollector, messageTransporterNotReady, () => true, @@ -120,6 +129,7 @@ describe('realtime user status adapter', () => { clientDecline = new RealtimeUserStatusAdapter( clientDeclineUsername, clientDeclineUsername, + clientDeclineStyleIndex, otherAdapterCollector, messageTransporterDecline, () => false, @@ -167,7 +177,7 @@ describe('realtime user status adapter', () => { type: MessageType.REALTIME_USER_STATE_SET, payload: { ownUser: { - styleIndex: 0, + styleIndex: clientLoggedIn1StyleIndex, displayName: clientLoggedIn1Username, }, users: [ @@ -177,7 +187,7 @@ describe('realtime user status adapter', () => { from: 0, to: 0, }, - styleIndex: 1, + styleIndex: clientLoggedIn2StyleIndex, username: clientLoggedIn2Username, displayName: clientLoggedIn2Username, }, @@ -187,7 +197,7 @@ describe('realtime user status adapter', () => { from: 0, to: 0, }, - styleIndex: 2, + styleIndex: guestStyleIndex, username: null, displayName: guestDisplayName, }, @@ -195,7 +205,7 @@ describe('realtime user status adapter', () => { active: true, cursor: null, displayName: clientDeclineUsername, - styleIndex: 4, + styleIndex: clientDeclineStyleIndex, username: clientDeclineUsername, }, ], @@ -233,7 +243,7 @@ describe('realtime user status adapter', () => { type: MessageType.REALTIME_USER_STATE_SET, payload: { ownUser: { - styleIndex: 1, + styleIndex: clientLoggedIn2StyleIndex, displayName: clientLoggedIn2Username, }, users: [ @@ -243,7 +253,7 @@ describe('realtime user status adapter', () => { from: newFrom, to: newTo, }, - styleIndex: 0, + styleIndex: clientLoggedIn1StyleIndex, username: clientLoggedIn1Username, displayName: clientLoggedIn1Username, }, @@ -253,7 +263,7 @@ describe('realtime user status adapter', () => { from: 0, to: 0, }, - styleIndex: 2, + styleIndex: guestStyleIndex, username: null, displayName: guestDisplayName, }, @@ -261,7 +271,7 @@ describe('realtime user status adapter', () => { active: true, cursor: null, displayName: clientDeclineUsername, - styleIndex: 4, + styleIndex: clientDeclineStyleIndex, username: clientDeclineUsername, }, ], @@ -272,7 +282,7 @@ describe('realtime user status adapter', () => { type: MessageType.REALTIME_USER_STATE_SET, payload: { ownUser: { - styleIndex: 2, + styleIndex: guestStyleIndex, displayName: guestDisplayName, }, users: [ @@ -282,7 +292,7 @@ describe('realtime user status adapter', () => { from: newFrom, to: newTo, }, - styleIndex: 0, + styleIndex: clientLoggedIn1StyleIndex, username: clientLoggedIn1Username, displayName: clientLoggedIn1Username, }, @@ -292,7 +302,7 @@ describe('realtime user status adapter', () => { from: 0, to: 0, }, - styleIndex: 1, + styleIndex: clientLoggedIn2StyleIndex, username: clientLoggedIn2Username, displayName: clientLoggedIn2Username, }, @@ -300,7 +310,7 @@ describe('realtime user status adapter', () => { active: true, cursor: null, displayName: clientDeclineUsername, - styleIndex: 4, + styleIndex: clientDeclineStyleIndex, username: clientDeclineUsername, }, ], @@ -312,7 +322,7 @@ describe('realtime user status adapter', () => { payload: { ownUser: { displayName: clientDeclineUsername, - styleIndex: 4, + styleIndex: clientDeclineStyleIndex, }, users: [ { @@ -321,7 +331,7 @@ describe('realtime user status adapter', () => { from: newFrom, to: newTo, }, - styleIndex: 0, + styleIndex: clientLoggedIn1StyleIndex, username: clientLoggedIn1Username, displayName: clientLoggedIn1Username, }, @@ -331,7 +341,7 @@ describe('realtime user status adapter', () => { from: 0, to: 0, }, - styleIndex: 1, + styleIndex: clientLoggedIn2StyleIndex, username: clientLoggedIn2Username, displayName: clientLoggedIn2Username, }, @@ -342,7 +352,7 @@ describe('realtime user status adapter', () => { to: 0, }, displayName: guestDisplayName, - styleIndex: 2, + styleIndex: guestStyleIndex, username: null, }, ], @@ -378,7 +388,7 @@ describe('realtime user status adapter', () => { type: MessageType.REALTIME_USER_STATE_SET, payload: { ownUser: { - styleIndex: 0, + styleIndex: clientLoggedIn1StyleIndex, displayName: clientLoggedIn1Username, }, users: [ @@ -388,7 +398,7 @@ describe('realtime user status adapter', () => { from: 0, to: 0, }, - styleIndex: 2, + styleIndex: guestStyleIndex, username: null, displayName: guestDisplayName, }, @@ -396,7 +406,7 @@ describe('realtime user status adapter', () => { active: true, cursor: null, displayName: clientDeclineUsername, - styleIndex: 4, + styleIndex: clientDeclineStyleIndex, username: clientDeclineUsername, }, ], @@ -407,7 +417,7 @@ describe('realtime user status adapter', () => { type: MessageType.REALTIME_USER_STATE_SET, payload: { ownUser: { - styleIndex: 2, + styleIndex: guestStyleIndex, displayName: guestDisplayName, }, users: [ @@ -417,7 +427,7 @@ describe('realtime user status adapter', () => { from: 0, to: 0, }, - styleIndex: 0, + styleIndex: clientLoggedIn1StyleIndex, username: clientLoggedIn1Username, displayName: clientLoggedIn1Username, }, @@ -425,7 +435,7 @@ describe('realtime user status adapter', () => { active: true, cursor: null, displayName: clientDeclineUsername, - styleIndex: 4, + styleIndex: clientDeclineStyleIndex, username: clientDeclineUsername, }, ], @@ -437,7 +447,7 @@ describe('realtime user status adapter', () => { payload: { ownUser: { displayName: clientDeclineUsername, - styleIndex: 4, + styleIndex: clientDeclineStyleIndex, }, users: [ { @@ -446,7 +456,7 @@ describe('realtime user status adapter', () => { from: 0, to: 0, }, - styleIndex: 0, + styleIndex: clientLoggedIn1StyleIndex, username: clientLoggedIn1Username, displayName: clientLoggedIn1Username, }, @@ -457,7 +467,7 @@ describe('realtime user status adapter', () => { to: 0, }, displayName: guestDisplayName, - styleIndex: 2, + styleIndex: guestStyleIndex, username: null, }, ], @@ -499,7 +509,7 @@ describe('realtime user status adapter', () => { type: MessageType.REALTIME_USER_STATE_SET, payload: { ownUser: { - styleIndex: 1, + styleIndex: clientLoggedIn2StyleIndex, displayName: clientLoggedIn2Username, }, users: [ @@ -509,7 +519,7 @@ describe('realtime user status adapter', () => { from: 0, to: 0, }, - styleIndex: 0, + styleIndex: clientLoggedIn1StyleIndex, username: clientLoggedIn1Username, displayName: clientLoggedIn1Username, }, @@ -519,7 +529,7 @@ describe('realtime user status adapter', () => { from: 0, to: 0, }, - styleIndex: 2, + styleIndex: guestStyleIndex, username: null, displayName: guestDisplayName, }, @@ -527,7 +537,7 @@ describe('realtime user status adapter', () => { active: true, cursor: null, displayName: clientDeclineUsername, - styleIndex: 4, + styleIndex: clientDeclineStyleIndex, username: clientDeclineUsername, }, ], @@ -539,7 +549,7 @@ describe('realtime user status adapter', () => { type: MessageType.REALTIME_USER_STATE_SET, payload: { ownUser: { - styleIndex: 2, + styleIndex: guestStyleIndex, displayName: guestDisplayName, }, users: [ @@ -549,7 +559,7 @@ describe('realtime user status adapter', () => { from: 0, to: 0, }, - styleIndex: 0, + styleIndex: clientLoggedIn1StyleIndex, username: clientLoggedIn1Username, displayName: clientLoggedIn1Username, }, @@ -559,7 +569,7 @@ describe('realtime user status adapter', () => { from: 0, to: 0, }, - styleIndex: 1, + styleIndex: clientLoggedIn2StyleIndex, username: clientLoggedIn2Username, displayName: clientLoggedIn2Username, }, @@ -567,7 +577,7 @@ describe('realtime user status adapter', () => { active: true, cursor: null, displayName: clientDeclineUsername, - styleIndex: 4, + styleIndex: clientDeclineStyleIndex, username: clientDeclineUsername, }, ], @@ -579,7 +589,7 @@ describe('realtime user status adapter', () => { type: MessageType.REALTIME_USER_STATE_SET, payload: { ownUser: { - styleIndex: 4, + styleIndex: clientDeclineStyleIndex, displayName: clientDeclineUsername, }, users: [ @@ -589,7 +599,7 @@ describe('realtime user status adapter', () => { from: 0, to: 0, }, - styleIndex: 0, + styleIndex: clientLoggedIn1StyleIndex, username: clientLoggedIn1Username, displayName: clientLoggedIn1Username, }, @@ -599,7 +609,7 @@ describe('realtime user status adapter', () => { from: 0, to: 0, }, - styleIndex: 1, + styleIndex: clientLoggedIn2StyleIndex, username: clientLoggedIn2Username, displayName: clientLoggedIn2Username, }, @@ -610,7 +620,7 @@ describe('realtime user status adapter', () => { to: 0, }, displayName: guestDisplayName, - styleIndex: 2, + styleIndex: guestStyleIndex, username: null, }, ], @@ -673,7 +683,7 @@ describe('realtime user status adapter', () => { type: MessageType.REALTIME_USER_STATE_SET, payload: { ownUser: { - styleIndex: 1, + styleIndex: clientLoggedIn2StyleIndex, displayName: clientLoggedIn2Username, }, users: [ @@ -683,7 +693,7 @@ describe('realtime user status adapter', () => { from: 0, to: 0, }, - styleIndex: 0, + styleIndex: clientLoggedIn1StyleIndex, username: clientLoggedIn1Username, displayName: clientLoggedIn1Username, }, @@ -693,7 +703,7 @@ describe('realtime user status adapter', () => { from: 0, to: 0, }, - styleIndex: 2, + styleIndex: guestStyleIndex, username: null, displayName: guestDisplayName, }, @@ -701,7 +711,7 @@ describe('realtime user status adapter', () => { active: true, cursor: null, displayName: clientDeclineUsername, - styleIndex: 4, + styleIndex: clientDeclineStyleIndex, username: clientDeclineUsername, }, ], @@ -713,7 +723,7 @@ describe('realtime user status adapter', () => { type: MessageType.REALTIME_USER_STATE_SET, payload: { ownUser: { - styleIndex: 2, + styleIndex: guestStyleIndex, displayName: guestDisplayName, }, users: [ @@ -723,7 +733,7 @@ describe('realtime user status adapter', () => { from: 0, to: 0, }, - styleIndex: 0, + styleIndex: clientLoggedIn1StyleIndex, username: clientLoggedIn1Username, displayName: clientLoggedIn1Username, }, @@ -733,7 +743,7 @@ describe('realtime user status adapter', () => { from: 0, to: 0, }, - styleIndex: 1, + styleIndex: clientLoggedIn2StyleIndex, username: clientLoggedIn2Username, displayName: clientLoggedIn2Username, }, @@ -741,7 +751,7 @@ describe('realtime user status adapter', () => { active: true, cursor: null, displayName: clientDeclineUsername, - styleIndex: 4, + styleIndex: clientDeclineStyleIndex, username: clientDeclineUsername, }, ], @@ -753,7 +763,7 @@ describe('realtime user status adapter', () => { type: MessageType.REALTIME_USER_STATE_SET, payload: { ownUser: { - styleIndex: 4, + styleIndex: clientDeclineStyleIndex, displayName: clientDeclineUsername, }, users: [ @@ -763,7 +773,7 @@ describe('realtime user status adapter', () => { from: 0, to: 0, }, - styleIndex: 0, + styleIndex: clientLoggedIn1StyleIndex, username: clientLoggedIn1Username, displayName: clientLoggedIn1Username, }, @@ -773,7 +783,7 @@ describe('realtime user status adapter', () => { from: 0, to: 0, }, - styleIndex: 1, + styleIndex: clientLoggedIn2StyleIndex, username: clientLoggedIn2Username, displayName: clientLoggedIn2Username, }, @@ -784,7 +794,7 @@ describe('realtime user status adapter', () => { to: 0, }, displayName: guestDisplayName, - styleIndex: 2, + styleIndex: guestStyleIndex, username: null, }, ], diff --git a/backend/src/realtime/realtime-note/test-utils/mock-connection.ts b/backend/src/realtime/realtime-note/test-utils/mock-connection.ts index f65692894..ca79d2883 100644 --- a/backend/src/realtime/realtime-note/test-utils/mock-connection.ts +++ b/backend/src/realtime/realtime-note/test-utils/mock-connection.ts @@ -37,26 +37,28 @@ export class MockConnectionBuilder { /** * Defines that the user who belongs to the connection is a guest. * - * @param displayName the display name of the guest user + * @param userId the user id of the guest user, not the guestUuid + * @param styleIndex the authorStyle of the mocked user */ - public withGuestUser(displayName: string): this { + public withGuestUser(userId: number, styleIndex = 0): this { + this.userId = userId; this.username = null; - this.displayName = displayName; - this.authorStyle = 2; - this.userId = 1000; + this.displayName = `Guest ${userId}`; + this.authorStyle = styleIndex; return this; } /** * Defines that the user who belongs to this connection is a logged-in user. * - * @param username the username of the mocked user + * @param userId the userId of the mocked user + * @param styleIndex the authorStyle of the mocked user */ - public withLoggedInUser(username: string): this { - this.username = username; - this.displayName = username; - this.userId = 1001; - this.authorStyle = 1; + public withLoggedInUser(userId: number, styleIndex = 0): this { + this.userId = userId; + this.username = `logged-in-${userId}`; + this.displayName = `Logged-in user ${userId}`; + this.authorStyle = styleIndex; return this; } diff --git a/backend/src/realtime/websocket/backend-websocket-adapter.spec.ts b/backend/src/realtime/websocket/backend-websocket-adapter.spec.ts index 0bda5122d..b67b1d10e 100644 --- a/backend/src/realtime/websocket/backend-websocket-adapter.spec.ts +++ b/backend/src/realtime/websocket/backend-websocket-adapter.spec.ts @@ -6,6 +6,7 @@ import { ConnectionState, DisconnectReason, + DisconnectReasonCode, Message, MessageType, } from '@hedgedoc/commons'; @@ -14,7 +15,7 @@ import WebSocket, { CloseEvent, MessageEvent } from 'ws'; import { BackendWebsocketAdapter } from './backend-websocket-adapter'; -describe('backend websocket adapter', () => { +describe('Backend websocket adapter', () => { let sut: BackendWebsocketAdapter; let mockedSocket: WebSocket; @@ -34,7 +35,11 @@ describe('backend websocket adapter', () => { }); it('can bind and unbind the close event', () => { - const handler = jest.fn((reason?: DisconnectReason) => console.log(reason)); + const handler = jest.fn((reason?: DisconnectReasonCode) => + console.log( + DisconnectReason[reason ?? DisconnectReasonCode.INTERNAL_ERROR], + ), + ); let modifiedHandler: (event: CloseEvent) => void = jest.fn(); jest @@ -46,10 +51,12 @@ describe('backend websocket adapter', () => { const unbind = sut.bindOnCloseEvent(handler); modifiedHandler( - Mock.of({ code: DisconnectReason.USER_NOT_PERMITTED }), + Mock.of({ code: DisconnectReasonCode.USER_NOT_PERMITTED }), ); expect(handler).toHaveBeenCalledTimes(1); - expect(handler).toHaveBeenCalledWith(DisconnectReason.USER_NOT_PERMITTED); + expect(handler).toHaveBeenCalledWith( + DisconnectReasonCode.USER_NOT_PERMITTED, + ); unbind(); diff --git a/backend/src/realtime/websocket/utils/extract-note-id-from-request-url.spec.ts b/backend/src/realtime/websocket/utils/extract-note-id-from-request-url.spec.ts index c96592789..bc80444ed 100644 --- a/backend/src/realtime/websocket/utils/extract-note-id-from-request-url.spec.ts +++ b/backend/src/realtime/websocket/utils/extract-note-id-from-request-url.spec.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -16,7 +16,7 @@ describe('extract note id from path', () => { it('can find a note id', () => { const mockedRequest = Mock.of({ - url: '/realtime?noteId=somethingsomething', + url: '/realtime?noteAlias=somethingsomething', }); expect(extractNoteAliasFromRequestUrl(mockedRequest)).toBe( 'somethingsomething', @@ -25,14 +25,14 @@ describe('extract note id from path', () => { it('fails if no note id is present', () => { const mockedRequest = Mock.of({ - url: '/realtime?nöteId=somethingsomething', + url: '/realtime?nöteAlias=somethingsomething', }); expect(() => extractNoteAliasFromRequestUrl(mockedRequest)).toThrow(); }); it('fails if note id is empty', () => { const mockedRequest = Mock.of({ - url: '/realtime?noteId=', + url: '/realtime?noteAlias=', }); expect(() => extractNoteAliasFromRequestUrl(mockedRequest)).toThrow(); }); diff --git a/backend/src/realtime/websocket/websocket.gateway.spec.ts b/backend/src/realtime/websocket/websocket.gateway.spec.ts index bf57837ea..a7bccb5d4 100644 --- a/backend/src/realtime/websocket/websocket.gateway.spec.ts +++ b/backend/src/realtime/websocket/websocket.gateway.spec.ts @@ -3,48 +3,37 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ +import { PermissionLevel } from '@hedgedoc/commons'; +import { FieldNameUser } from '@hedgedoc/database'; import { Optional } from '@mrdrogdrog/optional'; +import { Provider } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { EventEmitterModule } from '@nestjs/event-emitter'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { SchedulerRegistry } from '@nestjs/schedule'; 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 { AliasModule } from '../../alias/alias.module'; -import { ApiToken } from '../../api-token/api-token.entity'; -import { Identity } from '../../auth/identity.entity'; -import { Author } from '../../authors/author.entity'; +import { AliasService } from '../../alias/alias.service'; 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 { User } from '../../database/user.entity'; -import { eventModuleConfig } from '../../events'; -import { Group } from '../../groups/group.entity'; +import { mockKnexDb } from '../../database/mock/provider'; +import { NotInDBError } from '../../errors/errors'; +import { NoteEventMap } from '../../events'; +import { GroupsService } from '../../groups/groups.service'; import { LoggerModule } from '../../logger/logger.module'; -import { Alias } from '../../notes/aliases.entity'; -import { Note } from '../../notes/note.entity'; import { NoteService } from '../../notes/note.service'; -import { Tag } from '../../notes/tag.entity'; -import { NoteGroupPermission } from '../../permissions/note-group-permission.entity'; -import { NotePermissionLevel } from '../../permissions/note-permission.enum'; -import { NoteUserPermission } from '../../permissions/note-user-permission.entity'; import { PermissionService } from '../../permissions/permission.service'; -import { PermissionsModule } from '../../permissions/permissions.module'; -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 { RevisionsService } from '../../revisions/revisions.service'; import { SessionService } from '../../sessions/session.service'; -import { UsersModule } from '../../users/users.module'; import { UsersService } from '../../users/users.service'; 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 { RealtimeNoteStore } from '../realtime-note/realtime-note-store'; import { RealtimeNoteService } from '../realtime-note/realtime-note.service'; import * as extractNoteIdFromRequestUrlModule from './utils/extract-note-id-from-request-url'; import { WebsocketGateway } from './websocket.gateway'; @@ -67,46 +56,46 @@ describe('Websocket gateway', () => { const mockedSessionIdWithUser = 'mockedSessionIdWithUser'; const mockedValidUrl = 'mockedValidUrl'; const mockedValidGuestUrl = 'mockedValidGuestUrl'; - const mockedValidNoteId = 'mockedValidNoteId'; - const mockedValidGuestNoteId = 'mockedValidGuestNoteId'; + const mockedValidNoteAlias = 'mockedValidNoteId'; + const mockedValidNoteId = 20; + const mockedValidGuestNoteAlias = 'mockedValidGuestNoteId'; + const mockedValidGuestNoteId = 21; let sessionExistsForUser = true; let noteExistsForNoteId = true; - let userExistsForUsername = true; + let userExistsForUserId = true; let userHasReadPermissions = true; + let knexProvider: Provider; + beforeEach(async () => { + [, knexProvider] = mockKnexDb(); jest.resetAllMocks(); jest.resetModules(); sessionExistsForUser = true; noteExistsForNoteId = true; - userExistsForUsername = true; + userExistsForUserId = 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, - }, + knexProvider, + NoteService, + AliasService, + GroupsService, + RevisionsService, + RealtimeNoteService, + UsersService, + PermissionService, + SessionService, + RealtimeNoteStore, + EventEmitter2, + SchedulerRegistry, ], imports: [ LoggerModule, - AliasModule, - PermissionsModule, - RealtimeNoteModule, - UsersModule, - SessionModule, ConfigModule.forRoot({ isGlobal: true, load: [ @@ -116,36 +105,8 @@ describe('Websocket gateway', () => { 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(); + }).compile(); gateway = module.get(WebsocketGateway); sessionService = module.get(SessionService); @@ -165,57 +126,61 @@ describe('Websocket gateway', () => { ), ); - const mockUsername: string = 'mock-username'; + const mockUsername = 'Testy'; + const mockUserId = 42; + const mockAuthorStyle = 1; + const mockDisplayName = 'Testy McTestface'; + jest - .spyOn(sessionService, 'fetchUsernameForSessionId') + .spyOn(sessionService, 'getUserIdForSessionId') .mockImplementation((sessionId: string) => sessionExistsForUser && sessionId === mockedSessionIdWithUser - ? Promise.resolve(mockUsername) + ? Promise.resolve(mockUserId) : Promise.reject('no user for session id found'), ); - const mockUser = Mock.of({ username: mockUsername }); jest - .spyOn(usersService, 'getUserByUsername') - .mockImplementation( - (username: string): Promise => - userExistsForUsername && username === mockUsername - ? Promise.resolve(mockUser) - : Promise.reject('user not found'), - ); + .spyOn(usersService, 'getUserById') + .mockImplementation((userId: number) => { + if (userExistsForUserId && userId === mockUserId) { + return Promise.resolve({ + [FieldNameUser.id]: mockUserId, + [FieldNameUser.username]: mockUsername, + [FieldNameUser.displayName]: mockDisplayName, + [FieldNameUser.authorStyle]: mockAuthorStyle, + [FieldNameUser.email]: null, + [FieldNameUser.photoUrl]: null, + [FieldNameUser.guestUuid]: null, + [FieldNameUser.createdAt]: new Date().toISOString(), + }); + } else { + throw new NotInDBError('User not found'); + } + }); jest - .spyOn(extractNoteIdFromRequestUrlModule, 'extractNoteIdFromRequestUrl') + .spyOn( + extractNoteIdFromRequestUrlModule, + 'extractNoteAliasFromRequestUrl', + ) .mockImplementation((request: IncomingMessage): string => { if (request.url === mockedValidUrl) { - return mockedValidNoteId; + return mockedValidNoteAlias; } else if (request.url === mockedValidGuestUrl) { - return mockedValidGuestNoteId; + return mockedValidGuestNoteAlias; } else { throw new Error('no valid note id found'); } }); - const mockedNote = Mock.of({ - id: 4711, - owner: Promise.resolve(mockUser), - userPermissions: Promise.resolve([]), - groupPermissions: Promise.resolve([]), - }); - const mockedGuestNote = Mock.of({ - id: 1235, - owner: Promise.resolve(null), - userPermissions: Promise.resolve([]), - groupPermissions: Promise.resolve([]), - }); jest .spyOn(notesService, 'getNoteIdByAlias') - .mockImplementation((noteId: string) => { - if (noteExistsForNoteId && noteId === mockedValidNoteId) { - return Promise.resolve(mockedNote); + .mockImplementation((noteAlias: string) => { + if (noteExistsForNoteId && noteAlias === mockedValidNoteAlias) { + return Promise.resolve(mockedValidNoteId); } - if (noteId === mockedValidGuestNoteId) { - return Promise.resolve(mockedGuestNote); + if (noteAlias === mockedValidGuestNoteAlias) { + return Promise.resolve(mockedValidGuestNoteId); } else { return Promise.reject('no note found'); } @@ -224,13 +189,13 @@ describe('Websocket gateway', () => { jest .spyOn(permissionsService, 'determinePermission') .mockImplementation( - async (user: User | null, note: Note): Promise => - (user === mockUser && - note === mockedNote && + async (userId: number, noteId: number): Promise => + (userId === mockUserId && + noteId === mockedValidNoteId && userHasReadPermissions) || - (user === null && note === mockedGuestNote) - ? NotePermissionLevel.READ - : NotePermissionLevel.DENY, + (userId === null && noteId === mockedValidGuestNoteId) + ? PermissionLevel.READ + : PermissionLevel.DENY, ); const mockedRealtimeNote = Mock.of({ @@ -257,22 +222,6 @@ describe('Websocket gateway', () => { addClientSpy = jest.spyOn(mockedRealtimeNote, 'addClient'); }); - it('adds a valid connection request without a session', async () => { - const request = Mock.of({ - 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({ socket: { @@ -330,7 +279,7 @@ describe('Websocket gateway', () => { }); it("closes the connection if user doesn't exist for username", async () => { - userExistsForUsername = false; + userExistsForUserId = false; const request = Mock.of({ socket: { diff --git a/backend/src/revisions/revisions.service.spec.ts b/backend/src/revisions/revisions.service.spec.ts index 696758a67..207b63a4a 100644 --- a/backend/src/revisions/revisions.service.spec.ts +++ b/backend/src/revisions/revisions.service.spec.ts @@ -3,3 +3,604 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ +import { + FieldNameAlias, + FieldNameAuthorshipInfo, + FieldNameRevision, + FieldNameRevisionTag, + FieldNameUser, + NoteType, + TableAlias, + TableAuthorshipInfo, + TableRevision, + TableRevisionTag, + TableUser, +} from '@hedgedoc/database'; +import { Provider } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import * as diffModule from 'diff'; +import type { Tracker } from 'knex-mock-client'; +import * as uuidModule from 'uuid'; + +import { AliasService } from '../alias/alias.service'; +import appConfigMock from '../config/mock/app.config.mock'; +import { + createDefaultMockNoteConfig, + registerNoteConfig, +} from '../config/mock/note.config.mock'; +import { NoteConfig } from '../config/note.config'; +import { expectBindings } from '../database/mock/expect-bindings'; +import { + mockDelete, + mockInsert, + mockSelect, + mockUpdate, +} from '../database/mock/mock-queries'; +import { mockKnexDb } from '../database/mock/provider'; +import { GenericDBError, NotInDBError } from '../errors/errors'; +import { LoggerModule } from '../logger/logger.module'; +import { RevisionsService } from './revisions.service'; +import * as utilsExtractRevisionMetadataFromContentModule from './utils/extract-revision-metadata-from-content'; + +jest.mock('diff'); +jest.mock('uuid'); +jest.mock('./utils/extract-revision-metadata-from-content'); + +describe('RevisionsService', () => { + let service: RevisionsService; + let aliasService: AliasService; + let tracker: Tracker; + let knexProvider: Provider; + let noteConfig: NoteConfig; + + const mockNoteId = 42; + const mockPrimaryAlias = 'mock-note'; + const mockCreatedAt1 = '2012-05-25T09:08:34.123'; + const mockCreatedAt2 = '2025-09-23T18:04:08.957'; + const mockRevisionUuid1 = '84e72936-a851-4c4a-a729-36a851bc4a01'; + const mockRevisionUuid2 = '8573c04f-9e71-4b8f-b3c0-4f9e71db8ffd'; + const mockContent1 = 'Revision content'; + const mockContent2 = 'Revision content 2'; + const mockPatch = '---this-is-a-mock-patch---'; + const mockTitle = 'Note Title'; + const mockDescription = 'Note Description'; + const mockUsername = 'username'; + const mockGuestUuid = '9d1a0deb-fed1-45f0-9a0d-ebfed1e5f01f'; + const mockTag1 = 'tag1'; + const mockTag2 = 'tag2'; + + jest.mock('diff'); + + beforeAll(async () => { + noteConfig = createDefaultMockNoteConfig(); + [tracker, knexProvider] = mockKnexDb(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [RevisionsService, knexProvider, AliasService], + imports: [ + LoggerModule, + await ConfigModule.forRoot({ + isGlobal: true, + load: [appConfigMock, registerNoteConfig(noteConfig)], + }), + ], + }).compile(); + + service = module.get(RevisionsService); + aliasService = module.get(AliasService); + }); + + afterEach(() => { + tracker.reset(); + jest.resetAllMocks(); + jest.resetModules(); + }); + + it('getAllRevisionMetadataDto', async () => { + mockSelect( + tracker, + [ + `${TableRevision}"."${FieldNameRevision.uuid}`, + `${TableRevision}"."${FieldNameRevision.createdAt}`, + `${TableRevision}"."${FieldNameRevision.description}`, + `${TableRevision}"."${FieldNameRevision.content}`, + `${TableRevision}"."${FieldNameRevision.title}`, + `${TableUser}"."${FieldNameUser.username}`, + `${TableUser}"."${FieldNameUser.guestUuid}`, + `${TableRevisionTag}"."${FieldNameRevisionTag.tag}`, + ], + TableRevision, + FieldNameRevision.noteId, + [ + { + [FieldNameRevision.uuid]: mockRevisionUuid1, + [FieldNameRevision.createdAt]: mockCreatedAt1, + [FieldNameRevision.content]: mockContent1, + [FieldNameRevision.title]: mockTitle, + [FieldNameRevision.description]: mockDescription, + [FieldNameUser.username]: mockUsername, + [FieldNameUser.guestUuid]: null, + [FieldNameRevisionTag.tag]: mockTag1, + }, + ], + [ + { + joinTable: TableRevisionTag, + keyLeft: FieldNameRevisionTag.revisionUuid, + keyRight: FieldNameRevision.uuid, + }, + { + joinTable: TableAuthorshipInfo, + keyLeft: FieldNameAuthorshipInfo.revisionUuid, + keyRight: FieldNameRevision.uuid, + }, + { + joinTable: TableUser, + otherTable: TableAuthorshipInfo, + keyLeft: FieldNameUser.id, + keyRight: FieldNameAuthorshipInfo.authorId, + }, + ], + ); + const results = await service.getAllRevisionMetadataDto(mockNoteId); + expect(results).toHaveLength(1); + expect(results[0].uuid).toBe(mockRevisionUuid1); + expect(results[0].createdAt).toBe(mockCreatedAt1); + expect(results[0].length).toBe(mockContent1.length); + expect(results[0].authorUsernames).toHaveLength(1); + expect(results[0].authorUsernames[0]).toBe(mockUsername); + expect(results[0].authorGuestUuids).toHaveLength(0); + expect(results[0].title).toBe(mockTitle); + expect(results[0].description).toBe(mockDescription); + expect(results[0].tags).toHaveLength(1); + expect(results[0].tags[0]).toBe(mockTag1); + expectBindings(tracker, 'select', [[mockNoteId]]); + }); + + describe('purgeRevisions', () => { + let spyOnGetPrimaryAlias: jest.SpyInstance; + // eslint-disable-next-line func-style + const buildMockSelect = (returnValues: unknown) => { + mockSelect( + tracker, + [], + TableRevision, + [FieldNameRevision.noteId], + returnValues, + ); + }; + + beforeEach(() => { + spyOnGetPrimaryAlias = jest.spyOn( + aliasService, + 'getPrimaryAliasByNoteId', + ); + }); + + it('returns immediately, when there are no revisions', async () => { + buildMockSelect([]); + await service.purgeRevisions(mockNoteId); + expect(spyOnGetPrimaryAlias).toHaveBeenCalledTimes(0); + expectBindings(tracker, 'select', [[mockNoteId]]); + expectBindings(tracker, 'delete', [[]], false, true); + expectBindings(tracker, 'update', [[]], false, true); + }); + it('correctly purges all, but the last revisions', async () => { + // The typecast is required since jest does not see all signatures of the mocked function + // and assumes using the first signature, which is wrong here and leads to a type error + ( + jest.spyOn(diffModule, 'createPatch') as unknown as jest.MockedFunction< + (a: string, b: string, c: string) => string + > + ).mockImplementation((a, b, c) => `${mockPatch}\n${a}\n${b}\n${c}`); + buildMockSelect([ + { + [FieldNameRevision.uuid]: mockRevisionUuid2, + [FieldNameRevision.noteId]: mockNoteId, + [FieldNameRevision.content]: mockContent2, + }, + { + [FieldNameRevision.uuid]: mockRevisionUuid1, + [FieldNameRevision.noteId]: mockNoteId, + [FieldNameRevision.content]: mockContent1, + }, + ]); + mockDelete(tracker, TableRevision, [FieldNameRevision.uuid], 1); + mockUpdate( + tracker, + TableRevision, + [FieldNameRevision.patch], + FieldNameRevision.uuid, + 1, + ); + spyOnGetPrimaryAlias.mockResolvedValueOnce(mockPrimaryAlias); + await service.purgeRevisions(mockNoteId); + expectBindings(tracker, 'select', [[mockNoteId]]); + expectBindings(tracker, 'delete', [[mockRevisionUuid1]]); + expectBindings(tracker, 'update', [ + [ + `${mockPatch}\n${mockPrimaryAlias}\n\n${mockContent2}`, + mockRevisionUuid2, + ], + ]); + }); + }); + + describe('getRevisionDto', () => { + it('throws a NotInDBError when revision is not found', async () => { + mockSelect( + tracker, + [ + FieldNameRevision.uuid, + FieldNameRevision.createdAt, + FieldNameRevision.description, + FieldNameRevision.content, + FieldNameRevision.title, + FieldNameRevision.patch, + ], + TableRevision, + FieldNameRevision.uuid, + [], + ); + await expect(service.getRevisionDto(mockRevisionUuid1)).rejects.toThrow( + NotInDBError, + ); + expectBindings(tracker, 'select', [[mockRevisionUuid1]], true); + }); + + it('correctly returns the fetched revision', async () => { + mockSelect( + tracker, + [ + FieldNameRevision.uuid, + FieldNameRevision.createdAt, + FieldNameRevision.description, + FieldNameRevision.content, + FieldNameRevision.title, + FieldNameRevision.patch, + ], + TableRevision, + FieldNameRevision.uuid, + [ + { + [FieldNameRevision.uuid]: mockRevisionUuid1, + [FieldNameRevision.noteId]: mockNoteId, + [FieldNameRevision.patch]: mockPatch, + [FieldNameRevision.content]: mockContent1, + [FieldNameRevision.yjsStateVector]: null, + [FieldNameRevision.noteType]: NoteType.DOCUMENT, + [FieldNameRevision.title]: mockTitle, + [FieldNameRevision.description]: mockDescription, + [FieldNameRevision.createdAt]: mockCreatedAt1, + }, + ], + ); + const result = await service.getRevisionDto(mockRevisionUuid1); + expect(result).toStrictEqual({ + uuid: mockRevisionUuid1, + content: mockContent1, + length: mockContent1.length, + createdAt: mockCreatedAt1, + title: mockTitle, + description: mockDescription, + patch: mockPatch, + }); + expectBindings(tracker, 'select', [[mockRevisionUuid1]], true); + }); + }); + + describe('getLatestRevision', () => { + it('throws a NotInDBError when no revisions are found for the note', async () => { + mockSelect(tracker, [], TableRevision, FieldNameRevision.noteId, []); + await expect(service.getLatestRevision(mockNoteId)).rejects.toThrow( + NotInDBError, + ); + expectBindings(tracker, 'select', [[mockNoteId]], true); + }); + + it('correctly returns the last revision', async () => { + const mockRevision1 = { + [FieldNameRevision.uuid]: mockRevisionUuid1, + [FieldNameRevision.noteId]: mockNoteId, + [FieldNameRevision.patch]: mockPatch, + [FieldNameRevision.content]: mockContent1, + [FieldNameRevision.yjsStateVector]: null, + [FieldNameRevision.noteType]: NoteType.DOCUMENT, + [FieldNameRevision.title]: mockTitle, + [FieldNameRevision.description]: mockDescription, + [FieldNameRevision.createdAt]: mockCreatedAt1, + }; + const mockRevision2 = structuredClone(mockRevision1); + mockRevision2[FieldNameRevision.uuid] = mockRevisionUuid2; + mockRevision2[FieldNameRevision.createdAt] = mockCreatedAt2; + mockSelect(tracker, [], TableRevision, FieldNameRevision.noteId, [ + mockRevision2, + mockRevision1, + ]); + const result = await service.getLatestRevision(mockNoteId); + expect(result).toStrictEqual(mockRevision2); + expectBindings(tracker, 'select', [[mockNoteId]], true); + }); + }); + + it('getRevisionUserInfo', async () => { + mockSelect( + tracker, + [ + `${TableUser}"."${FieldNameUser.username}`, + `${TableUser}"."${FieldNameUser.guestUuid}`, + `${TableAuthorshipInfo}"."${FieldNameAuthorshipInfo.createdAt}`, + `${TableAuthorshipInfo}"."${FieldNameAuthorshipInfo.authorId}`, + ], + TableAuthorshipInfo, + FieldNameAuthorshipInfo.revisionUuid, + [ + { + [FieldNameUser.username]: mockUsername, + [FieldNameUser.guestUuid]: null, + [FieldNameAuthorshipInfo.createdAt]: mockCreatedAt1, + [FieldNameAuthorshipInfo.authorId]: 1, + }, + { + [FieldNameUser.username]: null, + [FieldNameUser.guestUuid]: mockGuestUuid, + [FieldNameAuthorshipInfo.createdAt]: mockCreatedAt2, + [FieldNameAuthorshipInfo.authorId]: 2, + }, + ], + [ + { + joinTable: TableUser, + otherTable: TableAuthorshipInfo, + keyLeft: FieldNameUser.id, + keyRight: FieldNameAuthorshipInfo.authorId, + }, + ], + ); + const result = await service.getRevisionUserInfo(mockRevisionUuid1); + expect(result.users).toHaveLength(1); + expect(result.users[0].username).toBe(mockUsername); + expect(result.users[0].createdAt).toBe(mockCreatedAt1); + expect(result.guestUserCount).toBe(1); + expectBindings(tracker, 'select', [[mockRevisionUuid1]]); + }); + + describe('createRevision', () => { + const lastRevision = { + [FieldNameRevision.uuid]: mockRevisionUuid1, + [FieldNameRevision.noteId]: mockNoteId, + [FieldNameRevision.patch]: mockPatch, + [FieldNameRevision.content]: mockContent1, + [FieldNameRevision.yjsStateVector]: null, + [FieldNameRevision.noteType]: NoteType.DOCUMENT, + [FieldNameRevision.title]: mockTitle, + [FieldNameRevision.description]: mockDescription, + [FieldNameRevision.createdAt]: mockCreatedAt1, + }; + + beforeEach(() => { + jest.spyOn(service, 'getLatestRevision').mockResolvedValue(lastRevision); + jest + .spyOn(aliasService, 'getPrimaryAliasByNoteId') + .mockResolvedValue(mockPrimaryAlias); + jest + .spyOn( + utilsExtractRevisionMetadataFromContentModule, + 'extractRevisionMetadataFromContent', + ) + .mockReturnValue({ + title: mockTitle, + description: mockDescription, + tags: [], + noteType: NoteType.DOCUMENT, + }); + // This wrong typecast is required since TypeScript does not see that + // `uuid.v7()` returns a string or a Uint8Array based on the given options + jest + .spyOn(uuidModule, 'v7') + .mockReturnValue(mockRevisionUuid1 as unknown as Uint8Array); + // The typecast is required since jest does not see all signatures of the mocked function + // and assumes using the first signature, which is wrong here and leads to a type error + ( + jest.spyOn(diffModule, 'createPatch') as unknown as jest.MockedFunction< + (a: string, b: string, c: string) => string + > + ).mockImplementation((a, b, c) => `${mockPatch}\n${a}\n${b}\n${c}`); + }); + + it('returns undefined when content did not change', async () => { + jest.spyOn(service, 'getLatestRevision').mockResolvedValue(lastRevision); + await expect( + service.createRevision(mockNoteId, mockContent1, false), + ).resolves.toBeUndefined(); + }); + + it('uses a correct diff when an old revision is present', async () => { + mockInsert( + tracker, + TableRevision, + [ + FieldNameRevision.content, + FieldNameRevision.description, + FieldNameRevision.noteId, + FieldNameRevision.noteType, + FieldNameRevision.patch, + FieldNameRevision.title, + FieldNameRevision.uuid, + FieldNameRevision.yjsStateVector, + ], + [mockRevisionUuid1], + ); + await service.createRevision(mockNoteId, mockContent2, false); + expectBindings(tracker, 'insert', [ + [ + mockContent2, + mockDescription, + mockNoteId, + NoteType.DOCUMENT, + `${mockPatch}\n${mockPrimaryAlias}\n${mockContent1}\n${mockContent2}`, + mockTitle, + mockRevisionUuid1, + null, + ], + ]); + }); + it('creates a correct revision when no old revisions are present', async () => { + mockInsert( + tracker, + TableRevision, + [ + FieldNameRevision.content, + FieldNameRevision.description, + FieldNameRevision.noteId, + FieldNameRevision.noteType, + FieldNameRevision.patch, + FieldNameRevision.title, + FieldNameRevision.uuid, + FieldNameRevision.yjsStateVector, + ], + [mockRevisionUuid1], + ); + await service.createRevision(mockNoteId, mockContent1, true); + expectBindings(tracker, 'insert', [ + [ + mockContent1, + mockDescription, + mockNoteId, + NoteType.DOCUMENT, + `${mockPatch}\n${mockPrimaryAlias}\n\n${mockContent1}`, + mockTitle, + mockRevisionUuid1, + null, + ], + ]); + }); + it('throws a GenericDBError when the revision could not be inserted', async () => { + mockInsert( + tracker, + TableRevision, + [ + FieldNameRevision.content, + FieldNameRevision.description, + FieldNameRevision.noteId, + FieldNameRevision.noteType, + FieldNameRevision.patch, + FieldNameRevision.title, + FieldNameRevision.uuid, + FieldNameRevision.yjsStateVector, + ], + [], + ); + await expect( + service.createRevision(mockNoteId, mockContent1, true), + ).rejects.toThrow(GenericDBError); + }); + }); + + it('getTagsByRevisionUuid correctly returns tags', async () => { + mockSelect( + tracker, + [FieldNameRevisionTag.tag], + TableRevisionTag, + FieldNameRevisionTag.revisionUuid, + [ + { + [FieldNameRevisionTag.tag]: mockTag1, + }, + { + [FieldNameRevisionTag.tag]: mockTag2, + }, + ], + ); + const results = await service.getTagsByRevisionUuid(mockRevisionUuid1); + expect(results).toHaveLength(2); + expect(results[0]).toBe(mockTag1); + expect(results[1]).toBe(mockTag2); + expectBindings(tracker, 'select', [[mockRevisionUuid1]]); + }); + + describe('removeOldRevisions', () => { + const now = 1758653425; + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(now); + noteConfig.revisionRetentionDays = 1; + }); + afterEach(() => { + jest.useRealTimers(); + }); + it("doesn't run if revisionRetentionDays is set to <= 0", async () => { + noteConfig.revisionRetentionDays = 0; + await service.removeOldRevisions(); + expectBindings(tracker, 'delete', [[]], false, true); + }); + it("doesn't update if no revisions were removed", async () => { + mockDelete(tracker, TableRevision, [FieldNameRevision.createdAt], []); + await service.removeOldRevisions(); + expectBindings(tracker, 'delete', [[now - 24 * 60 * 60 * 1000]]); + expectBindings(tracker, 'select', [], false, true); + }); + it('updates notes if revisions were deleted', async () => { + // The typecast is required since jest does not see all signatures of the mocked function + // and assumes using the first signature, which is wrong here and leads to a type error + ( + jest.spyOn(diffModule, 'createPatch') as unknown as jest.MockedFunction< + (a: string, b: string, c: string) => string + > + ).mockImplementation((a, b, c) => `${mockPatch}\n${a}\n${b}\n${c}`); + mockDelete( + tracker, + TableRevision, + [FieldNameRevision.createdAt], + [ + { + [FieldNameRevision.noteId]: mockNoteId, + }, + ], + ); + mockSelect( + tracker, + [ + FieldNameRevision.uuid, + FieldNameRevision.noteId, + FieldNameRevision.content, + FieldNameAlias.alias, + ], + TableRevision, + [FieldNameRevision.noteId, FieldNameAlias.isPrimary], + [ + { + [FieldNameRevision.uuid]: mockRevisionUuid1, + [FieldNameRevision.noteId]: mockNoteId, + [FieldNameRevision.content]: mockContent1, + [FieldNameAlias.alias]: mockPrimaryAlias, + }, + ], + [ + { + joinTable: TableAlias, + keyLeft: FieldNameAlias.noteId, + keyRight: FieldNameRevision.noteId, + }, + ], + ); + mockUpdate( + tracker, + TableRevision, + [FieldNameRevision.patch], + FieldNameRevision.uuid, + 1, + ); + await service.removeOldRevisions(); + expectBindings(tracker, 'delete', [[now - 24 * 60 * 60 * 1000]]); + expectBindings(tracker, 'select', [[mockNoteId, true]]); + expectBindings(tracker, 'update', [ + [ + `${mockPatch}\n${mockPrimaryAlias}\n\n${mockContent1}`, + mockRevisionUuid1, + ], + ]); + }); + }); +}); diff --git a/backend/src/revisions/revisions.service.ts b/backend/src/revisions/revisions.service.ts index 0df28e9d6..88eb75dd2 100644 --- a/backend/src/revisions/revisions.service.ts +++ b/backend/src/revisions/revisions.service.ts @@ -79,18 +79,18 @@ export class RevisionsService { >(`${TableRevision}.${FieldNameRevision.uuid}`, `${TableRevision}.${FieldNameRevision.createdAt}`, `${TableRevision}.${FieldNameRevision.description}`, `${TableRevision}.${FieldNameRevision.content}`, `${TableRevision}.${FieldNameRevision.title}`, `${TableUser}.${FieldNameUser.username}`, `${TableUser}.${FieldNameUser.guestUuid}`, `${TableRevisionTag}.${FieldNameRevisionTag.tag}`) .join( TableRevisionTag, - `${TableRevision}.${FieldNameRevision.uuid}`, `${TableRevisionTag}.${FieldNameRevisionTag.revisionUuid}`, + `${TableRevision}.${FieldNameRevision.uuid}`, ) .join( TableAuthorshipInfo, - `${TableRevision}.${FieldNameRevision.uuid}`, `${TableAuthorshipInfo}.${FieldNameAuthorshipInfo.revisionUuid}`, + `${TableRevision}.${FieldNameRevision.uuid}`, ) .join( TableUser, - `${TableAuthorshipInfo}.${FieldNameAuthorshipInfo.authorId}`, `${TableUser}.${FieldNameUser.id}`, + `${TableAuthorshipInfo}.${FieldNameAuthorshipInfo.authorId}`, ) .orderBy(`${TableRevision}.${FieldNameRevision.createdAt}`, 'desc') .orderBy(`${TableRevision}.${FieldNameRevision.uuid}`) @@ -167,7 +167,7 @@ export class RevisionsService { .orderBy(FieldNameRevision.createdAt, 'desc'); if (allRevisions.length === 0) { this.logger.debug(`No revisions found for note ${noteId}`); - return []; + return; } const latestRevision = allRevisions[0]; const revisionsToDelete = allRevisions.filter( @@ -269,33 +269,31 @@ export class RevisionsService { transaction?: Knex, ): Promise { const dbActor = transaction ?? this.knex; - const authorUsernamesAndGuestUuids = (await dbActor(TableAuthorshipInfo) + const authorUsernamesAndGuestUuids = await dbActor(TableAuthorshipInfo) .join( TableUser, `${TableAuthorshipInfo}.${FieldNameAuthorshipInfo.authorId}`, `${TableUser}.${FieldNameUser.id}`, ) - .select( - `${TableUser}.${FieldNameUser.username}`, - `${TableUser}.${FieldNameUser.guestUuid}`, - `${TableAuthorshipInfo}.${FieldNameAuthorshipInfo.createdAt}`, - ) - .distinct(`${TableAuthorshipInfo}.${FieldNameAuthorshipInfo.authorId}`) - .where(FieldNameAuthorshipInfo.revisionUuid, revisionUuid)) as { - username: User[FieldNameUser.username]; - guestUuid: User[FieldNameUser.guestUuid]; - createdAt: AuthorshipInfo[FieldNameAuthorshipInfo.createdAt]; - }[]; + .select() + .distinct< + (Pick & + Pick< + AuthorshipInfo, + FieldNameAuthorshipInfo.authorId | FieldNameAuthorshipInfo.createdAt + >)[] + >(`${TableUser}.${FieldNameUser.username}`, `${TableUser}.${FieldNameUser.guestUuid}`, `${TableAuthorshipInfo}.${FieldNameAuthorshipInfo.createdAt}`, `${TableAuthorshipInfo}.${FieldNameAuthorshipInfo.authorId}`) + .where(FieldNameAuthorshipInfo.revisionUuid, revisionUuid); const users: RevisionUserInfo['users'] = []; let guestUserCount = 0; for (const author of authorUsernamesAndGuestUuids) { - if (author.guestUuid !== null) { + if (author[FieldNameUser.guestUuid] !== null) { guestUserCount++; } - if (author.username !== null) { + if (author[FieldNameUser.username] !== null) { users.push({ - username: author.username, - createdAt: author.createdAt, + username: author[FieldNameUser.username], + createdAt: author[FieldNameAuthorshipInfo.createdAt], }); } } @@ -382,13 +380,13 @@ export class RevisionsService { extractRevisionMetadataFromContent(newContent); const revisionIds = await transaction(TableRevision).insert( { - [FieldNameRevision.uuid]: uuidv7(), + [FieldNameRevision.content]: newContent, + [FieldNameRevision.description]: description, [FieldNameRevision.noteId]: noteId, [FieldNameRevision.noteType]: noteType, - [FieldNameRevision.content]: newContent, [FieldNameRevision.patch]: patch, [FieldNameRevision.title]: title, - [FieldNameRevision.description]: description, + [FieldNameRevision.uuid]: uuidv7(), [FieldNameRevision.yjsStateVector]: yjsStateVector ?? null, }, [FieldNameRevision.uuid], diff --git a/backend/src/sessions/keyv-session-store.ts b/backend/src/sessions/keyv-session-store.ts index 57d5fb439..a7076df0c 100644 --- a/backend/src/sessions/keyv-session-store.ts +++ b/backend/src/sessions/keyv-session-store.ts @@ -46,7 +46,11 @@ export class KeyvSessionStore extends Store { .catch((error: Error) => callback(error)); } - set(sid: string, session: T, callback: (error?: Error) => void): void { + set( + sid: string, + session: T, + callback: (error?: Error) => void | Promise, + ): void { this.dataStore .set(sid, session) .then(() => callback()) diff --git a/backend/src/sessions/session.module.ts b/backend/src/sessions/session.module.ts index a226ef8ea..c480d76c0 100644 --- a/backend/src/sessions/session.module.ts +++ b/backend/src/sessions/session.module.ts @@ -1,15 +1,16 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { LoggerModule } from '../logger/logger.module'; import { SessionService } from './session.service'; @Module({ - imports: [LoggerModule], + imports: [LoggerModule, ConfigModule], exports: [SessionService], providers: [SessionService], }) diff --git a/backend/src/sessions/session.service.spec.ts b/backend/src/sessions/session.service.spec.ts index cdb462647..86fd8369d 100644 --- a/backend/src/sessions/session.service.spec.ts +++ b/backend/src/sessions/session.service.spec.ts @@ -1,207 +1,124 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ -import * as ConnectTypeormModule from 'connect-typeorm'; -import { TypeormStore } from 'connect-typeorm'; -import * as parseCookieModule from 'cookie'; -import * as cookieSignatureModule from 'cookie-signature'; -import { IncomingMessage } from 'http'; +import { Provider } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { serialize } from 'cookie'; +import { sign } from 'cookie-signature'; +import { Cookie } from 'express-session'; +import type { Tracker } from 'knex-mock-client'; +import { IncomingMessage } from 'node:http'; +import { Socket } from 'node:net'; import { Mock } from 'ts-mockery'; -import { Repository } from 'typeorm'; -import { AppConfig } from '../config/app.config'; -import { AuthConfig } from '../config/auth.config'; -import { DatabaseType } from '../config/database-type.enum'; -import { DatabaseConfig } from '../config/database.config'; -import { Loglevel } from '../config/loglevel.enum'; -import { ConsoleLoggerService } from '../logger/console-logger.service'; +import appConfigMock from '../config/mock/app.config.mock'; +import { + createDefaultMockAuthConfig, + registerAuthConfig, +} from '../config/mock/auth.config.mock'; +import { mockKnexDb } from '../database/mock/provider'; +import { LoggerModule } from '../logger/logger.module'; import { HEDGEDOC_SESSION } from '../utils/session'; -import { Session } from './session.entity'; -import { SessionService, SessionState } from './session.service'; - -jest.mock('cookie'); -jest.mock('cookie-signature'); +import { SessionService } from './session.service'; describe('SessionService', () => { - let mockedTypeormStore: TypeormStore; - let mockedSessionRepository: Repository; - let databaseConfigMock: DatabaseConfig; - let authConfigMock: AuthConfig; - let typeormStoreConstructorMock: jest.SpyInstance; - const mockedExistingSessionId = 'mockedExistingSessionId'; - const mockUsername = 'mock-user'; - const mockSecret = 'mockSecret'; - let sessionService: SessionService; + let service: SessionService; + let tracker: Tracker; + let knexProvider: Provider; + const authConfig = createDefaultMockAuthConfig(); - beforeEach(() => { - jest.resetModules(); - jest.restoreAllMocks(); - const mockedExistingSession = Mock.of({ - username: mockUsername, - }); - mockedTypeormStore = Mock.of({ - connect: jest.fn(() => mockedTypeormStore), - get: jest.fn(((sessionId, callback) => { - if (sessionId === mockedExistingSessionId) { - callback(undefined, mockedExistingSession); - } else { - callback(new Error("Session doesn't exist"), undefined); - } - }) as TypeormStore['get']), - }); - mockedSessionRepository = Mock.of>({}); - databaseConfigMock = Mock.of({ - type: DatabaseType.SQLITE, - }); - authConfigMock = Mock.of({ - session: { - secret: mockSecret, - }, - }); + beforeAll(async () => { + [tracker, knexProvider] = mockKnexDb(); - typeormStoreConstructorMock = jest - .spyOn(ConnectTypeormModule, 'TypeormStore') - .mockReturnValue(mockedTypeormStore); + const module: TestingModule = await Test.createTestingModule({ + providers: [SessionService, knexProvider], + imports: [ + LoggerModule, + await ConfigModule.forRoot({ + isGlobal: true, + load: [appConfigMock, registerAuthConfig(authConfig)], + }), + ], + }).compile(); - sessionService = new SessionService( - new ConsoleLoggerService({ loglevel: Loglevel.DEBUG } as AppConfig), - mockedSessionRepository, - databaseConfigMock, - authConfigMock, - ); + service = module.get(SessionService); }); - it('creates a new TypeormStore on create', () => { - expect(typeormStoreConstructorMock).toHaveBeenCalledWith({ - cleanupLimit: 2, - limitSubquery: true, - }); - expect(mockedTypeormStore.connect).toHaveBeenCalledWith( - mockedSessionRepository, - ); - expect(sessionService.getTypeormStore()).toBe(mockedTypeormStore); + afterEach(() => { + tracker.reset(); }); - it('can fetch a username for an existing session', async () => { - await expect( - sessionService.getUserIdForSessionId(mockedExistingSessionId), - ).resolves.toBe(mockUsername); + it('getSessionStore', () => { + const store = service.getSessionStore(); + expect(store).toBeDefined(); }); - it("can't fetch a username for a non-existing session", async () => { - await expect( - sessionService.getUserIdForSessionId("doesn't exist"), - ).rejects.toThrow(); - }); - - describe('extract verified session id from request', () => { - const validCookieHeader = 'validCookieHeader'; - const validSessionId = 'validSessionId'; - - function mockParseCookieModule(sessionCookieContent: string): void { - jest - .spyOn(parseCookieModule, 'parse') - .mockImplementation((header: string): Record => { - if (header === validCookieHeader) { - return { - [HEDGEDOC_SESSION]: sessionCookieContent, - }; - } else { - return {}; - } - }); - } - - beforeEach(() => { - jest.spyOn(parseCookieModule, 'parse').mockImplementation(() => { - throw new Error('call not expected!'); - }); - jest - .spyOn(cookieSignatureModule, 'unsign') - .mockImplementation((value, secret) => { - if (value.endsWith('.validSignature') && secret === mockSecret) { - return 'decryptedValue'; - } else { - return false; - } - }); - }); - - it('fails if no cookie header is present', () => { - const mockedRequest = Mock.of({ - headers: {}, - }); - expect( - sessionService.extractSessionIdFromRequest(mockedRequest).isEmpty(), - ).toBeTruthy(); - }); - - it("fails if the cookie header isn't valid", () => { - const mockedRequest = Mock.of({ - headers: { cookie: 'no' }, - }); - mockParseCookieModule(`s:anyValidSessionId.validSignature`); - expect( - sessionService.extractSessionIdFromRequest(mockedRequest).isEmpty(), - ).toBeTruthy(); - }); - - it("fails if the hedgedoc session cookie isn't marked as signed", () => { - const mockedRequest = Mock.of({ - headers: { cookie: validCookieHeader }, - }); - mockParseCookieModule('sessionId.validSignature'); - expect(() => - sessionService.extractSessionIdFromRequest(mockedRequest), - ).toThrow( - 'cookie "hedgedoc-session" doesn\'t look like a signed session cookie', + describe('getUserIdForSessionId', () => { + it('returns the correct user id for session id', async () => { + const testSessionId = 'testSessionId'; + const testUserId = 1337; + const sessionsStore = service.getSessionStore(); + sessionsStore.set( + testSessionId, + { + cookie: new Cookie(), + userId: testUserId, + }, + async (error) => { + expect(error).toBeUndefined(); + const result = await service.getUserIdForSessionId(testSessionId); + expect(result).toEqual(testUserId); + }, ); }); + it('returns undefined for non-valid session id', async () => { + const testSessionId = 'non-valid-session-id'; + const result = await service.getUserIdForSessionId(testSessionId); + expect(result).toBeUndefined(); + }); + }); - it("fails if the hedgedoc session cookie doesn't contain a session id", () => { - const mockedRequest = Mock.of({ - headers: { cookie: validCookieHeader }, - }); - mockParseCookieModule('s:.validSignature'); - expect(() => - sessionService.extractSessionIdFromRequest(mockedRequest), - ).toThrow( - 'cookie "hedgedoc-session" doesn\'t look like a signed session cookie', + describe('extractSessionIdFromRequest', () => { + const mockSocket = Mock.of(); + const sessionId = 'testSessionId'; + it('returns empty Optional if no cookie header is set', () => { + const testRequest = new IncomingMessage(mockSocket); + expect(service.extractSessionIdFromRequest(testRequest).isEmpty()).toBe( + true, ); }); - - it("fails if the hedgedoc session cookie doesn't contain a signature", () => { - const mockedRequest = Mock.of({ - headers: { cookie: validCookieHeader }, - }); - mockParseCookieModule('s:sessionId.'); - expect(() => - sessionService.extractSessionIdFromRequest(mockedRequest), - ).toThrow( - 'cookie "hedgedoc-session" doesn\'t look like a signed session cookie', + it('returns empty Optional if cookie is malformed', async () => { + const testRequest = new IncomingMessage(mockSocket); + testRequest.headers.cookie = serialize(HEDGEDOC_SESSION, 'foo', {}); + expect(() => service.extractSessionIdFromRequest(testRequest)).toThrow( + Error, ); }); - - it("fails if the hedgedoc session cookie isn't signed correctly", () => { - const mockedRequest = Mock.of({ - headers: { cookie: validCookieHeader }, - }); - mockParseCookieModule('s:sessionId.invalidSignature'); - expect(() => - sessionService.extractSessionIdFromRequest(mockedRequest), - ).toThrow('signature of cookie "hedgedoc-session" isn\'t valid.'); + it('returns empty Optional if cookie has invalid signature', async () => { + const testRequest = new IncomingMessage(mockSocket); + testRequest.headers.cookie = serialize( + HEDGEDOC_SESSION, + `s:${sessionId}:fakeSignature`, + {}, + ); + expect(() => service.extractSessionIdFromRequest(testRequest)).toThrow( + Error, + ); }); - - it('can extract a session id from a valid request', () => { - const mockedRequest = Mock.of({ - headers: { cookie: validCookieHeader }, - }); - mockParseCookieModule(`s:${validSessionId}.validSignature`); - expect( - sessionService.extractSessionIdFromRequest(mockedRequest).get(), - ).toBe(validSessionId); + it('returns the correct id for session id', () => { + const signature = sign(sessionId, authConfig.session.secret); + const testRequest = new IncomingMessage(mockSocket); + testRequest.headers.cookie = serialize( + HEDGEDOC_SESSION, + `s:${signature}`, + {}, + ); + expect(service.extractSessionIdFromRequest(testRequest).get()).toEqual( + sessionId, + ); }); }); }); diff --git a/backend/src/users/users.service.spec.ts b/backend/src/users/users.service.spec.ts index 696758a67..a850dea60 100644 --- a/backend/src/users/users.service.spec.ts +++ b/backend/src/users/users.service.spec.ts @@ -3,3 +3,218 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ +import { FieldNameUser, TableUser } from '@hedgedoc/database'; +import { BadRequestException, Provider } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import type { Tracker } from 'knex-mock-client'; +import * as uuidModule from 'uuid'; + +import appConfigMock from '../config/mock/app.config.mock'; +import databaseConfigMock from '../config/mock/database.config.mock'; +import { expectBindings } from '../database/mock/expect-bindings'; +import { + mockDelete, + mockInsert, + mockUpdate, +} from '../database/mock/mock-queries'; +import { mockKnexDb } from '../database/mock/provider'; +import { GenericDBError, NotInDBError } from '../errors/errors'; +import { LoggerModule } from '../logger/logger.module'; +import { UsersService } from './users.service'; + +jest.mock('uuid'); + +describe('UsersService', () => { + const username = 'testuser'; + const displayName = 'Test User'; + const email = 'test@example.com'; + const photoUrl = 'https://example.com/photo.png'; + const userId = 123; + const guestUuid = 'a5fdd770-4bff-4baa-bdd7-704bff7baa3c'; + + let service: UsersService; + let tracker: Tracker; + let knexProvider: Provider; + + beforeAll(async () => { + [tracker, knexProvider] = mockKnexDb(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [UsersService, knexProvider], + imports: [ + LoggerModule, + await ConfigModule.forRoot({ + isGlobal: true, + load: [appConfigMock, databaseConfigMock], + }), + ], + }).compile(); + + service = module.get(UsersService); + }); + + afterEach(() => { + tracker.reset(); + }); + + describe('createUser', () => { + it('throws BadRequestException if the username is not valid', async () => { + const wrongUsername = 'not=valid,!$?'; + await expect(() => + service.createUser(wrongUsername, displayName, email, photoUrl), + ).rejects.toThrow(BadRequestException); + }); + + it('inserts a new user', async () => { + mockInsert( + tracker, + TableUser, + [ + FieldNameUser.authorStyle, + FieldNameUser.displayName, + FieldNameUser.email, + FieldNameUser.guestUuid, + FieldNameUser.photoUrl, + FieldNameUser.username, + ], + [{ [FieldNameUser.id]: userId }], + ); + const result = await service.createUser( + username, + displayName, + email, + photoUrl, + ); + expect(result).toBe(userId); + expectBindings(tracker, 'insert', [ + [expect.any(Number), displayName, email, null, photoUrl, username], + ]); + }); + + it('throws GenericDBError if insert fails', async () => { + mockInsert( + tracker, + TableUser, + [ + FieldNameUser.authorStyle, + FieldNameUser.displayName, + FieldNameUser.email, + FieldNameUser.guestUuid, + FieldNameUser.photoUrl, + FieldNameUser.username, + ], + [], + ); + await expect( + service.createUser(username, displayName, email, photoUrl), + ).rejects.toThrow(GenericDBError); + }); + }); + + describe('createGuestUser', () => { + it('inserts a new guest user', async () => { + // This wrong typecast is required since TypeScript does not see that + // `uuid.v4()` returns a string or a Uint8Array based on the given options + jest + .spyOn(uuidModule, 'v4') + .mockReturnValue(guestUuid as unknown as Uint8Array); + mockInsert( + tracker, + TableUser, + [ + FieldNameUser.authorStyle, + FieldNameUser.displayName, + FieldNameUser.email, + FieldNameUser.guestUuid, + FieldNameUser.photoUrl, + FieldNameUser.username, + ], + [{ [FieldNameUser.id]: userId }], + ); + const [uuid, id] = await service.createGuestUser(); + expect(uuid).toBe(guestUuid); + expect(id).toBe(userId); + expectBindings(tracker, 'insert', [ + [ + expect.any(Number), + expect.stringContaining('Guest '), + null, + guestUuid, + null, + null, + ], + ]); + }); + + it('throws GenericDBError if insert fails', async () => { + mockInsert( + tracker, + TableUser, + [ + FieldNameUser.authorStyle, + FieldNameUser.displayName, + FieldNameUser.email, + FieldNameUser.guestUuid, + FieldNameUser.photoUrl, + FieldNameUser.username, + ], + [], + ); + await expect(service.createGuestUser()).rejects.toThrow(GenericDBError); + }); + }); + + describe('deleteUser', () => { + it('deletes a user by id', async () => { + mockDelete(tracker, TableUser, [FieldNameUser.id], 1); + await service.deleteUser(userId); + expectBindings(tracker, 'delete', [[userId]]); + }); + + it('throws NotInDBError if user not found', async () => { + mockDelete(tracker, TableUser, [FieldNameUser.id], 0); + await expect(service.deleteUser(userId)).rejects.toThrow(NotInDBError); + expectBindings(tracker, 'delete', [[userId]]); + }); + }); + + describe('updateUser', () => { + it('updates user fields', async () => { + mockUpdate(tracker, TableUser, [FieldNameUser.id], String(userId), 1); + await service.updateUser( + userId, + 'New Name', + 'new@example.com', + 'https://new.url', + ); + expectBindings(tracker, 'update', [ + [ + { + [FieldNameUser.displayName]: 'New Name', + [FieldNameUser.email]: 'new@example.com', + [FieldNameUser.photoUrl]: 'https://new.url', + }, + userId, + ], + ]); + }); + + it('throws NotInDBError if update fails', async () => { + mockUpdate(tracker, TableUser, [FieldNameUser.id], String(userId), 0); + await expect(service.updateUser(userId, 'New Name')).rejects.toThrow( + NotInDBError, + ); + expectBindings(tracker, 'update', [ + [{ [FieldNameUser.displayName]: 'New Name' }, userId], + ]); + }); + + it('does nothing if no fields are provided', async () => { + const spy = jest.spyOn(service['knex'](TableUser), 'update'); + await service.updateUser(userId); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); + }); +}); diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 400e5e4a7..e0e5da46d 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -55,7 +55,7 @@ export class UsersService { email: string | null, photoUrl: string | null, transaction?: Knex, - ): Promise { + ): Promise { if (!REGEX_USERNAME.test(username)) { throw new BadRequestException( `The username '${username}' is not a valid username.`, diff --git a/backend/src/utils/password.spec.ts b/backend/src/utils/password.spec.ts index 2d3da5a3c..67c48e42f 100644 --- a/backend/src/utils/password.spec.ts +++ b/backend/src/utils/password.spec.ts @@ -28,7 +28,7 @@ describe('hashPassword', () => { const regexArgon2 = /^\$argon2id\$v=19\$m=19456,t=2,p=1\$[\w+./]{22}\$[\w+./]{43}$/; const hash = await hashPassword(testPassword); - expect(regexArgon2.test(hash)).toBeTruthy(); + expect(regexArgon2.test(hash)).toBe(true); }); it('calls argon2.hash with the correct parameters', async () => { const spy = jest.spyOn(argon2, 'hash'); @@ -44,13 +44,13 @@ describe('hashPassword', () => { describe('checkPassword', () => { it("is returning true if the inputs are a plaintext password and it's hashed version", async () => { await checkPassword(testPassword, hashOfTestPassword).then((result) => - expect(result).toBeTruthy(), + expect(result).toBe(true), ); }); it('fails, if password is non-matching', async () => { const password = 'anotherTestPassword'; await checkPassword(password, hashOfTestPassword).then((result) => - expect(result).toBeFalsy(), + expect(result).toBe(false), ); }); it('calls argon2.verify with the correct parameters', async () => { @@ -63,11 +63,11 @@ describe('checkPassword', () => { const hash = '$argon2id$v=19$m=19456,t=2,p=1$4aBLKxd7MqYQqf/th835yQ$iUMe+HHphn8B8q6gQ3IPL2k1+Bdbb505r7LuqZIMTjg'; await checkPassword(password, hash).then((result) => - expect(result).toBeTruthy(), + expect(result).toBe(true), ); const password2 = 'a'.repeat(73); await checkPassword(password2, hash).then((result) => - expect(result).toBeFalsy(), + expect(result).toBe(false), ); }); }); diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/frontend-websocket-adapter.spec.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/frontend-websocket-adapter.spec.ts index 88cc547f8..3080badf9 100644 --- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/frontend-websocket-adapter.spec.ts +++ b/frontend/src/components/editor-page/editor-pane/hooks/yjs/frontend-websocket-adapter.spec.ts @@ -5,7 +5,7 @@ */ import { FrontendWebsocketAdapter } from './frontend-websocket-adapter' import type { Message } from '@hedgedoc/commons' -import { ConnectionState, DisconnectReason, MessageType } from '@hedgedoc/commons' +import { ConnectionState, DisconnectReasonCode, MessageType } from '@hedgedoc/commons' import { Mock } from 'ts-mockery' describe('frontend websocket', () => { @@ -34,7 +34,7 @@ describe('frontend websocket', () => { it('can bind and unbind the close event', () => { mockSocket() - const handler = jest.fn((reason?: DisconnectReason) => console.log(reason)) + const handler = jest.fn((reason?: DisconnectReasonCode) => console.log(reason)) let modifiedHandler: EventListenerOrEventListenerObject = jest.fn() @@ -44,9 +44,9 @@ describe('frontend websocket', () => { const unbind = adapter.bindOnCloseEvent(handler) - modifiedHandler(Mock.of({ code: DisconnectReason.USER_NOT_PERMITTED })) + modifiedHandler(Mock.of({ code: DisconnectReasonCode.USER_NOT_PERMITTED })) expect(handler).toHaveBeenCalledTimes(1) - expect(handler).toHaveBeenCalledWith(DisconnectReason.USER_NOT_PERMITTED) + expect(handler).toHaveBeenCalledWith(DisconnectReasonCode.USER_NOT_PERMITTED) unbind() diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-realtime-connection.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-realtime-connection.ts index a461b5ac0..0c843d842 100644 --- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-realtime-connection.ts +++ b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-realtime-connection.ts @@ -10,7 +10,7 @@ import { Logger } from '../../../../../utils/logger' import { isMockMode } from '../../../../../utils/test-modes' import { FrontendWebsocketAdapter } from './frontend-websocket-adapter' import { useWebsocketUrl } from './use-websocket-url' -import { DisconnectReason, MessageTransporter, MockedBackendTransportAdapter } from '@hedgedoc/commons' +import { DisconnectReasonCode, MessageTransporter, MockedBackendTransportAdapter } from '@hedgedoc/commons' import type { Listener } from 'eventemitter2' import { useCallback, useEffect, useMemo, useRef } from 'react' @@ -28,7 +28,7 @@ export const useRealtimeConnection = (): MessageTransporter => { const messageTransporter = useMemo(() => new MessageTransporter(), []) const reconnectCount = useRef(0) - const disconnectReason = useRef(undefined) + const disconnectReason = useRef(undefined) const establishWebsocketConnection = useCallback(() => { if (isMockMode) { logger.debug('Creating Loopback connection...') @@ -58,7 +58,11 @@ export const useRealtimeConnection = (): MessageTransporter => { const isConnected = useApplicationState((state) => state.realtimeStatus.isConnected) useEffect(() => { - if (isConnected || reconnectCount.current > 0 || disconnectReason.current === DisconnectReason.USER_NOT_PERMITTED) { + if ( + isConnected || + reconnectCount.current > 0 || + disconnectReason.current === DisconnectReasonCode.USER_NOT_PERMITTED + ) { return } establishWebsocketConnection() @@ -89,7 +93,7 @@ export const useRealtimeConnection = (): MessageTransporter => { const connectedListener = messageTransporter.doAsSoonAsReady(() => setRealtimeConnectionState(true)) const disconnectedListener = messageTransporter.on( 'disconnected', - (reason?: DisconnectReason) => { + (reason?: DisconnectReasonCode) => { disconnectReason.current = reason setRealtimeConnectionState(false) }, diff --git a/yarn.lock b/yarn.lock index 88b1ab89e..1424c921b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2766,6 +2766,7 @@ __metadata: jest: "npm:29.7.0" keyv: "npm:^5.3.2" knex: "npm:3.1.0" + knex-mock-client: "npm:3.0.2" ldapauth-fork: "npm:6.1.0" markdown-it: "npm:13.0.2" minio: "npm:8.0.4" @@ -13964,6 +13965,17 @@ __metadata: languageName: node linkType: hard +"knex-mock-client@npm:3.0.2": + version: 3.0.2 + resolution: "knex-mock-client@npm:3.0.2" + dependencies: + lodash.clonedeep: "npm:^4.5.0" + peerDependencies: + knex: ">=2.0.0" + checksum: 10c0/95b0430a7d5f074afb142644c0b60f8824f065608982a6321e816b9b1fcf6edb7c532cf8930372d8f5f86c57a407ab89bb076e4419cf5d0a10fb6df550dd7020 + languageName: node + linkType: hard + "knex@npm:3.1.0": version: 3.1.0 resolution: "knex@npm:3.1.0" @@ -14218,6 +14230,13 @@ __metadata: languageName: node linkType: hard +"lodash.clonedeep@npm:^4.5.0": + version: 4.5.0 + resolution: "lodash.clonedeep@npm:4.5.0" + checksum: 10c0/2caf0e4808f319d761d2939ee0642fa6867a4bbf2cfce43276698828380756b99d4c4fa226d881655e6ac298dd453fe12a5ec8ba49861777759494c534936985 + languageName: node + linkType: hard + "lodash.debounce@npm:^4.0.8": version: 4.0.8 resolution: "lodash.debounce@npm:4.0.8"