coverage tests spawn local server with authentication for read-write requests
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
+119 -26
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.');
});
+1 -1
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) {
+5 -5
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']