From d1b7c2a2db7c0a3889819ad2255e6dac19188187 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Sat, 7 Aug 2021 21:53:54 +0200 Subject: [PATCH] feat: add alias service Signed-off-by: Philip Molares --- src/notes/alias.service.spec.ts | 267 ++++++++++++++++++++++++++++++++ src/notes/alias.service.ts | 186 ++++++++++++++++++++++ src/notes/notes.module.ts | 5 +- 3 files changed, 456 insertions(+), 2 deletions(-) create mode 100644 src/notes/alias.service.spec.ts create mode 100644 src/notes/alias.service.ts diff --git a/src/notes/alias.service.spec.ts b/src/notes/alias.service.spec.ts new file mode 100644 index 000000000..20bc41274 --- /dev/null +++ b/src/notes/alias.service.spec.ts @@ -0,0 +1,267 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { AuthToken } from '../auth/auth-token.entity'; +import { Author } from '../authors/author.entity'; +import appConfigMock from '../config/mock/app.config.mock'; +import { + AlreadyInDBError, + ForbiddenIdError, + NotInDBError, + PrimaryAliasDeletionForbiddenError, +} from '../errors/errors'; +import { Group } from '../groups/group.entity'; +import { GroupsModule } from '../groups/groups.module'; +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 { Edit } from '../revisions/edit.entity'; +import { Revision } from '../revisions/revision.entity'; +import { RevisionsModule } from '../revisions/revisions.module'; +import { Session } from '../users/session.entity'; +import { User } from '../users/user.entity'; +import { UsersModule } from '../users/users.module'; +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'; + +describe('AliasService', () => { + let service: AliasService; + let noteRepo: Repository; + let aliasRepo: Repository; + let forbiddenNoteId: string; + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AliasService, + NotesService, + { + provide: getRepositoryToken(Note), + useClass: Repository, + }, + { + provide: getRepositoryToken(Alias), + useClass: Repository, + }, + { + provide: getRepositoryToken(Tag), + useClass: Repository, + }, + { + provide: getRepositoryToken(User), + useClass: Repository, + }, + ], + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [appConfigMock], + }), + LoggerModule, + UsersModule, + GroupsModule, + RevisionsModule, + ], + }) + .overrideProvider(getRepositoryToken(Note)) + .useClass(Repository) + .overrideProvider(getRepositoryToken(Tag)) + .useClass(Repository) + .overrideProvider(getRepositoryToken(Alias)) + .useClass(Repository) + .overrideProvider(getRepositoryToken(User)) + .useClass(Repository) + .overrideProvider(getRepositoryToken(AuthToken)) + .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(); + + const config = module.get(ConfigService); + forbiddenNoteId = config.get('appConfig').forbiddenNoteIds[0]; + service = module.get(AliasService); + noteRepo = module.get>(getRepositoryToken(Note)); + aliasRepo = module.get>(getRepositoryToken(Alias)); + }); + describe('addAlias', () => { + const alias = 'testAlias'; + const alias2 = 'testAlias2'; + const user = User.create('hardcoded', 'Testy') as User; + describe('creates', () => { + it('an primary alias if no alias is already present', async () => { + const note = Note.create(user); + jest + .spyOn(noteRepo, 'save') + .mockImplementationOnce(async (note: Note): Promise => note); + jest.spyOn(noteRepo, 'findOne').mockResolvedValueOnce(undefined); + jest.spyOn(aliasRepo, 'findOne').mockResolvedValueOnce(undefined); + const savedAlias = await service.addAlias(note, alias); + expect(savedAlias.name).toEqual(alias); + expect(savedAlias.primary).toBeTruthy(); + }); + it('an non-primary alias if an primary alias is already present', async () => { + const note = Note.create(user, alias); + jest + .spyOn(noteRepo, 'save') + .mockImplementationOnce(async (note: Note): Promise => note); + jest.spyOn(noteRepo, 'findOne').mockResolvedValueOnce(undefined); + jest.spyOn(aliasRepo, 'findOne').mockResolvedValueOnce(undefined); + const savedAlias = await service.addAlias(note, alias2); + expect(savedAlias.name).toEqual(alias2); + expect(savedAlias.primary).toBeFalsy(); + }); + }); + describe('does not create an alias', () => { + const note = Note.create(user, alias2); + it('with an already used name', async () => { + jest + .spyOn(aliasRepo, 'findOne') + .mockResolvedValueOnce(Alias.create(alias2)); + await expect(service.addAlias(note, alias2)).rejects.toThrow( + AlreadyInDBError, + ); + }); + it('with a forbidden name', async () => { + await expect(service.addAlias(note, forbiddenNoteId)).rejects.toThrow( + ForbiddenIdError, + ); + }); + }); + }); + + describe('removeAlias', () => { + const alias = 'testAlias'; + const alias2 = 'testAlias2'; + const user = User.create('hardcoded', 'Testy') as User; + describe('removes one alias correctly', () => { + const note = Note.create(user, alias); + note.aliases.push(Alias.create(alias2)); + it('with two aliases', async () => { + jest + .spyOn(noteRepo, 'save') + .mockImplementationOnce(async (note: Note): Promise => note); + jest + .spyOn(aliasRepo, 'remove') + .mockImplementationOnce( + async (alias: Alias): Promise => alias, + ); + const savedNote = await service.removeAlias(note, alias2); + expect(savedNote.aliases).toHaveLength(1); + expect(savedNote.aliases[0].name).toEqual(alias); + expect(savedNote.aliases[0].primary).toBeTruthy(); + }); + it('with one alias, that is primary', async () => { + jest + .spyOn(noteRepo, 'save') + .mockImplementationOnce(async (note: Note): Promise => note); + jest + .spyOn(aliasRepo, 'remove') + .mockImplementationOnce( + async (alias: Alias): Promise => alias, + ); + const savedNote = await service.removeAlias(note, alias); + expect(savedNote.aliases).toHaveLength(0); + }); + }); + describe('does not remove one alias', () => { + const note = Note.create(user, alias); + note.aliases.push(Alias.create(alias2)); + it('if the alias is unknown', async () => { + await expect(service.removeAlias(note, 'non existent')).rejects.toThrow( + NotInDBError, + ); + }); + it('if it is primary and not the last one', async () => { + await expect(service.removeAlias(note, alias)).rejects.toThrow( + PrimaryAliasDeletionForbiddenError, + ); + }); + }); + }); + + describe('makeAliasPrimary', () => { + const alias = Alias.create('testAlias', true); + const alias2 = Alias.create('testAlias2'); + const user = User.create('hardcoded', 'Testy') as User; + const note = Note.create(user, alias.name); + note.aliases.push(alias2); + it('mark the alias as primary', async () => { + jest + .spyOn(aliasRepo, 'findOne') + .mockResolvedValueOnce(alias) + .mockResolvedValueOnce(alias2); + jest + .spyOn(aliasRepo, 'save') + .mockImplementationOnce(async (alias: Alias): Promise => alias) + .mockImplementationOnce(async (alias: Alias): Promise => alias); + const createQueryBuilder = { + leftJoinAndSelect: () => createQueryBuilder, + where: () => createQueryBuilder, + orWhere: () => createQueryBuilder, + setParameter: () => createQueryBuilder, + getOne: () => { + return { + ...note, + aliases: note.aliases.map((anAlias) => { + if (anAlias.primary) { + anAlias.primary = false; + } + if (anAlias.name === alias2.name) { + anAlias.primary = true; + } + return anAlias; + }), + }; + }, + }; + jest + .spyOn(noteRepo, 'createQueryBuilder') + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + .mockImplementation(() => createQueryBuilder); + const savedAlias = await service.makeAliasPrimary(note, alias2.name); + expect(savedAlias.name).toEqual(alias2.name); + expect(savedAlias.primary).toBeTruthy(); + }); + it('does not mark the alias as primary, if the alias does not exist', async () => { + await expect( + service.makeAliasPrimary(note, 'i_dont_exist'), + ).rejects.toThrow(NotInDBError); + }); + }); + + it('toAliasDto correctly creates an AliasDto', () => { + const aliasName = 'testAlias'; + const alias = Alias.create(aliasName, true); + const user = User.create('hardcoded', 'Testy') as User; + const note = Note.create(user, alias.name); + const aliasDto = service.toAliasDto(alias, note); + expect(aliasDto.name).toEqual(aliasName); + expect(aliasDto.primaryAlias).toBeTruthy(); + expect(aliasDto.noteId).toEqual(note.publicId); + }); +}); diff --git a/src/notes/alias.service.ts b/src/notes/alias.service.ts new file mode 100644 index 000000000..d507864c2 --- /dev/null +++ b/src/notes/alias.service.ts @@ -0,0 +1,186 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Inject, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { + AlreadyInDBError, + NotInDBError, + PrimaryAliasDeletionForbiddenError, +} from '../errors/errors'; +import { ConsoleLoggerService } from '../logger/console-logger.service'; +import { AliasDto } from './alias.dto'; +import { Alias } from './alias.entity'; +import { Note } from './note.entity'; +import { NotesService } from './notes.service'; +import { getPrimaryAlias } from './utils'; + +@Injectable() +export class AliasService { + constructor( + private readonly logger: ConsoleLoggerService, + @InjectRepository(Note) private noteRepository: Repository, + @InjectRepository(Alias) private aliasRepository: Repository, + @Inject(NotesService) private notesService: NotesService, + ) { + this.logger.setContext(AliasService.name); + } + + /** + * @async + * Add the specified alias to the note. + * @param {Note} note - the note to add the alias to + * @param {string} alias - the alias to add to the note + * @throws {AlreadyInDBError} the alias is already in use. + * @return {Alias} the new alias + */ + async addAlias(note: Note, alias: string): Promise { + this.notesService.checkNoteIdOrAlias(alias); + + const foundAlias = await this.aliasRepository.findOne({ + where: { name: alias }, + }); + if (foundAlias !== undefined) { + this.logger.debug(`The alias '${alias}' is already used.`, 'addAlias'); + throw new AlreadyInDBError(`The alias '${alias}' is already used.`); + } + + const foundNote = await this.noteRepository.findOne({ + where: { publicId: alias }, + }); + if (foundNote !== undefined) { + this.logger.debug( + `The alias '${alias}' is already a public id.`, + 'addAlias', + ); + throw new AlreadyInDBError( + `The alias '${alias}' is already a public id.`, + ); + } + let newAlias: Alias; + if (note.aliases.length === 0) { + // the first alias is automatically made the primary alias + newAlias = Alias.create(alias, true); + } else { + newAlias = Alias.create(alias); + } + note.aliases.push(newAlias); + + await this.noteRepository.save(note); + return newAlias; + } + + /** + * @async + * Set the specified alias as the primary alias of the note. + * @param {Note} note - the note to change the primary alias + * @param {string} alias - the alias to be the new primary alias of the note + * @throws {NotInDBError} the alias is not part of this note. + * @return {Alias} the new primary alias + */ + async makeAliasPrimary(note: Note, alias: string): Promise { + let newPrimaryFound = false; + let oldPrimaryId = ''; + let newPrimaryId = ''; + + for (const anAlias of note.aliases) { + // found old primary + if (anAlias.primary) { + oldPrimaryId = anAlias.id; + } + + // found new primary + if (anAlias.name === alias) { + newPrimaryFound = true; + newPrimaryId = anAlias.id; + } + } + + if (!newPrimaryFound) { + // the provided alias is not already an alias of this note + this.logger.debug( + `The alias '${alias}' is not used by this note.`, + 'makeAliasPrimary', + ); + throw new NotInDBError(`The alias '${alias}' is not used by this note.`); + } + + const oldPrimary = await this.aliasRepository.findOne(oldPrimaryId); + const newPrimary = await this.aliasRepository.findOne(newPrimaryId); + + if (!oldPrimary || !newPrimary) { + throw new Error('This should not happen!'); + } + + oldPrimary.primary = false; + newPrimary.primary = true; + + await this.aliasRepository.save(oldPrimary); + await this.aliasRepository.save(newPrimary); + + return newPrimary; + } + + /** + * @async + * Remove the specified alias from the note. + * @param {Note} note - the note to remove the alias from + * @param {string} alias - the alias to remove from the note + * @throws {NotInDBError} the alias is not part of this note. + * @throws {PrimaryAliasDeletionForbiddenError} the primary alias can only be deleted if it's the only alias + */ + async removeAlias(note: Note, alias: string): Promise { + const primaryAlias = getPrimaryAlias(note); + + if (primaryAlias === alias && note.aliases.length !== 1) { + this.logger.debug( + `The alias '${alias}' is the primary alias, which can only be removed if it's the only alias.`, + 'removeAlias', + ); + throw new PrimaryAliasDeletionForbiddenError( + `The alias '${alias}' is the primary alias, which can only be removed if it's the only alias.`, + ); + } + + const filteredAliases = note.aliases.filter( + (anAlias) => anAlias.name !== alias, + ); + if (note.aliases.length === filteredAliases.length) { + this.logger.debug( + `The alias '${alias}' is not used by this note or is the primary alias, which can't be removed.`, + 'removeAlias', + ); + throw new NotInDBError( + `The alias '${alias}' is not used by this note or is the primary alias, which can't be removed.`, + ); + } + const aliasToDelete = note.aliases.find( + (anAlias) => anAlias.name === alias, + ); + if (aliasToDelete !== undefined) { + await this.aliasRepository.remove(aliasToDelete); + } + note.aliases = filteredAliases; + return await this.noteRepository.save(note); + } + + /** + * @async + * Build AliasDto from a note. + * @param {Alias} alias - the alias to use + * @param {Note} note - the note to use + * @return {AliasDto} the built AliasDto + * @throws {NotInDBError} the specified alias does not exist + */ + toAliasDto(alias: Alias, note: Note): AliasDto { + return { + name: alias.name, + primaryAlias: alias.primary, + noteId: note.publicId, + }; + } +} diff --git a/src/notes/notes.module.ts b/src/notes/notes.module.ts index 0bb564f07..1e83c85ab 100644 --- a/src/notes/notes.module.ts +++ b/src/notes/notes.module.ts @@ -15,6 +15,7 @@ import { RevisionsModule } from '../revisions/revisions.module'; import { User } from '../users/user.entity'; import { UsersModule } from '../users/users.module'; 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'; @@ -36,7 +37,7 @@ import { Tag } from './tag.entity'; ConfigModule, ], controllers: [], - providers: [NotesService], - exports: [NotesService], + providers: [NotesService, AliasService], + exports: [NotesService, AliasService], }) export class NotesModule {}