Files
ClaudeandClaude Opus 5.5 19c8151c7e feat(api): Benutzernamen (Profil-Tabelle, /api/profil, Pflicht beim Hochladen und Veröffentlichen)
- Schema: Tabelle `profiles` (Zeilen-ID = Konto-ID, `username` und `usernameKey`
  je 20 Zeichen, Unique-Index `username_key` auf `usernameKey`, rowSecurity an,
  keine Tabellenrechte) im Setup-Skript.
- Neues Modul `deploy/api/profiles.js`: `POST /api/profil` legt das Profil an
  oder ändert es (Zeilenrecht genau read("any"), Sperre je Konto). Muster,
  Sperrliste und Schlüssel (`usernameKey`) stehen in `common.js`; vergeben ->
  409 `usernameTaken`, auch wenn erst der Unique-Index bei einem Rennen
  entscheidet. Dazu die MSG-Texte `usernameInvalid`, `usernameReserved`,
  `usernameTaken`, `usernameRequired`.
- `POST /api/logos/hochladen` und `POST /api/veroeffentlichen` antworten 403
  `usernameRequired`, wenn das Konto kein Profil hat (`hasProfile`, nach der
  Anmeldung und vor allen anderen Prüfungen).
- Fake-Appwrite: Tabelle `profiles` mit Unique-Index (409), `addProfile`,
  `holdNext`. API-Dienst besteht jetzt aus sechs Dateien (READMEs).
- Client: die vier API-Texte in `API_MESSAGES` und `en.ts` (Gleichlauf mit MSG).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:40:44 +00:00

