feat(api): Logo-Endpunkte, Fehlercodes und gemeinsame Helfer
- common.js (aus app.js herausgelöst, Verhalten gleich): MSG, HttpError, Prüfungen, Rechte-Strings, Melder-Hash,
Bildmaße und die Dateiprüfungen checkFileExists/checkFileOwnership. Neu: die Logo-Texte in MSG, codeFor,
likeRowPermissions. app.js exportiert weiter alles wie bisher.
- Fehlerantworten sind jetzt {error, code}: code = MSG-Schlüssel des Textes, dynamische Prüftexte badRequest,
Appwrite-Fehler unavailable (502) bzw. internal (500).
- logos.js: POST /api/logos/hochladen, /herz, /melden, /loeschen und /api/admin/logo-sichtbarkeit nach dem Vorbild der
Karten-Endpunkte (Datei prüfen, Zeile anlegen, Datei auf read("any") festlegen, sonst Rücknahme; likeCount und
reportCount immer aus countRows; ein gemeinsames Rate-Limit für Karten- und Logo-Meldungen).
- appwrite.js: countRows. fake-appwrite.js: Tabellen logos, logo_likes, logo_reports mit Spaltenlängen, Bucket
logo-images und total in den Listen-Antworten.
- Tests: logos.test.js (59), code-Fälle und MSG-Eindeutigkeit in app.test.js (+7), countRows in appwrite.test.js (+2);
alle 111 bisherigen Tests unverändert grün (179 gesamt).
- Doku: api/README.md (Endpunkte, Fehlercodes, Logo-Regeln), deploy/README.md (Betrieb kopiert fünf Dateien).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
88abc2061d
commit
dc5de99611
10 files changed
+2181
-286
No files matched your search
@@ -9,6 +9,7 @@ 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,
|
||||
@@ -1569,3 +1570,173 @@ test('createApp verlangt Appwrite-Objekt und Salz', () => {
|
||||
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'));
|
||||
});
|
||||
Reference in new issue
Block a user