/* * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ import { ConfigModule, ConfigService } from '@nestjs/config'; import { EventEmitter2, EventEmitterModule } from '@nestjs/event-emitter'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Mock } from 'ts-mockery'; import { DataSource, EntityManager, FindOptionsWhere, Repository, } from 'typeorm'; import { Author } from '../authors/author.entity'; import { DefaultAccessLevel } from '../config/default-access-level.enum'; import appConfigMock from '../config/mock/app.config.mock'; import authConfigMock from '../config/mock/auth.config.mock'; import databaseConfigMock from '../config/mock/database.config.mock'; import { createDefaultMockNoteConfig, registerNoteConfig, } from '../config/mock/note.config.mock'; import { NoteConfig } from '../config/note.config'; import { AlreadyInDBError, ForbiddenIdError, MaximumDocumentLengthExceededError, NotInDBError, } from '../errors/errors'; import { eventModuleConfig, NoteEvent } from '../events'; import { Group } from '../groups/group.entity'; import { GroupsModule } from '../groups/groups.module'; import { SpecialGroup } from '../groups/groups.special'; import { Identity } from '../identity/identity.entity'; import { LoggerModule } from '../logger/logger.module'; import { NoteGroupPermission } from '../permissions/note-group-permission.entity'; import { NoteUserPermission } from '../permissions/note-user-permission.entity'; import { PublicAuthToken } from '../public-auth-token/public-auth-token.entity'; import { RealtimeNoteModule } from '../realtime/realtime-note/realtime-note.module'; import { Edit } from '../revisions/edit.entity'; import { Revision } from '../revisions/revision.entity'; import { RevisionsModule } from '../revisions/revisions.module'; import { RevisionsService } from '../revisions/revisions.service'; import { Session } from '../sessions/session.entity'; import { User } from '../users/user.entity'; import { UsersModule } from '../users/users.module'; import { mockSelectQueryBuilderInRepo } from '../utils/test-utils/mockSelectQueryBuilder'; import { Alias } from './alias.entity'; import { AliasService } from './alias.service'; import { Note } from './note.entity'; import { NotesService } from './notes.service'; import { Tag } from './tag.entity'; jest.mock('../revisions/revisions.service'); describe('NotesService', () => { let service: NotesService; let revisionsService: RevisionsService; const noteMockConfig: NoteConfig = createDefaultMockNoteConfig(); let noteRepo: Repository; let userRepo: Repository; let aliasRepo: Repository; let groupRepo: Repository; let forbiddenNoteId: string; let everyoneDefaultAccessPermission: string; let loggedinDefaultAccessPermission: string; let eventEmitter: EventEmitter2; const everyone = Group.create( SpecialGroup.EVERYONE, SpecialGroup.EVERYONE, true, ); const loggedin = Group.create( SpecialGroup.LOGGED_IN, SpecialGroup.LOGGED_IN, true, ); beforeEach(async () => { jest.resetAllMocks(); jest.resetModules(); /** * We need to have *one* userRepo for both the providers array and * the overrideProvider call, as otherwise we have two instances * and the mock of createQueryBuilder replaces the wrong one * **/ userRepo = new Repository( '', new EntityManager( new DataSource({ type: 'sqlite', database: ':memory:', }), ), undefined, ); noteRepo = new Repository( '', new EntityManager( new DataSource({ type: 'sqlite', database: ':memory:', }), ), undefined, ); aliasRepo = new Repository( '', new EntityManager( new DataSource({ type: 'sqlite', database: ':memory:', }), ), undefined, ); groupRepo = new Repository( '', new EntityManager( new DataSource({ type: 'sqlite', database: ':memory:', }), ), undefined, ); revisionsService = Mock.of({ getLatestRevision: jest.fn(), createRevision: jest.fn(), }); const module: TestingModule = await Test.createTestingModule({ providers: [ NotesService, { provide: RevisionsService, useValue: revisionsService, }, AliasService, { provide: getRepositoryToken(Note), useValue: noteRepo, }, { provide: getRepositoryToken(Tag), useClass: Repository, }, { provide: getRepositoryToken(Revision), useClass: Repository, }, { provide: getRepositoryToken(Alias), useValue: aliasRepo, }, { provide: getRepositoryToken(User), useValue: userRepo, }, { provide: getRepositoryToken(Group), useValue: groupRepo, }, ], imports: [ LoggerModule, UsersModule, GroupsModule, RevisionsModule, RealtimeNoteModule, ConfigModule.forRoot({ isGlobal: true, load: [ appConfigMock, databaseConfigMock, authConfigMock, registerNoteConfig(noteMockConfig), ], }), EventEmitterModule.forRoot(eventModuleConfig), ], }) .overrideProvider(getRepositoryToken(Note)) .useValue(noteRepo) .overrideProvider(getRepositoryToken(Tag)) .useClass(Repository) .overrideProvider(getRepositoryToken(Alias)) .useValue(aliasRepo) .overrideProvider(getRepositoryToken(User)) .useValue(userRepo) .overrideProvider(getRepositoryToken(PublicAuthToken)) .useValue({}) .overrideProvider(getRepositoryToken(Identity)) .useValue({}) .overrideProvider(getRepositoryToken(Edit)) .useValue({}) .overrideProvider(getRepositoryToken(Revision)) .useClass(Repository) .overrideProvider(getRepositoryToken(NoteGroupPermission)) .useValue({}) .overrideProvider(getRepositoryToken(NoteUserPermission)) .useValue({}) .overrideProvider(getRepositoryToken(Group)) .useValue(groupRepo) .overrideProvider(getRepositoryToken(Session)) .useValue({}) .overrideProvider(getRepositoryToken(Author)) .useValue({}) .compile(); const config = module.get(ConfigService); const noteConfig = config.get('noteConfig') as NoteConfig; forbiddenNoteId = noteConfig.forbiddenNoteIds[0]; everyoneDefaultAccessPermission = noteConfig.permissions.default.everyone; loggedinDefaultAccessPermission = noteConfig.permissions.default.loggedIn; service = module.get(NotesService); noteRepo = module.get>(getRepositoryToken(Note)); aliasRepo = module.get>(getRepositoryToken(Alias)); eventEmitter = module.get(EventEmitter2); }); /** * Creates a Note and a corresponding User and Group for testing. * The Note does not have any aliases. */ async function getMockData(): Promise<[Note, User, Group, Revision]> { const user = User.create('hardcoded', 'Testy') as User; const author = Author.create(1); author.user = Promise.resolve(user); const group = Group.create('testGroup', 'testGroup', false) as Group; jest .spyOn(noteRepo, 'save') // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore .mockImplementation(async (note: Note): Promise => note); mockGroupRepo(); const revision = Mock.of({ edits: Promise.resolve([ { startPos: 0, endPos: 1, updatedAt: new Date(1549312452000), author: Promise.resolve(author), } as Edit, { startPos: 0, endPos: 1, updatedAt: new Date(1549312452001), author: Promise.resolve(author), } as Edit, ]), createdAt: new Date(1549312452000), tags: Promise.resolve([ { id: 0, name: 'tag1', } as Tag, ]), content: 'mockContent', description: 'mockDescription', title: 'mockTitle', }); const note = Mock.of({ revisions: Promise.resolve([revision]), aliases: Promise.resolve([]), }); mockRevisionService(note, revision); mockSelectQueryBuilderInRepo(userRepo, user); note.publicId = 'testId'; note.owner = Promise.resolve(user); note.userPermissions = Promise.resolve([ { id: 1, note: Promise.resolve(note), user: Promise.resolve(user), canEdit: true, }, ]); note.groupPermissions = Promise.resolve([ { id: 1, note: Promise.resolve(note), group: Promise.resolve(group), canEdit: true, }, ]); note.viewCount = 1337; return [note, user, group, revision]; } function mockRevisionService(note: Note, revision: Revision) { jest .spyOn(revisionsService, 'getLatestRevision') .mockImplementation((requestedNote) => { expect(requestedNote).toBe(note); return Promise.resolve(revision); }); } function mockGroupRepo() { jest.spyOn(groupRepo, 'findOne').mockReset(); jest.spyOn(groupRepo, 'findOne').mockImplementation((args) => { const groupName = (args.where as FindOptionsWhere).name; if (groupName === loggedin.name) { return Promise.resolve(loggedin as Group); } else if (groupName === everyone.name) { return Promise.resolve(everyone as Group); } else { return Promise.resolve(null); } }); } it('should be defined', () => { expect(service).toBeDefined(); }); describe('getUserNotes', () => { describe('works', () => { const user = User.create('hardcoded', 'Testy') as User; const alias = 'alias'; const note = Note.create(user, alias) as Note; it('with no note', async () => { mockSelectQueryBuilderInRepo(noteRepo, null); const notes = await service.getUserNotes(user); expect(notes).toEqual([]); }); it('with one note', async () => { mockSelectQueryBuilderInRepo(noteRepo, note); const notes = await service.getUserNotes(user); expect(notes).toEqual([note]); }); it('with multiple note', async () => { mockSelectQueryBuilderInRepo(noteRepo, [note, note]); const notes = await service.getUserNotes(user); expect(notes).toEqual([note, note]); }); }); }); describe('createNote', () => { const user = User.create('hardcoded', 'Testy') as User; const alias = 'alias'; const content = 'testContent'; const newRevision = Mock.of({}); let createRevisionSpy: jest.SpyInstance; describe('works', () => { beforeEach(() => { jest .spyOn(noteRepo, 'save') // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore .mockImplementation(async (note: Note): Promise => note); jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(false); jest.spyOn(aliasRepo, 'existsBy').mockResolvedValueOnce(false); mockGroupRepo(); createRevisionSpy = jest .spyOn(revisionsService, 'createRevision') .mockResolvedValue(newRevision); mockSelectQueryBuilderInRepo(noteRepo, null); }); it('without alias, without owner', async () => { const newNote = await service.createNote(content, null); expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content); expect(await newNote.revisions).toStrictEqual([newRevision]); expect(await newNote.historyEntries).toHaveLength(0); expect(await newNote.userPermissions).toHaveLength(0); const groupPermissions = await newNote.groupPermissions; expect(groupPermissions).toHaveLength(2); expect(groupPermissions[0].canEdit).toEqual( everyoneDefaultAccessPermission !== (DefaultAccessLevel.WRITE as string), ); expect((await groupPermissions[0].group).name).toEqual( SpecialGroup.EVERYONE, ); expect(groupPermissions[1].canEdit).toEqual( loggedinDefaultAccessPermission === (DefaultAccessLevel.WRITE as string), ); expect((await groupPermissions[1].group).name).toEqual( SpecialGroup.LOGGED_IN, ); expect(await newNote.owner).toBeNull(); expect(await newNote.aliases).toHaveLength(0); }); it('without alias, with owner', async () => { const newNote = await service.createNote(content, user); expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content); expect(await newNote.revisions).toStrictEqual([newRevision]); expect(await newNote.historyEntries).toHaveLength(1); expect(await (await newNote.historyEntries)[0].user).toEqual(user); expect(await newNote.userPermissions).toHaveLength(0); const groupPermissions = await newNote.groupPermissions; expect(groupPermissions).toHaveLength(2); expect(groupPermissions[0].canEdit).toEqual( everyoneDefaultAccessPermission === (DefaultAccessLevel.WRITE as string), ); expect((await groupPermissions[0].group).name).toEqual( SpecialGroup.EVERYONE, ); expect(groupPermissions[1].canEdit).toEqual( loggedinDefaultAccessPermission === (DefaultAccessLevel.WRITE as string), ); expect((await groupPermissions[1].group).name).toEqual( SpecialGroup.LOGGED_IN, ); expect(await newNote.owner).toEqual(user); expect(await newNote.aliases).toHaveLength(0); }); it('with alias, without owner', async () => { const newNote = await service.createNote(content, null, alias); expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content); expect(await newNote.revisions).toStrictEqual([newRevision]); expect(await newNote.historyEntries).toHaveLength(0); expect(await newNote.userPermissions).toHaveLength(0); const groupPermissions = await newNote.groupPermissions; expect(groupPermissions).toHaveLength(2); expect(groupPermissions[0].canEdit).toEqual( everyoneDefaultAccessPermission !== (DefaultAccessLevel.WRITE as string), ); expect((await groupPermissions[0].group).name).toEqual( SpecialGroup.EVERYONE, ); expect(groupPermissions[1].canEdit).toEqual( loggedinDefaultAccessPermission === (DefaultAccessLevel.WRITE as string), ); expect((await groupPermissions[1].group).name).toEqual( SpecialGroup.LOGGED_IN, ); expect(await newNote.owner).toBeNull(); expect(await newNote.aliases).toHaveLength(1); }); it('with alias, with owner', async () => { const newNote = await service.createNote(content, user, alias); expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content); expect(await newNote.revisions).toStrictEqual([newRevision]); expect(await newNote.historyEntries).toHaveLength(1); expect(await (await newNote.historyEntries)[0].user).toEqual(user); expect(await newNote.userPermissions).toHaveLength(0); const groupPermissions = await newNote.groupPermissions; expect(groupPermissions).toHaveLength(2); expect(groupPermissions[0].canEdit).toEqual( everyoneDefaultAccessPermission === (DefaultAccessLevel.WRITE as string), ); expect((await groupPermissions[0].group).name).toEqual( SpecialGroup.EVERYONE, ); expect(groupPermissions[1].canEdit).toEqual( loggedinDefaultAccessPermission === (DefaultAccessLevel.WRITE as string), ); expect((await groupPermissions[1].group).name).toEqual( SpecialGroup.LOGGED_IN, ); expect(await newNote.owner).toEqual(user); expect(await newNote.aliases).toHaveLength(1); expect((await newNote.aliases)[0].name).toEqual(alias); }); describe('with maxDocumentLength 1000', () => { beforeEach(() => (noteMockConfig.maxDocumentLength = 1000)); it('and content has length maxDocumentLength', async () => { const content = 'x'.repeat(noteMockConfig.maxDocumentLength); const newNote = await service.createNote(content, user, alias); expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content); expect(await newNote.revisions).toStrictEqual([newRevision]); expect(await newNote.historyEntries).toHaveLength(1); expect(await (await newNote.historyEntries)[0].user).toEqual(user); expect(await newNote.userPermissions).toHaveLength(0); const groupPermissions = await newNote.groupPermissions; expect(groupPermissions).toHaveLength(2); expect(groupPermissions[0].canEdit).toEqual( everyoneDefaultAccessPermission === (DefaultAccessLevel.WRITE as string), ); expect((await groupPermissions[0].group).name).toEqual( SpecialGroup.EVERYONE, ); expect(groupPermissions[1].canEdit).toEqual( loggedinDefaultAccessPermission === (DefaultAccessLevel.WRITE as string), ); expect((await groupPermissions[1].group).name).toEqual( SpecialGroup.LOGGED_IN, ); expect(await newNote.owner).toEqual(user); expect(await newNote.aliases).toHaveLength(1); expect((await newNote.aliases)[0].name).toEqual(alias); }); }); describe('with other', () => { beforeEach( () => (noteMockConfig.permissions.default.everyone = DefaultAccessLevel.NONE), ); it('default permissions', async () => { mockGroupRepo(); const newNote = await service.createNote(content, user, alias); expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content); expect(await newNote.revisions).toStrictEqual([newRevision]); expect(await newNote.historyEntries).toHaveLength(1); expect(await (await newNote.historyEntries)[0].user).toEqual(user); expect(await newNote.userPermissions).toHaveLength(0); const groupPermissions = await newNote.groupPermissions; expect(groupPermissions).toHaveLength(1); expect(groupPermissions[0].canEdit).toEqual( loggedinDefaultAccessPermission === (DefaultAccessLevel.WRITE as string), ); expect((await groupPermissions[0].group).name).toEqual( SpecialGroup.LOGGED_IN, ); expect(await newNote.owner).toEqual(user); expect(await newNote.aliases).toHaveLength(1); expect((await newNote.aliases)[0].name).toEqual(alias); }); }); }); describe('fails:', () => { beforeEach(() => { mockSelectQueryBuilderInRepo(noteRepo, null); }); it('alias is forbidden', async () => { jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(false); jest.spyOn(aliasRepo, 'existsBy').mockResolvedValueOnce(false); await expect( service.createNote(content, null, forbiddenNoteId), ).rejects.toThrow(ForbiddenIdError); }); it('alias is already used (as another alias)', async () => { mockGroupRepo(); jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(false); jest.spyOn(aliasRepo, 'existsBy').mockResolvedValueOnce(true); jest.spyOn(noteRepo, 'save').mockImplementationOnce(async () => { throw new Error(); }); await expect(service.createNote(content, null, alias)).rejects.toThrow( AlreadyInDBError, ); }); it('alias is already used (as publicId)', async () => { mockGroupRepo(); jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(true); jest.spyOn(aliasRepo, 'existsBy').mockResolvedValueOnce(false); jest.spyOn(noteRepo, 'save').mockImplementationOnce(async () => { throw new Error(); }); await expect(service.createNote(content, null, alias)).rejects.toThrow( AlreadyInDBError, ); }); describe('with maxDocumentLength 1000', () => { beforeEach(() => (noteMockConfig.maxDocumentLength = 1000)); it('document is too long', async () => { mockGroupRepo(); jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(false); jest.spyOn(aliasRepo, 'existsBy').mockResolvedValueOnce(false); jest.spyOn(noteRepo, 'save').mockImplementationOnce(async () => { throw new Error(); }); const content = 'x'.repeat(noteMockConfig.maxDocumentLength + 1); await expect( service.createNote(content, user, alias), ).rejects.toThrow(MaximumDocumentLengthExceededError); }); }); }); }); describe('getNoteContent', () => { it('works', async () => { const content = 'testContent'; const revision = Mock.of({ content: content }); const newNote = Mock.of(); mockRevisionService(newNote, revision); const result = await service.getNoteContent(newNote); expect(result).toEqual(content); }); }); describe('getNoteByIdOrAlias', () => { it('works', async () => { const user = User.create('hardcoded', 'Testy') as User; const note = Note.create(user) as Note; mockSelectQueryBuilderInRepo(noteRepo, note); const foundNote = await service.getNoteByIdOrAlias('noteThatExists'); expect(foundNote).toEqual(note); }); describe('fails:', () => { it('no note found', async () => { mockSelectQueryBuilderInRepo(noteRepo, null); await expect( service.getNoteByIdOrAlias('noteThatDoesNoteExist'), ).rejects.toThrow(NotInDBError); }); it('id is forbidden', async () => { await expect( service.getNoteByIdOrAlias(forbiddenNoteId), ).rejects.toThrow(ForbiddenIdError); }); }); }); describe('deleteNote', () => { it('works', async () => { const user = User.create('hardcoded', 'Testy') as User; const note = Note.create(user) as Note; jest .spyOn(noteRepo, 'remove') .mockImplementationOnce(async (entry, _) => { expect(entry).toEqual(note); return entry; }); const mockedEventEmitter = jest .spyOn(eventEmitter, 'emit') .mockImplementationOnce((event) => { expect(event).toEqual(NoteEvent.DELETION); return true; }); expect(mockedEventEmitter).not.toHaveBeenCalled(); await service.deleteNote(note); expect(mockedEventEmitter).toHaveBeenCalled(); }); }); describe('updateNote', () => { it('adds a new revision if content is different', async () => { const [note, , , revision] = await getMockData(); const mockRevision = Mock.of({}); const createRevisionSpy = jest .spyOn(revisionsService, 'createRevision') .mockReturnValue(Promise.resolve(mockRevision)); const newContent = 'newContent'; const updatedNote = await service.updateNote(note, newContent); expect(await updatedNote.revisions).toStrictEqual([ revision, mockRevision, ]); expect(createRevisionSpy).toHaveBeenCalledWith(note, newContent); }); it("won't create a new revision if content is same", async () => { const [note, , , revision] = await getMockData(); const createRevisionSpy = jest .spyOn(revisionsService, 'createRevision') .mockReturnValue(Promise.resolve(undefined)); const newContent = 'newContent'; const updatedNote = await service.updateNote(note, newContent); expect(await updatedNote.revisions).toStrictEqual([revision]); expect(createRevisionSpy).toHaveBeenCalledWith(note, newContent); }); }); describe('toNotePermissionsDto', () => { it('works', async () => { const [note] = await getMockData(); const permissions = await service.toNotePermissionsDto(note); expect(permissions).toMatchInlineSnapshot(` { "owner": "hardcoded", "sharedToGroups": [ { "canEdit": true, "groupName": "testGroup", }, ], "sharedToUsers": [ { "canEdit": true, "username": "hardcoded", }, ], } `); }); }); describe('toNoteMetadataDto', () => { it('works', async () => { const [note] = await getMockData(); note.aliases = Promise.resolve([ Alias.create('testAlias', note, true) as Alias, ]); const metadataDto = await service.toNoteMetadataDto(note); expect(metadataDto).toMatchInlineSnapshot(` { "aliases": [ { "name": "testAlias", "noteId": "testId", "primaryAlias": true, }, ], "createdAt": undefined, "description": "mockDescription", "editedBy": [ "hardcoded", ], "id": "testId", "permissions": { "owner": "hardcoded", "sharedToGroups": [ { "canEdit": true, "groupName": "testGroup", }, ], "sharedToUsers": [ { "canEdit": true, "username": "hardcoded", }, ], }, "primaryAddress": "testAlias", "tags": [ "tag1", ], "title": "mockTitle", "updateUsername": "hardcoded", "updatedAt": 2019-02-04T20:34:12.000Z, "version": undefined, "viewCount": 1337, } `); }); it('returns publicId if no alias exists', async () => { const [note, ,] = await getMockData(); const metadataDto = await service.toNoteMetadataDto(note); expect(metadataDto.primaryAddress).toEqual(note.publicId); }); }); describe('toNoteDto', () => { it('works', async () => { const [note] = await getMockData(); note.aliases = Promise.resolve([ Alias.create('testAlias', note, true) as Alias, ]); const noteDto = await service.toNoteDto(note); expect(noteDto).toMatchInlineSnapshot(` { "content": "mockContent", "editedByAtPosition": [], "metadata": { "aliases": [ { "name": "testAlias", "noteId": "testId", "primaryAlias": true, }, ], "createdAt": undefined, "description": "mockDescription", "editedBy": [ "hardcoded", ], "id": "testId", "permissions": { "owner": "hardcoded", "sharedToGroups": [ { "canEdit": true, "groupName": "testGroup", }, ], "sharedToUsers": [ { "canEdit": true, "username": "hardcoded", }, ], }, "primaryAddress": "testAlias", "tags": [ "tag1", ], "title": "mockTitle", "updateUsername": "hardcoded", "updatedAt": 2019-02-04T20:34:12.000Z, "version": undefined, "viewCount": 1337, }, } `); }); }); });