Merge pull request #1359 from hedgedoc/feat/aliases

add list of aliases to note entity
This commit is contained in:
David Mehren 2021-09-23 15:52:06 +02:00 committed by GitHub
commit a031de4522
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 1989 additions and 148 deletions

View file

@ -6,13 +6,20 @@ entity "note" {
*id : uuid <<generated>> *id : uuid <<generated>>
-- --
publicId: text publicId: text
alias : text
*viewCount : number *viewCount : number
*ownerId : uuid <<FK user>> *ownerId : uuid <<FK user>>
description: text description: text
title: text title: text
} }
entity "alias" {
*id: uuid <<generated>>
---
name: text
' If the alias is primary. Can be NULL, which means it's not primary
primary: boolean
}
entity "user" { entity "user" {
*id : uuid <<generated>> *id : uuid <<generated>>
-- --
@ -169,6 +176,7 @@ media_upload "0..*" -- "1" note
note "1" -d- "1..*" revision note "1" -d- "1..*" revision
note "1" - "0..*" history_entry note "1" - "0..*" history_entry
note "0..*" -l- "0..*" tag note "0..*" -l- "0..*" tag
note "1" - "0..*" alias
note "0..*" -- "0..*" group note "0..*" -- "0..*" group
user "1..*" -- "0..*" group user "1..*" -- "0..*" group

View file

@ -18,7 +18,7 @@ Each note in HedgeDoc 2 contains the following information:
The `publicId` is the default possibility of identifying a note. It will be a randomly generated 128-bit value encoded with [base32-encode](https://www.npmjs.com/package/base32-encode) using the crockford variant and converted to lowercase. This variant of base32 is used, because that results in ids that only use one case of alpha-numeric characters and other url safe characters. We convert the id to lowercase, because we want to minimize case confusion. The `publicId` is the default possibility of identifying a note. It will be a randomly generated 128-bit value encoded with [base32-encode](https://www.npmjs.com/package/base32-encode) using the crockford variant and converted to lowercase. This variant of base32 is used, because that results in ids that only use one case of alpha-numeric characters and other url safe characters. We convert the id to lowercase, because we want to minimize case confusion.
`aliases` are the other way of identifying a note. There can be any number of them, and the owner of the note is able to add or remove them. All aliases are just strings (especially to accommodate the old identifier from HedgeDoc 1 [see below](#conversion-of-hedgedoc-1-notes)), but new aliases added with HedgeDoc 2 will only allow characters matching this regex: `[a-z0-9\-_]`. This is done to once again prevent case confusion. `aliases` are the other way of identifying a note. There can be any number of them, and the owner of the note is able to add or remove them. All aliases are just strings (especially to accommodate the old identifier from HedgeDoc 1 [see below](#conversion-of-hedgedoc-1-notes)), but new aliases added with HedgeDoc 2 will only allow characters matching this regex: `[a-z0-9\-_]`. This is done to once again prevent case confusion. One of the aliases can be set as the primary alias, which will be used as the identifier for the history entry.
`groupPermissions` and `userPermissions` each hold a list of the appropriate permissions. `groupPermissions` and `userPermissions` each hold a list of the appropriate permissions.
Each permission holds a reference to a note and a user/group and specify what the user/group is allowed to do. Each permission holds a reference to a note and a user/group and specify what the user/group is allowed to do.

View file

@ -0,0 +1,123 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ConfigModule } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import {
getConnectionToken,
getRepositoryToken,
TypeOrmModule,
} from '@nestjs/typeorm';
import { AuthToken } from '../../../auth/auth-token.entity';
import { Author } from '../../../authors/author.entity';
import appConfigMock from '../../../config/mock/app.config.mock';
import mediaConfigMock from '../../../config/mock/media.config.mock';
import { Group } from '../../../groups/group.entity';
import { GroupsModule } from '../../../groups/groups.module';
import { HistoryEntry } from '../../../history/history-entry.entity';
import { HistoryModule } from '../../../history/history.module';
import { Identity } from '../../../identity/identity.entity';
import { LoggerModule } from '../../../logger/logger.module';
import { MediaUpload } from '../../../media/media-upload.entity';
import { MediaModule } from '../../../media/media.module';
import { Alias } from '../../../notes/alias.entity';
import { AliasService } from '../../../notes/alias.service';
import { Note } from '../../../notes/note.entity';
import { NotesService } from '../../../notes/notes.service';
import { Tag } from '../../../notes/tag.entity';
import { NoteGroupPermission } from '../../../permissions/note-group-permission.entity';
import { NoteUserPermission } from '../../../permissions/note-user-permission.entity';
import { PermissionsModule } from '../../../permissions/permissions.module';
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 { AliasController } from './alias.controller';
describe('AliasController', () => {
let controller: AliasController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AliasController],
providers: [
AliasService,
NotesService,
{
provide: getRepositoryToken(Note),
useValue: {},
},
{
provide: getRepositoryToken(Tag),
useValue: {},
},
{
provide: getRepositoryToken(Alias),
useValue: {},
},
{
provide: getRepositoryToken(User),
useValue: {},
},
],
imports: [
RevisionsModule,
UsersModule,
GroupsModule,
LoggerModule,
PermissionsModule,
HistoryModule,
MediaModule,
ConfigModule.forRoot({
isGlobal: true,
load: [appConfigMock, mediaConfigMock],
}),
TypeOrmModule.forRoot(),
],
})
.overrideProvider(getConnectionToken())
.useValue({})
.overrideProvider(getRepositoryToken(Revision))
.useValue({})
.overrideProvider(getRepositoryToken(Edit))
.useValue({})
.overrideProvider(getRepositoryToken(User))
.useValue({})
.overrideProvider(getRepositoryToken(AuthToken))
.useValue({})
.overrideProvider(getRepositoryToken(Identity))
.useValue({})
.overrideProvider(getRepositoryToken(Note))
.useValue({})
.overrideProvider(getRepositoryToken(Tag))
.useValue({})
.overrideProvider(getRepositoryToken(HistoryEntry))
.useValue({})
.overrideProvider(getRepositoryToken(NoteGroupPermission))
.useValue({})
.overrideProvider(getRepositoryToken(NoteUserPermission))
.useValue({})
.overrideProvider(getRepositoryToken(Group))
.useValue({})
.overrideProvider(getRepositoryToken(MediaUpload))
.useValue({})
.overrideProvider(getRepositoryToken(Alias))
.useValue({})
.overrideProvider(getRepositoryToken(Session))
.useValue({})
.overrideProvider(getRepositoryToken(Author))
.useValue({})
.compile();
controller = module.get<AliasController>(AliasController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View file

@ -0,0 +1,140 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
BadRequestException,
Body,
Controller,
Delete,
HttpCode,
NotFoundException,
Param,
Post,
Put,
Req,
UnauthorizedException,
} from '@nestjs/common';
import { Request } from 'express';
import {
AlreadyInDBError,
ForbiddenIdError,
NotInDBError,
PrimaryAliasDeletionForbiddenError,
} from '../../../errors/errors';
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
import { AliasCreateDto } from '../../../notes/alias-create.dto';
import { AliasUpdateDto } from '../../../notes/alias-update.dto';
import { AliasDto } from '../../../notes/alias.dto';
import { AliasService } from '../../../notes/alias.service';
import { NotesService } from '../../../notes/notes.service';
import { PermissionsService } from '../../../permissions/permissions.service';
import { UsersService } from '../../../users/users.service';
@Controller('alias')
export class AliasController {
constructor(
private readonly logger: ConsoleLoggerService,
private aliasService: AliasService,
private noteService: NotesService,
private userService: UsersService,
private permissionsService: PermissionsService,
) {
this.logger.setContext(AliasController.name);
}
@Post()
async addAlias(
@Req() req: Request,
@Body() newAliasDto: AliasCreateDto,
): Promise<AliasDto> {
try {
// ToDo: use actual user here
const user = await this.userService.getUserByUsername('hardcoded');
const note = await this.noteService.getNoteByIdOrAlias(
newAliasDto.noteIdOrAlias,
);
if (!this.permissionsService.isOwner(user, note)) {
throw new UnauthorizedException('Reading note denied!');
}
const updatedAlias = await this.aliasService.addAlias(
note,
newAliasDto.newAlias,
);
return this.aliasService.toAliasDto(updatedAlias, note);
} catch (e) {
if (e instanceof AlreadyInDBError) {
throw new BadRequestException(e.message);
}
if (e instanceof ForbiddenIdError) {
throw new BadRequestException(e.message);
}
throw e;
}
}
@Put(':alias')
async makeAliasPrimary(
@Req() req: Request,
@Param('alias') alias: string,
@Body() changeAliasDto: AliasUpdateDto,
): Promise<AliasDto> {
if (!changeAliasDto.primaryAlias) {
throw new BadRequestException(
`The field 'primaryAlias' must be set to 'true'.`,
);
}
try {
// ToDo: use actual user here
const user = await this.userService.getUserByUsername('hardcoded');
const note = await this.noteService.getNoteByIdOrAlias(alias);
if (!this.permissionsService.isOwner(user, note)) {
throw new UnauthorizedException('Reading note denied!');
}
const updatedAlias = await this.aliasService.makeAliasPrimary(
note,
alias,
);
return this.aliasService.toAliasDto(updatedAlias, note);
} catch (e) {
if (e instanceof NotInDBError) {
throw new NotFoundException(e.message);
}
if (e instanceof ForbiddenIdError) {
throw new BadRequestException(e.message);
}
throw e;
}
}
@Delete(':alias')
@HttpCode(204)
async removeAlias(
@Req() req: Request,
@Param('alias') alias: string,
): Promise<void> {
try {
// ToDo: use actual user here
const user = await this.userService.getUserByUsername('hardcoded');
const note = await this.noteService.getNoteByIdOrAlias(alias);
if (!this.permissionsService.isOwner(user, note)) {
throw new UnauthorizedException('Reading note denied!');
}
await this.aliasService.removeAlias(note, alias);
return;
} catch (e) {
if (e instanceof NotInDBError) {
throw new NotFoundException(e.message);
}
if (e instanceof PrimaryAliasDeletionForbiddenError) {
throw new BadRequestException(e.message);
}
if (e instanceof ForbiddenIdError) {
throw new BadRequestException(e.message);
}
throw e;
}
}
}

View file

@ -19,6 +19,7 @@ import { HistoryEntry } from '../../../../history/history-entry.entity';
import { HistoryModule } from '../../../../history/history.module'; import { HistoryModule } from '../../../../history/history.module';
import { Identity } from '../../../../identity/identity.entity'; import { Identity } from '../../../../identity/identity.entity';
import { LoggerModule } from '../../../../logger/logger.module'; import { LoggerModule } from '../../../../logger/logger.module';
import { Alias } from '../../../../notes/alias.entity';
import { Note } from '../../../../notes/note.entity'; import { Note } from '../../../../notes/note.entity';
import { NotesModule } from '../../../../notes/notes.module'; import { NotesModule } from '../../../../notes/notes.module';
import { Tag } from '../../../../notes/tag.entity'; import { Tag } from '../../../../notes/tag.entity';
@ -73,6 +74,8 @@ describe('HistoryController', () => {
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Group)) .overrideProvider(getRepositoryToken(Group))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Alias))
.useValue({})
.overrideProvider(getRepositoryToken(Author)) .overrideProvider(getRepositoryToken(Author))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Session)) .overrideProvider(getRepositoryToken(Session))

View file

@ -18,6 +18,7 @@ import { Identity } from '../../../identity/identity.entity';
import { LoggerModule } from '../../../logger/logger.module'; import { LoggerModule } from '../../../logger/logger.module';
import { MediaUpload } from '../../../media/media-upload.entity'; import { MediaUpload } from '../../../media/media-upload.entity';
import { MediaModule } from '../../../media/media.module'; import { MediaModule } from '../../../media/media.module';
import { Alias } from '../../../notes/alias.entity';
import { Note } from '../../../notes/note.entity'; import { Note } from '../../../notes/note.entity';
import { Tag } from '../../../notes/tag.entity'; import { Tag } from '../../../notes/tag.entity';
import { NoteGroupPermission } from '../../../permissions/note-group-permission.entity'; import { NoteGroupPermission } from '../../../permissions/note-group-permission.entity';
@ -71,6 +72,8 @@ describe('MeController', () => {
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(MediaUpload)) .overrideProvider(getRepositoryToken(MediaUpload))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Alias))
.useValue({})
.overrideProvider(getRepositoryToken(Session)) .overrideProvider(getRepositoryToken(Session))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Author)) .overrideProvider(getRepositoryToken(Author))

