import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { spawn } from 'node:child_process'; import test from 'node:test'; import { startLocalWriteAuthProxy } from './test-support/local-write-auth-proxy.js'; const root = path.resolve(import.meta.dirname, '..'); const LOCAL_API_URL = 'http://127.0.0.1:47145'; const BACKEND_API_URL = 'http://127.0.0.1:47146'; const TEST_API_USER = 'local-write-user'; const TEST_API_PASSWORD = 'local-write-password'; const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); 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 runProcess(command, args, options = {}) { const child = spawn(command, args, { ...options, stdio: ['ignore', 'pipe', 'pipe'] }); const stdout = []; const stderr = []; child.stdout.on('data', chunk => stdout.push(chunk.toString())); child.stderr.on('data', chunk => stderr.push(chunk.toString())); const status = await new Promise((resolve, reject) => { child.once('error', reject); child.once('exit', resolve); }); return { status, stdout: stdout.join(''), stderr: stderr.join('') }; } function authorizationHeader() { const token = Buffer.from(`${TEST_API_USER}:${TEST_API_PASSWORD}`, 'utf8').toString('base64'); return `Basic ${token}`; } async function fetchWithWriteAuthRetry(url, options = {}) { const request = new Request(url, options); const response = await fetch(request.clone()); if (!WRITE_METHODS.has(request.method) || response.status !== 401) return response; assert.match(response.headers.get('www-authenticate') || '', /^Basic\s/i); await response.arrayBuffer(); const headers = new Headers(request.headers); headers.set('Authorization', authorizationHeader()); return fetch(new Request(request, { headers })); } async function requestJson(url, options = {}, expectedStatus = 200) { const response = await fetchWithWriteAuthRetry(url, options); const body = await response.json(); assert.equal(response.status, expectedStatus, JSON.stringify(body)); return { response, body }; } async function requestBuffer(url, expectedStatus = 200) { const response = await fetch(url); const body = Buffer.from(await response.arrayBuffer()); assert.equal(response.status, expectedStatus); return { response, body }; } async function waitForServer(baseUrl, child, output) { for (let attempt = 0; attempt < 80; attempt += 1) { if (child.exitCode != null) throw new Error(`Server wurde vorzeitig beendet.\n${output.join('')}`); try { const response = await fetch(`${baseUrl}/api/health`); if (response.ok) return; } catch { // Server startet noch. } await new Promise(resolve => setTimeout(resolve, 100)); } throw new Error(`Serverstart hat das Zeitlimit überschritten.\n${output.join('')}`); } test('the npm-started local API is exercised through a write-auth proxy on loopback port 47145', { timeout: 30000 }, async t => { const runtime = await fs.mkdtemp(path.join(os.tmpdir(), 'wegwichtel-media-api-')); const baseUrl = LOCAL_API_URL; let authProxy; const output = []; const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const detached = process.platform !== 'win32'; const child = spawn(npmCommand, ['start'], { cwd: root, env: { ...process.env, HOST: '127.0.0.1', PORT: '47146', DATA_DIR: path.join(runtime, 'data'), STORAGE_DIR: path.join(runtime, 'storage') }, stdio: ['ignore', 'pipe', 'pipe'], detached }); child.stdout.on('data', chunk => output.push(chunk.toString())); child.stderr.on('data', chunk => output.push(chunk.toString())); t.after(async () => { if (authProxy) await authProxy.close(); if (child.exitCode == null) { if (detached) { try { process.kill(-child.pid, 'SIGTERM'); } catch (error) { if (error.code !== 'ESRCH') throw error; } } else { child.kill('SIGTERM'); } await new Promise(resolve => child.once('exit', resolve)); } await fs.rm(runtime, { recursive: true, force: true }); }); await waitForServer(BACKEND_API_URL, child, output); authProxy = await startLocalWriteAuthProxy({ listenPort: 47145, targetPort: 47146, username: TEST_API_USER, password: TEST_API_PASSWORD }); const healthResponse = await fetch(`${baseUrl}/api/health`); assert.equal(healthResponse.status, 200); assert.equal(healthResponse.headers.get('www-authenticate'), null); const pythonHealthCheck = await runProcess('python3', ['-c', ` from common import ApiClient result = ApiClient('${LOCAL_API_URL}').request('GET', '/api/health') assert result['ok'] is True `], { cwd: root, env: { ...process.env, API_USER: '', API_PASSWORD: '', PYTHONPATH: path.join(root, 'tools/python') } }); assert.equal( pythonHealthCheck.status, 0, [pythonHealthCheck.stdout, pythonHealthCheck.stderr].filter(Boolean).join('\n') ); const gpxPath = path.join(root, 'examples', 'sample-route.gpx'); const gpx = await fs.readFile(gpxPath); const createRoute = await runProcess('python3', [ path.join(root, 'tools/python/create_route.py'), '--base-url', LOCAL_API_URL, '--name', 'Medien-API-Test', '--slug', '', '--description', '', '--school-name', '', '--gpx', gpxPath ], { cwd: root, env: { ...process.env, API_USER: ` ${TEST_API_USER} `, API_PASSWORD: ` ${TEST_API_PASSWORD} `, PYTHONPATH: path.join(root, 'tools/python') } }); assert.equal( createRoute.status, 0, [createRoute.stdout, createRoute.stderr].filter(Boolean).join('\n') ); const route = JSON.parse(createRoute.stdout); assert.equal(route.gpxUrl, `/api/routes/${route.id}/gpx`); const { response: gpxResponse, body: downloadedGpx } = await requestBuffer(`${baseUrl}${route.gpxUrl}`); assert.match(gpxResponse.headers.get('content-type') || '', /application\/gpx\+xml/); assert.deepEqual(downloadedGpx, gpx); const { body: poi } = await requestJson(`${baseUrl}/api/routes/${route.id}/pois`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Teststation', description: 'Station für Medientests', lat: route.start.lat, lon: route.start.lon, triggerRadiusM: 40, sequence: 0 }) }, 201); const { body: otherPoi } = await requestJson(`${baseUrl}/api/routes/${route.id}/pois`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Andere Teststation', lat: route.start.lat + 0.0001, lon: route.start.lon + 0.0001, triggerRadiusM: 40, sequence: 1 }) }, 201); const { body: fetchedPoi } = await requestJson( `${baseUrl}/api/routes/${route.id}/pois/${poi.id}` ); assert.equal(fetchedPoi.id, poi.id); assert.equal(fetchedPoi.routeId, route.id); assert.equal(fetchedPoi.title, 'Teststation'); const { body: updatedPoi } = await requestJson( `${baseUrl}/api/routes/${route.id}/pois/${poi.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Aktualisierte Teststation' }) } ); assert.equal(updatedPoi.title, 'Aktualisierte Teststation'); await requestJson(`${baseUrl}/api/routes/999999/pois/${poi.id}`, {}, 404); await requestJson(`${baseUrl}/api/routes/${route.id}/pois/999999`, {}, 404); await requestJson(`${baseUrl}/api/pois/${poi.id}`, {}, 404); const pictureForm = new FormData(); pictureForm.append('poiId', String(otherPoi.id)); pictureForm.append('caption', 'Erste Bildbeschreibung'); pictureForm.append('sequence', '0'); 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 }, 201 ); assert.equal(pictureResponse.headers.get('location'), `/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`); assert.equal(picture.poiId, poi.id); assert.equal(picture.caption, 'Erste Bildbeschreibung'); 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.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); await requestJson( `${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures/${picture.id}?metadata=true`, {}, 404 ); const { body: pictureList } = await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures`); assert.equal(pictureList.pictures.length, 1); assert.equal(pictureList.pictures[0].id, picture.id); const jsonPicturePayload = { caption: 'Bild aus JSON', sequence: 1, picture: { filename: 'bild-json.bin', base64: PNG_FILE.toString('base64') } }; const { body: jsonPicture } = await requestJson( `${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures`, { method: 'POST', headers: { 'Content-Type': 'text/json' }, body: JSON.stringify(jsonPicturePayload) }, 201 ); 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\/png/); assert.deepEqual(jsonPictureFile, PNG_FILE); await requestJson( `${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures`, { method: 'POST', headers: { 'Content-Type': 'text/json' }, body: '{}' }, 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}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ caption: 'Aktualisierte Bildbeschreibung', sequence: 2 }) } ); assert.equal(updatedPicture.caption, 'Aktualisierte Bildbeschreibung'); assert.equal(updatedPicture.sequence, 2); const audioForm = new FormData(); 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 }, 201 ); assert.equal(audioResponse.headers.get('location'), `/api/routes/${route.id}/pois/${poi.id}/audio`); assert.equal(audio.poiId, poi.id); 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.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([MP3_FILE], { type: 'image/png' }), 'doppelt.png'); await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`, { method: 'POST', body: duplicateAudio }, 409); const replacementAudio = { audio: { filename: 'ansage-neu.bin', base64: WAV_FILE.toString('base64') } }; const { body: replacedAudio } = await requestJson( `${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(replacementAudio) } ); 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\/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'); assert.equal(routeWithMedia.gpxUrl, `/api/routes/${route.id}/gpx`); assert.equal(routeWithMedia.pois[0].audioUrl, `/api/routes/${route.id}/pois/${poi.id}/audio`); assert.equal(routeWithMedia.pois[0].images[0].url, `/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`); assert.doesNotMatch(JSON.stringify(routeWithMedia), /\/media\//); const legacyMediaResponse = await fetch(`${baseUrl}/media/routes/${route.id}/route.gpx`); assert.equal(legacyMediaResponse.status, 404); const legacyPictureEndpoint = await fetch(`${baseUrl}/api/routes/${route.id}/pictures`); assert.equal(legacyPictureEndpoint.status, 404); const legacyAudioEndpoint = await fetch(`${baseUrl}/api/routes/${route.id}/audio`); assert.equal(legacyAudioEndpoint.status, 404); const { body: deletedAudio } = await requestJson( `${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`, { method: 'DELETE' } ); assert.equal(deletedAudio.deleted, true); const { body: deletedPicture } = await requestJson( `${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`, { method: 'DELETE' } ); assert.equal(deletedPicture.deleted, true); 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(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); for (const method of ['POST', 'PUT', 'DELETE']) { assert.ok(authProxy.stats.unauthorizedWrites[method] > 0, `${method} muss zunächst HTTP 401 auslösen.`); assert.ok(authProxy.stats.authorizedWrites[method] > 0, `${method} muss mit Basic Auth weitergeleitet werden.`); } assert.ok(authProxy.stats.forwardedReads > 0); assert.equal(authProxy.stats.authenticatedReads, 0, 'Lesende Requests dürfen nicht präventiv authentifiziert werden.'); });