Files
hedgedoc/backend/test/public-api/public-api.media.e2e-spec.ts
T
Erik Michelson c2ccf48c39 feat(media): permission checks for accessing media uploads
Previously, media uploads were associated with one note upload but
served regardless of the requesting user's permission. As we have
a fine-grade permission system in place, we can use this as well
for enforcing permission checks on media uploads. In order to make
an upload reusable across multiple notes, this adds the capability
to link one media upload to multiple notes and unlink that accordingly
again.

Signed-off-by: Erik Michelson <github@erik.michelson.eu>
2026-07-24 22:17:00 +02:00

251 lines
8.6 KiB
TypeScript

/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
import { PUBLIC_API_PREFIX } from '../../src/app.module';
import type { MediaUploadDto } from '../../src/dtos/media-upload.dto';
import { ConsoleLoggerService } from '../../src/logger/console-logger.service';
import { getCurrentDateTime, isoStringToDateTime } from '../../src/utils/datetime';
import type { TestSetup } from '../test-setup';
import { noteAlias1, TestSetupBuilder, username1 } from '../test-setup';
import { ensureDeleted } from '../utils';
import { promises as fs } from 'fs';
import { join } from 'path';
import request from 'supertest';
describe('Media', () => {
let testSetup: TestSetup;
let agent: request.SuperAgentTest;
let testImage: Buffer;
let uploadPath: string;
beforeEach(async () => {
testSetup = await TestSetupBuilder.create().withUsers().build();
testImage = await fs.readFile('test/public-api/fixtures/test.png');
uploadPath = testSetup.configService.get('mediaConfig').backend.filesystem.uploadPath;
await testSetup.init();
agent = request.agent(testSetup.app.getHttpServer());
const logger = await testSetup.app.resolve(ConsoleLoggerService);
logger.log('Switching logger', 'AppBootstrap');
testSetup.app.useLogger(logger);
});
afterEach(async () => {
// Delete the upload folder
await ensureDeleted(uploadPath);
await testSetup.cleanup();
});
describe(`POST ${PUBLIC_API_PREFIX}/media`, () => {
it('uploads image', async () => {
const uploadResponse = await agent
.post(`${PUBLIC_API_PREFIX}/media`)
.set('Authorization', `Bearer ${testSetup.authTokens[0].secret}`)
.attach('file', 'test/public-api/fixtures/test.png')
.set('HedgeDoc-Note', noteAlias1)
.expect('Content-Type', /json/)
.expect(201);
const uuid = uploadResponse.body.uuid;
const file = await fs.readFile(join(uploadPath, uuid + '.png'));
expect(file).toEqual(testImage);
});
it('uploads image and uppercase note alias', async () => {
const uploadResponse = await agent
.post(`${PUBLIC_API_PREFIX}/media`)
.set('Authorization', `Bearer ${testSetup.authTokens[0].secret}`)
.attach('file', 'test/public-api/fixtures/test.png')
.set('HedgeDoc-Note', noteAlias1.toUpperCase())
.expect('Content-Type', /json/)
.expect(201);
const uuid = uploadResponse.body.uuid;
const file = await fs.readFile(join(uploadPath, uuid + '.png'));
expect(file).toEqual(testImage);
});
describe('fails:', () => {
beforeEach(async () => {
await ensureDeleted(uploadPath);
});
it('MIME type not supported', async () => {
await agent
.post(`${PUBLIC_API_PREFIX}/media`)
.set('Authorization', `Bearer ${testSetup.authTokens[0].secret}`)
.attach('file', 'test/public-api/fixtures/test.zip')
.set('HedgeDoc-Note', noteAlias1)
.expect(400);
await expect(fs.access(uploadPath)).rejects.toBeDefined();
});
it('note does not exist', async () => {
await agent
.post(`${PUBLIC_API_PREFIX}/media`)
.set('Authorization', `Bearer ${testSetup.authTokens[0].secret}`)
.attach('file', 'test/public-api/fixtures/test.zip')
.set('HedgeDoc-Note', 'i_dont_exist')
.expect(404);
await expect(fs.access(uploadPath)).rejects.toBeDefined();
});
it('mediaBackend error', async () => {
await fs.mkdir(uploadPath, {
mode: '444',
});
await agent
.post(`${PUBLIC_API_PREFIX}/media`)
.set('Authorization', `Bearer ${testSetup.authTokens[0].secret}`)
.attach('file', 'test/public-api/fixtures/test.png')
.set('HedgeDoc-Note', noteAlias1)
.expect('Content-Type', /json/)
.expect(500);
});
it('no file uploaded', async () => {
await agent
.post(`${PUBLIC_API_PREFIX}/media`)
.set('Authorization', `Bearer ${testSetup.authTokens[0].secret}`)
.set('HedgeDoc-Note', noteAlias1)
.expect('Content-Type', /json/)
.expect(400);
});
afterEach(async () => {
await ensureDeleted(uploadPath);
});
});
});
describe(`GET ${PUBLIC_API_PREFIX}/media/{:uuid}`, () => {
const fileName = 'test.png';
it('returns the media info', async () => {
jest.useFakeTimers({
legacyFakeTimers: true,
});
const hardCodedNow = getCurrentDateTime();
const uuid = await testSetup.mediaService.saveFile(
fileName,
testImage,
testSetup.userIds[0],
testSetup.ownedNoteIds[0],
);
const response = await agent
.get(`${PUBLIC_API_PREFIX}/media/${uuid}`)
.set('Authorization', `Bearer ${testSetup.authTokens[0].secret}`)
.expect('Content-Type', /json/)
.expect(200);
const mediaDto: MediaUploadDto = response.body;
expect(mediaDto.uuid).toEqual(uuid);
expect(
isoStringToDateTime(mediaDto.createdAt).toMillis() - hardCodedNow.toMillis(),
).toBeLessThan(100);
expect(mediaDto.linkedNoteCount).toEqual(1);
expect(mediaDto.fileName).toEqual(fileName);
expect(mediaDto.username).toEqual(username1);
jest.useRealTimers();
});
});
describe(`DELETE ${PUBLIC_API_PREFIX}/media/{:uuid}`, () => {
it('successfully deletes an uploaded file', async () => {
const upload = await testSetup.mediaService.saveFile(
'test.png',
testImage,
testSetup.userIds[0],
testSetup.ownedNoteIds[0],
);
await agent
.delete(`${PUBLIC_API_PREFIX}/media/${upload}`)
.set('Authorization', `Bearer ${testSetup.authTokens[0].secret}`)
.expect(204);
});
it('deleting user is owner of file', async () => {
// upload a file with the default test user
const testNote = await testSetup.notesService.createNote(
'test content',
testSetup.userIds[0],
'test_delete_media_file',
);
const upload = await testSetup.mediaService.saveFile(
'test.png',
testImage,
testSetup.userIds[0],
testNote,
);
// try to delete upload with second user
await agent
.delete(`${PUBLIC_API_PREFIX}/media/${upload}`)
.set('Authorization', `Bearer ${testSetup.authTokens[1].secret}`)
.expect(403);
await agent
.get(`/uploads/${upload}.png`)
.set('Authorization', `Bearer ${testSetup.authTokens[1].secret}`)
.expect(200);
// delete upload for real
await agent
.delete(`${PUBLIC_API_PREFIX}/media/${upload}`)
.set('Authorization', `Bearer ${testSetup.authTokens[0].secret}`)
.expect(204);
// Test if file is really deleted
await agent
.get(`/uploads/${upload}.png`)
.set('Authorization', `Bearer ${testSetup.authTokens[0].secret}`)
.expect(404);
});
it('deleting user is owner of note', async () => {
// upload a file with the default test user
const testNote = await testSetup.notesService.createNote(
'test content',
testSetup.userIds[2],
'test_delete_media_note',
);
const upload = await testSetup.mediaService.saveFile(
'test.png',
testImage,
testSetup.userIds[0],
testNote,
);
// try to delete upload with second user
await agent
.delete(`${PUBLIC_API_PREFIX}/media/${upload}`)
.set('Authorization', `Bearer ${testSetup.authTokens[1].secret}`)
.expect(403);
await agent
.get(`/uploads/${upload}.png`)
.set('Authorization', `Bearer ${testSetup.authTokens[1].secret}`)
.expect(200);
// delete upload for real
await agent
.delete(`${PUBLIC_API_PREFIX}/media/${upload}`)
.set('Authorization', `Bearer ${testSetup.authTokens[2].secret}`)
.expect(204);
// Test if file is really deleted
await agent
.get(`/uploads/${upload}.png`)
.set('Authorization', `Bearer ${testSetup.authTokens[2].secret}`)
.expect(404);
});
it('errors if the user does not own the file', async () => {
const uuid = await testSetup.mediaService.saveFile(
'test.png',
testImage,
testSetup.userIds[0],
testSetup.ownedNoteIds[0],
);
await agent
.delete(`${PUBLIC_API_PREFIX}/media/${uuid}`)
.set('Authorization', `Bearer ${testSetup.authTokens[1].secret}`)
.expect(403);
});
});
});