included serverside mime detection
Sonarqube Scanner / Build and analyze (push) Successful in 1m6s

This commit is contained in:
Florian Zumpe
2026-06-17 11:42:43 +02:00
parent 3b3069ca8c
commit 0def9b23db
11 changed files with 574 additions and 126 deletions
+8 -6
View File
@@ -26,6 +26,7 @@ test('complete REST API documentation is kept outside the README', async () => {
'POST /api/routes/:id/append',
'POST /api/routes/:id/pois',
'PUT /api/routes/:routeId/pois/:poiId',
'DELETE /api/routes/:routeId/pois/:poiId',
'GET /api/routes/:routeId/pois/:poiId/pictures',
'GET /api/routes/:routeId/pois/:poiId/pictures/:pictureId',
'POST /api/routes/:routeId/pois/:poiId/pictures',
@@ -45,7 +46,7 @@ test('complete REST API documentation is kept outside the README', async () => {
'lat', 'lon', 'radiusKm', 'includeDeleted', 'gpx', 'name', 'slug',
'description', 'schoolName', 'title', 'triggerRadiusM', 'sequence',
'picture', 'pictureId', 'caption', 'audio', 'poiId', 'metadata',
'filename', 'contentType', 'base64', 'dataUrl'
'filename', 'base64', 'dataUrl'
]) {
assert.match(api, new RegExp(`\\b${parameter}\\b`), `missing parameter documentation: ${parameter}`);
}
@@ -57,17 +58,18 @@ test('POI media uploads use POI-scoped single-resource endpoints', async () => {
const mediaService = await read('src/services/media-service.js');
const api = await read('docs/REST-API.md');
assert.match(router, /post\('\/routes\/:routeId\/pois\/:poiId\/pictures', upload\.single\('picture'\), jsonUpload\('picture'\)/);
assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId', upload\.single\('picture'\), jsonUpload\('picture'\)/);
assert.match(router, /post\('\/routes\/:routeId\/pois\/:poiId\/pictures', upload\.single\('picture'\), inspectUpload\('picture', 'picture'/);
assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId', upload\.single\('picture'\), inspectUpload\('picture', 'picture'/);
assert.match(router, /delete\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId'/);
assert.match(router, /get\('\/routes\/:routeId\/pois\/:poiId\/audio'/);
assert.match(router, /post\('\/routes\/:routeId\/pois\/:poiId\/audio', upload\.single\('audio'\), jsonUpload\('audio'\)/);
assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId\/audio', upload\.single\('audio'\), jsonUpload\('audio'\)/);
assert.match(router, /post\('\/routes\/:routeId\/pois\/:poiId\/audio', upload\.single\('audio'\), inspectUpload\('audio', 'audio'/);
assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId\/audio', upload\.single\('audio'\), inspectUpload\('audio', 'audio'/);
assert.match(router, /delete\('\/routes\/:routeId\/pois\/:poiId\/audio'/);
assert.doesNotMatch(router, /\/routes\/:id\/(?:pictures|audio)/);
assert.match(router, /post\('\/routes\/:id\/pois', upload\.none\(\),/);
assert.match(router, /get\('\/routes\/:routeId\/pois\/:poiId'/);
assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId', upload\.none\(\)/);
assert.match(router, /delete\('\/routes\/:routeId\/pois\/:poiId'/);
assert.doesNotMatch(router, /['"]\/pois\/:id['"]/);
assert.doesNotMatch(routesService, /files\.images|files\.audio/);
assert.match(mediaService, /caption/);
@@ -78,7 +80,7 @@ test('POI media uploads use POI-scoped single-resource endpoints', async () => {
assert.doesNotMatch(router, /['"`]\/media\//);
assert.doesNotMatch(routesService, /safeMediaUrl|\/media\//);
assert.doesNotMatch(mediaService, /safeMediaUrl|\/media\//);
assert.match(router, /jsonUpload/);
assert.match(router, /inspectUpload/);
assert.match(api, /Content-Type: `application\/json`/);
assert.match(api, /`text\/json`/);
assert.match(api, /Base64/);
+57 -19
View File
@@ -7,6 +7,19 @@ import { spawn } from 'node:child_process';
import test from 'node:test';
const root = path.resolve(import.meta.dirname, '..');
const PNG_FILE = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=',
'base64'
);
const MP3_FILE = Buffer.concat([
Buffer.from('49443304000000000000', 'hex'),
Buffer.from('fffb9064', 'hex'),
Buffer.alloc(512)
]);
const WAV_FILE = Buffer.from(
'524946462400000057415645666d74201000000001000100401f0000803e0000020010006461746100000000',
'hex'
);
async function freePort() {
return new Promise((resolve, reject) => {
@@ -79,7 +92,7 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
const gpx = await fs.readFile(path.join(root, 'examples', 'sample-route.gpx'));
const routeForm = new FormData();
routeForm.append('name', 'Medien-API-Test');
routeForm.append('gpx', new Blob([gpx], { type: 'application/gpx+xml' }), 'route.gpx');
routeForm.append('gpx', new Blob([gpx], { type: 'application/octet-stream' }), 'route.txt');
const { body: route } = await requestJson(`${baseUrl}/api/routes`, {
method: 'POST',
body: routeForm
@@ -139,7 +152,7 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
pictureForm.append('poiId', String(otherPoi.id));
pictureForm.append('caption', 'Erste Bildbeschreibung');
pictureForm.append('sequence', '0');
pictureForm.append('picture', new Blob(['picture-one'], { type: 'image/png' }), 'bild.png');
pictureForm.append('picture', new Blob([PNG_FILE], { type: 'application/octet-stream' }), 'bild.txt');
const { response: pictureResponse, body: picture } = await requestJson(
`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures`,
{ method: 'POST', body: pictureForm },
@@ -151,7 +164,7 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
assert.equal(picture.url, `/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`);
const { response: pictureFileResponse, body: pictureFile } = await requestBuffer(`${baseUrl}${picture.url}`);
assert.match(pictureFileResponse.headers.get('content-type') || '', /image\/png/);
assert.equal(pictureFile.toString(), 'picture-one');
assert.deepEqual(pictureFile, PNG_FILE);
const { body: pictureMetadata } = await requestJson(`${baseUrl}${picture.url}?metadata=true`);
assert.equal(pictureMetadata.caption, 'Erste Bildbeschreibung');
assert.equal(pictureMetadata.poiId, poi.id);
@@ -169,9 +182,8 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
caption: 'Bild aus JSON',
sequence: 1,
picture: {
filename: 'bild-json.webp',
contentType: 'image/webp',
base64: Buffer.from('picture-json').toString('base64')
filename: 'bild-json.bin',
base64: PNG_FILE.toString('base64')
}
};
const { body: jsonPicture } = await requestJson(
@@ -186,8 +198,8 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
assert.equal(jsonPicture.poiId, otherPoi.id);
assert.equal(jsonPicture.caption, 'Bild aus JSON');
const { response: jsonPictureResponse, body: jsonPictureFile } = await requestBuffer(`${baseUrl}${jsonPicture.url}`);
assert.match(jsonPictureResponse.headers.get('content-type') || '', /image\/webp/);
assert.equal(jsonPictureFile.toString(), 'picture-json');
assert.match(jsonPictureResponse.headers.get('content-type') || '', /image\/png/);
assert.deepEqual(jsonPictureFile, PNG_FILE);
await requestJson(
`${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures`,
@@ -199,6 +211,22 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
400
);
await requestJson(
`${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
picture: {
filename: 'behauptetes-bild.png',
contentType: 'image/png',
base64: Buffer.from('kein Bild').toString('base64')
}
})
},
415
);
const { body: updatedPicture } = await requestJson(
`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`,
{
@@ -211,7 +239,7 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
assert.equal(updatedPicture.sequence, 2);
const audioForm = new FormData();
audioForm.append('audio', new Blob(['audio-one'], { type: 'audio/mpeg' }), 'ansage.mp3');
audioForm.append('audio', new Blob([MP3_FILE], { type: 'application/octet-stream' }), 'ansage.txt');
const { response: audioResponse, body: audio } = await requestJson(
`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`,
{ method: 'POST', body: audioForm },
@@ -222,12 +250,12 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
assert.equal(audio.url, `/api/routes/${route.id}/pois/${poi.id}/audio`);
const { response: audioFileResponse, body: audioFile } = await requestBuffer(`${baseUrl}${audio.url}`);
assert.match(audioFileResponse.headers.get('content-type') || '', /audio\/mpeg/);
assert.equal(audioFile.toString(), 'audio-one');
assert.deepEqual(audioFile, MP3_FILE);
const { body: audioMetadata } = await requestJson(`${baseUrl}${audio.url}?metadata=true`);
assert.equal(audioMetadata.poiId, poi.id);
const duplicateAudio = new FormData();
duplicateAudio.append('audio', new Blob(['duplicate'], { type: 'audio/mpeg' }), 'doppelt.mp3');
duplicateAudio.append('audio', new Blob([MP3_FILE], { type: 'image/png' }), 'doppelt.png');
await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`, {
method: 'POST',
body: duplicateAudio
@@ -235,9 +263,8 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
const replacementAudio = {
audio: {
filename: 'ansage-neu.ogg',
contentType: 'audio/ogg',
base64: Buffer.from('audio-two').toString('base64')
filename: 'ansage-neu.bin',
base64: WAV_FILE.toString('base64')
}
};
const { body: replacedAudio } = await requestJson(
@@ -250,8 +277,8 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
);
assert.equal(replacedAudio.url, `/api/routes/${route.id}/pois/${poi.id}/audio`);
const { response: replacedAudioResponse, body: replacedAudioFile } = await requestBuffer(`${baseUrl}${replacedAudio.url}`);
assert.match(replacedAudioResponse.headers.get('content-type') || '', /audio\/ogg/);
assert.equal(replacedAudioFile.toString(), 'audio-two');
assert.match(replacedAudioResponse.headers.get('content-type') || '', /audio\/wav/);
assert.deepEqual(replacedAudioFile, WAV_FILE);
const { body: routeWithMedia } = await requestJson(`${baseUrl}/api/routes/${route.id}`);
assert.equal(routeWithMedia.pois[0].images[0].caption, 'Aktualisierte Bildbeschreibung');
@@ -278,11 +305,22 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
);
assert.equal(deletedPicture.deleted, true);
const { body: deletedJsonPicture } = await requestJson(
`${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures/${jsonPicture.id}`,
const otherAudioForm = new FormData();
otherAudioForm.append('audio', new Blob([MP3_FILE], { type: 'text/plain' }), 'falsch.txt');
await requestJson(
`${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/audio`,
{ method: 'POST', body: otherAudioForm },
201
);
const { body: deletedPoi } = await requestJson(
`${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}`,
{ method: 'DELETE' }
);
assert.equal(deletedJsonPicture.deleted, true);
assert.equal(deletedPoi.deleted, true);
await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}`, {}, 404);
await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures/${jsonPicture.id}`, {}, 404);
await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/audio`, {}, 404);
await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`, {}, 404);
await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`, {}, 404);
+87
View File
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import http from 'node:http';
import test from 'node:test';
const root = path.resolve(import.meta.dirname, '..');
const read = relative => fs.readFile(path.join(root, relative), 'utf8');
test('upload MIME types are detected from content and not trusted request metadata', async () => {
const upload = await read('src/middleware/upload.js');
const media = await read('src/services/media-service.js');
const pkg = JSON.parse(await read('package.json'));
assert.equal(pkg.dependencies['file-type'], '22.0.1');
assert.equal(pkg.dependencies['@file-type/av'], '0.2.0');
assert.match(upload, /fileTypeFromFile/);
assert.match(upload, /customDetectors:\s*\[detectAv\]/);
assert.match(upload, /detectGpx/);
assert.doesNotMatch(upload, /allowed\.has\(file\.mimetype\)/);
assert.doesNotMatch(upload, /contentType ist erforderlich/);
assert.match(media, /file\.detectedExtension/);
});
test('Python API tools are syntax-valid and contain reusable 401 authentication handling', async () => {
const toolsDir = path.join(root, 'tools/python');
const names = (await fs.readdir(toolsDir)).filter(name => name.endsWith('.py'));
const expected = [
'append_route.py', 'common.py', 'create_audio.py', 'create_picture.py', 'create_poi.py',
'create_route.py', 'delete_audio.py', 'delete_picture.py', 'delete_poi.py', 'delete_route.py',
'restore_route.py', 'update_audio.py', 'update_picture.py', 'update_poi.py', 'update_route.py'
];
assert.deepEqual(names.sort(), expected.sort());
const compile = spawnSync('python3', ['-m', 'py_compile', ...names.map(name => path.join(toolsDir, name))], {
cwd: root,
encoding: 'utf8'
});
assert.equal(compile.status, 0, compile.stderr);
await fs.rm(path.join(toolsDir, '__pycache__'), { recursive: true, force: true });
const common = await read('tools/python/common.py');
assert.match(common, /class ApiClient/);
assert.match(common, /error\.code == 401/);
assert.match(common, /self\.prompt_credentials\(\)/);
assert.match(common, /getpass\.getpass/);
assert.match(common, /def encode_multipart/);
assert.doesNotMatch(common, /Content-Type:.*application\/octet-stream/);
});
test('Python client retries the same request with credentials after HTTP 401', async t => {
const expected = `Basic ${Buffer.from('tester:secret').toString('base64')}`;
let requests = 0;
const server = http.createServer((req, res) => {
requests += 1;
if (req.headers.authorization !== expected) {
res.writeHead(401, { 'Content-Type': 'application/json', 'WWW-Authenticate': 'Basic realm="test"' });
res.end(JSON.stringify({ message: 'Zugangsdaten erforderlich.' }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
t.after(() => server.close());
const { port } = server.address();
const script = `
from common import ApiClient, Credentials
class TestClient(ApiClient):
def prompt_credentials(self):
self.credentials = Credentials('tester', 'secret')
result = TestClient('http://127.0.0.1:${port}').request('GET', '/protected')
assert result == {'ok': True}
`;
const child = spawn('python3', ['-c', script], {
env: { ...process.env, PYTHONPATH: path.join(root, 'tools/python') },
stdio: ['ignore', 'pipe', 'pipe']
});
const stderr = [];
child.stderr.on('data', chunk => stderr.push(chunk.toString()));
const status = await new Promise(resolve => child.once('exit', resolve));
assert.equal(status, 0, stderr.join(''));
assert.equal(requests, 2);
});