coverage tests spawn local server with authentication for read-write requests
All checks were successful
Sonarqube Scanner / Build and analyze (push) Successful in 1m57s

This commit is contained in:
Florian Zumpe 2026-06-17 13:38:05 +02:00
parent d0046f5693
commit 8df484e77e
8 changed files with 247 additions and 36 deletions

View File

@ -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.

4
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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());
});
}
};
}

View File

@ -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.');
});

View File

@ -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) {

View File

@ -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']

View File

@ -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: