'use strict'; // Tests für deploy/api/server.js: Umgebungsprüfung und ein Start als echter Prozess gegen einen Fake-Appwrite. // Aufruf: node --test "deploy/api/*.test.js" const { test, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const http = require('node:http'); const path = require('node:path'); const { spawn } = require('node:child_process'); const { loadConfig } = require('./server.js'); const { startFakeAppwrite } = require('./fake-appwrite.js'); const SERVER = path.join(__dirname, 'server.js'); const KEY = 'KEY-test-secret-value'; const SALT = 'salt-test-secret-0123456789abcdef0123456789'; const children = []; const fakes = []; afterEach(async () => { for (const child of children.splice(0)) { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGKILL'); await new Promise((resolve) => child.once('exit', resolve)); } } for (const fake of fakes.splice(0)) await fake.close(); }); function validEnv(extra = {}) { return { AIRCARD_API_KEY: KEY, AIRCARD_API_SALT: SALT, AIRCARD_APPWRITE_PROJECT_ID: 'proj-test', ...extra, }; } function startProcess(env) { const child = spawn(process.execPath, [SERVER], { env: { PATH: process.env.PATH, ...env }, stdio: ['ignore', 'pipe', 'pipe'] }); children.push(child); const output = { text: '' }; child.stdout.on('data', (chunk) => (output.text += chunk)); child.stderr.on('data', (chunk) => (output.text += chunk)); const exited = new Promise((resolve) => child.once('exit', (code, signal) => resolve({ code, signal }))); return { child, output, exited }; } async function waitFor(condition, what, timeoutMs = 5000) { const deadline = Date.now() + timeoutMs; while (!condition()) { if (Date.now() > deadline) throw new Error(`Zeitüberschreitung beim Warten auf: ${what}`); await new Promise((resolve) => setTimeout(resolve, 20)); } } function httpRequest(port, method, urlPath, { headers = {}, body } = {}) { return new Promise((resolve, reject) => { const payload = body === undefined ? undefined : JSON.stringify(body); const req = http.request( { host: '127.0.0.1', port, method, path: urlPath, headers: payload === undefined ? headers : { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload), ...headers }, }, (res) => { const chunks = []; res.on('data', (chunk) => chunks.push(chunk)); res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null') })); }, ); req.on('error', reject); if (payload !== undefined) req.write(payload); req.end(); }); } test('loadConfig: Standardwerte und gelesene Werte', () => { const result = loadConfig(validEnv()); assert.equal(result.ok, true); assert.deepEqual(result.config, { port: 8088, endpoint: 'http://webklar-appwrite/v1', project: 'proj-test', key: KEY, salt: SALT, }); assert.deepEqual(result.warnings, []); const custom = loadConfig(validEnv({ AIRCARD_API_PORT: '9100', AIRCARD_APPWRITE_ENDPOINT: 'https://appwrite.example/v1' })); assert.equal(custom.config.port, 9100); assert.equal(custom.config.endpoint, 'https://appwrite.example/v1'); }); test('loadConfig: fehlender Key, Salz oder Projekt wird mit dem Namen der Variable gemeldet, nie mit Werten', () => { for (const name of ['AIRCARD_API_KEY', 'AIRCARD_API_SALT', 'AIRCARD_APPWRITE_PROJECT_ID']) { for (const value of [undefined, '', ' ']) { const env = validEnv(); if (value === undefined) delete env[name]; else env[name] = value; const result = loadConfig(env); assert.equal(result.ok, false, `${name}=${JSON.stringify(value)}`); assert.ok(result.error.includes(name)); assert.ok(!result.error.includes(KEY) && !result.error.includes(SALT)); } } const none = loadConfig({}); assert.equal(none.ok, false); assert.ok(none.error.includes('AIRCARD_API_KEY') && none.error.includes('AIRCARD_API_SALT')); }); test('loadConfig: ungültiger Port oder Endpunkt, kurzes Salz warnt', () => { assert.equal(loadConfig(validEnv({ AIRCARD_API_PORT: 'abc' })).ok, false); assert.equal(loadConfig(validEnv({ AIRCARD_API_PORT: '70000' })).ok, false); assert.equal(loadConfig(validEnv({ AIRCARD_API_PORT: '-1' })).ok, false); assert.equal(loadConfig(validEnv({ AIRCARD_APPWRITE_ENDPOINT: 'webklar-appwrite/v1' })).ok, false); const short = loadConfig(validEnv({ AIRCARD_API_SALT: 'xyz-9' })); assert.equal(short.ok, true); assert.equal(short.warnings.length, 1); assert.ok(short.warnings[0].includes('AIRCARD_API_SALT') && !short.warnings[0].includes('xyz-9')); }); test('Start ohne Key oder ohne Salz: Exit-Code 1, Meldung ohne Werte', async () => { for (const missing of ['AIRCARD_API_KEY', 'AIRCARD_API_SALT']) { const env = validEnv({ AIRCARD_API_PORT: '0' }); delete env[missing]; const { output, exited } = startProcess(env); const { code } = await exited; assert.equal(code, 1, missing); assert.ok(output.text.includes(missing)); assert.ok(output.text.includes('[aircard-api]')); assert.ok(!output.text.includes(KEY) && !output.text.includes(SALT)); } }); test('Start als Prozess: Gesundheit, Melden über Fake-Appwrite mit Server-Key, sauberes Beenden bei SIGTERM', async () => { const fake = await startFakeAppwrite({ project: 'proj-test', key: KEY }); fakes.push(fake); fake.addRow( 'gallery', 'card-1', { title: 'Karte', imageFileId: 'i', previewFileId: 'p', ownerId: 'user-me', hidden: false, reportCount: 0 }, ['read("any")'], ); const { child, output, exited } = startProcess(validEnv({ AIRCARD_API_PORT: '0', AIRCARD_APPWRITE_ENDPOINT: fake.endpoint })); await waitFor(() => /listening on :\d+/.test(output.text), 'Startmeldung'); const port = Number(/listening on :(\d+)/.exec(output.text)[1]); const health = await httpRequest(port, 'GET', '/api/gesundheit'); assert.deepEqual(health.json, { ok: true }); const reported = await httpRequest(port, 'POST', '/api/melden', { body: { id: 'card-1', reason: 'spam' }, headers: { 'X-Real-Ip': '203.0.113.5' }, }); assert.equal(reported.status, 200); assert.deepEqual(reported.json, { ok: true }); assert.equal(fake.row('gallery', 'card-1').reportCount, 1); assert.ok(fake.calls.length > 0); for (const call of fake.calls) assert.equal(call.headers['x-appwrite-key'], KEY); child.kill('SIGTERM'); const { code } = await exited; assert.equal(code, 0); assert.ok(!output.text.includes(KEY) && !output.text.includes(SALT), 'Ausgabe enthält weder Key noch Salz'); assert.ok(!output.text.includes('203.0.113.5'), 'Ausgabe enthält keine IP'); for (const line of output.text.trim().split('\n')) assert.ok(line.startsWith('[aircard-api]'), line); });