diff --git a/src/api/private/notes/notes.controller.spec.ts b/src/api/private/notes/notes.controller.spec.ts new file mode 100644 index 000000000..d42903ca7 --- /dev/null +++ b/src/api/private/notes/notes.controller.spec.ts @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { NotesController } from './notes.controller'; +import { NotesService } from '../../../notes/notes.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Note } from '../../../notes/note.entity'; +import { Tag } from '../../../notes/tag.entity'; +import { RevisionsModule } from '../../../revisions/revisions.module'; +import { UsersModule } from '../../../users/users.module'; +import { GroupsModule } from '../../../groups/groups.module'; +import { LoggerModule } from '../../../logger/logger.module'; +import { PermissionsModule } from '../../../permissions/permissions.module'; +import { HistoryModule } from '../../../history/history.module'; +import { MediaModule } from '../../../media/media.module'; +import { ConfigModule } from '@nestjs/config'; +import appConfigMock from '../../../config/mock/app.config.mock'; +import mediaConfigMock from '../../../config/mock/media.config.mock'; +import { Revision } from '../../../revisions/revision.entity'; +import { Authorship } from '../../../revisions/authorship.entity'; +import { AuthorColor } from '../../../notes/author-color.entity'; +import { User } from '../../../users/user.entity'; +import { AuthToken } from '../../../auth/auth-token.entity'; +import { Identity } from '../../../users/identity.entity'; +import { HistoryEntry } from '../../../history/history-entry.entity'; +import { NoteGroupPermission } from '../../../permissions/note-group-permission.entity'; +import { NoteUserPermission } from '../../../permissions/note-user-permission.entity'; +import { Group } from '../../../groups/group.entity'; +import { MediaUpload } from '../../../media/media-upload.entity'; + +describe('NotesController', () => { + let controller: NotesController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [NotesController], + providers: [ + NotesService, + { + provide: getRepositoryToken(Note), + useValue: {}, + }, + { + provide: getRepositoryToken(Tag), + useValue: {}, + }, + ], + imports: [ + RevisionsModule, + UsersModule, + GroupsModule, + LoggerModule, + PermissionsModule, + HistoryModule, + MediaModule, + ConfigModule.forRoot({ + isGlobal: true, + load: [appConfigMock, mediaConfigMock], + }), + ], + }) + .overrideProvider(getRepositoryToken(Note)) + .useValue({}) + .overrideProvider(getRepositoryToken(Revision)) + .useValue({}) + .overrideProvider(getRepositoryToken(Authorship)) + .useValue({}) + .overrideProvider(getRepositoryToken(AuthorColor)) + .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({}) + .compile(); + + controller = module.get(NotesController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/api/private/notes/notes.controller.ts b/src/api/private/notes/notes.controller.ts new file mode 100644 index 000000000..752a150bc --- /dev/null +++ b/src/api/private/notes/notes.controller.ts @@ -0,0 +1,219 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { + BadRequestException, + Controller, + Delete, + Get, + HttpCode, + NotFoundException, + Param, + Post, + UnauthorizedException, +} from '@nestjs/common'; +import { Note } from '../../../notes/note.entity'; +import { + AlreadyInDBError, + ForbiddenIdError, + NotInDBError, +} from '../../../errors/errors'; +import { NoteDto } from '../../../notes/note.dto'; +import { ConsoleLoggerService } from '../../../logger/console-logger.service'; +import { NotesService } from '../../../notes/notes.service'; +import { PermissionsService } from '../../../permissions/permissions.service'; +import { HistoryService } from '../../../history/history.service'; +import { UsersService } from '../../../users/users.service'; +import { MediaUploadDto } from '../../../media/media-upload.dto'; +import { MediaService } from '../../../media/media.service'; +import { RevisionMetadataDto } from '../../../revisions/revision-metadata.dto'; +import { RevisionDto } from '../../../revisions/revision.dto'; +import { RevisionsService } from '../../../revisions/revisions.service'; +import { MarkdownBody } from '../../utils/markdownbody-decorator'; + +@Controller('notes') +export class NotesController { + constructor( + private readonly logger: ConsoleLoggerService, + private noteService: NotesService, + private permissionsService: PermissionsService, + private historyService: HistoryService, + private userService: UsersService, + private mediaService: MediaService, + private revisionsService: RevisionsService, + ) { + this.logger.setContext(NotesController.name); + } + + @Get(':noteIdOrAlias') + async getNote( + @Param('noteIdOrAlias') noteIdOrAlias: string, + ): Promise { + // ToDo: use actual user here + const user = await this.userService.getUserByUsername('hardcoded'); + let note: Note; + try { + note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias); + } catch (e) { + if (e instanceof NotInDBError) { + throw new NotFoundException(e.message); + } + if (e instanceof ForbiddenIdError) { + throw new BadRequestException(e.message); + } + throw e; + } + if (!this.permissionsService.mayRead(user, note)) { + throw new UnauthorizedException('Reading note denied!'); + } + await this.historyService.createOrUpdateHistoryEntry(note, user); + return await this.noteService.toNoteDto(note); + } + + @Get(':noteIdOrAlias/media') + async getNotesMedia( + @Param('noteIdOrAlias') noteIdOrAlias: string, + ): Promise { + try { + // ToDo: use actual user here + const user = await this.userService.getUserByUsername('hardcoded'); + const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias); + if (!this.permissionsService.mayRead(user, note)) { + throw new UnauthorizedException('Reading note denied!'); + } + const media = await this.mediaService.listUploadsByNote(note); + return media.map((media) => this.mediaService.toMediaUploadDto(media)); + } catch (e) { + if (e instanceof NotInDBError) { + throw new NotFoundException(e.message); + } + throw e; + } + } + + @Post() + @HttpCode(201) + async createNote(@MarkdownBody() text: string): Promise { + // ToDo: use actual user here + const user = await this.userService.getUserByUsername('hardcoded'); + // ToDo: provide user for createNoteDto + if (!this.permissionsService.mayCreate(user)) { + throw new UnauthorizedException('Creating note denied!'); + } + this.logger.debug('Got raw markdown:\n' + text); + return await this.noteService.toNoteDto( + await this.noteService.createNote(text, undefined, user), + ); + } + + @Post(':noteAlias') + @HttpCode(201) + async createNamedNote( + @Param('noteAlias') noteAlias: string, + @MarkdownBody() text: string, + ): Promise { + // ToDo: use actual user here + const user = await this.userService.getUserByUsername('hardcoded'); + if (!this.permissionsService.mayCreate(user)) { + throw new UnauthorizedException('Creating note denied!'); + } + this.logger.debug('Got raw markdown:\n' + text, 'createNamedNote'); + try { + return await this.noteService.toNoteDto( + await this.noteService.createNote(text, noteAlias, user), + ); + } catch (e) { + if (e instanceof AlreadyInDBError) { + throw new BadRequestException(e.message); + } + if (e instanceof ForbiddenIdError) { + throw new BadRequestException(e.message); + } + throw e; + } + } + + @Delete(':noteIdOrAlias') + @HttpCode(204) + async deleteNote( + @Param('noteIdOrAlias') noteIdOrAlias: string, + ): Promise { + try { + // ToDo: use actual user here + const user = await this.userService.getUserByUsername('hardcoded'); + const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias); + if (!this.permissionsService.isOwner(user, note)) { + throw new UnauthorizedException('Deleting note denied!'); + } + this.logger.debug('Deleting note: ' + noteIdOrAlias, 'deleteNote'); + await this.noteService.deleteNote(note); + this.logger.debug('Successfully deleted ' + noteIdOrAlias, 'deleteNote'); + return; + } catch (e) { + if (e instanceof NotInDBError) { + throw new NotFoundException(e.message); + } + if (e instanceof ForbiddenIdError) { + throw new BadRequestException(e.message); + } + throw e; + } + } + + @Get(':noteIdOrAlias/revisions') + async getNoteRevisions( + @Param('noteIdOrAlias') noteIdOrAlias: string, + ): Promise { + try { + // ToDo: use actual user here + const user = await this.userService.getUserByUsername('hardcoded'); + const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias); + if (!this.permissionsService.mayRead(user, note)) { + throw new UnauthorizedException('Reading note denied!'); + } + const revisions = await this.revisionsService.getAllRevisions(note); + return await Promise.all( + revisions.map((revision) => + this.revisionsService.toRevisionMetadataDto(revision), + ), + ); + } catch (e) { + if (e instanceof NotInDBError) { + throw new NotFoundException(e.message); + } + if (e instanceof ForbiddenIdError) { + throw new BadRequestException(e.message); + } + throw e; + } + } + + @Get(':noteIdOrAlias/revisions/:revisionId') + async getNoteRevision( + @Param('noteIdOrAlias') noteIdOrAlias: string, + @Param('revisionId') revisionId: number, + ): Promise { + try { + // ToDo: use actual user here + const user = await this.userService.getUserByUsername('hardcoded'); + const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias); + if (!this.permissionsService.mayRead(user, note)) { + throw new UnauthorizedException('Reading note denied!'); + } + return this.revisionsService.toRevisionDto( + await this.revisionsService.getRevision(note, revisionId), + ); + } catch (e) { + if (e instanceof NotInDBError) { + throw new NotFoundException(e.message); + } + if (e instanceof ForbiddenIdError) { + throw new BadRequestException(e.message); + } + throw e; + } + } +} diff --git a/src/api/private/private-api.module.ts b/src/api/private/private-api.module.ts index bcc7c2210..bd51ad1e5 100644 --- a/src/api/private/private-api.module.ts +++ b/src/api/private/private-api.module.ts @@ -14,6 +14,10 @@ import { FrontendConfigModule } from '../../frontend-config/frontend-config.modu import { HistoryController } from './me/history/history.controller'; import { HistoryModule } from '../../history/history.module'; import { NotesModule } from '../../notes/notes.module'; +import { NotesController } from './notes/notes.controller'; +import { PermissionsModule } from '../../permissions/permissions.module'; +import { MediaModule } from '../../media/media.module'; +import { RevisionsModule } from '../../revisions/revisions.module'; @Module({ imports: [ @@ -23,7 +27,15 @@ import { NotesModule } from '../../notes/notes.module'; FrontendConfigModule, HistoryModule, NotesModule, + PermissionsModule, + MediaModule, + RevisionsModule, + ], + controllers: [ + TokensController, + ConfigController, + HistoryController, + NotesController, ], - controllers: [TokensController, ConfigController, HistoryController], }) export class PrivateApiModule {} diff --git a/test/private-api/notes.e2e-spec.ts b/test/private-api/notes.e2e-spec.ts new file mode 100644 index 000000000..7e5806755 --- /dev/null +++ b/test/private-api/notes.e2e-spec.ts @@ -0,0 +1,283 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable +@typescript-eslint/no-unsafe-assignment, +@typescript-eslint/no-unsafe-member-access +*/ + +import { INestApplication } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { Test } from '@nestjs/testing'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import * as request from 'supertest'; +import mediaConfigMock from '../../src/config/mock/media.config.mock'; +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 { NotInDBError } from '../../src/errors/errors'; +import { GroupsModule } from '../../src/groups/groups.module'; +import { LoggerModule } from '../../src/logger/logger.module'; +import { NotesModule } from '../../src/notes/notes.module'; +import { NotesService } from '../../src/notes/notes.service'; +import { PermissionsModule } from '../../src/permissions/permissions.module'; +import { AuthModule } from '../../src/auth/auth.module'; +import { UsersService } from '../../src/users/users.service'; +import { User } from '../../src/users/user.entity'; +import { UsersModule } from '../../src/users/users.module'; +import { promises as fs } from 'fs'; +import { MediaService } from '../../src/media/media.service'; +import { PrivateApiModule } from '../../src/api/private/private-api.module'; +import { join } from 'path'; + +describe('Notes', () => { + let app: INestApplication; + let notesService: NotesService; + let mediaService: MediaService; + let user: User; + let user2: User; + let content: string; + let forbiddenNoteId: string; + let uploadPath: 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-notes.sqlite', + autoLoadEntities: true, + synchronize: true, + dropSchema: true, + }), + LoggerModule, + AuthModule, + UsersModule, + ], + }).compile(); + + const config = moduleRef.get(ConfigService); + forbiddenNoteId = config.get('appConfig').forbiddenNoteIds[0]; + uploadPath = config.get('mediaConfig').backend.filesystem.uploadPath; + app = moduleRef.createNestApplication(); + await app.init(); + notesService = moduleRef.get(NotesService); + mediaService = moduleRef.get(MediaService); + const userService = moduleRef.get(UsersService); + user = await userService.createUser('hardcoded', 'Testy'); + user2 = await userService.createUser('hardcoded2', 'Max Mustermann'); + content = 'This is a test note.'; + }); + + it('POST /notes', async () => { + const response = await request(app.getHttpServer()) + .post('/notes') + .set('Content-Type', 'text/markdown') + .send(content) + .expect('Content-Type', /json/) + .expect(201); + expect(response.body.metadata?.id).toBeDefined(); + expect( + await notesService.getNoteContent( + await notesService.getNoteByIdOrAlias(response.body.metadata.id), + ), + ).toEqual(content); + }); + + describe('GET /notes/{note}', () => { + it('works with an existing note', async () => { + // check if we can succefully get a note that exists + await notesService.createNote(content, 'test1', user); + const response = await request(app.getHttpServer()) + .get('/notes/test1') + .expect('Content-Type', /json/) + .expect(200); + expect(response.body.content).toEqual(content); + }); + it('fails with an non-existing note', async () => { + // check if a missing note correctly returns 404 + await request(app.getHttpServer()) + .get('/notes/i_dont_exist') + .expect('Content-Type', /json/) + .expect(404); + }); + }); + + describe('POST /notes/{note}', () => { + it('works with a non-existing alias', async () => { + const response = await request(app.getHttpServer()) + .post('/notes/test2') + .set('Content-Type', 'text/markdown') + .send(content) + .expect('Content-Type', /json/) + .expect(201); + expect(response.body.metadata?.id).toBeDefined(); + return expect( + await notesService.getNoteContent( + await notesService.getNoteByIdOrAlias(response.body.metadata?.id), + ), + ).toEqual(content); + }); + + it('fails with a forbidden alias', async () => { + await request(app.getHttpServer()) + .post(`/notes/${forbiddenNoteId}`) + .set('Content-Type', 'text/markdown') + .send(content) + .expect('Content-Type', /json/) + .expect(400); + }); + + it('fails with a existing alias', async () => { + await request(app.getHttpServer()) + .post('/notes/test2') + .set('Content-Type', 'text/markdown') + .send(content) + .expect('Content-Type', /json/) + .expect(400); + }); + }); + + describe('DELETE /notes/{note}', () => { + it('works with an existing alias', async () => { + await notesService.createNote(content, 'test3', user); + await request(app.getHttpServer()).delete('/notes/test3').expect(204); + await expect(notesService.getNoteByIdOrAlias('test3')).rejects.toEqual( + new NotInDBError("Note with id/alias 'test3' not found."), + ); + }); + it('fails with a forbidden alias', async () => { + await request(app.getHttpServer()) + .delete(`/notes/${forbiddenNoteId}`) + .expect(400); + }); + it('fails with a non-existing alias', async () => { + await request(app.getHttpServer()) + .delete('/notes/i_dont_exist') + .expect(404); + }); + }); + + describe('GET /notes/{note}/revisions', () => { + it('works with existing alias', async () => { + await notesService.createNote(content, 'test4', user); + const response = await request(app.getHttpServer()) + .get('/notes/test4/revisions') + .expect('Content-Type', /json/) + .expect(200); + expect(response.body).toHaveLength(1); + }); + + it('fails with a forbidden alias', async () => { + await request(app.getHttpServer()) + .get(`/notes/${forbiddenNoteId}/revisions`) + .expect(400); + }); + + it('fails with non-existing alias', async () => { + // check if a missing note correctly returns 404 + await request(app.getHttpServer()) + .get('/notes/i_dont_exist/revisions') + .expect('Content-Type', /json/) + .expect(404); + }); + }); + + describe('GET /notes/{note}/revisions/{revision-id}', () => { + it('works with an existing alias', async () => { + const note = await notesService.createNote(content, 'test5', user); + const revision = await notesService.getLatestRevision(note); + const response = await request(app.getHttpServer()) + .get(`/notes/test5/revisions/${revision.id}`) + .expect('Content-Type', /json/) + .expect(200); + expect(response.body.content).toEqual(content); + }); + it('fails with a forbidden alias', async () => { + await request(app.getHttpServer()) + .get(`/notes/${forbiddenNoteId}/revisions/1`) + .expect(400); + }); + it('fails with non-existing alias', async () => { + // check if a missing note correctly returns 404 + await request(app.getHttpServer()) + .get('/notes/i_dont_exist/revisions/1') + .expect('Content-Type', /json/) + .expect(404); + }); + }); + + describe('GET /notes/{note}/media', () => { + it('works', async () => { + const note = await notesService.createNote(content, 'test6', user); + const extraNote = await notesService.createNote(content, 'test7', user); + const httpServer = app.getHttpServer(); + const response = await request(httpServer) + .get(`/notes/${note.id}/media/`) + .expect('Content-Type', /json/) + .expect(200); + expect(response.body).toHaveLength(0); + + const testImage = await fs.readFile('test/public-api/fixtures/test.png'); + const url0 = await mediaService.saveFile(testImage, 'hardcoded', note.id); + const url1 = await mediaService.saveFile( + testImage, + 'hardcoded', + extraNote.id, + ); + + const responseAfter = await request(httpServer) + .get(`/notes/${note.id}/media/`) + .expect('Content-Type', /json/) + .expect(200); + expect(responseAfter.body).toHaveLength(1); + expect(responseAfter.body[0].url).toEqual(url0); + expect(responseAfter.body[0].url).not.toEqual(url1); + for (const fileUrl of [url0, url1]) { + const fileName = fileUrl.replace('/uploads/', ''); + // delete the file afterwards + await fs.unlink(join(uploadPath, fileName)); + } + await fs.rmdir(uploadPath); + }); + it('fails, when note does not exist', async () => { + await request(app.getHttpServer()) + .get(`/notes/i_dont_exist/media/`) + .expect('Content-Type', /json/) + .expect(404); + }); + it("fails, when user can't read note", async () => { + const note = await notesService.createNote( + 'This is a test note.', + 'test11', + user2, + ); + await request(app.getHttpServer()) + .get(`/notes/${note.id}/media/`) + .expect('Content-Type', /json/) + .expect(401); + }); + }); + + afterAll(async () => { + await app.close(); + }); +});