fix(api): Logo-Datei nach dem Festlegen der Rechte noch einmal prüfen
Der Hochlader behält delete auf seiner Datei, bis die API sie auf read("any") festlegt. Zwischen der ersten Prüfung
(Typ, Größe, PNG-Kopf, Maße) und dem Festlegen ließ sich die Datei unter derselben ID löschen und mit anderem Inhalt
neu anlegen (z. B. 4000 × 4000). Jetzt liest die API den Dateianfang nach dem Festlegen noch einmal, wenn der
Hochlader die Datei nicht mehr ändern kann:
- PNG-Kopf und Maße 8 × 8 bis 720 × 250 werden erneut geprüft. Fällt die Datei durch oder fehlt sie, löscht die API die
Datei (Server-Key) und die Zeile (beides wird versucht) und antwortet 400 logoFormat bzw. fileMissing. Log:
"logo <id> removed after upload check (<code>)", bleibt etwas stehen: "rollback failed for logo <id>".
- Ein Appwrite-Fehler beim zweiten Lesen wird wie ein Fehler beim Festlegen behandelt (502, Zeile zurückgenommen, die
Datei bekommt delete(owner) zurück).
- fake-appwrite.js: Testhilfe swapContentAfterPermissions (der Inhalt ändert sich nach dem nächsten Rechte-Update).
- Tests: 7 neue in logos.test.js (Austausch auf 4000 × 4000, weitere ungültige Inhalte, Datei weg, Appwrite-Fehler,
Aufräumen scheitert, Ablauffolge, weiterhin gültiger Inhalt), der Logtest prüft das neue Ereignis mit. Der
Karten-Ablauf ist unverändert (app.js und common.js nicht angefasst).
- Doku: api/README.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dc5de99611
commit
fc9e5f5577
4 files changed
+198
-6
No files matched your search
@@ -152,6 +152,21 @@ const sha32 = (text) => crypto.createHash('sha256').update(text).digest('hex').s
|
||||
const nonAccountCalls = (fake) => fake.calls.filter((c) => c.path !== '/v1/account');
|
||||
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) {
|
||||
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]);
|
||||
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');
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
/** Die Logzeilen ohne Präfix und Zeitstempel, die ein Logo betreffen. */
|
||||
const logEvents = (logs, id) => logs.map((line) => line.replace(/^\[aircard-api\] \S+ /, '')).filter((line) => line.includes(id));
|
||||
|
||||
// ---- Reine Helfer ------------------------------------------------------------------------------
|
||||
|
||||
test('logoLikeId und logoReportId: erste 32 Hex von sha256, Logo-Meldungen mit dem Präfix logo|', () => {
|
||||
@@ -478,6 +493,127 @@ test('Logo hochladen: gelingt das Zurücknehmen, steht keine Rollback-Fehlerzeil
|
||||
assert.equal(logs.filter((line) => line.includes('rollback failed')).length, 0);
|
||||
});
|
||||
|
||||
// ---- Hochladen: der Inhalt wird nach dem Festlegen der Rechte noch einmal geprüft --------------------------
|
||||
|
||||
test('Logo hochladen: der Dateianfang wird vor und nach dem Festlegen der Rechte gelesen (abrufen, lesen, anlegen, festlegen, lesen)', async () => {
|
||||
const { upload, fake } = await setup();
|
||||
logoFile(fake, 'logo1');
|
||||
assert.equal((await upload(uploadBody())).status, 201);
|
||||
assert.deepEqual(logoSteps(fake, 'logo1'), ['Datei abrufen', 'Anfang lesen', 'Zeile anlegen', 'Rechte festlegen', 'Anfang lesen']);
|
||||
for (const c of fake.calls.filter((x) => x.path.endsWith('/files/logo1/view'))) assert.equal(c.headers['x-appwrite-key'], fake.key);
|
||||
});
|
||||
|
||||
test('Logo hochladen: ein nach dem Festlegen anderer, aber weiterhin gültiger Inhalt (720 × 250) wird nicht beanstandet', async () => {
|
||||
const { upload, fake } = await setup();
|
||||
logoFile(fake, 'logo1');
|
||||
fake.swapContentAfterPermissions('logo1', pngHeader(720, 250));
|
||||
assert.equal((await upload(uploadBody())).status, 201);
|
||||
assert.ok(fake.row('logos', 'logo1'));
|
||||
assert.deepEqual(fake.file('logo1').$permissions, ['read("any")']);
|
||||
});
|
||||
|
||||
test('Logo hochladen: wird die Datei zwischen Prüfung und Festlegen ausgetauscht (danach 4000 × 4000), ist das 400 logoFormat, die Datei ist gelöscht und keine Zeile bleibt', async () => {
|
||||
const { upload, fake, logs } = await setup();
|
||||
logoFile(fake, 'logo1'); // beim ersten Lesen gültig (200 × 100)
|
||||
fake.swapContentAfterPermissions('logo1', pngHeader(4000, 4000));
|
||||
|
||||
expectError(await upload(uploadBody()), 400, 'logoFormat');
|
||||
assert.equal(fake.rows('logos').length, 0, 'keine Zeile bleibt');
|
||||
assert.equal(fake.file('logo1'), null, 'die Datei ist gelöscht');
|
||||
assert.deepEqual(logoSteps(fake, 'logo1'), [
|
||||
'Datei abrufen',
|
||||
'Anfang lesen',
|
||||
'Zeile anlegen',
|
||||
'Rechte festlegen',
|
||||
'Anfang lesen',
|
||||
'Datei löschen',
|
||||
'Zeile löschen',
|
||||
]);
|
||||
// ein Ereignis im Log (nur ID und Code), nie „uploaded“
|
||||
assert.deepEqual(logEvents(logs, 'logo1'), ['logo logo1 removed after upload check (logoFormat)']);
|
||||
});
|
||||
|
||||
test('Logo hochladen: nach dem Festlegen zählt der Inhalt noch einmal (kein PNG-Kopf, zu klein, zu groß): 400 logoFormat, Datei und Zeile sind weg', async () => {
|
||||
const { upload, fake } = await setup();
|
||||
let n = 0;
|
||||
for (const [label, content] of [
|
||||
['4000 × 4000', pngHeader(4000, 4000)],
|
||||
['721 × 100', pngHeader(721, 100)],
|
||||
['720 × 251', pngHeader(720, 251)],
|
||||
['7 × 8', pngHeader(7, 8)],
|
||||
['WebP-Inhalt', webpHeader('VP8L', 200, 100)],
|
||||
['kein Bild', Buffer.from('kein Bild, nur Text, lang genug für die Prüfung 123456789')],
|
||||
['leer', Buffer.alloc(0)],
|
||||
]) {
|
||||
n += 1;
|
||||
const id = `tausch${n}`;
|
||||
logoFile(fake, id);
|
||||
fake.swapContentAfterPermissions(id, content);
|
||||
expectError(await upload(uploadBody({ fileId: id })), 400, 'logoFormat', label);
|
||||
assert.equal(fake.row('logos', id), null, `${label}: keine Zeile`);
|
||||
assert.equal(fake.file(id), null, `${label}: Datei gelöscht`);
|
||||
}
|
||||
assert.equal(fake.rows('logos').length, 0);
|
||||
});
|
||||
|
||||
test('Logo hochladen: ist die Datei nach dem Festlegen nicht mehr lesbar, ist das 400 fileMissing, Datei und Zeile werden entfernt', async () => {
|
||||
const { upload, fake, logs } = await setup();
|
||||
logoFile(fake, 'logo1');
|
||||
let reads = 0;
|
||||
fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/files/logo1/view') && (reads += 1) === 2, { status: 404, type: 'storage_file_not_found' });
|
||||
expectError(await upload(uploadBody()), 400, 'fileMissing');
|
||||
assert.equal(fake.rows('logos').length, 0);
|
||||
assert.equal(fake.file('logo1'), null);
|
||||
assert.deepEqual(logEvents(logs, 'logo1'), ['logo logo1 removed after upload check (fileMissing)']);
|
||||
});
|
||||
|
||||
test('Logo hochladen: Appwrite-Fehler beim zweiten Lesen ist 502, nichts bleibt veröffentlicht, die Datei bekommt delete(owner) zurück, die Wiederholung klappt', async () => {
|
||||
const { upload, fake, logs } = await setup();
|
||||
logoFile(fake, 'logo1');
|
||||
let reads = 0;
|
||||
fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/files/logo1/view') && (reads += 1) === 2, { status: 500 });
|
||||
expectError(await upload(uploadBody()), 502, 'unavailable');
|
||||
assert.equal(fake.rows('logos').length, 0);
|
||||
assert.deepEqual(fake.file('logo1').$permissions, UPLOAD_PERMISSIONS, 'wie vor dem Aufruf: kein Logo, die Datei lässt sich vom Hochlader löschen');
|
||||
assert.deepEqual(logoSteps(fake, 'logo1'), [
|
||||
'Datei abrufen',
|
||||
'Anfang lesen',
|
||||
'Zeile anlegen',
|
||||
'Rechte festlegen',
|
||||
'Anfang lesen',
|
||||
'Zeile löschen',
|
||||
'Rechte festlegen',
|
||||
]);
|
||||
assert.deepEqual(logEvents(logs, '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 () => {
|
||||
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)
|
||||
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.deepEqual(fake.file('logo1').$permissions, ['read("any")'], 'die Datei blieb liegen');
|
||||
assert.deepEqual(logEvents(logs, 'logo1'), ['rollback failed for logo 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 });
|
||||
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);
|
||||
});
|
||||
|
||||
test('Logo hochladen: Validierung liefert 400 mit dem passenden Code und fasst Appwrite (außer dem Konto) nicht an', async () => {
|
||||
const { upload, fake } = await setup();
|
||||
logoFile(fake, 'logo1');
|
||||
@@ -1067,8 +1203,11 @@ test('Logo-Endpunkte: Appwrite-Ausfall ist 502 unavailable, eine fehlende Tabell
|
||||
test('Logs der Logo-Endpunkte enthalten nie Key, Salz, JWT, IPs oder Namen, aber das Präfix [aircard-api]', async () => {
|
||||
const { upload, heart, report, setVisible, remove, fake, logs } = await setup();
|
||||
logoFile(fake, 'logo1');
|
||||
logoFile(fake, 'logo2');
|
||||
fake.swapContentAfterPermissions('logo2', pngHeader(4000, 4000));
|
||||
seedLogo(fake, { id: 'logo-9', owner: OTHER });
|
||||
await upload(uploadBody({ name: 'GEHEIMER Name', displayName: 'GEHEIMER Anzeigename' }));
|
||||
await upload(uploadBody({ fileId: 'logo2', name: 'GEHEIMER Tausch' })); // nach dem Festlegen beanstandet und entfernt
|
||||
await upload(uploadBody(), 'xxx.yyy.zzz');
|
||||
await heart('logo-9', true);
|
||||
await report('logo-9', '203.0.7.9');
|
||||
@@ -1080,6 +1219,7 @@ test('Logs der Logo-Endpunkte enthalten nie Key, Salz, JWT, IPs oder Namen, aber
|
||||
await remove('logo1');
|
||||
|
||||
assert.ok(logs.length > 0);
|
||||
assert.ok(logs.some((line) => line.endsWith('logo 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)}…`);
|
||||
|
||||
Reference in new issue
Block a user