Merge pull request #777 from hedgedoc/feat/permissionchecking

This commit is contained in:
Yannick Bungers 2021-02-18 22:52:36 +01:00 committed by GitHub
commit c6bc0dc85c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 953 additions and 147 deletions

View file

@ -119,6 +119,11 @@ entity "note_group_permission" {
*canEdit : boolean *canEdit : boolean
} }
entity "group_members_user" {
*group : number <<FK group>>
*member : uuid <<FK user>>
}
entity "tag" { entity "tag" {
*id: number <<generated>> *id: number <<generated>>
*name: text *name: text
@ -144,16 +149,16 @@ entity "history_entry" {
user "1" -- "0..*" note: owner user "1" -- "0..*" note: owner
user "1" -u- "1..*" identity user "1" -u- "1..*" identity
user "1" - "1..*" auth_token: authTokens user "1" -l- "1..*" auth_token: authTokens
user "1" -l- "1..*" session user "1" -r- "1..*" session
user "1" - "0..*" media_upload user "1" -- "0..*" media_upload
user "1" - "0..*" history_entry user "1" - "0..*" history_entry
user "0..*" -- "0..*" note user "0..*" -- "0..*" note
user "1" - "0..*" authorship user "1" -- "0..*" authorship
(user, note) . author_colors (user, note) . author_colors
revision "0..*" - "0..*" authorship revision "0..*" -- "0..*" authorship
(revision, authorship) .. revision_authorship (revision, authorship) .. revision_authorship
media_upload "0..*" -- "1" note media_upload "0..*" -- "1" note
@ -161,9 +166,11 @@ note "1" - "1..*" revision
note "1" - "0..*" history_entry note "1" - "0..*" history_entry
note "0..*" -l- "0..*" tag note "0..*" -l- "0..*" tag
note "0..*" -- "0..*" group note "0..*" -- "0..*" group
user "1..*" -- "0..*" group
user "0..*" -- "0..*" note user "0..*" -- "0..*" note
(user, note) . note_user_permission (user, note) . note_user_permission
(note, group) . note_group_permission (note, group) . note_group_permission
(user, group) . group_members_user
@enduml @enduml

View file

@ -20,6 +20,8 @@ import { User } from '../../../users/user.entity';
import { UsersModule } from '../../../users/users.module'; import { UsersModule } from '../../../users/users.module';
import { MeController } from './me.controller'; import { MeController } from './me.controller';
import { HistoryEntry } from '../../../history/history-entry.entity'; import { HistoryEntry } from '../../../history/history-entry.entity';
import { NoteGroupPermission } from '../../../permissions/note-group-permission.entity';
import { NoteUserPermission } from '../../../permissions/note-user-permission.entity';
describe('Me Controller', () => { describe('Me Controller', () => {
let controller: MeController; let controller: MeController;
@ -47,6 +49,10 @@ describe('Me Controller', () => {
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(HistoryEntry)) .overrideProvider(getRepositoryToken(HistoryEntry))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(NoteGroupPermission))
.useValue({})
.overrideProvider(getRepositoryToken(NoteUserPermission))
.useValue({})
.compile(); .compile();
controller = module.get<MeController>(MeController); controller = module.get<MeController>(MeController);

View file

@ -22,6 +22,8 @@ import { AuthToken } from '../../../auth/auth-token.entity';
import { Identity } from '../../../users/identity.entity'; import { Identity } from '../../../users/identity.entity';
import { User } from '../../../users/user.entity'; import { User } from '../../../users/user.entity';
import { MediaController } from './media.controller'; import { MediaController } from './media.controller';
import { NoteGroupPermission } from '../../../permissions/note-group-permission.entity';
import { NoteUserPermission } from '../../../permissions/note-user-permission.entity';
describe('Media Controller', () => { describe('Media Controller', () => {
let controller: MediaController; let controller: MediaController;
@ -57,6 +59,10 @@ describe('Media Controller', () => {
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Tag)) .overrideProvider(getRepositoryToken(Tag))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(NoteGroupPermission))
.useValue({})
.overrideProvider(getRepositoryToken(NoteUserPermission))
.useValue({})
.compile(); .compile();
controller = module.get<MediaController>(MediaController); controller = module.get<MediaController>(MediaController);

View file

@ -19,8 +19,11 @@ import { Identity } from '../../../users/identity.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 { NotesController } from './notes.controller'; import { NotesController } from './notes.controller';
import { PermissionsModule } from '../../../permissions/permissions.module';
import { HistoryModule } from '../../../history/history.module'; import { HistoryModule } from '../../../history/history.module';
import { HistoryEntry } from '../../../history/history-entry.entity'; import { HistoryEntry } from '../../../history/history-entry.entity';
import { NoteGroupPermission } from '../../../permissions/note-group-permission.entity';
import { NoteUserPermission } from '../../../permissions/note-user-permission.entity';
describe('Notes Controller', () => { describe('Notes Controller', () => {
let controller: NotesController; let controller: NotesController;
@ -39,7 +42,13 @@ describe('Notes Controller', () => {
useValue: {}, useValue: {},
}, },
], ],
imports: [RevisionsModule, UsersModule, LoggerModule, HistoryModule], imports: [
RevisionsModule,
UsersModule,
LoggerModule,
PermissionsModule,
HistoryModule,
],
}) })
.overrideProvider(getRepositoryToken(Note)) .overrideProvider(getRepositoryToken(Note))
.useValue({}) .useValue({})
@ -61,6 +70,10 @@ describe('Notes Controller', () => {
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(HistoryEntry)) .overrideProvider(getRepositoryToken(HistoryEntry))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(NoteGroupPermission))
.useValue({})
.overrideProvider(getRepositoryToken(NoteUserPermission))
.useValue({})
.compile(); .compile();
controller = module.get<NotesController>(NotesController); controller = module.get<NotesController>(NotesController);

View file

@ -15,6 +15,7 @@ import {
Post, Post,
Put, Put,
Request, Request,
UnauthorizedException,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { NotInDBError } from '../../../errors/errors'; import { NotInDBError } from '../../../errors/errors';
@ -33,6 +34,8 @@ import { NoteDto } from '../../../notes/note.dto';
import { NoteMetadataDto } from '../../../notes/note-metadata.dto'; import { NoteMetadataDto } from '../../../notes/note-metadata.dto';
import { RevisionMetadataDto } from '../../../revisions/revision-metadata.dto'; import { RevisionMetadataDto } from '../../../revisions/revision-metadata.dto';
import { RevisionDto } from '../../../revisions/revision.dto'; import { RevisionDto } from '../../../revisions/revision.dto';
import { PermissionsService } from '../../../permissions/permissions.service';
import { Note } from '../../../notes/note.entity';
@ApiTags('notes') @ApiTags('notes')
@ApiSecurity('token') @ApiSecurity('token')
@ -42,6 +45,7 @@ export class NotesController {
private readonly logger: ConsoleLoggerService, private readonly logger: ConsoleLoggerService,
private noteService: NotesService, private noteService: NotesService,
private revisionsService: RevisionsService, private revisionsService: RevisionsService,
private permissionsService: PermissionsService,
private historyService: HistoryService, private historyService: HistoryService,
) { ) {
this.logger.setContext(NotesController.name); this.logger.setContext(NotesController.name);
@ -54,7 +58,10 @@ export class NotesController {
@MarkdownBody() text: string, @MarkdownBody() text: string,
): Promise<NoteDto> { ): Promise<NoteDto> {
// ToDo: provide user for createNoteDto // ToDo: provide user for createNoteDto
this.logger.debug('Got raw markdown:\n' + text, 'createNote'); if (!this.permissionsService.mayCreate(req.user)) {
throw new UnauthorizedException('Creating note denied!');
}
this.logger.debug('Got raw markdown:\n' + text);
return this.noteService.toNoteDto( return this.noteService.toNoteDto(
await this.noteService.createNote(text, undefined, req.user), await this.noteService.createNote(text, undefined, req.user),
); );
@ -62,18 +69,24 @@ export class NotesController {
@UseGuards(TokenAuthGuard) @UseGuards(TokenAuthGuard)
@Get(':noteIdOrAlias') @Get(':noteIdOrAlias')
async getNote(@Request() req, @Param('noteIdOrAlias') noteIdOrAlias: string) { async getNote(
// ToDo: check if user is allowed to view this note @Request() req,
@Param('noteIdOrAlias') noteIdOrAlias: string,
): Promise<NoteDto> {
let note: Note;
try { try {
const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias); note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias);
await this.historyService.createOrUpdateHistoryEntry(note, req.user);
return this.noteService.toNoteDto(note);
} catch (e) { } catch (e) {
if (e instanceof NotInDBError) { if (e instanceof NotInDBError) {
throw new NotFoundException(e.message); throw new NotFoundException(e.message);
} }
throw e; throw e;
} }
if (!this.permissionsService.mayRead(req.user, note)) {
throw new UnauthorizedException('Reading note denied!');
}
await this.historyService.createOrUpdateHistoryEntry(note, req.user);
return this.noteService.toNoteDto(note);
} }
@UseGuards(TokenAuthGuard) @UseGuards(TokenAuthGuard)
@ -83,7 +96,9 @@ export class NotesController {
@Param('noteAlias') noteAlias: string, @Param('noteAlias') noteAlias: string,
@MarkdownBody() text: string, @MarkdownBody() text: string,
): Promise<NoteDto> { ): Promise<NoteDto> {
// ToDo: check if user is allowed to view this note if (!this.permissionsService.mayCreate(req.user)) {
throw new UnauthorizedException('Creating note denied!');
}
this.logger.debug('Got raw markdown:\n' + text, 'createNamedNote'); this.logger.debug('Got raw markdown:\n' + text, 'createNamedNote');
return this.noteService.toNoteDto( return this.noteService.toNoteDto(
await this.noteService.createNote(text, noteAlias, req.user), await this.noteService.createNote(text, noteAlias, req.user),
@ -96,18 +111,21 @@ export class NotesController {
@Request() req, @Request() req,
@Param('noteIdOrAlias') noteIdOrAlias: string, @Param('noteIdOrAlias') noteIdOrAlias: string,
): Promise<void> { ): Promise<void> {
// ToDo: check if user is allowed to delete this note
this.logger.debug('Deleting note: ' + noteIdOrAlias, 'deleteNote');
try { try {
const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias);
if (!this.permissionsService.isOwner(req.user, note)) {
throw new UnauthorizedException('Deleting note denied!');
}
this.logger.debug('Deleting note: ' + noteIdOrAlias, 'deleteNote');
await this.noteService.deleteNoteByIdOrAlias(noteIdOrAlias); await this.noteService.deleteNoteByIdOrAlias(noteIdOrAlias);
this.logger.debug('Successfully deleted ' + noteIdOrAlias, 'deleteNote');
return;
} catch (e) { } catch (e) {
if (e instanceof NotInDBError) { if (e instanceof NotInDBError) {
throw new NotFoundException(e.message); throw new NotFoundException(e.message);
} }
throw e; throw e;
} }
this.logger.debug('Successfully deleted ' + noteIdOrAlias, 'deleteNote');
return;
} }
@UseGuards(TokenAuthGuard) @UseGuards(TokenAuthGuard)
@ -117,9 +135,12 @@ export class NotesController {
@Param('noteIdOrAlias') noteIdOrAlias: string, @Param('noteIdOrAlias') noteIdOrAlias: string,
@MarkdownBody() text: string, @MarkdownBody() text: string,
): Promise<NoteDto> { ): Promise<NoteDto> {
// ToDo: check if user is allowed to change this note
this.logger.debug('Got raw markdown:\n' + text, 'updateNote');
try { try {
const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias);
if (!this.permissionsService.mayWrite(req.user, note)) {
throw new UnauthorizedException('Updating note denied!');
}
this.logger.debug('Got raw markdown:\n' + text, 'updateNote');
return this.noteService.toNoteDto( return this.noteService.toNoteDto(
await this.noteService.updateNoteByIdOrAlias(noteIdOrAlias, text), await this.noteService.updateNoteByIdOrAlias(noteIdOrAlias, text),
); );
@ -138,8 +159,11 @@ export class NotesController {
@Request() req, @Request() req,
@Param('noteIdOrAlias') noteIdOrAlias: string, @Param('noteIdOrAlias') noteIdOrAlias: string,
): Promise<string> { ): Promise<string> {
// ToDo: check if user is allowed to view this notes content
try { try {
const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias);
if (!this.permissionsService.mayRead(req.user, note)) {
throw new UnauthorizedException('Reading note denied!');
}
return await this.noteService.getNoteContent(noteIdOrAlias); return await this.noteService.getNoteContent(noteIdOrAlias);
} catch (e) { } catch (e) {
if (e instanceof NotInDBError) { if (e instanceof NotInDBError) {
@ -155,8 +179,11 @@ export class NotesController {
@Request() req, @Request() req,
@Param('noteIdOrAlias') noteIdOrAlias: string, @Param('noteIdOrAlias') noteIdOrAlias: string,
): Promise<NoteMetadataDto> { ): Promise<NoteMetadataDto> {
// ToDo: check if user is allowed to view this notes metadata
try { try {
const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias);
if (!this.permissionsService.mayRead(req.user, note)) {
throw new UnauthorizedException('Reading note denied!');
}
return this.noteService.toNoteMetadataDto( return this.noteService.toNoteMetadataDto(
await this.noteService.getNoteByIdOrAlias(noteIdOrAlias), await this.noteService.getNoteByIdOrAlias(noteIdOrAlias),
); );
@ -175,8 +202,11 @@ export class NotesController {
@Param('noteIdOrAlias') noteIdOrAlias: string, @Param('noteIdOrAlias') noteIdOrAlias: string,
@Body() updateDto: NotePermissionsUpdateDto, @Body() updateDto: NotePermissionsUpdateDto,
): Promise<NotePermissionsDto> { ): Promise<NotePermissionsDto> {
// ToDo: check if user is allowed to view this notes permissions
try { try {
const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias);
if (!this.permissionsService.isOwner(req.user, note)) {
throw new UnauthorizedException('Updating note denied!');
}
return this.noteService.toNotePermissionsDto( return this.noteService.toNotePermissionsDto(
await this.noteService.updateNotePermissions(noteIdOrAlias, updateDto), await this.noteService.updateNotePermissions(noteIdOrAlias, updateDto),
); );
@ -194,8 +224,11 @@ export class NotesController {
@Request() req, @Request() req,
@Param('noteIdOrAlias') noteIdOrAlias: string, @Param('noteIdOrAlias') noteIdOrAlias: string,
): Promise<RevisionMetadataDto[]> { ): Promise<RevisionMetadataDto[]> {
// ToDo: check if user is allowed to view this notes revisions
try { try {
const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias);
if (!this.permissionsService.mayRead(req.user, note)) {
throw new UnauthorizedException('Reading note denied!');
}
const revisions = await this.revisionsService.getAllRevisions( const revisions = await this.revisionsService.getAllRevisions(
noteIdOrAlias, noteIdOrAlias,
); );
@ -219,8 +252,11 @@ export class NotesController {
@Param('noteIdOrAlias') noteIdOrAlias: string, @Param('noteIdOrAlias') noteIdOrAlias: string,
@Param('revisionId') revisionId: number, @Param('revisionId') revisionId: number,
): Promise<RevisionDto> { ): Promise<RevisionDto> {
// ToDo: check if user is allowed to view this notes revision
try { try {
const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias);
if (!this.permissionsService.mayRead(req.user, note)) {
throw new UnauthorizedException('Reading note denied!');
}
return this.revisionsService.toRevisionDto( return this.revisionsService.toRevisionDto(
await this.revisionsService.getRevision(noteIdOrAlias, revisionId), await this.revisionsService.getRevision(noteIdOrAlias, revisionId),
); );

View file

@ -16,6 +16,7 @@ import { MeController } from './me/me.controller';
import { NotesController } from './notes/notes.controller'; import { NotesController } from './notes/notes.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';
import { PermissionsModule } from '../../permissions/permissions.module';
@Module({ @Module({
imports: [ imports: [
@ -26,6 +27,7 @@ import { MonitoringController } from './monitoring/monitoring.controller';
MonitoringModule, MonitoringModule,
LoggerModule, LoggerModule,
MediaModule, MediaModule,
PermissionsModule,
], ],
controllers: [ controllers: [
MeController, MeController,

View file

@ -4,7 +4,14 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; import {
Column,
Entity,
JoinTable,
ManyToMany,
PrimaryGeneratedColumn,
} from 'typeorm';
import { User } from '../users/user.entity';
@Entity() @Entity()
export class Group { export class Group {
@ -26,4 +33,11 @@ export class Group {
*/ */
@Column() @Column()
special: boolean; special: boolean;
@ManyToMany((_) => User, (user) => user.groups, {
eager: true,
cascade: true,
})
@JoinTable()
members: User[];
} }

View file

@ -21,6 +21,8 @@ import { AuthToken } from '../auth/auth-token.entity';
import { Revision } from '../revisions/revision.entity'; import { Revision } from '../revisions/revision.entity';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { NotInDBError } from '../errors/errors'; import { NotInDBError } from '../errors/errors';
import { NoteGroupPermission } from '../permissions/note-group-permission.entity';
import { NoteUserPermission } from '../permissions/note-user-permission.entity';
describe('HistoryService', () => { describe('HistoryService', () => {
let service: HistoryService; let service: HistoryService;
@ -54,6 +56,10 @@ describe('HistoryService', () => {
.useClass(Repository) .useClass(Repository)
.overrideProvider(getRepositoryToken(Tag)) .overrideProvider(getRepositoryToken(Tag))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(NoteGroupPermission))
.useValue({})
.overrideProvider(getRepositoryToken(NoteUserPermission))
.useValue({})
.compile(); .compile();
service = module.get<HistoryService>(HistoryService); service = module.get<HistoryService>(HistoryService);
@ -99,7 +105,7 @@ describe('HistoryService', () => {
describe('createOrUpdateHistoryEntry', () => { describe('createOrUpdateHistoryEntry', () => {
describe('works', () => { describe('works', () => {
it('without an preexisting entry', async () => { it('without an preexisting entry', async () => {
const user = new User(); const user = {} as User;
const alias = 'alias'; const alias = 'alias';
jest.spyOn(historyRepo, 'findOne').mockResolvedValueOnce(undefined); jest.spyOn(historyRepo, 'findOne').mockResolvedValueOnce(undefined);
jest jest
@ -118,7 +124,7 @@ describe('HistoryService', () => {
}); });
it('with an preexisting entry', async () => { it('with an preexisting entry', async () => {
const user = new User(); const user = {} as User;
const alias = 'alias'; const alias = 'alias';
const historyEntry = HistoryEntry.create( const historyEntry = HistoryEntry.create(
user, user,
@ -148,7 +154,7 @@ describe('HistoryService', () => {
describe('updateHistoryEntry', () => { describe('updateHistoryEntry', () => {
describe('works', () => { describe('works', () => {
it('with an entry', async () => { it('with an entry', async () => {
const user = new User(); const user = {} as User;
const alias = 'alias'; const alias = 'alias';
const note = Note.create(user, alias); const note = Note.create(user, alias);
const historyEntry = HistoryEntry.create(user, note); const historyEntry = HistoryEntry.create(user, note);
@ -173,7 +179,7 @@ describe('HistoryService', () => {
}); });
it('without an entry', async () => { it('without an entry', async () => {
const user = new User(); const user = {} as User;
const alias = 'alias'; const alias = 'alias';
const note = Note.create(user, alias); const note = Note.create(user, alias);
jest.spyOn(historyRepo, 'findOne').mockResolvedValueOnce(undefined); jest.spyOn(historyRepo, 'findOne').mockResolvedValueOnce(undefined);
@ -192,7 +198,7 @@ describe('HistoryService', () => {
describe('deleteHistoryEntry', () => { describe('deleteHistoryEntry', () => {
describe('works', () => { describe('works', () => {
it('with an entry', async () => { it('with an entry', async () => {
const user = new User(); const user = {} as User;
const alias = 'alias'; const alias = 'alias';
const note = Note.create(user, alias); const note = Note.create(user, alias);
const historyEntry = HistoryEntry.create(user, note); const historyEntry = HistoryEntry.create(user, note);
@ -208,7 +214,7 @@ describe('HistoryService', () => {
}); });
it('without an entry', async () => { it('without an entry', async () => {
const user = new User(); const user = {} as User;
const alias = 'alias'; const alias = 'alias';
const note = Note.create(user, alias); const note = Note.create(user, alias);
jest.spyOn(historyRepo, 'findOne').mockResolvedValueOnce(undefined); jest.spyOn(historyRepo, 'findOne').mockResolvedValueOnce(undefined);
@ -225,7 +231,7 @@ describe('HistoryService', () => {
describe('toHistoryEntryDto', () => { describe('toHistoryEntryDto', () => {
describe('works', () => { describe('works', () => {
it('with aliased note', async () => { it('with aliased note', async () => {
const user = new User(); const user = {} as User;
const alias = 'alias'; const alias = 'alias';
const title = 'title'; const title = 'title';
const tags = ['tag1', 'tag2']; const tags = ['tag1', 'tag2'];
@ -247,7 +253,7 @@ describe('HistoryService', () => {
}); });
it('with regular note', async () => { it('with regular note', async () => {
const user = new User(); const user = {} as User;
const title = 'title'; const title = 'title';
const id = 'id'; const id = 'id';
const tags = ['tag1', 'tag2']; const tags = ['tag1', 'tag2'];

View file

@ -25,6 +25,8 @@ import { MediaService } from './media.service';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import { ClientError, NotInDBError, PermissionError } from '../errors/errors'; import { ClientError, NotInDBError, PermissionError } from '../errors/errors';
import { NoteGroupPermission } from '../permissions/note-group-permission.entity';
import { NoteUserPermission } from '../permissions/note-user-permission.entity';
describe('MediaService', () => { describe('MediaService', () => {
let service: MediaService; let service: MediaService;
@ -70,6 +72,10 @@ describe('MediaService', () => {
.useClass(Repository) .useClass(Repository)
.overrideProvider(getRepositoryToken(Tag)) .overrideProvider(getRepositoryToken(Tag))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(NoteGroupPermission))
.useValue({})
.overrideProvider(getRepositoryToken(NoteUserPermission))
.useValue({})
.overrideProvider(getRepositoryToken(MediaUpload)) .overrideProvider(getRepositoryToken(MediaUpload))
.useClass(Repository) .useClass(Repository)
.compile(); .compile();

View file

@ -13,10 +13,18 @@ import { AuthorColor } from './author-color.entity';
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';
import { NoteGroupPermission } from '../permissions/note-group-permission.entity';
import { NoteUserPermission } from '../permissions/note-user-permission.entity';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Note, AuthorColor, Tag]), TypeOrmModule.forFeature([
Note,
AuthorColor,
Tag,
NoteGroupPermission,
NoteUserPermission,
]),
forwardRef(() => RevisionsModule), forwardRef(() => RevisionsModule),
UsersModule, UsersModule,
LoggerModule, LoggerModule,

View file

@ -18,6 +18,8 @@ import { AuthorColor } from './author-color.entity';
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';
import { NoteGroupPermission } from '../permissions/note-group-permission.entity';
import { NoteUserPermission } from '../permissions/note-user-permission.entity';
describe('NotesService', () => { describe('NotesService', () => {
let service: NotesService; let service: NotesService;
@ -53,6 +55,10 @@ describe('NotesService', () => {
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Tag)) .overrideProvider(getRepositoryToken(Tag))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(NoteGroupPermission))
.useValue({})
.overrideProvider(getRepositoryToken(NoteUserPermission))
.useValue({})
.compile(); .compile();
service = module.get<NotesService>(NotesService); service = module.get<NotesService>(NotesService);
}); });

View file

@ -159,6 +159,7 @@ export class NotesService {
historyEntries: [], historyEntries: [],
updatedAt: new Date(), updatedAt: new Date(),
userName: 'Testy', userName: 'Testy',
groups: [],
}, },
description: 'Very descriptive text.', description: 'Very descriptive text.',
userPermissions: [], userPermissions: [],

View file

@ -9,11 +9,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { LoggerModule } from '../logger/logger.module'; import { LoggerModule } from '../logger/logger.module';
import { NoteGroupPermission } from './note-group-permission.entity'; import { NoteGroupPermission } from './note-group-permission.entity';
import { NoteUserPermission } from './note-user-permission.entity'; import { NoteUserPermission } from './note-user-permission.entity';
import { PermissionsService } from './permissions.service';
@Module({ @Module({
imports: [ exports: [PermissionsService],
TypeOrmModule.forFeature([NoteUserPermission, NoteGroupPermission]), providers: [PermissionsService],
LoggerModule,
],
}) })
export class PermissionsModule {} export class PermissionsModule {}

View file

@ -0,0 +1,526 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Test, TestingModule } from '@nestjs/testing';
import { LoggerModule } from '../logger/logger.module';
import { GuestPermission, PermissionsService } from './permissions.service';
import { User } from '../users/user.entity';
import { Note } from '../notes/note.entity';
import { UsersModule } from '../users/users.module';
import { NotesModule } from '../notes/notes.module';
import { PermissionsModule } from './permissions.module';
import { getRepositoryToken } from '@nestjs/typeorm';
import { NoteGroupPermission } from './note-group-permission.entity';
import { NoteUserPermission } from './note-user-permission.entity';
import { Identity } from '../users/identity.entity';
import { AuthToken } from '../auth/auth-token.entity';
import { Authorship } from '../revisions/authorship.entity';
import { AuthorColor } from '../notes/author-color.entity';
import { Revision } from '../revisions/revision.entity';
import { Tag } from '../notes/tag.entity';
import { Group } from '../groups/group.entity';
jest.mock('../permissions/note-group-permission.entity.ts');
jest.mock('../groups/group.entity.ts');
jest.mock('../notes/note.entity.ts');
jest.mock('../users/user.entity.ts');
describe('PermissionsService', () => {
let permissionsService: PermissionsService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [PermissionsService],
imports: [PermissionsModule, UsersModule, LoggerModule, NotesModule],
})
.overrideProvider(getRepositoryToken(User))
.useValue({})
.overrideProvider(getRepositoryToken(AuthToken))
.useValue({})
.overrideProvider(getRepositoryToken(Identity))
.useValue({})
.overrideProvider(getRepositoryToken(Authorship))
.useValue({})
.overrideProvider(getRepositoryToken(AuthorColor))
.useValue({})
.overrideProvider(getRepositoryToken(Revision))
.useValue({})
.overrideProvider(getRepositoryToken(Note))
.useValue({})
.overrideProvider(getRepositoryToken(Tag))
.useValue({})
.overrideProvider(getRepositoryToken(NoteGroupPermission))
.useValue({})
.overrideProvider(getRepositoryToken(NoteUserPermission))
.useValue({})
.compile();
permissionsService = module.get<PermissionsService>(PermissionsService);
});
// The two users we test with:
const user2 = {} as User;
user2.id = '2';
const user1 = {} as User;
user1.id = '1';
it('should be defined', () => {
expect(permissionsService).toBeDefined();
});
function createNote(owner: User): Note {
const note = {} as Note;
note.userPermissions = [];
note.groupPermissions = [];
note.owner = owner;
return note;
}
/*
* Creates the permission objects for UserPermission for two users with write and with out write permission
*/
function createNoteUserPermissionNotes(): Note[] {
const note0 = createNote(user1);
const note1 = createNote(user2);
const note2 = createNote(user2);
const note3 = createNote(user2);
const note4 = createNote(user2);
const note5 = createNote(user2);
const note6 = createNote(user2);
const note7 = createNote(user2);
const noteUserPermission1 = {} as NoteUserPermission;
noteUserPermission1.user = user1;
const noteUserPermission2 = {} as NoteUserPermission;
noteUserPermission2.user = user2;
const noteUserPermission3 = {} as NoteUserPermission;
noteUserPermission3.user = user1;
noteUserPermission3.canEdit = true;
const noteUserPermission4 = {} as NoteUserPermission;
noteUserPermission4.user = user2;
noteUserPermission4.canEdit = true;
note1.userPermissions.push(noteUserPermission1);
note2.userPermissions.push(noteUserPermission1);
note2.userPermissions.push(noteUserPermission2);
note3.userPermissions.push(noteUserPermission2);
note3.userPermissions.push(noteUserPermission1);
note4.userPermissions.push(noteUserPermission3);
note5.userPermissions.push(noteUserPermission3);
note5.userPermissions.push(noteUserPermission4);
note6.userPermissions.push(noteUserPermission4);
note6.userPermissions.push(noteUserPermission3);
note7.userPermissions.push(noteUserPermission2);
const everybody = {} as Group;
everybody.name = 'everybody';
everybody.special = true;
const noteEverybodyRead = createNote(user1);
const noteGroupPermissionRead = {} as NoteGroupPermission;
noteGroupPermissionRead.group = everybody;
noteGroupPermissionRead.canEdit = false;
noteGroupPermissionRead.note = noteEverybodyRead;
noteEverybodyRead.groupPermissions = [noteGroupPermissionRead];
const noteEverybodyWrite = createNote(user1);
const noteGroupPermissionWrite = {} as NoteGroupPermission;
noteGroupPermissionWrite.group = everybody;
noteGroupPermissionWrite.canEdit = true;
noteGroupPermissionWrite.note = noteEverybodyWrite;
noteEverybodyWrite.groupPermissions = [noteGroupPermissionWrite];
return [
note0,
note1,
note2,
note3,
note4,
note5,
note6,
note7,
noteEverybodyRead,
noteEverybodyWrite,
];
}
const notes = createNoteUserPermissionNotes();
describe('mayRead works with', () => {
it('Owner', () => {
permissionsService.guestPermission = GuestPermission.DENY;
expect(permissionsService.mayRead(user1, notes[0])).toBeTruthy();
expect(permissionsService.mayRead(user1, notes[7])).toBeFalsy();
});
it('userPermission read', () => {
permissionsService.guestPermission = GuestPermission.DENY;
expect(permissionsService.mayRead(user1, notes[1])).toBeTruthy();
expect(permissionsService.mayRead(user1, notes[2])).toBeTruthy();
expect(permissionsService.mayRead(user1, notes[3])).toBeTruthy();
});
it('userPermission write', () => {
permissionsService.guestPermission = GuestPermission.DENY;
expect(permissionsService.mayRead(user1, notes[4])).toBeTruthy();
expect(permissionsService.mayRead(user1, notes[5])).toBeTruthy();
expect(permissionsService.mayRead(user1, notes[6])).toBeTruthy();
expect(permissionsService.mayRead(user1, notes[7])).toBeFalsy();
});
describe('guest permission', () => {
it('CREATE_ALIAS', () => {
permissionsService.guestPermission = GuestPermission.CREATE_ALIAS;
expect(permissionsService.mayRead(null, notes[8])).toBeTruthy();
});
it('CREATE', () => {
permissionsService.guestPermission = GuestPermission.CREATE;
expect(permissionsService.mayRead(null, notes[8])).toBeTruthy();
});
it('WRITE', () => {
permissionsService.guestPermission = GuestPermission.WRITE;
expect(permissionsService.mayRead(null, notes[8])).toBeTruthy();
});
it('READ', () => {
permissionsService.guestPermission = GuestPermission.READ;
expect(permissionsService.mayRead(null, notes[8])).toBeTruthy();
});
});
});
describe('mayWrite works with', () => {
it('Owner', () => {
permissionsService.guestPermission = GuestPermission.DENY;
expect(permissionsService.mayWrite(user1, notes[0])).toBeTruthy();
expect(permissionsService.mayWrite(user1, notes[7])).toBeFalsy();
});
it('userPermission read', () => {
permissionsService.guestPermission = GuestPermission.DENY;
expect(permissionsService.mayWrite(user1, notes[1])).toBeFalsy();
expect(permissionsService.mayWrite(user1, notes[2])).toBeFalsy();
expect(permissionsService.mayWrite(user1, notes[3])).toBeFalsy();
});
it('userPermission write', () => {
permissionsService.guestPermission = GuestPermission.DENY;
expect(permissionsService.mayWrite(user1, notes[4])).toBeTruthy();
expect(permissionsService.mayWrite(user1, notes[5])).toBeTruthy();
expect(permissionsService.mayWrite(user1, notes[6])).toBeTruthy();
expect(permissionsService.mayWrite(user1, notes[7])).toBeFalsy();
});
describe('guest permission', () => {
it('CREATE_ALIAS', () => {
permissionsService.guestPermission = GuestPermission.CREATE_ALIAS;
expect(permissionsService.mayWrite(null, notes[9])).toBeTruthy();
});
it('CREATE', () => {
permissionsService.guestPermission = GuestPermission.CREATE;
expect(permissionsService.mayWrite(null, notes[9])).toBeTruthy();
});
it('WRITE', () => {
permissionsService.guestPermission = GuestPermission.WRITE;
expect(permissionsService.mayWrite(null, notes[9])).toBeTruthy();
});
it('READ', () => {
permissionsService.guestPermission = GuestPermission.READ;
expect(permissionsService.mayWrite(null, notes[9])).toBeFalsy();
});
});
});
/*
* Helper Object that arranges a list of GroupPermissions and if they allow a user to read or write a particular note.
*/
class NoteGroupPermissionWithResultForUser {
permissions: NoteGroupPermission[];
allowsRead: boolean;
allowsWrite: boolean;
}
/*
* Setup function to create all the groups we use in the tests.
*/
function createGroups(): { [id: string]: Group } {
const result: { [id: string]: Group } = {};
const everybody: Group = new Group();
everybody.special = true;
everybody.name = 'everybody';
result['everybody'] = everybody;
const loggedIn = new Group();
loggedIn.special = true;
loggedIn.name = 'loggedIn';
result['loggedIn'] = loggedIn;
const user1group = new Group();
user1group.name = 'user1group';
user1group.members = [user1];
result['user1group'] = user1group;
const user2group = new Group();
user2group.name = 'user2group';
user2group.members = [user2];
result['user2group'] = user2group;
const user1and2group = new Group();
user1and2group.name = 'user1and2group';
user1and2group.members = [user1, user2];
result['user1and2group'] = user1and2group;
const user2and1group = new Group();
user2and1group.name = 'user2and1group';
user2and1group.members = [user2, user1];
result['user2and1group'] = user2and1group;
return result;
}
/*
* Create all GroupPermissions: For each group two GroupPermissions are created one with read permission and one with write permission.
*/
function createAllNoteGroupPermissions(): NoteGroupPermission[][] {
const groups = createGroups();
/*
* Helper function for creating GroupPermissions
*/
function createNoteGroupPermission(
group: Group,
write: boolean,
): NoteGroupPermission {
const noteGroupPermission = new NoteGroupPermission();
noteGroupPermission.canEdit = write;
noteGroupPermission.group = group;
return noteGroupPermission;
}
const everybodyRead = createNoteGroupPermission(groups['everybody'], false);
const everybodyWrite = createNoteGroupPermission(groups['everybody'], true);
const loggedInRead = createNoteGroupPermission(groups['loggedIn'], false);
const loggedInWrite = createNoteGroupPermission(groups['loggedIn'], true);
const user1groupRead = createNoteGroupPermission(
groups['user1group'],
false,
);
const user1groupWrite = createNoteGroupPermission(
groups['user1group'],
true,
);
const user2groupRead = createNoteGroupPermission(
groups['user2group'],
false,
);
const user2groupWrite = createNoteGroupPermission(
groups['user2group'],
true,
);
const user1and2groupRead = createNoteGroupPermission(
groups['user1and2group'],
false,
);
const user1and2groupWrite = createNoteGroupPermission(
groups['user1and2group'],
true,
);
const user2and1groupRead = createNoteGroupPermission(
groups['user2and1group'],
false,
);
const user2and1groupWrite = createNoteGroupPermission(
groups['user2and1group'],
true,
);
return [
[user1groupRead, user1and2groupRead, user2and1groupRead, null], // group0: allow user1 to read via group
[user2and1groupWrite, user1and2groupWrite, user1groupWrite, null], // group1: allow user1 to write via group
[everybodyRead, everybodyWrite, null], // group2: permissions of the special group everybody
[loggedInRead, loggedInWrite, null], // group3: permissions of the special group loggedIn
[user2groupWrite, user2groupRead, null], // group4: don't allow user1 to read or write via group
];
}
/*
* creates the matrix multiplication of group0 to group4 of createAllNoteGroupPermissions
*/
function createNoteGroupPermissionsCombinations(
guestPermission: GuestPermission,
): NoteGroupPermissionWithResultForUser[] {
// for logged in users
const noteGroupPermissions = createAllNoteGroupPermissions();
const result: NoteGroupPermissionWithResultForUser[] = [];
for (const group0 of noteGroupPermissions[0]) {
for (const group1 of noteGroupPermissions[1]) {
for (const group2 of noteGroupPermissions[2]) {
for (const group3 of noteGroupPermissions[3]) {
for (const group4 of noteGroupPermissions[4]) {
const insert = [];
let readPermission = false;
let writePermission = false;
if (group0 !== null) {
// user1 in ReadGroups
readPermission = true;
insert.push(group0);
}
if (group1 !== null) {
// user1 in WriteGroups
readPermission = true;
writePermission = true;
insert.push(group1);
}
if (group2 !== null) {
// everybody group TODO config options
switch (guestPermission) {
case GuestPermission.CREATE_ALIAS:
case GuestPermission.CREATE:
case GuestPermission.WRITE:
writePermission = writePermission || group2.canEdit;
readPermission = true;
break;
case GuestPermission.READ:
readPermission = true;
}
insert.push(group2);
}
if (group3 !== null) {
// loggedIn users
readPermission = true;
writePermission = writePermission || group3.canEdit;
insert.push(group3);
}
if (group4 !== null) {
// user not in group
insert.push(group4);
}
result.push({
permissions: insert,
allowsRead: readPermission,
allowsWrite: writePermission,
});
}
}
}
}
}
return result;
}
// inspired by https://stackoverflow.com/questions/9960908/permutations-in-javascript
function permutator(
inputArr: NoteGroupPermission[],
): NoteGroupPermission[][] {
const results = [];
function permute(arr, memo) {
let cur;
for (let i = 0; i < arr.length; i++) {
cur = arr.splice(i, 1);
if (arr.length === 0) {
results.push(memo.concat(cur));
}
permute(arr.slice(), memo.concat(cur));
arr.splice(i, 0, cur[0]);
}
return results;
}
return permute(inputArr, []);
}
// takes each set of permissions from createNoteGroupPermissionsCombinations, permute them and add them to the list
function permuteNoteGroupPermissions(
noteGroupPermissions: NoteGroupPermissionWithResultForUser[],
): NoteGroupPermissionWithResultForUser[] {
const result: NoteGroupPermissionWithResultForUser[] = [];
for (const permission of noteGroupPermissions) {
const permutations = permutator(permission.permissions);
for (const permutation of permutations) {
result.push({
permissions: permutation,
allowsRead: permission.allowsRead,
allowsWrite: permission.allowsWrite,
});
}
}
return result;
}
describe('check if groups work with', () => {
const guestPermission = GuestPermission.WRITE;
const rawPermissions = createNoteGroupPermissionsCombinations(
guestPermission,
);
const permissions = permuteNoteGroupPermissions(rawPermissions);
let i = 0;
for (const permission of permissions) {
const note = createNote(user2);
note.groupPermissions = permission.permissions;
let permissionString = '';
for (const perm of permission.permissions) {
permissionString += ' ' + perm.group.name + ':' + perm.canEdit;
}
it('mayWrite - test #' + i + ':' + permissionString, () => {
permissionsService.guestPermission = guestPermission;
expect(permissionsService.mayWrite(user1, note)).toEqual(
permission.allowsWrite,
);
});
it('mayRead - test #' + i + ':' + permissionString, () => {
permissionsService.guestPermission = guestPermission;
expect(permissionsService.mayRead(user1, note)).toEqual(
permission.allowsRead,
);
});
i++;
}
});
describe('mayCreate works for', () => {
it('logged in', () => {
permissionsService.guestPermission = GuestPermission.DENY;
expect(permissionsService.mayCreate(user1)).toBeTruthy();
});
it('guest denied', () => {
permissionsService.guestPermission = GuestPermission.DENY;
expect(permissionsService.mayCreate(null)).toBeFalsy();
});
it('guest read', () => {
permissionsService.guestPermission = GuestPermission.READ;
expect(permissionsService.mayCreate(null)).toBeFalsy();
});
it('guest write', () => {
permissionsService.guestPermission = GuestPermission.WRITE;
expect(permissionsService.mayCreate(null)).toBeFalsy();
});
it('guest create', () => {
permissionsService.guestPermission = GuestPermission.CREATE;
expect(permissionsService.mayCreate(null)).toBeTruthy();
});
it('guest create alias', () => {
permissionsService.guestPermission = GuestPermission.CREATE_ALIAS;
expect(permissionsService.mayCreate(null)).toBeTruthy();
});
});
describe('isOwner works', () => {
it('for positive case', () => {
permissionsService.guestPermission = GuestPermission.DENY;
expect(permissionsService.isOwner(user1, notes[0])).toBeTruthy();
});
it('for negative case', () => {
permissionsService.guestPermission = GuestPermission.DENY;
expect(permissionsService.isOwner(user1, notes[1])).toBeFalsy();
});
});
});

View file

@ -0,0 +1,129 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { User } from '../users/user.entity';
import { Note } from '../notes/note.entity';
// TODO move to config or remove
export enum GuestPermission {
DENY = 'deny',
READ = 'read',
WRITE = 'write',
CREATE = 'create',
CREATE_ALIAS = 'createAlias',
}
@Injectable()
export class PermissionsService {
public guestPermission: GuestPermission; // TODO change to configOption
mayRead(user: User, note: Note): boolean {
if (this.isOwner(user, note)) return true;
if (this.hasPermissionUser(user, note, false)) return true;
// noinspection RedundantIfStatementJS
if (this.hasPermissionGroup(user, note, false)) return true;
return false;
}
mayWrite(user: User, note: Note): boolean {
if (this.isOwner(user, note)) return true;
if (this.hasPermissionUser(user, note, true)) return true;
// noinspection RedundantIfStatementJS
if (this.hasPermissionGroup(user, note, true)) return true;
return false;
}
mayCreate(user: User): boolean {
if (user) {
return true;
} else {
if (
this.guestPermission == GuestPermission.CREATE ||
this.guestPermission == GuestPermission.CREATE_ALIAS
) {
// TODO change to guestPermission to config option
return true;
}
}
return false;
}
isOwner(user: User, note: Note): boolean {
if (!user) return false;
if (!note.owner) return false;
return note.owner.id === user.id;
}
private hasPermissionUser(
user: User,
note: Note,
wantEdit: boolean,
): boolean {
if (!user) {
return false;
}
for (const userPermission of note.userPermissions) {
if (
userPermission.user.id === user.id &&
(userPermission.canEdit || !wantEdit)
) {
return true;
}
}
return false;
}
private hasPermissionGroup(
user: User,
note: Note,
wantEdit: boolean,
): boolean {
// TODO: Get real config value
let guestsAllowed = false;
switch (this.guestPermission) {
case GuestPermission.CREATE_ALIAS:
case GuestPermission.CREATE:
case GuestPermission.WRITE:
guestsAllowed = true;
break;
case GuestPermission.READ:
guestsAllowed = !wantEdit;
}
for (const groupPermission of note.groupPermissions) {
if (groupPermission.canEdit || !wantEdit) {
// Handle special groups
if (groupPermission.group.special) {
if (groupPermission.group.name == 'loggedIn') {
// TODO: Name of group for logged in users
return true;
}
if (
groupPermission.group.name == 'everybody' &&
(groupPermission.canEdit || !wantEdit) &&
guestsAllowed
) {
// TODO: Name of group in which everybody even guests can edit
return true;
}
} else {
// Handle normal groups
if (user) {
for (const member of groupPermission.group.members) {
if (member.id === user.id) return true;
}
}
}
}
}
return false;
}
}

View file

@ -17,6 +17,8 @@ import { Authorship } from './authorship.entity';
import { Revision } from './revision.entity'; import { Revision } from './revision.entity';
import { RevisionsService } from './revisions.service'; import { RevisionsService } from './revisions.service';
import { Tag } from '../notes/tag.entity'; import { Tag } from '../notes/tag.entity';
import { NoteGroupPermission } from '../permissions/note-group-permission.entity';
import { NoteUserPermission } from '../permissions/note-user-permission.entity';
describe('RevisionsService', () => { describe('RevisionsService', () => {
let service: RevisionsService; let service: RevisionsService;
@ -48,6 +50,10 @@ describe('RevisionsService', () => {
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Tag)) .overrideProvider(getRepositoryToken(Tag))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(NoteGroupPermission))
.useValue({})
.overrideProvider(getRepositoryToken(NoteUserPermission))
.useValue({})
.compile(); .compile();
service = module.get<RevisionsService>(RevisionsService); service = module.get<RevisionsService>(RevisionsService);

View file

@ -7,6 +7,7 @@
import { import {
CreateDateColumn, CreateDateColumn,
Entity, Entity,
ManyToMany,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
@ -14,6 +15,7 @@ import { Column, OneToMany } from 'typeorm';
import { Note } from '../notes/note.entity'; import { Note } from '../notes/note.entity';
import { AuthToken } from '../auth/auth-token.entity'; import { AuthToken } from '../auth/auth-token.entity';
import { Identity } from './identity.entity'; import { Identity } from './identity.entity';
import { Group } from '../groups/group.entity';
import { HistoryEntry } from '../history/history-entry.entity'; import { HistoryEntry } from '../history/history-entry.entity';
@Entity() @Entity()
@ -52,9 +54,15 @@ export class User {
@OneToMany((_) => Identity, (identity) => identity.user) @OneToMany((_) => Identity, (identity) => identity.user)
identities: Identity[]; identities: Identity[];
@ManyToMany((_) => Group, (group) => group.members)
groups: Group[];
@OneToMany((_) => HistoryEntry, (historyEntry) => historyEntry.user) @OneToMany((_) => HistoryEntry, (historyEntry) => historyEntry.user)
historyEntries: HistoryEntry[]; historyEntries: HistoryEntry[];
// eslint-disable-next-line @typescript-eslint/no-empty-function
private constructor() {}
public static create( public static create(
userName: string, userName: string,
displayName: string, displayName: string,

View file

@ -20,11 +20,15 @@ import { PermissionsModule } from '../../src/permissions/permissions.module';
import { AuthModule } from '../../src/auth/auth.module'; import { AuthModule } from '../../src/auth/auth.module';
import { TokenAuthGuard } from '../../src/auth/token-auth.guard'; import { TokenAuthGuard } from '../../src/auth/token-auth.guard';
import { MockAuthGuard } from '../../src/auth/mock-auth.guard'; import { MockAuthGuard } from '../../src/auth/mock-auth.guard';
import { UsersService } from '../../src/users/users.service';
import { User } from '../../src/users/user.entity';
import { UsersModule } from '../../src/users/users.module'; import { UsersModule } from '../../src/users/users.module';
describe('Notes', () => { describe('Notes', () => {
let app: INestApplication; let app: INestApplication;
let notesService: NotesService; let notesService: NotesService;
let user: User;
let content: string;
beforeAll(async () => { beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ const moduleRef = await Test.createTestingModule({
@ -56,14 +60,16 @@ describe('Notes', () => {
app = moduleRef.createNestApplication(); app = moduleRef.createNestApplication();
await app.init(); await app.init();
notesService = moduleRef.get(NotesService); notesService = moduleRef.get(NotesService);
const userService = moduleRef.get(UsersService);
user = await userService.createUser('hardcoded', 'Testy');
content = 'This is a test note.';
}); });
it(`POST /notes`, async () => { it('POST /notes', async () => {
const newNote = 'This is a test note.';
const response = await request(app.getHttpServer()) const response = await request(app.getHttpServer())
.post('/notes') .post('/notes')
.set('Content-Type', 'text/markdown') .set('Content-Type', 'text/markdown')
.send(newNote) .send(content)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(201); .expect(201);
expect(response.body.metadata?.id).toBeDefined(); expect(response.body.metadata?.id).toBeDefined();
@ -71,88 +77,98 @@ describe('Notes', () => {
await notesService.getCurrentContent( await notesService.getCurrentContent(
await notesService.getNoteByIdOrAlias(response.body.metadata.id), await notesService.getNoteByIdOrAlias(response.body.metadata.id),
), ),
).toEqual(newNote); ).toEqual(content);
}); });
it(`GET /notes/{note}`, async () => { describe('GET /notes/{note}', () => {
// check if we can succefully get a note that exists it('works with an existing note', async () => {
await notesService.createNote('This is a test note.', 'test1'); // check if we can succefully get a note that exists
const response = await request(app.getHttpServer()) await notesService.createNote(content, 'test1', user);
.get('/notes/test1') const response = await request(app.getHttpServer())
.expect('Content-Type', /json/) .get('/notes/test1')
.expect(200); .expect('Content-Type', /json/)
expect(response.body.content).toEqual('This is a test note.'); .expect(200);
expect(response.body.content).toEqual(content);
// check if a missing note correctly returns 404 });
await request(app.getHttpServer()) it('fails with an non-existing note', async () => {
.get('/notes/i_dont_exist') // check if a missing note correctly returns 404
.expect('Content-Type', /json/) await request(app.getHttpServer())
.expect(404); .get('/notes/i_dont_exist')
.expect('Content-Type', /json/)
.expect(404);
});
}); });
it(`POST /notes/{note}`, async () => { describe('POST /notes/{note}', () => {
const newNote = 'This is a test note.'; it('works with a non-existing alias', async () => {
const response = await request(app.getHttpServer()) const response = await request(app.getHttpServer())
.post('/notes/test2') .post('/notes/test2')
.set('Content-Type', 'text/markdown') .set('Content-Type', 'text/markdown')
.send(newNote) .send(content)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(201); .expect(201);
expect(response.body.metadata?.id).toBeDefined(); expect(response.body.metadata?.id).toBeDefined();
return expect( return expect(
await notesService.getCurrentContent( await notesService.getCurrentContent(
await notesService.getNoteByIdOrAlias(response.body.metadata?.id), await notesService.getNoteByIdOrAlias(response.body.metadata?.id),
), ),
).toEqual(newNote); ).toEqual(content);
});
}); });
it(`DELETE /notes/{note}`, async () => { describe('DELETE /notes/{note}', () => {
await notesService.createNote('This is a test note.', 'test3'); it('works with an existing alias', async () => {
await request(app.getHttpServer()).delete('/notes/test3').expect(200); await notesService.createNote(content, 'test3', user);
await expect(notesService.getNoteByIdOrAlias('test3')).rejects.toEqual( await request(app.getHttpServer()).delete('/notes/test3').expect(200);
new NotInDBError("Note with id/alias 'test3' not found."), await expect(notesService.getNoteByIdOrAlias('test3')).rejects.toEqual(
); new NotInDBError("Note with id/alias 'test3' not found."),
// check if a missing note correctly returns 404 );
await request(app.getHttpServer()) });
.delete('/notes/i_dont_exist') it('fails with a non-existing alias', async () => {
.expect(404); await request(app.getHttpServer())
.delete('/notes/i_dont_exist')
.expect(404);
});
}); });
it(`PUT /notes/{note}`, async () => { describe('PUT /notes/{note}', () => {
await notesService.createNote('This is a test note.', 'test4'); const changedContent = 'New note text';
const response = await request(app.getHttpServer()) it('works with existing alias', async () => {
.put('/notes/test4') await notesService.createNote(content, 'test4', user);
.set('Content-Type', 'text/markdown') const response = await request(app.getHttpServer())
.send('New note text') .put('/notes/test4')
.expect(200); .set('Content-Type', 'text/markdown')
await expect( .send(changedContent)
await notesService.getCurrentContent( .expect(200);
await notesService.getNoteByIdOrAlias('test4'), await expect(
), await notesService.getCurrentContent(
).toEqual('New note text'); await notesService.getNoteByIdOrAlias('test4'),
expect(response.body.content).toEqual('New note text'); ),
).toEqual(changedContent);
// check if a missing note correctly returns 404 expect(response.body.content).toEqual(changedContent);
await request(app.getHttpServer()) });
.put('/notes/i_dont_exist') it('fails with a non-existing alias', async () => {
.set('Content-Type', 'text/markdown') await request(app.getHttpServer())
.expect('Content-Type', /json/) .put('/notes/i_dont_exist')
.expect(404); .set('Content-Type', 'text/markdown')
.expect('Content-Type', /json/)
.expect(404);
});
}); });
describe('GET /notes/{note}/metadata', () => { describe('GET /notes/{note}/metadata', () => {
it(`returns complete metadata object`, async () => { it('returns complete metadata object', async () => {
await notesService.createNote('This is a test note.', 'test6'); await notesService.createNote(content, 'test5', user);
const metadata = await request(app.getHttpServer()) const metadata = await request(app.getHttpServer())
.get('/notes/test6/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('test6'); expect(metadata.body.alias).toEqual('test5');
expect(metadata.body.title).toBeNull(); expect(metadata.body.title).toBeNull();
expect(metadata.body.description).toBeNull(); expect(metadata.body.description).toBeNull();
expect(typeof metadata.body.createTime).toEqual('string'); expect(typeof metadata.body.createTime).toEqual('string');
expect(metadata.body.editedBy).toEqual([]); expect(metadata.body.editedBy).toEqual([]);
expect(metadata.body.permissions.owner).toBeNull(); expect(metadata.body.permissions.owner.userName).toEqual('hardcoded');
expect(metadata.body.permissions.sharedToUsers).toEqual([]); expect(metadata.body.permissions.sharedToUsers).toEqual([]);
expect(metadata.body.permissions.sharedToUsers).toEqual([]); expect(metadata.body.permissions.sharedToUsers).toEqual([]);
expect(metadata.body.tags).toEqual([]); expect(metadata.body.tags).toEqual([]);
@ -163,7 +179,9 @@ describe('Notes', () => {
expect(typeof metadata.body.updateUser.photo).toEqual('string'); expect(typeof metadata.body.updateUser.photo).toEqual('string');
expect(typeof metadata.body.viewCount).toEqual('number'); expect(typeof metadata.body.viewCount).toEqual('number');
expect(metadata.body.editedBy).toEqual([]); expect(metadata.body.editedBy).toEqual([]);
});
it('fails with non-existing alias', async () => {
// check if a missing note correctly returns 404 // check if a missing note correctly returns 404
await request(app.getHttpServer()) await request(app.getHttpServer())
.get('/notes/i_dont_exist/metadata') .get('/notes/i_dont_exist/metadata')
@ -173,66 +191,75 @@ describe('Notes', () => {
it('has the correct update/create dates', async () => { it('has the correct update/create dates', async () => {
// create a note // create a note
const note = await notesService.createNote( const note = await notesService.createNote(content, 'test5a', user);
'This is a test note.',
'test6a',
);
// save the creation time // save the creation time
const createDate = (await note.revisions)[0].createdAt; const createDate = (await note.revisions)[0].createdAt;
// wait one second // wait one second
await new Promise((r) => setTimeout(r, 1000)); await new Promise((r) => setTimeout(r, 1000));
// update the note // update the note
await notesService.updateNoteByIdOrAlias('test6a', 'More test content'); await notesService.updateNoteByIdOrAlias('test5a', 'More test content');
const metadata = await request(app.getHttpServer()) const metadata = await request(app.getHttpServer())
.get('/notes/test6a/metadata') .get('/notes/test5a/metadata')
.expect(200); .expect(200);
expect(metadata.body.createTime).toEqual(createDate.toISOString()); expect(metadata.body.createTime).toEqual(createDate.toISOString());
expect(metadata.body.updateTime).not.toEqual(createDate.toISOString()); expect(metadata.body.updateTime).not.toEqual(createDate.toISOString());
}); });
}); });
it(`GET /notes/{note}/revisions`, async () => { describe('GET /notes/{note}/revisions', () => {
await notesService.createNote('This is a test note.', 'test7'); it('works with existing alias', async () => {
const response = await request(app.getHttpServer()) await notesService.createNote(content, 'test6', user);
.get('/notes/test7/revisions') const response = await request(app.getHttpServer())
.expect('Content-Type', /json/) .get('/notes/test6/revisions')
.expect(200); .expect('Content-Type', /json/)
expect(response.body).toHaveLength(1); .expect(200);
expect(response.body).toHaveLength(1);
});
// check if a missing note correctly returns 404 it('fails with non-existing alias', async () => {
await request(app.getHttpServer()) // check if a missing note correctly returns 404
.get('/notes/i_dont_exist/revisions') await request(app.getHttpServer())
.expect('Content-Type', /json/) .get('/notes/i_dont_exist/revisions')
.expect(404); .expect('Content-Type', /json/)
.expect(404);
});
}); });
it(`GET /notes/{note}/revisions/{revision-id}`, async () => { describe('GET /notes/{note}/revisions/{revision-id}', () => {
const note = await notesService.createNote('This is a test note.', 'test8'); it('works with an existing alias', async () => {
const revision = await notesService.getLatestRevision(note); const note = await notesService.createNote(content, 'test7', user);
const response = await request(app.getHttpServer()) const revision = await notesService.getLatestRevision(note);
.get('/notes/test8/revisions/' + revision.id) const response = await request(app.getHttpServer())
.expect('Content-Type', /json/) .get('/notes/test7/revisions/' + revision.id)
.expect(200); .expect('Content-Type', /json/)
expect(response.body.content).toEqual('This is a test note.'); .expect(200);
expect(response.body.content).toEqual(content);
// check if a missing note correctly returns 404 });
await request(app.getHttpServer()) it('fails with non-existing alias', async () => {
.get('/notes/i_dont_exist/revisions/1') // check if a missing note correctly returns 404
.expect('Content-Type', /json/) await request(app.getHttpServer())
.expect(404); .get('/notes/i_dont_exist/revisions/1')
.expect('Content-Type', /json/)
.expect(404);
});
}); });
it(`GET /notes/{note}/content`, async () => { describe('GET /notes/{note}/content', () => {
await notesService.createNote('This is a test note.', 'test9'); it('works with an existing alias', async () => {
const response = await request(app.getHttpServer()) await notesService.createNote(content, 'test8', user);
.get('/notes/test9/content') const response = await request(app.getHttpServer())
.expect(200); .get('/notes/test8/content')
expect(response.text).toEqual('This is a test note.'); .expect(200);
expect(response.text).toEqual(content);
});
// check if a missing note correctly returns 404 it('fails with non-existing alias', async () => {
await request(app.getHttpServer()) // check if a missing note correctly returns 404
.get('/notes/i_dont_exist/content') await request(app.getHttpServer())
.expect(404); .get('/notes/i_dont_exist/content')
.expect('Content-Type', /text\/markdown/)
.expect(404);
});
}); });
afterAll(async () => { afterAll(async () => {