Files
AirCardIOS/deploy/api/app.test.js
T
ClaudeandClaude Sonnet 5.5 7bbe0a9fe6 fix(api): Dateien bereits veröffentlichter oder gesperrter Karten nicht wiederverwenden
Veröffentlichen lehnt mit 403 "Diese Bilder sind bereits veröffentlicht oder
gesperrt." ab, wenn eine Datei kein read("any") trägt (ausgeblendet) oder von
einer Galerie-Zeile des Autors schon als Bild oder Vorschau verwendet wird
(auch überkreuz, auch ausgeblendete Karten). Die Nutzungsprüfung liest die
Zeilen des Autors über den vorhandenen ownerId-Index seitenweise mit Cursor
(auf die Datei-ID-Spalten gibt es keinen Index) und läuft zusammen mit dem
Anlegen je Autor nacheinander. 92 Tests.

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

1251 lines
59 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: ['read("any")', '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 der neu hochgeladenen Dateien nicht an', async () => {
const { post, fake } = await setup();
seedUploads(fake);
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201);
// Dieselbe private Karte nochmal, mit frisch hochgeladenen Dateien (z. B. zweiter Tab): 409, Dateien bleiben unverändert.
const extra = ['read("any")', 'delete("user:user-me")', 'update("user:user-me")'];
fake.addFile({ id: 'img2', owner: ME, permissions: extra });
fake.addFile({ id: 'prev2', owner: ME, mimeType: 'image/webp', permissions: extra });
const res = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img2', previewFileId: 'prev2' }), JWT_ME);
assert.equal(res.status, 409);
assert.deepEqual(fake.file('img2').$permissions, extra);
assert.deepEqual(fake.file('prev2').$permissions, extra);
assert.equal(fake.calls.filter((c) => c.method === 'PUT' && /\/files\/(img2|prev2)$/.test(c.path)).length, 0, 'kein PUT auf die neuen Dateien');
});
const TAKEN = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.';
test('Dateien einer ausgeblendeten Karte lassen sich nicht unter anderer sourceCardId neu veröffentlichen (403)', async () => {
const { post, fake } = await setup();
const { imageFileId, previewFileId } = seedCard(fake, { id: 'hid', hidden: true, reportCount: 3 });
const hiddenFile = ['read("user:user-me")', 'read("label:admin")', 'delete("user:user-me")'];
const res = await post('/api/veroeffentlichen', publishBody({ imageFileId, previewFileId, sourceCardId: 'andere-karte' }), JWT_ME);
assert.equal(res.status, 403);
assert.equal(res.json.error, TAKEN);
assert.equal(fake.rows('gallery').length, 1, 'keine neue Zeile');
assert.equal(fake.row('gallery', 'hid').hidden, true);
assert.deepEqual(fake.file(imageFileId).$permissions, hiddenFile);
assert.deepEqual(fake.file(previewFileId).$permissions, hiddenFile);
assert.equal(fake.calls.filter((c) => c.method === 'PUT').length, 0, 'Dateirechte bleiben unangetastet');
// auch mit der ursprünglichen sourceCardId bleibt es bei 403 (Dateien gesperrt) und nicht 409
const same = await post('/api/veroeffentlichen', publishBody({ imageFileId, previewFileId, sourceCardId: 'src-hid' }), JWT_ME);
assert.equal(same.status, 403);
});
test('Dateien einer sichtbaren Karte lassen sich nicht unter anderer sourceCardId neu veröffentlichen (403)', async () => {
const { post, fake } = await setup();
const { imageFileId, previewFileId } = seedCard(fake, { id: 'vis' });
fake.addFile({ id: 'fresh-img', owner: ME });
fake.addFile({ id: 'fresh-prev', owner: ME, mimeType: 'image/webp' });
for (const [label, overrides] of [
['beide Dateien', { imageFileId, previewFileId }],
['nur das Bild', { imageFileId, previewFileId: 'fresh-prev' }],
['nur die Vorschau', { imageFileId: 'fresh-img', previewFileId }],
]) {
const res = await post('/api/veroeffentlichen', publishBody({ ...overrides, sourceCardId: 'andere-karte' }), JWT_ME);
assert.equal(res.status, 403, label);
assert.equal(res.json.error, TAKEN, label);
}
assert.equal(fake.rows('gallery').length, 1);
// frische Dateien gehen
const ok = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'fresh-img', previewFileId: 'fresh-prev', sourceCardId: 'andere-karte' }), JWT_ME);
assert.equal(ok.status, 201);
});
test('Überkreuz-Verwendung: Bild einer Karte als Vorschau der neuen (und umgekehrt) wird abgelehnt', async () => {
const { post, fake } = await setup();
seedCard(fake, { id: 'vis' }); // Bild vis-img, Vorschau vis-prev
fake.addFile({ id: 'fresh-img', owner: ME });
fake.addFile({ id: 'fresh-prev', owner: ME, mimeType: 'image/webp' });
// vis-img (PNG) darf Vorschau sein, vis-prev (WebP) darf Bild sein: Format allein verhindert die Kreuzung nicht
const asPreview = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'fresh-img', previewFileId: 'vis-img', sourceCardId: 'x1' }), JWT_ME);
assert.equal(asPreview.status, 403);
assert.equal(asPreview.json.error, TAKEN);
const asImage = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'vis-prev', previewFileId: 'fresh-prev', sourceCardId: 'x2' }), JWT_ME);
assert.equal(asImage.status, 403);
assert.equal(asImage.json.error, TAKEN);
assert.equal(fake.rows('gallery').length, 1);
});
test('Dateien ohne read("any") (nicht frisch hochgeladen) werden abgelehnt (403)', async () => {
const { post, fake } = await setup();
fake.addFile({ id: 'img1', owner: ME, permissions: ['delete("user:user-me")'] });
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(res.json.error, TAKEN);
fake.addFile({ id: 'img1', owner: ME });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', permissions: ['read("user:user-me")', 'delete("user:user-me")'] });
res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 403);
assert.equal(fake.rows('gallery').length, 0);
});
test('Prüfung der Dateinutzung blättert durch alle Karten des Autors (250 Karten) und fragt nur dessen Zeilen ab', async () => {
const { post, fake } = await setup();
for (let i = 0; i < 250; i += 1) {
const owner = i % 50 === 0 ? OTHER : ME;
fake.addRow(
'gallery',
`row${i}`,
{ title: `K${i}`, imageFileId: `f${i}a`, previewFileId: `f${i}b`, sourceCardId: `s${i}`, ownerId: owner, hidden: false, reportCount: 0 },
['read("any")'],
);
}
fake.addFile({ id: 'f230a', owner: ME });
fake.addFile({ id: 'f230b', owner: ME, mimeType: 'image/webp' });
const taken = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'f230a', previewFileId: 'f230b', sourceCardId: 'neu1' }), JWT_ME);
assert.equal(taken.status, 403, 'Karte 230 liegt auf der dritten Seite');
assert.equal(taken.json.error, TAKEN);
const lists = fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith('/tables/gallery/rows'));
assert.equal(lists.length, 3, 'drei Seiten à 100');
for (const call of lists) assert.ok(decodeURIComponent(call.search).includes('"attribute":"ownerId","values":["user-me"]'));
seedUploads(fake);
const ok = await post('/api/veroeffentlichen', publishBody({ sourceCardId: 'neu2' }), JWT_ME);
assert.equal(ok.status, 201);
// Karten anderer Autoren zählen nicht (deren Dateien kann dieser Autor ohnehin nicht verwenden)
assert.ok(fake.row('gallery', 'row0'));
});
test('Gleichzeitiges Veröffentlichen derselben Dateien unter zwei sourceCardIds: genau eines gelingt', async () => {
const { post, fake } = await setup();
seedUploads(fake);
// Das Anlegen der ersten Zeile verzögern, damit beide Anfragen die Prüfung vor dem Anlegen erreichen würden.
fake.delayNext((c) => c.method === 'POST' && c.path.endsWith('/tables/gallery/rows'), 80);
const results = await Promise.all([
post('/api/veroeffentlichen', publishBody({ sourceCardId: 'c1' }), JWT_ME),
post('/api/veroeffentlichen', publishBody({ sourceCardId: 'c2' }), JWT_ME),
]);
assert.deepEqual(results.map((r) => r.status).sort(), [201, 403]);
assert.equal(fake.rows('gallery').length, 1);
});
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: {} }));
});