View file

@ -19,6 +19,7 @@ import { Identity } from '../../../identity/identity.entity';
import { LoggerModule } from '../../../logger/logger.module'; import { LoggerModule } from '../../../logger/logger.module';
import { MediaUpload } from '../../../media/media-upload.entity'; import { MediaUpload } from '../../../media/media-upload.entity';
import { MediaModule } from '../../../media/media.module'; import { MediaModule } from '../../../media/media.module';
import { Alias } from '../../../notes/alias.entity';
import { Note } from '../../../notes/note.entity'; import { Note } from '../../../notes/note.entity';
import { NotesModule } from '../../../notes/notes.module'; import { NotesModule } from '../../../notes/notes.module';
import { Tag } from '../../../notes/tag.entity'; import { Tag } from '../../../notes/tag.entity';
@ -76,6 +77,8 @@ describe('MediaController', () => {
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Group)) .overrideProvider(getRepositoryToken(Group))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Alias))
.useValue({})
.overrideProvider(getRepositoryToken(Session)) .overrideProvider(getRepositoryToken(Session))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Author)) .overrideProvider(getRepositoryToken(Author))

View file

@ -23,6 +23,7 @@ import { Identity } from '../../../identity/identity.entity';
import { LoggerModule } from '../../../logger/logger.module'; import { LoggerModule } from '../../../logger/logger.module';
import { MediaUpload } from '../../../media/media-upload.entity'; import { MediaUpload } from '../../../media/media-upload.entity';
import { MediaModule } from '../../../media/media.module'; import { MediaModule } from '../../../media/media.module';
import { Alias } from '../../../notes/alias.entity';
import { Note } from '../../../notes/note.entity'; import { Note } from '../../../notes/note.entity';
import { NotesService } from '../../../notes/notes.service'; import { NotesService } from '../../../notes/notes.service';
import { Tag } from '../../../notes/tag.entity'; import { Tag } from '../../../notes/tag.entity';
@ -53,6 +54,10 @@ describe('NotesController', () => {
provide: getRepositoryToken(Tag), provide: getRepositoryToken(Tag),
useValue: {}, useValue: {},
}, },
{
provide: getRepositoryToken(Alias),
useValue: {},
},
{ {
provide: getRepositoryToken(User), provide: getRepositoryToken(User),
useValue: {}, useValue: {},
@ -99,6 +104,8 @@ describe('NotesController', () => {
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(MediaUpload)) .overrideProvider(getRepositoryToken(MediaUpload))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Alias))
.useValue({})
.overrideProvider(getRepositoryToken(Session)) .overrideProvider(getRepositoryToken(Session))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Author)) .overrideProvider(getRepositoryToken(Author))

View file

@ -15,6 +15,7 @@ import { NotesModule } from '../../notes/notes.module';
import { PermissionsModule } from '../../permissions/permissions.module'; import { PermissionsModule } from '../../permissions/permissions.module';
import { RevisionsModule } from '../../revisions/revisions.module'; import { RevisionsModule } from '../../revisions/revisions.module';
import { UsersModule } from '../../users/users.module'; import { UsersModule } from '../../users/users.module';
import { AliasController } from './alias/alias.controller';
import { AuthController } from './auth/auth.controller'; import { AuthController } from './auth/auth.controller';
import { ConfigController } from './config/config.controller'; import { ConfigController } from './config/config.controller';
import { HistoryController } from './me/history/history.controller'; import { HistoryController } from './me/history/history.controller';
@ -43,6 +44,7 @@ import { TokensController } from './tokens/tokens.controller';
HistoryController, HistoryController,
MeController, MeController,
NotesController, NotesController,
AliasController,
AuthController, AuthController,
], ],
}) })

View file

@ -0,0 +1,123 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ConfigModule } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import {
getConnectionToken,
getRepositoryToken,
TypeOrmModule,
} from '@nestjs/typeorm';
import { AuthToken } from '../../../auth/auth-token.entity';
import { Author } from '../../../authors/author.entity';
import appConfigMock from '../../../config/mock/app.config.mock';
import mediaConfigMock from '../../../config/mock/media.config.mock';
import { Group } from '../../../groups/group.entity';
import { GroupsModule } from '../../../groups/groups.module';
import { HistoryEntry } from '../../../history/history-entry.entity';
import { HistoryModule } from '../../../history/history.module';
import { Identity } from '../../../identity/identity.entity';
import { LoggerModule } from '../../../logger/logger.module';
import { MediaUpload } from '../../../media/media-upload.entity';
import { MediaModule } from '../../../media/media.module';
import { Alias } from '../../../notes/alias.entity';
import { AliasService } from '../../../notes/alias.service';
import { Note } from '../../../notes/note.entity';
import { NotesService } from '../../../notes/notes.service';
import { Tag } from '../../../notes/tag.entity';
import { NoteGroupPermission } from '../../../permissions/note-group-permission.entity';
import { NoteUserPermission } from '../../../permissions/note-user-permission.entity';
import { PermissionsModule } from '../../../permissions/permissions.module';
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 { AliasController } from './alias.controller';
describe('AliasController', () => {
let controller: AliasController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AliasController],
providers: [
AliasService,
NotesService,
{
provide: getRepositoryToken(Note),
useValue: {},
},
{
provide: getRepositoryToken(Tag),
useValue: {},
},
{
provide: getRepositoryToken(Alias),
useValue: {},
},
{
provide: getRepositoryToken(User),
useValue: {},
},
],
imports: [
RevisionsModule,
UsersModule,
GroupsModule,
LoggerModule,
PermissionsModule,
HistoryModule,
MediaModule,
ConfigModule.forRoot({
isGlobal: true,
load: [appConfigMock, mediaConfigMock],
}),
TypeOrmModule.forRoot(),
],
})
.overrideProvider(getConnectionToken())
.useValue({})
.overrideProvider(getRepositoryToken(Revision))
.useValue({})
.overrideProvider(getRepositoryToken(Edit))
.useValue({})
.overrideProvider(getRepositoryToken(User))
.useValue({})
.overrideProvider(getRepositoryToken(AuthToken))
.useValue({})
.overrideProvider(getRepositoryToken(Identity))
.useValue({})
.overrideProvider(getRepositoryToken(Note))
.useValue({})
.overrideProvider(getRepositoryToken(Tag))
.useValue({})
.overrideProvider(getRepositoryToken(HistoryEntry))
.useValue({})
.overrideProvider(getRepositoryToken(NoteGroupPermission))
.useValue({})
.overrideProvider(getRepositoryToken(NoteUserPermission))
.useValue({})
.overrideProvider(getRepositoryToken(Group))
.useValue({})
.overrideProvider(getRepositoryToken(MediaUpload))
.useValue({})
.overrideProvider(getRepositoryToken(Alias))
.useValue({})
.overrideProvider(getRepositoryToken(Session))
.useValue({})
.overrideProvider(getRepositoryToken(Author))
.useValue({})
.compile();
controller = module.get<AliasController>(AliasController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View file

@ -0,0 +1,159 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
BadRequestException,
Body,
Controller,
Delete,
HttpCode,
NotFoundException,
Param,
Post,
Put,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import {
ApiNoContentResponse,
ApiOkResponse,
ApiSecurity,
ApiTags,
} from '@nestjs/swagger';
import { TokenAuthGuard } from '../../../auth/token.strategy';
import {
AlreadyInDBError,
ForbiddenIdError,
NotInDBError,
PrimaryAliasDeletionForbiddenError,
} from '../../../errors/errors';
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
import { AliasCreateDto } from '../../../notes/alias-create.dto';
import { AliasUpdateDto } from '../../../notes/alias-update.dto';
import { AliasDto } from '../../../notes/alias.dto';
import { AliasService } from '../../../notes/alias.service';
import { NotesService } from '../../../notes/notes.service';
import { PermissionsService } from '../../../permissions/permissions.service';
import { User } from '../../../users/user.entity';
import { FullApi } from '../../utils/fullapi-decorator';
import { RequestUser } from '../../utils/request-user.decorator';
@ApiTags('alias')
@ApiSecurity('token')
@Controller('alias')
export class AliasController {
constructor(
private readonly logger: ConsoleLoggerService,
private aliasService: AliasService,
private noteService: NotesService,
private permissionsService: PermissionsService,
) {
this.logger.setContext(AliasController.name);
}
@UseGuards(TokenAuthGuard)
@Post()
@ApiOkResponse({
description: 'The new alias',
type: AliasDto,
})
@FullApi
async addAlias(
@RequestUser() user: User,
@Body() newAliasDto: AliasCreateDto,
): Promise<AliasDto> {
try {
const note = await this.noteService.getNoteByIdOrAlias(
newAliasDto.noteIdOrAlias,
);
if (!this.permissionsService.isOwner(user, note)) {
throw new UnauthorizedException('Reading note denied!');
}
const updatedAlias = await this.aliasService.addAlias(
note,
newAliasDto.newAlias,
);
return this.aliasService.toAliasDto(updatedAlias, note);
} catch (e) {
if (e instanceof AlreadyInDBError) {
throw new BadRequestException(e.message);
}
if (e instanceof ForbiddenIdError) {
throw new BadRequestException(e.message);
}
throw e;
}
}
@UseGuards(TokenAuthGuard)
@Put(':alias')
@ApiOkResponse({
description: 'The updated alias',
type: AliasDto,
})
@FullApi
async makeAliasPrimary(
@RequestUser() user: User,
@Param('alias') alias: string,
@Body() changeAliasDto: AliasUpdateDto,
): Promise<AliasDto> {
if (!changeAliasDto.primaryAlias) {
throw new BadRequestException(
`The field 'primaryAlias' must be set to 'true'.`,
);
}
try {
const note = await this.noteService.getNoteByIdOrAlias(alias);
if (!this.permissionsService.isOwner(user, note)) {
throw new UnauthorizedException('Reading note denied!');
}
const updatedAlias = await this.aliasService.makeAliasPrimary(
note,
alias,
);
return this.aliasService.toAliasDto(updatedAlias, note);
} catch (e) {
if (e instanceof NotInDBError) {
throw new NotFoundException(e.message);
}
if (e instanceof ForbiddenIdError) {
throw new BadRequestException(e.message);
}
throw e;
}
}
@UseGuards(TokenAuthGuard)
@Delete(':alias')
@HttpCode(204)
@ApiNoContentResponse({
description: 'The alias was deleted',
})
@FullApi
async removeAlias(
@RequestUser() user: User,
@Param('alias') alias: string,
): Promise<void> {
try {
const note = await this.noteService.getNoteByIdOrAlias(alias);
if (!this.permissionsService.isOwner(user, note)) {
throw new UnauthorizedException('Reading note denied!');
}
await this.aliasService.removeAlias(note, alias);
} catch (e) {
if (e instanceof NotInDBError) {
throw new NotFoundException(e.message);
}
if (e instanceof PrimaryAliasDeletionForbiddenError) {
throw new BadRequestException(e.message);
}
if (e instanceof ForbiddenIdError) {
throw new BadRequestException(e.message);
}
throw e;
}
}
}

View file

@ -22,6 +22,7 @@ import { Identity } from '../../../identity/identity.entity';
import { LoggerModule } from '../../../logger/logger.module'; import { LoggerModule } from '../../../logger/logger.module';
import { MediaUpload } from '../../../media/media-upload.entity'; import { MediaUpload } from '../../../media/media-upload.entity';
import { MediaModule } from '../../../media/media.module'; import { MediaModule } from '../../../media/media.module';
import { Alias } from '../../../notes/alias.entity';
import { Note } from '../../../notes/note.entity'; import { Note } from '../../../notes/note.entity';
import { NotesModule } from '../../../notes/notes.module'; import { NotesModule } from '../../../notes/notes.module';
import { Tag } from '../../../notes/tag.entity'; import { Tag } from '../../../notes/tag.entity';
@ -79,6 +80,8 @@ describe('Me Controller', () => {
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(MediaUpload)) .overrideProvider(getRepositoryToken(MediaUpload))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Alias))
.useValue({})
.overrideProvider(getRepositoryToken(Session)) .overrideProvider(getRepositoryToken(Session))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Author)) .overrideProvider(getRepositoryToken(Author))

View file

