fix(api): Logo-Zeilen-ID ist ein Hash aus Konto und Datei, nicht mehr die Datei-ID

Die Zeilen-ID eines gelöschten Logos steht öffentlich in der Liste und in gespeicherten Karten. War sie die Datei-ID, konnte jeder eine Datei
mit genau dieser ID anlegen, als Logo hochladen und so die alte ID neu belegen: gespeicherte Karten anderer zeigten dann sein Bild.

- logos.js: Zeilen-ID = hash32("<ownerId>|<fileId>") (logoRowId); die Antwort 201 {id} trägt sie. Dieselbe Datei desselben Kontos ergibt dieselbe ID
  (409 logoTaken, bei einem Altlogo über die fileId der Zeile); alle anderen Regeln bleiben. Die Logzeilen nennen die Zeilen-ID, die Fehlerzeilen
  zusätzlich die Datei-ID (sie folgt nicht mehr aus der Zeilen-ID, und die Zeile ist beim Aufräumen vielleicht schon weg).
- Altlogos (Zeilen-ID = Datei-ID, eines ist live) gehen weiter: Herz, Melden, Löschen und Sichtbarkeit adressieren nur die Zeile und lesen die Datei aus fileId.
- Tests: seedLogo legt Zeile und Datei mit verschiedenen IDs an; neu: logoRowId, die ID eines gelöschten Logos lässt sich nicht neu belegen, anderes Konto
  bekommt eine verwendete Datei nicht (403), Altlogo (Herz, Meldung, Ausblenden/Einblenden, Löschen, erneutes Hochladen 409).
- README und Spec: ID-Regel. Der Client (upload-logo.ts) folgt im nächsten Commit; beide gehen zusammen live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 05:07:28 +00:00
1 parent ec0b51a949
commit 6d152c11b3
4 files changed
+198 -75

No files matched your search

