This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user