@ -16,6 +16,7 @@ import { Identity } from '../../../identity/identity.entity';
import { LoggerModule } from '../../../logger/logger.module'; import { LoggerModule } from '../../../logger/logger.module';
import { MediaUpload } from '../../../media/media-upload.entity'; import { MediaUpload } from '../../../media/media-upload.entity';
import { MediaModule } from '../../../media/media.module'; import { MediaModule } from '../../../media/media.module';
import { Alias } from '../../../notes/alias.entity';
import { Note } from '../../../notes/note.entity'; import { Note } from '../../../notes/note.entity';
import { NotesModule } from '../../../notes/notes.module'; import { NotesModule } from '../../../notes/notes.module';
import { Tag } from '../../../notes/tag.entity'; import { Tag } from '../../../notes/tag.entity';
@ -65,6 +66,8 @@ describe('Media Controller', () => {
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Group)) .overrideProvider(getRepositoryToken(Group))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Alias))
.useValue({})
.overrideProvider(getRepositoryToken(Session)) .overrideProvider(getRepositoryToken(Session))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Author)) .overrideProvider(getRepositoryToken(Author))

View file

@ -23,6 +23,7 @@ import { Identity } from '../../../identity/identity.entity';
import { LoggerModule } from '../../../logger/logger.module'; import { LoggerModule } from '../../../logger/logger.module';
import { MediaUpload } from '../../../media/media-upload.entity'; import { MediaUpload } from '../../../media/media-upload.entity';
import { MediaModule } from '../../../media/media.module'; import { MediaModule } from '../../../media/media.module';
import { Alias } from '../../../notes/alias.entity';
import { Note } from '../../../notes/note.entity'; import { Note } from '../../../notes/note.entity';
import { NotesService } from '../../../notes/notes.service'; import { NotesService } from '../../../notes/notes.service';
import { Tag } from '../../../notes/tag.entity'; import { Tag } from '../../../notes/tag.entity';
@ -53,6 +54,10 @@ describe('Notes Controller', () => {
provide: getRepositoryToken(Tag), provide: getRepositoryToken(Tag),
useValue: {}, useValue: {},
}, },
{
provide: getRepositoryToken(Alias),
useValue: {},
},
{ {
provide: getRepositoryToken(User), provide: getRepositoryToken(User),
useValue: {}, useValue: {},
@ -101,6 +106,8 @@ describe('Notes Controller', () => {
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(MediaUpload)) .overrideProvider(getRepositoryToken(MediaUpload))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Alias))
.useValue({})
.overrideProvider(getRepositoryToken(Session)) .overrideProvider(getRepositoryToken(Session))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Author)) .overrideProvider(getRepositoryToken(Author))

View file

