mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2026-06-23 04:10:17 +00:00
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:
@@ -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',
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+4
-4
@@ -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()
|
||||
|
||||
|
||||
+8
-4
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user