+161 -52
View File
@@ -11,9 +11,10 @@ const http = require('node:http');
const { createAppwrite } = require('./appwrite.js');
const { startFakeAppwrite, pngHeader, webpHeader } = require('./fake-appwrite.js');
const { createApp } = require('./app.js');
const { createLogoRoutes, logoLikeId, logoReportId, validateLogoUpload } = require('./logos.js');
const { createLogoRoutes, logoLikeId, logoReportId, logoRowId, validateLogoUpload } = require('./logos.js');
const {
MSG,
isId,
visibleRowPermissions,
hiddenRowPermissions,
likeRowPermissions,
@@ -115,10 +116,18 @@ function logoFile(fake, id, owner = ME, { width = 200, height = 100, ...rest } =
fake.addFile({ id, bucket: LOGO_BUCKET, owner, content: pngHeader(width, height), ...rest });
}
/** Veröffentlichtes Logo: Zeile und Datei mit genau den Rechten, die die API vergibt. */
function seedLogo(fake, { id = 'logo-1', owner = ME, hidden = false, reportCount = 0, likeCount = 0, name = 'Logo', mono = false } = {}) {
/** Zeilen-ID des Logos, das `owner` aus der Datei `fileId` anlegt (so vergibt sie die API). */
const rowOf = (fileId, owner = ME) => logoRowId(owner, fileId);
/** Datei-ID, die `seedLogo` einer Logo-Zeile gibt: nie gleich der Zeilen-ID (nur Altlogos haben Zeilen-ID = Datei-ID). */
const fileOf = (id) => `file-of-${id}`;
/**
* Veröffentlichtes Logo: Zeile und Datei mit genau den Rechten, die die API vergibt. Die Datei hat die ID `fileId`
* (Standard `fileOf(id)`); ein Altlogo mit Zeilen-ID = Datei-ID legt der Test mit `fileId: id` an.
*/
function seedLogo(fake, { id = 'logo-1', fileId = fileOf(id), owner = ME, hidden = false, reportCount = 0, likeCount = 0, name = 'Logo', mono = false } = {}) {
fake.addFile({
id,
id: fileId,
bucket: LOGO_BUCKET,
owner,
permissions: hidden ? hiddenFilePermissions(owner) : visibleFilePermissions(),
@@ -127,7 +136,7 @@ function seedLogo(fake, { id = 'logo-1', owner = ME, hidden = false, reportCount
fake.addRow(
'logos',
id,
{ name, displayName: '', fileId: id, ownerId: owner, mono, likeCount, hidden, reportCount },
{ name, displayName: '', fileId, ownerId: owner, mono, likeCount, hidden, reportCount },
hidden ? hiddenRowPermissions(owner) : visibleRowPermissions(),
);
}
@@ -136,7 +145,7 @@ function seedLogo(fake, { id = 'logo-1', owner = ME, hidden = false, reportCount
function seedLogoRows(fake, owner, count) {
for (let i = 0; i < count; i += 1) {
const id = `${owner}-l${i}`;
fake.addRow('logos', id, { name: `L${i}`, displayName: '', fileId: id, ownerId: owner, mono: false, likeCount: 0, hidden: i % 2 === 1, reportCount: 0 }, visibleRowPermissions());
fake.addRow('logos', id, { name: `L${i}`, displayName: '', fileId: fileOf(id), ownerId: owner, mono: false, likeCount: 0, hidden: i % 2 === 1, reportCount: 0 }, visibleRowPermissions());
}
}
@@ -153,13 +162,14 @@ const nonAccountCalls = (fake) => fake.calls.filter((c) => c.path !== '/v1/accou
const filePuts = (fake, id) => fake.calls.filter((c) => c.method === 'PUT' && c.path.endsWith(`/files/${id}`));
/** Die Aufrufe zu einem Logo in der Reihenfolge, in der sie ankamen (Datei abrufen, lesen, festlegen, löschen; Zeile anlegen, löschen). */
function logoSteps(fake, id) {
function logoSteps(fake, fileId) {
const rowId = rowOf(fileId);
const steps = [];
for (const c of fake.calls) {
if (c.path.endsWith(`/files/${id}/view`)) steps.push('Anfang lesen');
else if (c.path.endsWith(`/files/${id}`)) steps.push({ GET: 'Datei abrufen', PUT: 'Rechte festlegen', DELETE: 'Datei löschen' }[c.method]);
if (c.path.endsWith(`/files/${fileId}/view`)) steps.push('Anfang lesen');
else if (c.path.endsWith(`/files/${fileId}`)) steps.push({ GET: 'Datei abrufen', PUT: 'Rechte festlegen', DELETE: 'Datei löschen' }[c.method]);
else if (c.method === 'POST' && c.path.endsWith('/tables/logos/rows')) steps.push('Zeile anlegen');
else if (c.method === 'DELETE' && c.path.endsWith(`/tables/logos/rows/${id}`)) steps.push('Zeile löschen');
else if (c.method === 'DELETE' && c.path.endsWith(`/tables/logos/rows/${rowId}`)) steps.push('Zeile löschen');
}
return steps;
}
@@ -179,6 +189,16 @@ test('logoLikeId und logoReportId: erste 32 Hex von sha256, Logo-Meldungen mit d
assert.notEqual(logoReportId('l1', 'h1'), logoReportId('l1', 'h2'));
});
test('logoRowId: erste 32 Hex von sha256("<Konto>|<Datei>"), eine gültige ID, nie die Datei-ID, je Konto und Datei verschieden', () => {
assert.equal(logoRowId('u1', 'f1'), sha32('u1|f1'));
assert.match(logoRowId('u1', 'f1'), /^[0-9a-f]{32}$/);
assert.ok(isId(logoRowId('u1', 'f1')));
assert.notEqual(logoRowId('u1', 'f1'), 'f1');
assert.equal(logoRowId('u1', 'f1'), logoRowId('u1', 'f1'), 'dieselbe Datei desselben Kontos: dieselbe ID');
assert.notEqual(logoRowId('u1', 'f1'), logoRowId('u2', 'f1'), 'dieselbe Datei-ID bei einem anderen Konto: andere Zeilen-ID');
assert.notEqual(logoRowId('u1', 'f1'), logoRowId('u1', 'f2'));
});
test('likeRowPermissions: genau read("user:<id>"), unzulässige Nutzer-IDs werden abgelehnt', () => {
assert.deepEqual(likeRowPermissions('abc'), ['read("user:abc")']);
for (const bad of ['a")', 'x","read("any', '', '../x', 5, null]) assert.throws(() => likeRowPermissions(bad));
@@ -234,15 +254,18 @@ test('createLogoRoutes liefert die fünf Routen mit Name, json und run', () => {
// ---- Hochladen --------------------------------------------------------------------------------------
test('Logo hochladen: legt eine sichtbare Zeile an (Zeilen-ID = Datei-ID), die Datei trägt danach genau read("any")', async () => {
test('Logo hochladen: legt eine sichtbare Zeile an (Zeilen-ID = Hash aus Konto und Datei, nicht die Datei-ID), die Datei trägt danach genau read("any")', async () => {
const { upload, fake, logs } = await setup();
logoFile(fake, 'logo1');
const res = await upload(uploadBody({ name: ' Mein Logo ', mono: true }));
const rowId = rowOf('logo1');
assert.equal(res.status, 201);
assert.deepEqual(res.json, { id: 'logo1' });
assert.deepEqual(res.json, { id: rowId });
assert.match(rowId, /^[0-9a-f]{32}$/);
assert.equal(fake.row('logos', 'logo1'), null, 'unter der Datei-ID gibt es keine Zeile');
const row = fake.row('logos', 'logo1');
const row = fake.row('logos', rowId);
assert.deepEqual(row.$permissions, ['read("any")']);
assert.equal(row.name, 'Mein Logo');
assert.equal(row.displayName, 'Kenso');
@@ -260,7 +283,7 @@ test('Logo hochladen: legt eine sichtbare Zeile an (Zeilen-ID = Datei-ID), die D
assert.equal(c.headers['x-appwrite-key'], fake.key);
assert.equal(c.headers['x-appwrite-jwt'], undefined);
}
assert.equal(logs.filter((line) => line.endsWith('logo logo1 uploaded')).length, 1);
assert.equal(logs.filter((line) => line.endsWith(`logo ${rowId} uploaded`)).length, 1);
});
test('Logo hochladen: Anzeigename ist optional, mono fehlt = false', async () => {
@@ -268,7 +291,7 @@ test('Logo hochladen: Anzeigename ist optional, mono fehlt = false', async () =>
logoFile(fake, 'logo1');
const res = await upload({ name: 'Nur Name', fileId: 'logo1' });
assert.equal(res.status, 201);
const row = fake.row('logos', 'logo1');
const row = fake.row('logos', rowOf('logo1'));
assert.equal(row.displayName, '');
assert.equal(row.mono, false);
});
@@ -392,18 +415,29 @@ test('Logo hochladen: dieselbe Datei zweimal ergibt 409 logoTaken, Datei und Zei
expectError(await upload(uploadBody({ name: 'Anderer Name' })), 409, 'logoTaken');
assert.deepEqual(fake.file('logo1').$permissions, ['read("any")']);
assert.equal(fake.rows('logos').length, 1);
assert.equal(fake.row('logos', 'logo1').name, 'Mein Logo');
assert.equal(fake.row('logos', rowOf('logo1')).name, 'Mein Logo');
// dieselbe ID als frisch hochgeladene Datei (z. B. nach Löschen und Neuanlegen): 409, die Rechte der Datei bleiben unangetastet
// dieselbe Datei-ID als frisch hochgeladene Datei (z. B. nach Löschen und Neuanlegen): 409, die Rechte der Datei bleiben unangetastet
fake.addFile({ id: 'logo1', bucket: LOGO_BUCKET, owner: ME, permissions: [...UPLOAD_PERMISSIONS, 'update("user:user-me")'], content: pngHeader(50, 50) });
expectError(await upload(uploadBody()), 409, 'logoTaken');
assert.deepEqual(fake.file('logo1').$permissions, [...UPLOAD_PERMISSIONS, 'update("user:user-me")']);
assert.equal(filePuts(fake, 'logo1').length, 1, 'nur der erste, erfolgreiche Upload hat Rechte gesetzt');
});
test('Logo hochladen: eine Zeile mit dieser ID gehört schon jemand anderem (liegengebliebener Anker): 409 logoTaken, Datei unangetastet', async () => {
test('Logo hochladen: ein anderes Konto bekommt eine schon verwendete Datei nicht (403), die Zeilen-ID allein hält nicht zwei Konten auseinander', async () => {
const { upload, fake } = await setup();
fake.addRow('logos', 'anker', { name: 'Alt', displayName: '', fileId: 'anker', ownerId: OTHER, mono: false, likeCount: 0, hidden: false, reportCount: 0 }, visibleRowPermissions());
logoFile(fake, 'logo1');
assert.equal((await upload(uploadBody())).status, 201);
// je Konto eine eigene Zeilen-ID: dass eine Datei nie zwei Logos trägt, sichert der Besitz (nach dem Anlegen kein delete(owner) mehr)
expectError(await upload(uploadBody(), JWT_OTHER), 403, 'foreignFile');
assert.equal(fake.rows('logos').length, 1);
assert.equal(fake.row('logos', rowOf('logo1', OTHER)), null);
});
test('Logo hochladen: steht unter der Zeilen-ID schon eine Zeile, die nicht in der Liste des Kontos ist (Rest): 409 logoTaken, Datei unangetastet', async () => {
const { upload, fake } = await setup();
fake.addRow('logos', rowOf('anker'), { name: 'Alt', displayName: '', fileId: 'andere-datei', ownerId: OTHER, mono: false, likeCount: 0, hidden: false, reportCount: 0 }, visibleRowPermissions());
logoFile(fake, 'anker');
expectError(await upload(uploadBody({ fileId: 'anker' })), 409, 'logoTaken');
assert.deepEqual(fake.file('anker').$permissions, UPLOAD_PERMISSIONS);
@@ -411,6 +445,23 @@ test('Logo hochladen: eine Zeile mit dieser ID gehört schon jemand anderem (lie
assert.equal(filePuts(fake, 'anker').length, 0, 'ein 409 fasst die Dateirechte nie an');
});
test('Logo hochladen: die ID eines gelöschten Logos lässt sich nicht mit einer Datei gleicher ID neu belegen', async () => {
const { upload, remove, fake } = await setup();
logoFile(fake, 'logo1');
const { id: victimRowId } = (await upload(uploadBody())).json;
assert.equal((await remove(victimRowId)).status, 200);
assert.equal(fake.row('logos', victimRowId), null);
// die Zeilen-ID ist öffentlich (Liste, gespeicherte Karten): jemand legt eine Datei mit genau dieser ID an und lädt sie hoch
logoFile(fake, victimRowId, OTHER);
const res = await upload(uploadBody({ fileId: victimRowId }), JWT_OTHER);
assert.equal(res.status, 201);
assert.notEqual(res.json.id, victimRowId, 'die alte Zeilen-ID bleibt unbelegt');
assert.equal(fake.row('logos', victimRowId), null);
assert.equal(fake.row('logos', res.json.id).fileId, victimRowId);
assert.equal(fake.row('logos', res.json.id).ownerId, OTHER);
});
test('Logo hochladen: höchstens 30 Logos je Konto (ausgeblendete zählen mit), das 31. ergibt 429 tooManyLogos', async () => {
const { upload, fake } = await setup();
seedLogoRows(fake, ME, 30);
@@ -481,7 +532,7 @@ test('Logo hochladen: scheitert auch das Zurücknehmen, bleibt die Zeile stehen,
assert.equal(filePuts(fake, 'logo1').length, 1, 'ohne erfolgreiches Zurücknehmen bekommt die Datei delete(owner) nicht zurück');
const lines = logs.filter((line) => line.includes('rollback failed'));
assert.equal(lines.length, 1);
assert.match(lines[0], /^\[aircard-api\] \S+ rollback failed for logo logo1$/);
assert.match(lines[0], new RegExp(`^\\[aircard-api\\] \\S+ rollback failed for logo ${rowOf('logo1')} \\(file logo1\\)$`));
assert.ok(!logs.join('\n').includes(JWT_ME));
});
@@ -508,7 +559,7 @@ test('Logo hochladen: ein nach dem Festlegen anderer, aber weiterhin gültiger I
logoFile(fake, 'logo1');
fake.swapContentAfterPermissions('logo1', pngHeader(720, 250));
assert.equal((await upload(uploadBody())).status, 201);
assert.ok(fake.row('logos', 'logo1'));
assert.ok(fake.row('logos', rowOf('logo1')));
assert.deepEqual(fake.file('logo1').$permissions, ['read("any")']);
});
@@ -530,7 +581,7 @@ test('Logo hochladen: wird die Datei zwischen Prüfung und Festlegen ausgetausch
'Zeile löschen',
]);
// ein Ereignis im Log (nur ID und Code), nie „uploaded“
assert.deepEqual(logEvents(logs, 'logo1'), ['logo logo1 removed after upload check (logoFormat)']);
assert.deepEqual(logEvents(logs, rowOf('logo1')), [`logo ${rowOf('logo1')} removed after upload check (logoFormat)`]);
});
test('Logo hochladen: nach dem Festlegen zählt der Inhalt noch einmal (kein PNG-Kopf, zu klein, zu groß): 400 logoFormat, Datei und Zeile sind weg', async () => {
@@ -550,7 +601,7 @@ test('Logo hochladen: nach dem Festlegen zählt der Inhalt noch einmal (kein PNG
logoFile(fake, id);
fake.swapContentAfterPermissions(id, content);
expectError(await upload(uploadBody({ fileId: id })), 400, 'logoFormat', label);
assert.equal(fake.row('logos', id), null, `${label}: keine Zeile`);
assert.equal(fake.row('logos', rowOf(id)), null, `${label}: keine Zeile`);
assert.equal(fake.file(id), null, `${label}: Datei gelöscht`);
}
assert.equal(fake.rows('logos').length, 0);
@@ -564,7 +615,7 @@ test('Logo hochladen: ist die Datei nach dem Festlegen nicht mehr lesbar, ist da
expectError(await upload(uploadBody()), 400, 'fileMissing');
assert.equal(fake.rows('logos').length, 0);
assert.equal(fake.file('logo1'), null);
assert.deepEqual(logEvents(logs, 'logo1'), ['logo logo1 removed after upload check (fileMissing)']);
assert.deepEqual(logEvents(logs, rowOf('logo1')), [`logo ${rowOf('logo1')} removed after upload check (fileMissing)`]);
});
test('Logo hochladen: Appwrite-Fehler beim zweiten Lesen ist 502, nichts bleibt veröffentlicht, die Datei bekommt delete(owner) zurück, die Wiederholung klappt', async () => {
@@ -584,34 +635,35 @@ test('Logo hochladen: Appwrite-Fehler beim zweiten Lesen ist 502, nichts bleibt
'Zeile löschen',
'Rechte festlegen',
]);
assert.deepEqual(logEvents(logs, 'logo1'), [], 'ein Fehler ohne Urteil ist kein Ereignis');
assert.deepEqual(logEvents(logs, rowOf('logo1')), [], 'ein Fehler ohne Urteil ist kein Ereignis');
assert.equal((await upload(uploadBody())).status, 201);
assert.deepEqual(fake.file('logo1').$permissions, ['read("any")']);
});
test('Logo hochladen: scheitert das Aufräumen nach einer Beanstandung, steht `rollback failed for logo <id>` im Log, die Antwort bleibt 400 logoFormat', async () => {
test('Logo hochladen: scheitert das Aufräumen nach einer Beanstandung, steht `rollback failed for logo <id> (file <id>)` im Log, die Antwort bleibt 400 logoFormat', async () => {
const { upload, remove, fake, logs } = await setup();
// die Datei lässt sich nicht löschen: die Zeile wird trotzdem entfernt (sie macht das Logo für andere sichtbar)
// die Datei lässt sich nicht löschen: die Zeile wird trotzdem entfernt (sie macht das Logo für andere sichtbar); die Zeile ist weg,
// darum nennt die Logzeile die Datei-ID, die sich aus der Zeilen-ID nicht ableiten lässt
logoFile(fake, 'logo1');
fake.swapContentAfterPermissions('logo1', pngHeader(4000, 4000));
fake.failNext((c) => c.method === 'DELETE' && c.path.endsWith('/files/logo1'), { status: 500 });
expectError(await upload(uploadBody()), 400, 'logoFormat');
assert.equal(fake.row('logos', 'logo1'), null);
assert.equal(fake.row('logos', rowOf('logo1')), null);
assert.deepEqual(fake.file('logo1').$permissions, ['read("any")'], 'die Datei blieb liegen');
assert.deepEqual(logEvents(logs, 'logo1'), ['rollback failed for logo logo1']);
assert.deepEqual(logEvents(logs, rowOf('logo1')), [`rollback failed for logo ${rowOf('logo1')} (file logo1)`]);
// die Zeile lässt sich nicht löschen: die Datei ist weg, die Zeile bleibt als Anker und der Hochlader räumt sie über /api/logos/loeschen auf
logoFile(fake, 'logo2');
fake.swapContentAfterPermissions('logo2', pngHeader(4000, 4000));
fake.failNext((c) => c.method === 'DELETE' && c.path.includes('/tables/logos/rows/logo2'), { status: 500 });
fake.failNext((c) => c.method === 'DELETE' && c.path.includes(`/tables/logos/rows/${rowOf('logo2')}`), { status: 500 });
expectError(await upload(uploadBody({ fileId: 'logo2' })), 400, 'logoFormat');
assert.equal(fake.file('logo2'), null);
assert.ok(fake.row('logos', 'logo2'));
assert.deepEqual(logEvents(logs, 'logo2'), ['rollback failed for logo logo2']);
assert.equal((await remove('logo2')).status, 200);
assert.equal(fake.row('logos', 'logo2'), null);
assert.ok(fake.row('logos', rowOf('logo2')));
assert.deepEqual(logEvents(logs, rowOf('logo2')), [`rollback failed for logo ${rowOf('logo2')} (file logo2)`]);
assert.equal((await remove(rowOf('logo2'))).status, 200);
assert.equal(fake.row('logos', rowOf('logo2')), null);
});
test('Logo hochladen: Validierung liefert 400 mit dem passenden Code und fasst Appwrite (außer dem Konto) nicht an', async () => {
@@ -821,7 +873,7 @@ test('Logo melden: drei verschiedene Netze blenden das Logo aus (Zeilen- und Dat
assert.deepEqual(res.json, { ok: true });
assert.equal(fake.row('logos', 'logo-1').reportCount, 2);
assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")']);
assert.deepEqual(fake.file('logo-1').$permissions, ['read("any")']);
assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, ['read("any")']);
res = await report('logo-1', '203.0.3.9', 'sonstiges');
assert.equal(res.status, 200);
@@ -830,7 +882,7 @@ test('Logo melden: drei verschiedene Netze blenden das Logo aus (Zeilen- und Dat
assert.equal(row.hidden, true);
assert.equal(row.reportCount, 3);
assert.deepEqual(row.$permissions, HIDDEN_PERMISSIONS);
assert.deepEqual(fake.file('logo-1').$permissions, HIDDEN_PERMISSIONS);
assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, HIDDEN_PERMISSIONS);
assert.equal(fake.rows('logo_reports').length, 3);
// ausgeblendetes Logo melden: 404
@@ -964,7 +1016,7 @@ test('Logo melden: eine doppelte Meldung heilt einen früher steckengebliebenen
assert.deepEqual(res.json, { schonGemeldet: true });
assert.equal(fake.row('logos', 'logo-1').hidden, true);
assert.equal(fake.row('logos', 'logo-1').reportCount, 3);
assert.deepEqual(fake.file('logo-1').$permissions, HIDDEN_PERMISSIONS);
assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, HIDDEN_PERMISSIONS);
});
test('Logo melden: Ausfall beim Anlegen der Meldung ist 502 und zählt nicht, Ausfall beim Ausblenden endet mit 502 und der nächste Versuch blendet aus', async () => {
@@ -977,13 +1029,13 @@ test('Logo melden: Ausfall beim Anlegen der Meldung ist 502 und zählt nicht, Au
await report('logo-1', '203.0.1.9');
await report('logo-1', '203.0.2.9');
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/logo-1'), { status: 500 });
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith(`/files/${fileOf('logo-1')}`), { status: 500 });
expectError(await report('logo-1', '203.0.3.9'), 502, 'unavailable');
assert.equal(fake.row('logos', 'logo-1').hidden, false, 'Dateien zuerst, Zeile zuletzt: die Zeile ist noch sichtbar');
const retry = await report('logo-1', '203.0.3.9');
assert.equal(retry.status, 200);
assert.equal(fake.row('logos', 'logo-1').hidden, true);
assert.deepEqual(fake.file('logo-1').$permissions, HIDDEN_PERMISSIONS);
assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, HIDDEN_PERMISSIONS);
});
// ---- Löschen ----------------------------------------------------------------------------------------
@@ -1001,16 +1053,16 @@ test('Logo löschen durch den Hochlader: Datei, Herzen, Meldungen und Zeile sind
assert.equal(res.status, 200);
assert.deepEqual(res.json, { ok: true });
assert.equal(fake.row('logos', 'logo-1'), null);
assert.equal(fake.file('logo-1'), null);
assert.equal(fake.file(fileOf('logo-1')), null);
assert.deepEqual(fake.rows('logo_likes').map((r) => r.logoId), ['logo-2']);
assert.deepEqual(fake.rows('logo_reports').map((r) => r.logoId), ['logo-2']);
assert.ok(fake.row('logos', 'logo-2'));
assert.ok(fake.file('logo-2'));
assert.ok(fake.file(fileOf('logo-2')));
assert.equal(logs.filter((line) => line.endsWith('logo logo-1 deleted')).length, 1);
// Reihenfolge: Datei, Herzen, Meldungen, zuletzt die Zeile
const order = fake.calls.filter((c) => c.method === 'DELETE').map((c) => c.path.split('/').slice(-3).join('/'));
assert.ok(order[0].endsWith('files/logo-1'), order[0]);
assert.ok(order[0].endsWith(`files/${fileOf('logo-1')}`), order[0]);
assert.ok(order[order.length - 1].endsWith('logos/rows/logo-1'), order[order.length - 1]);
});
@@ -1041,7 +1093,7 @@ test('Logo löschen durch Admin geht, auch bei ausgeblendetem Logo', async () =>
const res = await remove('logo-1', JWT_ADMIN);
assert.equal(res.status, 200);
assert.equal(fake.row('logos', 'logo-1'), null);
assert.equal(fake.file('logo-1'), null);
assert.equal(fake.file(fileOf('logo-1')), null);
assert.equal(fake.rows('logo_reports').length, 0);
assert.equal(logs.filter((line) => line.endsWith('logo logo-1 deleted by admin')).length, 1);
});
@@ -1052,7 +1104,7 @@ test('Logo löschen durch Fremde: 403 forbidden und nichts gelöscht', async ()
seedLikes(fake, 'logo-1', ['u1']);
expectError(await remove('logo-1', JWT_OTHER), 403, 'forbidden');
assert.ok(fake.row('logos', 'logo-1'));
assert.ok(fake.file('logo-1'));
assert.ok(fake.file(fileOf('logo-1')));
assert.equal(fake.rows('logo_likes').length, 1);
assert.equal(fake.calls.filter((c) => c.method === 'DELETE').length, 0);
});
@@ -1074,7 +1126,7 @@ test('Logo löschen ist wiederholbar, wenn die Zeile zuletzt scheitert (die Zeil
fake.failNext((c) => c.method === 'DELETE' && c.path.includes('/tables/logos/rows/'), { status: 500 });
expectError(await remove('logo-1'), 502, 'unavailable');
assert.ok(fake.row('logos', 'logo-1'), 'Zeile bleibt, bis alles andere weg ist');
assert.equal(fake.file('logo-1'), null);
assert.equal(fake.file(fileOf('logo-1')), null);
assert.equal(fake.rows('logo_likes').length, 0);
const retry = await remove('logo-1');
@@ -1092,7 +1144,7 @@ test('Logo-Sichtbarkeit: Nicht-Admin 403 adminOnly, ohne JWT 401, nichts ändert
expectError(await setVisible('logo-1', false, null), 401, 'unauthorized');
assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")']);
assert.equal(fake.row('logos', 'logo-1').hidden, false);
assert.deepEqual(fake.file('logo-1').$permissions, ['read("any")']);
assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, ['read("any")']);
});
test('Admin blendet ein Logo aus: Zeilen- und Dateirechte, hidden=true, Meldungen und Herzen bleiben', async () => {
@@ -1109,7 +1161,7 @@ test('Admin blendet ein Logo aus: Zeilen- und Dateirechte, hidden=true, Meldunge
assert.equal(row.reportCount, 1);
assert.equal(row.likeCount, 2);
assert.deepEqual(row.$permissions, HIDDEN_PERMISSIONS);
assert.deepEqual(fake.file('logo-1').$permissions, HIDDEN_PERMISSIONS);
assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, HIDDEN_PERMISSIONS);
assert.equal(fake.rows('logo_reports').length, 1);
assert.equal(fake.rows('logo_likes').length, 2);
assert.equal(logs.filter((line) => line.endsWith('logo logo-1 hidden by admin')).length, 1);
@@ -1130,7 +1182,7 @@ test('Admin blendet ein Logo ein: sichtbare Rechte, hidden=false, reportCount=0,
assert.equal(row.reportCount, 0);
assert.equal(row.likeCount, 2);
assert.deepEqual(row.$permissions, ['read("any")']);
assert.deepEqual(fake.file('logo-1').$permissions, ['read("any")']);
assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, ['read("any")']);
assert.deepEqual(fake.rows('logo_reports').map((r) => r.logoId), ['logo-2']);
assert.equal(fake.rows('logo_likes').length, 2);
assert.equal(logs.filter((line) => line.endsWith('logo logo-1 made visible by admin')).length, 1);
@@ -1152,7 +1204,7 @@ test('Logo-Sichtbarkeit: Body-Prüfung und fehlendes Logo', async () => {
test('Logo-Sichtbarkeit: scheitert das Setzen der Dateirechte, bleibt die Zeile unverändert (Dateien zuerst, Zeile zuletzt)', async () => {
const { setVisible, fake } = await setup();
seedLogo(fake);
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/logo-1'), { status: 500 });
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith(`/files/${fileOf('logo-1')}`), { status: 500 });
expectError(await setVisible('logo-1', false), 502, 'unavailable');
assert.equal(fake.row('logos', 'logo-1').hidden, false);
assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")']);
@@ -1160,6 +1212,63 @@ test('Logo-Sichtbarkeit: scheitert das Setzen der Dateirechte, bleibt die Zeile
assert.equal(fake.row('logos', 'logo-1').hidden, true);
});
// ---- Altlogos: Zeilen-ID = Datei-ID (vor dem Hash-Schema angelegt) -------------------------------------------
test('Altlogo (Zeilen-ID = Datei-ID): Herz, Meldung, Ausblenden und Einblenden gehen weiter über die Zeilen-ID, die Datei über fileId', async () => {
const { heart, report, setVisible, fake } = await setup();
seedLogo(fake, { id: 'alt1', fileId: 'alt1' });
assert.deepEqual((await heart('alt1', true)).json, { herz: true, likeCount: 1 });
assert.deepEqual((await heart('alt1', true, JWT_OTHER)).json, { herz: true, likeCount: 2 });
assert.deepEqual((await heart('alt1', false)).json, { herz: false, likeCount: 1 });
assert.equal(fake.row('logos', 'alt1').likeCount, 1);
assert.deepEqual((await report('alt1', '203.0.1.9')).json, { ok: true });
assert.equal(fake.row('logos', 'alt1').reportCount, 1);
assert.equal((await setVisible('alt1', false)).status, 200);
assert.equal(fake.row('logos', 'alt1').hidden, true);
assert.deepEqual(fake.file('alt1').$permissions, HIDDEN_PERMISSIONS);
expectError(await heart('alt1', true, JWT_ADMIN), 404, 'logoGone', 'ausgeblendet');
assert.equal((await setVisible('alt1', true)).status, 200);
assert.equal(fake.row('logos', 'alt1').hidden, false);
assert.deepEqual(fake.file('alt1').$permissions, ['read("any")']);
});
test('Altlogo löschen: Datei, Herzen, Meldungen und Zeile sind weg, die alte ID lässt sich danach nicht neu belegen', async () => {
const { heart, report, remove, upload, fake } = await setup();
seedLogo(fake, { id: 'alt1', fileId: 'alt1' });
seedLogo(fake, { id: 'alt2', fileId: 'alt2', owner: OTHER }); // bleibt unberührt
await heart('alt1', true);
await report('alt1', '203.0.1.9');
assert.equal(fake.rows('logo_likes').length, 1);
assert.equal(fake.rows('logo_reports').length, 1);
assert.equal((await remove('alt1')).status, 200);
assert.equal(fake.row('logos', 'alt1'), null);
assert.equal(fake.file('alt1'), null);
assert.equal(fake.rows('logo_likes').length, 0);
assert.equal(fake.rows('logo_reports').length, 0);
assert.ok(fake.row('logos', 'alt2'));
assert.ok(fake.file('alt2'));
// jemand legt eine Datei mit der alten ID an und lädt sie hoch: es entsteht eine Zeile mit neuer ID, die alte bleibt frei
logoFile(fake, 'alt1', OTHER);
const res = await upload(uploadBody({ fileId: 'alt1' }), JWT_OTHER);
assert.equal(res.status, 201);
assert.equal(res.json.id, rowOf('alt1', OTHER));
assert.equal(fake.row('logos', 'alt1'), null);
});
test('Altlogo: dieselbe Datei noch einmal hochladen ist 409 logoTaken (die Zeile trägt sie schon), keine zweite Zeile', async () => {
const { upload, fake } = await setup();
seedLogo(fake, { id: 'alt1', fileId: 'alt1' });
expectError(await upload(uploadBody({ fileId: 'alt1' })), 409, 'logoTaken');
assert.equal(fake.rows('logos').length, 1);
assert.deepEqual(fake.file('alt1').$permissions, ['read("any")']);
assert.equal(filePuts(fake, 'alt1').length, 0);
});
// ---- Routen, Logs, Fehler ---------------------------------------------------------------------------
test('Logo-Routen: nur POST mit application/json (sonst 404 bzw. 415), Appwrite wird dabei nicht berührt', async () => {
@@ -1215,11 +1324,11 @@ test('Logs der Logo-Endpunkte enthalten nie Key, Salz, JWT, IPs oder Namen, aber
await report('logo-9', '203.0.9.9');
await setVisible('logo-9', true);
fake.failNext((c) => c.method === 'GET' && c.path.includes('/tables/logos/rows/'), { status: 500 });
await remove('logo1');
await remove('logo1');
await remove(rowOf('logo1'));
await remove(rowOf('logo1'));
assert.ok(logs.length > 0);
assert.ok(logs.some((line) => line.endsWith('logo logo2 removed after upload check (logoFormat)')));
assert.ok(logs.some((line) => line.endsWith(`logo ${rowOf('logo2')} removed after upload check (logoFormat)`)));
const all = logs.join('\n');
for (const secret of [fake.key, SALT, JWT_ME, JWT_OTHER, JWT_ADMIN, 'xxx.yyy.zzz', '203.0.', '127.0.0.1', reporterHash(SALT, '203.0.7.9'), 'GEHEIMER']) {
assert.ok(!all.includes(secret), `Log enthält ${secret.slice(0, 12)}…`);