feat: add alias service

Signed-off-by: Philip Molares <philip.molares@udo.edu>
This commit is contained in:
Philip Molares 2021-08-07 21:53:54 +02:00
parent aaef0f72ba
commit 0db7a41d1a
3 changed files with 456 additions and 2 deletions

View file

@ -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<Note>;
let aliasRepo: Repository<Alias>;
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>(ConfigService);
forbiddenNoteId = config.get('appConfig').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 alias if no alias is already present', async () => {
const note = Note.create(user);
jest
.spyOn(noteRepo, 'save')
.mockImplementationOnce(async (note: Note): Promise<Note> => 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> => 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> => note);
jest
.spyOn(aliasRepo, 'remove')
.mockImplementationOnce(
async (alias: Alias): Promise<Alias> => 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> => note);
jest
.spyOn(aliasRepo, 'remove')
.mockImplementationOnce(
async (alias: Alias): Promise<Alias> => 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> => alias)
.mockImplementationOnce(async (alias: Alias): Promise<Alias> => 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);
});
});

186
src/notes/alias.service.ts Normal file
View file

@ -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<Note>,
@InjectRepository(Alias) private aliasRepository: Repository<Alias>,
@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<Alias> {
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<Alias> {
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<Note> {
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,
};
}
}

View file

@ -15,6 +15,7 @@ import { RevisionsModule } from '../revisions/revisions.module';
import { User } from '../users/user.entity'; import { User } from '../users/user.entity';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { Alias } from './alias.entity'; import { Alias } from './alias.entity';
import { AliasService } from './alias.service';
import { Note } from './note.entity'; import { Note } from './note.entity';
import { NotesService } from './notes.service'; import { NotesService } from './notes.service';
import { Tag } from './tag.entity'; import { Tag } from './tag.entity';
@ -36,7 +37,7 @@ import { Tag } from './tag.entity';
ConfigModule, ConfigModule,
], ],
controllers: [], controllers: [],
providers: [NotesService], providers: [NotesService, AliasService],
exports: [NotesService], exports: [NotesService, AliasService],
}) })
export class NotesModule {} export class NotesModule {}