@ -13,6 +13,7 @@ import { NotesModule } from '../../notes/notes.module';
import { PermissionsModule } from '../../permissions/permissions.module'; import { PermissionsModule } from '../../permissions/permissions.module';
import { RevisionsModule } from '../../revisions/revisions.module'; import { RevisionsModule } from '../../revisions/revisions.module';
import { UsersModule } from '../../users/users.module'; import { UsersModule } from '../../users/users.module';
import { AliasController } from './alias/alias.controller';
import { MeController } from './me/me.controller'; import { MeController } from './me/me.controller';
import { MediaController } from './media/media.controller'; import { MediaController } from './media/media.controller';
import { MonitoringController } from './monitoring/monitoring.controller'; import { MonitoringController } from './monitoring/monitoring.controller';
@ -30,6 +31,7 @@ import { NotesController } from './notes/notes.controller';
PermissionsModule, PermissionsModule,
], ],
controllers: [ controllers: [
AliasController,
MeController, MeController,
NotesController, NotesController,
MediaController, MediaController,

View file

@ -39,3 +39,7 @@ export class PermissionsUpdateInconsistentError extends Error {
export class MediaBackendError extends Error { export class MediaBackendError extends Error {
name = 'MediaBackendError'; name = 'MediaBackendError';
} }
export class PrimaryAliasDeletionForbiddenError extends Error {
name = 'PrimaryAliasDeletionForbiddenError';
}

View file

@ -15,6 +15,7 @@ import { NotInDBError } from '../errors/errors';
import { Group } from '../groups/group.entity'; import { Group } from '../groups/group.entity';
import { Identity } from '../identity/identity.entity'; import { Identity } from '../identity/identity.entity';
import { LoggerModule } from '../logger/logger.module'; import { LoggerModule } from '../logger/logger.module';
import { Alias } from '../notes/alias.entity';
import { Note } from '../notes/note.entity'; import { Note } from '../notes/note.entity';
import { NotesModule } from '../notes/notes.module'; import { NotesModule } from '../notes/notes.module';
import { Tag } from '../notes/tag.entity'; import { Tag } from '../notes/tag.entity';
@ -34,6 +35,7 @@ describe('HistoryService', () => {
let historyRepo: Repository<HistoryEntry>; let historyRepo: Repository<HistoryEntry>;
let connection; let connection;
let noteRepo: Repository<Note>; let noteRepo: Repository<Note>;
let aliasRepo: Repository<Alias>;
type MockConnection = { type MockConnection = {
transaction: () => void; transaction: () => void;
@ -92,12 +94,15 @@ describe('HistoryService', () => {
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Author)) .overrideProvider(getRepositoryToken(Author))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Alias))
.useClass(Repository)
.compile(); .compile();
service = module.get<HistoryService>(HistoryService); service = module.get<HistoryService>(HistoryService);
historyRepo = module.get<Repository<HistoryEntry>>( historyRepo = module.get<Repository<HistoryEntry>>(
getRepositoryToken(HistoryEntry), getRepositoryToken(HistoryEntry),
); );
aliasRepo = module.get<Repository<Alias>>(getRepositoryToken(Alias));
connection = module.get<Connection>(Connection); connection = module.get<Connection>(Connection);
noteRepo = module.get<Repository<Note>>(getRepositoryToken(Note)); noteRepo = module.get<Repository<Note>>(getRepositoryToken(Note));
}); });
@ -151,7 +156,8 @@ describe('HistoryService', () => {
Note.create(user, alias), Note.create(user, alias),
user, user,
); );
expect(createHistoryEntry.note.alias).toEqual(alias); expect(createHistoryEntry.note.aliases).toHaveLength(1);
expect(createHistoryEntry.note.aliases[0].name).toEqual(alias);
expect(createHistoryEntry.note.owner).toEqual(user); expect(createHistoryEntry.note.owner).toEqual(user);
expect(createHistoryEntry.user).toEqual(user); expect(createHistoryEntry.user).toEqual(user);
expect(createHistoryEntry.pinStatus).toEqual(false); expect(createHistoryEntry.pinStatus).toEqual(false);
@ -168,7 +174,8 @@ describe('HistoryService', () => {
Note.create(user, alias), Note.create(user, alias),
user, user,
); );
expect(createHistoryEntry.note.alias).toEqual(alias); expect(createHistoryEntry.note.aliases).toHaveLength(1);
expect(createHistoryEntry.note.aliases[0].name).toEqual(alias);
expect(createHistoryEntry.note.owner).toEqual(user); expect(createHistoryEntry.note.owner).toEqual(user);
expect(createHistoryEntry.user).toEqual(user); expect(createHistoryEntry.user).toEqual(user);
expect(createHistoryEntry.pinStatus).toEqual(false); expect(createHistoryEntry.pinStatus).toEqual(false);
@ -180,14 +187,27 @@ describe('HistoryService', () => {
}); });
describe('updateHistoryEntry', () => { describe('updateHistoryEntry', () => {
describe('works', () => {
it('with an entry', async () => {
const user = {} as User; const user = {} as User;
const alias = 'alias'; const alias = 'alias';
const note = Note.create(user, alias); const note = Note.create(user, alias);
beforeEach(() => {
const createQueryBuilder = {
leftJoinAndSelect: () => createQueryBuilder,
where: () => createQueryBuilder,
orWhere: () => createQueryBuilder,
setParameter: () => createQueryBuilder,
getOne: () => note,
};
jest
.spyOn(noteRepo, 'createQueryBuilder')
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
.mockImplementation(() => createQueryBuilder);
});
describe('works', () => {
it('with an entry', async () => {
const historyEntry = HistoryEntry.create(user, note); const historyEntry = HistoryEntry.create(user, note);
jest.spyOn(historyRepo, 'findOne').mockResolvedValueOnce(historyEntry); jest.spyOn(historyRepo, 'findOne').mockResolvedValueOnce(historyEntry);
jest.spyOn(noteRepo, 'findOne').mockResolvedValueOnce(note);
jest jest
.spyOn(historyRepo, 'save') .spyOn(historyRepo, 'save')
.mockImplementation( .mockImplementation(
@ -200,18 +220,15 @@ describe('HistoryService', () => {
pinStatus: true, pinStatus: true,
}, },
); );
expect(updatedHistoryEntry.note.alias).toEqual(alias); expect(updatedHistoryEntry.note.aliases).toHaveLength(1);
expect(updatedHistoryEntry.note.aliases[0].name).toEqual(alias);
expect(updatedHistoryEntry.note.owner).toEqual(user); expect(updatedHistoryEntry.note.owner).toEqual(user);
expect(updatedHistoryEntry.user).toEqual(user); expect(updatedHistoryEntry.user).toEqual(user);
expect(updatedHistoryEntry.pinStatus).toEqual(true); expect(updatedHistoryEntry.pinStatus).toEqual(true);
}); });
it('without an entry', async () => { it('without an entry', async () => {
const user = {} as User;
const alias = 'alias';
const note = Note.create(user, alias);
jest.spyOn(historyRepo, 'findOne').mockResolvedValueOnce(undefined); jest.spyOn(historyRepo, 'findOne').mockResolvedValueOnce(undefined);
jest.spyOn(noteRepo, 'findOne').mockResolvedValueOnce(note);
await expect( await expect(
service.updateHistoryEntry(note, user, { service.updateHistoryEntry(note, user, {
pinStatus: true, pinStatus: true,
@ -278,7 +295,18 @@ describe('HistoryService', () => {
const note = Note.create(user, alias); const note = Note.create(user, alias);
const historyEntry = HistoryEntry.create(user, note); const historyEntry = HistoryEntry.create(user, note);
jest.spyOn(historyRepo, 'findOne').mockResolvedValueOnce(historyEntry); jest.spyOn(historyRepo, 'findOne').mockResolvedValueOnce(historyEntry);
jest.spyOn(noteRepo, 'findOne').mockResolvedValueOnce(note); const createQueryBuilder = {
leftJoinAndSelect: () => createQueryBuilder,
where: () => createQueryBuilder,
orWhere: () => createQueryBuilder,
setParameter: () => createQueryBuilder,
getOne: () => note,
};
jest
.spyOn(noteRepo, 'createQueryBuilder')
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
.mockImplementation(() => createQueryBuilder);
jest jest
.spyOn(historyRepo, 'remove') .spyOn(historyRepo, 'remove')
.mockImplementation( .mockImplementation(
@ -295,8 +323,19 @@ describe('HistoryService', () => {
const alias = 'alias'; const alias = 'alias';
it('without an entry', async () => { it('without an entry', async () => {
const note = Note.create(user, alias); const note = Note.create(user, alias);
const createQueryBuilder = {
leftJoinAndSelect: () => createQueryBuilder,
where: () => createQueryBuilder,
orWhere: () => createQueryBuilder,
setParameter: () => createQueryBuilder,
getOne: () => note,
};
jest
.spyOn(noteRepo, 'createQueryBuilder')
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
.mockImplementation(() => createQueryBuilder);
jest.spyOn(historyRepo, 'findOne').mockResolvedValueOnce(undefined); jest.spyOn(historyRepo, 'findOne').mockResolvedValueOnce(undefined);
jest.spyOn(noteRepo, 'findOne').mockResolvedValueOnce(note);
await expect(service.deleteHistoryEntry(note, user)).rejects.toThrow( await expect(service.deleteHistoryEntry(note, user)).rejects.toThrow(
NotInDBError, NotInDBError,
); );
@ -320,15 +359,24 @@ describe('HistoryService', () => {
pinStatus: historyEntryImport.pinStatus, pinStatus: historyEntryImport.pinStatus,
updatedAt: historyEntryImport.lastVisited, updatedAt: historyEntryImport.lastVisited,
}; };
const createQueryBuilder = {
innerJoin: () => createQueryBuilder,
where: () => createQueryBuilder,
orWhere: () => createQueryBuilder,
getOne: () => note,
};
const mockedManager = { const mockedManager = {
find: jest.fn().mockResolvedValueOnce([historyEntry]), find: jest.fn().mockResolvedValueOnce([historyEntry]),
findOne: jest.fn().mockResolvedValueOnce(note), createQueryBuilder: () => createQueryBuilder,
remove: jest.fn().mockImplementationOnce((entry: HistoryEntry) => { remove: jest.fn().mockImplementationOnce((entry: HistoryEntry) => {
expect(entry.note.alias).toEqual(alias); expect(entry.note.aliases).toHaveLength(1);
expect(entry.note.aliases[0].name).toEqual(alias);
expect(entry.pinStatus).toEqual(false); expect(entry.pinStatus).toEqual(false);
}), }),
save: jest.fn().mockImplementationOnce((entry: HistoryEntry) => { save: jest.fn().mockImplementationOnce((entry: HistoryEntry) => {
expect(entry.note.alias).toEqual(newlyCreatedHistoryEntry.note.alias); expect(entry.note.aliases).toEqual(
newlyCreatedHistoryEntry.note.aliases,
);
expect(entry.pinStatus).toEqual(newlyCreatedHistoryEntry.pinStatus); expect(entry.pinStatus).toEqual(newlyCreatedHistoryEntry.pinStatus);
expect(entry.updatedAt).toEqual(newlyCreatedHistoryEntry.updatedAt); expect(entry.updatedAt).toEqual(newlyCreatedHistoryEntry.updatedAt);
}), }),
@ -358,36 +406,23 @@ describe('HistoryService', () => {
}); });
const historyEntry = HistoryEntry.create(user, note); const historyEntry = HistoryEntry.create(user, note);
historyEntry.pinStatus = true; historyEntry.pinStatus = true;
jest.spyOn(noteRepo, 'findOne').mockResolvedValueOnce(note); const createQueryBuilder = {
leftJoinAndSelect: () => createQueryBuilder,
where: () => createQueryBuilder,
orWhere: () => createQueryBuilder,
getOne: () => note,
};
jest
.spyOn(noteRepo, 'createQueryBuilder')
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
.mockImplementation(() => createQueryBuilder);
const historyEntryDto = service.toHistoryEntryDto(historyEntry); const historyEntryDto = service.toHistoryEntryDto(historyEntry);
expect(historyEntryDto.pinStatus).toEqual(true); expect(historyEntryDto.pinStatus).toEqual(true);
expect(historyEntryDto.identifier).toEqual(alias); expect(historyEntryDto.identifier).toEqual(alias);
expect(historyEntryDto.tags).toEqual(tags); expect(historyEntryDto.tags).toEqual(tags);
expect(historyEntryDto.title).toEqual(title); expect(historyEntryDto.title).toEqual(title);
}); });
it('with regular note', async () => {
const user = {} as User;
const title = 'title';
const id = 'id';
const tags = ['tag1', 'tag2'];
const note = Note.create(user);
note.title = title;
note.id = id;
note.tags = tags.map((tag) => {
const newTag = new Tag();
newTag.name = tag;
return newTag;
});
const historyEntry = HistoryEntry.create(user, note);
historyEntry.pinStatus = true;
jest.spyOn(noteRepo, 'findOne').mockResolvedValueOnce(note);
const historyEntryDto = service.toHistoryEntryDto(historyEntry);
expect(historyEntryDto.pinStatus).toEqual(true);
expect(historyEntryDto.identifier).toEqual(id);
expect(historyEntryDto.tags).toEqual(tags);
expect(historyEntryDto.title).toEqual(title);
});
}); });
}); });
}); });

View file

@ -11,12 +11,14 @@ import { NotInDBError } from '../errors/errors';
import { ConsoleLoggerService } from '../logger/console-logger.service'; import { ConsoleLoggerService } from '../logger/console-logger.service';
import { Note } from '../notes/note.entity'; import { Note } from '../notes/note.entity';
import { NotesService } from '../notes/notes.service'; import { NotesService } from '../notes/notes.service';
import { getPrimaryAlias } from '../notes/utils';
import { User } from '../users/user.entity'; import { User } from '../users/user.entity';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { HistoryEntryImportDto } from './history-entry-import.dto'; import { HistoryEntryImportDto } from './history-entry-import.dto';
import { HistoryEntryUpdateDto } from './history-entry-update.dto'; import { HistoryEntryUpdateDto } from './history-entry-update.dto';
import { HistoryEntryDto } from './history-entry.dto'; import { HistoryEntryDto } from './history-entry.dto';
import { HistoryEntry } from './history-entry.entity'; import { HistoryEntry } from './history-entry.entity';
import { getIdentifier } from './utils';
@Injectable() @Injectable()
export class HistoryService { export class HistoryService {
@ -41,7 +43,7 @@ export class HistoryService {
async getEntriesByUser(user: User): Promise<HistoryEntry[]> { async getEntriesByUser(user: User): Promise<HistoryEntry[]> {
return await this.historyEntryRepository.find({ return await this.historyEntryRepository.find({
where: { user: user }, where: { user: user },
relations: ['note', 'user'], relations: ['note', 'note.aliases', 'user'],
}); });
} }
@ -58,7 +60,7 @@ export class HistoryService {
note: note, note: note,
user: user, user: user,
}, },
relations: ['note', 'user'], relations: ['note', 'note.aliases', 'user'],
}); });
if (!entry) { if (!entry) {
throw new NotInDBError( throw new NotInDBError(
@ -150,23 +152,19 @@ export class HistoryService {
await this.connection.transaction(async (manager) => { await this.connection.transaction(async (manager) => {
const currentHistory = await manager.find<HistoryEntry>(HistoryEntry, { const currentHistory = await manager.find<HistoryEntry>(HistoryEntry, {
where: { user: user }, where: { user: user },
relations: ['note', 'user'], relations: ['note', 'note.aliases', 'user'],
}); });
for (const entry of currentHistory) { for (const entry of currentHistory) {
await manager.remove<HistoryEntry>(entry); await manager.remove<HistoryEntry>(entry);
} }
for (const historyEntry of history) { for (const historyEntry of history) {
this.notesService.checkNoteIdOrAlias(historyEntry.note); this.notesService.checkNoteIdOrAlias(historyEntry.note);
const note = await manager.findOne<Note>(Note, { const note = await manager
where: [ .createQueryBuilder<Note>(Note, 'note')
{ .innerJoin('note.aliases', 'alias')
id: historyEntry.note, .where('note.id = :id', { id: historyEntry.note })
}, .orWhere('alias.name = :id', { id: historyEntry.note })
{ .getOne();
alias: historyEntry.note,
},
],
});
if (note === undefined) { if (note === undefined) {
this.logger.debug( this.logger.debug(
`Could not find note '${historyEntry.note}'`, `Could not find note '${historyEntry.note}'`,
@ -191,7 +189,7 @@ export class HistoryService {
*/ */
toHistoryEntryDto(entry: HistoryEntry): HistoryEntryDto { toHistoryEntryDto(entry: HistoryEntry): HistoryEntryDto {
return { return {
identifier: entry.note.alias ? entry.note.alias : entry.note.id, identifier: getIdentifier(entry),
lastVisited: entry.updatedAt, lastVisited: entry.updatedAt,
tags: this.notesService.toTagList(entry.note), tags: this.notesService.toTagList(entry.note),
title: entry.note.title ?? '', title: entry.note.title ?? '',

36
src/history/utils.spec.ts Normal file
View file

@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Alias } from '../notes/alias.entity';
import { Note } from '../notes/note.entity';
import { User } from '../users/user.entity';
import { HistoryEntry } from './history-entry.entity';
import { getIdentifier } from './utils';
describe('getIdentifier', () => {
const alias = 'alias';
let note: Note;
let entry: HistoryEntry;
beforeEach(() => {
const user = User.create('hardcoded', 'Testy') as User;
note = Note.create(user, alias);
entry = HistoryEntry.create(user, note);
});
it('returns the publicId if there are no aliases', () => {
note.aliases = undefined as unknown as Alias[];
expect(getIdentifier(entry)).toEqual(note.publicId);
});
it('returns the publicId, if the alias array is empty', () => {
note.aliases = [];
expect(getIdentifier(entry)).toEqual(note.publicId);
});
it('returns the publicId, if the only alias is not primary', () => {
note.aliases[0].primary = false;
expect(getIdentifier(entry)).toEqual(note.publicId);
});
it('returns the primary alias, if one exists', () => {
expect(getIdentifier(entry)).toEqual(note.aliases[0].name);
});
});

18
src/history/utils.ts Normal file
View file

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { getPrimaryAlias } from '../notes/utils';
import { HistoryEntry } from './history-entry.entity';
export function getIdentifier(entry: HistoryEntry): string {
if (!entry.note.aliases || entry.note.aliases.length === 0) {
return entry.note.publicId;
}
const primaryAlias = getPrimaryAlias(entry.note);
if (primaryAlias === undefined) {
return entry.note.publicId;
}
return primaryAlias;
}

View file

@ -17,6 +17,7 @@ import { ClientError, NotInDBError } from '../errors/errors';
import { Group } from '../groups/group.entity'; import { Group } from '../groups/group.entity';
import { Identity } from '../identity/identity.entity'; import { Identity } from '../identity/identity.entity';
import { LoggerModule } from '../logger/logger.module'; import { LoggerModule } from '../logger/logger.module';
import { Alias } from '../notes/alias.entity';
import { Note } from '../notes/note.entity'; import { Note } from '../notes/note.entity';
import { NotesModule } from '../notes/notes.module'; import { NotesModule } from '../notes/notes.module';
import { Tag } from '../notes/tag.entity'; import { Tag } from '../notes/tag.entity';
@ -83,6 +84,8 @@ describe('MediaService', () => {
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Author)) .overrideProvider(getRepositoryToken(Author))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Alias))
.useValue({})
.compile(); .compile();
service = module.get<MediaService>(MediaService); service = module.get<MediaService>(MediaService);
@ -105,7 +108,18 @@ describe('MediaService', () => {
const alias = 'alias'; const alias = 'alias';
note = Note.create(user, alias); note = Note.create(user, alias);
jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user); jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user);
jest.spyOn(noteRepo, 'findOne').mockResolvedValueOnce(note); const createQueryBuilder = {
leftJoinAndSelect: () => createQueryBuilder,
where: () => createQueryBuilder,
orWhere: () => createQueryBuilder,
setParameter: () => createQueryBuilder,
getOne: () => note,
};
jest
.spyOn(noteRepo, 'createQueryBuilder')
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
.mockImplementation(() => createQueryBuilder);
}); });
it('works', async () => { it('works', async () => {
@ -279,7 +293,7 @@ describe('MediaService', () => {
id: 'testMediaUpload', id: 'testMediaUpload',
backendData: 'testBackendData', backendData: 'testBackendData',
note: { note: {
alias: 'test', aliases: [Alias.create('test', true)],
} as Note, } as Note,
user: { user: {
userName: 'hardcoded', userName: 'hardcoded',

View file

@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
export class AliasCreateDto {
/**
* The note id or alias, which identifies the note the alias should be added to
*/
@IsString()
@ApiProperty()
noteIdOrAlias: string;
/**
* The new alias
*/
@IsString()
@ApiProperty()
newAlias: string;
}

View file

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean } from 'class-validator';
export class AliasUpdateDto {
/**
* Whether the alias should become the primary alias or not
*/
@IsBoolean()
@ApiProperty()
primaryAlias: boolean;
}

30
src/notes/alias.dto.ts Normal file
View file

@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsString } from 'class-validator';
export class AliasDto {
/**
* The name of the alias
*/
@IsString()
@ApiProperty()
name: string;
/**
* Is the alias the primary alias or not
*/
@IsBoolean()
@ApiProperty()
primaryAlias: boolean;
/**
* The public id of the note the alias is associated with
*/
@IsString()
@ApiProperty()
noteId: string;
}

63
src/notes/alias.entity.ts Normal file
View file

@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
Column,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm';
import { Note } from './note.entity';
import { PrimaryValueTransformer } from './primary.value-transformer';
@Entity()
@Unique('Only one primary alias per note', ['note', 'primary'])
export class Alias {
@PrimaryGeneratedColumn('uuid')
id: string;
/**
* the actual alias
*/
@Column({
nullable: false,
unique: true,
})
name: string;
/**
* Is this alias the primary alias, by which people access the note?
*/
@Column({
/*
Because of the @Unique at the top of this entity, this field must be saved as null instead of false in the DB.
If a non-primary alias would be saved with `primary: false` it would only be possible to have one non-primary and one primary alias.
But a nullable field does not have such problems.
This way the DB keeps track that one note really only has one primary alias.
*/
comment:
'This field tells you if this is the primary alias of the note. If this field is null, that means this alias is not primary.',
nullable: true,
transformer: new PrimaryValueTransformer(),
})
primary: boolean;
@ManyToOne((_) => Note, (note) => note.aliases, {
onDelete: 'CASCADE', // This deletes the Alias, when the associated Note is deleted
})
note: Note;
// eslint-disable-next-line @typescript-eslint/no-empty-function
private constructor() {}
static create(name: string, primary = false): Alias {
const alias = new Alias();
alias.name = name;
alias.primary = primary;
return alias;
}
}

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

@ -25,13 +25,19 @@ export class NoteMetadataDto {
id: string; id: string;
/** /**
* Alias of the note * All aliases of the note (including the primaryAlias)
* Can be null */
@IsArray()
@IsString({ each: true })
@ApiProperty()
aliases: string[];
/**
* The primary alias of the note
*/ */
@IsString() @IsString()
@IsOptional() @ApiProperty()
@ApiPropertyOptional() primaryAlias: string | null;
alias: string | null;
/** /**
* Title of the note * Title of the note

View file

@ -19,6 +19,7 @@ import { NoteGroupPermission } from '../permissions/note-group-permission.entity
import { NoteUserPermission } from '../permissions/note-user-permission.entity'; import { NoteUserPermission } from '../permissions/note-user-permission.entity';
import { Revision } from '../revisions/revision.entity'; import { Revision } from '../revisions/revision.entity';
import { User } from '../users/user.entity'; import { User } from '../users/user.entity';
import { Alias } from './alias.entity';
import { Tag } from './tag.entity'; import { Tag } from './tag.entity';
import { generatePublicId } from './utils'; import { generatePublicId } from './utils';
@ -28,12 +29,12 @@ export class Note {
id: string; id: string;
@Column({ type: 'text' }) @Column({ type: 'text' })
publicId: string; publicId: string;
@Column({ @OneToMany(
unique: true, (_) => Alias,
nullable: true, (alias) => alias.note,
type: 'text', { cascade: true }, // This ensures that embedded Aliases are automatically saved to the database
}) )
alias: string | null; aliases: Alias[];
@OneToMany( @OneToMany(
(_) => NoteGroupPermission, (_) => NoteGroupPermission,
(groupPermission) => groupPermission.note, (groupPermission) => groupPermission.note,
@ -84,7 +85,7 @@ export class Note {
public static create(owner?: User, alias?: string): Note { public static create(owner?: User, alias?: string): Note {
const newNote = new Note(); const newNote = new Note();
newNote.publicId = generatePublicId(); newNote.publicId = generatePublicId();
newNote.alias = alias ?? null; newNote.aliases = alias ? [Alias.create(alias, true)] : [];
newNote.viewCount = 0; newNote.viewCount = 0;
newNote.owner = owner ?? null; newNote.owner = owner ?? null;
newNote.userPermissions = []; newNote.userPermissions = [];

View file

@ -14,6 +14,8 @@ import { NoteUserPermission } from '../permissions/note-user-permission.entity';
import { RevisionsModule } from '../revisions/revisions.module'; 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 { 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';
@ -26,6 +28,7 @@ import { Tag } from './tag.entity';
NoteGroupPermission, NoteGroupPermission,
NoteUserPermission, NoteUserPermission,
User, User,
Alias,
]), ]),
forwardRef(() => RevisionsModule), forwardRef(() => RevisionsModule),
UsersModule, UsersModule,
@ -34,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 {}

View file

@ -29,6 +29,7 @@ import { RevisionsModule } from '../revisions/revisions.module';
import { Session } from '../users/session.entity'; import { Session } from '../users/session.entity';
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 { import {
NoteGroupPermissionUpdateDto, NoteGroupPermissionUpdateDto,
NoteUserPermissionUpdateDto, NoteUserPermissionUpdateDto,
@ -63,6 +64,10 @@ describe('NotesService', () => {
provide: getRepositoryToken(Tag), provide: getRepositoryToken(Tag),
useClass: Repository, useClass: Repository,
}, },
{
provide: getRepositoryToken(Alias),
useClass: Repository,
},
{ {
provide: getRepositoryToken(User), provide: getRepositoryToken(User),
useValue: userRepo, useValue: userRepo,
@ -83,6 +88,8 @@ describe('NotesService', () => {
.useClass(Repository) .useClass(Repository)
.overrideProvider(getRepositoryToken(Tag)) .overrideProvider(getRepositoryToken(Tag))
.useClass(Repository) .useClass(Repository)
.overrideProvider(getRepositoryToken(Alias))
.useClass(Repository)
.overrideProvider(getRepositoryToken(User)) .overrideProvider(getRepositoryToken(User))
.useValue(userRepo) .useValue(userRepo)
.overrideProvider(getRepositoryToken(AuthToken)) .overrideProvider(getRepositoryToken(AuthToken))
@ -166,7 +173,7 @@ describe('NotesService', () => {
expect(newNote.groupPermissions).toHaveLength(0); expect(newNote.groupPermissions).toHaveLength(0);
expect(newNote.tags).toHaveLength(0); expect(newNote.tags).toHaveLength(0);
expect(newNote.owner).toBeNull(); expect(newNote.owner).toBeNull();
expect(newNote.alias).toBeNull(); expect(newNote.aliases).toHaveLength(0);
}); });
it('without alias, with owner', async () => { it('without alias, with owner', async () => {
const newNote = await service.createNote(content, undefined, user); const newNote = await service.createNote(content, undefined, user);
@ -179,7 +186,7 @@ describe('NotesService', () => {
expect(newNote.groupPermissions).toHaveLength(0); expect(newNote.groupPermissions).toHaveLength(0);
expect(newNote.tags).toHaveLength(0); expect(newNote.tags).toHaveLength(0);
expect(newNote.owner).toEqual(user); expect(newNote.owner).toEqual(user);
expect(newNote.alias).toBeNull(); expect(newNote.aliases).toHaveLength(0);
}); });
it('with alias, without owner', async () => { it('with alias, without owner', async () => {
const newNote = await service.createNote(content, alias); const newNote = await service.createNote(content, alias);
@ -191,7 +198,7 @@ describe('NotesService', () => {
expect(newNote.groupPermissions).toHaveLength(0); expect(newNote.groupPermissions).toHaveLength(0);
expect(newNote.tags).toHaveLength(0); expect(newNote.tags).toHaveLength(0);
expect(newNote.owner).toBeNull(); expect(newNote.owner).toBeNull();
expect(newNote.alias).toEqual(alias); expect(newNote.aliases).toHaveLength(1);
}); });
it('with alias, with owner', async () => { it('with alias, with owner', async () => {
const newNote = await service.createNote(content, alias, user); const newNote = await service.createNote(content, alias, user);
@ -204,7 +211,8 @@ describe('NotesService', () => {
expect(newNote.groupPermissions).toHaveLength(0); expect(newNote.groupPermissions).toHaveLength(0);
expect(newNote.tags).toHaveLength(0); expect(newNote.tags).toHaveLength(0);
expect(newNote.owner).toEqual(user); expect(newNote.owner).toEqual(user);
expect(newNote.alias).toEqual(alias); expect(newNote.aliases).toHaveLength(1);
expect(newNote.aliases[0].name).toEqual(alias);
}); });
}); });
describe('fails:', () => { describe('fails:', () => {
@ -276,19 +284,40 @@ describe('NotesService', () => {
it('works', async () => { it('works', async () => {
const user = User.create('hardcoded', 'Testy') as User; const user = User.create('hardcoded', 'Testy') as User;
const note = Note.create(user); const note = Note.create(user);
jest.spyOn(noteRepo, 'findOne').mockResolvedValueOnce(note); const createQueryBuilder = {
leftJoinAndSelect: () => createQueryBuilder,
where: () => createQueryBuilder,
orWhere: () => createQueryBuilder,
setParameter: () => createQueryBuilder,
getOne: () => note,
};
jest
.spyOn(noteRepo, 'createQueryBuilder')
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
.mockImplementation(() => createQueryBuilder);
const foundNote = await service.getNoteByIdOrAlias('noteThatExists'); const foundNote = await service.getNoteByIdOrAlias('noteThatExists');
expect(foundNote).toEqual(note); expect(foundNote).toEqual(note);
}); });
describe('fails:', () => { describe('fails:', () => {
it('no note found', async () => { it('no note found', async () => {
jest.spyOn(noteRepo, 'findOne').mockResolvedValueOnce(undefined); const createQueryBuilder = {
leftJoinAndSelect: () => createQueryBuilder,
where: () => createQueryBuilder,
orWhere: () => createQueryBuilder,
setParameter: () => createQueryBuilder,
getOne: () => undefined,
};
jest
.spyOn(noteRepo, 'createQueryBuilder')
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
.mockImplementation(() => createQueryBuilder);
await expect( await expect(
service.getNoteByIdOrAlias('noteThatDoesNoteExist'), service.getNoteByIdOrAlias('noteThatDoesNoteExist'),
).rejects.toThrow(NotInDBError); ).rejects.toThrow(NotInDBError);
}); });
it('id is forbidden', async () => { it('id is forbidden', async () => {
jest.spyOn(noteRepo, 'findOne').mockResolvedValueOnce(undefined);
await expect( await expect(
service.getNoteByIdOrAlias(forbiddenNoteId), service.getNoteByIdOrAlias(forbiddenNoteId),
).rejects.toThrow(ForbiddenIdError); ).rejects.toThrow(ForbiddenIdError);
@ -709,7 +738,7 @@ describe('NotesService', () => {
// @ts-ignore // @ts-ignore
.mockImplementation(() => createQueryBuilder); .mockImplementation(() => createQueryBuilder);
note.publicId = 'testId'; note.publicId = 'testId';
note.alias = 'testAlias'; note.aliases = [Alias.create('testAlias', true)];
note.title = 'testTitle'; note.title = 'testTitle';
note.description = 'testDescription'; note.description = 'testDescription';
note.owner = user; note.owner = user;
@ -737,7 +766,8 @@ describe('NotesService', () => {
note.viewCount = 1337; note.viewCount = 1337;
const metadataDto = await service.toNoteMetadataDto(note); const metadataDto = await service.toNoteMetadataDto(note);
expect(metadataDto.id).toEqual(note.publicId); expect(metadataDto.id).toEqual(note.publicId);
expect(metadataDto.alias).toEqual(note.alias); expect(metadataDto.aliases).toHaveLength(1);
expect(metadataDto.aliases[0]).toEqual(note.aliases[0].name);
expect(metadataDto.title).toEqual(note.title); expect(metadataDto.title).toEqual(note.title);
expect(metadataDto.createTime).toEqual(revisions[0].createdAt); expect(metadataDto.createTime).toEqual(revisions[0].createdAt);
expect(metadataDto.description).toEqual(note.description); expect(metadataDto.description).toEqual(note.description);
@ -808,7 +838,7 @@ describe('NotesService', () => {
// @ts-ignore // @ts-ignore
.mockImplementation(() => createQueryBuilder); .mockImplementation(() => createQueryBuilder);
note.publicId = 'testId'; note.publicId = 'testId';
note.alias = 'testAlias'; note.aliases = [Alias.create('testAlias', true)];
note.title = 'testTitle'; note.title = 'testTitle';
note.description = 'testDescription'; note.description = 'testDescription';
note.owner = user; note.owner = user;
@ -836,7 +866,8 @@ describe('NotesService', () => {
note.viewCount = 1337; note.viewCount = 1337;
const noteDto = await service.toNoteDto(note); const noteDto = await service.toNoteDto(note);
expect(noteDto.metadata.id).toEqual(note.publicId); expect(noteDto.metadata.id).toEqual(note.publicId);
expect(noteDto.metadata.alias).toEqual(note.alias); expect(noteDto.metadata.aliases).toHaveLength(1);
expect(noteDto.metadata.aliases[0]).toEqual(note.aliases[0].name);
expect(noteDto.metadata.title).toEqual(note.title); expect(noteDto.metadata.title).toEqual(note.title);
expect(noteDto.metadata.createTime).toEqual(revisions[0].createdAt); expect(noteDto.metadata.createTime).toEqual(revisions[0].createdAt);
expect(noteDto.metadata.description).toEqual(note.description); expect(noteDto.metadata.description).toEqual(note.description);

View file

@ -24,6 +24,7 @@ import { RevisionsService } from '../revisions/revisions.service';
import { User } from '../users/user.entity'; import { User } from '../users/user.entity';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { checkArrayForDuplicates } from '../utils/arrayDuplicatCheck'; import { checkArrayForDuplicates } from '../utils/arrayDuplicatCheck';
import { Alias } from './alias.entity';
import { NoteMetadataDto } from './note-metadata.dto'; import { NoteMetadataDto } from './note-metadata.dto';
import { import {
NotePermissionsDto, NotePermissionsDto,
@ -32,6 +33,7 @@ import {
import { NoteDto } from './note.dto'; import { NoteDto } from './note.dto';
import { Note } from './note.entity'; import { Note } from './note.entity';
import { Tag } from './tag.entity'; import { Tag } from './tag.entity';
import { getPrimaryAlias } from './utils';
@Injectable() @Injectable()
export class NotesService { export class NotesService {
@ -39,6 +41,7 @@ export class NotesService {
private readonly logger: ConsoleLoggerService, private readonly logger: ConsoleLoggerService,
@InjectRepository(Note) private noteRepository: Repository<Note>, @InjectRepository(Note) private noteRepository: Repository<Note>,
@InjectRepository(Tag) private tagRepository: Repository<Tag>, @InjectRepository(Tag) private tagRepository: Repository<Tag>,
@InjectRepository(Alias) private aliasRepository: Repository<Alias>,
@InjectRepository(User) private userRepository: Repository<User>, @InjectRepository(User) private userRepository: Repository<User>,
@Inject(UsersService) private usersService: UsersService, @Inject(UsersService) private usersService: UsersService,
@Inject(GroupsService) private groupsService: GroupsService, @Inject(GroupsService) private groupsService: GroupsService,
@ -59,7 +62,13 @@ export class NotesService {
async getUserNotes(user: User): Promise<Note[]> { async getUserNotes(user: User): Promise<Note[]> {
const notes = await this.noteRepository.find({ const notes = await this.noteRepository.find({
where: { owner: user }, where: { owner: user },
relations: ['owner', 'userPermissions', 'groupPermissions', 'tags'], relations: [
'owner',
'userPermissions',
'groupPermissions',
'tags',
'aliases',
],
}); });
if (notes === undefined) { if (notes === undefined) {
return []; return [];
@ -79,21 +88,19 @@ export class NotesService {
*/ */
async createNote( async createNote(
noteContent: string, noteContent: string,
alias?: NoteMetadataDto['alias'], alias?: string,
owner?: User, owner?: User,
): Promise<Note> { ): Promise<Note> {
const newNote = Note.create(); if (alias) {
this.checkNoteIdOrAlias(alias);
}
const newNote = Note.create(owner, alias);
//TODO: Calculate patch //TODO: Calculate patch
newNote.revisions = Promise.resolve([ newNote.revisions = Promise.resolve([
Revision.create(noteContent, noteContent), Revision.create(noteContent, noteContent),
]); ]);
if (alias) {
newNote.alias = alias;
this.checkNoteIdOrAlias(alias);
}
if (owner) { if (owner) {
newNote.historyEntries = [HistoryEntry.create(owner)]; newNote.historyEntries = [HistoryEntry.create(owner)];
newNote.owner = owner;
} }
try { try {
return await this.noteRepository.save(newNote); return await this.noteRepository.save(newNote);
@ -156,24 +163,33 @@ export class NotesService {
'getNoteByIdOrAlias', 'getNoteByIdOrAlias',
); );
this.checkNoteIdOrAlias(noteIdOrAlias); this.checkNoteIdOrAlias(noteIdOrAlias);
const note = await this.noteRepository.findOne({
where: [ /**
{ * This query gets the note's aliases, owner, groupPermissions (and the groups), userPermissions (and the users) and tags and
publicId: noteIdOrAlias, * then only gets the note, that either has a publicId :noteIdOrAlias or has any alias with this name.
}, **/
{ const note = await this.noteRepository
alias: noteIdOrAlias, .createQueryBuilder('note')
}, .leftJoinAndSelect('note.aliases', 'alias')
], .leftJoinAndSelect('note.owner', 'owner')
relations: [ .leftJoinAndSelect('note.groupPermissions', 'group_permission')
'owner', .leftJoinAndSelect('group_permission.group', 'group')
'groupPermissions', .leftJoinAndSelect('note.userPermissions', 'user_permission')
'groupPermissions.group', .leftJoinAndSelect('user_permission.user', 'user')
'userPermissions', .leftJoinAndSelect('note.tags', 'tag')
'userPermissions.user', .where('note.publicId = :noteIdOrAlias')
'tags', .orWhere((queryBuilder) => {
], const subQuery = queryBuilder
}); .subQuery()
.select('alias.noteId')
.from(Alias, 'alias')
.where('alias.name = :noteIdOrAlias')
.getQuery();
return 'note.id IN ' + subQuery;
})
.setParameter('noteIdOrAlias', noteIdOrAlias)
.getOne();
if (note === undefined) { if (note === undefined) {
this.logger.debug( this.logger.debug(
`Could not find note '${noteIdOrAlias}'`, `Could not find note '${noteIdOrAlias}'`,
@ -369,7 +385,8 @@ export class NotesService {
const updateUser = await this.calculateUpdateUser(note); const updateUser = await this.calculateUpdateUser(note);
return { return {
id: note.publicId, id: note.publicId,
alias: note.alias ?? null, aliases: note.aliases.map((alias) => alias.name),
primaryAlias: getPrimaryAlias(note) ?? null,
title: note.title ?? '', title: note.title ?? '',
createTime: (await this.getFirstRevision(note)).createdAt, createTime: (await this.getFirstRevision(note)).createdAt,
description: note.description ?? '', description: note.description ?? '',

View file

@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ValueTransformer } from 'typeorm';
export class PrimaryValueTransformer implements ValueTransformer {
from(value: boolean | null): boolean {
if (value === null) {
return false;
}
return value;
}
to(value: boolean): boolean | null {
if (!value) {
return null;
}
return value;
}
}

View file

@ -5,19 +5,36 @@
*/ */
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { generatePublicId } from './utils'; import { User } from '../users/user.entity';
import { Alias } from './alias.entity';
import { Note } from './note.entity';
import { generatePublicId, getPrimaryAlias } from './utils';
jest.mock('crypto'); jest.mock('crypto');
const random128bitBuffer = Buffer.from([
0xe1, 0x75, 0x86, 0xb7, 0xc3, 0xfb, 0x03, 0xa9, 0x26, 0x9f, 0xc9, 0xd6, 0x8c,
0x2d, 0x7b, 0x7b,
]);
const mockRandomBytes = randomBytes as jest.MockedFunction<typeof randomBytes>;
mockRandomBytes.mockImplementation((_) => random128bitBuffer);
it('generatePublicId', () => { it('generatePublicId', () => {
const random128bitBuffer = Buffer.from([
0xe1, 0x75, 0x86, 0xb7, 0xc3, 0xfb, 0x03, 0xa9, 0x26, 0x9f, 0xc9, 0xd6,
0x8c, 0x2d, 0x7b, 0x7b,
]);
const mockRandomBytes = randomBytes as jest.MockedFunction<
typeof randomBytes
>;
mockRandomBytes.mockImplementationOnce((_) => random128bitBuffer);
expect(generatePublicId()).toEqual('w5trddy3zc1tj9mzs7b8rbbvfc'); expect(generatePublicId()).toEqual('w5trddy3zc1tj9mzs7b8rbbvfc');
}); });
describe('getPrimaryAlias', () => {
const alias = 'alias';
let note: Note;
beforeEach(() => {
const user = User.create('hardcoded', 'Testy') as User;
note = Note.create(user, alias);
});
it('finds correct primary alias', () => {
note.aliases.push(Alias.create('annother', false));
expect(getPrimaryAlias(note)).toEqual(alias);
});
it('returns undefined if there is no alias', () => {
note.aliases[0].primary = false;
expect(getPrimaryAlias(note)).toEqual(undefined);
});
});

View file

@ -6,6 +6,9 @@
import base32Encode from 'base32-encode'; import base32Encode from 'base32-encode';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { Alias } from './alias.entity';
import { Note } from './note.entity';
/** /**
* Generate publicId for a note. * Generate publicId for a note.
* This is a randomly generated 128-bit value encoded with base32-encode using the crockford variant and converted to lowercase. * This is a randomly generated 128-bit value encoded with base32-encode using the crockford variant and converted to lowercase.
@ -14,3 +17,17 @@ export function generatePublicId(): string {
const randomId = randomBytes(16); const randomId = randomBytes(16);
return base32Encode(randomId, 'Crockford').toLowerCase(); return base32Encode(randomId, 'Crockford').toLowerCase();
} }
/**
* Extract the primary alias from a aliases of a note
* @param {Note} note - the note from which the primary alias should be extracted
*/
export function getPrimaryAlias(note: Note): string | undefined {
const listWithPrimaryAlias = note.aliases.filter(
(alias: Alias) => alias.primary,
);
if (listWithPrimaryAlias.length !== 1) {
return undefined;
}
return listWithPrimaryAlias[0].name;
}

View file

@ -13,6 +13,7 @@ import appConfigMock from '../config/mock/app.config.mock';
import { Group } from '../groups/group.entity'; import { Group } from '../groups/group.entity';
import { Identity } from '../identity/identity.entity'; import { Identity } from '../identity/identity.entity';
import { LoggerModule } from '../logger/logger.module'; import { LoggerModule } from '../logger/logger.module';
import { Alias } from '../notes/alias.entity';
import { Note } from '../notes/note.entity'; import { Note } from '../notes/note.entity';
import { NotesModule } from '../notes/notes.module'; import { NotesModule } from '../notes/notes.module';
import { Tag } from '../notes/tag.entity'; import { Tag } from '../notes/tag.entity';
@ -67,6 +68,8 @@ describe('PermissionsService', () => {
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Author)) .overrideProvider(getRepositoryToken(Author))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Alias))
.useValue({})
.compile(); .compile();
permissionsService = module.get<PermissionsService>(PermissionsService); permissionsService = module.get<PermissionsService>(PermissionsService);
}); });

View file

@ -15,6 +15,7 @@ import { NotInDBError } from '../errors/errors';
import { Group } from '../groups/group.entity'; import { Group } from '../groups/group.entity';
import { Identity } from '../identity/identity.entity'; import { Identity } from '../identity/identity.entity';
import { LoggerModule } from '../logger/logger.module'; import { LoggerModule } from '../logger/logger.module';
import { Alias } from '../notes/alias.entity';
import { Note } from '../notes/note.entity'; import { Note } from '../notes/note.entity';
import { NotesModule } from '../notes/notes.module'; import { NotesModule } from '../notes/notes.module';
import { Tag } from '../notes/tag.entity'; import { Tag } from '../notes/tag.entity';
@ -68,6 +69,8 @@ describe('RevisionsService', () => {
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Group)) .overrideProvider(getRepositoryToken(Group))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Alias))
.useValue({})
.overrideProvider(getRepositoryToken(Session)) .overrideProvider(getRepositoryToken(Session))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Author)) .overrideProvider(getRepositoryToken(Author))

View file

@ -12,6 +12,7 @@ import { HistoryEntry } from './history/history-entry.entity';
import { Identity } from './identity/identity.entity'; import { Identity } from './identity/identity.entity';
import { ProviderType } from './identity/provider-type.enum'; import { ProviderType } from './identity/provider-type.enum';
import { MediaUpload } from './media/media-upload.entity'; import { MediaUpload } from './media/media-upload.entity';
import { Alias } from './notes/alias.entity';
import { Note } from './notes/note.entity'; import { Note } from './notes/note.entity';
import { Tag } from './notes/tag.entity'; import { Tag } from './notes/tag.entity';
import { NoteGroupPermission } from './permissions/note-group-permission.entity'; import { NoteGroupPermission } from './permissions/note-group-permission.entity';
@ -43,6 +44,7 @@ createConnection({
Identity, Identity,
Author, Author,
Session, Session,
Alias,
], ],
synchronize: true, synchronize: true,
logging: false, logging: false,
@ -89,12 +91,14 @@ createConnection({
if (!foundUsers) { if (!foundUsers) {
throw new Error('Could not find freshly seeded users. Aborting.'); throw new Error('Could not find freshly seeded users. Aborting.');
} }
const foundNotes = await connection.manager.find(Note); const foundNotes = await connection.manager.find(Note, {
relations: ['aliases'],
});
if (!foundNotes) { if (!foundNotes) {
throw new Error('Could not find freshly seeded notes. Aborting.'); throw new Error('Could not find freshly seeded notes. Aborting.');
} }
for (const note of foundNotes) { for (const note of foundNotes) {
if (!note.alias) { if (!note.aliases[0]) {
throw new Error( throw new Error(
'Could not find alias of freshly seeded notes. Aborting.', 'Could not find alias of freshly seeded notes. Aborting.',
); );
@ -106,7 +110,7 @@ createConnection({
); );
} }
for (const note of foundNotes) { for (const note of foundNotes) {
console.log(`Created Note '${note.alias ?? ''}'`); console.log(`Created Note '${note.aliases[0].name ?? ''}'`);
} }
for (const user of foundUsers) { for (const user of foundUsers) {
for (const note of foundNotes) { for (const note of foundNotes) {
@ -114,7 +118,7 @@ createConnection({
await connection.manager.save(historyEntry); await connection.manager.save(historyEntry);
console.log( console.log(
`Created HistoryEntry for user '${user.userName}' and note '${ `Created HistoryEntry for user '${user.userName}' and note '${
note.alias ?? '' note.aliases[0].name ?? ''
}'`, }'`,
); );
} }

View file

@ -0,0 +1,219 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { INestApplication } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Test } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
import request from 'supertest';
import { PrivateApiModule } from '../../src/api/private/private-api.module';
import { AuthModule } from '../../src/auth/auth.module';
import appConfigMock from '../../src/config/mock/app.config.mock';
import authConfigMock from '../../src/config/mock/auth.config.mock';
import customizationConfigMock from '../../src/config/mock/customization.config.mock';
import externalConfigMock from '../../src/config/mock/external-services.config.mock';
import mediaConfigMock from '../../src/config/mock/media.config.mock';
import { GroupsModule } from '../../src/groups/groups.module';
import { LoggerModule } from '../../src/logger/logger.module';
import { AliasCreateDto } from '../../src/notes/alias-create.dto';
import { AliasUpdateDto } from '../../src/notes/alias-update.dto';
import { AliasService } from '../../src/notes/alias.service';
import { NotesModule } from '../../src/notes/notes.module';
import { NotesService } from '../../src/notes/notes.service';
import { PermissionsModule } from '../../src/permissions/permissions.module';
import { User } from '../../src/users/user.entity';
import { UsersModule } from '../../src/users/users.module';
import { UsersService } from '../../src/users/users.service';
describe('Alias', () => {
let app: INestApplication;
let aliasService: AliasService;
let notesService: NotesService;
let user: User;
let content: string;
let forbiddenNoteId: string;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [
mediaConfigMock,
appConfigMock,
authConfigMock,
customizationConfigMock,
externalConfigMock,
],
}),
PrivateApiModule,
NotesModule,
PermissionsModule,
GroupsModule,
TypeOrmModule.forRoot({
type: 'sqlite',
database: './hedgedoc-e2e-private-alias.sqlite',
autoLoadEntities: true,
synchronize: true,
dropSchema: true,
}),
LoggerModule,
AuthModule,
UsersModule,
],
}).compile();
const config = moduleRef.get<ConfigService>(ConfigService);
forbiddenNoteId = config.get('appConfig').forbiddenNoteIds[0];
app = moduleRef.createNestApplication();
await app.init();
aliasService = moduleRef.get(AliasService);
notesService = moduleRef.get(NotesService);
const userService = moduleRef.get(UsersService);
user = await userService.createUser('hardcoded', 'Testy');
content = 'This is a test note.';
});
describe('POST /alias', () => {
const testAlias = 'aliasTest';
const newAliasDto: AliasCreateDto = {
noteIdOrAlias: testAlias,
newAlias: '',
};
let publicId = '';
beforeAll(async () => {
const note = await notesService.createNote(content, testAlias, user);
publicId = note.publicId;
});
it('create with normal alias', async () => {
const newAlias = 'normalAlias';
newAliasDto.newAlias = newAlias;
const metadata = await request(app.getHttpServer())
.post(`/alias`)
.set('Content-Type', 'application/json')
.send(newAliasDto)
.expect(201);
expect(metadata.body.name).toEqual(newAlias);
expect(metadata.body.primaryAlias).toBeFalsy();
expect(metadata.body.noteId).toEqual(publicId);
const note = await request(app.getHttpServer())
.get(`/notes/${newAlias}`)
.expect(200);
expect(note.body.metadata.aliases).toContain(newAlias);
expect(note.body.metadata.primaryAlias).toBeTruthy();
expect(note.body.metadata.id).toEqual(publicId);
});
describe('does not create an alias', () => {
it('because of a forbidden alias', async () => {
newAliasDto.newAlias = forbiddenNoteId;
await request(app.getHttpServer())
.post(`/alias`)
.set('Content-Type', 'application/json')
.send(newAliasDto)
.expect(400);
});
it('because of a alias that is a public id', async () => {
newAliasDto.newAlias = publicId;
await request(app.getHttpServer())
.post(`/alias`)
.set('Content-Type', 'application/json')
.send(newAliasDto)
.expect(400);
});
});
});
describe('PUT /alias/{alias}', () => {
const testAlias = 'aliasTest2';
const newAlias = 'normalAlias2';
const changeAliasDto: AliasUpdateDto = {
primaryAlias: true,
};
let publicId = '';
beforeAll(async () => {
const note = await notesService.createNote(content, testAlias, user);
publicId = note.publicId;
await aliasService.addAlias(note, newAlias);
});
it('updates a note with a normal alias', async () => {
const metadata = await request(app.getHttpServer())
.put(`/alias/${newAlias}`)
.set('Content-Type', 'application/json')
.send(changeAliasDto)
.expect(200);
expect(metadata.body.name).toEqual(newAlias);
expect(metadata.body.primaryAlias).toBeTruthy();
expect(metadata.body.noteId).toEqual(publicId);
const note = await request(app.getHttpServer())
.get(`/notes/${newAlias}`)
.expect(200);
expect(note.body.metadata.aliases).toContain(newAlias);
expect(note.body.metadata.primaryAlias).toBeTruthy();
expect(note.body.metadata.id).toEqual(publicId);
});
describe('does not update', () => {
it('a note with unknown alias', async () => {
await request(app.getHttpServer())
.put(`/alias/i_dont_exist`)
.set('Content-Type', 'application/json')
.send(changeAliasDto)
.expect(404);
});
it('if the property primaryAlias is false', async () => {
changeAliasDto.primaryAlias = false;
await request(app.getHttpServer())
.put(`/alias/${newAlias}`)
.set('Content-Type', 'application/json')
.send(changeAliasDto)
.expect(400);
});
});
});
describe('DELETE /alias/{alias}', () => {
const testAlias = 'aliasTest3';
const newAlias = 'normalAlias3';
beforeAll(async () => {
const note = await notesService.createNote(content, testAlias, user);
await aliasService.addAlias(note, newAlias);
});
it('deletes a normal alias', async () => {
await request(app.getHttpServer())
.delete(`/alias/${newAlias}`)
.expect(204);
await request(app.getHttpServer()).get(`/notes/${newAlias}`).expect(404);
});
it('does not delete an unknown alias', async () => {
await request(app.getHttpServer())
.delete(`/alias/i_dont_exist`)
.expect(404);
});
it('does not delete an primary alias (if it is not the only one)', async () => {
const note = await notesService.getNoteByIdOrAlias(testAlias);
await aliasService.addAlias(note, newAlias);
await request(app.getHttpServer())
.delete(`/alias/${testAlias}`)
.expect(400);
await request(app.getHttpServer()).get(`/notes/${newAlias}`).expect(200);
});
it('deletes a primary alias (if it is the only one)', async () => {
await request(app.getHttpServer())
.delete(`/alias/${newAlias}`)
.expect(204);
await request(app.getHttpServer())
.delete(`/alias/${testAlias}`)
.expect(204);
});
});
});

View file

@ -77,7 +77,7 @@ describe('History', () => {
const userService = moduleRef.get(UsersService); const userService = moduleRef.get(UsersService);
user = await userService.createUser('hardcoded', 'Testy'); user = await userService.createUser('hardcoded', 'Testy');
const notesService = moduleRef.get(NotesService); const notesService = moduleRef.get(NotesService);
note = await notesService.createNote(content, null, user); note = await notesService.createNote(content, 'note', user);
note2 = await notesService.createNote(content, 'note2', user); note2 = await notesService.createNote(content, 'note2', user);
}); });
@ -109,7 +109,9 @@ describe('History', () => {
const pinStatus = true; const pinStatus = true;
const lastVisited = new Date('2020-12-01 12:23:34'); const lastVisited = new Date('2020-12-01 12:23:34');
const postEntryDto = new HistoryEntryImportDto(); const postEntryDto = new HistoryEntryImportDto();
postEntryDto.note = note2.alias; postEntryDto.note = note2.aliases.filter(
(alias) => alias.primary,
)[0].name;
postEntryDto.pinStatus = pinStatus; postEntryDto.pinStatus = pinStatus;
postEntryDto.lastVisited = lastVisited; postEntryDto.lastVisited = lastVisited;
await request(app.getHttpServer()) await request(app.getHttpServer())
@ -119,7 +121,7 @@ describe('History', () => {
.expect(201); .expect(201);
const userEntries = await historyService.getEntriesByUser(user); const userEntries = await historyService.getEntriesByUser(user);
expect(userEntries.length).toEqual(1); expect(userEntries.length).toEqual(1);
expect(userEntries[0].note.alias).toEqual(note2.alias); expect(userEntries[0].note.aliases).toEqual(note2.aliases);
expect(userEntries[0].user.userName).toEqual(user.userName); expect(userEntries[0].user.userName).toEqual(user.userName);
expect(userEntries[0].pinStatus).toEqual(pinStatus); expect(userEntries[0].pinStatus).toEqual(pinStatus);
expect(userEntries[0].updatedAt).toEqual(lastVisited); expect(userEntries[0].updatedAt).toEqual(lastVisited);
@ -136,7 +138,9 @@ describe('History', () => {
pinStatus = !previousHistory[0].pinStatus; pinStatus = !previousHistory[0].pinStatus;
lastVisited = new Date('2020-12-01 23:34:45'); lastVisited = new Date('2020-12-01 23:34:45');
postEntryDto = new HistoryEntryImportDto(); postEntryDto = new HistoryEntryImportDto();
postEntryDto.note = note2.alias; postEntryDto.note = note2.aliases.filter(
(alias) => alias.primary,
)[0].name;
postEntryDto.pinStatus = pinStatus; postEntryDto.pinStatus = pinStatus;
postEntryDto.lastVisited = lastVisited; postEntryDto.lastVisited = lastVisited;
}); });
@ -165,7 +169,7 @@ describe('History', () => {
afterEach(async () => { afterEach(async () => {
const historyEntries = await historyService.getEntriesByUser(user); const historyEntries = await historyService.getEntriesByUser(user);
expect(historyEntries).toHaveLength(1); expect(historyEntries).toHaveLength(1);
expect(historyEntries[0].note.alias).toEqual(prevEntry.note.alias); expect(historyEntries[0].note.aliases).toEqual(prevEntry.note.aliases);
expect(historyEntries[0].user.userName).toEqual( expect(historyEntries[0].user.userName).toEqual(
prevEntry.user.userName, prevEntry.user.userName,
); );
@ -184,8 +188,9 @@ describe('History', () => {
it('PUT /me/history/:note', async () => { it('PUT /me/history/:note', async () => {
const entry = await historyService.updateHistoryEntryTimestamp(note2, user); const entry = await historyService.updateHistoryEntryTimestamp(note2, user);
expect(entry.pinStatus).toBeFalsy(); expect(entry.pinStatus).toBeFalsy();
const alias = entry.note.aliases.filter((alias) => alias.primary)[0].name;
await request(app.getHttpServer()) await request(app.getHttpServer())
.put(`/me/history/${entry.note.alias || 'undefined'}`) .put(`/me/history/${alias || 'undefined'}`)
.send({ pinStatus: true }) .send({ pinStatus: true })
.expect(200); .expect(200);
const userEntries = await historyService.getEntriesByUser(user); const userEntries = await historyService.getEntriesByUser(user);
@ -196,10 +201,11 @@ describe('History', () => {
it('DELETE /me/history/:note', async () => { it('DELETE /me/history/:note', async () => {
const entry = await historyService.updateHistoryEntryTimestamp(note2, user); const entry = await historyService.updateHistoryEntryTimestamp(note2, user);
const alias = entry.note.aliases.filter((alias) => alias.primary)[0].name;
const entry2 = await historyService.updateHistoryEntryTimestamp(note, user); const entry2 = await historyService.updateHistoryEntryTimestamp(note, user);
const entryDto = historyService.toHistoryEntryDto(entry2); const entryDto = historyService.toHistoryEntryDto(entry2);
await request(app.getHttpServer()) await request(app.getHttpServer())
.delete(`/me/history/${entry.note.alias || 'undefined'}`) .delete(`/me/history/${alias || 'undefined'}`)
.expect(200); .expect(200);
const userEntries = await historyService.getEntriesByUser(user); const userEntries = await historyService.getEntriesByUser(user);
expect(userEntries.length).toEqual(1); expect(userEntries.length).toEqual(1);

View file

@ -45,6 +45,7 @@ describe('Me', () => {
let user: User; let user: User;
let content: string; let content: string;
let note1: Note; let note1: Note;
let alias2: string;
let note2: Note; let note2: Note;
beforeAll(async () => { beforeAll(async () => {
@ -88,8 +89,9 @@ describe('Me', () => {
user = await userService.createUser('hardcoded', 'Testy'); user = await userService.createUser('hardcoded', 'Testy');
const notesService = moduleRef.get(NotesService); const notesService = moduleRef.get(NotesService);
content = 'This is a test note.'; content = 'This is a test note.';
note1 = await notesService.createNote(content, null, user); alias2 = 'note2';
note2 = await notesService.createNote(content, 'note2', user); note1 = await notesService.createNote(content, undefined, user);
note2 = await notesService.createNote(content, alias2, user);
}); });
it('GET /me', async () => { it('GET /me', async () => {

View file

@ -0,0 +1,215 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { INestApplication } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Test } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
import request from 'supertest';
import { PublicApiModule } from '../../src/api/public/public-api.module';
import { AuthModule } from '../../src/auth/auth.module';
import { MockAuthGuard } from '../../src/auth/mock-auth.guard';
import { TokenAuthGuard } from '../../src/auth/token.strategy';
import appConfigMock from '../../src/config/mock/app.config.mock';
import mediaConfigMock from '../../src/config/mock/media.config.mock';
import { GroupsModule } from '../../src/groups/groups.module';
import { LoggerModule } from '../../src/logger/logger.module';
import { AliasCreateDto } from '../../src/notes/alias-create.dto';
import { AliasUpdateDto } from '../../src/notes/alias-update.dto';
import { AliasService } from '../../src/notes/alias.service';
import { NotesModule } from '../../src/notes/notes.module';
import { NotesService } from '../../src/notes/notes.service';
import { PermissionsModule } from '../../src/permissions/permissions.module';
import { User } from '../../src/users/user.entity';
import { UsersModule } from '../../src/users/users.module';
import { UsersService } from '../../src/users/users.service';
describe('Notes', () => {
let app: INestApplication;
let notesService: NotesService;
let aliasService: AliasService;
let user: User;
let content: string;
let forbiddenNoteId: string;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [mediaConfigMock, appConfigMock],
}),
PublicApiModule,
NotesModule,
PermissionsModule,
GroupsModule,
TypeOrmModule.forRoot({
type: 'sqlite',
database: './hedgedoc-e2e-notes.sqlite',
autoLoadEntities: true,
synchronize: true,
dropSchema: true,
}),
LoggerModule,
AuthModule,
UsersModule,
],
})
.overrideGuard(TokenAuthGuard)
.useClass(MockAuthGuard)
.compile();
const config = moduleRef.get<ConfigService>(ConfigService);
forbiddenNoteId = config.get('appConfig').forbiddenNoteIds[0];
app = moduleRef.createNestApplication();
await app.init();
notesService = moduleRef.get(NotesService);
aliasService = moduleRef.get(AliasService);
const userService = moduleRef.get(UsersService);
user = await userService.createUser('hardcoded', 'Testy');
content = 'This is a test note.';
});
describe('POST /alias', () => {
const testAlias = 'aliasTest';
const newAliasDto: AliasCreateDto = {
noteIdOrAlias: testAlias,
newAlias: '',
};
let publicId = '';
beforeAll(async () => {
const note = await notesService.createNote(content, testAlias, user);
publicId = note.publicId;
});
it('create with normal alias', async () => {
const newAlias = 'normalAlias';
newAliasDto.newAlias = newAlias;
const metadata = await request(app.getHttpServer())
.post(`/alias`)
.set('Content-Type', 'application/json')
.send(newAliasDto)
.expect(201);
expect(metadata.body.name).toEqual(newAlias);
expect(metadata.body.primaryAlias).toBeFalsy();
expect(metadata.body.noteId).toEqual(publicId);
const note = await request(app.getHttpServer())
.get(`/notes/${newAlias}`)
.expect(200);
expect(note.body.metadata.aliases).toContain(newAlias);
expect(note.body.metadata.primaryAlias).toBeTruthy();
expect(note.body.metadata.id).toEqual(publicId);
});
describe('does not create an alias', () => {
it('because of a forbidden alias', async () => {
newAliasDto.newAlias = forbiddenNoteId;
await request(app.getHttpServer())
.post(`/alias`)
.set('Content-Type', 'application/json')
.send(newAliasDto)
.expect(400);
});
it('because of a alias that is a public id', async () => {
newAliasDto.newAlias = publicId;
await request(app.getHttpServer())
.post(`/alias`)
.set('Content-Type', 'application/json')
.send(newAliasDto)
.expect(400);
});
});
});
describe('PUT /alias/{alias}', () => {
const testAlias = 'aliasTest2';
const newAlias = 'normalAlias2';
const changeAliasDto: AliasUpdateDto = {
primaryAlias: true,
};
let publicId = '';
beforeAll(async () => {
const note = await notesService.createNote(content, testAlias, user);
publicId = note.publicId;
await aliasService.addAlias(note, newAlias);
});
it('updates a note with a normal alias', async () => {
const metadata = await request(app.getHttpServer())
.put(`/alias/${newAlias}`)
.set('Content-Type', 'application/json')
.send(changeAliasDto)
.expect(200);
expect(metadata.body.name).toEqual(newAlias);
expect(metadata.body.primaryAlias).toBeTruthy();
expect(metadata.body.noteId).toEqual(publicId);
const note = await request(app.getHttpServer())
.get(`/notes/${newAlias}`)
.expect(200);
expect(note.body.metadata.aliases).toContain(newAlias);
expect(note.body.metadata.primaryAlias).toBeTruthy();
expect(note.body.metadata.id).toEqual(publicId);
});
describe('does not update', () => {
it('a note with unknown alias', async () => {
await request(app.getHttpServer())
.put(`/alias/i_dont_exist`)
.set('Content-Type', 'application/json')
.send(changeAliasDto)
.expect(404);
});
it('if the property primaryAlias is false', async () => {
changeAliasDto.primaryAlias = false;
await request(app.getHttpServer())
.put(`/alias/${newAlias}`)
.set('Content-Type', 'application/json')
.send(changeAliasDto)
.expect(400);
});
});
});
describe('DELETE /alias/{alias}', () => {
const testAlias = 'aliasTest3';
const newAlias = 'normalAlias3';
beforeAll(async () => {
const note = await notesService.createNote(content, testAlias, user);
await aliasService.addAlias(note, newAlias);
});
it('deletes a normal alias', async () => {
await request(app.getHttpServer())
.delete(`/alias/${newAlias}`)
.expect(204);
await request(app.getHttpServer()).get(`/notes/${newAlias}`).expect(404);
});
it('does not delete an unknown alias', async () => {
await request(app.getHttpServer())
.delete(`/alias/i_dont_exist`)
.expect(404);
});
it('does not delete a primary alias (if it is not the only one)', async () => {
const note = await notesService.getNoteByIdOrAlias(testAlias);
await aliasService.addAlias(note, newAlias);
await request(app.getHttpServer())
.delete(`/alias/${testAlias}`)
.expect(400);
await request(app.getHttpServer()).get(`/notes/${newAlias}`).expect(200);
});
it('deletes a primary alias (if it is the only one)', async () => {
await request(app.getHttpServer())
.delete(`/alias/${newAlias}`)
.expect(204);
await request(app.getHttpServer())
.delete(`/alias/${testAlias}`)
.expect(204);
});
});
});

View file

@ -157,15 +157,15 @@ describe('Me', () => {
.send(historyEntryUpdateDto) .send(historyEntryUpdateDto)
.expect(200); .expect(200);
const history = await historyService.getEntriesByUser(user); const history = await historyService.getEntriesByUser(user);
let historyEntry: HistoryEntryDto = response.body; const historyEntry: HistoryEntryDto = response.body;
expect(historyEntry.pinStatus).toEqual(true); expect(historyEntry.pinStatus).toEqual(true);
historyEntry = null; let theEntry: HistoryEntryDto;
for (const e of history) { for (const entry of history) {
if (e.note.alias === noteName) { if (entry.note.aliases.find((element) => element.name === noteName)) {
historyEntry = historyService.toHistoryEntryDto(e); theEntry = historyService.toHistoryEntryDto(entry);
} }
} }
expect(historyEntry.pinStatus).toEqual(true); expect(theEntry.pinStatus).toEqual(true);
}); });
it('fails with a non-existing note', async () => { it('fails with a non-existing note', async () => {
await request(app.getHttpServer()) await request(app.getHttpServer())
@ -185,13 +185,11 @@ describe('Me', () => {
.expect(204); .expect(204);
expect(response.body).toEqual({}); expect(response.body).toEqual({});
const history = await historyService.getEntriesByUser(user); const history = await historyService.getEntriesByUser(user);
let historyEntry: HistoryEntry = null; for (const entry of history) {
for (const e of history) { if (entry.note.aliases.find((element) => element.name === noteName)) {
if (e.note.alias === noteName) { throw new Error('Deleted history entry still in history');
historyEntry = e;
} }
} }
return expect(historyEntry).toBeNull();
}); });
describe('fails', () => { describe('fails', () => {
it('with a non-existing note', async () => { it('with a non-existing note', async () => {
@ -218,8 +216,8 @@ describe('Me', () => {
.expect(200); .expect(200);
const noteMetaDtos = response.body as NoteMetadataDto[]; const noteMetaDtos = response.body as NoteMetadataDto[];
expect(noteMetaDtos).toHaveLength(1); expect(noteMetaDtos).toHaveLength(1);
expect(noteMetaDtos[0].alias).toEqual(noteName); expect(noteMetaDtos[0].primaryAlias).toEqual(noteName);
expect(noteMetaDtos[0].updateUser.userName).toEqual(user.userName); expect(noteMetaDtos[0].updateUser?.userName).toEqual(user.userName);
}); });
it('GET /me/media', async () => { it('GET /me/media', async () => {

View file

@ -208,7 +208,7 @@ describe('Notes', () => {
updateNotePermission.sharedToGroups = []; updateNotePermission.sharedToGroups = [];
await notesService.updateNotePermissions(note, updateNotePermission); await notesService.updateNotePermissions(note, updateNotePermission);
const updatedNote = await notesService.getNoteByIdOrAlias( const updatedNote = await notesService.getNoteByIdOrAlias(
note.alias ?? '', note.aliases.filter((alias) => alias.primary)[0].name,
); );
expect(updatedNote.userPermissions).toHaveLength(1); expect(updatedNote.userPermissions).toHaveLength(1);
expect(updatedNote.userPermissions[0].canEdit).toEqual( expect(updatedNote.userPermissions[0].canEdit).toEqual(
@ -274,7 +274,8 @@ describe('Notes', () => {
.get('/notes/test5/metadata') .get('/notes/test5/metadata')
.expect(200); .expect(200);
expect(typeof metadata.body.id).toEqual('string'); expect(typeof metadata.body.id).toEqual('string');
expect(metadata.body.alias).toEqual('test5'); expect(metadata.body.aliases).toEqual(['test5']);
expect(metadata.body.primaryAlias).toEqual('test5');
expect(metadata.body.title).toEqual(''); expect(metadata.body.title).toEqual('');
expect(metadata.body.description).toEqual(''); expect(metadata.body.description).toEqual('');
expect(typeof metadata.body.createTime).toEqual('string'); expect(typeof metadata.body.createTime).toEqual('string');