fix(api): Log-Flut begrenzen und Appwrite-Fehler strenger behandeln

- Kein Zugriffsprotokoll je Anfrage mehr (abgewiesene Anfragen an das
  unauthentifizierte /api/melden loggten sonst unbegrenzt); nur noch
  Ereignis- und 5xx-Zeilen, höchstens 120 pro Minute mit Sammelzeile
- Compose: logging json-file, max-size 5m, max-file 2
- appwrite.js: 404 nur mit Typ row_not_found/storage_file_not_found als
  "fehlt", listRows ohne rows-Array wirft, redirect: error
- Appwrite-Fehler: 502 bei 0/429/5xx, sonst 500 "Interner Fehler."
- Veröffentlichen legt beide Dateien auf die sichtbaren Rechte fest
  (erst nach dem Anlegen der Zeile, Rollback bei Fehler)
- setFilePermissions mit Promise.allSettled, gemeinsamer safeToken-Helfer
- Tests: 85 (Log-Flut, Log-Budget, 404-Typen, Auth-Header, Konto-Antworten)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 14:24:05 +00:00
1 parent dd1597862a
commit cd55d4c891
8 files changed
+423 -31

No files matched your search

+196 -1
View File
@@ -8,7 +8,7 @@ const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const http = require('node:http');
const { createAppwrite } = require('./appwrite.js');
const { createAppwrite, AppwriteError } = require('./appwrite.js');
const { startFakeAppwrite } = require('./fake-appwrite.js');
const {
createApp,
@@ -917,6 +917,201 @@ test('Logs enthalten nie Key, Salz, JWT oder IPs im Klartext, aber das Präfix [
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.113.5'); // 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.113.5');
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.113.1');
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 die sichtbaren Rechte fest (kanonischer Zustand)', async () => {
const { post, fake } = await setup();
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: ['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")', 'delete("user:user-me")']);
assert.deepEqual(fake.file('prev1').$permissions, ['read("any")', 'delete("user:user-me")']);
});
test('Ein 409 beim Veröffentlichen fasst die Dateirechte nicht an (auch nicht die einer ausgeblendeten Karte)', async () => {
const { post, fake } = await setup();
// Karte 1 ist schon veröffentlicht und wurde ausgeblendet; ihr Autor schickt dieselben Dateien erneut.
const hiddenFile = ['read("user:user-me")', 'read("label:admin")', 'delete("user:user-me")'];
fake.addFile({ id: 'img1', owner: ME, permissions: hiddenFile });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', permissions: hiddenFile });
const rowId = galleryRowId(ME, 'card1');
fake.addRow(
'gallery',
rowId,
{ title: 'Karte', imageFileId: 'img1', previewFileId: 'prev1', sourceCardId: 'card1', ownerId: ME, hidden: true, reportCount: 3 },
hiddenRowPermissions(ME),
);
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 409);
assert.deepEqual(fake.file('img1').$permissions, hiddenFile);
assert.deepEqual(fake.file('prev1').$permissions, hiddenFile);
assert.equal(fake.row('gallery', rowId).hidden, true);
assert.equal(fake.calls.filter((c) => c.method === 'PUT').length, 0, 'kein PUT auf Dateien');
});
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');
// 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")', 'delete("user:user-me")']);
assert.deepEqual(fake.file('card-1-prev').$permissions, ['read("any")', 'delete("user:user-me")']);
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 = {