Files
AirCardIOS/deploy/api/logos.test.js
T
ClaudeandClaude Opus 5.5 fc9e5f5577 fix(api): Logo-Datei nach dem Festlegen der Rechte noch einmal prüfen
Der Hochlader behält delete auf seiner Datei, bis die API sie auf read("any") festlegt. Zwischen der ersten Prüfung
(Typ, Größe, PNG-Kopf, Maße) und dem Festlegen ließ sich die Datei unter derselben ID löschen und mit anderem Inhalt
neu anlegen (z. B. 4000 × 4000). Jetzt liest die API den Dateianfang nach dem Festlegen noch einmal, wenn der
Hochlader die Datei nicht mehr ändern kann:

- PNG-Kopf und Maße 8 × 8 bis 720 × 250 werden erneut geprüft. Fällt die Datei durch oder fehlt sie, löscht die API die
  Datei (Server-Key) und die Zeile (beides wird versucht) und antwortet 400 logoFormat bzw. fileMissing. Log:
  "logo <id> removed after upload check (<code>)", bleibt etwas stehen: "rollback failed for logo <id>".
- Ein Appwrite-Fehler beim zweiten Lesen wird wie ein Fehler beim Festlegen behandelt (502, Zeile zurückgenommen, die
  Datei bekommt delete(owner) zurück).
- fake-appwrite.js: Testhilfe swapContentAfterPermissions (der Inhalt ändert sich nach dem nächsten Rechte-Update).
- Tests: 7 neue in logos.test.js (Austausch auf 4000 × 4000, weitere ungültige Inhalte, Datei weg, Appwrite-Fehler,
  Aufräumen scheitert, Ablauffolge, weiterhin gültiger Inhalt), der Logtest prüft das neue Ereignis mit. Der
  Karten-Ablauf ist unverändert (app.js und common.js nicht angefasst).
- Doku: api/README.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:28:12 +00:00

1247 lines
64 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// Tests für deploy/api/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, validateLogoUpload } = require('./logos.js');
const {
MSG,
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: <Text des MSG-Schlüssels>, code: <Schlüssel>}. */
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 });
}
/** Veröffentlichtes Logo: Zeile und Datei mit genau den Rechten, die die API vergibt. */
function seedLogo(fake, { id = 'logo-1', owner = ME, hidden = false, reportCount = 0, likeCount = 0, name = 'Logo', mono = false } = {}) {
fake.addFile({
id,
bucket: LOGO_BUCKET,
owner,
permissions: hidden ? hiddenFilePermissions(owner) : visibleFilePermissions(),
content: pngHeader(200, 100),
});
fake.addRow(
'logos',
id,
{ name, displayName: '', fileId: id, 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: 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}`));
/** 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, id) {
const steps = [];
for (const c of fake.calls) {
if (c.path.endsWith(`/files/${id}/view`)) steps.push('Anfang lesen');
else if (c.path.endsWith(`/files/${id}`)) 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/${id}`)) 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('likeRowPermissions: genau read("user:<id>"), 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 = 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 }));
assert.equal(res.status, 201);
assert.deepEqual(res.json, { id: 'logo1' });
const row = fake.row('logos', 'logo1');
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 logo1 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', '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('<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"></svg>')],
['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', 'logo1').name, 'Mein Logo');
// dieselbe 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: eine Zeile mit dieser ID gehört schon jemand anderem (liegengebliebener Anker): 409 logoTaken, Datei unangetastet', async () => {
const { upload, fake } = await setup();
fake.addRow('logos', 'anker', { name: 'Alt', displayName: '', fileId: 'anker', 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: 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 <id>` 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], /^\[aircard-api\] \S+ rollback failed for logo 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', '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, 'logo1'), ['logo 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', 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, 'logo1'), ['logo 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, '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 <id>` 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)
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', 'logo1'), null);
assert.deepEqual(fake.file('logo1').$permissions, ['read("any")'], 'die Datei blieb liegen');
assert.deepEqual(logEvents(logs, 'logo1'), ['rollback failed for logo 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/logo2'), { status: 500 });
expectError(await upload(uploadBody({ fileId: 'logo2' })), 400, 'logoFormat');
assert.equal(fake.file('logo2'), null);
assert.ok(fake.row('logos', 'logo2'));
assert.deepEqual(logEvents(logs, 'logo2'), ['rollback failed for logo logo2']);
assert.equal((await remove('logo2')).status, 200);
assert.equal(fake.row('logos', '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);
});
// ---- Herzen -----------------------------------------------------------------------------------------
test('Herz: an, nochmal an (bleibt 1), aus, nochmal aus (bleibt 0); Like-Zeile mit ID logoLikeId und Recht read("user:<ich>")', 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("<logoId>|<userId>")');
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('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('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|<id>|<hash>"), 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('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/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('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('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('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/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('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('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('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('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('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('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/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);
});
// ---- 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('logo1');
await remove('logo1');
assert.ok(logs.length > 0);
assert.ok(logs.some((line) => line.endsWith('logo 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, []);
});