From 8df484e77ee7f2fe77641dea1a09886e770edf97 Mon Sep 17 00:00:00 2001 From: Florian Zumpe Date: Wed, 17 Jun 2026 13:38:05 +0200 Subject: [PATCH] coverage tests spawn local server with authentication for read-write requests --- README.md | 8 ++ package-lock.json | 4 +- package.json | 2 +- test-support/local-write-auth-proxy.js | 110 +++++++++++++++++++ test/media-api.test.js | 145 ++++++++++++++++++++----- test/server-config.test.js | 2 +- test/upload-detection-tools.test.js | 10 +- tools/python/README.md | 2 +- 8 files changed, 247 insertions(+), 36 deletions(-) create mode 100644 test-support/local-write-auth-proxy.js diff --git a/README.md b/README.md index 6673a1a..eae4176 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,14 @@ test/ automatisierte Tests tools/python/ interaktive API-Werkzeuge ohne Node.js-Abhängigkeit ``` +## Automatisierte Tests + +Die REST-Integrationstests starten die Anwendung über `npm start` mit isolierten temporären Daten- und Speicherverzeichnissen. Der eigentliche Node.js-Prozess lauscht innerhalb des Testaufbaus auf `127.0.0.1:47146`. Davor läuft ein ausschließlich für die Tests bestimmter Reverse-Proxy auf `http://127.0.0.1:47145`, sodass dieser Loopback-Endpunkt das einzige Ziel der API-Aufrufe bleibt. Der Produktivserver wird von der Testsuite nicht angesprochen. + +Der lokale Proxy lässt `GET`, `HEAD` und `OPTIONS` ohne Anmeldung passieren. Für `POST`, `PUT`, `PATCH` und `DELETE` antwortet er zunächst mit `401 Unauthorized` und `WWW-Authenticate: Basic`, sofern kein gültiger Authorization-Header vorhanden ist. Nach erfolgreicher Basic-Auth-Prüfung entfernt der Proxy den Header wieder und leitet den Request an den lokal per NPM gestarteten Server weiter. Damit wird ein möglicher Nginx-Passwortschutz für schreibende Operationen realistisch simuliert, ohne Authentifizierung in die Anwendung selbst einzubauen. + +Der Integrationstest prüft dabei ausdrücklich, dass lesende Requests nicht präventiv authentifiziert werden und dass für `POST`, `PUT` und `DELETE` jeweils zuerst ein `401` und anschließend ein erfolgreicher authentifizierter Request erfolgt. Zusätzlich bleibt ein kleiner, isolierter HTTP-Mock auf einem zufälligen Loopback-Port bestehen, der die 401-Wiederholungslogik der Python-Request-Schicht unabhängig vom vollständigen REST-Test prüft. + ## Technische Hinweise - Medienpfade werden relativ zu `storage/` gespeichert. So bleibt das Projekt verschiebbar. diff --git a/package-lock.json b/package-lock.json index 2f790b5..ba56389 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wegwichtel-next", - "version": "0.12.1", + "version": "0.12.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wegwichtel-next", - "version": "0.12.1", + "version": "0.12.3", "hasInstallScript": true, "dependencies": { "@file-type/av": "0.2.0", diff --git a/package.json b/package.json index 9d55775..88e6188 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wegwichtel-next", - "version": "0.12.1", + "version": "0.12.3", "private": true, "description": "GPS-gestützte Lern- und Wanderwege mit GPX, POIs, Bildern und Audioansagen.", "type": "module", diff --git a/test-support/local-write-auth-proxy.js b/test-support/local-write-auth-proxy.js new file mode 100644 index 0000000..c967664 --- /dev/null +++ b/test-support/local-write-auth-proxy.js @@ -0,0 +1,110 @@ +import http from 'node:http'; + +const PROTECTED_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +function increment(counter, method) { + counter[method] = (counter[method] || 0) + 1; +} + +function basicAuthorization(username, password) { + const token = Buffer.from(`${username}:${password}`, 'utf8').toString('base64'); + return `Basic ${token}`; +} + +function unauthorized(response) { + response.writeHead(401, { + 'Content-Type': 'application/json; charset=utf-8', + 'WWW-Authenticate': 'Basic realm="Wegwichtel local test"' + }); + response.end(JSON.stringify({ + error: 'Unauthorized', + message: 'Für schreibende Operationen sind Zugangsdaten erforderlich.' + })); +} + +function badGateway(response, error) { + if (response.headersSent) { + response.destroy(error); + return; + } + response.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify({ + error: 'BadGateway', + message: `Lokaler Testserver nicht erreichbar: ${error.message}` + })); +} + +export async function startLocalWriteAuthProxy({ + listenHost = '127.0.0.1', + listenPort = 47145, + targetHost = '127.0.0.1', + targetPort, + username, + password +}) { + if (!targetPort) throw new TypeError('targetPort ist erforderlich.'); + if (!username || !password) throw new TypeError('Test-Credentials sind erforderlich.'); + + const expectedAuthorization = basicAuthorization(username, password); + const stats = { + unauthorizedWrites: {}, + authorizedWrites: {}, + forwardedReads: 0, + authenticatedReads: 0 + }; + + const server = http.createServer((request, response) => { + const method = (request.method || 'GET').toUpperCase(); + const isProtected = PROTECTED_METHODS.has(method); + const suppliedAuthorization = request.headers.authorization; + + if (isProtected && suppliedAuthorization !== expectedAuthorization) { + increment(stats.unauthorizedWrites, method); + request.resume(); + unauthorized(response); + return; + } + + if (isProtected) { + increment(stats.authorizedWrites, method); + } else { + stats.forwardedReads += 1; + if (suppliedAuthorization) stats.authenticatedReads += 1; + } + + const headers = { ...request.headers, host: `${targetHost}:${targetPort}` }; + delete headers.authorization; + + const upstream = http.request({ + host: targetHost, + port: targetPort, + method, + path: request.url, + headers + }, upstreamResponse => { + response.writeHead(upstreamResponse.statusCode || 502, upstreamResponse.headers); + upstreamResponse.pipe(response); + }); + + upstream.on('error', error => badGateway(response, error)); + request.pipe(upstream); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(listenPort, listenHost, () => { + server.off('error', reject); + resolve(); + }); + }); + + return { + stats, + async close() { + if (!server.listening) return; + await new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()); + }); + } + }; +} diff --git a/test/media-api.test.js b/test/media-api.test.js index 389c1db..43727a8 100644 --- a/test/media-api.test.js +++ b/test/media-api.test.js @@ -1,12 +1,17 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; -import net from 'node:net'; 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' @@ -21,19 +26,42 @@ const WAV_FILE = Buffer.from( 'hex' ); -async function freePort() { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.once('error', reject); - server.listen(0, '127.0.0.1', () => { - const { port } = server.address(); - server.close(error => error ? reject(error) : resolve(port)); - }); +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 fetch(url, options); + const response = await fetchWithWriteAuthRetry(url, options); const body = await response.json(); assert.equal(response.status, expectedStatus, JSON.stringify(body)); return { response, body }; @@ -60,43 +88,101 @@ async function waitForServer(baseUrl, child, output) { throw new Error(`Serverstart hat das Zeitlimit überschritten.\n${output.join('')}`); } -test('pictures and audio are managed as individual REST resources', { timeout: 30000 }, async t => { +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 port = await freePort(); - const baseUrl = `http://127.0.0.1:${port}`; + const baseUrl = LOCAL_API_URL; + let authProxy; const output = []; - const child = spawn(process.execPath, ['server.js'], { + 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: String(port), + PORT: '47146', DATA_DIR: path.join(runtime, 'data'), STORAGE_DIR: path.join(runtime, 'storage') }, - stdio: ['ignore', 'pipe', 'pipe'] + 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) { - child.kill('SIGTERM'); + 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(baseUrl, child, output); + await waitForServer(BACKEND_API_URL, child, output); + authProxy = await startLocalWriteAuthProxy({ + listenPort: 47145, + targetPort: 47146, + username: TEST_API_USER, + password: TEST_API_PASSWORD + }); - 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/octet-stream' }), 'route.txt'); - const { body: route } = await requestJson(`${baseUrl}/api/routes`, { - method: 'POST', - body: routeForm - }, 201); + 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/); @@ -324,4 +410,11 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3 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.'); }); diff --git a/test/server-config.test.js b/test/server-config.test.js index 811aa7b..884cc8a 100644 --- a/test/server-config.test.js +++ b/test/server-config.test.js @@ -27,7 +27,7 @@ test('default server socket uses the local Wegwichtel endpoint', async () => { test('retired internal instance label is absent from authored project files', async () => { const extensions = new Set(['.js', '.json', '.md', '.html', '.css', '.env', '.sql', '.gpx', '.svg']); - const ignoredDirectories = new Set(['node_modules', 'vendor', '.git']); + const ignoredDirectories = new Set(['node_modules', 'vendor', 'coverage', 'data', 'storage', '.git']); const files = []; async function collect(directory) { diff --git a/test/upload-detection-tools.test.js b/test/upload-detection-tools.test.js index c9794c6..b754ad6 100644 --- a/test/upload-detection-tools.test.js +++ b/test/upload-detection-tools.test.js @@ -55,9 +55,9 @@ test('Python API tools are syntax-valid and contain reusable 401 authentication }); -test('Python client retries the same request with API_USER and API_PASSWORD after HTTP 401', async t => { - const apiUser = process.env.API_USER || 'tester'; - const apiPassword = process.env.API_PASSWORD || 'secret'; +test('Python client retries an intentionally simulated reverse-proxy 401 with trimmed environment credentials', async t => { + const apiUser = 'local-test-user'; + const apiPassword = 'local-test-password'; const expectedAuthorization = `Basic ${Buffer.from(`${apiUser}:${apiPassword}`).toString('base64')}`; const receivedAuthorization = []; @@ -83,8 +83,8 @@ assert result == {'ok': True} const child = spawn('python3', ['-c', script], { env: { ...process.env, - API_USER: apiUser, - API_PASSWORD: apiPassword, + API_USER: ` ${apiUser} `, + API_PASSWORD: ` ${apiPassword} `, PYTHONPATH: path.join(root, 'tools/python') }, stdio: ['ignore', 'pipe', 'pipe'] diff --git a/tools/python/README.md b/tools/python/README.md index b8aa3ed..3d22711 100644 --- a/tools/python/README.md +++ b/tools/python/README.md @@ -4,7 +4,7 @@ Die Skripte benötigen nur Python 3 und die Standardbibliothek. Node.js, `curl` ## Authentifizierung -Jeder Request wird zunächst ohne vorauseilenden Authorization-Header ausgeführt. Antwortet ein vorgeschalteter Webserver mit `401 Unauthorized`, verwendet die gemeinsame Request-Schicht zuerst die Umgebungsvariablen `API_USER` und `API_PASSWORD`. Sind beide gesetzt, wird derselbe Request genau mit diesen Basic-Auth-Credentials wiederholt. Fehlen sie, fragt das Werkzeug Benutzername und Passwort interaktiv ab. Das Passwort wird mit `getpass` ohne Bildschirmausgabe eingelesen. +Jeder Request wird zunächst ohne vorauseilenden Authorization-Header ausgeführt. Antwortet ein vorgeschalteter Webserver mit `401 Unauthorized`, verwendet die gemeinsame Request-Schicht zuerst die Umgebungsvariablen `API_USER` und `API_PASSWORD`. Sind beide gesetzt, wird derselbe Request genau mit diesen Basic-Auth-Credentials wiederholt. Fehlen sie, fragt das Werkzeug Benutzername und Passwort interaktiv ab. Das Passwort wird mit `getpass` ohne Bildschirmausgabe eingelesen. Führende und nachgestellte Whitespaces werden aus beiden Umgebungsvariablen entfernt; reine Whitespace-Werte gelten als nicht gesetzt. Für unbeaufsichtigte Aufrufe und CI-Pipelines: