coverage tests spawn local server with authentication for read-write requests
Sonarqube Scanner / Build and analyze (push) Successful in 1m57s
Sonarqube Scanner / Build and analyze (push) Successful in 1m57s
This commit is contained in:
+119
-26
@@ -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.');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user