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

1793 lines
90 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// Tests für deploy/api/app.js: echte HTTP-Anfragen auf Port 0 gegen createApp, dahinter ein Fake-Appwrite im
// Speicher (fake-appwrite.js) und das echte appwrite.js. Aufruf: node --test "deploy/api/*.test.js"
const { test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const http = require('node:http');
const { createAppwrite, AppwriteError } = require('./appwrite.js');
const { MSG, HttpError, codeFor } = require('./common.js');
const { startFakeAppwrite, pngHeader, webpHeader } = require('./fake-appwrite.js');
const {
createApp,
visibleRowPermissions,
hiddenRowPermissions,
uploadFilePermissions,
visibleFilePermissions,
hiddenFilePermissions,
reporterHash,
reporterNetwork,
clientIp,
normalizeIp,
readImageSize,
validatePublish,
buildSearchText,
galleryRowId,
reportRowId,
} = require('./app.js');
const SALT = 'test-salt-0123456789abcdef0123456789abcdef';
const ME = 'user-me';
const OTHER = 'user-other';
const ADMIN = 'user-admin';
/** Konto ohne Benutzernamen (alle anderen haben aus `setup` ein Profil): Veröffentlichen geht erst mit Profil. */
const NEWBIE = 'user-new';
const JWT_ME = 'eyJhbGciOiJI.payload-me.signature-me';
const JWT_OTHER = 'eyJhbGciOiJI.payload-other.signature-other';
const JWT_ADMIN = 'eyJhbGciOiJI.payload-admin.signature-admin';
const JWT_NEW = 'eyJhbGciOiJI.payload-new.signature-new';
const MIB5 = 5 * 1024 * 1024;
const TAKEN = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.';
const NOT_CARD_FORMAT = 'Das Bild hat nicht das Kartenformat.';
const NOT_YOUR_CARD = 'Diese Karte gehört nicht zu deinem Konto.';
const TOO_MANY_CARDS = 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.';
// ---- Aufbau ---------------------------------------------------------------------------------
const cleanups = [];
afterEach(async () => {
for (const fn of cleanups.splice(0).reverse()) await fn();
});
async function setup() {
const fake = await startFakeAppwrite();
fake.addUser(JWT_ME, { id: ME });
fake.addUser(JWT_OTHER, { id: OTHER });
fake.addUser(JWT_ADMIN, { id: ADMIN, labels: ['admin'] });
fake.addUser(JWT_NEW, { id: NEWBIE });
// Veröffentlichen verlangt einen Benutzernamen (Tabelle `profiles`): ME, OTHER und ADMIN haben einen, NEWBIE nicht.
for (const id of [ME, OTHER, ADMIN]) fake.addProfile(id);
const logs = [];
const clock = { value: 1_700_000_000_000 };
const appwrite = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key });
const app = createApp({ appwrite, salt: SALT, now: () => clock.value, log: (line) => logs.push(String(line)) });
const server = http.createServer(app);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
cleanups.push(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
await fake.close();
});
const call = (method, path, options) => request(port, method, path, options);
const post = (path, body, jwt, headers = {}) =>
call('POST', path, { body, headers: { ...(jwt ? { 'X-Aircard-Jwt': jwt } : {}), ...headers } });
const report = (id, ip, reason = 'spam') => post('/api/melden', { id, reason }, null, ip ? { 'X-Real-Ip': ip } : {});
return { fake, app, logs, clock, port, call, post, report };
}
function request(port, method, path, { headers = {}, body, raw, contentType = 'application/json' } = {}) {
return new Promise((resolve, reject) => {
const payload = raw !== undefined ? raw : body === undefined ? undefined : JSON.stringify(body);
const requestHeaders = { ...headers };
if (payload !== undefined && contentType) requestHeaders['Content-Type'] = contentType;
if (payload !== undefined && !('Transfer-Encoding' in requestHeaders)) {
requestHeaders['Content-Length'] = Buffer.byteLength(payload);
}
const req = http.request({ host: '127.0.0.1', port, method, path, headers: requestHeaders }, (res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
let json = null;
try {
json = JSON.parse(text);
} catch {
// kein JSON
}
resolve({ status: res.statusCode, headers: res.headers, text, json });
});
});
req.on('error', reject);
if (payload !== undefined) req.write(payload);
req.end();
});
}
function publishBody(overrides = {}) {
return {
title: 'Mein Motiv',
issuer: 'Beispielbank',
tags: ['Blau', ' Neon '],
displayName: 'Kenso',
imageFileId: 'img1',
previewFileId: 'prev1',
sourceCardId: 'card1',
...overrides,
};
}
/** Zwei frisch hochgeladene Dateien und die private Karte, aus der veröffentlicht wird (Standard-sourceCardId `card1`). */
function seedUploads(fake, owner = ME, { image = 'img1', preview = 'prev1', card = 'card1' } = {}) {
fake.addFile({ id: image, owner });
fake.addFile({ id: preview, owner, mimeType: 'image/webp' });
if (card) fake.addCard(card, owner);
}
/** Legt eine veröffentlichte Karte direkt im Fake an (Zeile + zwei Dateien mit passenden Rechten). */
function seedCard(fake, { id = 'card-1', owner = ME, hidden = false, reportCount = 0 } = {}) {
const imageFileId = `${id}-img`;
const previewFileId = `${id}-prev`;
fake.addFile({ id: imageFileId, owner });
fake.addFile({ id: previewFileId, owner, mimeType: 'image/webp' });
// Veröffentlichte Dateien tragen nie delete(owner): sichtbar nur read("any"), ausgeblendet nur Autor und Admin lesen.
const filePermissions = hidden ? hiddenFilePermissions(owner) : visibleFilePermissions();
fake.file(imageFileId).$permissions = filePermissions;
fake.file(previewFileId).$permissions = filePermissions;
fake.addCard(`src-${id}`, owner);
fake.addRow(
'gallery',
id,
{
title: 'Karte',
issuer: '',
tags: '',
displayName: '',
imageFileId,
previewFileId,
sourceCardId: `src-${id}`,
ownerId: owner,
hidden,
reportCount,
searchText: 'karte',
},
hidden ? hiddenRowPermissions(owner) : visibleRowPermissions(),
);
return { imageFileId, previewFileId };
}
const storageCalls = (fake) => fake.calls.filter((c) => c.path.startsWith('/v1/storage/'));
const sha32 = (text) => crypto.createHash('sha256').update(text).digest('hex').slice(0, 32);
// ---- Reine Helfer ------------------------------------------------------------------------------
test('Rechte-Strings sind genau die aus der Spec', () => {
assert.deepEqual(visibleRowPermissions(), ['read("any")']);
assert.deepEqual(hiddenRowPermissions('abc'), ['read("user:abc")', 'read("label:admin")']);
// Abweichung von Spec §7 (Ruling): veröffentlichte Dateien tragen kein delete(owner), sonst ließe sich der Inhalt
// nach dem Veröffentlichen löschen und unter derselben ID neu anlegen.
assert.deepEqual(visibleFilePermissions(), ['read("any")']);
assert.deepEqual(hiddenFilePermissions('abc'), ['read("user:abc")', 'read("label:admin")']);
// frisch hochgeladene (noch nicht veröffentlichte) Dateien: mit delete(owner), damit der Client Fehlversuche aufräumt
assert.deepEqual(uploadFilePermissions('abc'), ['read("any")', 'delete("user:abc")']);
});
test('Rechte-Helfer lehnen unzulässige Nutzer-IDs ab (kein Einschleusen in Rechte-Strings)', () => {
for (const bad of ['a")', 'x","read("any', '', '../x', 5, null]) {
assert.throws(() => hiddenRowPermissions(bad));
assert.throws(() => uploadFilePermissions(bad));
assert.throws(() => hiddenFilePermissions(bad));
}
});
test('Zeilen-IDs sind deterministisch: erste 32 Hex von sha256(a|b)', () => {
assert.equal(galleryRowId('u1', 'c1'), sha32('u1|c1'));
assert.equal(reportRowId('g1', 'hash1'), sha32('g1|hash1'));
assert.match(galleryRowId('u1', 'c1'), /^[0-9a-f]{32}$/);
assert.notEqual(galleryRowId('u1', 'c1'), galleryRowId('u2', 'c1'));
});
test('reporterNetwork: IPv4 -> /24, IPv6 -> /48, IPv4-gemappte IPv6 wie IPv4', () => {
assert.equal(reporterNetwork('203.0.7.9'), '203.0.7.0/24');
assert.equal(reporterNetwork(' 203.0.7.200:5555 '), '203.0.7.0/24');
assert.equal(reporterNetwork('::ffff:203.0.7.9'), '203.0.7.0/24');
assert.equal(reporterNetwork('2001:0DB8:abcd:0012:0000:0000:0000:0001'), '2001:db8:abcd::/48');
assert.equal(reporterNetwork('[2001:db8:abcd:ffff::9]'), '2001:db8:abcd::/48');
assert.equal(reporterNetwork('2001:db8::1'), '2001:db8:0::/48');
assert.equal(reporterNetwork('fe80::1%eth0'), 'fe80:0:0::/48');
assert.equal(reporterNetwork('kein-ip'), 'kein-ip');
assert.equal(reporterNetwork(undefined), '');
// normalizeIp (Rate-Limit je Anschluss) bleibt feiner: volle IPv4, IPv6 /64
assert.equal(normalizeIp('203.0.7.9'), '203.0.7.9');
});
test('reporterHash ist HMAC-SHA256 (Hex) des Netzes: drei Adressen aus einem /24 (IPv6: /48) sind ein Melder', () => {
assert.equal(reporterHash(SALT, '203.0.7.9'), crypto.createHmac('sha256', SALT).update('203.0.7.0/24').digest('hex'));
assert.notEqual(reporterHash('anderes-salz', '203.0.7.9'), reporterHash(SALT, '203.0.7.9'));
assert.equal(reporterHash(SALT, '203.0.7.9'), reporterHash(SALT, '203.0.7.200'));
assert.notEqual(reporterHash(SALT, '203.0.7.9'), reporterHash(SALT, '203.0.8.9'));
assert.equal(reporterHash(SALT, '2001:db8:abcd:12::1'), reporterHash(SALT, '2001:db8:abcd:ffff:ffff:eeee:dddd:2'));
assert.notEqual(reporterHash(SALT, '2001:db8:abcd:12::1'), reporterHash(SALT, '2001:db8:abce:12::1'));
});
test('normalizeIp: IPv4 bleibt, IPv4-gemappte IPv6 wird IPv4, sonst /64', () => {
assert.equal(normalizeIp('203.0.7.9'), '203.0.7.9');
assert.equal(normalizeIp(' 203.0.7.9:5555 '), '203.0.7.9');
assert.equal(normalizeIp('::ffff:203.0.7.9'), '203.0.7.9');
assert.equal(normalizeIp('::ffff:cb00:7107'), '203.0.113.7');
assert.equal(normalizeIp('2001:0DB8:abcd:0012:0000:0000:0000:0001'), '2001:db8:abcd:12::/64');
assert.equal(normalizeIp('2001:db8::1'), '2001:db8:0:0::/64');
assert.equal(normalizeIp('::1'), '0:0:0:0::/64');
assert.equal(normalizeIp('[2001:db8:1:2::9]'), '2001:db8:1:2::/64');
assert.equal(normalizeIp('fe80::1%eth0'), 'fe80:0:0:0::/64');
assert.equal(normalizeIp('64:ff9b::192.0.2.33'), '64:ff9b:0:0::/64');
assert.equal(normalizeIp('kein-ip'), 'kein-ip');
assert.equal(normalizeIp(undefined), '');
});
test('clientIp: x-real-ip, sonst erstes x-forwarded-for, sonst Socket', () => {
const socket = { remoteAddress: '10.0.0.9' };
assert.equal(clientIp({ headers: { 'x-real-ip': '1.1.1.1', 'x-forwarded-for': '2.2.2.2' }, socket }), '1.1.1.1');
assert.equal(clientIp({ headers: { 'x-forwarded-for': ' 2.2.2.2 , 3.3.3.3' }, socket }), '2.2.2.2');
assert.equal(clientIp({ headers: {}, socket }), '10.0.0.9');
assert.equal(clientIp({ headers: { 'x-real-ip': '' }, socket }), '10.0.0.9');
});
test('buildSearchText: klein, mit Leerzeichen verbunden, höchstens 300 Zeichen', () => {
assert.equal(buildSearchText({ title: 'Mein MOTIV', issuer: 'Beispielbank', tags: ['blau', 'neon'] }), 'mein motiv beispielbank blau neon');
assert.equal(buildSearchText({ title: 'A', issuer: '', tags: [] }), 'a');
assert.equal(buildSearchText({ title: 'A', issuer: 'B', tags: 'x,y' }), 'a b x y');
assert.equal(buildSearchText({ title: 'x'.repeat(400), issuer: '', tags: [] }).length, 300);
});
test('validatePublish: normalisiert Tags und liefert saubere Daten', () => {
const result = validatePublish(publishBody({ title: ' Mein Motiv ', tags: [' Blau ', 'NEON', '', 'blau'], issuer: undefined, displayName: null }));
assert.equal(result.ok, true);
assert.deepEqual(result.data, {
title: 'Mein Motiv',
issuer: '',
tags: ['blau', 'neon'],
displayName: '',
imageFileId: 'img1',
previewFileId: 'prev1',
sourceCardId: 'card1',
});
});
test('validatePublish: Längen und Tags an den Grenzen', () => {
assert.equal(validatePublish(publishBody({ title: 'x'.repeat(60), issuer: 'y'.repeat(40), displayName: 'z'.repeat(30) })).ok, true);
assert.equal(validatePublish(publishBody({ tags: ['a', 'b', 'c', 'd', 'e'] })).ok, true);
assert.equal(validatePublish(publishBody({ tags: ['x'.repeat(20)] })).ok, true);
assert.equal(validatePublish(publishBody({ tags: ['a', 'b', 'c', 'd', 'e', 'f'] })).ok, false);
assert.equal(validatePublish(publishBody({ tags: ['x'.repeat(21)] })).ok, false);
const bad = validatePublish(publishBody({ title: '' }));
assert.equal(bad.ok, false);
assert.equal(typeof bad.message, 'string');
});
// ---- Gesundheit, Routing, Body ------------------------------------------------------------------
test('GET /api/gesundheit antwortet {ok:true} mit sicheren Kopfzeilen', async () => {
const { call } = await setup();
const res = await call('GET', '/api/gesundheit?x=1');
assert.equal(res.status, 200);
assert.deepEqual(res.json, { ok: true });
assert.match(res.headers['content-type'], /^application\/json/);
assert.equal(res.headers['cache-control'], 'no-store');
assert.equal(res.headers['x-content-type-options'], 'nosniff');
});
test('Alles andere ist 404, auch falsche Methoden', async () => {
const { call, fake } = await setup();
for (const [method, path] of [
['GET', '/'],
['GET', '/api'],
['GET', '/api/melden'],
['POST', '/api/gesundheit'],
['POST', '/api/unbekannt'],
['POST', '/api/melden/'],
['POST', '/api/admin'],
['PUT', '/api/melden'],
['OPTIONS', '/api/melden'],
['DELETE', '/api/zurueckziehen'],
['GET', '/v1/account'],
]) {
const res = await call(method, path, { body: {} });
assert.equal(res.status, 404, `${method} ${path}`);
assert.equal(typeof res.json.error, 'string');
}
assert.equal(fake.calls.length, 0);
});
test('Nur application/json: sonst 415 (auch mit Charset-Zusatz erlaubt)', async () => {
const { call, fake } = await setup();
seedCard(fake);
for (const contentType of ['text/plain', 'application/x-www-form-urlencoded', 'application/jsonx', null]) {
const res = await call('POST', '/api/melden', { raw: '{"id":"card-1","reason":"spam"}', contentType });
assert.equal(res.status, 415, String(contentType));
}
const ok = await call('POST', '/api/melden', {
raw: '{"id":"card-1","reason":"spam"}',
contentType: 'application/json; charset=utf-8',
headers: { 'X-Real-Ip': '203.0.1.9' },
});
assert.equal(ok.status, 200);
});
test('Body über 8 KB: 413 (mit Content-Length und chunked), genau 8 KB geht noch durch', async () => {
const { call, fake } = await setup();
const padded = (length) => {
const base = '{"id":"nope","reason":"spam"}';
return base + ' '.repeat(length - Buffer.byteLength(base));
};
const declared = await call('POST', '/api/melden', { raw: padded(8193) });
assert.equal(declared.status, 413);
assert.equal(typeof declared.json.error, 'string');
assert.equal(declared.headers.connection, 'close', 'nach einem zu großen Body wird die Verbindung geschlossen');
const chunked = await call('POST', '/api/veroeffentlichen', {
raw: padded(9000),
headers: { 'Transfer-Encoding': 'chunked', 'X-Aircard-Jwt': JWT_ME },
});
assert.equal(chunked.status, 413);
const exact = await call('POST', '/api/melden', { raw: padded(8192), headers: { 'X-Real-Ip': '203.0.2.9' } });
assert.equal(exact.status, 404, 'genau 8192 Byte werden verarbeitet (Karte existiert nicht)');
assert.equal(fake.calls.filter((c) => c.path.includes('/account')).length, 0, 'bei 413 wurde nichts bei Appwrite abgefragt');
});
test('Kaputtes JSON, Array oder leerer Body: 400', async () => {
const { call } = await setup();
for (const raw of ['{kaputt', '[]', '"text"', 'null', '']) {
const res = await call('POST', '/api/melden', { raw });
assert.equal(res.status, 400, JSON.stringify(raw));
assert.equal(typeof res.json.error, 'string');
}
});
// ---- Veröffentlichen --------------------------------------------------------------------------------
test('Veröffentlichen: legt eine sichtbare Zeile an (Rechte, searchText, ownerId)', async () => {
const { post, fake } = await setup();
seedUploads(fake);
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 201);
assert.equal(res.json.id, galleryRowId(ME, 'card1'));
const row = fake.row('gallery', res.json.id);
assert.deepEqual(row.$permissions, ['read("any")']);
assert.equal(row.title, 'Mein Motiv');
assert.equal(row.issuer, 'Beispielbank');
assert.equal(row.tags, ',blau,neon,', 'Tags mit Trennzeichen an beiden Enden (exakter Tag-Filter)');
assert.equal(row.displayName, 'Kenso');
assert.equal(row.imageFileId, 'img1');
assert.equal(row.previewFileId, 'prev1');
assert.equal(row.sourceCardId, 'card1');
assert.equal(row.ownerId, ME);
assert.equal(row.hidden, false);
assert.equal(row.reportCount, 0);
assert.equal(row.searchText, 'mein motiv beispielbank blau neon');
// der Server-Key hängt an jedem Datenaufruf, das JWT nur am Konto-Aufruf
const accountCall = fake.calls.find((c) => c.path === '/v1/account');
assert.equal(accountCall.headers['x-appwrite-jwt'], JWT_ME);
assert.equal(accountCall.headers['x-appwrite-key'], undefined);
for (const c of fake.calls.filter((x) => x.path !== '/v1/account')) {
assert.equal(c.headers['x-appwrite-key'], fake.key);
assert.equal(c.headers['x-appwrite-jwt'], undefined);
}
});
test('Veröffentlichen: Bild und Vorschau dürfen PNG oder WebP sein, Anzeigename und Herausgeber sind optional', async () => {
const { post, fake } = await setup();
fake.addCard('card1', ME);
fake.addFile({ id: 'img1', owner: ME, mimeType: 'image/png', size: MIB5 });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/png', role: 'preview' });
const res = await post('/api/veroeffentlichen', { title: 'Nur Titel', imageFileId: 'img1', previewFileId: 'prev1', sourceCardId: 'card1' }, JWT_ME);
assert.equal(res.status, 201);
const row = fake.row('gallery', res.json.id);
assert.equal(row.issuer, '');
assert.equal(row.tags, '');
assert.equal(row.displayName, '');
});
test('Veröffentlichen ohne oder mit ungültigem JWT: 401 und nichts angelegt', async () => {
const { post, fake } = await setup();
seedUploads(fake);
assert.equal((await post('/api/veroeffentlichen', publishBody())).status, 401);
assert.equal((await post('/api/veroeffentlichen', publishBody(), 'xxx.yyy.zzz')).status, 401);
assert.equal((await post('/api/veroeffentlichen', publishBody(), 'zwei teile')).status, 401);
assert.equal((await post('/api/veroeffentlichen', {}, '')).status, 401);
assert.equal(fake.rows('gallery').length, 0);
assert.equal(storageCalls(fake).length, 0);
assert.equal(fake.calls.filter((c) => c.path === '/v1/account').length, 1, 'fehlendes oder kaputtes JWT erreicht Appwrite gar nicht erst');
});
test('Veröffentlichen ohne Benutzernamen: 403 usernameRequired noch vor jeder anderen Prüfung (auch vor Body, Dateien und Karte), nichts angelegt', async () => {
const { post, fake } = await setup();
seedUploads(fake, NEWBIE);
const publish = (body) => post('/api/veroeffentlichen', body, JWT_NEW);
expectError(await publish(publishBody()), 403, 'usernameRequired', 'gültiger Body');
expectError(await publish({}), 403, 'usernameRequired', 'ungültiger Body (kein Titel, keine Dateien)');
expectError(await publish(publishBody({ title: '' })), 403, 'usernameRequired', 'Titel fehlt');
expectError(await publish(publishBody({ imageFileId: 'gibts-nicht' })), 403, 'usernameRequired', 'Datei gibt es nicht');
expectError(await publish(publishBody({ sourceCardId: 'fremde-karte' })), 403, 'usernameRequired', 'Karte gibt es nicht');
expectError(await publish(null), 403, 'usernameRequired', 'kein Body');
assert.equal(fake.rows('gallery').length, 0);
assert.equal(storageCalls(fake).length, 0, 'keine Datei wird angefasst');
assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-new")'], 'die Dateien bleiben, wie sie waren');
// Appwrite sieht je Versuch nur die Prüfung des JWT und die Suche nach dem Profil (Zeilen-ID = Konto-ID)
assert.deepEqual(
fake.calls.filter((c) => c.path !== '/v1/account').map((c) => `${c.method} ${c.path}`),
Array(6).fill('GET /v1/tablesdb/aircard/tables/profiles/rows/user-new'),
);
});
test('Veröffentlichen: das Profil gehört dem Konto – ein Profil der anderen Konten hilft nicht, mit eigenem Profil (über /api/profil) geht es', async () => {
const { post, fake } = await setup();
seedUploads(fake, NEWBIE);
expectError(await post('/api/veroeffentlichen', publishBody(), JWT_NEW), 403, 'usernameRequired');
const named = await post('/api/profil', { username: 'Neuling' }, JWT_NEW);
assert.deepEqual(named.json, { username: 'Neuling' });
const res = await post('/api/veroeffentlichen', publishBody(), JWT_NEW);
assert.equal(res.status, 201);
assert.equal(fake.row('gallery', res.json.id).ownerId, NEWBIE);
});
test('Veröffentlichen: lässt sich das Profil nicht prüfen (Appwrite-Fehler), 502 und nichts wird angelegt; der nächste Versuch gelingt', async () => {
const { post, fake } = await setup();
seedUploads(fake);
fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/tables/profiles/rows/user-me'), { status: 500 });
expectError(await post('/api/veroeffentlichen', publishBody(), JWT_ME), 502, 'unavailable');
assert.equal(fake.rows('gallery').length, 0);
assert.equal(storageCalls(fake).length, 0);
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201);
});
test('Veröffentlichen mit fremder Datei: 403 und nichts angelegt', async () => {
const { post, fake } = await setup();
fake.addFile({ id: 'img1', owner: OTHER });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' });
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 403);
assert.equal(typeof res.json.error, 'string');
fake.addFile({ id: 'img2', owner: ME });
fake.addFile({ id: 'prev2', owner: OTHER, mimeType: 'image/webp' });
res = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img2', previewFileId: 'prev2' }), JWT_ME);
assert.equal(res.status, 403);
// Rechte ohne delete("user:me"): nur lesen reicht nicht
fake.addFile({ id: 'img3', owner: ME, permissions: ['read("any")', 'read("user:user-me")'] });
fake.addFile({ id: 'prev3', owner: ME, mimeType: 'image/webp' });
res = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img3', previewFileId: 'prev3' }), JWT_ME);
assert.equal(res.status, 403);
assert.equal(fake.rows('gallery').length, 0);
});
test('Veröffentlichen mit Datei in falschem Bucket: 403', async () => {
const { post, fake } = await setup();
fake.addFile({ id: 'img1', owner: ME, bucket: 'card-images' });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' });
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 403);
assert.equal(fake.rows('gallery').length, 0);
});
test('Veröffentlichen mit fehlender Datei: 400 "Bild nicht gefunden."', async () => {
const { post, fake } = await setup();
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' });
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 400);
assert.equal(res.json.error, 'Bild nicht gefunden.');
fake.addFile({ id: 'img1', owner: ME });
res = await post('/api/veroeffentlichen', publishBody({ previewFileId: 'ghost' }), JWT_ME);
assert.equal(res.status, 400);
assert.equal(res.json.error, 'Bild nicht gefunden.');
assert.equal(fake.rows('gallery').length, 0);
});
test('Veröffentlichen: nur PNG/WebP bis 5 MiB, sonst 400', async () => {
const { post, fake } = await setup();
fake.addCard('card1', ME);
const attempt = (image, preview) => {
fake.addFile({ id: 'img1', owner: ME, ...image });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', ...preview });
return post('/api/veroeffentlichen', publishBody(), JWT_ME);
};
assert.equal((await attempt({ mimeType: 'image/jpeg' }, {})).status, 400);
assert.equal((await attempt({ mimeType: 'application/pdf' }, {})).status, 400);
assert.equal((await attempt({}, { mimeType: 'image/svg+xml' })).status, 400);
assert.equal((await attempt({ size: MIB5 + 1 }, {})).status, 400);
assert.equal((await attempt({}, { size: MIB5 + 1 })).status, 400);
assert.equal(fake.rows('gallery').length, 0);
assert.equal((await attempt({ size: MIB5 }, { size: MIB5 })).status, 201);
});
test('Veröffentlichen doppelt: 409 "Diese Karte ist schon veröffentlicht."', async () => {
const { post, fake } = await setup();
seedUploads(fake);
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201);
fake.addFile({ id: 'img2', owner: ME });
fake.addFile({ id: 'prev2', owner: ME, mimeType: 'image/webp' });
const again = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img2', previewFileId: 'prev2' }), JWT_ME);
assert.equal(again.status, 409);
assert.equal(again.json.error, 'Diese Karte ist schon veröffentlicht.');
assert.equal(fake.rows('gallery').length, 1);
// eine andere Person veröffentlicht ihre eigene Karte (Zeilen-ID enthält den Besitzer)
seedUploads(fake, OTHER, { image: 'img9', preview: 'prev9', card: 'card9' });
const other = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img9', previewFileId: 'prev9', sourceCardId: 'card9' }), JWT_OTHER);
assert.equal(other.status, 201);
assert.notEqual(other.json.id, galleryRowId(ME, 'card1'));
assert.equal(fake.rows('gallery').length, 2);
});
test('Veröffentlichen nach Zurückziehen ist wieder möglich (gleiche Zeilen-ID)', async () => {
const { post, fake } = await setup();
seedUploads(fake);
const first = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal((await post('/api/zurueckziehen', { id: first.json.id }, JWT_ME)).status, 200);
seedUploads(fake);
const second = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(second.status, 201);
assert.equal(second.json.id, first.json.id);
});
test('Veröffentlichen: Validierung liefert 400 und fasst Appwrite (Dateien, Zeilen) nicht an', async () => {
const { post, fake } = await setup();
seedUploads(fake);
const cases = [
['leerer Titel', { title: '' }, /Titel/],
['nur Leerzeichen als Titel', { title: ' ' }, /Titel/],
['Titel fehlt', { title: undefined }, /Titel/],
['Titel zu lang', { title: 'x'.repeat(61) }, /Titel/],
['Titel keine Zeichenkette', { title: 42 }, /Titel/],
['Titel mit Steuerzeichen', { title: 'a\nb' }, /Titel/],
['Herausgeber zu lang', { issuer: 'x'.repeat(41) }, /Herausgeber/],
['Anzeigename zu lang', { displayName: 'x'.repeat(31) }, /Anzeigename/],
['6 Tags', { tags: ['a', 'b', 'c', 'd', 'e', 'f'] }, /Tags/],
['Tag zu lang', { tags: ['x'.repeat(21)] }, /Tag/],
['Tags als Zeichenkette', { tags: 'a,b' }, /Tags/],
['Tag keine Zeichenkette', { tags: ['a', 5] }, /Tags/],
['Tag mit Komma', { tags: ['a,b'] }, /Komma/],
['Titel mit Nullbreiten-Leerzeichen', { title: 'a\u200bb' }, /Titel/],
['Titel nur aus Nullbreitenzeichen', { title: '\u200b\u200d\ufeff' }, /Titel/],
['Titel mit Rechts-nach-links-Überschreibung', { title: 'foto\u202egnp.exe' }, /Titel/],
['Titel mit Isolate', { title: 'a\u2066b' }, /Titel/],
['Titel mit C1-Steuerzeichen', { title: 'a\u0085b' }, /Titel/],
['Herausgeber mit Bidi-Zeichen', { issuer: 'Bank\u200f' }, /Herausgeber/],
['Anzeigename mit Wortverbinder', { displayName: 'Ken\u2060so' }, /Anzeigename/],
['Tag mit Nullbreiten-Zeichen', { tags: ['gol\u200bd'] }, /Tags/],
['Bild-ID mit ../', { imageFileId: '../x' }, /ID/],
['Bild-ID ..', { imageFileId: '..' }, /ID/],
['Bild-ID Zahl', { imageFileId: 123 }, /ID/],
['Bild-ID mit Schrägstrich', { imageFileId: 'a/b' }, /ID/],
['Bild-ID zu lang', { imageFileId: 'a'.repeat(37) }, /ID/],
['Bild-ID beginnt mit Punkt', { imageFileId: '.hidden' }, /ID/],
['Bild-ID leer', { imageFileId: '' }, /ID/],
['Vorschau-ID null', { previewFileId: null }, /ID/],
['Vorschau-ID mit Leerzeichen', { previewFileId: 'a b' }, /ID/],
['sourceCardId ../x', { sourceCardId: '../x' }, /ID/],
['sourceCardId Zahl', { sourceCardId: 7 }, /ID/],
['sourceCardId fehlt', { sourceCardId: undefined }, /ID/],
['Bild = Vorschau', { previewFileId: 'img1' }, /verschieden/],
];
for (const [label, overrides, pattern] of cases) {
const res = await post('/api/veroeffentlichen', publishBody(overrides), JWT_ME);
assert.equal(res.status, 400, label);
assert.match(res.json.error, pattern, label);
}
assert.equal(fake.rows('gallery').length, 0);
assert.equal(storageCalls(fake).length, 0, 'ungültige IDs erreichen Appwrite nie');
assert.equal((await post('/api/veroeffentlichen', publishBody({ tags: 'a,b' }), JWT_ME)).status, 400);
});
test('Veröffentlichen: Tags werden serverseitig erneut normalisiert', async () => {
const { post, fake } = await setup();
seedUploads(fake);
const res = await post('/api/veroeffentlichen', publishBody({ tags: [' BLAU ', 'Neon', '', ' ', 'blau'] }), JWT_ME);
assert.equal(res.status, 201);
assert.equal(fake.row('gallery', res.json.id).tags, ',blau,neon,');
});
test('Veröffentlichen: Appwrite-Ausfall ergibt 502 mit deutschem Text, nichts angelegt', async () => {
const { post, fake } = await setup();
seedUploads(fake);
fake.failNext((c) => c.path === '/v1/account', { status: 500 });
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 502);
assert.equal(res.json.error, 'Der Dienst ist gerade nicht erreichbar.');
fake.failNext((c) => c.method === 'POST' && c.path.endsWith('/rows'), { status: 500 });
res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 502);
assert.equal(fake.rows('gallery').length, 0);
});
// ---- Melden ---------------------------------------------------------------------------------------
test('Melden: drei verschiedene IPs blenden die Karte aus (Zeilen- und Dateirechte)', async () => {
const { report, fake } = await setup();
const { imageFileId, previewFileId } = seedCard(fake);
let res = await report('card-1', '203.0.1.9');
assert.deepEqual(res.json, { ok: true });
assert.equal(fake.row('gallery', 'card-1').reportCount, 1);
assert.equal(fake.row('gallery', 'card-1').hidden, false);
res = await report('card-1', '203.0.2.9', 'urheberrecht');
assert.deepEqual(res.json, { ok: true });
assert.equal(fake.row('gallery', 'card-1').reportCount, 2);
assert.deepEqual(fake.row('gallery', 'card-1').$permissions, ['read("any")']);
res = await report('card-1', '203.0.3.9', 'kartendaten');
assert.equal(res.status, 200);
assert.deepEqual(res.json, { ok: true });
const row = fake.row('gallery', 'card-1');
assert.equal(row.hidden, true);
assert.equal(row.reportCount, 3);
assert.deepEqual(row.$permissions, ['read("user:user-me")', 'read("label:admin")']);
const hiddenFile = ['read("user:user-me")', 'read("label:admin")'];
assert.deepEqual(fake.file(imageFileId).$permissions, hiddenFile);
assert.deepEqual(fake.file(previewFileId).$permissions, hiddenFile);
assert.equal(fake.rows('reports').length, 3);
// ausgeblendete Karte melden: 404
res = await report('card-1', '203.0.4.9');
assert.equal(res.status, 404);
assert.equal(res.json.error, 'Diese Karte ist nicht (mehr) verfügbar.');
assert.equal(fake.rows('reports').length, 3);
});
test('Melden: zwei Melder reichen nicht, die Meldungen speichern nur Hash, Grund und Karte', async () => {
const { report, fake } = await setup();
seedCard(fake);
await report('card-1', '203.0.1.9', 'spam');
await report('card-1', '203.0.2.9', 'sonstiges');
assert.equal(fake.row('gallery', 'card-1').hidden, false);
const hash = reporterHash(SALT, '203.0.1.9');
const stored = fake.row('reports', reportRowId('card-1', hash));
assert.ok(stored, 'Zeilen-ID = sha256(galleryId|reporterHash)');
assert.equal(stored.galleryId, 'card-1');
assert.equal(stored.reason, 'spam');
assert.equal(stored.reporterHash, hash);
assert.deepEqual(stored.$permissions, []);
assert.ok(!JSON.stringify(fake.rows('reports')).includes('203.0.'), 'keine IP im Klartext gespeichert');
});
test('Melden: dieselbe IP fünfmal zählt einmal, Antworten sind schonGemeldet', async () => {
const { report, fake } = await setup();
seedCard(fake);
const first = await report('card-1', '203.0.9.9');
assert.deepEqual(first.json, { ok: true });
for (let i = 0; i < 4; i += 1) {
const again = await report('card-1', '203.0.9.9', i % 2 ? 'spam' : 'unangemessen');
assert.equal(again.status, 200);
assert.deepEqual(again.json, { schonGemeldet: true });
}
assert.equal(fake.row('gallery', 'card-1').reportCount, 1);
assert.equal(fake.row('gallery', 'card-1').hidden, false);
assert.equal(fake.rows('reports').length, 1);
});
test('Melden: Rate-Limit 10 pro Stunde und IP, danach 429, nach einer Stunde wieder erlaubt', async () => {
const { report, fake, clock } = await setup();
seedCard(fake);
for (let i = 0; i < 10; i += 1) {
const res = await report('card-1', '203.0.50.9');
assert.equal(res.status, 200, `Meldung ${i + 1}`);
}
let res = await report('card-1', '203.0.50.9');
assert.equal(res.status, 429);
assert.equal(res.json.error, 'Zu viele Meldungen – bitte später nochmal.');
assert.equal(res.headers['retry-after'], '3600');
// eine andere IP ist nicht betroffen
assert.equal((await report('card-1', '203.0.51.9')).status, 200);
// knapp vor Ablauf noch gesperrt, nach einer Stunde wieder frei
clock.value += 3600_000 - 1;
assert.equal((await report('card-1', '203.0.50.9')).status, 429);
clock.value += 1;
res = await report('card-1', '203.0.50.9');
assert.equal(res.status, 200);
assert.deepEqual(res.json, { schonGemeldet: true });
});
test('Melden: abgelaufene Rate-Limit-Einträge werden entfernt', async () => {
const { report, fake, clock, app } = await setup();
seedCard(fake);
for (const ip of ['203.0.1.9', '203.0.2.9', '203.0.3.9']) await report('card-1', ip);
assert.equal(app.rateLimitEntries(), 3);
clock.value += 3600_000 + 60_000;
await report('card-1', '203.0.4.9');
assert.equal(app.rateLimitEntries(), 1, 'die drei alten Einträge sind weg, nur der neue bleibt');
});
test('Melden: Ungültige Anfragen (Grund, ID) zählen nicht zum Limit und erreichen Appwrite nicht', async () => {
const { report, post, fake, app } = await setup();
seedCard(fake);
for (const reason of ['gibt-es-nicht', '', null, 7, undefined, 'SPAM']) {
const res = await post('/api/melden', { id: 'card-1', reason }, null, { 'X-Real-Ip': '203.0.1.9' });
assert.equal(res.status, 400, String(reason));
assert.equal(typeof res.json.error, 'string');
}
for (const id of ['../x', '..', 42, null, undefined, 'a/b', 'a'.repeat(37), '']) {
const res = await post('/api/melden', { id, reason: 'spam' }, null, { 'X-Real-Ip': '203.0.1.9' });
assert.equal(res.status, 400, String(id));
}
assert.equal(fake.calls.length, 0);
assert.equal(app.rateLimitEntries(), 0);
assert.equal((await report('card-1', '203.0.1.9')).status, 200);
});
test('Melden: nicht vorhandene Karte ist 404', async () => {
const { report, fake } = await setup();
const res = await report('gibts-nicht', '203.0.1.9');
assert.equal(res.status, 404);
assert.equal(res.json.error, 'Diese Karte ist nicht (mehr) verfügbar.');
assert.equal(fake.rows('reports').length, 0);
});
test('Melden: gültige Gründe sind unangemessen, kartendaten, urheberrecht, spam, sonstiges', async () => {
const { report, fake } = await setup();
seedCard(fake);
const reasons = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'];
for (let i = 0; i < reasons.length; i += 1) {
const res = await report('card-1', `198.51.${100 + i}.1`, reasons[i]);
assert.equal(res.status, 200, reasons[i]);
if (i === 2) break; // ab 3 Meldern ist die Karte weg
}
const stored = fake.rows('reports').map((r) => r.reason).sort();
assert.deepEqual(stored, ['kartendaten', 'unangemessen', 'urheberrecht']);
});
test('Melden: IPv6-Adressen aus demselben /48 zählen als ein Melder', async () => {
const { report, fake } = await setup();
seedCard(fake);
assert.deepEqual((await report('card-1', '2001:db8:abcd:12::1')).json, { ok: true });
assert.deepEqual((await report('card-1', '2001:db8:abcd:12:aaaa:bbbb:cccc:dddd')).json, { schonGemeldet: true });
assert.deepEqual((await report('card-1', '2001:db8:abcd:13::1')).json, { schonGemeldet: true }, 'anderes /64, selbes /48');
assert.deepEqual((await report('card-1', '2001:db8:abce:12::1')).json, { ok: true });
assert.equal(fake.row('gallery', 'card-1').reportCount, 2);
});
test('Melden: drei Adressen aus einem IPv4-/24 zählen als ein Melder und blenden nicht aus', async () => {
const { report, fake } = await setup();
seedCard(fake);
assert.deepEqual((await report('card-1', '198.51.100.1')).json, { ok: true });
assert.deepEqual((await report('card-1', '198.51.100.77')).json, { schonGemeldet: true });
assert.deepEqual((await report('card-1', '198.51.100.254')).json, { schonGemeldet: true });
assert.equal(fake.row('gallery', 'card-1').reportCount, 1);
assert.equal(fake.row('gallery', 'card-1').hidden, false);
// drei verschiedene /24 blenden aus
await report('card-1', '198.51.101.1');
await report('card-1', '198.51.102.1');
assert.equal(fake.row('gallery', 'card-1').hidden, true);
});
test('Melden: das Rate-Limit zählt weiter je Anschluss (volle IPv4), nicht je Netz', async () => {
const { report, fake } = await setup();
seedCard(fake);
// 10 Meldungen von einer Adresse sind erlaubt, die 11. nicht; eine Nachbaradresse im selben /24 ist noch frei
for (let i = 0; i < 10; i += 1) assert.equal((await report('card-1', '198.51.100.1')).status, 200);
assert.equal((await report('card-1', '198.51.100.1')).status, 429);
assert.equal((await report('card-1', '198.51.100.2')).status, 200);
});
test('Melden: ohne X-Real-Ip gilt das erste X-Forwarded-For, sonst die Socket-Adresse', async () => {
const { post, fake } = await setup();
seedCard(fake);
const viaForwarded = (ip) => post('/api/melden', { id: 'card-1', reason: 'spam' }, null, { 'X-Forwarded-For': `${ip}, 10.0.0.1` });
assert.deepEqual((await viaForwarded('203.0.77.9')).json, { ok: true });
assert.deepEqual((await viaForwarded('203.0.77.9')).json, { schonGemeldet: true });
assert.deepEqual((await viaForwarded('203.0.78.9')).json, { ok: true });
// ohne beide Header zählt die Socket-Adresse (127.0.0.1) als dritter Melder
assert.deepEqual((await post('/api/melden', { id: 'card-1', reason: 'spam' })).json, { ok: true });
assert.equal(fake.row('gallery', 'card-1').hidden, true, 'drei verschiedene Quellen');
});
test('Melden: gleichzeitige Meldungen von drei IPs ergeben genau drei und blenden aus', async () => {
const { report, fake } = await setup();
seedCard(fake);
const results = await Promise.all([report('card-1', '203.0.1.9'), report('card-1', '203.0.2.9'), report('card-1', '203.0.3.9')]);
assert.deepEqual(results.map((r) => r.status), [200, 200, 200]);
const row = fake.row('gallery', 'card-1');
assert.equal(row.reportCount, 3);
assert.equal(row.hidden, true);
assert.equal(fake.rows('reports').length, 3);
});
test('Melden: eine verspätete Zählerstand-Änderung überschreibt neuere Stände nicht (Sperre je Karte)', async () => {
const { report, fake } = await setup();
seedCard(fake);
// Die erste Meldung schreibt ihren Zähler (1) erst nach 150 ms; währenddessen kommen zwei weitere.
fake.delayNext((c) => c.method === 'PATCH' && c.body && c.body.data && c.body.data.reportCount === 1, 150);
const first = report('card-1', '203.0.1.9');
await new Promise((resolve) => setTimeout(resolve, 40));
const others = await Promise.all([report('card-1', '203.0.2.9'), report('card-1', '203.0.3.9')]);
const firstResult = await first;
assert.deepEqual([firstResult.status, ...others.map((r) => r.status)], [200, 200, 200]);
const row = fake.row('gallery', 'card-1');
assert.equal(row.reportCount, 3);
assert.equal(row.hidden, true);
assert.deepEqual(row.$permissions, ['read("user:user-me")', 'read("label:admin")']);
});
test('Melden: eine doppelte Meldung heilt einen früher steckengebliebenen Zählerstand', async () => {
const { report, fake } = await setup();
seedCard(fake);
// drei Meldungen liegen schon vor, der Zähler an der Karte kam aber nie an (z. B. Ausfall mitten im Ablauf)
for (const ip of ['203.0.1.9', '203.0.2.9', '203.0.3.9']) {
const hash = reporterHash(SALT, ip);
fake.addRow('reports', reportRowId('card-1', hash), { galleryId: 'card-1', reason: 'spam', reporterHash: hash }, []);
}
assert.equal(fake.row('gallery', 'card-1').hidden, false);
const res = await report('card-1', '203.0.1.9');
assert.deepEqual(res.json, { schonGemeldet: true });
assert.equal(fake.row('gallery', 'card-1').hidden, true);
assert.equal(fake.row('gallery', 'card-1').reportCount, 3);
});
test('Melden: Ausfall beim Anlegen der Meldung ergibt 502 und zählt nicht', async () => {
const { report, fake } = await setup();
seedCard(fake);
fake.failNext((c) => c.method === 'POST' && c.path.endsWith('/tables/reports/rows'), { status: 500 });
const res = await report('card-1', '203.0.1.9');
assert.equal(res.status, 502);
assert.equal(fake.row('gallery', 'card-1').reportCount, 0);
assert.equal(fake.rows('reports').length, 0);
});
test('Melden: Ausfall beim Ausblenden endet mit 502, ein erneuter Versuch blendet aus', async () => {
const { report, fake } = await setup();
seedCard(fake, { reportCount: 0 });
await report('card-1', '203.0.1.9');
await report('card-1', '203.0.2.9');
fake.failNext((c) => c.method === 'PATCH' && c.path.includes('/tables/gallery/rows/'), { status: 500 });
const res = await report('card-1', '203.0.3.9');
assert.equal(res.status, 502);
assert.equal(fake.row('gallery', 'card-1').hidden, false);
const retry = await report('card-1', '203.0.3.9');
assert.equal(retry.status, 200);
assert.equal(fake.row('gallery', 'card-1').hidden, true);
});
// ---- Zurückziehen ----------------------------------------------------------------------------------
test('Zurückziehen durch den Autor löscht Zeile, beide Dateien und die Meldungen', async () => {
const { post, fake } = await setup();
const { imageFileId, previewFileId } = seedCard(fake);
seedCard(fake, { id: 'card-2' });
for (const [id, hash] of [['card-1', 'h1'], ['card-1', 'h2'], ['card-2', 'h3']]) {
fake.addRow('reports', reportRowId(id, hash), { galleryId: id, reason: 'spam', reporterHash: hash }, []);
}
const res = await post('/api/zurueckziehen', { id: 'card-1' }, JWT_ME);
assert.equal(res.status, 200);
assert.deepEqual(res.json, { ok: true });
assert.equal(fake.row('gallery', 'card-1'), null);
assert.equal(fake.file(imageFileId), null);
assert.equal(fake.file(previewFileId), null);
assert.deepEqual(fake.rows('reports').map((r) => r.galleryId), ['card-2']);
assert.ok(fake.row('gallery', 'card-2'), 'andere Karten bleiben');
assert.ok(fake.file('card-2-img'));
});
test('Zurückziehen durch Admin geht, auch bei ausgeblendeter Karte', async () => {
const { post, fake } = await setup();
seedCard(fake, { hidden: true, reportCount: 3 });
const res = await post('/api/zurueckziehen', { id: 'card-1' }, JWT_ADMIN);
assert.equal(res.status, 200);
assert.equal(fake.row('gallery', 'card-1'), null);
assert.equal(fake.file('card-1-img'), null);
});
test('Zurückziehen durch Fremde: 403 und nichts gelöscht', async () => {
const { post, fake } = await setup();
seedCard(fake);
const res = await post('/api/zurueckziehen', { id: 'card-1' }, JWT_OTHER);
assert.equal(res.status, 403);
assert.equal(typeof res.json.error, 'string');
assert.ok(fake.row('gallery', 'card-1'));
assert.ok(fake.file('card-1-img'));
assert.ok(fake.file('card-1-prev'));
});
test('Zurückziehen: 401 ohne JWT, 404 bei fehlender Karte, 400 bei ungültiger ID', async () => {
const { post, fake } = await setup();
seedCard(fake);
assert.equal((await post('/api/zurueckziehen', { id: 'card-1' })).status, 401);
assert.equal((await post('/api/zurueckziehen', { id: 'card-1' }, 'xxx.yyy.zzz')).status, 401);
assert.equal((await post('/api/zurueckziehen', { id: 'nope' }, JWT_ME)).status, 404);
for (const id of ['../x', '..', 5, null, undefined]) {
assert.equal((await post('/api/zurueckziehen', { id }, JWT_ME)).status, 400, String(id));
}
assert.ok(fake.row('gallery', 'card-1'));
});
test('Zurückziehen ist wiederholbar, wenn schon eine Datei fehlt (Wiederholung nach Teilausfall)', async () => {
const { post, fake } = await setup();
seedCard(fake);
fake.failNext((c) => c.method === 'DELETE' && c.path.includes('/tables/gallery/rows/'), { status: 500 });
assert.equal((await post('/api/zurueckziehen', { id: 'card-1' }, JWT_ME)).status, 502);
assert.ok(fake.row('gallery', 'card-1'), 'Zeile bleibt, bis alles andere weg ist');
assert.equal(fake.file('card-1-img'), null);
const retry = await post('/api/zurueckziehen', { id: 'card-1' }, JWT_ME);
assert.equal(retry.status, 200);
assert.equal(fake.row('gallery', 'card-1'), null);
});
// ---- Admin ------------------------------------------------------------------------------------------
test('Admin-Sichtbarkeit: Nicht-Admin 403, ohne JWT 401', async () => {
const { post, fake } = await setup();
seedCard(fake);
assert.equal((await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, JWT_ME)).status, 403);
assert.equal((await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, JWT_OTHER)).status, 403);
assert.equal((await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false })).status, 401);
assert.deepEqual(fake.row('gallery', 'card-1').$permissions, ['read("any")']);
assert.equal(fake.row('gallery', 'card-1').hidden, false);
});
test('Admin blendet aus: Zeilen- und Dateirechte, hidden=true, Meldungen bleiben', async () => {
const { post, fake } = await setup();
seedCard(fake, { reportCount: 1 });
fake.addRow('reports', reportRowId('card-1', 'h1'), { galleryId: 'card-1', reason: 'spam', reporterHash: 'h1' }, []);
const res = await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, JWT_ADMIN);
assert.equal(res.status, 200);
const row = fake.row('gallery', 'card-1');
assert.equal(row.hidden, true);
assert.equal(row.reportCount, 1);
assert.deepEqual(row.$permissions, ['read("user:user-me")', 'read("label:admin")']);
const hiddenFile = ['read("user:user-me")', 'read("label:admin")'];
assert.deepEqual(fake.file('card-1-img').$permissions, hiddenFile);
assert.deepEqual(fake.file('card-1-prev').$permissions, hiddenFile);
assert.equal(fake.rows('reports').length, 1);
});
test('Admin blendet ein: sichtbare Rechte, hidden=false, reportCount=0, Meldungen gelöscht', async () => {
const { post, fake, report } = await setup();
seedCard(fake, { hidden: true, reportCount: 3 });
seedCard(fake, { id: 'card-2' });
for (const [id, hash] of [['card-1', 'h1'], ['card-1', 'h2'], ['card-1', 'h3'], ['card-2', 'h4']]) {
fake.addRow('reports', reportRowId(id, hash), { galleryId: id, reason: 'spam', reporterHash: hash }, []);
}
const res = await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: true }, JWT_ADMIN);
assert.equal(res.status, 200);
const row = fake.row('gallery', 'card-1');
assert.equal(row.hidden, false);
assert.equal(row.reportCount, 0);
assert.deepEqual(row.$permissions, ['read("any")']);
assert.deepEqual(fake.file('card-1-img').$permissions, ['read("any")']);
assert.deepEqual(fake.file('card-1-prev').$permissions, ['read("any")']);
assert.deepEqual(fake.rows('reports').map((r) => r.galleryId), ['card-2']);
// die Karte ist wieder meldbar
assert.equal((await report('card-1', '203.0.1.9')).status, 200);
});
test('Admin-Sichtbarkeit: Body-Prüfung und fehlende Karte', async () => {
const { post, fake } = await setup();
seedCard(fake);
for (const body of [
{ id: 'card-1' },
{ id: 'card-1', sichtbar: 'ja' },
{ id: 'card-1', sichtbar: 1 },
{ id: '../x', sichtbar: true },
{ id: 7, sichtbar: true },
{},
]) {
assert.equal((await post('/api/admin/sichtbarkeit', body, JWT_ADMIN)).status, 400, JSON.stringify(body));
}
assert.equal((await post('/api/admin/sichtbarkeit', { id: 'nope', sichtbar: true }, JWT_ADMIN)).status, 404);
});
// ---- Logs und Fehler --------------------------------------------------------------------------------
test('Logs enthalten nie Key, Salz, JWT oder IPs im Klartext, aber das Präfix [aircard-api]', async () => {
const { post, report, fake, logs } = await setup();
seedUploads(fake);
seedCard(fake, { id: 'card-9' });
await post('/api/veroeffentlichen', publishBody(), JWT_ME);
await post('/api/veroeffentlichen', publishBody(), 'xxx.yyy.zzz');
await report('card-9', '203.0.7.9');
await report('card-9', '203.0.8.9');
await report('card-9', '203.0.9.9');
await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: true }, JWT_ADMIN);
fake.failNext((c) => c.method === 'GET' && c.path.includes('/rows/'), { status: 500 });
await post('/api/zurueckziehen', { id: 'card-9' }, JWT_ME);
await post('/api/zurueckziehen', { id: 'card-9' }, JWT_ME);
assert.ok(logs.length > 0);
const all = logs.join('\n');
for (const secret of [fake.key, SALT, JWT_ME, JWT_OTHER, JWT_ADMIN, 'xxx.yyy.zzz', '203.0.', '127.0.0.1', reporterHash(SALT, '203.0.7.9')]) {
assert.ok(!all.includes(secret), `Log enthält ${secret.slice(0, 12)}…`);
}
for (const line of logs) assert.ok(line.startsWith('[aircard-api] '), line);
});
test('Log-Flut: abgewiesene Anfragen (400/401/404/413/415/429) erzeugen keine Logzeilen', async () => {
const { call, post, report, logs } = await setup();
const big = `{"id":"x","reason":"spam"}${' '.repeat(9000)}`;
for (let i = 0; i < 12; i += 1) {
await report('gibts-nicht', '203.0.5.9'); // 404, ab der 11. Meldung 429
await call('POST', '/api/melden', { raw: '{"id":"x","reason":"spam"}', contentType: 'text/plain' }); // 415
await call('POST', '/api/melden', { raw: big }); // 413
await call('POST', '/api/melden', { raw: '{kaputt' }); // 400
await post('/api/melden', { id: '../x', reason: 'spam' }); // 400
await post('/api/veroeffentlichen', publishBody()); // 401
await post('/api/zurueckziehen', { id: 'x' }, 'zwei teile'); // 401
await call('GET', '/api/gibts-nicht'); // 404
}
const lastLimited = await report('gibts-nicht', '203.0.5.9');
assert.equal(lastLimited.status, 429, 'das Rate-Limit hat wirklich gegriffen');
assert.deepEqual(logs, []);
});
test('Log-Budget: höchstens 120 Zeilen pro Minute, der Rest wird gezählt und als Sammelzeile nachgetragen', async () => {
const logs = [];
const clock = { value: 1_700_000_000_000 };
const broken = {
accountFromJwt: async () => {
throw new AppwriteError(500, 'general_server_error', 'kaputt');
},
};
const app = createApp({ appwrite: broken, salt: SALT, now: () => clock.value, log: (line) => logs.push(String(line)) });
const server = http.createServer(app);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
cleanups.push(() => {
server.closeAllConnections();
return new Promise((resolve) => server.close(resolve));
});
const { port } = server.address();
const hit = () => request(port, 'POST', '/api/zurueckziehen', { body: { id: 'x' }, headers: { 'X-Aircard-Jwt': JWT_ME } });
for (let i = 0; i < 300; i += 1) assert.equal((await hit()).status, 502);
assert.equal(logs.length, 120, 'nach 120 Zeilen ist Schluss');
clock.value += 60_000;
await hit();
assert.equal(logs.length, 122, 'Sammelzeile + die neue Zeile');
assert.match(logs[120], /^\[aircard-api\] \S+ 180 log lines suppressed/);
assert.match(logs[121], /appwrite error status=500 type=general_server_error/);
});
test('Appwrite-Fehler: 502 bei 5xx und 429, 500 bei allem anderen (falscher Key, fehlende Tabelle)', async () => {
const { report, fake } = await setup();
seedCard(fake);
const isRowRead = (c) => c.method === 'GET' && c.path.includes('/tables/gallery/rows/');
const expectations = [
[{ status: 500, type: 'general_server_error' }, 502, 'Der Dienst ist gerade nicht erreichbar.'],
[{ status: 429, type: 'general_rate_limit_exceeded' }, 502, 'Der Dienst ist gerade nicht erreichbar.'],
[{ status: 404, type: 'table_not_found' }, 500, 'Interner Fehler.'],
[{ status: 404, type: 'database_not_found' }, 500, 'Interner Fehler.'],
[{ status: 404, type: 'general_route_not_found' }, 500, 'Interner Fehler.'],
[{ status: 401, type: 'general_unauthorized_scope' }, 500, 'Interner Fehler.'],
];
for (const [failure, status, message] of expectations) {
fake.failNext(isRowRead, failure);
const res = await report('card-1', '203.0.1.9');
assert.equal(res.status, status, `${failure.status} ${failure.type}`);
assert.equal(res.json.error, message);
}
assert.equal(fake.rows('reports').length, 0);
});
test('Fehlende Tabelle oder Bucket ist kein „Karte nicht verfügbar“, sondern ein 500', async () => {
const fake = await startFakeAppwrite();
fake.addUser(JWT_ME, { id: ME });
const appwrite = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key, database: 'falsche-db' });
const app = createApp({ appwrite, salt: SALT, log: () => {} });
const server = http.createServer(app);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
cleanups.push(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
await fake.close();
});
const { port } = server.address();
const res = await request(port, 'POST', '/api/melden', { body: { id: 'card-1', reason: 'spam' } });
assert.equal(res.status, 500);
assert.equal(res.json.error, 'Interner Fehler.');
});
test('Veröffentlichen legt beide Dateien auf genau read("any") fest: kein delete(owner) mehr (kanonischer Zustand)', async () => {
const { post, fake } = await setup();
fake.addCard('card1', ME);
fake.addFile({ id: 'img1', owner: ME, permissions: ['read("any")', 'delete("user:user-me")', 'update("user:user-me")', 'read("users")'] });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', permissions: ['read("any")', 'delete("user:user-me")', 'write("user:user-me")'] });
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 201);
assert.deepEqual(fake.file('img1').$permissions, ['read("any")']);
assert.deepEqual(fake.file('prev1').$permissions, ['read("any")']);
});
test('Löschen und Neuanlegen derselben Datei nach dem Veröffentlichen ist ausgeschlossen: Autor hat kein delete mehr', async () => {
const { post, fake } = await setup();
seedUploads(fake);
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201);
for (const id of ['img1', 'prev1']) {
assert.ok(!fake.file(id).$permissions.some((p) => p.startsWith('delete(') || p.startsWith('update(') || p.startsWith('write(')), id);
}
// dieselben (jetzt gepinnten) Dateien lassen sich nicht noch einmal einreichen
fake.addCard('card2', ME);
const again = await post('/api/veroeffentlichen', publishBody({ sourceCardId: 'card2' }), JWT_ME);
assert.equal(again.status, 403);
assert.equal(again.json.error, TAKEN);
});
test('Ein 409 beim Veröffentlichen fasst die Dateirechte der neu hochgeladenen Dateien nicht an', async () => {
const { post, fake } = await setup();
seedUploads(fake);
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201);
// Dieselbe private Karte nochmal, mit frisch hochgeladenen Dateien (z. B. zweiter Tab): 409, Dateien bleiben unverändert.
const extra = ['read("any")', 'delete("user:user-me")', 'update("user:user-me")'];
fake.addFile({ id: 'img2', owner: ME, permissions: extra });
fake.addFile({ id: 'prev2', owner: ME, mimeType: 'image/webp', permissions: extra });
const res = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img2', previewFileId: 'prev2' }), JWT_ME);
assert.equal(res.status, 409);
assert.deepEqual(fake.file('img2').$permissions, extra);
assert.deepEqual(fake.file('prev2').$permissions, extra);
assert.equal(fake.calls.filter((c) => c.method === 'PUT' && /\/files\/(img2|prev2)$/.test(c.path)).length, 0, 'kein PUT auf die neuen Dateien');
});
test('Dateien einer ausgeblendeten Karte lassen sich nicht unter anderer sourceCardId neu veröffentlichen (403)', async () => {
const { post, fake } = await setup();
const { imageFileId, previewFileId } = seedCard(fake, { id: 'hid', hidden: true, reportCount: 3 });
fake.addCard('andere-karte', ME);
const hiddenFile = ['read("user:user-me")', 'read("label:admin")'];
const res = await post('/api/veroeffentlichen', publishBody({ imageFileId, previewFileId, sourceCardId: 'andere-karte' }), JWT_ME);
assert.equal(res.status, 403);
assert.equal(res.json.error, TAKEN);
assert.equal(fake.rows('gallery').length, 1, 'keine neue Zeile');
assert.equal(fake.row('gallery', 'hid').hidden, true);
assert.deepEqual(fake.file(imageFileId).$permissions, hiddenFile);
assert.deepEqual(fake.file(previewFileId).$permissions, hiddenFile);
assert.equal(fake.calls.filter((c) => c.method === 'PUT').length, 0, 'Dateirechte bleiben unangetastet');
// auch mit der ursprünglichen sourceCardId bleibt es bei 403 (Dateien gesperrt) und nicht 409
const same = await post('/api/veroeffentlichen', publishBody({ imageFileId, previewFileId, sourceCardId: 'src-hid' }), JWT_ME);
assert.equal(same.status, 403);
});
test('Dateien einer sichtbaren Karte lassen sich nicht unter anderer sourceCardId neu veröffentlichen (403)', async () => {
const { post, fake } = await setup();
const { imageFileId, previewFileId } = seedCard(fake, { id: 'vis' });
fake.addCard('andere-karte', ME);
fake.addFile({ id: 'fresh-img', owner: ME });
fake.addFile({ id: 'fresh-prev', owner: ME, mimeType: 'image/webp' });
for (const [label, overrides] of [
['beide Dateien', { imageFileId, previewFileId }],
['nur das Bild', { imageFileId, previewFileId: 'fresh-prev' }],
['nur die Vorschau', { imageFileId: 'fresh-img', previewFileId }],
]) {
const res = await post('/api/veroeffentlichen', publishBody({ ...overrides, sourceCardId: 'andere-karte' }), JWT_ME);
assert.equal(res.status, 403, label);
assert.equal(res.json.error, TAKEN, label);
}
assert.equal(fake.rows('gallery').length, 1);
// frische Dateien gehen
const ok = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'fresh-img', previewFileId: 'fresh-prev', sourceCardId: 'andere-karte' }), JWT_ME);
assert.equal(ok.status, 201);
});
test('Überkreuz-Verwendung: Bild einer Karte als Vorschau der neuen (und umgekehrt) wird abgelehnt', async () => {
const { post, fake } = await setup();
seedCard(fake, { id: 'vis' }); // Bild vis-img, Vorschau vis-prev
fake.addCard('x1', ME);
fake.addCard('x2', ME);
fake.addFile({ id: 'fresh-img', owner: ME });
fake.addFile({ id: 'fresh-prev', owner: ME, mimeType: 'image/webp' });
// vis-img (PNG) darf Vorschau sein, vis-prev (WebP) darf Bild sein: Format allein verhindert die Kreuzung nicht
const asPreview = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'fresh-img', previewFileId: 'vis-img', sourceCardId: 'x1' }), JWT_ME);
assert.equal(asPreview.status, 403);
assert.equal(asPreview.json.error, TAKEN);
const asImage = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'vis-prev', previewFileId: 'fresh-prev', sourceCardId: 'x2' }), JWT_ME);
assert.equal(asImage.status, 403);
assert.equal(asImage.json.error, TAKEN);
assert.equal(fake.rows('gallery').length, 1);
});
test('Dateien ohne read("any") (nicht frisch hochgeladen) werden abgelehnt (403)', async () => {
const { post, fake } = await setup();
fake.addCard('card1', ME);
fake.addFile({ id: 'img1', owner: ME, permissions: ['delete("user:user-me")'] });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' });
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 403);
assert.equal(res.json.error, TAKEN);
fake.addFile({ id: 'img1', owner: ME });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', permissions: ['read("user:user-me")', 'delete("user:user-me")'] });
res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 403);
assert.equal(fake.rows('gallery').length, 0);
});
test('Prüfung der Dateinutzung und des Limits fragt nur die Zeilen des Autors ab (ein Aufruf, höchstens 20 Zeilen)', async () => {
const { post, fake } = await setup();
for (let i = 0; i < 40; i += 1) {
const owner = i % 2 === 0 ? OTHER : ME; // 20 Karten je Person
fake.addRow(
'gallery',
`row${i}`,
{ title: `K${i}`, imageFileId: `f${i}a`, previewFileId: `f${i}b`, sourceCardId: `s${i}`, ownerId: owner, hidden: false, reportCount: 0 },
['read("any")'],
);
}
// Zeile 39 gehört OTHER: ME hat 19 Zeilen (eine weniger als das Limit), OTHER 21
fake.rows('gallery').find((row) => row.$id === 'row39').ownerId = OTHER;
fake.addFile({ id: 'f37a', owner: ME, permissions: ['read("any")', 'delete("user:user-me")'] });
fake.addFile({ id: 'f37b', owner: ME, mimeType: 'image/webp' });
fake.addCard('neu1', ME);
const taken = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'f37a', previewFileId: 'f37b', sourceCardId: 'neu1' }), JWT_ME);
assert.equal(taken.status, 403, 'Karte 37 des Autors gehört zu seinen 19 Zeilen');
assert.equal(taken.json.error, TAKEN);
const lists = fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith('/tables/gallery/rows'));
assert.equal(lists.length, 1);
const search = decodeURIComponent(lists[0].search);
assert.ok(search.includes('"attribute":"ownerId","values":["user-me"]'));
assert.ok(search.includes('"method":"limit","values":[20]'));
seedUploads(fake);
fake.addCard('neu2', ME);
const ok = await post('/api/veroeffentlichen', publishBody({ sourceCardId: 'neu2' }), JWT_ME);
assert.equal(ok.status, 201);
});
test('Gleichzeitiges Veröffentlichen derselben Dateien unter zwei sourceCardIds: genau eines gelingt', async () => {
const { post, fake } = await setup();
seedUploads(fake);
fake.addCard('c1', ME);
fake.addCard('c2', ME);
// Das Anlegen der ersten Zeile verzögern, damit beide Anfragen die Prüfung vor dem Anlegen erreichen würden.
fake.delayNext((c) => c.method === 'POST' && c.path.endsWith('/tables/gallery/rows'), 80);
const results = await Promise.all([
post('/api/veroeffentlichen', publishBody({ sourceCardId: 'c1' }), JWT_ME),
post('/api/veroeffentlichen', publishBody({ sourceCardId: 'c2' }), JWT_ME),
]);
assert.deepEqual(results.map((r) => r.status).sort(), [201, 403]);
assert.equal(fake.rows('gallery').length, 1);
});
test('Veröffentlichen: scheitert das Festlegen der Dateirechte, wird die Zeile zurückgenommen', async () => {
const { post, fake } = await setup();
seedUploads(fake);
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/prev1'), { status: 500 });
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 502);
assert.equal(fake.rows('gallery').length, 0, 'keine halbfertige Zeile, die eine Wiederholung mit 409 blockiert');
// Das schon festgelegte Bild bekommt delete(owner) zurück, damit der Client den Fehlversuch aufräumen kann
assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-me")']);
// Datei verschwindet zwischen Prüfung und Festlegen: 400 und ebenfalls zurückgenommen
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/img1'), { status: 404, type: 'storage_file_not_found' });
res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 400);
assert.equal(res.json.error, 'Bild nicht gefunden.');
assert.equal(fake.rows('gallery').length, 0);
// danach geht ein neuer Versuch
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201);
});
test('Dateirechte setzen wartet auf beide Aufrufe, auch wenn einer scheitert (Sperre bleibt bis dahin)', async () => {
const { post, fake } = await setup();
seedCard(fake);
fake.delayNext((c) => c.method === 'PUT' && c.path.endsWith('/files/card-1-img'), 150);
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/card-1-prev'), { status: 500 });
const res = await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, JWT_ADMIN);
assert.equal(res.status, 502);
// Die Antwort kam erst, nachdem auch der langsame PUT durch war.
assert.deepEqual(fake.file('card-1-img').$permissions, ['read("user:user-me")', 'read("label:admin")']);
assert.deepEqual(fake.file('card-1-prev').$permissions, ['read("any")']);
assert.equal(fake.row('gallery', 'card-1').hidden, false, 'die Zeile wird erst nach den Dateien geändert');
});
test('Ein gültiges JWT nur in Authorization oder X-Appwrite-JWT wird ignoriert (401)', async () => {
const { post, fake } = await setup();
seedUploads(fake);
seedCard(fake);
const ignored = [
{ Authorization: `Bearer ${JWT_ME}` },
{ 'X-Appwrite-JWT': JWT_ME },
{ 'X-Appwrite-JWT': JWT_ADMIN, Authorization: `Bearer ${JWT_ADMIN}` },
];
for (const headers of ignored) {
assert.equal((await post('/api/veroeffentlichen', publishBody(), null, headers)).status, 401);
assert.equal((await post('/api/zurueckziehen', { id: 'card-1' }, null, headers)).status, 401);
assert.equal((await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, null, headers)).status, 401);
}
assert.equal(fake.calls.filter((c) => c.path === '/v1/account').length, 0, 'Appwrite wurde nie nach dem Konto gefragt');
assert.equal(fake.rows('gallery').length, 1);
assert.ok(fake.row('gallery', 'card-1'));
});
test('Konto-Antwort mit ungültiger $id ergibt 401, ohne labels oder mit falschem Typ ist die Person kein Admin', async () => {
const { post, fake } = await setup();
seedCard(fake);
for (const [jwt, account] of [
['bad.id.one', { $id: 'x","read("any' }],
['bad.id.two', { $id: 42 }],
['bad.id.three', { $id: '../x' }],
['bad.id.four', { $id: '' }],
['bad.id.five', {}],
['bad.id.six', { $id: 'a'.repeat(37) }],
]) {
fake.addRawUser(jwt, account);
assert.equal((await post('/api/zurueckziehen', { id: 'card-1' }, jwt)).status, 401, JSON.stringify(account));
}
assert.ok(fake.row('gallery', 'card-1'));
fake.addRawUser('no.labels.jwt', { $id: 'user-nolabels' });
fake.addRawUser('string.labels.jwt', { $id: 'user-stringlabels', labels: 'admin' });
fake.addRawUser('object.labels.jwt', { $id: 'user-objectlabels', labels: { admin: true } });
for (const jwt of ['no.labels.jwt', 'string.labels.jwt', 'object.labels.jwt']) {
assert.equal((await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, jwt)).status, 403, jwt);
assert.equal((await post('/api/zurueckziehen', { id: 'card-1' }, jwt)).status, 403, jwt);
}
assert.equal(fake.row('gallery', 'card-1').hidden, false);
});
test('Unerwartete Fehler im Appwrite-Objekt ergeben 500, ohne die Anfrage hängen zu lassen', async () => {
const logs = [];
const broken = {
accountFromJwt: async () => {
throw new Error('kaputt GEHEIM');
},
};
const app = createApp({ appwrite: broken, salt: SALT, log: (line) => logs.push(line) });
const server = http.createServer(app);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
cleanups.push(() => {
server.closeAllConnections();
return new Promise((resolve) => server.close(resolve));
});
const res = await request(server.address().port, 'POST', '/api/zurueckziehen', {
body: { id: 'x' },
headers: { 'X-Aircard-Jwt': JWT_ME },
});
assert.equal(res.status, 500);
assert.equal(typeof res.json.error, 'string');
assert.ok(!logs.join('\n').includes('GEHEIM'), 'Fehlertexte werden nicht ins Log übernommen');
});
// ---- Bildmaße (Defense in Depth beim Veröffentlichen) ---------------------------------------------
test('readImageSize liest PNG-IHDR sowie WebP VP8, VP8L und VP8X', () => {
assert.deepEqual(readImageSize(pngHeader(1536, 969)), { width: 1536, height: 969 });
assert.deepEqual(readImageSize(webpHeader('VP8 ', 480, 303)), { width: 480, height: 303 });
assert.deepEqual(readImageSize(webpHeader('VP8L', 1536, 969)), { width: 1536, height: 969 });
assert.deepEqual(readImageSize(webpHeader('VP8X', 16383, 16383)), { width: 16383, height: 16383 });
assert.deepEqual(readImageSize(pngHeader(65535, 70000)), { width: 65535, height: 70000 });
// gekürzt auf genau die nötigen Bytes (PNG 24, VP8 30, VP8L 25, VP8X 30) geht es noch, eines weniger nicht
assert.deepEqual(readImageSize(pngHeader(7, 8).subarray(0, 24)), { width: 7, height: 8 });
assert.equal(readImageSize(pngHeader(7, 8).subarray(0, 23)), null);
assert.deepEqual(readImageSize(webpHeader('VP8 ', 7, 8).subarray(0, 30)), { width: 7, height: 8 });
assert.equal(readImageSize(webpHeader('VP8 ', 7, 8).subarray(0, 29)), null);
assert.deepEqual(readImageSize(webpHeader('VP8L', 7, 8).subarray(0, 25)), { width: 7, height: 8 });
assert.equal(readImageSize(webpHeader('VP8L', 7, 8).subarray(0, 24)), null);
assert.deepEqual(readImageSize(webpHeader('VP8X', 7, 8).subarray(0, 30)), { width: 7, height: 8 });
assert.equal(readImageSize(webpHeader('VP8X', 7, 8).subarray(0, 29)), null);
});
test('readImageSize: Unbekanntes, Leeres und Kaputtes ergibt null', () => {
assert.equal(readImageSize(Buffer.alloc(0)), null);
assert.equal(readImageSize(Buffer.from('GIF89a')), null);
assert.equal(readImageSize(Buffer.alloc(64)), null);
assert.equal(readImageSize(Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="1536" height="969"></svg>')), null);
const brokenPng = pngHeader(1536, 969);
brokenPng.write('IDAT', 12, 'latin1'); // erster Chunk ist nicht IHDR
assert.equal(readImageSize(brokenPng), null);
const lossyBadStart = webpHeader('VP8 ', 480, 303);
lossyBadStart[24] = 0; // Startcode kaputt
assert.equal(readImageSize(lossyBadStart), null);
const losslessBadSignature = webpHeader('VP8L', 480, 303);
losslessBadSignature[20] = 0;
assert.equal(readImageSize(losslessBadSignature), null);
const unknownChunk = webpHeader('VP8 ', 480, 303);
unknownChunk.write('ANIM', 12, 'latin1');
assert.equal(readImageSize(unknownChunk), null);
assert.equal(readImageSize(null), null);
assert.equal(readImageSize('PNG'), null);
});
test('Veröffentlichen: Bild muss 1536 x 969 sein, Vorschau 480 breit und 302 bis 304 hoch (sonst 400)', async () => {
const { post, fake } = await setup();
fake.addCard('card1', ME);
const attempt = ({ image, preview }) => {
fake.addFile({ id: 'img1', owner: ME, content: image });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', content: preview });
return post('/api/veroeffentlichen', publishBody(), JWT_ME);
};
const okImage = pngHeader(1536, 969);
const okPreview = webpHeader('VP8 ', 480, 303);
for (const [label, files] of [
['Bild zu schmal', { image: pngHeader(1535, 969), preview: okPreview }],
['Bild zu hoch', { image: pngHeader(1536, 970), preview: okPreview }],
['Bild 4 Megapixel', { image: pngHeader(2000, 2000), preview: okPreview }],
['Bild riesig (Dekompressionsbombe)', { image: pngHeader(60000, 60000), preview: okPreview }],
['Bild WebP riesig', { image: webpHeader('VP8L', 16383, 16383), preview: okPreview }],
['Vorschau zu breit', { image: okImage, preview: webpHeader('VP8 ', 481, 303) }],
['Vorschau zu niedrig', { image: okImage, preview: webpHeader('VP8 ', 480, 301) }],
['Vorschau zu hoch', { image: okImage, preview: webpHeader('VP8 ', 480, 305) }],
['Vorschau in Bildgröße', { image: okImage, preview: webpHeader('VP8L', 1536, 969) }],
['Bild ist kein Bild', { image: Buffer.from('MZ\x90\x00 kein Bild, nur Text, lang genug für die Prüfung 123456789'), preview: okPreview }],
['Vorschau ist kein Bild', { image: okImage, preview: Buffer.alloc(100) }],
['Bild leer', { image: Buffer.alloc(0), preview: okPreview }],
['Bild abgeschnitten', { image: pngHeader(1536, 969).subarray(0, 20), preview: okPreview }],
]) {
const res = await attempt(files);
assert.equal(res.status, 400, label);
assert.equal(res.json.error, NOT_CARD_FORMAT, label);
assert.equal(fake.rows('gallery').length, 0, label);
assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-me")'], `${label}: Dateien bleiben unangetastet`);
}
// genau die erwarteten Maße gehen; die Vorschau darf 302, 303 oder 304 hoch sein, auch als PNG (Browser ohne WebP-Export)
for (const preview of [webpHeader('VP8 ', 480, 302), webpHeader('VP8L', 480, 304), webpHeader('VP8X', 480, 303), pngHeader(480, 303)]) {
const res = await attempt({ image: okImage, preview });
assert.equal(res.status, 201);
assert.equal((await post('/api/zurueckziehen', { id: res.json.id }, JWT_ME)).status, 200);
}
// auch ein WebP-Bild in voller Größe ist ein gültiges Kartenbild
assert.equal((await attempt({ image: webpHeader('VP8L', 1536, 969), preview: okPreview })).status, 201);
});
test('Veröffentlichen liest nur den Anfang der Dateien und schickt dafür den Server-Key an /view', async () => {
const { post, fake } = await setup();
seedUploads(fake);
const big = Buffer.concat([pngHeader(1536, 969), Buffer.alloc(3 * 1024 * 1024)]);
fake.addFile({ id: 'img1', owner: ME, content: big, size: big.length });
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201);
const views = fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith('/view'));
assert.deepEqual(views.map((c) => c.path).sort(), [
'/v1/storage/buckets/gallery-images/files/img1/view',
'/v1/storage/buckets/gallery-images/files/prev1/view',
]);
for (const c of views) assert.equal(c.headers['x-appwrite-key'], fake.key);
});
test('Veröffentlichen: fremde oder gesperrte Dateien werden nicht gelesen (kein Maß-Orakel)', async () => {
const { post, fake } = await setup();
fake.addCard('card1', ME);
fake.addFile({ id: 'img1', owner: OTHER });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' });
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 403);
assert.equal(fake.calls.filter((c) => c.path.endsWith('/view')).length, 0);
});
test('Veröffentlichen: Datei verschwindet vor dem Lesen der Maße ergibt 400 "Bild nicht gefunden."', async () => {
const { post, fake } = await setup();
seedUploads(fake);
fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/files/prev1/view'), { status: 404, type: 'storage_file_not_found' });
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 400);
assert.equal(res.json.error, 'Bild nicht gefunden.');
assert.equal(fake.rows('gallery').length, 0);
});
test('Veröffentlichen: Appwrite-Fehler beim Lesen der Maße ergibt 502, nichts angelegt', async () => {
const { post, fake } = await setup();
seedUploads(fake);
fake.failNext((c) => c.path.endsWith('/files/img1/view'), { status: 500 });
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 502);
assert.equal(fake.rows('gallery').length, 0);
});
// ---- Limits und Besitz der Quellkarte ---------------------------------------------------------
function seedPublications(fake, owner, count) {
for (let i = 0; i < count; i += 1) {
fake.addRow(
'gallery',
`${owner}-pub${i}`,
{ title: `P${i}`, imageFileId: `${owner}-i${i}`, previewFileId: `${owner}-p${i}`, sourceCardId: `${owner}-s${i}`, ownerId: owner, hidden: i % 2 === 1, reportCount: 0 },
['read("any")'],
);
}
}
test('Veröffentlichen: höchstens 20 Veröffentlichungen je Konto (ausgeblendete zählen mit), die 21. ergibt 429', async () => {
const { post, fake } = await setup();
seedPublications(fake, ME, 20);
seedUploads(fake);
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 429);
assert.equal(res.json.error, TOO_MANY_CARDS);
assert.equal(fake.rows('gallery').length, 20);
assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-me")'], 'Dateien unangetastet');
// Karten anderer Konten zählen nicht
seedPublications(fake, OTHER, 25);
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 429);
seedUploads(fake, OTHER, { image: 'oimg', preview: 'oprev', card: 'ocard' });
assert.equal((await post('/api/veroeffentlichen', publishBody({ imageFileId: 'oimg', previewFileId: 'oprev', sourceCardId: 'ocard' }), JWT_OTHER)).status, 429);
});
test('Veröffentlichen: mit 19 Veröffentlichungen geht die 20., nach dem Zurückziehen wieder eine', async () => {
const { post, fake } = await setup();
seedPublications(fake, ME, 19);
seedUploads(fake);
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 201);
fake.addCard('card2', ME);
seedUploads(fake, ME, { image: 'img2', preview: 'prev2', card: 'card2' });
assert.equal((await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img2', previewFileId: 'prev2', sourceCardId: 'card2' }), JWT_ME)).status, 429);
assert.equal((await post('/api/zurueckziehen', { id: res.json.id }, JWT_ME)).status, 200);
assert.equal((await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img2', previewFileId: 'prev2', sourceCardId: 'card2' }), JWT_ME)).status, 201);
});
test('Veröffentlichen: sourceCardId muss eine Zeile in `cards` mit read("user:<ich>") sein, sonst 403', async () => {
const { post, fake } = await setup();
seedUploads(fake, ME, { card: null });
// gibt es nicht
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 403);
assert.equal(res.json.error, NOT_YOUR_CARD);
// gehört jemand anderem
fake.addCard('card1', OTHER);
res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 403);
assert.equal(res.json.error, NOT_YOUR_CARD);
// nur lesbar für alle, aber nicht meine
fake.addCard('card1', OTHER, ['read("any")']);
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 403);
assert.equal(fake.rows('gallery').length, 0);
assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-me")'], 'Dateien unangetastet');
assert.equal(fake.calls.filter((c) => c.method === 'POST' && c.path.endsWith('/tables/gallery/rows')).length, 0);
// meine Karte geht
fake.addCard('card1', ME);
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201);
});
test('Veröffentlichen: Fehler beim Lesen von `cards` ergibt 502, ein fehlender Tabellenname 500, nichts angelegt', async () => {
const { post, fake } = await setup();
seedUploads(fake);
fake.failNext((c) => c.method === 'GET' && c.path.includes('/tables/cards/rows/'), { status: 503 });
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 502);
fake.failNext((c) => c.method === 'GET' && c.path.includes('/tables/cards/rows/'), { status: 404, type: 'table_not_found' });
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 500);
assert.equal(fake.rows('gallery').length, 0);
});
// ---- Rollback-Protokoll ------------------------------------------------------------------------
test('Veröffentlichen: scheitern Festlegen und Zurücknehmen, steht `rollback failed for card <id>` im Log (nur die ID)', async () => {
const { post, fake, logs } = await setup();
seedUploads(fake);
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/prev1'), { status: 500 });
fake.failNext((c) => c.method === 'DELETE' && c.path.includes('/tables/gallery/rows/'), { status: 500 });
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 502);
const id = galleryRowId(ME, 'card1');
assert.equal(fake.rows('gallery').length, 1, 'die Zeile blieb stehen (Zurücknehmen scheiterte)');
const lines = logs.filter((line) => line.includes('rollback failed'));
assert.equal(lines.length, 1);
assert.match(lines[0], new RegExp(`^\\[aircard-api\\] \\S+ rollback failed for card ${id}$`));
assert.ok(!logs.join('\n').includes(JWT_ME));
});
test('Veröffentlichen: gelingt das Zurücknehmen, steht keine Rollback-Fehlerzeile im Log', async () => {
const { post, fake, logs } = await setup();
seedUploads(fake);
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/prev1'), { status: 500 });
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 502);
assert.equal(fake.rows('gallery').length, 0);
assert.equal(logs.filter((line) => line.includes('rollback failed')).length, 0);
});
test('createApp verlangt Appwrite-Objekt und Salz', () => {
assert.throws(() => createApp({ salt: SALT }));
assert.throws(() => createApp({ appwrite: {}, salt: '' }));
assert.throws(() => createApp({ appwrite: {} }));
});
// ---- Fehlercodes: jede Fehlerantwort ist {error, code} -------------------------------------------------
/** Erwartet Status und die Antwort {error: <Text des MSG-Schlüssels>, code: <Schlüssel>} (sonst nichts). */
function expectError(res, status, code, label = code) {
assert.equal(res.status, status, `${label}: Status`);
assert.deepEqual(res.json, { error: MSG[code], code }, label);
}
test('MSG: alle Texte sind paarweise verschieden (der Text bestimmt den Code), die Logo-Texte stehen wörtlich fest', () => {
const texts = Object.values(MSG);
assert.ok(texts.length > 0);
assert.equal(new Set(texts).size, texts.length, 'jeder Text gehört zu genau einem Schlüssel');
assert.ok(texts.every((text) => typeof text === 'string' && text !== ''));
const logoKeys = ['logoGone', 'badLogoId', 'logoNameMissing', 'logoFileType', 'logoFileSize', 'logoFormat', 'logoTaken', 'tooManyLogos', 'badHeart', 'badMono'];
assert.deepEqual(Object.fromEntries(logoKeys.map((key) => [key, MSG[key]])), {
logoGone: 'Dieses Logo ist nicht (mehr) verfügbar.',
badLogoId: 'Ungültige Logo-ID.',
logoNameMissing: 'Bitte gib einen Namen an.',
logoFileType: 'Das Logo muss eine PNG-Datei sein.',
logoFileSize: 'Das Logo ist größer als 1 MB.',
logoFormat: 'Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).',
logoTaken: 'Diese Datei wird schon für ein Logo verwendet.',
tooManyLogos: 'Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.',
badHeart: 'Ungültige Angabe zum Herz.',
badMono: 'Ungültige Angabe zu „einfarbig“.',
});
// die bisherigen Texte bleiben unverändert (Auszug)
assert.equal(MSG.cardGone, 'Diese Karte ist nicht (mehr) verfügbar.');
assert.equal(MSG.forbidden, 'Dazu hast du keine Berechtigung.');
assert.equal(MSG.tooManyReports, 'Zu viele Meldungen – bitte später nochmal.');
assert.equal(MSG.tooManyPublications, TOO_MANY_CARDS);
assert.equal(MSG.unavailable, 'Der Dienst ist gerade nicht erreichbar.');
assert.equal(MSG.internal, 'Interner Fehler.');
});
test('codeFor und HttpError: Code = Schlüssel des Textes, sonst badRequest (400) bzw. error', () => {
assert.equal(codeFor(404, MSG.cardGone), 'cardGone');
assert.equal(codeFor(404, MSG.logoGone), 'logoGone');
assert.equal(codeFor(429, MSG.tooManyLogos), 'tooManyLogos');
assert.equal(codeFor(500, MSG.internal), 'internal');
assert.equal(codeFor(400, 'Ungültige Bild-ID.'), 'badRequest', 'dynamischer Prüftext, Status 400');
assert.equal(codeFor(400, MSG.badRequest), 'badRequest');
assert.equal(codeFor(403, 'irgendein anderer Text'), 'error', 'dynamischer Text, anderer Status');
assert.equal(codeFor(409, undefined), 'error');
const withHeaders = new HttpError(429, MSG.tooManyReports, { 'Retry-After': '5' });
assert.ok(withHeaders instanceof Error);
assert.deepEqual([withHeaders.status, withHeaders.message, withHeaders.code, withHeaders.headers], [429, MSG.tooManyReports, 'tooManyReports', { 'Retry-After': '5' }]);
assert.deepEqual(new HttpError(404, MSG.notFound).headers, {});
assert.equal(new HttpError(400, 'Der Titel ist ungültig.').code, 'badRequest');
assert.equal(new HttpError(403, 'Der Titel ist ungültig.').code, 'error');
assert.equal(new HttpError(409, 'x', {}, 'eigener').code, 'eigener', 'ein ausdrücklicher Code gewinnt');
});
test('Fehlerantworten: Anfrage-Fehler (404, 413, 415, 400, 401) haben {error, code}', async () => {
const { call, post, report, fake } = await setup();
seedCard(fake, { id: 'card-9' });
const big = `{"id":"x","reason":"spam"}${' '.repeat(9000)}`;
expectError(await call('GET', '/api/gibts-nicht'), 404, 'notFound', 'unbekannte Route');
expectError(await call('GET', '/api/melden'), 404, 'notFound', 'falsche Methode');
expectError(await call('POST', '/api/melden', { raw: '{}', contentType: 'text/plain' }), 415, 'jsonOnly');
expectError(await call('POST', '/api/melden', { raw: big }), 413, 'tooLarge');
expectError(await call('POST', '/api/melden', { raw: '{kaputt' }), 400, 'badRequest', 'kaputtes JSON');
expectError(await call('POST', '/api/melden', { raw: '[]' }), 400, 'badRequest', 'Array statt Objekt');
expectError(await report('../x', '203.0.1.9'), 400, 'badCardId');
expectError(await report('card-9', '203.0.1.9', 'gibt-es-nicht'), 400, 'badReason');
expectError(await post('/api/veroeffentlichen', publishBody()), 401, 'unauthorized', 'veröffentlichen ohne JWT');
expectError(await post('/api/zurueckziehen', { id: 'card-9' }, 'xxx.yyy.zzz'), 401, 'unauthorized', 'zurückziehen mit falschem JWT');
expectError(await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: true }), 401, 'unauthorized', 'Admin ohne JWT');
});
test('Fehlerantworten: /api/melden mit unbekannter ID ist genau {error, code} aus der Spec', async () => {
const { report } = await setup();
const res = await report('gibts-nicht', '203.0.1.9');
assert.equal(res.status, 404);
assert.deepEqual(res.json, { error: 'Diese Karte ist nicht (mehr) verfügbar.', code: 'cardGone' });
});
test('Fehlerantworten: Logik-Fehler der Karten-Endpunkte tragen den MSG-Schlüssel, dynamische Prüftexte badRequest', async () => {
const { post, fake } = await setup();
seedCard(fake, { id: 'card-9', owner: OTHER });
seedUploads(fake);
expectError(await post('/api/zurueckziehen', { id: 'nope' }, JWT_ME), 404, 'cardGone');
expectError(await post('/api/zurueckziehen', { id: 'card-9' }, JWT_ME), 403, 'forbidden');
expectError(await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: true }, JWT_ME), 403, 'adminOnly');
expectError(await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: 'ja' }, JWT_ADMIN), 400, 'badVisibility');
expectError(await post('/api/admin/sichtbarkeit', { id: '../x', sichtbar: true }, JWT_ADMIN), 400, 'badCardId');
expectError(await post('/api/veroeffentlichen', publishBody({ sourceCardId: '../x' }), JWT_ME), 400, 'badCardId');
// Prüftexte mit Zahlen oder Feldnamen haben keinen festen Schlüssel: code badRequest, Text bleibt der genaue
for (const [overrides, text] of [
[{ title: '' }, 'Bitte gib einen Titel an.'],
[{ title: 'x'.repeat(61) }, 'Der Titel ist zu lang (höchstens 60 Zeichen).'],
[{ imageFileId: '../x' }, 'Ungültige Bild-ID.'],
[{ tags: ['a', 'b', 'c', 'd', 'e', 'f'] }, 'Bitte gib höchstens 5 Tags an.'],
[{ previewFileId: 'img1' }, 'Bild und Vorschau müssen verschiedene Dateien sein.'],
]) {
const res = await post('/api/veroeffentlichen', publishBody(overrides), JWT_ME);
assert.equal(res.status, 400, text);
assert.deepEqual(res.json, { error: text, code: 'badRequest' }, text);
}
});
test('Fehlerantworten: Datei-, Besitz- und Limit-Fehler beim Veröffentlichen tragen ihren Schlüssel', async () => {
const { post, report, fake } = await setup();
const attempt = (n, { image = {}, preview = {}, card = `card${n}` } = {}, overrides = {}) => {
fake.addFile({ id: `img${n}`, owner: ME, ...image });
fake.addFile({ id: `prev${n}`, owner: ME, mimeType: 'image/webp', ...preview });
if (card) fake.addCard(card, ME);
return post('/api/veroeffentlichen', publishBody({ imageFileId: `img${n}`, previewFileId: `prev${n}`, sourceCardId: `card${n}`, ...overrides }), JWT_ME);
};
expectError(await attempt(1, { image: { owner: OTHER } }), 403, 'foreignFile');
expectError(await attempt(2, { image: { bucket: 'card-images' } }), 403, 'foreignFile', 'falscher Bucket');
expectError(await attempt(3, {}, { previewFileId: 'ghost' }), 400, 'fileMissing');
expectError(await attempt(4, { image: { mimeType: 'image/jpeg' } }), 400, 'fileType');
expectError(await attempt(5, { image: { size: MIB5 + 1 } }), 400, 'fileSize');
expectError(await attempt(6, { image: { content: pngHeader(100, 100) } }), 400, 'imageFormat');
expectError(await attempt(7, { image: { permissions: ['delete("user:user-me")'] } }), 403, 'filesTaken');
expectError(await attempt(8, { card: null }), 403, 'notYourCard');
assert.equal((await attempt(9)).status, 201);
expectError(await attempt(10, {}, { sourceCardId: 'card9' }), 409, 'alreadyPublished');
seedPublications(fake, ME, 19); // mit der Karte oben sind es 20
expectError(await attempt(11), 429, 'tooManyPublications');
// Rate-Limit der Meldungen (die Karte `card9` ist sichtbar veröffentlicht)
const id = galleryRowId(ME, 'card9');
for (let i = 0; i < 10; i += 1) assert.equal((await report(id, '203.0.50.9')).status, 200);
const limited = await report(id, '203.0.50.9');
expectError(limited, 429, 'tooManyReports');
assert.equal(limited.headers['retry-after'], '3600');
});
test('Fehlerantworten: Appwrite-Fehler haben code unavailable (502) bzw. internal (500), unerwartete Fehler internal', async () => {
const { report, fake } = await setup();
seedCard(fake);
const isRowRead = (c) => c.method === 'GET' && c.path.includes('/tables/gallery/rows/');
fake.failNext(isRowRead, { status: 500 });
expectError(await report('card-1', '203.0.1.9'), 502, 'unavailable', '5xx');
fake.failNext(isRowRead, { status: 429, type: 'general_rate_limit_exceeded' });
expectError(await report('card-1', '203.0.1.9'), 502, 'unavailable', '429');
fake.failNext(isRowRead, { status: 404, type: 'table_not_found' });
expectError(await report('card-1', '203.0.1.9'), 500, 'internal', 'fehlende Tabelle');
fake.failNext(isRowRead, { status: 401, type: 'general_unauthorized_scope' });
expectError(await report('card-1', '203.0.1.9'), 500, 'internal', 'falscher Key');
const broken = {
accountFromJwt: async () => {
throw new Error('kaputt GEHEIM');
},
};
const app = createApp({ appwrite: broken, salt: SALT, log: () => {} });
const server = http.createServer(app);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
cleanups.push(() => {
server.closeAllConnections();
return new Promise((resolve) => server.close(resolve));
});
const res = await request(server.address().port, 'POST', '/api/zurueckziehen', { body: { id: 'x' }, headers: { 'X-Aircard-Jwt': JWT_ME } });
expectError(res, 500, 'internal', 'unerwarteter Fehler');
assert.ok(!res.text.includes('GEHEIM'));
});