coverage tests spawn local server with authentication for read-write requests
All checks were successful
Sonarqube Scanner / Build and analyze (push) Successful in 1m57s
All checks were successful
Sonarqube Scanner / Build and analyze (push) Successful in 1m57s
This commit is contained in:
parent
d0046f5693
commit
8df484e77e
@ -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
4
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
110
test-support/local-write-auth-proxy.js
Normal file
110
test-support/local-write-auth-proxy.js
Normal 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());
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -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.');
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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']
|
||||
|
||||
@ -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:
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user