'use strict'; // Tests für deploy/api/logos.js (Community-Logos): 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 } = require('./appwrite.js'); const { startFakeAppwrite, pngHeader, webpHeader } = require('./fake-appwrite.js'); const { createApp } = require('./app.js'); const { createLogoRoutes, logoLikeId, logoReportId, logoRowId, validateLogoUpload } = require('./logos.js'); const { MSG, isId, visibleRowPermissions, hiddenRowPermissions, likeRowPermissions, visibleFilePermissions, hiddenFilePermissions, reporterHash, } = require('./common.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 MIB = 1024 * 1024; const LOGO_BUCKET = 'logo-images'; /** Rechte einer frisch hochgeladenen Logo-Datei von ME. */ const UPLOAD_PERMISSIONS = ['read("any")', 'delete("user:user-me")']; const HIDDEN_PERMISSIONS = ['read("user:user-me")', 'read("label:admin")']; // ---- 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 upload = (body, jwt = JWT_ME) => post('/api/logos/hochladen', body, jwt); const heart = (id, herz, jwt = JWT_ME) => post('/api/logos/herz', { id, herz }, jwt); const report = (id, ip, reason = 'spam') => post('/api/logos/melden', { id, reason }, null, ip ? { 'X-Real-Ip': ip } : {}); const remove = (id, jwt = JWT_ME) => post('/api/logos/loeschen', { id }, jwt); const setVisible = (id, sichtbar, jwt = JWT_ADMIN) => post('/api/admin/logo-sichtbarkeit', { id, sichtbar }, jwt); return { fake, app, logs, clock, port, call, post, upload, heart, report, remove, setVisible }; } 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) 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(); }); } /** Erwartet Status und genau {error: , code: }. */ function expectError(res, status, code, label = code) { assert.equal(res.status, status, `${label}: Status`); assert.deepEqual(res.json, { error: MSG[code], code }, label); } function uploadBody(overrides = {}) { return { name: 'Mein Logo', displayName: 'Kenso', mono: false, fileId: 'logo1', ...overrides }; } /** Frisch hochgeladene Logo-Datei: PNG im Bucket logo-images mit read("any") und delete(owner). */ function logoFile(fake, id, owner = ME, { width = 200, height = 100, ...rest } = {}) { fake.addFile({ id, bucket: LOGO_BUCKET, owner, content: pngHeader(width, height), ...rest }); } /** Zeilen-ID des Logos, das `owner` aus der Datei `fileId` anlegt (so vergibt sie die API). */ const rowOf = (fileId, owner = ME) => logoRowId(owner, fileId); /** Datei-ID, die `seedLogo` einer Logo-Zeile gibt: nie gleich der Zeilen-ID (nur Altlogos haben Zeilen-ID = Datei-ID). */ const fileOf = (id) => `file-of-${id}`; /** * Veröffentlichtes Logo: Zeile und Datei mit genau den Rechten, die die API vergibt. Die Datei hat die ID `fileId` * (Standard `fileOf(id)`); ein Altlogo mit Zeilen-ID = Datei-ID legt der Test mit `fileId: id` an. */ function seedLogo(fake, { id = 'logo-1', fileId = fileOf(id), owner = ME, hidden = false, reportCount = 0, likeCount = 0, name = 'Logo', mono = false } = {}) { fake.addFile({ id: fileId, bucket: LOGO_BUCKET, owner, permissions: hidden ? hiddenFilePermissions(owner) : visibleFilePermissions(), content: pngHeader(200, 100), }); fake.addRow( 'logos', id, { name, displayName: '', fileId, ownerId: owner, mono, likeCount, hidden, reportCount }, hidden ? hiddenRowPermissions(owner) : visibleRowPermissions(), ); } /** `count` Logo-Zeilen eines Kontos ohne Dateien (für das Limit); jede zweite ist ausgeblendet. */ function seedLogoRows(fake, owner, count) { for (let i = 0; i < count; i += 1) { const id = `${owner}-l${i}`; fake.addRow('logos', id, { name: `L${i}`, displayName: '', fileId: fileOf(id), ownerId: owner, mono: false, likeCount: 0, hidden: i % 2 === 1, reportCount: 0 }, visibleRowPermissions()); } } function seedLikes(fake, logoId, userIds) { for (const userId of userIds) fake.addRow('logo_likes', logoLikeId(logoId, userId), { logoId, userId }, likeRowPermissions(userId)); } function seedReports(fake, logoId, hashes) { for (const hash of hashes) fake.addRow('logo_reports', logoReportId(logoId, hash), { logoId, reason: 'spam', reporterHash: hash }, []); } const sha32 = (text) => crypto.createHash('sha256').update(text).digest('hex').slice(0, 32); const nonAccountCalls = (fake) => fake.calls.filter((c) => c.path !== '/v1/account'); const filePuts = (fake, id) => fake.calls.filter((c) => c.method === 'PUT' && c.path.endsWith(`/files/${id}`)); const fileReads = (fake, id) => fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith(`/files/${id}/view`)); /** Wartet (höchstens etwa 2 s), bis `condition()` stimmt: so fängt ein Test den Moment ab, in dem ein Aufruf bei Appwrite noch läuft. */ async function until(condition) { for (let i = 0; i < 400 && !condition(); i += 1) await new Promise((resolve) => setTimeout(resolve, 5)); assert.ok(condition(), 'der erwartete Zustand wurde nicht erreicht'); } /** Die Aufrufe zu einem Logo in der Reihenfolge, in der sie ankamen (Datei abrufen, lesen, festlegen, löschen; Zeile anlegen, löschen). */ function logoSteps(fake, fileId) { const rowId = rowOf(fileId); const steps = []; for (const c of fake.calls) { if (c.path.endsWith(`/files/${fileId}/view`)) steps.push('Anfang lesen'); else if (c.path.endsWith(`/files/${fileId}`)) steps.push({ GET: 'Datei abrufen', PUT: 'Rechte festlegen', DELETE: 'Datei löschen' }[c.method]); else if (c.method === 'POST' && c.path.endsWith('/tables/logos/rows')) steps.push('Zeile anlegen'); else if (c.method === 'DELETE' && c.path.endsWith(`/tables/logos/rows/${rowId}`)) steps.push('Zeile löschen'); } return steps; } /** Die Logzeilen ohne Präfix und Zeitstempel, die ein Logo betreffen. */ const logEvents = (logs, id) => logs.map((line) => line.replace(/^\[aircard-api\] \S+ /, '')).filter((line) => line.includes(id)); // ---- Reine Helfer ------------------------------------------------------------------------------ test('logoLikeId und logoReportId: erste 32 Hex von sha256, Logo-Meldungen mit dem Präfix logo|', () => { assert.equal(logoLikeId('l1', 'u1'), sha32('l1|u1')); assert.equal(logoReportId('l1', 'h1'), sha32('logo|l1|h1')); assert.match(logoLikeId('l1', 'u1'), /^[0-9a-f]{32}$/); assert.match(logoReportId('l1', 'h1'), /^[0-9a-f]{32}$/); assert.notEqual(logoLikeId('l1', 'u1'), logoLikeId('l1', 'u2')); assert.notEqual(logoLikeId('l1', 'u1'), logoLikeId('l2', 'u1')); assert.notEqual(logoReportId('l1', 'h1'), logoReportId('l1', 'h2')); }); test('logoRowId: erste 32 Hex von sha256("|"), eine gültige ID, nie die Datei-ID, je Konto und Datei verschieden', () => { assert.equal(logoRowId('u1', 'f1'), sha32('u1|f1')); assert.match(logoRowId('u1', 'f1'), /^[0-9a-f]{32}$/); assert.ok(isId(logoRowId('u1', 'f1'))); assert.notEqual(logoRowId('u1', 'f1'), 'f1'); assert.equal(logoRowId('u1', 'f1'), logoRowId('u1', 'f1'), 'dieselbe Datei desselben Kontos: dieselbe ID'); assert.notEqual(logoRowId('u1', 'f1'), logoRowId('u2', 'f1'), 'dieselbe Datei-ID bei einem anderen Konto: andere Zeilen-ID'); assert.notEqual(logoRowId('u1', 'f1'), logoRowId('u1', 'f2')); }); test('likeRowPermissions: genau read("user:"), unzulässige Nutzer-IDs werden abgelehnt', () => { assert.deepEqual(likeRowPermissions('abc'), ['read("user:abc")']); for (const bad of ['a")', 'x","read("any', '', '../x', 5, null]) assert.throws(() => likeRowPermissions(bad)); }); test('validateLogoUpload: normalisiert Name und Anzeigename, mono fehlt = false', () => { assert.deepEqual(validateLogoUpload({ name: ' Mein Logo ', displayName: ' Kenso ', mono: true, fileId: 'f1' }), { ok: true, data: { name: 'Mein Logo', displayName: 'Kenso', mono: true, fileId: 'f1' }, }); assert.deepEqual(validateLogoUpload({ name: 'A', fileId: 'f1' }), { ok: true, data: { name: 'A', displayName: '', mono: false, fileId: 'f1' } }); assert.equal(validateLogoUpload({ name: 'A', displayName: null, mono: false, fileId: 'f1' }).data.displayName, ''); assert.equal(validateLogoUpload({ name: 'A', displayName: ' ', fileId: 'f1' }).data.displayName, ''); // unbekannte Felder werden nicht übernommen assert.deepEqual(Object.keys(validateLogoUpload({ name: 'A', fileId: 'f1', ownerId: 'x', hidden: true, likeCount: 99 }).data).sort(), ['displayName', 'fileId', 'mono', 'name']); }); test('validateLogoUpload: Längen an den Grenzen (Name 40, Anzeigename 30) und Fehlertexte', () => { assert.equal(validateLogoUpload({ name: 'x'.repeat(40), displayName: 'y'.repeat(30), fileId: 'f1' }).ok, true); assert.deepEqual(validateLogoUpload({ name: 'x'.repeat(41), fileId: 'f1' }), { ok: false, message: 'Der Name ist zu lang (höchstens 40 Zeichen).' }); assert.deepEqual(validateLogoUpload({ name: 'x', displayName: 'y'.repeat(31), fileId: 'f1' }), { ok: false, message: 'Der Anzeigename ist zu lang (höchstens 30 Zeichen).', }); assert.deepEqual(validateLogoUpload({ name: '', fileId: 'f1' }), { ok: false, message: MSG.logoNameMissing }); assert.deepEqual(validateLogoUpload({ fileId: 'f1' }), { ok: false, message: MSG.logoNameMissing }); assert.deepEqual(validateLogoUpload({ name: 'x', mono: 'ja', fileId: 'f1' }), { ok: false, message: MSG.badMono }); assert.deepEqual(validateLogoUpload({ name: 'x', fileId: '../x' }), { ok: false, message: 'Ungültige Datei-ID.' }); for (const body of [null, [], 'text', 7, undefined]) { assert.deepEqual(validateLogoUpload(body), { ok: false, message: MSG.badRequest }, JSON.stringify(body)); } }); test('createLogoRoutes liefert die fünf Routen mit Name, json und run', () => { const routes = createLogoRoutes({ appwrite: {}, salt: SALT, say: () => {}, withLock: (key, fn) => fn(), rateLimit: () => ({ allowed: true }), requireUser: async () => ({ id: ME, isAdmin: false }), }); assert.deepEqual( routes.map(([key]) => key), ['POST /api/logos/hochladen', 'POST /api/logos/herz', 'POST /api/logos/melden', 'POST /api/logos/loeschen', 'POST /api/admin/logo-sichtbarkeit'], ); for (const [, route] of routes) { assert.equal(route.json, true); assert.equal(typeof route.run, 'function'); assert.match(route.name, /^[a-z/-]+$/); } }); // ---- Hochladen -------------------------------------------------------------------------------------- test('Logo hochladen: legt eine sichtbare Zeile an (Zeilen-ID = Hash aus Konto und Datei, nicht die Datei-ID), die Datei trägt danach genau read("any")', async () => { const { upload, fake, logs } = await setup(); logoFile(fake, 'logo1'); const res = await upload(uploadBody({ name: ' Mein Logo ', mono: true })); const rowId = rowOf('logo1'); assert.equal(res.status, 201); assert.deepEqual(res.json, { id: rowId }); assert.match(rowId, /^[0-9a-f]{32}$/); assert.equal(fake.row('logos', 'logo1'), null, 'unter der Datei-ID gibt es keine Zeile'); const row = fake.row('logos', rowId); assert.deepEqual(row.$permissions, ['read("any")']); assert.equal(row.name, 'Mein Logo'); assert.equal(row.displayName, 'Kenso'); assert.equal(row.fileId, 'logo1'); assert.equal(row.ownerId, ME); assert.equal(row.mono, true); assert.equal(row.likeCount, 0); assert.equal(row.hidden, false); assert.equal(row.reportCount, 0); assert.deepEqual(fake.file('logo1').$permissions, ['read("any")'], 'kein delete(owner) mehr: das Bild lässt sich nicht tauschen'); // der Server-Key hängt an jedem Datenaufruf, das JWT nur am Konto-Aufruf assert.equal(fake.calls.find((c) => c.path === '/v1/account').headers['x-appwrite-jwt'], JWT_ME); for (const c of nonAccountCalls(fake)) { assert.equal(c.headers['x-appwrite-key'], fake.key); assert.equal(c.headers['x-appwrite-jwt'], undefined); } assert.equal(logs.filter((line) => line.endsWith(`logo ${rowId} uploaded`)).length, 1); }); test('Logo hochladen: Anzeigename ist optional, mono fehlt = false', async () => { const { upload, fake } = await setup(); logoFile(fake, 'logo1'); const res = await upload({ name: 'Nur Name', fileId: 'logo1' }); assert.equal(res.status, 201); const row = fake.row('logos', rowOf('logo1')); assert.equal(row.displayName, ''); assert.equal(row.mono, false); }); test('Logo hochladen ohne oder mit ungültigem JWT: 401, nichts angelegt, die Datei bleibt unberührt', async () => { const { upload, fake } = await setup(); logoFile(fake, 'logo1'); expectError(await upload(uploadBody(), null), 401, 'unauthorized'); expectError(await upload(uploadBody(), 'xxx.yyy.zzz'), 401, 'unauthorized'); expectError(await upload(uploadBody(), 'zwei teile'), 401, 'unauthorized'); expectError(await upload({}, ''), 401, 'unauthorized'); assert.equal(fake.rows('logos').length, 0); assert.deepEqual(fake.file('logo1').$permissions, UPLOAD_PERMISSIONS); assert.equal(fake.calls.filter((c) => c.path.startsWith('/v1/storage/')).length, 0); assert.equal(fake.calls.filter((c) => c.path === '/v1/account').length, 1, 'fehlendes oder kaputtes JWT erreicht Appwrite gar nicht erst'); }); test('Logo hochladen mit fremder, falscher oder gesperrter Datei: 403, keine Zeile, Datei unverändert, Inhalt wird nicht gelesen', async () => { const { upload, fake } = await setup(); logoFile(fake, 'fremd', OTHER); logoFile(fake, 'fremd-veroeffentlicht', OTHER, { permissions: visibleFilePermissions() }); logoFile(fake, 'fremd-ausgeblendet', OTHER, { permissions: hiddenFilePermissions(OTHER) }); logoFile(fake, 'nur-lesen', ME, { permissions: ['read("any")', 'read("user:user-me")'] }); logoFile(fake, 'ohne-lesen', ME, { permissions: ['delete("user:user-me")'] }); fake.addFile({ id: 'aus-galerie', owner: ME, content: pngHeader(100, 100) }); // Bucket gallery-images fake.addFile({ id: 'aus-karten', bucket: 'card-images', owner: ME, content: pngHeader(100, 100) }); const cases = [ ['fremd', 'foreignFile'], ['fremd-veroeffentlicht', 'foreignFile'], ['fremd-ausgeblendet', 'foreignFile'], ['nur-lesen', 'foreignFile'], ['aus-galerie', 'foreignFile'], ['aus-karten', 'foreignFile'], ['ohne-lesen', 'filesTaken'], ]; const before = Object.fromEntries(cases.map(([id]) => [id, [...fake.file(id).$permissions]])); for (const [id, code] of cases) expectError(await upload(uploadBody({ fileId: id })), 403, code, id); assert.equal(fake.rows('logos').length, 0); for (const [id] of cases) assert.deepEqual(fake.file(id).$permissions, before[id], `${id} unverändert`); assert.equal(fake.calls.filter((c) => c.method === 'PUT').length, 0); assert.equal(fake.calls.filter((c) => c.path.endsWith('/view')).length, 0, 'fremde oder gesperrte Dateien werden nicht gelesen (kein Maß-Orakel)'); }); test('Logo hochladen mit fehlender Datei: 400 "Bild nicht gefunden."', async () => { const { upload, fake } = await setup(); expectError(await upload(uploadBody({ fileId: 'gibts-nicht' })), 400, 'fileMissing'); assert.equal(fake.rows('logos').length, 0); }); test('Logo hochladen: nur PNG bis 1 MiB, sonst 400 logoFileType bzw. logoFileSize', async () => { const { upload, fake } = await setup(); const attempt = (opts) => { logoFile(fake, 'logo1', ME, opts); return upload(uploadBody()); }; for (const mimeType of ['image/webp', 'image/jpeg', 'image/svg+xml', 'application/pdf']) { expectError(await attempt({ mimeType }), 400, 'logoFileType', mimeType); } expectError(await attempt({ size: MIB + 1 }), 400, 'logoFileSize', '1 MiB + 1'); expectError(await attempt({ size: 5 * MIB }), 400, 'logoFileSize', '5 MiB'); expectError(await attempt({ size: 'groß' }), 400, 'logoFileSize', 'keine Zahl'); assert.equal(fake.rows('logos').length, 0); assert.deepEqual(fake.file('logo1').$permissions, UPLOAD_PERMISSIONS, 'Datei bleibt unangetastet'); assert.equal((await attempt({ size: MIB })).status, 201, 'genau 1 MiB geht'); }); test('Logo hochladen: Maße von 8 × 8 bis 720 × 250 gehen, alles andere ist 400 logoFormat (Datei bleibt unverändert)', async () => { const { upload, fake } = await setup(); for (const [width, height] of [[721, 100], [7, 7], [720, 251], [8, 7], [7, 8], [0, 0], [1, 1], [60000, 60000], [721, 251]]) { logoFile(fake, 'logo1', ME, { width, height }); expectError(await upload(uploadBody()), 400, 'logoFormat', `${width} × ${height}`); assert.deepEqual(fake.file('logo1').$permissions, UPLOAD_PERMISSIONS); } assert.equal(fake.rows('logos').length, 0); let n = 0; for (const [width, height] of [[8, 8], [720, 250], [720, 8], [8, 250], [200, 100]]) { n += 1; logoFile(fake, `ok${n}`, ME, { width, height }); assert.equal((await upload(uploadBody({ fileId: `ok${n}` }))).status, 201, `${width} × ${height}`); } }); test('Logo hochladen: Inhalt ohne PNG-Kopf ist 400 logoFormat (kaputt, abgeschnitten, WebP-Inhalt mit PNG-Typ)', async () => { const { upload, fake } = await setup(); for (const [label, content] of [ ['leer', Buffer.alloc(0)], ['Nullen', Buffer.alloc(100)], ['Text', Buffer.from('')], ['PNG abgeschnitten', pngHeader(200, 100).subarray(0, 20)], ['WebP-Inhalt, Typ PNG', webpHeader('VP8L', 200, 100)], ]) { logoFile(fake, 'logo1', ME, { content }); expectError(await upload(uploadBody()), 400, 'logoFormat', label); } assert.equal(fake.rows('logos').length, 0); assert.deepEqual(fake.file('logo1').$permissions, UPLOAD_PERMISSIONS); }); test('Logo hochladen: Datei verschwindet vor dem Lesen der Maße ergibt 400 "Bild nicht gefunden.", Appwrite-Fehler dabei 502', async () => { const { upload, fake } = await setup(); logoFile(fake, 'logo1'); fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/files/logo1/view'), { status: 404, type: 'storage_file_not_found' }); expectError(await upload(uploadBody()), 400, 'fileMissing'); fake.failNext((c) => c.path.endsWith('/files/logo1/view'), { status: 500 }); expectError(await upload(uploadBody()), 502, 'unavailable'); assert.equal(fake.rows('logos').length, 0); assert.equal((await upload(uploadBody())).status, 201); }); test('Logo hochladen: dieselbe Datei zweimal ergibt 409 logoTaken, Datei und Zeile bleiben wie sie sind', async () => { const { upload, fake } = await setup(); logoFile(fake, 'logo1'); assert.equal((await upload(uploadBody())).status, 201); // die Datei trägt jetzt nur noch read("any"): trotzdem 409 und nicht 403 expectError(await upload(uploadBody({ name: 'Anderer Name' })), 409, 'logoTaken'); assert.deepEqual(fake.file('logo1').$permissions, ['read("any")']); assert.equal(fake.rows('logos').length, 1); assert.equal(fake.row('logos', rowOf('logo1')).name, 'Mein Logo'); // dieselbe Datei-ID als frisch hochgeladene Datei (z. B. nach Löschen und Neuanlegen): 409, die Rechte der Datei bleiben unangetastet fake.addFile({ id: 'logo1', bucket: LOGO_BUCKET, owner: ME, permissions: [...UPLOAD_PERMISSIONS, 'update("user:user-me")'], content: pngHeader(50, 50) }); expectError(await upload(uploadBody()), 409, 'logoTaken'); assert.deepEqual(fake.file('logo1').$permissions, [...UPLOAD_PERMISSIONS, 'update("user:user-me")']); assert.equal(filePuts(fake, 'logo1').length, 1, 'nur der erste, erfolgreiche Upload hat Rechte gesetzt'); }); test('Logo hochladen: ein anderes Konto bekommt eine schon verwendete Datei nicht (403), die Zeilen-ID allein hält nicht zwei Konten auseinander', async () => { const { upload, fake } = await setup(); logoFile(fake, 'logo1'); assert.equal((await upload(uploadBody())).status, 201); // je Konto eine eigene Zeilen-ID: dass eine Datei nie zwei Logos trägt, sichert der Besitz (nach dem Anlegen kein delete(owner) mehr) expectError(await upload(uploadBody(), JWT_OTHER), 403, 'foreignFile'); assert.equal(fake.rows('logos').length, 1); assert.equal(fake.row('logos', rowOf('logo1', OTHER)), null); }); test('Logo hochladen: steht unter der Zeilen-ID schon eine Zeile, die nicht in der Liste des Kontos ist (Rest): 409 logoTaken, Datei unangetastet', async () => { const { upload, fake } = await setup(); fake.addRow('logos', rowOf('anker'), { name: 'Alt', displayName: '', fileId: 'andere-datei', ownerId: OTHER, mono: false, likeCount: 0, hidden: false, reportCount: 0 }, visibleRowPermissions()); logoFile(fake, 'anker'); expectError(await upload(uploadBody({ fileId: 'anker' })), 409, 'logoTaken'); assert.deepEqual(fake.file('anker').$permissions, UPLOAD_PERMISSIONS); assert.equal(fake.rows('logos').length, 1); assert.equal(filePuts(fake, 'anker').length, 0, 'ein 409 fasst die Dateirechte nie an'); }); test('Logo hochladen: die ID eines gelöschten Logos lässt sich nicht mit einer Datei gleicher ID neu belegen', async () => { const { upload, remove, fake } = await setup(); logoFile(fake, 'logo1'); const { id: victimRowId } = (await upload(uploadBody())).json; assert.equal((await remove(victimRowId)).status, 200); assert.equal(fake.row('logos', victimRowId), null); // die Zeilen-ID ist öffentlich (Liste, gespeicherte Karten): jemand legt eine Datei mit genau dieser ID an und lädt sie hoch logoFile(fake, victimRowId, OTHER); const res = await upload(uploadBody({ fileId: victimRowId }), JWT_OTHER); assert.equal(res.status, 201); assert.notEqual(res.json.id, victimRowId, 'die alte Zeilen-ID bleibt unbelegt'); assert.equal(fake.row('logos', victimRowId), null); assert.equal(fake.row('logos', res.json.id).fileId, victimRowId); assert.equal(fake.row('logos', res.json.id).ownerId, OTHER); }); test('Logo hochladen: höchstens 30 Logos je Konto (ausgeblendete zählen mit), das 31. ergibt 429 tooManyLogos', async () => { const { upload, fake } = await setup(); seedLogoRows(fake, ME, 30); logoFile(fake, 'logo1'); expectError(await upload(uploadBody()), 429, 'tooManyLogos'); assert.equal(fake.rows('logos').length, 30); assert.deepEqual(fake.file('logo1').$permissions, UPLOAD_PERMISSIONS, 'Datei unangetastet'); // Logos anderer Konten zählen nicht, und jedes Konto hat sein eigenes Limit seedLogoRows(fake, OTHER, 40); expectError(await upload(uploadBody()), 429, 'tooManyLogos'); logoFile(fake, 'logo-other', OTHER); expectError(await upload(uploadBody({ fileId: 'logo-other' }), JWT_OTHER), 429, 'tooManyLogos'); }); test('Logo hochladen: mit 29 Logos geht das 30., nach dem Löschen eines Logos wieder eins', async () => { const { upload, remove, fake } = await setup(); seedLogoRows(fake, ME, 29); logoFile(fake, 'logo1'); assert.equal((await upload(uploadBody())).status, 201); logoFile(fake, 'logo2'); expectError(await upload(uploadBody({ fileId: 'logo2' })), 429, 'tooManyLogos'); assert.equal((await remove('user-me-l0')).status, 200); assert.equal((await upload(uploadBody({ fileId: 'logo2' }))).status, 201); }); test('Logo hochladen: das Limit liest nur die Zeilen des Kontos (ein Aufruf mit ownerId und limit 30)', async () => { const { upload, fake } = await setup(); seedLogoRows(fake, ME, 3); seedLogoRows(fake, OTHER, 40); logoFile(fake, 'logo1'); assert.equal((await upload(uploadBody())).status, 201); const lists = fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith('/tables/logos/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":[30]')); }); test('Logo hochladen: scheitert das Festlegen der Dateirechte, wird die Zeile zurückgenommen und die Datei bekommt delete(owner) zurück', async () => { const { upload, fake } = await setup(); logoFile(fake, 'logo1'); fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/logo1'), { status: 500 }); expectError(await upload(uploadBody()), 502, 'unavailable'); assert.equal(fake.rows('logos').length, 0, 'keine halbfertige Zeile, die eine Wiederholung mit 409 blockiert'); assert.deepEqual(fake.file('logo1').$permissions, UPLOAD_PERMISSIONS); const puts = filePuts(fake, 'logo1'); assert.equal(puts.length, 2, 'Festlegen (scheitert) und Rückgabe von delete(owner)'); assert.deepEqual(puts[1].body, { permissions: UPLOAD_PERMISSIONS }); // Datei verschwindet zwischen Prüfung und Festlegen: 400 und ebenfalls zurückgenommen fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/logo1'), { status: 404, type: 'storage_file_not_found' }); expectError(await upload(uploadBody()), 400, 'fileMissing'); assert.equal(fake.rows('logos').length, 0); // danach geht ein neuer Versuch assert.equal((await upload(uploadBody())).status, 201); assert.deepEqual(fake.file('logo1').$permissions, ['read("any")']); }); test('Logo hochladen: scheitert auch das Zurücknehmen, bleibt die Zeile stehen, die Datei wird nicht angefasst und `rollback failed for logo ` steht im Log', async () => { const { upload, fake, logs } = await setup(); logoFile(fake, 'logo1'); fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/logo1'), { status: 500 }); fake.failNext((c) => c.method === 'DELETE' && c.path.includes('/tables/logos/rows/'), { status: 500 }); expectError(await upload(uploadBody()), 502, 'unavailable'); assert.equal(fake.rows('logos').length, 1, 'die Zeile blieb stehen (Zurücknehmen scheiterte)'); assert.equal(filePuts(fake, 'logo1').length, 1, 'ohne erfolgreiches Zurücknehmen bekommt die Datei delete(owner) nicht zurück'); 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 logo ${rowOf('logo1')} \\(file logo1\\)$`)); assert.ok(!logs.join('\n').includes(JWT_ME)); }); test('Logo hochladen: gelingt das Zurücknehmen, steht keine Rollback-Fehlerzeile im Log', async () => { const { upload, fake, logs } = await setup(); logoFile(fake, 'logo1'); fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/logo1'), { status: 500 }); assert.equal((await upload(uploadBody())).status, 502); assert.equal(logs.filter((line) => line.includes('rollback failed')).length, 0); }); // ---- Hochladen: der Inhalt wird nach dem Festlegen der Rechte noch einmal geprüft -------------------------- test('Logo hochladen: der Dateianfang wird vor und nach dem Festlegen der Rechte gelesen (abrufen, lesen, anlegen, festlegen, lesen)', async () => { const { upload, fake } = await setup(); logoFile(fake, 'logo1'); assert.equal((await upload(uploadBody())).status, 201); assert.deepEqual(logoSteps(fake, 'logo1'), ['Datei abrufen', 'Anfang lesen', 'Zeile anlegen', 'Rechte festlegen', 'Anfang lesen']); for (const c of fake.calls.filter((x) => x.path.endsWith('/files/logo1/view'))) assert.equal(c.headers['x-appwrite-key'], fake.key); }); test('Logo hochladen: ein nach dem Festlegen anderer, aber weiterhin gültiger Inhalt (720 × 250) wird nicht beanstandet', async () => { const { upload, fake } = await setup(); logoFile(fake, 'logo1'); fake.swapContentAfterPermissions('logo1', pngHeader(720, 250)); assert.equal((await upload(uploadBody())).status, 201); assert.ok(fake.row('logos', rowOf('logo1'))); assert.deepEqual(fake.file('logo1').$permissions, ['read("any")']); }); test('Logo hochladen: wird die Datei zwischen Prüfung und Festlegen ausgetauscht (danach 4000 × 4000), ist das 400 logoFormat, die Datei ist gelöscht und keine Zeile bleibt', async () => { const { upload, fake, logs } = await setup(); logoFile(fake, 'logo1'); // beim ersten Lesen gültig (200 × 100) fake.swapContentAfterPermissions('logo1', pngHeader(4000, 4000)); expectError(await upload(uploadBody()), 400, 'logoFormat'); assert.equal(fake.rows('logos').length, 0, 'keine Zeile bleibt'); assert.equal(fake.file('logo1'), null, 'die Datei ist gelöscht'); assert.deepEqual(logoSteps(fake, 'logo1'), [ 'Datei abrufen', 'Anfang lesen', 'Zeile anlegen', 'Rechte festlegen', 'Anfang lesen', 'Datei löschen', 'Zeile löschen', ]); // ein Ereignis im Log (nur ID und Code), nie „uploaded“ assert.deepEqual(logEvents(logs, rowOf('logo1')), [`logo ${rowOf('logo1')} removed after upload check (logoFormat)`]); }); test('Logo hochladen: nach dem Festlegen zählt der Inhalt noch einmal (kein PNG-Kopf, zu klein, zu groß): 400 logoFormat, Datei und Zeile sind weg', async () => { const { upload, fake } = await setup(); let n = 0; for (const [label, content] of [ ['4000 × 4000', pngHeader(4000, 4000)], ['721 × 100', pngHeader(721, 100)], ['720 × 251', pngHeader(720, 251)], ['7 × 8', pngHeader(7, 8)], ['WebP-Inhalt', webpHeader('VP8L', 200, 100)], ['kein Bild', Buffer.from('kein Bild, nur Text, lang genug für die Prüfung 123456789')], ['leer', Buffer.alloc(0)], ]) { n += 1; const id = `tausch${n}`; logoFile(fake, id); fake.swapContentAfterPermissions(id, content); expectError(await upload(uploadBody({ fileId: id })), 400, 'logoFormat', label); assert.equal(fake.row('logos', rowOf(id)), null, `${label}: keine Zeile`); assert.equal(fake.file(id), null, `${label}: Datei gelöscht`); } assert.equal(fake.rows('logos').length, 0); }); test('Logo hochladen: ist die Datei nach dem Festlegen nicht mehr lesbar, ist das 400 fileMissing, Datei und Zeile werden entfernt', async () => { const { upload, fake, logs } = await setup(); logoFile(fake, 'logo1'); let reads = 0; fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/files/logo1/view') && (reads += 1) === 2, { status: 404, type: 'storage_file_not_found' }); expectError(await upload(uploadBody()), 400, 'fileMissing'); assert.equal(fake.rows('logos').length, 0); assert.equal(fake.file('logo1'), null); assert.deepEqual(logEvents(logs, rowOf('logo1')), [`logo ${rowOf('logo1')} removed after upload check (fileMissing)`]); }); test('Logo hochladen: Appwrite-Fehler beim zweiten Lesen ist 502, nichts bleibt veröffentlicht, die Datei bekommt delete(owner) zurück, die Wiederholung klappt', async () => { const { upload, fake, logs } = await setup(); logoFile(fake, 'logo1'); let reads = 0; fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/files/logo1/view') && (reads += 1) === 2, { status: 500 }); expectError(await upload(uploadBody()), 502, 'unavailable'); assert.equal(fake.rows('logos').length, 0); assert.deepEqual(fake.file('logo1').$permissions, UPLOAD_PERMISSIONS, 'wie vor dem Aufruf: kein Logo, die Datei lässt sich vom Hochlader löschen'); assert.deepEqual(logoSteps(fake, 'logo1'), [ 'Datei abrufen', 'Anfang lesen', 'Zeile anlegen', 'Rechte festlegen', 'Anfang lesen', 'Zeile löschen', 'Rechte festlegen', ]); assert.deepEqual(logEvents(logs, rowOf('logo1')), [], 'ein Fehler ohne Urteil ist kein Ereignis'); assert.equal((await upload(uploadBody())).status, 201); assert.deepEqual(fake.file('logo1').$permissions, ['read("any")']); }); test('Logo hochladen: scheitert das Aufräumen nach einer Beanstandung, steht `rollback failed for logo (file )` im Log, die Antwort bleibt 400 logoFormat', async () => { const { upload, remove, fake, logs } = await setup(); // die Datei lässt sich nicht löschen: die Zeile wird trotzdem entfernt (sie macht das Logo für andere sichtbar); die Zeile ist weg, // darum nennt die Logzeile die Datei-ID, die sich aus der Zeilen-ID nicht ableiten lässt logoFile(fake, 'logo1'); fake.swapContentAfterPermissions('logo1', pngHeader(4000, 4000)); fake.failNext((c) => c.method === 'DELETE' && c.path.endsWith('/files/logo1'), { status: 500 }); expectError(await upload(uploadBody()), 400, 'logoFormat'); assert.equal(fake.row('logos', rowOf('logo1')), null); assert.deepEqual(fake.file('logo1').$permissions, ['read("any")'], 'die Datei blieb liegen'); assert.deepEqual(logEvents(logs, rowOf('logo1')), [`rollback failed for logo ${rowOf('logo1')} (file logo1)`]); // die Zeile lässt sich nicht löschen: die Datei ist weg, die Zeile bleibt als Anker und der Hochlader räumt sie über /api/logos/loeschen auf logoFile(fake, 'logo2'); fake.swapContentAfterPermissions('logo2', pngHeader(4000, 4000)); fake.failNext((c) => c.method === 'DELETE' && c.path.includes(`/tables/logos/rows/${rowOf('logo2')}`), { status: 500 }); expectError(await upload(uploadBody({ fileId: 'logo2' })), 400, 'logoFormat'); assert.equal(fake.file('logo2'), null); assert.ok(fake.row('logos', rowOf('logo2'))); assert.deepEqual(logEvents(logs, rowOf('logo2')), [`rollback failed for logo ${rowOf('logo2')} (file logo2)`]); assert.equal((await remove(rowOf('logo2'))).status, 200); assert.equal(fake.row('logos', rowOf('logo2')), null); }); test('Logo hochladen: Validierung liefert 400 mit dem passenden Code und fasst Appwrite (außer dem Konto) nicht an', async () => { const { upload, fake } = await setup(); logoFile(fake, 'logo1'); for (const [label, overrides, code] of [ ['Name fehlt', { name: undefined }, 'logoNameMissing'], ['Name leer', { name: '' }, 'logoNameMissing'], ['Name nur Leerzeichen', { name: ' ' }, 'logoNameMissing'], ['Name null', { name: null }, 'logoNameMissing'], ['Name nur BOM', { name: '' }, 'logoNameMissing'], ['mono "ja"', { mono: 'ja' }, 'badMono'], ['mono 1', { mono: 1 }, 'badMono'], ['mono "true"', { mono: 'true' }, 'badMono'], ['mono null', { mono: null }, 'badMono'], ['mono Liste', { mono: [true] }, 'badMono'], ]) { expectError(await upload(uploadBody(overrides)), 400, code, label); } // Prüftexte mit Feldnamen oder Zahlen haben keinen festen Schlüssel: code badRequest for (const [label, overrides, text] of [ ['Name zu lang', { name: 'x'.repeat(41) }, 'Der Name ist zu lang (höchstens 40 Zeichen).'], ['Name keine Zeichenkette', { name: 42 }, 'Der Name ist ungültig.'], ['Name mit Zeilenumbruch', { name: 'a\nb' }, 'Der Name enthält ungültige Zeichen.'], ['Name mit Nullbreiten-Leerzeichen', { name: 'a​b' }, 'Der Name enthält ungültige Zeichen.'], ['Name mit Rechts-nach-links-Überschreibung', { name: 'logo‮gnp' }, 'Der Name enthält ungültige Zeichen.'], ['Anzeigename zu lang', { displayName: 'x'.repeat(31) }, 'Der Anzeigename ist zu lang (höchstens 30 Zeichen).'], ['Anzeigename keine Zeichenkette', { displayName: 5 }, 'Der Anzeigename ist ungültig.'], ['Anzeigename mit Bidi-Zeichen', { displayName: 'Ken⁦so' }, 'Der Anzeigename enthält ungültige Zeichen.'], ['Datei-ID mit ../', { fileId: '../x' }, 'Ungültige Datei-ID.'], ['Datei-ID ..', { fileId: '..' }, 'Ungültige Datei-ID.'], ['Datei-ID Zahl', { fileId: 123 }, 'Ungültige Datei-ID.'], ['Datei-ID leer', { fileId: '' }, 'Ungültige Datei-ID.'], ['Datei-ID fehlt', { fileId: undefined }, 'Ungültige Datei-ID.'], ['Datei-ID zu lang', { fileId: 'a'.repeat(37) }, 'Ungültige Datei-ID.'], ['Datei-ID mit Schrägstrich', { fileId: 'a/b' }, 'Ungültige Datei-ID.'], ]) { const res = await upload(uploadBody(overrides)); assert.equal(res.status, 400, label); assert.deepEqual(res.json, { error: text, code: 'badRequest' }, label); } assert.equal(fake.rows('logos').length, 0); assert.equal(nonAccountCalls(fake).length, 0, 'ungültige Anfragen erreichen Appwrite (außer dem Konto) nie'); }); test('Logo hochladen: kaputtes JSON, Array oder leerer Body ist 400 badRequest', async () => { const { call, fake } = await setup(); for (const raw of ['{kaputt', '[]', '"text"', 'null', '']) { const res = await call('POST', '/api/logos/hochladen', { raw, headers: { 'X-Aircard-Jwt': JWT_ME } }); expectError(res, 400, 'badRequest', JSON.stringify(raw)); } assert.equal(nonAccountCalls(fake).length, 0); }); test('Logo hochladen: gleichzeitig dieselbe Datei oder zwei Dateien am Limit, genau eine Anfrage gelingt (Sperre je Konto)', async () => { const { upload, fake } = await setup(); logoFile(fake, 'logo1'); fake.delayNext((c) => c.method === 'POST' && c.path.endsWith('/tables/logos/rows'), 80); const same = await Promise.all([upload(uploadBody()), upload(uploadBody())]); assert.deepEqual(same.map((r) => r.status).sort(), [201, 409]); assert.equal(fake.rows('logos').length, 1); seedLogoRows(fake, ME, 28); // mit dem Logo oben 29 logoFile(fake, 'a'); logoFile(fake, 'b'); fake.delayNext((c) => c.method === 'POST' && c.path.endsWith('/tables/logos/rows'), 80); const limited = await Promise.all([upload(uploadBody({ fileId: 'a' })), upload(uploadBody({ fileId: 'b' }))]); assert.deepEqual(limited.map((r) => r.status).sort(), [201, 429]); assert.equal(fake.rows('logos').filter((row) => row.ownerId === ME).length, 30); }); // ---- Hochladen: die Sperre des Logos gilt vom Anlegen der Zeile bis zur zweiten Prüfung ---------------------------- test('Logo hochladen: Herz und Meldung, die nach dem Anlegen der Zeile eintreffen, warten auf die zweite Prüfung und hinterlassen nichts, wenn sie das Logo entfernt', async () => { const { upload, heart, report, fake } = await setup(); const rowId = rowOf('logo1'); logoFile(fake, 'logo1'); fake.swapContentAfterPermissions('logo1', pngHeader(4000, 4000)); // die zweite Prüfung beanstandet das Logo ... let reads = 0; fake.delayNext((c) => c.method === 'GET' && c.path.endsWith('/files/logo1/view') && (reads += 1) === 2, 150); // ... und liest langsam const uploading = upload(uploadBody()); await until(() => fileReads(fake, 'logo1').length === 2); assert.ok(fake.row('logos', rowId), 'die Zeile steht schon (öffentlich), die zweite Prüfung läuft noch'); const [up, liked, reported] = await Promise.all([uploading, heart(rowId, true), report(rowId, '203.0.1.9')]); expectError(up, 400, 'logoFormat'); expectError(liked, 404, 'logoGone', 'Herz'); expectError(reported, 404, 'logoGone', 'Meldung'); assert.equal(fake.rows('logos').length, 0); assert.equal(fake.rows('logo_likes').length, 0, 'kein Herz ohne Logo'); assert.equal(fake.rows('logo_reports').length, 0, 'keine Meldung ohne Logo'); }); test('Logo hochladen: auch bei der Rücknahme nach einem Fehler (zweites Lesen scheitert) bleibt kein Herz zurück, das in der Zwischenzeit eintraf', async () => { const { upload, heart, fake } = await setup(); const rowId = rowOf('logo1'); logoFile(fake, 'logo1'); let puts = 0; let reads = 0; fake.delayNext((c) => c.method === 'PUT' && c.path.endsWith('/files/logo1') && (puts += 1) === 1, 150); // das Festlegen dauert, die Zeile steht schon fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/files/logo1/view') && (reads += 1) === 2, { status: 500 }); // zweite Prüfung scheitert const uploading = upload(uploadBody()); await until(() => filePuts(fake, 'logo1').length === 1); assert.ok(fake.row('logos', rowId)); const [up, liked] = await Promise.all([uploading, heart(rowId, true)]); expectError(up, 502, 'unavailable'); expectError(liked, 404, 'logoGone', 'Herz'); assert.equal(fake.rows('logos').length, 0); assert.equal(fake.rows('logo_likes').length, 0, 'kein Herz ohne Logo'); assert.deepEqual(fake.file('logo1').$permissions, UPLOAD_PERMISSIONS, 'die Datei bekam delete(owner) zurück'); }); test('Logo hochladen: ein Löschen, das während der zweiten Prüfung eintrifft, wartet: der Upload gelingt, danach ist das fertige Logo weg', async () => { const { upload, remove, fake } = await setup(); const rowId = rowOf('logo1'); logoFile(fake, 'logo1'); let reads = 0; fake.delayNext((c) => c.method === 'GET' && c.path.endsWith('/files/logo1/view') && (reads += 1) === 2, 150); const done = []; const uploading = upload(uploadBody()).then((res) => (done.push('upload'), res)); await until(() => fileReads(fake, 'logo1').length === 2); const deleting = remove(rowId).then((res) => (done.push('delete'), res)); const [up, del] = await Promise.all([uploading, deleting]); assert.equal(up.status, 201, 'das Löschen nimmt dem Upload nicht mitten in der Prüfung die Datei weg'); assert.deepEqual(up.json, { id: rowId }); assert.equal(del.status, 200); assert.deepEqual(done, ['upload', 'delete']); assert.equal(fake.row('logos', rowId), null); assert.equal(fake.file('logo1'), null); }); // ---- Herzen ----------------------------------------------------------------------------------------- test('Herz: an, nochmal an (bleibt 1), aus, nochmal aus (bleibt 0); Like-Zeile mit ID logoLikeId und Recht read("user:")', async () => { const { heart, fake } = await setup(); seedLogo(fake); let res = await heart('logo-1', true); assert.equal(res.status, 200); assert.deepEqual(res.json, { herz: true, likeCount: 1 }); const like = fake.row('logo_likes', logoLikeId('logo-1', ME)); assert.ok(like, 'Zeilen-ID = hash32("|")'); assert.deepEqual(like.$permissions, ['read("user:user-me")']); assert.equal(like.logoId, 'logo-1'); assert.equal(like.userId, ME); assert.equal(fake.row('logos', 'logo-1').likeCount, 1); res = await heart('logo-1', true); assert.deepEqual(res.json, { herz: true, likeCount: 1 }); assert.equal(fake.rows('logo_likes').length, 1); res = await heart('logo-1', false); assert.equal(res.status, 200); assert.deepEqual(res.json, { herz: false, likeCount: 0 }); assert.equal(fake.row('logos', 'logo-1').likeCount, 0); assert.equal(fake.rows('logo_likes').length, 0); res = await heart('logo-1', false); assert.deepEqual(res.json, { herz: false, likeCount: 0 }); assert.equal(fake.row('logos', 'logo-1').likeCount, 0); }); test('Herz: zwei Nutzer ergeben 2, jeder nimmt nur sein eigenes zurück', async () => { const { heart, fake } = await setup(); seedLogo(fake); assert.deepEqual((await heart('logo-1', true, JWT_ME)).json, { herz: true, likeCount: 1 }); assert.deepEqual((await heart('logo-1', true, JWT_OTHER)).json, { herz: true, likeCount: 2 }); assert.deepEqual((await heart('logo-1', true, JWT_ADMIN)).json, { herz: true, likeCount: 3 }); assert.equal(fake.row('logos', 'logo-1').likeCount, 3); assert.deepEqual((await heart('logo-1', false, JWT_ME)).json, { herz: false, likeCount: 2 }); assert.deepEqual(fake.rows('logo_likes').map((like) => like.userId).sort(), [ADMIN, OTHER]); assert.equal(fake.row('logos', 'logo-1').likeCount, 2); }); test('Herz: likeCount ist immer die Zahl der Zeilen (heilt einen falschen Zählerstand), die Logo-Zeile ändert sich nur bei Abweichung', async () => { const { heart, fake } = await setup(); seedLogo(fake, { likeCount: 7 }); seedLikes(fake, 'logo-1', ['u1', 'u2']); seedLogo(fake, { id: 'logo-2' }); seedLikes(fake, 'logo-2', ['u1', 'u2', 'u3', 'u4']); const res = await heart('logo-1', true); assert.deepEqual(res.json, { herz: true, likeCount: 3 }, 'nur die Herzen dieses Logos zählen'); assert.equal(fake.row('logos', 'logo-1').likeCount, 3); const patch = fake.calls.find((c) => c.method === 'PATCH' && c.path.endsWith('/tables/logos/rows/logo-1')); assert.deepEqual(patch.body, { data: { likeCount: 3 } }, 'ohne permissions: die Rechte der Zeile bleiben'); assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")']); const seen = fake.calls.length; assert.deepEqual((await heart('logo-1', true)).json, { herz: true, likeCount: 3 }); assert.equal(fake.calls.slice(seen).filter((c) => c.method === 'PATCH').length, 0, 'Zähler stimmt schon: kein Schreibzugriff auf die Logo-Zeile'); const counts = fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith('/tables/logo_likes/rows')); assert.ok(counts.length >= 1); assert.ok(decodeURIComponent(counts[0].search).includes('"method":"limit","values":[1]'), 'gezählt wird über total einer Abfrage mit limit(1)'); }); test('Herz: ausgeblendetes oder fehlendes Logo ist 404 logoGone, auch beim Entfernen, und legt nichts an', async () => { const { heart, fake } = await setup(); seedLogo(fake, { id: 'versteckt', owner: OTHER, hidden: true }); seedLikes(fake, 'versteckt', [ME]); expectError(await heart('versteckt', true), 404, 'logoGone', 'ausgeblendet, an'); expectError(await heart('versteckt', false), 404, 'logoGone', 'ausgeblendet, aus'); expectError(await heart('gibts-nicht', true), 404, 'logoGone', 'fehlt, an'); expectError(await heart('gibts-nicht', false), 404, 'logoGone', 'fehlt, aus'); assert.equal(fake.rows('logo_likes').length, 1, 'die vorhandene Like-Zeile bleibt, nichts Neues'); assert.equal(fake.row('logos', 'versteckt').likeCount, 0); }); test('Herz: Anmeldung und Body-Prüfung (id, herz), ungültige Anfragen erreichen Appwrite nicht', async () => { const { heart, post, fake } = await setup(); seedLogo(fake); expectError(await heart('logo-1', true, null), 401, 'unauthorized', 'ohne JWT'); expectError(await heart('logo-1', true, 'xxx.yyy.zzz'), 401, 'unauthorized', 'falsches JWT'); expectError(await post('/api/logos/herz', { id: 'logo-1', herz: 'ja' }), 401, 'unauthorized', 'Anmeldung vor Body-Prüfung'); for (const herz of ['ja', 'true', 1, 0, null, undefined, [true]]) { expectError(await post('/api/logos/herz', { id: 'logo-1', herz }, JWT_ME), 400, 'badHeart', JSON.stringify(herz)); } for (const id of ['../x', '..', 5, null, undefined, 'a/b', 'a'.repeat(37), '']) { expectError(await heart(id, true), 400, 'badLogoId', String(id)); } assert.equal(fake.rows('logo_likes').length, 0); assert.equal(nonAccountCalls(fake).length, 0); }); test('Herz: Ausfall beim Zählen endet mit 502, die Wiederholung zählt richtig (das Herz war schon angelegt)', async () => { const { heart, fake } = await setup(); seedLogo(fake); fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/tables/logo_likes/rows'), { status: 500 }); expectError(await heart('logo-1', true), 502, 'unavailable'); assert.equal(fake.rows('logo_likes').length, 1); assert.equal(fake.row('logos', 'logo-1').likeCount, 0); assert.deepEqual((await heart('logo-1', true)).json, { herz: true, likeCount: 1 }); assert.equal(fake.row('logos', 'logo-1').likeCount, 1); }); test('Herz: gleichzeitige Herzen mehrerer Nutzer ergeben die richtige Zahl (Sperre je Logo)', async () => { const { heart, fake } = await setup(); seedLogo(fake); fake.delayNext((c) => c.method === 'PATCH' && c.path.endsWith('/tables/logos/rows/logo-1'), 100); const results = await Promise.all([heart('logo-1', true, JWT_ME), heart('logo-1', true, JWT_OTHER), heart('logo-1', true, JWT_ADMIN)]); assert.deepEqual(results.map((r) => r.status), [200, 200, 200]); assert.equal(fake.rows('logo_likes').length, 3); assert.equal(fake.row('logos', 'logo-1').likeCount, 3, 'ein verspäteter Zählerstand überschreibt keinen neueren'); }); // ---- Melden ----------------------------------------------------------------------------------------- test('Logo melden: drei verschiedene Netze blenden das Logo aus (Zeilen- und Dateirechte), danach 404', async () => { const { report, fake } = await setup(); seedLogo(fake); let res = await report('logo-1', '203.0.1.9'); assert.equal(res.status, 200); assert.deepEqual(res.json, { ok: true }); assert.equal(fake.row('logos', 'logo-1').reportCount, 1); assert.equal(fake.row('logos', 'logo-1').hidden, false); res = await report('logo-1', '203.0.2.9', 'urheberrecht'); assert.deepEqual(res.json, { ok: true }); assert.equal(fake.row('logos', 'logo-1').reportCount, 2); assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")']); assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, ['read("any")']); res = await report('logo-1', '203.0.3.9', 'sonstiges'); assert.equal(res.status, 200); assert.deepEqual(res.json, { ok: true }); const row = fake.row('logos', 'logo-1'); assert.equal(row.hidden, true); assert.equal(row.reportCount, 3); assert.deepEqual(row.$permissions, HIDDEN_PERMISSIONS); assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, HIDDEN_PERMISSIONS); assert.equal(fake.rows('logo_reports').length, 3); // ausgeblendetes Logo melden: 404 expectError(await report('logo-1', '203.0.4.9'), 404, 'logoGone'); assert.equal(fake.rows('logo_reports').length, 3); }); test('Logo melden: die Meldungen speichern nur Hash, Grund und Logo (ID = hash32("logo||"), keine Rechte)', async () => { const { report, fake } = await setup(); seedLogo(fake); await report('logo-1', '203.0.1.9', 'spam'); await report('logo-1', '203.0.2.9', 'unangemessen'); assert.equal(fake.row('logos', 'logo-1').hidden, false); const hash = reporterHash(SALT, '203.0.1.9'); const stored = fake.row('logo_reports', sha32(`logo|logo-1|${hash}`)); assert.ok(stored); assert.equal(stored.logoId, 'logo-1'); assert.equal(stored.reason, 'spam'); assert.equal(stored.reporterHash, hash); assert.deepEqual(stored.$permissions, []); assert.ok(!JSON.stringify(fake.rows('logo_reports')).includes('203.0.'), 'keine IP im Klartext gespeichert'); assert.equal(fake.rows('reports').length, 0, 'Logo-Meldungen landen nicht bei den Karten-Meldungen'); }); test('Logo melden: dieselbe IP mehrmals zählt einmal ({schonGemeldet:true}), ein IPv4-/24 und ein IPv6-/48 sind je ein Melder', async () => { const { report, fake } = await setup(); seedLogo(fake); assert.deepEqual((await report('logo-1', '203.0.9.9')).json, { ok: true }); for (let i = 0; i < 4; i += 1) { const again = await report('logo-1', '203.0.9.9', i % 2 ? 'spam' : 'unangemessen'); assert.equal(again.status, 200); assert.deepEqual(again.json, { schonGemeldet: true }); } assert.deepEqual((await report('logo-1', '203.0.9.200')).json, { schonGemeldet: true }, 'selbes /24'); assert.equal(fake.row('logos', 'logo-1').reportCount, 1); assert.equal(fake.row('logos', 'logo-1').hidden, false); assert.deepEqual((await report('logo-1', '2001:db8:abcd:12::1')).json, { ok: true }); assert.deepEqual((await report('logo-1', '2001:db8:abcd:13::1')).json, { schonGemeldet: true }, 'selbes /48'); assert.equal(fake.row('logos', 'logo-1').reportCount, 2); assert.equal(fake.rows('logo_reports').length, 2); }); test('Logo melden: gültige Gründe sind unangemessen, kartendaten, urheberrecht, spam, sonstiges', async () => { const { report, fake } = await setup(); seedLogo(fake); seedLogo(fake, { id: 'logo-2' }); const reasons = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']; for (let i = 0; i < reasons.length; i += 1) { const res = await report(i < 2 ? 'logo-1' : 'logo-2', `198.51.${100 + i}.1`, reasons[i]); assert.equal(res.status, 200, reasons[i]); } assert.deepEqual(fake.rows('logo_reports').map((r) => r.reason).sort(), [...reasons].sort()); }); test('Logo melden: Ungültige Anfragen (Grund, ID) zählen nicht zum Limit und erreichen Appwrite nicht', async () => { const { report, post, fake, app } = await setup(); seedLogo(fake); for (const reason of ['gibt-es-nicht', '', null, 7, undefined, 'SPAM']) { const res = await post('/api/logos/melden', { id: 'logo-1', reason }, null, { 'X-Real-Ip': '203.0.1.9' }); expectError(res, 400, 'badReason', String(reason)); } for (const id of ['../x', '..', 42, null, undefined, 'a/b', 'a'.repeat(37), '']) { const res = await post('/api/logos/melden', { id, reason: 'spam' }, null, { 'X-Real-Ip': '203.0.1.9' }); expectError(res, 400, 'badLogoId', String(id)); } assert.equal(fake.calls.length, 0); assert.equal(app.rateLimitEntries(), 0); assert.equal((await report('logo-1', '203.0.1.9')).status, 200); }); test('Logo melden: fehlendes Logo ist 404 logoGone und legt keine Meldung an', async () => { const { report, fake } = await setup(); expectError(await report('gibts-nicht', '203.0.1.9'), 404, 'logoGone'); assert.equal(fake.rows('logo_reports').length, 0); }); test('Logo melden: Rate-Limit 10 pro Stunde und Anschluss, danach 429 mit Retry-After, nach einer Stunde wieder erlaubt', async () => { const { report, fake, clock } = await setup(); seedLogo(fake); for (let i = 0; i < 10; i += 1) assert.equal((await report('logo-1', '203.0.50.9')).status, 200, `Meldung ${i + 1}`); const limited = await report('logo-1', '203.0.50.9'); expectError(limited, 429, 'tooManyReports'); assert.equal(limited.headers['retry-after'], '3600'); assert.equal((await report('logo-1', '203.0.51.9')).status, 200, 'eine andere IP ist nicht betroffen'); clock.value += 3600_000; assert.equal((await report('logo-1', '203.0.50.9')).status, 200); }); test('Logo melden: Karten und Logos teilen sich ein Rate-Limit je Anschluss (6 + 5 Meldungen, die 11. ergibt 429)', async () => { const { report, post, fake, app } = await setup(); seedLogo(fake); fake.addRow( 'gallery', 'card-1', { title: 'Karte', issuer: '', tags: '', displayName: '', imageFileId: 'ci', previewFileId: 'cp', sourceCardId: 'src', ownerId: ME, hidden: false, reportCount: 0, searchText: 'karte' }, visibleRowPermissions(), ); const ip = { 'X-Real-Ip': '203.0.50.9' }; const reportCard = () => post('/api/melden', { id: 'card-1', reason: 'spam' }, null, ip); for (let i = 0; i < 6; i += 1) assert.equal((await reportCard()).status, 200, `Karte ${i + 1}`); for (let i = 0; i < 4; i += 1) assert.equal((await report('logo-1', '203.0.50.9')).status, 200, `Logo ${i + 1}`); expectError(await report('logo-1', '203.0.50.9'), 429, 'tooManyReports', 'die 11. Meldung'); expectError(await reportCard(), 429, 'tooManyReports', 'auch Karten sind gesperrt'); assert.equal(app.rateLimitEntries(), 1, 'ein gemeinsamer Zähler'); // eine andere Adresse hat ein eigenes Limit assert.equal((await report('logo-1', '203.0.51.9')).status, 200); }); test('Logo melden: gleichzeitige Meldungen von drei Netzen ergeben genau drei und blenden aus (Sperre je Logo)', async () => { const { report, fake } = await setup(); seedLogo(fake); const results = await Promise.all([report('logo-1', '203.0.1.9'), report('logo-1', '203.0.2.9'), report('logo-1', '203.0.3.9')]); assert.deepEqual(results.map((r) => r.status), [200, 200, 200]); const row = fake.row('logos', 'logo-1'); assert.equal(row.reportCount, 3); assert.equal(row.hidden, true); assert.equal(fake.rows('logo_reports').length, 3); }); test('Logo melden: eine doppelte Meldung heilt einen früher steckengebliebenen Zählerstand', async () => { const { report, fake } = await setup(); seedLogo(fake); seedReports(fake, 'logo-1', ['203.0.1.9', '203.0.2.9', '203.0.3.9'].map((ip) => reporterHash(SALT, ip))); assert.equal(fake.row('logos', 'logo-1').hidden, false); const res = await report('logo-1', '203.0.1.9'); assert.deepEqual(res.json, { schonGemeldet: true }); assert.equal(fake.row('logos', 'logo-1').hidden, true); assert.equal(fake.row('logos', 'logo-1').reportCount, 3); assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, HIDDEN_PERMISSIONS); }); test('Logo melden: Ausfall beim Anlegen der Meldung ist 502 und zählt nicht, Ausfall beim Ausblenden endet mit 502 und der nächste Versuch blendet aus', async () => { const { report, fake } = await setup(); seedLogo(fake); fake.failNext((c) => c.method === 'POST' && c.path.endsWith('/tables/logo_reports/rows'), { status: 500 }); expectError(await report('logo-1', '203.0.1.9'), 502, 'unavailable'); assert.equal(fake.row('logos', 'logo-1').reportCount, 0); assert.equal(fake.rows('logo_reports').length, 0); await report('logo-1', '203.0.1.9'); await report('logo-1', '203.0.2.9'); fake.failNext((c) => c.method === 'PUT' && c.path.endsWith(`/files/${fileOf('logo-1')}`), { status: 500 }); expectError(await report('logo-1', '203.0.3.9'), 502, 'unavailable'); assert.equal(fake.row('logos', 'logo-1').hidden, false, 'Dateien zuerst, Zeile zuletzt: die Zeile ist noch sichtbar'); const retry = await report('logo-1', '203.0.3.9'); assert.equal(retry.status, 200); assert.equal(fake.row('logos', 'logo-1').hidden, true); assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, HIDDEN_PERMISSIONS); }); // ---- Löschen ---------------------------------------------------------------------------------------- test('Logo löschen durch den Hochlader: Datei, Herzen, Meldungen und Zeile sind weg, andere Logos bleiben', async () => { const { remove, fake, logs } = await setup(); seedLogo(fake, { id: 'logo-1' }); seedLogo(fake, { id: 'logo-2' }); seedLikes(fake, 'logo-1', ['u1', 'u2']); seedLikes(fake, 'logo-2', ['u1']); seedReports(fake, 'logo-1', ['h1', 'h2']); seedReports(fake, 'logo-2', ['h3']); const res = await remove('logo-1'); assert.equal(res.status, 200); assert.deepEqual(res.json, { ok: true }); assert.equal(fake.row('logos', 'logo-1'), null); assert.equal(fake.file(fileOf('logo-1')), null); assert.deepEqual(fake.rows('logo_likes').map((r) => r.logoId), ['logo-2']); assert.deepEqual(fake.rows('logo_reports').map((r) => r.logoId), ['logo-2']); assert.ok(fake.row('logos', 'logo-2')); assert.ok(fake.file(fileOf('logo-2'))); assert.equal(logs.filter((line) => line.endsWith('logo logo-1 deleted')).length, 1); // Reihenfolge: Datei, Herzen, Meldungen, zuletzt die Zeile const order = fake.calls.filter((c) => c.method === 'DELETE').map((c) => c.path.split('/').slice(-3).join('/')); assert.ok(order[0].endsWith(`files/${fileOf('logo-1')}`), order[0]); assert.ok(order[order.length - 1].endsWith('logos/rows/logo-1'), order[order.length - 1]); }); test('Logo löschen: Herzen und Meldungen werden in Runden zu 100 gelöscht, auch über 100 hinaus', async () => { const { remove, fake } = await setup(); seedLogo(fake); seedLikes(fake, 'logo-1', Array.from({ length: 230 }, (_, i) => `u${i}`)); seedReports(fake, 'logo-1', Array.from({ length: 120 }, (_, i) => `h${i}`)); seedLikes(fake, 'anderes-logo', ['u1']); assert.equal((await remove('logo-1')).status, 200); assert.equal(fake.rows('logo_likes').filter((r) => r.logoId === 'logo-1').length, 0); assert.equal(fake.rows('logo_reports').length, 0); assert.equal(fake.rows('logo_likes').length, 1, 'Herzen anderer Logos bleiben'); const lists = (table) => fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith(`/tables/${table}/rows`)); assert.equal(lists('logo_likes').length, 4, '100 + 100 + 30 + leere Abschlussrunde'); assert.equal(lists('logo_reports').length, 3, '100 + 20 + leere Abschlussrunde'); const search = decodeURIComponent(lists('logo_likes')[0].search); assert.ok(search.includes('"attribute":"logoId","values":["logo-1"]')); assert.ok(search.includes('"method":"limit","values":[100]')); }); test('Logo löschen durch Admin geht, auch bei ausgeblendetem Logo', async () => { const { remove, fake, logs } = await setup(); seedLogo(fake, { hidden: true, reportCount: 3 }); seedReports(fake, 'logo-1', ['h1', 'h2', 'h3']); const res = await remove('logo-1', JWT_ADMIN); assert.equal(res.status, 200); assert.equal(fake.row('logos', 'logo-1'), null); assert.equal(fake.file(fileOf('logo-1')), null); assert.equal(fake.rows('logo_reports').length, 0); assert.equal(logs.filter((line) => line.endsWith('logo logo-1 deleted by admin')).length, 1); }); test('Logo löschen durch Fremde: 403 forbidden und nichts gelöscht', async () => { const { remove, fake } = await setup(); seedLogo(fake); seedLikes(fake, 'logo-1', ['u1']); expectError(await remove('logo-1', JWT_OTHER), 403, 'forbidden'); assert.ok(fake.row('logos', 'logo-1')); assert.ok(fake.file(fileOf('logo-1'))); assert.equal(fake.rows('logo_likes').length, 1); assert.equal(fake.calls.filter((c) => c.method === 'DELETE').length, 0); }); test('Logo löschen: 401 ohne JWT, 404 logoGone bei fehlendem Logo, 400 badLogoId bei ungültiger ID', async () => { const { remove, fake } = await setup(); seedLogo(fake); expectError(await remove('logo-1', null), 401, 'unauthorized'); expectError(await remove('logo-1', 'xxx.yyy.zzz'), 401, 'unauthorized'); expectError(await remove('gibts-nicht'), 404, 'logoGone'); for (const id of ['../x', '..', 5, null, undefined, '']) expectError(await remove(id), 400, 'badLogoId', String(id)); assert.ok(fake.row('logos', 'logo-1')); }); test('Logo löschen ist wiederholbar, wenn die Zeile zuletzt scheitert (die Zeile bleibt als Anker)', async () => { const { remove, fake } = await setup(); seedLogo(fake); seedLikes(fake, 'logo-1', ['u1']); fake.failNext((c) => c.method === 'DELETE' && c.path.includes('/tables/logos/rows/'), { status: 500 }); expectError(await remove('logo-1'), 502, 'unavailable'); assert.ok(fake.row('logos', 'logo-1'), 'Zeile bleibt, bis alles andere weg ist'); assert.equal(fake.file(fileOf('logo-1')), null); assert.equal(fake.rows('logo_likes').length, 0); const retry = await remove('logo-1'); assert.equal(retry.status, 200, 'die Datei fehlt schon, das ist kein Fehler'); assert.equal(fake.row('logos', 'logo-1'), null); }); // ---- Admin: Sichtbarkeit ------------------------------------------------------------------------------ test('Logo-Sichtbarkeit: Nicht-Admin 403 adminOnly, ohne JWT 401, nichts ändert sich', async () => { const { setVisible, fake } = await setup(); seedLogo(fake); expectError(await setVisible('logo-1', false, JWT_ME), 403, 'adminOnly', 'Hochlader'); expectError(await setVisible('logo-1', false, JWT_OTHER), 403, 'adminOnly', 'Fremde'); expectError(await setVisible('logo-1', false, null), 401, 'unauthorized'); assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")']); assert.equal(fake.row('logos', 'logo-1').hidden, false); assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, ['read("any")']); }); test('Admin blendet ein Logo aus: Zeilen- und Dateirechte, hidden=true, Meldungen und Herzen bleiben', async () => { const { setVisible, fake, logs } = await setup(); seedLogo(fake, { reportCount: 1, likeCount: 2 }); seedReports(fake, 'logo-1', ['h1']); seedLikes(fake, 'logo-1', ['u1', 'u2']); const res = await setVisible('logo-1', false); assert.equal(res.status, 200); assert.deepEqual(res.json, { ok: true }); const row = fake.row('logos', 'logo-1'); assert.equal(row.hidden, true); assert.equal(row.reportCount, 1); assert.equal(row.likeCount, 2); assert.deepEqual(row.$permissions, HIDDEN_PERMISSIONS); assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, HIDDEN_PERMISSIONS); assert.equal(fake.rows('logo_reports').length, 1); assert.equal(fake.rows('logo_likes').length, 2); assert.equal(logs.filter((line) => line.endsWith('logo logo-1 hidden by admin')).length, 1); }); test('Admin blendet ein Logo ein: sichtbare Rechte, hidden=false, reportCount=0, Meldungen gelöscht, Herzen bleiben, wieder meldbar', async () => { const { setVisible, report, fake, logs } = await setup(); seedLogo(fake, { hidden: true, reportCount: 3, likeCount: 2 }); seedLogo(fake, { id: 'logo-2' }); seedReports(fake, 'logo-1', ['h1', 'h2', 'h3']); seedReports(fake, 'logo-2', ['h4']); seedLikes(fake, 'logo-1', ['u1', 'u2']); const res = await setVisible('logo-1', true); assert.equal(res.status, 200); const row = fake.row('logos', 'logo-1'); assert.equal(row.hidden, false); assert.equal(row.reportCount, 0); assert.equal(row.likeCount, 2); assert.deepEqual(row.$permissions, ['read("any")']); assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, ['read("any")']); assert.deepEqual(fake.rows('logo_reports').map((r) => r.logoId), ['logo-2']); assert.equal(fake.rows('logo_likes').length, 2); assert.equal(logs.filter((line) => line.endsWith('logo logo-1 made visible by admin')).length, 1); assert.equal((await report('logo-1', '203.0.1.9')).status, 200, 'das Logo ist wieder meldbar'); }); test('Logo-Sichtbarkeit: Body-Prüfung und fehlendes Logo', async () => { const { setVisible, post, fake } = await setup(); seedLogo(fake); for (const sichtbar of ['ja', 'true', 1, 0, null, undefined]) { expectError(await post('/api/admin/logo-sichtbarkeit', { id: 'logo-1', sichtbar }, JWT_ADMIN), 400, 'badVisibility', String(sichtbar)); } for (const id of ['../x', '..', 7, null, undefined, '']) expectError(await setVisible(id, true), 400, 'badLogoId', String(id)); expectError(await setVisible('gibts-nicht', true), 404, 'logoGone'); expectError(await post('/api/admin/logo-sichtbarkeit', {}, JWT_ADMIN), 400, 'badLogoId'); }); test('Logo-Sichtbarkeit: scheitert das Setzen der Dateirechte, bleibt die Zeile unverändert (Dateien zuerst, Zeile zuletzt)', async () => { const { setVisible, fake } = await setup(); seedLogo(fake); fake.failNext((c) => c.method === 'PUT' && c.path.endsWith(`/files/${fileOf('logo-1')}`), { status: 500 }); expectError(await setVisible('logo-1', false), 502, 'unavailable'); assert.equal(fake.row('logos', 'logo-1').hidden, false); assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")']); assert.equal((await setVisible('logo-1', false)).status, 200, 'der nächste Versuch klappt'); assert.equal(fake.row('logos', 'logo-1').hidden, true); }); // ---- Altlogos: Zeilen-ID = Datei-ID (vor dem Hash-Schema angelegt) ------------------------------------------- test('Altlogo (Zeilen-ID = Datei-ID): Herz, Meldung, Ausblenden und Einblenden gehen weiter über die Zeilen-ID, die Datei über fileId', async () => { const { heart, report, setVisible, fake } = await setup(); seedLogo(fake, { id: 'alt1', fileId: 'alt1' }); assert.deepEqual((await heart('alt1', true)).json, { herz: true, likeCount: 1 }); assert.deepEqual((await heart('alt1', true, JWT_OTHER)).json, { herz: true, likeCount: 2 }); assert.deepEqual((await heart('alt1', false)).json, { herz: false, likeCount: 1 }); assert.equal(fake.row('logos', 'alt1').likeCount, 1); assert.deepEqual((await report('alt1', '203.0.1.9')).json, { ok: true }); assert.equal(fake.row('logos', 'alt1').reportCount, 1); assert.equal((await setVisible('alt1', false)).status, 200); assert.equal(fake.row('logos', 'alt1').hidden, true); assert.deepEqual(fake.file('alt1').$permissions, HIDDEN_PERMISSIONS); expectError(await heart('alt1', true, JWT_ADMIN), 404, 'logoGone', 'ausgeblendet'); assert.equal((await setVisible('alt1', true)).status, 200); assert.equal(fake.row('logos', 'alt1').hidden, false); assert.deepEqual(fake.file('alt1').$permissions, ['read("any")']); }); test('Altlogo löschen: Datei, Herzen, Meldungen und Zeile sind weg, die alte ID lässt sich danach nicht neu belegen', async () => { const { heart, report, remove, upload, fake } = await setup(); seedLogo(fake, { id: 'alt1', fileId: 'alt1' }); seedLogo(fake, { id: 'alt2', fileId: 'alt2', owner: OTHER }); // bleibt unberührt await heart('alt1', true); await report('alt1', '203.0.1.9'); assert.equal(fake.rows('logo_likes').length, 1); assert.equal(fake.rows('logo_reports').length, 1); assert.equal((await remove('alt1')).status, 200); assert.equal(fake.row('logos', 'alt1'), null); assert.equal(fake.file('alt1'), null); assert.equal(fake.rows('logo_likes').length, 0); assert.equal(fake.rows('logo_reports').length, 0); assert.ok(fake.row('logos', 'alt2')); assert.ok(fake.file('alt2')); // jemand legt eine Datei mit der alten ID an und lädt sie hoch: es entsteht eine Zeile mit neuer ID, die alte bleibt frei logoFile(fake, 'alt1', OTHER); const res = await upload(uploadBody({ fileId: 'alt1' }), JWT_OTHER); assert.equal(res.status, 201); assert.equal(res.json.id, rowOf('alt1', OTHER)); assert.equal(fake.row('logos', 'alt1'), null); }); test('Altlogo: dieselbe Datei noch einmal hochladen ist 409 logoTaken (die Zeile trägt sie schon), keine zweite Zeile', async () => { const { upload, fake } = await setup(); seedLogo(fake, { id: 'alt1', fileId: 'alt1' }); expectError(await upload(uploadBody({ fileId: 'alt1' })), 409, 'logoTaken'); assert.equal(fake.rows('logos').length, 1); assert.deepEqual(fake.file('alt1').$permissions, ['read("any")']); assert.equal(filePuts(fake, 'alt1').length, 0); }); // ---- Routen, Logs, Fehler --------------------------------------------------------------------------- test('Logo-Routen: nur POST mit application/json (sonst 404 bzw. 415), Appwrite wird dabei nicht berührt', async () => { const { call, fake } = await setup(); for (const path of ['/api/logos/hochladen', '/api/logos/herz', '/api/logos/melden', '/api/logos/loeschen', '/api/admin/logo-sichtbarkeit']) { for (const method of ['GET', 'PUT', 'DELETE', 'OPTIONS']) { expectError(await call(method, path, { body: {} }), 404, 'notFound', `${method} ${path}`); } expectError(await call('POST', `${path}/`, { body: {} }), 404, 'notFound', `${path}/`); for (const contentType of ['text/plain', 'application/x-www-form-urlencoded', null]) { expectError(await call('POST', path, { raw: '{}', contentType }), 415, 'jsonOnly', `${path} ${contentType}`); } } assert.equal(fake.calls.length, 0); }); test('Logo-Routen: Body über 8 KB ist 413 tooLarge', async () => { const { call } = await setup(); const raw = `{"id":"x","reason":"spam"}${' '.repeat(9000)}`; expectError(await call('POST', '/api/logos/melden', { raw }), 413, 'tooLarge'); }); test('Logo-Endpunkte: Appwrite-Ausfall ist 502 unavailable, eine fehlende Tabelle 500 internal (nie „Logo nicht verfügbar“)', async () => { const { heart, report, upload, fake } = await setup(); seedLogo(fake); const isLogoRead = (c) => c.method === 'GET' && c.path.includes('/tables/logos/rows/'); fake.failNext(isLogoRead, { status: 503 }); expectError(await heart('logo-1', true), 502, 'unavailable'); fake.failNext(isLogoRead, { status: 429, type: 'general_rate_limit_exceeded' }); expectError(await report('logo-1', '203.0.1.9'), 502, 'unavailable'); fake.failNext(isLogoRead, { status: 404, type: 'table_not_found' }); expectError(await report('logo-1', '203.0.1.9'), 500, 'internal'); fake.failNext(isLogoRead, { status: 401, type: 'general_unauthorized_scope' }); expectError(await heart('logo-1', true), 500, 'internal'); fake.failNext((c) => c.path === '/v1/account', { status: 500 }); expectError(await upload(uploadBody()), 502, 'unavailable'); assert.equal(fake.rows('logo_likes').length, 0); assert.equal(fake.rows('logo_reports').length, 0); }); test('Logs der Logo-Endpunkte enthalten nie Key, Salz, JWT, IPs oder Namen, aber das Präfix [aircard-api]', async () => { const { upload, heart, report, setVisible, remove, fake, logs } = await setup(); logoFile(fake, 'logo1'); logoFile(fake, 'logo2'); fake.swapContentAfterPermissions('logo2', pngHeader(4000, 4000)); seedLogo(fake, { id: 'logo-9', owner: OTHER }); await upload(uploadBody({ name: 'GEHEIMER Name', displayName: 'GEHEIMER Anzeigename' })); await upload(uploadBody({ fileId: 'logo2', name: 'GEHEIMER Tausch' })); // nach dem Festlegen beanstandet und entfernt await upload(uploadBody(), 'xxx.yyy.zzz'); await heart('logo-9', true); await report('logo-9', '203.0.7.9'); await report('logo-9', '203.0.8.9'); await report('logo-9', '203.0.9.9'); await setVisible('logo-9', true); fake.failNext((c) => c.method === 'GET' && c.path.includes('/tables/logos/rows/'), { status: 500 }); await remove(rowOf('logo1')); await remove(rowOf('logo1')); assert.ok(logs.length > 0); assert.ok(logs.some((line) => line.endsWith(`logo ${rowOf('logo2')} removed after upload check (logoFormat)`))); 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'), 'GEHEIMER']) { 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 Logo-Anfragen (400/401/404/429) und Herzen erzeugen keine Logzeilen', async () => { const { upload, heart, report, remove, setVisible, fake, logs } = await setup(); seedLogo(fake); for (let i = 0; i < 12; i += 1) { await report('gibts-nicht', '203.0.5.9'); // 404, ab der 11. Meldung 429 await report('../x', '203.0.5.9'); // 400 await upload(uploadBody(), null); // 401 await upload(uploadBody({ name: '' })); // 400 await heart('logo-1', true); // Herzen werden nicht geloggt await heart('logo-1', false); await heart('gibts-nicht', true); // 404 await remove('logo-1', JWT_OTHER); // 403 await setVisible('logo-1', false, JWT_ME); // 403 } assert.equal((await report('gibts-nicht', '203.0.5.9')).status, 429, 'das Rate-Limit hat wirklich gegriffen'); assert.deepEqual(logs, []); });