'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 { MSG, HttpError, codeFor } = require('./common.js'); const { startFakeAppwrite, pngHeader, webpHeader } = require('./fake-appwrite.js'); const { createApp, visibleRowPermissions, hiddenRowPermissions, uploadFilePermissions, visibleFilePermissions, hiddenFilePermissions, reporterHash, reporterNetwork, clientIp, normalizeIp, readImageSize, validatePublish, buildSearchText, galleryRowId, reportRowId, } = require('./app.js'); const SALT = 'test-salt-0123456789abcdef0123456789abcdef'; const ME = 'user-me'; const OTHER = 'user-other'; const ADMIN = 'user-admin'; /** Konto ohne Benutzernamen (alle anderen haben aus `setup` ein Profil): Veröffentlichen geht erst mit Profil. */ const NEWBIE = 'user-new'; 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 JWT_NEW = 'eyJhbGciOiJI.payload-new.signature-new'; const MIB5 = 5 * 1024 * 1024; const TAKEN = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.'; const NOT_CARD_FORMAT = 'Das Bild hat nicht das Kartenformat.'; const NOT_YOUR_CARD = 'Diese Karte gehört nicht zu deinem Konto.'; const TOO_MANY_CARDS = 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.'; // ---- 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'] }); fake.addUser(JWT_NEW, { id: NEWBIE }); // Veröffentlichen verlangt einen Benutzernamen (Tabelle `profiles`): ME, OTHER und ADMIN haben einen, NEWBIE nicht. for (const id of [ME, OTHER, ADMIN]) fake.addProfile(id); 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, }; } /** Zwei frisch hochgeladene Dateien und die private Karte, aus der veröffentlicht wird (Standard-sourceCardId `card1`). */ function seedUploads(fake, owner = ME, { image = 'img1', preview = 'prev1', card = 'card1' } = {}) { fake.addFile({ id: image, owner }); fake.addFile({ id: preview, owner, mimeType: 'image/webp' }); if (card) fake.addCard(card, owner); } /** 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' }); // Veröffentlichte Dateien tragen nie delete(owner): sichtbar nur read("any"), ausgeblendet nur Autor und Admin lesen. const filePermissions = hidden ? hiddenFilePermissions(owner) : visibleFilePermissions(); fake.file(imageFileId).$permissions = filePermissions; fake.file(previewFileId).$permissions = filePermissions; fake.addCard(`src-${id}`, 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")']); // Abweichung von Spec §7 (Ruling): veröffentlichte Dateien tragen kein delete(owner), sonst ließe sich der Inhalt // nach dem Veröffentlichen löschen und unter derselben ID neu anlegen. assert.deepEqual(visibleFilePermissions(), ['read("any")']); assert.deepEqual(hiddenFilePermissions('abc'), ['read("user:abc")', 'read("label:admin")']); // frisch hochgeladene (noch nicht veröffentlichte) Dateien: mit delete(owner), damit der Client Fehlversuche aufräumt assert.deepEqual(uploadFilePermissions('abc'), ['read("any")', '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(() => uploadFilePermissions(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('reporterNetwork: IPv4 -> /24, IPv6 -> /48, IPv4-gemappte IPv6 wie IPv4', () => { assert.equal(reporterNetwork('203.0.7.9'), '203.0.7.0/24'); assert.equal(reporterNetwork(' 203.0.7.200:5555 '), '203.0.7.0/24'); assert.equal(reporterNetwork('::ffff:203.0.7.9'), '203.0.7.0/24'); assert.equal(reporterNetwork('2001:0DB8:abcd:0012:0000:0000:0000:0001'), '2001:db8:abcd::/48'); assert.equal(reporterNetwork('[2001:db8:abcd:ffff::9]'), '2001:db8:abcd::/48'); assert.equal(reporterNetwork('2001:db8::1'), '2001:db8:0::/48'); assert.equal(reporterNetwork('fe80::1%eth0'), 'fe80:0:0::/48'); assert.equal(reporterNetwork('kein-ip'), 'kein-ip'); assert.equal(reporterNetwork(undefined), ''); // normalizeIp (Rate-Limit je Anschluss) bleibt feiner: volle IPv4, IPv6 /64 assert.equal(normalizeIp('203.0.7.9'), '203.0.7.9'); }); test('reporterHash ist HMAC-SHA256 (Hex) des Netzes: drei Adressen aus einem /24 (IPv6: /48) sind ein Melder', () => { assert.equal(reporterHash(SALT, '203.0.7.9'), crypto.createHmac('sha256', SALT).update('203.0.7.0/24').digest('hex')); assert.notEqual(reporterHash('anderes-salz', '203.0.7.9'), reporterHash(SALT, '203.0.7.9')); assert.equal(reporterHash(SALT, '203.0.7.9'), reporterHash(SALT, '203.0.7.200')); assert.notEqual(reporterHash(SALT, '203.0.7.9'), reporterHash(SALT, '203.0.8.9')); assert.equal(reporterHash(SALT, '2001:db8:abcd:12::1'), reporterHash(SALT, '2001:db8:abcd:ffff:ffff:eeee:dddd:2')); assert.notEqual(reporterHash(SALT, '2001:db8:abcd:12::1'), reporterHash(SALT, '2001:db8:abce:12::1')); }); test('normalizeIp: IPv4 bleibt, IPv4-gemappte IPv6 wird IPv4, sonst /64', () => { assert.equal(normalizeIp('203.0.7.9'), '203.0.7.9'); assert.equal(normalizeIp(' 203.0.7.9:5555 '), '203.0.7.9'); assert.equal(normalizeIp('::ffff:203.0.7.9'), '203.0.7.9'); 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.1.9' }, }); 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.2.9' } }); 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,', 'Tags mit Trennzeichen an beiden Enden (exakter Tag-Filter)'); 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.addCard('card1', ME); fake.addFile({ id: 'img1', owner: ME, mimeType: 'image/png', size: MIB5 }); fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/png', role: 'preview' }); 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 ohne Benutzernamen: 403 usernameRequired noch vor jeder anderen Prüfung (auch vor Body, Dateien und Karte), nichts angelegt', async () => { const { post, fake } = await setup(); seedUploads(fake, NEWBIE); const publish = (body) => post('/api/veroeffentlichen', body, JWT_NEW); expectError(await publish(publishBody()), 403, 'usernameRequired', 'gültiger Body'); expectError(await publish({}), 403, 'usernameRequired', 'ungültiger Body (kein Titel, keine Dateien)'); expectError(await publish(publishBody({ title: '' })), 403, 'usernameRequired', 'Titel fehlt'); expectError(await publish(publishBody({ imageFileId: 'gibts-nicht' })), 403, 'usernameRequired', 'Datei gibt es nicht'); expectError(await publish(publishBody({ sourceCardId: 'fremde-karte' })), 403, 'usernameRequired', 'Karte gibt es nicht'); expectError(await publish(null), 403, 'usernameRequired', 'kein Body'); assert.equal(fake.rows('gallery').length, 0); assert.equal(storageCalls(fake).length, 0, 'keine Datei wird angefasst'); assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-new")'], 'die Dateien bleiben, wie sie waren'); // Appwrite sieht je Versuch nur die Prüfung des JWT und die Suche nach dem Profil (Zeilen-ID = Konto-ID) assert.deepEqual( fake.calls.filter((c) => c.path !== '/v1/account').map((c) => `${c.method} ${c.path}`), Array(6).fill('GET /v1/tablesdb/aircard/tables/profiles/rows/user-new'), ); }); test('Veröffentlichen: das Profil gehört dem Konto – ein Profil der anderen Konten hilft nicht, mit eigenem Profil (über /api/profil) geht es', async () => { const { post, fake } = await setup(); seedUploads(fake, NEWBIE); expectError(await post('/api/veroeffentlichen', publishBody(), JWT_NEW), 403, 'usernameRequired'); const named = await post('/api/profil', { username: 'Neuling' }, JWT_NEW); assert.deepEqual(named.json, { username: 'Neuling' }); const res = await post('/api/veroeffentlichen', publishBody(), JWT_NEW); assert.equal(res.status, 201); assert.equal(fake.row('gallery', res.json.id).ownerId, NEWBIE); }); test('Veröffentlichen: lässt sich das Profil nicht prüfen (Appwrite-Fehler), 502 und nichts wird angelegt; der nächste Versuch gelingt', async () => { const { post, fake } = await setup(); seedUploads(fake); fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/tables/profiles/rows/user-me'), { status: 500 }); expectError(await post('/api/veroeffentlichen', publishBody(), JWT_ME), 502, 'unavailable'); assert.equal(fake.rows('gallery').length, 0); assert.equal(storageCalls(fake).length, 0); assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201); }); 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(); fake.addCard('card1', ME); 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 veröffentlicht ihre eigene Karte (Zeilen-ID enthält den Besitzer) seedUploads(fake, OTHER, { image: 'img9', preview: 'prev9', card: 'card9' }); const other = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img9', previewFileId: 'prev9', sourceCardId: 'card9' }), 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/], ['Titel mit Nullbreiten-Leerzeichen', { title: 'a\u200bb' }, /Titel/], ['Titel nur aus Nullbreitenzeichen', { title: '\u200b\u200d\ufeff' }, /Titel/], ['Titel mit Rechts-nach-links-Überschreibung', { title: 'foto\u202egnp.exe' }, /Titel/], ['Titel mit Isolate', { title: 'a\u2066b' }, /Titel/], ['Titel mit C1-Steuerzeichen', { title: 'a\u0085b' }, /Titel/], ['Herausgeber mit Bidi-Zeichen', { issuer: 'Bank\u200f' }, /Herausgeber/], ['Anzeigename mit Wortverbinder', { displayName: 'Ken\u2060so' }, /Anzeigename/], ['Tag mit Nullbreiten-Zeichen', { tags: ['gol\u200bd'] }, /Tags/], ['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.1.9'); 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.2.9', '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.3.9', '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")']; 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.4.9'); 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.1.9', 'spam'); await report('card-1', '203.0.2.9', 'sonstiges'); assert.equal(fake.row('gallery', 'card-1').hidden, false); const hash = reporterHash(SALT, '203.0.1.9'); 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.'), '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.9.9'); assert.deepEqual(first.json, { ok: true }); for (let i = 0; i < 4; i += 1) { const again = await report('card-1', '203.0.9.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.50.9'); assert.equal(res.status, 200, `Meldung ${i + 1}`); } let res = await report('card-1', '203.0.50.9'); 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.51.9')).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.50.9')).status, 429); clock.value += 1; res = await report('card-1', '203.0.50.9'); 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.1.9', '203.0.2.9', '203.0.3.9']) await report('card-1', ip); assert.equal(app.rateLimitEntries(), 3); clock.value += 3600_000 + 60_000; await report('card-1', '203.0.4.9'); 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.1.9' }); 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.1.9' }); 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.1.9')).status, 200); }); test('Melden: nicht vorhandene Karte ist 404', async () => { const { report, fake } = await setup(); const res = await report('gibts-nicht', '203.0.1.9'); 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 /48 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, { schonGemeldet: true }, 'anderes /64, selbes /48'); assert.deepEqual((await report('card-1', '2001:db8:abce:12::1')).json, { ok: true }); assert.equal(fake.row('gallery', 'card-1').reportCount, 2); }); test('Melden: drei Adressen aus einem IPv4-/24 zählen als ein Melder und blenden nicht aus', async () => { const { report, fake } = await setup(); seedCard(fake); assert.deepEqual((await report('card-1', '198.51.100.1')).json, { ok: true }); assert.deepEqual((await report('card-1', '198.51.100.77')).json, { schonGemeldet: true }); assert.deepEqual((await report('card-1', '198.51.100.254')).json, { schonGemeldet: true }); assert.equal(fake.row('gallery', 'card-1').reportCount, 1); assert.equal(fake.row('gallery', 'card-1').hidden, false); // drei verschiedene /24 blenden aus await report('card-1', '198.51.101.1'); await report('card-1', '198.51.102.1'); assert.equal(fake.row('gallery', 'card-1').hidden, true); }); test('Melden: das Rate-Limit zählt weiter je Anschluss (volle IPv4), nicht je Netz', async () => { const { report, fake } = await setup(); seedCard(fake); // 10 Meldungen von einer Adresse sind erlaubt, die 11. nicht; eine Nachbaradresse im selben /24 ist noch frei for (let i = 0; i < 10; i += 1) assert.equal((await report('card-1', '198.51.100.1')).status, 200); assert.equal((await report('card-1', '198.51.100.1')).status, 429); assert.equal((await report('card-1', '198.51.100.2')).status, 200); }); 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.77.9')).json, { ok: true }); assert.deepEqual((await viaForwarded('203.0.77.9')).json, { schonGemeldet: true }); assert.deepEqual((await viaForwarded('203.0.78.9')).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.1.9'), report('card-1', '203.0.2.9'), report('card-1', '203.0.3.9')]); 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.1.9'); await new Promise((resolve) => setTimeout(resolve, 40)); const others = await Promise.all([report('card-1', '203.0.2.9'), report('card-1', '203.0.3.9')]); 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.1.9', '203.0.2.9', '203.0.3.9']) { 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.1.9'); 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.1.9'); 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.1.9'); await report('card-1', '203.0.2.9'); fake.failNext((c) => c.method === 'PATCH' && c.path.includes('/tables/gallery/rows/'), { status: 500 }); const res = await report('card-1', '203.0.3.9'); assert.equal(res.status, 502); assert.equal(fake.row('gallery', 'card-1').hidden, false); const retry = await report('card-1', '203.0.3.9'); 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")']; 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")']); assert.deepEqual(fake.file('card-1-prev').$permissions, ['read("any")']); assert.deepEqual(fake.rows('reports').map((r) => r.galleryId), ['card-2']); // die Karte ist wieder meldbar assert.equal((await report('card-1', '203.0.1.9')).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.7.9'); await report('card-9', '203.0.8.9'); await report('card-9', '203.0.9.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.', '127.0.0.1', reporterHash(SALT, '203.0.7.9')]) { 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.5.9'); // 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.5.9'); 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.1.9'); 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 read("any") fest: kein delete(owner) mehr (kanonischer Zustand)', async () => { const { post, fake } = await setup(); fake.addCard('card1', ME); 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")']); assert.deepEqual(fake.file('prev1').$permissions, ['read("any")']); }); test('Löschen und Neuanlegen derselben Datei nach dem Veröffentlichen ist ausgeschlossen: Autor hat kein delete mehr', async () => { const { post, fake } = await setup(); seedUploads(fake); assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201); for (const id of ['img1', 'prev1']) { assert.ok(!fake.file(id).$permissions.some((p) => p.startsWith('delete(') || p.startsWith('update(') || p.startsWith('write(')), id); } // dieselben (jetzt gepinnten) Dateien lassen sich nicht noch einmal einreichen fake.addCard('card2', ME); const again = await post('/api/veroeffentlichen', publishBody({ sourceCardId: 'card2' }), JWT_ME); assert.equal(again.status, 403); assert.equal(again.json.error, TAKEN); }); 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'); }); 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 }); fake.addCard('andere-karte', ME); const hiddenFile = ['read("user:user-me")', 'read("label:admin")']; 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.addCard('andere-karte', ME); 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.addCard('x1', ME); fake.addCard('x2', ME); 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.addCard('card1', ME); 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 und des Limits fragt nur die Zeilen des Autors ab (ein Aufruf, höchstens 20 Zeilen)', async () => { const { post, fake } = await setup(); for (let i = 0; i < 40; i += 1) { const owner = i % 2 === 0 ? OTHER : ME; // 20 Karten je Person 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")'], ); } // Zeile 39 gehört OTHER: ME hat 19 Zeilen (eine weniger als das Limit), OTHER 21 fake.rows('gallery').find((row) => row.$id === 'row39').ownerId = OTHER; fake.addFile({ id: 'f37a', owner: ME, permissions: ['read("any")', 'delete("user:user-me")'] }); fake.addFile({ id: 'f37b', owner: ME, mimeType: 'image/webp' }); fake.addCard('neu1', ME); const taken = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'f37a', previewFileId: 'f37b', sourceCardId: 'neu1' }), JWT_ME); assert.equal(taken.status, 403, 'Karte 37 des Autors gehört zu seinen 19 Zeilen'); 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, 1); const search = decodeURIComponent(lists[0].search); assert.ok(search.includes('"attribute":"ownerId","values":["user-me"]')); assert.ok(search.includes('"method":"limit","values":[20]')); seedUploads(fake); fake.addCard('neu2', ME); const ok = await post('/api/veroeffentlichen', publishBody({ sourceCardId: 'neu2' }), JWT_ME); assert.equal(ok.status, 201); }); test('Gleichzeitiges Veröffentlichen derselben Dateien unter zwei sourceCardIds: genau eines gelingt', async () => { const { post, fake } = await setup(); seedUploads(fake); fake.addCard('c1', ME); fake.addCard('c2', ME); // 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'); // Das schon festgelegte Bild bekommt delete(owner) zurück, damit der Client den Fehlversuch aufräumen kann assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-me")']); // 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")']); assert.deepEqual(fake.file('card-1-prev').$permissions, ['read("any")']); 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'); }); // ---- Bildmaße (Defense in Depth beim Veröffentlichen) --------------------------------------------- test('readImageSize liest PNG-IHDR sowie WebP VP8, VP8L und VP8X', () => { assert.deepEqual(readImageSize(pngHeader(1536, 969)), { width: 1536, height: 969 }); assert.deepEqual(readImageSize(webpHeader('VP8 ', 480, 303)), { width: 480, height: 303 }); assert.deepEqual(readImageSize(webpHeader('VP8L', 1536, 969)), { width: 1536, height: 969 }); assert.deepEqual(readImageSize(webpHeader('VP8X', 16383, 16383)), { width: 16383, height: 16383 }); assert.deepEqual(readImageSize(pngHeader(65535, 70000)), { width: 65535, height: 70000 }); // gekürzt auf genau die nötigen Bytes (PNG 24, VP8 30, VP8L 25, VP8X 30) geht es noch, eines weniger nicht assert.deepEqual(readImageSize(pngHeader(7, 8).subarray(0, 24)), { width: 7, height: 8 }); assert.equal(readImageSize(pngHeader(7, 8).subarray(0, 23)), null); assert.deepEqual(readImageSize(webpHeader('VP8 ', 7, 8).subarray(0, 30)), { width: 7, height: 8 }); assert.equal(readImageSize(webpHeader('VP8 ', 7, 8).subarray(0, 29)), null); assert.deepEqual(readImageSize(webpHeader('VP8L', 7, 8).subarray(0, 25)), { width: 7, height: 8 }); assert.equal(readImageSize(webpHeader('VP8L', 7, 8).subarray(0, 24)), null); assert.deepEqual(readImageSize(webpHeader('VP8X', 7, 8).subarray(0, 30)), { width: 7, height: 8 }); assert.equal(readImageSize(webpHeader('VP8X', 7, 8).subarray(0, 29)), null); }); test('readImageSize: Unbekanntes, Leeres und Kaputtes ergibt null', () => { assert.equal(readImageSize(Buffer.alloc(0)), null); assert.equal(readImageSize(Buffer.from('GIF89a')), null); assert.equal(readImageSize(Buffer.alloc(64)), null); assert.equal(readImageSize(Buffer.from('')), null); const brokenPng = pngHeader(1536, 969); brokenPng.write('IDAT', 12, 'latin1'); // erster Chunk ist nicht IHDR assert.equal(readImageSize(brokenPng), null); const lossyBadStart = webpHeader('VP8 ', 480, 303); lossyBadStart[24] = 0; // Startcode kaputt assert.equal(readImageSize(lossyBadStart), null); const losslessBadSignature = webpHeader('VP8L', 480, 303); losslessBadSignature[20] = 0; assert.equal(readImageSize(losslessBadSignature), null); const unknownChunk = webpHeader('VP8 ', 480, 303); unknownChunk.write('ANIM', 12, 'latin1'); assert.equal(readImageSize(unknownChunk), null); assert.equal(readImageSize(null), null); assert.equal(readImageSize('PNG'), null); }); test('Veröffentlichen: Bild muss 1536 x 969 sein, Vorschau 480 breit und 302 bis 304 hoch (sonst 400)', async () => { const { post, fake } = await setup(); fake.addCard('card1', ME); const attempt = ({ image, preview }) => { fake.addFile({ id: 'img1', owner: ME, content: image }); fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', content: preview }); return post('/api/veroeffentlichen', publishBody(), JWT_ME); }; const okImage = pngHeader(1536, 969); const okPreview = webpHeader('VP8 ', 480, 303); for (const [label, files] of [ ['Bild zu schmal', { image: pngHeader(1535, 969), preview: okPreview }], ['Bild zu hoch', { image: pngHeader(1536, 970), preview: okPreview }], ['Bild 4 Megapixel', { image: pngHeader(2000, 2000), preview: okPreview }], ['Bild riesig (Dekompressionsbombe)', { image: pngHeader(60000, 60000), preview: okPreview }], ['Bild WebP riesig', { image: webpHeader('VP8L', 16383, 16383), preview: okPreview }], ['Vorschau zu breit', { image: okImage, preview: webpHeader('VP8 ', 481, 303) }], ['Vorschau zu niedrig', { image: okImage, preview: webpHeader('VP8 ', 480, 301) }], ['Vorschau zu hoch', { image: okImage, preview: webpHeader('VP8 ', 480, 305) }], ['Vorschau in Bildgröße', { image: okImage, preview: webpHeader('VP8L', 1536, 969) }], ['Bild ist kein Bild', { image: Buffer.from('MZ\x90\x00 kein Bild, nur Text, lang genug für die Prüfung 123456789'), preview: okPreview }], ['Vorschau ist kein Bild', { image: okImage, preview: Buffer.alloc(100) }], ['Bild leer', { image: Buffer.alloc(0), preview: okPreview }], ['Bild abgeschnitten', { image: pngHeader(1536, 969).subarray(0, 20), preview: okPreview }], ]) { const res = await attempt(files); assert.equal(res.status, 400, label); assert.equal(res.json.error, NOT_CARD_FORMAT, label); assert.equal(fake.rows('gallery').length, 0, label); assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-me")'], `${label}: Dateien bleiben unangetastet`); } // genau die erwarteten Maße gehen; die Vorschau darf 302, 303 oder 304 hoch sein, auch als PNG (Browser ohne WebP-Export) for (const preview of [webpHeader('VP8 ', 480, 302), webpHeader('VP8L', 480, 304), webpHeader('VP8X', 480, 303), pngHeader(480, 303)]) { const res = await attempt({ image: okImage, preview }); assert.equal(res.status, 201); assert.equal((await post('/api/zurueckziehen', { id: res.json.id }, JWT_ME)).status, 200); } // auch ein WebP-Bild in voller Größe ist ein gültiges Kartenbild assert.equal((await attempt({ image: webpHeader('VP8L', 1536, 969), preview: okPreview })).status, 201); }); test('Veröffentlichen liest nur den Anfang der Dateien und schickt dafür den Server-Key an /view', async () => { const { post, fake } = await setup(); seedUploads(fake); const big = Buffer.concat([pngHeader(1536, 969), Buffer.alloc(3 * 1024 * 1024)]); fake.addFile({ id: 'img1', owner: ME, content: big, size: big.length }); assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201); const views = fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith('/view')); assert.deepEqual(views.map((c) => c.path).sort(), [ '/v1/storage/buckets/gallery-images/files/img1/view', '/v1/storage/buckets/gallery-images/files/prev1/view', ]); for (const c of views) assert.equal(c.headers['x-appwrite-key'], fake.key); }); test('Veröffentlichen: fremde oder gesperrte Dateien werden nicht gelesen (kein Maß-Orakel)', async () => { const { post, fake } = await setup(); fake.addCard('card1', ME); fake.addFile({ id: 'img1', owner: OTHER }); fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' }); assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 403); assert.equal(fake.calls.filter((c) => c.path.endsWith('/view')).length, 0); }); test('Veröffentlichen: Datei verschwindet vor dem Lesen der Maße ergibt 400 "Bild nicht gefunden."', async () => { const { post, fake } = await setup(); seedUploads(fake); fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/files/prev1/view'), { status: 404, type: 'storage_file_not_found' }); const 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); }); test('Veröffentlichen: Appwrite-Fehler beim Lesen der Maße ergibt 502, nichts angelegt', async () => { const { post, fake } = await setup(); seedUploads(fake); fake.failNext((c) => c.path.endsWith('/files/img1/view'), { status: 500 }); const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME); assert.equal(res.status, 502); assert.equal(fake.rows('gallery').length, 0); }); // ---- Limits und Besitz der Quellkarte --------------------------------------------------------- function seedPublications(fake, owner, count) { for (let i = 0; i < count; i += 1) { fake.addRow( 'gallery', `${owner}-pub${i}`, { title: `P${i}`, imageFileId: `${owner}-i${i}`, previewFileId: `${owner}-p${i}`, sourceCardId: `${owner}-s${i}`, ownerId: owner, hidden: i % 2 === 1, reportCount: 0 }, ['read("any")'], ); } } test('Veröffentlichen: höchstens 20 Veröffentlichungen je Konto (ausgeblendete zählen mit), die 21. ergibt 429', async () => { const { post, fake } = await setup(); seedPublications(fake, ME, 20); seedUploads(fake); const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME); assert.equal(res.status, 429); assert.equal(res.json.error, TOO_MANY_CARDS); assert.equal(fake.rows('gallery').length, 20); assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-me")'], 'Dateien unangetastet'); // Karten anderer Konten zählen nicht seedPublications(fake, OTHER, 25); assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 429); seedUploads(fake, OTHER, { image: 'oimg', preview: 'oprev', card: 'ocard' }); assert.equal((await post('/api/veroeffentlichen', publishBody({ imageFileId: 'oimg', previewFileId: 'oprev', sourceCardId: 'ocard' }), JWT_OTHER)).status, 429); }); test('Veröffentlichen: mit 19 Veröffentlichungen geht die 20., nach dem Zurückziehen wieder eine', async () => { const { post, fake } = await setup(); seedPublications(fake, ME, 19); seedUploads(fake); const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME); assert.equal(res.status, 201); fake.addCard('card2', ME); seedUploads(fake, ME, { image: 'img2', preview: 'prev2', card: 'card2' }); assert.equal((await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img2', previewFileId: 'prev2', sourceCardId: 'card2' }), JWT_ME)).status, 429); assert.equal((await post('/api/zurueckziehen', { id: res.json.id }, JWT_ME)).status, 200); assert.equal((await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img2', previewFileId: 'prev2', sourceCardId: 'card2' }), JWT_ME)).status, 201); }); test('Veröffentlichen: sourceCardId muss eine Zeile in `cards` mit read("user:") sein, sonst 403', async () => { const { post, fake } = await setup(); seedUploads(fake, ME, { card: null }); // gibt es nicht let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME); assert.equal(res.status, 403); assert.equal(res.json.error, NOT_YOUR_CARD); // gehört jemand anderem fake.addCard('card1', OTHER); res = await post('/api/veroeffentlichen', publishBody(), JWT_ME); assert.equal(res.status, 403); assert.equal(res.json.error, NOT_YOUR_CARD); // nur lesbar für alle, aber nicht meine fake.addCard('card1', OTHER, ['read("any")']); assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 403); assert.equal(fake.rows('gallery').length, 0); assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-me")'], 'Dateien unangetastet'); assert.equal(fake.calls.filter((c) => c.method === 'POST' && c.path.endsWith('/tables/gallery/rows')).length, 0); // meine Karte geht fake.addCard('card1', ME); assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201); }); test('Veröffentlichen: Fehler beim Lesen von `cards` ergibt 502, ein fehlender Tabellenname 500, nichts angelegt', async () => { const { post, fake } = await setup(); seedUploads(fake); fake.failNext((c) => c.method === 'GET' && c.path.includes('/tables/cards/rows/'), { status: 503 }); assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 502); fake.failNext((c) => c.method === 'GET' && c.path.includes('/tables/cards/rows/'), { status: 404, type: 'table_not_found' }); assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 500); assert.equal(fake.rows('gallery').length, 0); }); // ---- Rollback-Protokoll ------------------------------------------------------------------------ test('Veröffentlichen: scheitern Festlegen und Zurücknehmen, steht `rollback failed for card ` im Log (nur die ID)', async () => { const { post, fake, logs } = await setup(); seedUploads(fake); fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/prev1'), { status: 500 }); fake.failNext((c) => c.method === 'DELETE' && c.path.includes('/tables/gallery/rows/'), { status: 500 }); const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME); assert.equal(res.status, 502); const id = galleryRowId(ME, 'card1'); assert.equal(fake.rows('gallery').length, 1, 'die Zeile blieb stehen (Zurücknehmen scheiterte)'); const lines = logs.filter((line) => line.includes('rollback failed')); assert.equal(lines.length, 1); assert.match(lines[0], new RegExp(`^\\[aircard-api\\] \\S+ rollback failed for card ${id}$`)); assert.ok(!logs.join('\n').includes(JWT_ME)); }); test('Veröffentlichen: gelingt das Zurücknehmen, steht keine Rollback-Fehlerzeile im Log', async () => { const { post, fake, logs } = await setup(); seedUploads(fake); fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/prev1'), { status: 500 }); assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 502); assert.equal(fake.rows('gallery').length, 0); assert.equal(logs.filter((line) => line.includes('rollback failed')).length, 0); }); test('createApp verlangt Appwrite-Objekt und Salz', () => { assert.throws(() => createApp({ salt: SALT })); assert.throws(() => createApp({ appwrite: {}, salt: '' })); assert.throws(() => createApp({ appwrite: {} })); }); // ---- Fehlercodes: jede Fehlerantwort ist {error, code} ------------------------------------------------- /** Erwartet Status und die Antwort {error: , code: } (sonst nichts). */ function expectError(res, status, code, label = code) { assert.equal(res.status, status, `${label}: Status`); assert.deepEqual(res.json, { error: MSG[code], code }, label); } test('MSG: alle Texte sind paarweise verschieden (der Text bestimmt den Code), die Logo-Texte stehen wörtlich fest', () => { const texts = Object.values(MSG); assert.ok(texts.length > 0); assert.equal(new Set(texts).size, texts.length, 'jeder Text gehört zu genau einem Schlüssel'); assert.ok(texts.every((text) => typeof text === 'string' && text !== '')); const logoKeys = ['logoGone', 'badLogoId', 'logoNameMissing', 'logoFileType', 'logoFileSize', 'logoFormat', 'logoTaken', 'tooManyLogos', 'badHeart', 'badMono']; assert.deepEqual(Object.fromEntries(logoKeys.map((key) => [key, MSG[key]])), { logoGone: 'Dieses Logo ist nicht (mehr) verfügbar.', badLogoId: 'Ungültige Logo-ID.', logoNameMissing: 'Bitte gib einen Namen an.', logoFileType: 'Das Logo muss eine PNG-Datei sein.', logoFileSize: 'Das Logo ist größer als 1 MB.', logoFormat: 'Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).', logoTaken: 'Diese Datei wird schon für ein Logo verwendet.', tooManyLogos: 'Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.', badHeart: 'Ungültige Angabe zum Herz.', badMono: 'Ungültige Angabe zu „einfarbig“.', }); // die bisherigen Texte bleiben unverändert (Auszug) assert.equal(MSG.cardGone, 'Diese Karte ist nicht (mehr) verfügbar.'); assert.equal(MSG.forbidden, 'Dazu hast du keine Berechtigung.'); assert.equal(MSG.tooManyReports, 'Zu viele Meldungen – bitte später nochmal.'); assert.equal(MSG.tooManyPublications, TOO_MANY_CARDS); assert.equal(MSG.unavailable, 'Der Dienst ist gerade nicht erreichbar.'); assert.equal(MSG.internal, 'Interner Fehler.'); }); test('codeFor und HttpError: Code = Schlüssel des Textes, sonst badRequest (400) bzw. error', () => { assert.equal(codeFor(404, MSG.cardGone), 'cardGone'); assert.equal(codeFor(404, MSG.logoGone), 'logoGone'); assert.equal(codeFor(429, MSG.tooManyLogos), 'tooManyLogos'); assert.equal(codeFor(500, MSG.internal), 'internal'); assert.equal(codeFor(400, 'Ungültige Bild-ID.'), 'badRequest', 'dynamischer Prüftext, Status 400'); assert.equal(codeFor(400, MSG.badRequest), 'badRequest'); assert.equal(codeFor(403, 'irgendein anderer Text'), 'error', 'dynamischer Text, anderer Status'); assert.equal(codeFor(409, undefined), 'error'); const withHeaders = new HttpError(429, MSG.tooManyReports, { 'Retry-After': '5' }); assert.ok(withHeaders instanceof Error); assert.deepEqual([withHeaders.status, withHeaders.message, withHeaders.code, withHeaders.headers], [429, MSG.tooManyReports, 'tooManyReports', { 'Retry-After': '5' }]); assert.deepEqual(new HttpError(404, MSG.notFound).headers, {}); assert.equal(new HttpError(400, 'Der Titel ist ungültig.').code, 'badRequest'); assert.equal(new HttpError(403, 'Der Titel ist ungültig.').code, 'error'); assert.equal(new HttpError(409, 'x', {}, 'eigener').code, 'eigener', 'ein ausdrücklicher Code gewinnt'); }); test('Fehlerantworten: Anfrage-Fehler (404, 413, 415, 400, 401) haben {error, code}', async () => { const { call, post, report, fake } = await setup(); seedCard(fake, { id: 'card-9' }); const big = `{"id":"x","reason":"spam"}${' '.repeat(9000)}`; expectError(await call('GET', '/api/gibts-nicht'), 404, 'notFound', 'unbekannte Route'); expectError(await call('GET', '/api/melden'), 404, 'notFound', 'falsche Methode'); expectError(await call('POST', '/api/melden', { raw: '{}', contentType: 'text/plain' }), 415, 'jsonOnly'); expectError(await call('POST', '/api/melden', { raw: big }), 413, 'tooLarge'); expectError(await call('POST', '/api/melden', { raw: '{kaputt' }), 400, 'badRequest', 'kaputtes JSON'); expectError(await call('POST', '/api/melden', { raw: '[]' }), 400, 'badRequest', 'Array statt Objekt'); expectError(await report('../x', '203.0.1.9'), 400, 'badCardId'); expectError(await report('card-9', '203.0.1.9', 'gibt-es-nicht'), 400, 'badReason'); expectError(await post('/api/veroeffentlichen', publishBody()), 401, 'unauthorized', 'veröffentlichen ohne JWT'); expectError(await post('/api/zurueckziehen', { id: 'card-9' }, 'xxx.yyy.zzz'), 401, 'unauthorized', 'zurückziehen mit falschem JWT'); expectError(await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: true }), 401, 'unauthorized', 'Admin ohne JWT'); }); test('Fehlerantworten: /api/melden mit unbekannter ID ist genau {error, code} aus der Spec', async () => { const { report } = await setup(); const res = await report('gibts-nicht', '203.0.1.9'); assert.equal(res.status, 404); assert.deepEqual(res.json, { error: 'Diese Karte ist nicht (mehr) verfügbar.', code: 'cardGone' }); }); test('Fehlerantworten: Logik-Fehler der Karten-Endpunkte tragen den MSG-Schlüssel, dynamische Prüftexte badRequest', async () => { const { post, fake } = await setup(); seedCard(fake, { id: 'card-9', owner: OTHER }); seedUploads(fake); expectError(await post('/api/zurueckziehen', { id: 'nope' }, JWT_ME), 404, 'cardGone'); expectError(await post('/api/zurueckziehen', { id: 'card-9' }, JWT_ME), 403, 'forbidden'); expectError(await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: true }, JWT_ME), 403, 'adminOnly'); expectError(await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: 'ja' }, JWT_ADMIN), 400, 'badVisibility'); expectError(await post('/api/admin/sichtbarkeit', { id: '../x', sichtbar: true }, JWT_ADMIN), 400, 'badCardId'); expectError(await post('/api/veroeffentlichen', publishBody({ sourceCardId: '../x' }), JWT_ME), 400, 'badCardId'); // Prüftexte mit Zahlen oder Feldnamen haben keinen festen Schlüssel: code badRequest, Text bleibt der genaue for (const [overrides, text] of [ [{ title: '' }, 'Bitte gib einen Titel an.'], [{ title: 'x'.repeat(61) }, 'Der Titel ist zu lang (höchstens 60 Zeichen).'], [{ imageFileId: '../x' }, 'Ungültige Bild-ID.'], [{ tags: ['a', 'b', 'c', 'd', 'e', 'f'] }, 'Bitte gib höchstens 5 Tags an.'], [{ previewFileId: 'img1' }, 'Bild und Vorschau müssen verschiedene Dateien sein.'], ]) { const res = await post('/api/veroeffentlichen', publishBody(overrides), JWT_ME); assert.equal(res.status, 400, text); assert.deepEqual(res.json, { error: text, code: 'badRequest' }, text); } }); test('Fehlerantworten: Datei-, Besitz- und Limit-Fehler beim Veröffentlichen tragen ihren Schlüssel', async () => { const { post, report, fake } = await setup(); const attempt = (n, { image = {}, preview = {}, card = `card${n}` } = {}, overrides = {}) => { fake.addFile({ id: `img${n}`, owner: ME, ...image }); fake.addFile({ id: `prev${n}`, owner: ME, mimeType: 'image/webp', ...preview }); if (card) fake.addCard(card, ME); return post('/api/veroeffentlichen', publishBody({ imageFileId: `img${n}`, previewFileId: `prev${n}`, sourceCardId: `card${n}`, ...overrides }), JWT_ME); }; expectError(await attempt(1, { image: { owner: OTHER } }), 403, 'foreignFile'); expectError(await attempt(2, { image: { bucket: 'card-images' } }), 403, 'foreignFile', 'falscher Bucket'); expectError(await attempt(3, {}, { previewFileId: 'ghost' }), 400, 'fileMissing'); expectError(await attempt(4, { image: { mimeType: 'image/jpeg' } }), 400, 'fileType'); expectError(await attempt(5, { image: { size: MIB5 + 1 } }), 400, 'fileSize'); expectError(await attempt(6, { image: { content: pngHeader(100, 100) } }), 400, 'imageFormat'); expectError(await attempt(7, { image: { permissions: ['delete("user:user-me")'] } }), 403, 'filesTaken'); expectError(await attempt(8, { card: null }), 403, 'notYourCard'); assert.equal((await attempt(9)).status, 201); expectError(await attempt(10, {}, { sourceCardId: 'card9' }), 409, 'alreadyPublished'); seedPublications(fake, ME, 19); // mit der Karte oben sind es 20 expectError(await attempt(11), 429, 'tooManyPublications'); // Rate-Limit der Meldungen (die Karte `card9` ist sichtbar veröffentlicht) const id = galleryRowId(ME, 'card9'); for (let i = 0; i < 10; i += 1) assert.equal((await report(id, '203.0.50.9')).status, 200); const limited = await report(id, '203.0.50.9'); expectError(limited, 429, 'tooManyReports'); assert.equal(limited.headers['retry-after'], '3600'); }); test('Fehlerantworten: Appwrite-Fehler haben code unavailable (502) bzw. internal (500), unerwartete Fehler internal', async () => { const { report, fake } = await setup(); seedCard(fake); const isRowRead = (c) => c.method === 'GET' && c.path.includes('/tables/gallery/rows/'); fake.failNext(isRowRead, { status: 500 }); expectError(await report('card-1', '203.0.1.9'), 502, 'unavailable', '5xx'); fake.failNext(isRowRead, { status: 429, type: 'general_rate_limit_exceeded' }); expectError(await report('card-1', '203.0.1.9'), 502, 'unavailable', '429'); fake.failNext(isRowRead, { status: 404, type: 'table_not_found' }); expectError(await report('card-1', '203.0.1.9'), 500, 'internal', 'fehlende Tabelle'); fake.failNext(isRowRead, { status: 401, type: 'general_unauthorized_scope' }); expectError(await report('card-1', '203.0.1.9'), 500, 'internal', 'falscher Key'); const broken = { accountFromJwt: async () => { throw new Error('kaputt GEHEIM'); }, }; const app = createApp({ appwrite: broken, salt: SALT, log: () => {} }); 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 } }); expectError(res, 500, 'internal', 'unerwarteter Fehler'); assert.ok(!res.text.includes('GEHEIM')); });