feat(api): aircard-api für Veröffentlichen, Melden und Admin

Neuer abhängigkeitsfreier Node-22-Dienst in deploy/api/ (appwrite.js, app.js,
server.js), der alle Galerie-Schreibzugriffe mit dem Appwrite-Server-Key
ausführt: POST /api/veroeffentlichen, /zurueckziehen, /melden (Rate-Limit
10/h je Melder, IPv6 auf /64, Auto-Ausblenden ab 3), /admin/sichtbarkeit und
GET /api/gesundheit.

- JWT-Prüfung über GET /account (Header X-Aircard-Jwt), Admin = Label admin
- IDs per Muster geprüft, Pfadsegmente kodiert, "." und ".." abgelehnt
- deterministische Zeilen-IDs (Galerie, Meldung): Appwrite-409 ersetzt jede
  Doppelt-Prüfung; Sperre je Karte gegen verlorene Zählerstände
- Dateiprüfung: Bucket, delete("user:<ich>"), PNG/WebP, höchstens 5 MiB
- Appwrite-Aufrufe mit 10-s-Zeitlimit, Logs ohne Key, Salz, JWT und IPs
- Tests gegen einen Fake-Appwrite im Speicher (70 Tests), Compose-Service
  aircard-api (Router aircard-gallery-api) und README ergänzt

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 14:09:30 +00:00
1 parent 9e4f4ffacc
commit dd1597862a
11 files changed
+2659 -13

No files matched your search

+172
View File
@@ -0,0 +1,172 @@
'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);
});