1586 lines
84 KiB
JavaScript
Raw Permalink 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, 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';
/** Konto ohne Benutzernamen (alle anderen haben aus `setup` ein Profil): Hochladen geht erst mit Profil. */
const NEWBIE = 'user-new';
const JWT_ME = 'eyJhbGciOiJI.payload-me.signature-me';
const JWT_OTHER = 'eyJhbGciOiJI.payload-other.signature-other';
const JWT_ADMIN = 'eyJhbGciOiJI.payload-admin.signature-admin';
const JWT_NEW = 'eyJhbGciOiJI.payload-new.signature-new';
const 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'] });
fake.addUser(JWT_NEW, { id: NEWBIE });
// Hochladen verlangt einen Benutzernamen (Tabelle `profiles`): ME, OTHER und ADMIN haben einen, NEWBIE nicht.
for (const id of [ME, OTHER, ADMIN]) fake.addProfile(id);
const logs = [];
const clock = { value: 1_700_000_000_000 };
const appwrite = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key });
const app = createApp({ appwrite, salt: SALT, now: () => clock.value, log: (line) => logs.push(String(line)) });
const server = http.createServer(app);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
cleanups.push(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
await fake.close();
});
const call = (method, path, options) => request(port, method, path, options);
const post = (path, body, jwt, headers = {}) =>
call('POST', path, { body, headers: { ...(jwt ? { 'X-Aircard-Jwt': jwt } : {}), ...headers } });
const 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 });
}
/** 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');
/** Die Aufrufe bei Appwrite außer dem Konto und der Suche nach dem Profil von ME (die macht jede Anfrage zum Hochladen als Erstes). */
const callsBeyondProfile = (fake) => nonAccountCalls(fake).filter((c) => !(c.method === 'GET' && c.path.endsWith('/tables/profiles/rows/user-me')));
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`));
/** Kann ein Gast diese Zeile lesen? (Appwrite-Rollen `any` und `guests`.) */
const guestCanRead = (row) => row.$permissions.some((permission) => permission === 'read("any")' || permission === 'read("guests")');
/** 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("<Konto>|<Datei>"), 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:<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 = 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 ohne Benutzernamen: 403 usernameRequired noch vor jeder anderen Prüfung (auch vor Body und Datei), nichts angelegt, Datei unberührt', async () => {
const { upload, post, fake } = await setup();
logoFile(fake, 'logo1', NEWBIE);
expectError(await upload(uploadBody(), JWT_NEW), 403, 'usernameRequired', 'gültiger Body');
expectError(await upload({}, JWT_NEW), 403, 'usernameRequired', 'ungültiger Body (kein Name, keine Datei)');
expectError(await upload(uploadBody({ fileId: 'gibts-nicht' }), JWT_NEW), 403, 'usernameRequired', 'Datei gibt es nicht');
expectError(await upload(uploadBody({ fileId: 'logo1', mono: 'ja' }), JWT_NEW), 403, 'usernameRequired', 'mono ungültig');
expectError(await post('/api/logos/hochladen', null, JWT_NEW), 403, 'usernameRequired', 'kein Body');
assert.equal(fake.rows('logos').length, 0);
assert.deepEqual(fake.file('logo1').$permissions, ['read("any")', 'delete("user:user-new")'], 'die Datei bleibt, wie sie war');
assert.equal(fake.calls.filter((c) => c.path.startsWith('/v1/storage/')).length, 0, 'keine Datei wird angefasst');
// Appwrite sieht je Versuch nur die Prüfung des JWT und die Suche nach dem Profil (Zeilen-ID = Konto-ID)
assert.deepEqual(
nonAccountCalls(fake).map((c) => `${c.method} ${c.path}`),
Array(5).fill('GET /v1/tablesdb/aircard/tables/profiles/rows/user-new'),
);
});
test('Logo hochladen: das Profil gehört dem Konto – ein Profil der anderen Konten hilft nicht, mit eigenem Profil (über /api/profil) geht es', async () => {
const { upload, post, fake } = await setup();
logoFile(fake, 'logo1', NEWBIE);
expectError(await upload(uploadBody(), JWT_NEW), 403, 'usernameRequired');
const named = await post('/api/profil', { username: 'Neuling' }, JWT_NEW);
assert.deepEqual(named.json, { username: 'Neuling' });
const res = await upload(uploadBody(), JWT_NEW);
assert.equal(res.status, 201);
assert.equal(fake.row('logos', rowOf('logo1', NEWBIE)).ownerId, NEWBIE);
});
test('Logo hochladen: lässt sich das Profil nicht prüfen (Appwrite-Fehler), 502 und nichts wird angelegt; der nächste Versuch gelingt', async () => {
const { upload, fake } = await setup();
logoFile(fake, 'logo1');
fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/tables/profiles/rows/user-me'), { status: 500 });
expectError(await upload(uploadBody()), 502, 'unavailable');
assert.equal(fake.rows('logos').length, 0);
assert.deepEqual(fake.file('logo1').$permissions, UPLOAD_PERMISSIONS);
assert.equal((await upload(uploadBody())).status, 201);
});
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', 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 <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], new RegExp(`^\\[aircard-api\\] \\S+ rollback failed for logo ${rowOf('logo1')} \\(file logo1\\)$`));
assert.equal(logs.filter((line) => line.includes('restore failed')).length, 0, 'die Datei wird gar nicht erst angefasst');
assert.ok(!logs.join('\n').includes(JWT_ME));
});
test('Logo hochladen: scheitert nach dem Zurücknehmen der Zeile die Rückgabe von delete(owner), steht `restore failed for logo <id> (file <id>)` im Log, die Antwort bleibt der ursprüngliche Fehler', async () => {
const { upload, fake, logs } = await setup();
logoFile(fake, 'logo1');
let reads = 0;
let puts = 0;
fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/files/logo1/view') && (reads += 1) === 2, { status: 500 }); // zweite Prüfung: Rücknahme
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/logo1') && (puts += 1) === 2, { status: 500 }); // das 2. PUT gibt delete(owner) zurück
expectError(await upload(uploadBody()), 502, 'unavailable');
assert.equal(fake.rows('logos').length, 0, 'die Zeile ist zurückgenommen');
assert.deepEqual(fake.file('logo1').$permissions, ['read("any")'], 'die Datei blieb festgelegt, ohne delete(owner): der Client kann sie nicht mehr löschen');
assert.equal(filePuts(fake, 'logo1').length, 2);
// eine Zeile im Log, mit Zeilen- und Datei-ID (ohne die Datei findet niemand mehr den Rest: die Zeile ist ja weg), nie JWT oder Konto
const lines = logs.filter((line) => line.includes('restore failed'));
assert.equal(lines.length, 1);
assert.match(lines[0], new RegExp(`^\\[aircard-api\\] \\S+ restore failed for logo ${rowOf('logo1')} \\(file logo1\\)$`));
assert.equal(logs.filter((line) => line.includes('rollback failed')).length, 0, 'die Zeile ließ sich zurücknehmen');
assert.ok(!logs.join('\n').includes(JWT_ME));
assert.ok(!logs.join('\n').includes(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') || line.includes('restore 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 <id> (file <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); 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 Konto und Profil) 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(callsBeyondProfile(fake).length, 0, 'ungültige Anfragen erreichen Appwrite (außer Konto und Profil) 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(callsBeyondProfile(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:<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(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|<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(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);
assert.equal(fake.calls.filter((c) => c.method === 'PUT' || c.method === 'PATCH').length, 0, 'ein schon ausgeblendetes Logo wird nicht noch einmal ausgeblendet');
});
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);
assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")'], 'auch nicht ausgeblendet: wer nicht löschen darf, ändert nichts');
assert.equal(fake.calls.filter((c) => c.method === 'PUT' || c.method === 'PATCH').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);
assert.deepEqual(fake.row('logos', 'logo-1').$permissions, HIDDEN_PERMISSIONS, 'der Anker ist unsichtbar');
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);
});
test('Logo löschen: zuerst wird das Logo ausgeblendet (Datei, dann Zeile, genau die ausgeblendeten Rechte), danach folgen Datei, Herzen, Meldungen und zuletzt die Zeile', async () => {
const { remove, fake } = await setup();
seedLogo(fake);
seedLikes(fake, 'logo-1', ['u1']);
seedReports(fake, 'logo-1', ['h1']);
const file = fileOf('logo-1');
assert.equal((await remove('logo-1')).status, 200);
const at = (predicate) => fake.calls.findIndex(predicate);
const hideFile = at((c) => c.method === 'PUT' && c.path.endsWith(`/files/${file}`));
const hideRow = at((c) => c.method === 'PATCH' && c.path.endsWith('/tables/logos/rows/logo-1'));
const dropFile = at((c) => c.method === 'DELETE' && c.path.endsWith(`/files/${file}`));
const dropLike = at((c) => c.method === 'DELETE' && c.path.includes('/tables/logo_likes/rows/'));
const dropReport = at((c) => c.method === 'DELETE' && c.path.includes('/tables/logo_reports/rows/'));
const dropRow = at((c) => c.method === 'DELETE' && c.path.endsWith('/tables/logos/rows/logo-1'));
const order = [hideFile, hideRow, dropFile, dropLike, dropReport, dropRow];
assert.ok(order.every((index) => index !== -1), `jeder Schritt kam vor: ${order}`);
assert.deepEqual(order, [...order].sort((a, b) => a - b), 'Reihenfolge: Datei ausblenden, Zeile ausblenden, Datei, Herzen, Meldungen, Zeile löschen');
assert.deepEqual(fake.calls[hideFile].body, { permissions: HIDDEN_PERMISSIONS });
assert.deepEqual(fake.calls[hideRow].body, { data: { hidden: true }, permissions: HIDDEN_PERMISSIONS });
assert.equal(fake.row('logos', 'logo-1'), null);
assert.equal(fake.file(file), null);
});
test('Logo löschen: bricht es ab, nachdem die Datei gelöscht ist, bleibt höchstens eine unsichtbare Zeile (genau die ausgeblendeten Rechte des Hochladers), die Wiederholung vollendet es', async () => {
const failures = [
['Herzen lesen', (c) => c.method === 'GET' && c.path.endsWith('/tables/logo_likes/rows')],
['Meldungen lesen', (c) => c.method === 'GET' && c.path.endsWith('/tables/logo_reports/rows')],
['Zeile löschen', (c) => c.method === 'DELETE' && c.path.includes('/tables/logos/rows/')],
];
// der Hochlader löscht sein Logo, und ein Admin löscht das Logo eines anderen (die Rechte der Zeile bleiben die des Hochladers)
for (const [who, owner, jwt] of [['Hochlader', ME, JWT_ME], ['Admin', OTHER, JWT_ADMIN]]) {
for (const [step, failing] of failures) {
const label = `${who}, Abbruch bei „${step}“`;
const { remove, fake } = await setup();
seedLogo(fake, { owner });
seedLikes(fake, 'logo-1', ['u1', 'u2']);
seedReports(fake, 'logo-1', ['h1']);
assert.equal(guestCanRead(fake.row('logos', 'logo-1')), true, `${label}: vorher öffentlich`);
fake.failNext(failing, { status: 500 });
expectError(await remove('logo-1', jwt), 502, 'unavailable', label);
// die Datei ist weg (unter ihrer ID könnte jemand eine neue anlegen), die Zeile steht noch, aber kein Gast liest sie mehr
const row = fake.row('logos', 'logo-1');
assert.equal(fake.file(fileOf('logo-1')), null, `${label}: Datei gelöscht`);
assert.ok(row, `${label}: die Zeile bleibt als Anker`);
assert.equal(row.hidden, true, `${label}: hidden`);
assert.deepEqual(row.$permissions, hiddenRowPermissions(owner), `${label}: genau die ausgeblendeten Rechte`);
assert.equal(guestCanRead(row), false, `${label}: kein Gast liest die Zeile`);
// die Wiederholung vollendet das Löschen, ohne noch einmal auszublenden
const seen = fake.calls.length;
assert.equal((await remove('logo-1', jwt)).status, 200, `${label}: Wiederholung`);
assert.equal(fake.row('logos', 'logo-1'), null, `${label}: Zeile weg`);
assert.equal(fake.rows('logo_likes').length, 0, `${label}: Herzen weg`);
assert.equal(fake.rows('logo_reports').length, 0, `${label}: Meldungen weg`);
assert.equal(fake.calls.slice(seen).filter((c) => c.method === 'PUT' || c.method === 'PATCH').length, 0, `${label}: nicht noch einmal ausgeblendet`);
}
}
});
test('Logo löschen: scheitert schon das Ausblenden, ist noch nichts gelöscht (Zeile öffentlich, Datei und Herzen da), die Wiederholung klappt', async () => {
const { remove, fake } = await setup();
seedLogo(fake);
seedLikes(fake, 'logo-1', ['u1']);
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith(`/files/${fileOf('logo-1')}`), { status: 500 });
expectError(await remove('logo-1'), 502, 'unavailable');
assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")']);
assert.equal(fake.row('logos', 'logo-1').hidden, false);
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, 'bis hierher wurde nichts gelöscht');
assert.equal((await remove('logo-1')).status, 200);
assert.equal(fake.row('logos', 'logo-1'), null);
assert.equal(fake.file(fileOf('logo-1')), null);
assert.equal(fake.rows('logo_likes').length, 0);
});
// ---- 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, []);
});