Files
AirCardIOS/deploy/api/app.test.js
T
ClaudeandClaude Sonnet 5.5 cd55d4c891 fix(api): Log-Flut begrenzen und Appwrite-Fehler strenger behandeln
- Kein Zugriffsprotokoll je Anfrage mehr (abgewiesene Anfragen an das
  unauthentifizierte /api/melden loggten sonst unbegrenzt); nur noch
  Ereignis- und 5xx-Zeilen, höchstens 120 pro Minute mit Sammelzeile
- Compose: logging json-file, max-size 5m, max-file 2
- appwrite.js: 404 nur mit Typ row_not_found/storage_file_not_found als
  "fehlt", listRows ohne rows-Array wirft, redirect: error
- Appwrite-Fehler: 502 bei 0/429/5xx, sonst 500 "Interner Fehler."
- Veröffentlichen legt beide Dateien auf die sichtbaren Rechte fest
  (erst nach dem Anlegen der Zeile, Rollback bei Fehler)
- setFilePermissions mit Promise.allSettled, gemeinsamer safeToken-Helfer
- Tests: 85 (Log-Flut, Log-Budget, 404-Typen, Auth-Header, Konto-Antworten)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:24:05 +00:00

1143 lines
53 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// Tests für deploy/api/app.js: echte HTTP-Anfragen auf Port 0 gegen createApp, dahinter ein Fake-Appwrite im
// Speicher (fake-appwrite.js) und das echte appwrite.js. Aufruf: node --test "deploy/api/*.test.js"
const { test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const http = require('node:http');
const { createAppwrite, AppwriteError } = require('./appwrite.js');
const { startFakeAppwrite } = require('./fake-appwrite.js');
const {
createApp,
visibleRowPermissions,
hiddenRowPermissions,
visibleFilePermissions,
hiddenFilePermissions,
reporterHash,
clientIp,
normalizeIp,
validatePublish,
buildSearchText,
galleryRowId,
reportRowId,
} = require('./app.js');
const SALT = 'test-salt-0123456789abcdef0123456789abcdef';
const ME = 'user-me';
const OTHER = 'user-other';
const ADMIN = 'user-admin';
const JWT_ME = 'eyJhbGciOiJI.payload-me.signature-me';
const JWT_OTHER = 'eyJhbGciOiJI.payload-other.signature-other';
const JWT_ADMIN = 'eyJhbGciOiJI.payload-admin.signature-admin';
const MIB5 = 5 * 1024 * 1024;
// ---- Aufbau ---------------------------------------------------------------------------------
const cleanups = [];
afterEach(async () => {
for (const fn of cleanups.splice(0).reverse()) await fn();
});
async function setup() {
const fake = await startFakeAppwrite();
fake.addUser(JWT_ME, { id: ME });
fake.addUser(JWT_OTHER, { id: OTHER });
fake.addUser(JWT_ADMIN, { id: ADMIN, labels: ['admin'] });
const logs = [];
const clock = { value: 1_700_000_000_000 };
const appwrite = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key });
const app = createApp({ appwrite, salt: SALT, now: () => clock.value, log: (line) => logs.push(String(line)) });
const server = http.createServer(app);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
cleanups.push(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
await fake.close();
});
const call = (method, path, options) => request(port, method, path, options);
const post = (path, body, jwt, headers = {}) =>
call('POST', path, { body, headers: { ...(jwt ? { 'X-Aircard-Jwt': jwt } : {}), ...headers } });
const report = (id, ip, reason = 'spam') => post('/api/melden', { id, reason }, null, ip ? { 'X-Real-Ip': ip } : {});
return { fake, app, logs, clock, port, call, post, report };
}
function request(port, method, path, { headers = {}, body, raw, contentType = 'application/json' } = {}) {
return new Promise((resolve, reject) => {
const payload = raw !== undefined ? raw : body === undefined ? undefined : JSON.stringify(body);
const requestHeaders = { ...headers };
if (payload !== undefined && contentType) requestHeaders['Content-Type'] = contentType;
if (payload !== undefined && !('Transfer-Encoding' in requestHeaders)) {
requestHeaders['Content-Length'] = Buffer.byteLength(payload);
}
const req = http.request({ host: '127.0.0.1', port, method, path, headers: requestHeaders }, (res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
let json = null;
try {
json = JSON.parse(text);
} catch {
// kein JSON
}
resolve({ status: res.statusCode, headers: res.headers, text, json });
});
});
req.on('error', reject);
if (payload !== undefined) req.write(payload);
req.end();
});
}
function publishBody(overrides = {}) {
return {
title: 'Mein Motiv',
issuer: 'Beispielbank',
tags: ['Blau', ' Neon '],
displayName: 'Kenso',
imageFileId: 'img1',
previewFileId: 'prev1',
sourceCardId: 'card1',
...overrides,
};
}
function seedUploads(fake, owner = ME, { image = 'img1', preview = 'prev1' } = {}) {
fake.addFile({ id: image, owner });
fake.addFile({ id: preview, owner, mimeType: 'image/webp' });
}
/** Legt eine veröffentlichte Karte direkt im Fake an (Zeile + zwei Dateien mit passenden Rechten). */
function seedCard(fake, { id = 'card-1', owner = ME, hidden = false, reportCount = 0 } = {}) {
const imageFileId = `${id}-img`;
const previewFileId = `${id}-prev`;
fake.addFile({ id: imageFileId, owner });
fake.addFile({ id: previewFileId, owner, mimeType: 'image/webp' });
if (hidden) {
fake.file(imageFileId).$permissions = hiddenFilePermissions(owner);
fake.file(previewFileId).$permissions = hiddenFilePermissions(owner);
}
fake.addRow(
'gallery',
id,
{
title: 'Karte',
issuer: '',
tags: '',
displayName: '',
imageFileId,
previewFileId,
sourceCardId: `src-${id}`,
ownerId: owner,
hidden,
reportCount,
searchText: 'karte',
},
hidden ? hiddenRowPermissions(owner) : visibleRowPermissions(),
);
return { imageFileId, previewFileId };
}
const storageCalls = (fake) => fake.calls.filter((c) => c.path.startsWith('/v1/storage/'));
const sha32 = (text) => crypto.createHash('sha256').update(text).digest('hex').slice(0, 32);
// ---- Reine Helfer ------------------------------------------------------------------------------
test('Rechte-Strings sind genau die aus der Spec', () => {
assert.deepEqual(visibleRowPermissions(), ['read("any")']);
assert.deepEqual(hiddenRowPermissions('abc'), ['read("user:abc")', 'read("label:admin")']);
assert.deepEqual(visibleFilePermissions('abc'), ['read("any")', 'delete("user:abc")']);
assert.deepEqual(hiddenFilePermissions('abc'), ['read("user:abc")', 'read("label:admin")', 'delete("user:abc")']);
});
test('Rechte-Helfer lehnen unzulässige Nutzer-IDs ab (kein Einschleusen in Rechte-Strings)', () => {
for (const bad of ['a")', 'x","read("any', '', '../x', 5, null]) {
assert.throws(() => hiddenRowPermissions(bad));
assert.throws(() => visibleFilePermissions(bad));
assert.throws(() => hiddenFilePermissions(bad));
}
});
test('Zeilen-IDs sind deterministisch: erste 32 Hex von sha256(a|b)', () => {
assert.equal(galleryRowId('u1', 'c1'), sha32('u1|c1'));
assert.equal(reportRowId('g1', 'hash1'), sha32('g1|hash1'));
assert.match(galleryRowId('u1', 'c1'), /^[0-9a-f]{32}$/);
assert.notEqual(galleryRowId('u1', 'c1'), galleryRowId('u2', 'c1'));
});
test('reporterHash ist HMAC-SHA256 (Hex) mit dem Salz; IPv6 wird auf /64 gekürzt', () => {
assert.equal(reporterHash(SALT, '203.0.113.7'), crypto.createHmac('sha256', SALT).update('203.0.113.7').digest('hex'));
assert.notEqual(reporterHash('anderes-salz', '203.0.113.7'), reporterHash(SALT, '203.0.113.7'));
assert.equal(reporterHash(SALT, '2001:db8:abcd:12::1'), reporterHash(SALT, '2001:db8:abcd:12:ffff:eeee:dddd:2'));
assert.notEqual(reporterHash(SALT, '2001:db8:abcd:12::1'), reporterHash(SALT, '2001:db8:abcd:13::1'));
});
test('normalizeIp: IPv4 bleibt, IPv4-gemappte IPv6 wird IPv4, sonst /64', () => {
assert.equal(normalizeIp('203.0.113.7'), '203.0.113.7');
assert.equal(normalizeIp(' 203.0.113.7:5555 '), '203.0.113.7');
assert.equal(normalizeIp('::ffff:203.0.113.7'), '203.0.113.7');
assert.equal(normalizeIp('::ffff:cb00:7107'), '203.0.113.7');
assert.equal(normalizeIp('2001:0DB8:abcd:0012:0000:0000:0000:0001'), '2001:db8:abcd:12::/64');
assert.equal(normalizeIp('2001:db8::1'), '2001:db8:0:0::/64');
assert.equal(normalizeIp('::1'), '0:0:0:0::/64');
assert.equal(normalizeIp('[2001:db8:1:2::9]'), '2001:db8:1:2::/64');
assert.equal(normalizeIp('fe80::1%eth0'), 'fe80:0:0:0::/64');
assert.equal(normalizeIp('64:ff9b::192.0.2.33'), '64:ff9b:0:0::/64');
assert.equal(normalizeIp('kein-ip'), 'kein-ip');
assert.equal(normalizeIp(undefined), '');
});
test('clientIp: x-real-ip, sonst erstes x-forwarded-for, sonst Socket', () => {
const socket = { remoteAddress: '10.0.0.9' };
assert.equal(clientIp({ headers: { 'x-real-ip': '1.1.1.1', 'x-forwarded-for': '2.2.2.2' }, socket }), '1.1.1.1');
assert.equal(clientIp({ headers: { 'x-forwarded-for': ' 2.2.2.2 , 3.3.3.3' }, socket }), '2.2.2.2');
assert.equal(clientIp({ headers: {}, socket }), '10.0.0.9');
assert.equal(clientIp({ headers: { 'x-real-ip': '' }, socket }), '10.0.0.9');
});
test('buildSearchText: klein, mit Leerzeichen verbunden, höchstens 300 Zeichen', () => {
assert.equal(buildSearchText({ title: 'Mein MOTIV', issuer: 'Beispielbank', tags: ['blau', 'neon'] }), 'mein motiv beispielbank blau neon');
assert.equal(buildSearchText({ title: 'A', issuer: '', tags: [] }), 'a');
assert.equal(buildSearchText({ title: 'A', issuer: 'B', tags: 'x,y' }), 'a b x y');
assert.equal(buildSearchText({ title: 'x'.repeat(400), issuer: '', tags: [] }).length, 300);
});
test('validatePublish: normalisiert Tags und liefert saubere Daten', () => {
const result = validatePublish(publishBody({ title: ' Mein Motiv ', tags: [' Blau ', 'NEON', '', 'blau'], issuer: undefined, displayName: null }));
assert.equal(result.ok, true);
assert.deepEqual(result.data, {
title: 'Mein Motiv',
issuer: '',
tags: ['blau', 'neon'],
displayName: '',
imageFileId: 'img1',
previewFileId: 'prev1',
sourceCardId: 'card1',
});
});
test('validatePublish: Längen und Tags an den Grenzen', () => {
assert.equal(validatePublish(publishBody({ title: 'x'.repeat(60), issuer: 'y'.repeat(40), displayName: 'z'.repeat(30) })).ok, true);
assert.equal(validatePublish(publishBody({ tags: ['a', 'b', 'c', 'd', 'e'] })).ok, true);
assert.equal(validatePublish(publishBody({ tags: ['x'.repeat(20)] })).ok, true);
assert.equal(validatePublish(publishBody({ tags: ['a', 'b', 'c', 'd', 'e', 'f'] })).ok, false);
assert.equal(validatePublish(publishBody({ tags: ['x'.repeat(21)] })).ok, false);
const bad = validatePublish(publishBody({ title: '' }));
assert.equal(bad.ok, false);
assert.equal(typeof bad.message, 'string');
});
// ---- Gesundheit, Routing, Body ------------------------------------------------------------------
test('GET /api/gesundheit antwortet {ok:true} mit sicheren Kopfzeilen', async () => {
const { call } = await setup();
const res = await call('GET', '/api/gesundheit?x=1');
assert.equal(res.status, 200);
assert.deepEqual(res.json, { ok: true });
assert.match(res.headers['content-type'], /^application\/json/);
assert.equal(res.headers['cache-control'], 'no-store');
assert.equal(res.headers['x-content-type-options'], 'nosniff');
});
test('Alles andere ist 404, auch falsche Methoden', async () => {
const { call, fake } = await setup();
for (const [method, path] of [
['GET', '/'],
['GET', '/api'],
['GET', '/api/melden'],
['POST', '/api/gesundheit'],
['POST', '/api/unbekannt'],
['POST', '/api/melden/'],
['POST', '/api/admin'],
['PUT', '/api/melden'],
['OPTIONS', '/api/melden'],
['DELETE', '/api/zurueckziehen'],
['GET', '/v1/account'],
]) {
const res = await call(method, path, { body: {} });
assert.equal(res.status, 404, `${method} ${path}`);
assert.equal(typeof res.json.error, 'string');
}
assert.equal(fake.calls.length, 0);
});
test('Nur application/json: sonst 415 (auch mit Charset-Zusatz erlaubt)', async () => {
const { call, fake } = await setup();
seedCard(fake);
for (const contentType of ['text/plain', 'application/x-www-form-urlencoded', 'application/jsonx', null]) {
const res = await call('POST', '/api/melden', { raw: '{"id":"card-1","reason":"spam"}', contentType });
assert.equal(res.status, 415, String(contentType));
}
const ok = await call('POST', '/api/melden', {
raw: '{"id":"card-1","reason":"spam"}',
contentType: 'application/json; charset=utf-8',
headers: { 'X-Real-Ip': '203.0.113.1' },
});
assert.equal(ok.status, 200);
});
test('Body über 8 KB: 413 (mit Content-Length und chunked), genau 8 KB geht noch durch', async () => {
const { call, fake } = await setup();
const padded = (length) => {
const base = '{"id":"nope","reason":"spam"}';
return base + ' '.repeat(length - Buffer.byteLength(base));
};
const declared = await call('POST', '/api/melden', { raw: padded(8193) });
assert.equal(declared.status, 413);
assert.equal(typeof declared.json.error, 'string');
assert.equal(declared.headers.connection, 'close', 'nach einem zu großen Body wird die Verbindung geschlossen');
const chunked = await call('POST', '/api/veroeffentlichen', {
raw: padded(9000),
headers: { 'Transfer-Encoding': 'chunked', 'X-Aircard-Jwt': JWT_ME },
});
assert.equal(chunked.status, 413);
const exact = await call('POST', '/api/melden', { raw: padded(8192), headers: { 'X-Real-Ip': '203.0.113.2' } });
assert.equal(exact.status, 404, 'genau 8192 Byte werden verarbeitet (Karte existiert nicht)');
assert.equal(fake.calls.filter((c) => c.path.includes('/account')).length, 0, 'bei 413 wurde nichts bei Appwrite abgefragt');
});
test('Kaputtes JSON, Array oder leerer Body: 400', async () => {
const { call } = await setup();
for (const raw of ['{kaputt', '[]', '"text"', 'null', '']) {
const res = await call('POST', '/api/melden', { raw });
assert.equal(res.status, 400, JSON.stringify(raw));
assert.equal(typeof res.json.error, 'string');
}
});
// ---- Veröffentlichen --------------------------------------------------------------------------------
test('Veröffentlichen: legt eine sichtbare Zeile an (Rechte, searchText, ownerId)', async () => {
const { post, fake } = await setup();
seedUploads(fake);
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 201);
assert.equal(res.json.id, galleryRowId(ME, 'card1'));
const row = fake.row('gallery', res.json.id);
assert.deepEqual(row.$permissions, ['read("any")']);
assert.equal(row.title, 'Mein Motiv');
assert.equal(row.issuer, 'Beispielbank');
assert.equal(row.tags, 'blau,neon');
assert.equal(row.displayName, 'Kenso');
assert.equal(row.imageFileId, 'img1');
assert.equal(row.previewFileId, 'prev1');
assert.equal(row.sourceCardId, 'card1');
assert.equal(row.ownerId, ME);
assert.equal(row.hidden, false);
assert.equal(row.reportCount, 0);
assert.equal(row.searchText, 'mein motiv beispielbank blau neon');
// der Server-Key hängt an jedem Datenaufruf, das JWT nur am Konto-Aufruf
const accountCall = fake.calls.find((c) => c.path === '/v1/account');
assert.equal(accountCall.headers['x-appwrite-jwt'], JWT_ME);
assert.equal(accountCall.headers['x-appwrite-key'], undefined);
for (const c of fake.calls.filter((x) => x.path !== '/v1/account')) {
assert.equal(c.headers['x-appwrite-key'], fake.key);
assert.equal(c.headers['x-appwrite-jwt'], undefined);
}
});
test('Veröffentlichen: Bild und Vorschau dürfen PNG oder WebP sein, Anzeigename und Herausgeber sind optional', async () => {
const { post, fake } = await setup();
fake.addFile({ id: 'img1', owner: ME, mimeType: 'image/png', size: MIB5 });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/png' });
const res = await post('/api/veroeffentlichen', { title: 'Nur Titel', imageFileId: 'img1', previewFileId: 'prev1', sourceCardId: 'card1' }, JWT_ME);
assert.equal(res.status, 201);
const row = fake.row('gallery', res.json.id);
assert.equal(row.issuer, '');
assert.equal(row.tags, '');
assert.equal(row.displayName, '');
});
test('Veröffentlichen ohne oder mit ungültigem JWT: 401 und nichts angelegt', async () => {
const { post, fake } = await setup();
seedUploads(fake);
assert.equal((await post('/api/veroeffentlichen', publishBody())).status, 401);
assert.equal((await post('/api/veroeffentlichen', publishBody(), 'xxx.yyy.zzz')).status, 401);
assert.equal((await post('/api/veroeffentlichen', publishBody(), 'zwei teile')).status, 401);
assert.equal((await post('/api/veroeffentlichen', {}, '')).status, 401);
assert.equal(fake.rows('gallery').length, 0);
assert.equal(storageCalls(fake).length, 0);
assert.equal(fake.calls.filter((c) => c.path === '/v1/account').length, 1, 'fehlendes oder kaputtes JWT erreicht Appwrite gar nicht erst');
});
test('Veröffentlichen mit fremder Datei: 403 und nichts angelegt', async () => {
const { post, fake } = await setup();
fake.addFile({ id: 'img1', owner: OTHER });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' });
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 403);
assert.equal(typeof res.json.error, 'string');
fake.addFile({ id: 'img2', owner: ME });
fake.addFile({ id: 'prev2', owner: OTHER, mimeType: 'image/webp' });
res = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img2', previewFileId: 'prev2' }), JWT_ME);
assert.equal(res.status, 403);
// Rechte ohne delete("user:me"): nur lesen reicht nicht
fake.addFile({ id: 'img3', owner: ME, permissions: ['read("any")', 'read("user:user-me")'] });
fake.addFile({ id: 'prev3', owner: ME, mimeType: 'image/webp' });
res = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img3', previewFileId: 'prev3' }), JWT_ME);
assert.equal(res.status, 403);
assert.equal(fake.rows('gallery').length, 0);
});
test('Veröffentlichen mit Datei in falschem Bucket: 403', async () => {
const { post, fake } = await setup();
fake.addFile({ id: 'img1', owner: ME, bucket: 'card-images' });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' });
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 403);
assert.equal(fake.rows('gallery').length, 0);
});
test('Veröffentlichen mit fehlender Datei: 400 "Bild nicht gefunden."', async () => {
const { post, fake } = await setup();
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' });
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 400);
assert.equal(res.json.error, 'Bild nicht gefunden.');
fake.addFile({ id: 'img1', owner: ME });
res = await post('/api/veroeffentlichen', publishBody({ previewFileId: 'ghost' }), JWT_ME);
assert.equal(res.status, 400);
assert.equal(res.json.error, 'Bild nicht gefunden.');
assert.equal(fake.rows('gallery').length, 0);
});
test('Veröffentlichen: nur PNG/WebP bis 5 MiB, sonst 400', async () => {
const { post, fake } = await setup();
const attempt = (image, preview) => {
fake.addFile({ id: 'img1', owner: ME, ...image });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', ...preview });
return post('/api/veroeffentlichen', publishBody(), JWT_ME);
};
assert.equal((await attempt({ mimeType: 'image/jpeg' }, {})).status, 400);
assert.equal((await attempt({ mimeType: 'application/pdf' }, {})).status, 400);
assert.equal((await attempt({}, { mimeType: 'image/svg+xml' })).status, 400);
assert.equal((await attempt({ size: MIB5 + 1 }, {})).status, 400);
assert.equal((await attempt({}, { size: MIB5 + 1 })).status, 400);
assert.equal(fake.rows('gallery').length, 0);
assert.equal((await attempt({ size: MIB5 }, { size: MIB5 })).status, 201);
});
test('Veröffentlichen doppelt: 409 "Diese Karte ist schon veröffentlicht."', async () => {
const { post, fake } = await setup();
seedUploads(fake);
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201);
fake.addFile({ id: 'img2', owner: ME });
fake.addFile({ id: 'prev2', owner: ME, mimeType: 'image/webp' });
const again = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img2', previewFileId: 'prev2' }), JWT_ME);
assert.equal(again.status, 409);
assert.equal(again.json.error, 'Diese Karte ist schon veröffentlicht.');
assert.equal(fake.rows('gallery').length, 1);
// eine andere Person darf dieselbe sourceCardId-Zeichenfolge haben (Zeilen-ID enthält den Besitzer)
seedUploads(fake, OTHER, { image: 'img9', preview: 'prev9' });
const other = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img9', previewFileId: 'prev9' }), JWT_OTHER);
assert.equal(other.status, 201);
assert.notEqual(other.json.id, galleryRowId(ME, 'card1'));
assert.equal(fake.rows('gallery').length, 2);
});
test('Veröffentlichen nach Zurückziehen ist wieder möglich (gleiche Zeilen-ID)', async () => {
const { post, fake } = await setup();
seedUploads(fake);
const first = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal((await post('/api/zurueckziehen', { id: first.json.id }, JWT_ME)).status, 200);
seedUploads(fake);
const second = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(second.status, 201);
assert.equal(second.json.id, first.json.id);
});
test('Veröffentlichen: Validierung liefert 400 und fasst Appwrite (Dateien, Zeilen) nicht an', async () => {
const { post, fake } = await setup();
seedUploads(fake);
const cases = [
['leerer Titel', { title: '' }, /Titel/],
['nur Leerzeichen als Titel', { title: ' ' }, /Titel/],
['Titel fehlt', { title: undefined }, /Titel/],
['Titel zu lang', { title: 'x'.repeat(61) }, /Titel/],
['Titel keine Zeichenkette', { title: 42 }, /Titel/],
['Titel mit Steuerzeichen', { title: 'a\nb' }, /Titel/],
['Herausgeber zu lang', { issuer: 'x'.repeat(41) }, /Herausgeber/],
['Anzeigename zu lang', { displayName: 'x'.repeat(31) }, /Anzeigename/],
['6 Tags', { tags: ['a', 'b', 'c', 'd', 'e', 'f'] }, /Tags/],
['Tag zu lang', { tags: ['x'.repeat(21)] }, /Tag/],
['Tags als Zeichenkette', { tags: 'a,b' }, /Tags/],
['Tag keine Zeichenkette', { tags: ['a', 5] }, /Tags/],
['Tag mit Komma', { tags: ['a,b'] }, /Komma/],
['Bild-ID mit ../', { imageFileId: '../x' }, /ID/],
['Bild-ID ..', { imageFileId: '..' }, /ID/],
['Bild-ID Zahl', { imageFileId: 123 }, /ID/],
['Bild-ID mit Schrägstrich', { imageFileId: 'a/b' }, /ID/],
['Bild-ID zu lang', { imageFileId: 'a'.repeat(37) }, /ID/],
['Bild-ID beginnt mit Punkt', { imageFileId: '.hidden' }, /ID/],
['Bild-ID leer', { imageFileId: '' }, /ID/],
['Vorschau-ID null', { previewFileId: null }, /ID/],
['Vorschau-ID mit Leerzeichen', { previewFileId: 'a b' }, /ID/],
['sourceCardId ../x', { sourceCardId: '../x' }, /ID/],
['sourceCardId Zahl', { sourceCardId: 7 }, /ID/],
['sourceCardId fehlt', { sourceCardId: undefined }, /ID/],
['Bild = Vorschau', { previewFileId: 'img1' }, /verschieden/],
];
for (const [label, overrides, pattern] of cases) {
const res = await post('/api/veroeffentlichen', publishBody(overrides), JWT_ME);
assert.equal(res.status, 400, label);
assert.match(res.json.error, pattern, label);
}
assert.equal(fake.rows('gallery').length, 0);
assert.equal(storageCalls(fake).length, 0, 'ungültige IDs erreichen Appwrite nie');
assert.equal((await post('/api/veroeffentlichen', publishBody({ tags: 'a,b' }), JWT_ME)).status, 400);
});
test('Veröffentlichen: Tags werden serverseitig erneut normalisiert', async () => {
const { post, fake } = await setup();
seedUploads(fake);
const res = await post('/api/veroeffentlichen', publishBody({ tags: [' BLAU ', 'Neon', '', ' ', 'blau'] }), JWT_ME);
assert.equal(res.status, 201);
assert.equal(fake.row('gallery', res.json.id).tags, 'blau,neon');
});
test('Veröffentlichen: Appwrite-Ausfall ergibt 502 mit deutschem Text, nichts angelegt', async () => {
const { post, fake } = await setup();
seedUploads(fake);
fake.failNext((c) => c.path === '/v1/account', { status: 500 });
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 502);
assert.equal(res.json.error, 'Der Dienst ist gerade nicht erreichbar.');
fake.failNext((c) => c.method === 'POST' && c.path.endsWith('/rows'), { status: 500 });
res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 502);
assert.equal(fake.rows('gallery').length, 0);
});
// ---- Melden ---------------------------------------------------------------------------------------
test('Melden: drei verschiedene IPs blenden die Karte aus (Zeilen- und Dateirechte)', async () => {
const { report, fake } = await setup();
const { imageFileId, previewFileId } = seedCard(fake);
let res = await report('card-1', '203.0.113.1');
assert.deepEqual(res.json, { ok: true });
assert.equal(fake.row('gallery', 'card-1').reportCount, 1);
assert.equal(fake.row('gallery', 'card-1').hidden, false);
res = await report('card-1', '203.0.113.2', 'urheberrecht');
assert.deepEqual(res.json, { ok: true });
assert.equal(fake.row('gallery', 'card-1').reportCount, 2);
assert.deepEqual(fake.row('gallery', 'card-1').$permissions, ['read("any")']);
res = await report('card-1', '203.0.113.3', 'kartendaten');
assert.equal(res.status, 200);
assert.deepEqual(res.json, { ok: true });
const row = fake.row('gallery', 'card-1');
assert.equal(row.hidden, true);
assert.equal(row.reportCount, 3);
assert.deepEqual(row.$permissions, ['read("user:user-me")', 'read("label:admin")']);
const hiddenFile = ['read("user:user-me")', 'read("label:admin")', 'delete("user:user-me")'];
assert.deepEqual(fake.file(imageFileId).$permissions, hiddenFile);
assert.deepEqual(fake.file(previewFileId).$permissions, hiddenFile);
assert.equal(fake.rows('reports').length, 3);
// ausgeblendete Karte melden: 404
res = await report('card-1', '203.0.113.4');
assert.equal(res.status, 404);
assert.equal(res.json.error, 'Diese Karte ist nicht (mehr) verfügbar.');
assert.equal(fake.rows('reports').length, 3);
});
test('Melden: zwei Melder reichen nicht, die Meldungen speichern nur Hash, Grund und Karte', async () => {
const { report, fake } = await setup();
seedCard(fake);
await report('card-1', '203.0.113.1', 'spam');
await report('card-1', '203.0.113.2', 'sonstiges');
assert.equal(fake.row('gallery', 'card-1').hidden, false);
const hash = reporterHash(SALT, '203.0.113.1');
const stored = fake.row('reports', reportRowId('card-1', hash));
assert.ok(stored, 'Zeilen-ID = sha256(galleryId|reporterHash)');
assert.equal(stored.galleryId, 'card-1');
assert.equal(stored.reason, 'spam');
assert.equal(stored.reporterHash, hash);
assert.deepEqual(stored.$permissions, []);
assert.ok(!JSON.stringify(fake.rows('reports')).includes('203.0.113'), 'keine IP im Klartext gespeichert');
});
test('Melden: dieselbe IP fünfmal zählt einmal, Antworten sind schonGemeldet', async () => {
const { report, fake } = await setup();
seedCard(fake);
const first = await report('card-1', '203.0.113.9');
assert.deepEqual(first.json, { ok: true });
for (let i = 0; i < 4; i += 1) {
const again = await report('card-1', '203.0.113.9', i % 2 ? 'spam' : 'unangemessen');
assert.equal(again.status, 200);
assert.deepEqual(again.json, { schonGemeldet: true });
}
assert.equal(fake.row('gallery', 'card-1').reportCount, 1);
assert.equal(fake.row('gallery', 'card-1').hidden, false);
assert.equal(fake.rows('reports').length, 1);
});
test('Melden: Rate-Limit 10 pro Stunde und IP, danach 429, nach einer Stunde wieder erlaubt', async () => {
const { report, fake, clock } = await setup();
seedCard(fake);
for (let i = 0; i < 10; i += 1) {
const res = await report('card-1', '203.0.113.50');
assert.equal(res.status, 200, `Meldung ${i + 1}`);
}
let res = await report('card-1', '203.0.113.50');
assert.equal(res.status, 429);
assert.equal(res.json.error, 'Zu viele Meldungen – bitte später nochmal.');
assert.equal(res.headers['retry-after'], '3600');
// eine andere IP ist nicht betroffen
assert.equal((await report('card-1', '203.0.113.51')).status, 200);
// knapp vor Ablauf noch gesperrt, nach einer Stunde wieder frei
clock.value += 3600_000 - 1;
assert.equal((await report('card-1', '203.0.113.50')).status, 429);
clock.value += 1;
res = await report('card-1', '203.0.113.50');
assert.equal(res.status, 200);
assert.deepEqual(res.json, { schonGemeldet: true });
});
test('Melden: abgelaufene Rate-Limit-Einträge werden entfernt', async () => {
const { report, fake, clock, app } = await setup();
seedCard(fake);
for (const ip of ['203.0.113.1', '203.0.113.2', '203.0.113.3']) await report('card-1', ip);
assert.equal(app.rateLimitEntries(), 3);
clock.value += 3600_000 + 60_000;
await report('card-1', '203.0.113.4');
assert.equal(app.rateLimitEntries(), 1, 'die drei alten Einträge sind weg, nur der neue bleibt');
});
test('Melden: Ungültige Anfragen (Grund, ID) zählen nicht zum Limit und erreichen Appwrite nicht', async () => {
const { report, post, fake, app } = await setup();
seedCard(fake);
for (const reason of ['gibt-es-nicht', '', null, 7, undefined, 'SPAM']) {
const res = await post('/api/melden', { id: 'card-1', reason }, null, { 'X-Real-Ip': '203.0.113.1' });
assert.equal(res.status, 400, String(reason));
assert.equal(typeof res.json.error, 'string');
}
for (const id of ['../x', '..', 42, null, undefined, 'a/b', 'a'.repeat(37), '']) {
const res = await post('/api/melden', { id, reason: 'spam' }, null, { 'X-Real-Ip': '203.0.113.1' });
assert.equal(res.status, 400, String(id));
}
assert.equal(fake.calls.length, 0);
assert.equal(app.rateLimitEntries(), 0);
assert.equal((await report('card-1', '203.0.113.1')).status, 200);
});
test('Melden: nicht vorhandene Karte ist 404', async () => {
const { report, fake } = await setup();
const res = await report('gibts-nicht', '203.0.113.1');
assert.equal(res.status, 404);
assert.equal(res.json.error, 'Diese Karte ist nicht (mehr) verfügbar.');
assert.equal(fake.rows('reports').length, 0);
});
test('Melden: gültige Gründe sind unangemessen, kartendaten, urheberrecht, spam, sonstiges', async () => {
const { report, fake } = await setup();
seedCard(fake);
const reasons = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'];
for (let i = 0; i < reasons.length; i += 1) {
const res = await report('card-1', `198.51.100.${i + 1}`, reasons[i]);
assert.equal(res.status, 200, reasons[i]);
if (i === 2) break; // ab 3 Meldern ist die Karte weg
}
const stored = fake.rows('reports').map((r) => r.reason).sort();
assert.deepEqual(stored, ['kartendaten', 'unangemessen', 'urheberrecht']);
});
test('Melden: IPv6-Adressen aus demselben /64 zählen als ein Melder', async () => {
const { report, fake } = await setup();
seedCard(fake);
assert.deepEqual((await report('card-1', '2001:db8:abcd:12::1')).json, { ok: true });
assert.deepEqual((await report('card-1', '2001:db8:abcd:12:aaaa:bbbb:cccc:dddd')).json, { schonGemeldet: true });
assert.deepEqual((await report('card-1', '2001:db8:abcd:13::1')).json, { ok: true });
assert.equal(fake.row('gallery', 'card-1').reportCount, 2);
});
test('Melden: ohne X-Real-Ip gilt das erste X-Forwarded-For, sonst die Socket-Adresse', async () => {
const { post, fake } = await setup();
seedCard(fake);
const viaForwarded = (ip) => post('/api/melden', { id: 'card-1', reason: 'spam' }, null, { 'X-Forwarded-For': `${ip}, 10.0.0.1` });
assert.deepEqual((await viaForwarded('203.0.113.77')).json, { ok: true });
assert.deepEqual((await viaForwarded('203.0.113.77')).json, { schonGemeldet: true });
assert.deepEqual((await viaForwarded('203.0.113.78')).json, { ok: true });
// ohne beide Header zählt die Socket-Adresse (127.0.0.1) als dritter Melder
assert.deepEqual((await post('/api/melden', { id: 'card-1', reason: 'spam' })).json, { ok: true });
assert.equal(fake.row('gallery', 'card-1').hidden, true, 'drei verschiedene Quellen');
});
test('Melden: gleichzeitige Meldungen von drei IPs ergeben genau drei und blenden aus', async () => {
const { report, fake } = await setup();
seedCard(fake);
const results = await Promise.all([report('card-1', '203.0.113.1'), report('card-1', '203.0.113.2'), report('card-1', '203.0.113.3')]);
assert.deepEqual(results.map((r) => r.status), [200, 200, 200]);
const row = fake.row('gallery', 'card-1');
assert.equal(row.reportCount, 3);
assert.equal(row.hidden, true);
assert.equal(fake.rows('reports').length, 3);
});
test('Melden: eine verspätete Zählerstand-Änderung überschreibt neuere Stände nicht (Sperre je Karte)', async () => {
const { report, fake } = await setup();
seedCard(fake);
// Die erste Meldung schreibt ihren Zähler (1) erst nach 150 ms; währenddessen kommen zwei weitere.
fake.delayNext((c) => c.method === 'PATCH' && c.body && c.body.data && c.body.data.reportCount === 1, 150);
const first = report('card-1', '203.0.113.1');
await new Promise((resolve) => setTimeout(resolve, 40));
const others = await Promise.all([report('card-1', '203.0.113.2'), report('card-1', '203.0.113.3')]);
const firstResult = await first;
assert.deepEqual([firstResult.status, ...others.map((r) => r.status)], [200, 200, 200]);
const row = fake.row('gallery', 'card-1');
assert.equal(row.reportCount, 3);
assert.equal(row.hidden, true);
assert.deepEqual(row.$permissions, ['read("user:user-me")', 'read("label:admin")']);
});
test('Melden: eine doppelte Meldung heilt einen früher steckengebliebenen Zählerstand', async () => {
const { report, fake } = await setup();
seedCard(fake);
// drei Meldungen liegen schon vor, der Zähler an der Karte kam aber nie an (z. B. Ausfall mitten im Ablauf)
for (const ip of ['203.0.113.1', '203.0.113.2', '203.0.113.3']) {
const hash = reporterHash(SALT, ip);
fake.addRow('reports', reportRowId('card-1', hash), { galleryId: 'card-1', reason: 'spam', reporterHash: hash }, []);
}
assert.equal(fake.row('gallery', 'card-1').hidden, false);
const res = await report('card-1', '203.0.113.1');
assert.deepEqual(res.json, { schonGemeldet: true });
assert.equal(fake.row('gallery', 'card-1').hidden, true);
assert.equal(fake.row('gallery', 'card-1').reportCount, 3);
});
test('Melden: Ausfall beim Anlegen der Meldung ergibt 502 und zählt nicht', async () => {
const { report, fake } = await setup();
seedCard(fake);
fake.failNext((c) => c.method === 'POST' && c.path.endsWith('/tables/reports/rows'), { status: 500 });
const res = await report('card-1', '203.0.113.1');
assert.equal(res.status, 502);
assert.equal(fake.row('gallery', 'card-1').reportCount, 0);
assert.equal(fake.rows('reports').length, 0);
});
test('Melden: Ausfall beim Ausblenden endet mit 502, ein erneuter Versuch blendet aus', async () => {
const { report, fake } = await setup();
seedCard(fake, { reportCount: 0 });
await report('card-1', '203.0.113.1');
await report('card-1', '203.0.113.2');
fake.failNext((c) => c.method === 'PATCH' && c.path.includes('/tables/gallery/rows/'), { status: 500 });
const res = await report('card-1', '203.0.113.3');
assert.equal(res.status, 502);
assert.equal(fake.row('gallery', 'card-1').hidden, false);
const retry = await report('card-1', '203.0.113.3');
assert.equal(retry.status, 200);
assert.equal(fake.row('gallery', 'card-1').hidden, true);
});
// ---- Zurückziehen ----------------------------------------------------------------------------------
test('Zurückziehen durch den Autor löscht Zeile, beide Dateien und die Meldungen', async () => {
const { post, fake } = await setup();
const { imageFileId, previewFileId } = seedCard(fake);
seedCard(fake, { id: 'card-2' });
for (const [id, hash] of [['card-1', 'h1'], ['card-1', 'h2'], ['card-2', 'h3']]) {
fake.addRow('reports', reportRowId(id, hash), { galleryId: id, reason: 'spam', reporterHash: hash }, []);
}
const res = await post('/api/zurueckziehen', { id: 'card-1' }, JWT_ME);
assert.equal(res.status, 200);
assert.deepEqual(res.json, { ok: true });
assert.equal(fake.row('gallery', 'card-1'), null);
assert.equal(fake.file(imageFileId), null);
assert.equal(fake.file(previewFileId), null);
assert.deepEqual(fake.rows('reports').map((r) => r.galleryId), ['card-2']);
assert.ok(fake.row('gallery', 'card-2'), 'andere Karten bleiben');
assert.ok(fake.file('card-2-img'));
});
test('Zurückziehen durch Admin geht, auch bei ausgeblendeter Karte', async () => {
const { post, fake } = await setup();
seedCard(fake, { hidden: true, reportCount: 3 });
const res = await post('/api/zurueckziehen', { id: 'card-1' }, JWT_ADMIN);
assert.equal(res.status, 200);
assert.equal(fake.row('gallery', 'card-1'), null);
assert.equal(fake.file('card-1-img'), null);
});
test('Zurückziehen durch Fremde: 403 und nichts gelöscht', async () => {
const { post, fake } = await setup();
seedCard(fake);
const res = await post('/api/zurueckziehen', { id: 'card-1' }, JWT_OTHER);
assert.equal(res.status, 403);
assert.equal(typeof res.json.error, 'string');
assert.ok(fake.row('gallery', 'card-1'));
assert.ok(fake.file('card-1-img'));
assert.ok(fake.file('card-1-prev'));
});
test('Zurückziehen: 401 ohne JWT, 404 bei fehlender Karte, 400 bei ungültiger ID', async () => {
const { post, fake } = await setup();
seedCard(fake);
assert.equal((await post('/api/zurueckziehen', { id: 'card-1' })).status, 401);
assert.equal((await post('/api/zurueckziehen', { id: 'card-1' }, 'xxx.yyy.zzz')).status, 401);
assert.equal((await post('/api/zurueckziehen', { id: 'nope' }, JWT_ME)).status, 404);
for (const id of ['../x', '..', 5, null, undefined]) {
assert.equal((await post('/api/zurueckziehen', { id }, JWT_ME)).status, 400, String(id));
}
assert.ok(fake.row('gallery', 'card-1'));
});
test('Zurückziehen ist wiederholbar, wenn schon eine Datei fehlt (Wiederholung nach Teilausfall)', async () => {
const { post, fake } = await setup();
seedCard(fake);
fake.failNext((c) => c.method === 'DELETE' && c.path.includes('/tables/gallery/rows/'), { status: 500 });
assert.equal((await post('/api/zurueckziehen', { id: 'card-1' }, JWT_ME)).status, 502);
assert.ok(fake.row('gallery', 'card-1'), 'Zeile bleibt, bis alles andere weg ist');
assert.equal(fake.file('card-1-img'), null);
const retry = await post('/api/zurueckziehen', { id: 'card-1' }, JWT_ME);
assert.equal(retry.status, 200);
assert.equal(fake.row('gallery', 'card-1'), null);
});
// ---- Admin ------------------------------------------------------------------------------------------
test('Admin-Sichtbarkeit: Nicht-Admin 403, ohne JWT 401', async () => {
const { post, fake } = await setup();
seedCard(fake);
assert.equal((await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, JWT_ME)).status, 403);
assert.equal((await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, JWT_OTHER)).status, 403);
assert.equal((await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false })).status, 401);
assert.deepEqual(fake.row('gallery', 'card-1').$permissions, ['read("any")']);
assert.equal(fake.row('gallery', 'card-1').hidden, false);
});
test('Admin blendet aus: Zeilen- und Dateirechte, hidden=true, Meldungen bleiben', async () => {
const { post, fake } = await setup();
seedCard(fake, { reportCount: 1 });
fake.addRow('reports', reportRowId('card-1', 'h1'), { galleryId: 'card-1', reason: 'spam', reporterHash: 'h1' }, []);
const res = await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, JWT_ADMIN);
assert.equal(res.status, 200);
const row = fake.row('gallery', 'card-1');
assert.equal(row.hidden, true);
assert.equal(row.reportCount, 1);
assert.deepEqual(row.$permissions, ['read("user:user-me")', 'read("label:admin")']);
const hiddenFile = ['read("user:user-me")', 'read("label:admin")', 'delete("user:user-me")'];
assert.deepEqual(fake.file('card-1-img').$permissions, hiddenFile);
assert.deepEqual(fake.file('card-1-prev').$permissions, hiddenFile);
assert.equal(fake.rows('reports').length, 1);
});
test('Admin blendet ein: sichtbare Rechte, hidden=false, reportCount=0, Meldungen gelöscht', async () => {
const { post, fake, report } = await setup();
seedCard(fake, { hidden: true, reportCount: 3 });
seedCard(fake, { id: 'card-2' });
for (const [id, hash] of [['card-1', 'h1'], ['card-1', 'h2'], ['card-1', 'h3'], ['card-2', 'h4']]) {
fake.addRow('reports', reportRowId(id, hash), { galleryId: id, reason: 'spam', reporterHash: hash }, []);
}
const res = await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: true }, JWT_ADMIN);
assert.equal(res.status, 200);
const row = fake.row('gallery', 'card-1');
assert.equal(row.hidden, false);
assert.equal(row.reportCount, 0);
assert.deepEqual(row.$permissions, ['read("any")']);
assert.deepEqual(fake.file('card-1-img').$permissions, ['read("any")', 'delete("user:user-me")']);
assert.deepEqual(fake.file('card-1-prev').$permissions, ['read("any")', 'delete("user:user-me")']);
assert.deepEqual(fake.rows('reports').map((r) => r.galleryId), ['card-2']);
// die Karte ist wieder meldbar
assert.equal((await report('card-1', '203.0.113.1')).status, 200);
});
test('Admin-Sichtbarkeit: Body-Prüfung und fehlende Karte', async () => {
const { post, fake } = await setup();
seedCard(fake);
for (const body of [
{ id: 'card-1' },
{ id: 'card-1', sichtbar: 'ja' },
{ id: 'card-1', sichtbar: 1 },
{ id: '../x', sichtbar: true },
{ id: 7, sichtbar: true },
{},
]) {
assert.equal((await post('/api/admin/sichtbarkeit', body, JWT_ADMIN)).status, 400, JSON.stringify(body));
}
assert.equal((await post('/api/admin/sichtbarkeit', { id: 'nope', sichtbar: true }, JWT_ADMIN)).status, 404);
});
// ---- Logs und Fehler --------------------------------------------------------------------------------
test('Logs enthalten nie Key, Salz, JWT oder IPs im Klartext, aber das Präfix [aircard-api]', async () => {
const { post, report, fake, logs } = await setup();
seedUploads(fake);
seedCard(fake, { id: 'card-9' });
await post('/api/veroeffentlichen', publishBody(), JWT_ME);
await post('/api/veroeffentlichen', publishBody(), 'xxx.yyy.zzz');
await report('card-9', '203.0.113.7');
await report('card-9', '203.0.113.8');
await report('card-9', '203.0.113.9');
await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: true }, JWT_ADMIN);
fake.failNext((c) => c.method === 'GET' && c.path.includes('/rows/'), { status: 500 });
await post('/api/zurueckziehen', { id: 'card-9' }, JWT_ME);
await post('/api/zurueckziehen', { id: 'card-9' }, JWT_ME);
assert.ok(logs.length > 0);
const all = logs.join('\n');
for (const secret of [fake.key, SALT, JWT_ME, JWT_OTHER, JWT_ADMIN, 'xxx.yyy.zzz', '203.0.113', '127.0.0.1', reporterHash(SALT, '203.0.113.7')]) {
assert.ok(!all.includes(secret), `Log enthält ${secret.slice(0, 12)}…`);
}
for (const line of logs) assert.ok(line.startsWith('[aircard-api] '), line);
});
test('Log-Flut: abgewiesene Anfragen (400/401/404/413/415/429) erzeugen keine Logzeilen', async () => {
const { call, post, report, logs } = await setup();
const big = `{"id":"x","reason":"spam"}${' '.repeat(9000)}`;
for (let i = 0; i < 12; i += 1) {
await report('gibts-nicht', '203.0.113.5'); // 404, ab der 11. Meldung 429
await call('POST', '/api/melden', { raw: '{"id":"x","reason":"spam"}', contentType: 'text/plain' }); // 415
await call('POST', '/api/melden', { raw: big }); // 413
await call('POST', '/api/melden', { raw: '{kaputt' }); // 400
await post('/api/melden', { id: '../x', reason: 'spam' }); // 400
await post('/api/veroeffentlichen', publishBody()); // 401
await post('/api/zurueckziehen', { id: 'x' }, 'zwei teile'); // 401
await call('GET', '/api/gibts-nicht'); // 404
}
const lastLimited = await report('gibts-nicht', '203.0.113.5');
assert.equal(lastLimited.status, 429, 'das Rate-Limit hat wirklich gegriffen');
assert.deepEqual(logs, []);
});
test('Log-Budget: höchstens 120 Zeilen pro Minute, der Rest wird gezählt und als Sammelzeile nachgetragen', async () => {
const logs = [];
const clock = { value: 1_700_000_000_000 };
const broken = {
accountFromJwt: async () => {
throw new AppwriteError(500, 'general_server_error', 'kaputt');
},
};
const app = createApp({ appwrite: broken, salt: SALT, now: () => clock.value, log: (line) => logs.push(String(line)) });
const server = http.createServer(app);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
cleanups.push(() => {
server.closeAllConnections();
return new Promise((resolve) => server.close(resolve));
});
const { port } = server.address();
const hit = () => request(port, 'POST', '/api/zurueckziehen', { body: { id: 'x' }, headers: { 'X-Aircard-Jwt': JWT_ME } });
for (let i = 0; i < 300; i += 1) assert.equal((await hit()).status, 502);
assert.equal(logs.length, 120, 'nach 120 Zeilen ist Schluss');
clock.value += 60_000;
await hit();
assert.equal(logs.length, 122, 'Sammelzeile + die neue Zeile');
assert.match(logs[120], /^\[aircard-api\] \S+ 180 log lines suppressed/);
assert.match(logs[121], /appwrite error status=500 type=general_server_error/);
});
test('Appwrite-Fehler: 502 bei 5xx und 429, 500 bei allem anderen (falscher Key, fehlende Tabelle)', async () => {
const { report, fake } = await setup();
seedCard(fake);
const isRowRead = (c) => c.method === 'GET' && c.path.includes('/tables/gallery/rows/');
const expectations = [
[{ status: 500, type: 'general_server_error' }, 502, 'Der Dienst ist gerade nicht erreichbar.'],
[{ status: 429, type: 'general_rate_limit_exceeded' }, 502, 'Der Dienst ist gerade nicht erreichbar.'],
[{ status: 404, type: 'table_not_found' }, 500, 'Interner Fehler.'],
[{ status: 404, type: 'database_not_found' }, 500, 'Interner Fehler.'],
[{ status: 404, type: 'general_route_not_found' }, 500, 'Interner Fehler.'],
[{ status: 401, type: 'general_unauthorized_scope' }, 500, 'Interner Fehler.'],
];
for (const [failure, status, message] of expectations) {
fake.failNext(isRowRead, failure);
const res = await report('card-1', '203.0.113.1');
assert.equal(res.status, status, `${failure.status} ${failure.type}`);
assert.equal(res.json.error, message);
}
assert.equal(fake.rows('reports').length, 0);
});
test('Fehlende Tabelle oder Bucket ist kein „Karte nicht verfügbar“, sondern ein 500', async () => {
const fake = await startFakeAppwrite();
fake.addUser(JWT_ME, { id: ME });
const appwrite = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key, database: 'falsche-db' });
const app = createApp({ appwrite, salt: SALT, log: () => {} });
const server = http.createServer(app);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
cleanups.push(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
await fake.close();
});
const { port } = server.address();
const res = await request(port, 'POST', '/api/melden', { body: { id: 'card-1', reason: 'spam' } });
assert.equal(res.status, 500);
assert.equal(res.json.error, 'Interner Fehler.');
});
test('Veröffentlichen legt beide Dateien auf genau die sichtbaren Rechte fest (kanonischer Zustand)', async () => {
const { post, fake } = await setup();
fake.addFile({ id: 'img1', owner: ME, permissions: ['read("any")', 'delete("user:user-me")', 'update("user:user-me")', 'read("users")'] });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', permissions: ['delete("user:user-me")', 'write("user:user-me")'] });
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 201);
assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-me")']);
assert.deepEqual(fake.file('prev1').$permissions, ['read("any")', 'delete("user:user-me")']);
});
test('Ein 409 beim Veröffentlichen fasst die Dateirechte nicht an (auch nicht die einer ausgeblendeten Karte)', async () => {
const { post, fake } = await setup();
// Karte 1 ist schon veröffentlicht und wurde ausgeblendet; ihr Autor schickt dieselben Dateien erneut.
const hiddenFile = ['read("user:user-me")', 'read("label:admin")', 'delete("user:user-me")'];
fake.addFile({ id: 'img1', owner: ME, permissions: hiddenFile });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', permissions: hiddenFile });
const rowId = galleryRowId(ME, 'card1');
fake.addRow(
'gallery',
rowId,
{ title: 'Karte', imageFileId: 'img1', previewFileId: 'prev1', sourceCardId: 'card1', ownerId: ME, hidden: true, reportCount: 3 },
hiddenRowPermissions(ME),
);
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 409);
assert.deepEqual(fake.file('img1').$permissions, hiddenFile);
assert.deepEqual(fake.file('prev1').$permissions, hiddenFile);
assert.equal(fake.row('gallery', rowId).hidden, true);
assert.equal(fake.calls.filter((c) => c.method === 'PUT').length, 0, 'kein PUT auf Dateien');
});
test('Veröffentlichen: scheitert das Festlegen der Dateirechte, wird die Zeile zurückgenommen', async () => {
const { post, fake } = await setup();
seedUploads(fake);
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/prev1'), { status: 500 });
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 502);
assert.equal(fake.rows('gallery').length, 0, 'keine halbfertige Zeile, die eine Wiederholung mit 409 blockiert');
// Datei verschwindet zwischen Prüfung und Festlegen: 400 und ebenfalls zurückgenommen
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/img1'), { status: 404, type: 'storage_file_not_found' });
res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 400);
assert.equal(res.json.error, 'Bild nicht gefunden.');
assert.equal(fake.rows('gallery').length, 0);
// danach geht ein neuer Versuch
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201);
});
test('Dateirechte setzen wartet auf beide Aufrufe, auch wenn einer scheitert (Sperre bleibt bis dahin)', async () => {
const { post, fake } = await setup();
seedCard(fake);
fake.delayNext((c) => c.method === 'PUT' && c.path.endsWith('/files/card-1-img'), 150);
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/card-1-prev'), { status: 500 });
const res = await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, JWT_ADMIN);
assert.equal(res.status, 502);
// Die Antwort kam erst, nachdem auch der langsame PUT durch war.
assert.deepEqual(fake.file('card-1-img').$permissions, ['read("user:user-me")', 'read("label:admin")', 'delete("user:user-me")']);
assert.deepEqual(fake.file('card-1-prev').$permissions, ['read("any")', 'delete("user:user-me")']);
assert.equal(fake.row('gallery', 'card-1').hidden, false, 'die Zeile wird erst nach den Dateien geändert');
});
test('Ein gültiges JWT nur in Authorization oder X-Appwrite-JWT wird ignoriert (401)', async () => {
const { post, fake } = await setup();
seedUploads(fake);
seedCard(fake);
const ignored = [
{ Authorization: `Bearer ${JWT_ME}` },
{ 'X-Appwrite-JWT': JWT_ME },
{ 'X-Appwrite-JWT': JWT_ADMIN, Authorization: `Bearer ${JWT_ADMIN}` },
];
for (const headers of ignored) {
assert.equal((await post('/api/veroeffentlichen', publishBody(), null, headers)).status, 401);
assert.equal((await post('/api/zurueckziehen', { id: 'card-1' }, null, headers)).status, 401);
assert.equal((await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, null, headers)).status, 401);
}
assert.equal(fake.calls.filter((c) => c.path === '/v1/account').length, 0, 'Appwrite wurde nie nach dem Konto gefragt');
assert.equal(fake.rows('gallery').length, 1);
assert.ok(fake.row('gallery', 'card-1'));
});
test('Konto-Antwort mit ungültiger $id ergibt 401, ohne labels oder mit falschem Typ ist die Person kein Admin', async () => {
const { post, fake } = await setup();
seedCard(fake);
for (const [jwt, account] of [
['bad.id.one', { $id: 'x","read("any' }],
['bad.id.two', { $id: 42 }],
['bad.id.three', { $id: '../x' }],
['bad.id.four', { $id: '' }],
['bad.id.five', {}],
['bad.id.six', { $id: 'a'.repeat(37) }],
]) {
fake.addRawUser(jwt, account);
assert.equal((await post('/api/zurueckziehen', { id: 'card-1' }, jwt)).status, 401, JSON.stringify(account));
}
assert.ok(fake.row('gallery', 'card-1'));
fake.addRawUser('no.labels.jwt', { $id: 'user-nolabels' });
fake.addRawUser('string.labels.jwt', { $id: 'user-stringlabels', labels: 'admin' });
fake.addRawUser('object.labels.jwt', { $id: 'user-objectlabels', labels: { admin: true } });
for (const jwt of ['no.labels.jwt', 'string.labels.jwt', 'object.labels.jwt']) {
assert.equal((await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, jwt)).status, 403, jwt);
assert.equal((await post('/api/zurueckziehen', { id: 'card-1' }, jwt)).status, 403, jwt);
}
assert.equal(fake.row('gallery', 'card-1').hidden, false);
});
test('Unerwartete Fehler im Appwrite-Objekt ergeben 500, ohne die Anfrage hängen zu lassen', async () => {
const logs = [];
const broken = {
accountFromJwt: async () => {
throw new Error('kaputt GEHEIM');
},
};
const app = createApp({ appwrite: broken, salt: SALT, log: (line) => logs.push(line) });
const server = http.createServer(app);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
cleanups.push(() => {
server.closeAllConnections();
return new Promise((resolve) => server.close(resolve));
});
const res = await request(server.address().port, 'POST', '/api/zurueckziehen', {
body: { id: 'x' },
headers: { 'X-Aircard-Jwt': JWT_ME },
});
assert.equal(res.status, 500);
assert.equal(typeof res.json.error, 'string');
assert.ok(!logs.join('\n').includes('GEHEIM'), 'Fehlertexte werden nicht ins Log übernommen');
});
test('createApp verlangt Appwrite-Objekt und Salz', () => {
assert.throws(() => createApp({ salt: SALT }));
assert.throws(() => createApp({ appwrite: {}, salt: '' }));
assert.throws(() => createApp({ appwrite: {} }));
});