test(backend): update tests for knex mocking

Co-authored-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson
2025-07-09 18:14:53 +00:00
parent a356505087
commit 627ce448c4
45 changed files with 4117 additions and 3017 deletions
+7 -3
View File
@@ -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',
],
+1
View File
@@ -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",
+292 -236
View File
@@ -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<Note>;
let aliasRepo: Repository<Alias>;
let forbiddenNoteId: string;
beforeEach(async () => {
noteRepo = new Repository<Note>(
'',
new EntityManager(
new DataSource({
type: 'sqlite',
database: ':memory:',
}),
),
undefined,
);
aliasRepo = new Repository<Alias>(
'',
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>(ConfigService);
forbiddenNoteId = config.get('noteConfig').forbiddenNoteIds[0];
service = module.get<AliasService>(AliasService);
noteRepo = module.get<Repository<Note>>(getRepositoryToken(Note));
aliasRepo = module.get<Repository<Alias>>(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> => 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> => 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> => note);
jest
.spyOn(aliasRepo, 'remove')
.mockImplementationOnce(
async (alias: Alias): Promise<Alias> => 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> => note);
jest
.spyOn(aliasRepo, 'remove')
.mockImplementationOnce(
async (alias: Alias): Promise<Alias> => 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> => alias)
.mockImplementationOnce(async (alias: Alias): Promise<Alias> => alias);
mockSelectQueryBuilderInRepo(
noteRepo,
Mock.of<Note>({
...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);
});
});
});
+8 -5
View File
@@ -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<boolean> {
private async isAliasUsed(
alias: string,
transaction?: Knex,
): Promise<boolean> {
const dbActor = transaction ? transaction : this.knex;
const result = await dbActor(TableAlias)
.select(FieldNameAlias.alias)
+2 -3
View File
@@ -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],
})
+353 -330
View File
@@ -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<User>;
let apiTokenRepo: Repository<ApiToken>;
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>(ApiTokenService);
userRepo = module.get<Repository<User>>(getRepositoryToken(User));
apiTokenRepo = module.get<Repository<ApiToken>>(
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<ApiToken> => {
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<ApiToken> => {
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<ApiToken> => {
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<ApiToken> => {
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<ApiToken> => {
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<ApiToken[]> => {
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<ApiToken> => {
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());
});
});
});
+39 -30
View File
@@ -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<ApiToken[]> {
return this.knex(TableApiToken)
.select()
async getTokensOfUserById(userId: number): Promise<ApiTokenDto[]> {
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<ApiToken, FieldNameApiToken.secretHash>) => ({
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<void> {
@@ -40,12 +40,10 @@ export class ApiTokensController {
@Get()
@OpenApi(200)
async getUserTokens(
getUserTokens(
@RequestUserId({ forbidGuests: true }) userId: number,
): Promise<ApiTokenDto[]> {
return (await this.apiTokenService.getTokensOfUserById(userId)).map(
(token) => this.apiTokenService.toAuthTokenDto(token),
);
return this.apiTokenService.getTokensOfUserById(userId);
}
@Post()
@@ -40,7 +40,7 @@ describe('extract note from request', () => {
return Mock.of<CompleteRequest>({
params: parameterValue
? {
noteIdOrAlias: parameterValue,
noteAlias: parameterValue,
}
: {},
headers: headerValue
@@ -57,7 +57,7 @@ describe('get note interceptor', () => {
it('extracts the note from the request parameters', async () => {
const request = Mock.of<CompleteRequest>({
params: { noteIdOrAlias: mockNoteId },
params: { noteAlias: mockNoteId },
});
const context = mockExecutionContext(request);
const sut: GetNoteIdInterceptor = new GetNoteIdInterceptor(notesService);
@@ -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(
{
@@ -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');
}
}
+108 -120
View File
@@ -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 */
},
@@ -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]);
}
}
+93
View File
@@ -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);
}
+18
View File
@@ -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];
}
@@ -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,
+126
View File
@@ -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>(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);
});
});
});
+356
View File
@@ -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>(MediaService);
fileSystemBackend = module.get<FilesystemBackend>(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<string | null> => {
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<string> => {
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,
},
]);
});
});
});
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -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<User, FieldNameUser.username>
>(`${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<Group, FieldNameGroup.name> &
@@ -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(
+78 -104
View File
@@ -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<boolean> {
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<PermissionLevel> {
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<PermissionLevel> {
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<void> {
public async removeUserPermission(
noteId: number,
userId: number,
): Promise<void> {
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<void> {
public async removeGroupPermission(
noteId: number,
groupId: number,
): Promise<void> {
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<void> {
public async changeOwner(noteId: number, newOwnerId: number): Promise<void> {
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<NotePermissionsDto> {
public async getPermissionsDtoForNote(
noteId: number,
): Promise<NotePermissionsDto> {
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(),
@@ -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<CompleteRequest>({
userId: userId,
});
return Mock.of<ExecutionContext>({
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<ConsoleLoggerService>({
@@ -43,33 +53,20 @@ describe('permissions guard', () => {
});
reflector = Mock.of<Reflector>({
get: jest.fn(() => requiredPermission),
get: jest.fn(),
});
handler = jest.fn();
permissionsService = Mock.of<PermissionService>({
mayCreate: jest.fn(() => createAllowed),
determinePermission: jest.fn(() => Promise.resolve(determinedPermission)),
checkIfUserMayCreateNote: jest.fn(),
determinePermission: jest.fn(),
});
requestUser = Mock.of<User>({});
const request = Mock.of<CompleteRequest>({
user: requestUser,
});
context = Mock.of<ExecutionContext>({
getHandler: () => handler,
switchToHttp: () =>
Mock.of({
getRequest: () => request,
}),
});
mockedNote = Mock.of<Note>({});
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,
);
});
},
File diff suppressed because it is too large Load Diff
@@ -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(),
@@ -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<Note>({ 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');
@@ -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<Revision>({
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<Note>({ id: mockedNoteId });
realtimeNote = new RealtimeNote(note, mockedContent);
realtimeNote = new RealtimeNote(mockedNoteId, mockedContent);
revisionsService = Mock.of<RevisionsService>({
getLatestRevision: jest.fn(),
createAndSaveRevision: jest.fn(),
createRevision: jest.fn(),
});
consoleLoggerService = Mock.of<ConsoleLoggerService>({
@@ -92,13 +91,13 @@ describe('RealtimeNoteService', () => {
mockedAppConfig = Mock.of<AppConfig>({ persistInterval: 0 });
mockedPermissionService = Mock.of<PermissionService>({
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<PermissionLevel> => {
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),
);
});
@@ -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', () => {
@@ -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<Note>({ 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<void>((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
@@ -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,
},
],
@@ -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;
}
@@ -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<CloseEvent>({ code: DisconnectReason.USER_NOT_PERMITTED }),
Mock.of<CloseEvent>({ code: DisconnectReasonCode.USER_NOT_PERMITTED }),
);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(DisconnectReason.USER_NOT_PERMITTED);
expect(handler).toHaveBeenCalledWith(
DisconnectReasonCode.USER_NOT_PERMITTED,
);
unbind();
@@ -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<IncomingMessage>({
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<IncomingMessage>({
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<IncomingMessage>({
url: '/realtime?noteId=',
url: '/realtime?noteAlias=',
});
expect(() => extractNoteAliasFromRequestUrl(mockedRequest)).toThrow();
});
@@ -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<NoteEventMap>,
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>(WebsocketGateway);
sessionService = module.get<SessionService>(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<User>({ username: mockUsername });
jest
.spyOn(usersService, 'getUserByUsername')
.mockImplementation(
(username: string): Promise<User> =>
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<Note>({
id: 4711,
owner: Promise.resolve(mockUser),
userPermissions: Promise.resolve([]),
groupPermissions: Promise.resolve([]),
});
const mockedGuestNote = Mock.of<Note>({
id: 1235,
owner: Promise.resolve(null),
userPermissions: Promise.resolve([]),
groupPermissions: Promise.resolve([]),
});
jest
.spyOn(notesService, '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<NotePermissionLevel> =>
(user === mockUser &&
note === mockedNote &&
async (userId: number, noteId: number): Promise<PermissionLevel> =>
(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<RealtimeNote>({
@@ -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<IncomingMessage>({
socket: {
remoteAddress: 'mockHost',
},
url: mockedValidGuestUrl,
headers: {},
});
await expect(
gateway.handleConnection(mockedWebsocket, request),
).resolves.not.toThrow();
expect(addClientSpy).toHaveBeenCalledWith(mockedWebsocketConnection);
expect(mockedWebsocketCloseSpy).not.toHaveBeenCalled();
});
it('adds a valid connection request', async () => {
const request = Mock.of<IncomingMessage>({
socket: {
@@ -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<IncomingMessage>({
socket: {
@@ -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>(RevisionsService);
aliasService = module.get<AliasService>(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,
],
]);
});
});
});
+21 -23
View File
@@ -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<RevisionUserInfo> {
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<User, FieldNameUser.username | FieldNameUser.guestUuid> &
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],
+5 -1
View File
@@ -46,7 +46,11 @@ export class KeyvSessionStore<T extends SessionData> 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>,
): void {
this.dataStore
.set(sid, session)
.then(() => callback())
+3 -2
View File
@@ -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],
})
+95 -178
View File
@@ -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<Session>;
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<SessionState>({
username: mockUsername,
});
mockedTypeormStore = Mock.of<TypeormStore>({
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<Repository<Session>>({});
databaseConfigMock = Mock.of<DatabaseConfig>({
type: DatabaseType.SQLITE,
});
authConfigMock = Mock.of<AuthConfig>({
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>(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<string, string> => {
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<IncomingMessage>({
headers: {},
});
expect(
sessionService.extractSessionIdFromRequest(mockedRequest).isEmpty(),
).toBeTruthy();
});
it("fails if the cookie header isn't valid", () => {
const mockedRequest = Mock.of<IncomingMessage>({
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<IncomingMessage>({
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<IncomingMessage>({
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<Socket>();
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<IncomingMessage>({
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<IncomingMessage>({
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<IncomingMessage>({
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,
);
});
});
});
+215
View File
@@ -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>(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();
});
});
});
+1 -1
View File
@@ -55,7 +55,7 @@ export class UsersService {
email: string | null,
photoUrl: string | null,
transaction?: Knex,
): Promise<User[FieldNameUser.id]> {
): Promise<number> {
if (!REGEX_USERNAME.test(username)) {
throw new BadRequestException(
`The username '${username}' is not a valid username.`,
+5 -5
View File
@@ -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),
);
});
});
@@ -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<CloseEvent>({ code: DisconnectReason.USER_NOT_PERMITTED }))
modifiedHandler(Mock.of<CloseEvent>({ code: DisconnectReasonCode.USER_NOT_PERMITTED }))
expect(handler).toHaveBeenCalledTimes(1)
expect(handler).toHaveBeenCalledWith(DisconnectReason.USER_NOT_PERMITTED)
expect(handler).toHaveBeenCalledWith(DisconnectReasonCode.USER_NOT_PERMITTED)
unbind()
@@ -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<DisconnectReason | undefined>(undefined)
const disconnectReason = useRef<DisconnectReasonCode | undefined>(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)
},
+19
View File
@@ -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"