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
@@ -71,7 +71,7 @@ Jede Fehlerantwort trägt neben dem deutschen Text einen `code`: den Schlüssel
|
|||||||
- **Limit**: höchstens 20 Veröffentlichungen je Konto (Zeilen mit `ownerId` = ich, auch ausgeblendete); ab da `429` mit dem Text aus der Tabelle. Zurückziehen gibt einen Platz frei. Das Limit liest die Zeilen des Autors über `ownerId` (dort gibt es einen Index) mit `limit(20)`.
|
- **Limit**: höchstens 20 Veröffentlichungen je Konto (Zeilen mit `ownerId` = ich, auch ausgeblendete); ab da `429` mit dem Text aus der Tabelle. Zurückziehen gibt einen Platz frei. Das Limit liest die Zeilen des Autors über `ownerId` (dort gibt es einen Index) mit `limit(20)`.
|
||||||
- **Dateien** (beide): liegen im Bucket `gallery-images`, tragen `delete("user:<ich>")`, sind `image/png` oder `image/webp` und höchstens 5 MiB. Eine Datei, die es nicht gibt, ergibt `400`; eine fremde `403` (Typ und Größe fremder Dateien nennt die API nicht). **Bildmaße**: Die API liest die ersten 64 Bytes beider Dateien (`GET …/files/<id>/view` mit Server-Key, der Rest der Übertragung wird abgebrochen) und liest PNG-IHDR bzw. WebP (`VP8 `, `VP8L`, `VP8X`) aus. Das Bild muss genau 1536 × 969 sein, die Vorschau 480 breit und 302–304 hoch (die Karte ist 1536 × 969, die Vorschau 480 × 303), sonst `400` „Das Bild hat nicht das Kartenformat.“ Das ist Defense in Depth gegen Dekompressionsbomben; die maßgebliche Prüfung beim Anzeigen macht der Client (Pixelgrenze 4 Megapixel). **Keine Wiederverwendung**: Beide Dateien müssen `read("any")` tragen (frisch hochgeladen; ausgeblendete Dateien nie) und dürfen von keiner Galerie-Zeile des Autors schon als Bild oder Vorschau verwendet werden (auch überkreuz, auch von ausgeblendeten Karten), sonst `403` „Diese Bilder sind bereits veröffentlicht oder gesperrt.“ So lassen sich die Dateien einer gemeldeten oder bereits veröffentlichten Karte nicht unter einer anderen `sourceCardId` neu einstellen. Die Prüfung läuft zusammen mit Limit, Quellkarte und Anlegen je Autor nacheinander. Nach dem Anlegen der Zeile setzt die API beide Dateien auf genau `read("any")` (**kein `delete("user:<ich>")` mehr**, kanonischer Zustand, auch wenn der Client mehr Rechte vergeben hat); scheitert das, wird die Zeile zurückgenommen und schon festgelegte Dateien bekommen `delete("user:<ich>")` zurück, damit der Client den Fehlversuch aufräumen kann (scheitert schon das Zurücknehmen, steht `rollback failed for card <id>` im Log). Ein 409 fasst die Dateirechte nie an. Bei Appwrite 1.8 liefert eine Datei aus einem anderen Bucket schon beim Abruf 404, das erscheint dann ebenfalls als `400` „Bild nicht gefunden.“
|
- **Dateien** (beide): liegen im Bucket `gallery-images`, tragen `delete("user:<ich>")`, sind `image/png` oder `image/webp` und höchstens 5 MiB. Eine Datei, die es nicht gibt, ergibt `400`; eine fremde `403` (Typ und Größe fremder Dateien nennt die API nicht). **Bildmaße**: Die API liest die ersten 64 Bytes beider Dateien (`GET …/files/<id>/view` mit Server-Key, der Rest der Übertragung wird abgebrochen) und liest PNG-IHDR bzw. WebP (`VP8 `, `VP8L`, `VP8X`) aus. Das Bild muss genau 1536 × 969 sein, die Vorschau 480 breit und 302–304 hoch (die Karte ist 1536 × 969, die Vorschau 480 × 303), sonst `400` „Das Bild hat nicht das Kartenformat.“ Das ist Defense in Depth gegen Dekompressionsbomben; die maßgebliche Prüfung beim Anzeigen macht der Client (Pixelgrenze 4 Megapixel). **Keine Wiederverwendung**: Beide Dateien müssen `read("any")` tragen (frisch hochgeladen; ausgeblendete Dateien nie) und dürfen von keiner Galerie-Zeile des Autors schon als Bild oder Vorschau verwendet werden (auch überkreuz, auch von ausgeblendeten Karten), sonst `403` „Diese Bilder sind bereits veröffentlicht oder gesperrt.“ So lassen sich die Dateien einer gemeldeten oder bereits veröffentlichten Karte nicht unter einer anderen `sourceCardId` neu einstellen. Die Prüfung läuft zusammen mit Limit, Quellkarte und Anlegen je Autor nacheinander. Nach dem Anlegen der Zeile setzt die API beide Dateien auf genau `read("any")` (**kein `delete("user:<ich>")` mehr**, kanonischer Zustand, auch wenn der Client mehr Rechte vergeben hat); scheitert das, wird die Zeile zurückgenommen und schon festgelegte Dateien bekommen `delete("user:<ich>")` zurück, damit der Client den Fehlversuch aufräumen kann (scheitert schon das Zurücknehmen, steht `rollback failed for card <id>` im Log). Ein 409 fasst die Dateirechte nie an. Bei Appwrite 1.8 liefert eine Datei aus einem anderen Bucket schon beim Abruf 404, das erscheint dann ebenfalls als `400` „Bild nicht gefunden.“
|
||||||
- **Logo-Felder** (`/api/logos/hochladen`): `name` 1–40 und `displayName` ≤ 30 Zeichen (getrimmt; leerer Anzeigename = „Anonym“; dieselben verbotenen Zeichen wie bei den Karten-Texten), `mono` fehlt = `false`, sonst muss es ein Boolean sein (`badMono`), `fileId` nach dem ID-Muster. Die Zeilen-ID des Logos ist die Datei-ID: so kann eine Datei nie zwei Logos tragen.
|
- **Logo-Felder** (`/api/logos/hochladen`): `name` 1–40 und `displayName` ≤ 30 Zeichen (getrimmt; leerer Anzeigename = „Anonym“; dieselben verbotenen Zeichen wie bei den Karten-Texten), `mono` fehlt = `false`, sonst muss es ein Boolean sein (`badMono`), `fileId` nach dem ID-Muster. Die Zeilen-ID des Logos ist die Datei-ID: so kann eine Datei nie zwei Logos tragen.
|
||||||
- **Logo-Datei und Reihenfolge**: Die Datei liegt im Bucket `logo-images`, trägt `delete("user:<ich>")` und `read("any")`, ist `image/png`, höchstens 1 MiB (1048576 Byte) und hat einen PNG-Kopf mit 8 × 8 bis 720 × 250 Pixeln (die API liest nur die ersten 64 Bytes). Die Prüfungen laufen in dieser Reihenfolge, damit fremde Dateien nichts verraten: JWT (`401`) → Body (`400`) → Datei da und im richtigen Bucket (`400`/`403`) → je Konto nacheinander (Sperre `logo-owner:<id>`): schon 30 Logos (`429`, ausgeblendete zählen mit) → diese Datei ist schon ein Logo des Kontos (`409`) → Besitz (`403`: kein `delete("user:<ich>")` oder kein `read("any")`) → Typ und Größe (`400`) → Maße (`400`) → Zeile anlegen (`409`, wenn die ID schon vergeben ist) → Datei auf genau `read("any")` setzen (**kein `delete("user:<ich>")` mehr**, sonst ließe sich das Bild unter derselben ID tauschen). Scheitert das Festlegen, wird die Zeile zurückgenommen und nur dann bekommt die Datei `delete("user:<ich>")` zurück (scheitert schon das Zurücknehmen, steht `rollback failed for logo <id>` im Log). Fremde, schon veröffentlichte oder gesperrte Dateien ergeben `403`, ohne dass die API ihren Inhalt liest.
|
- **Logo-Datei und Reihenfolge**: Die Datei liegt im Bucket `logo-images`, trägt `delete("user:<ich>")` und `read("any")`, ist `image/png`, höchstens 1 MiB (1048576 Byte) und hat einen PNG-Kopf mit 8 × 8 bis 720 × 250 Pixeln (die API liest nur die ersten 64 Bytes). Die Prüfungen laufen in dieser Reihenfolge, damit fremde Dateien nichts verraten: JWT (`401`) → Body (`400`) → Datei da und im richtigen Bucket (`400`/`403`) → je Konto nacheinander (Sperre `logo-owner:<id>`): schon 30 Logos (`429`, ausgeblendete zählen mit) → diese Datei ist schon ein Logo des Kontos (`409`) → Besitz (`403`: kein `delete("user:<ich>")` oder kein `read("any")`) → Typ und Größe (`400`) → Maße (`400`) → Zeile anlegen (`409`, wenn die ID schon vergeben ist) → Datei auf genau `read("any")` setzen (**kein `delete("user:<ich>")` mehr**, sonst ließe sich das Bild unter derselben ID tauschen). Scheitert das Festlegen, wird die Zeile zurückgenommen und nur dann bekommt die Datei `delete("user:<ich>")` zurück (scheitert schon das Zurücknehmen, steht `rollback failed for logo <id>` im Log). **Zweite Prüfung**: Danach liest die API den Dateianfang noch einmal und prüft PNG-Kopf und Maße erneut. Jetzt kann der Hochlader die Datei nicht mehr ändern; so lässt sich der Inhalt nicht zwischen der ersten Prüfung und dem Festlegen unter derselben ID austauschen (Löschen und Neuanlegen der Datei). Fällt die Datei jetzt durch oder fehlt sie, löscht die API die Datei mit dem Server-Key und die Zeile (beides wird versucht, auch wenn das erste scheitert) 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("user:<ich>")` zurück). Fremde, schon veröffentlichte oder gesperrte Dateien ergeben `403`, ohne dass die API ihren Inhalt liest.
|
||||||
- **Herzen** (`/api/logos/herz`): ein Herz je Konto und Logo, Zeilen-ID `sha256(<logoId>|<userId>)` (erste 32 Hex), Recht `read("user:<id>")`. `herz: true` legt die Zeile an (`409` = schon da, kein Fehler), `false` löscht sie (schon weg = kein Fehler). Danach ist `likeCount` immer `countRows` aller Herzen des Logos (neu gezählt, nie hoch- oder runtergezählt) und wird nur geschrieben, wenn er abweicht. Fehlendes oder ausgeblendetes Logo: `404`, auch beim Entfernen. Pro Logo läuft immer nur eine Änderung gleichzeitig (Sperre `logo:<id>`). Herzen werden nicht geloggt.
|
- **Herzen** (`/api/logos/herz`): ein Herz je Konto und Logo, Zeilen-ID `sha256(<logoId>|<userId>)` (erste 32 Hex), Recht `read("user:<id>")`. `herz: true` legt die Zeile an (`409` = schon da, kein Fehler), `false` löscht sie (schon weg = kein Fehler). Danach ist `likeCount` immer `countRows` aller Herzen des Logos (neu gezählt, nie hoch- oder runtergezählt) und wird nur geschrieben, wenn er abweicht. Fehlendes oder ausgeblendetes Logo: `404`, auch beim Entfernen. Pro Logo läuft immer nur eine Änderung gleichzeitig (Sperre `logo:<id>`). Herzen werden nicht geloggt.
|
||||||
- **Logo melden** (`/api/logos/melden`): wie `/api/melden`, mit denselben Gründen, demselben Melder (Netz, gehasht) und **demselben Rate-Limit** (ein gemeinsamer Zähler je Anschluss für Karten und Logos: 6 Karten- und 5 Logo-Meldungen vom selben Anschluss ergeben bei der 11. ein `429`). Die Zeilen-ID der Meldung ist `sha256(logo|<logoId>|<reporterHash>)`, `reportCount` ist `countRows` der Meldungen des Logos, ab 3 wird ausgeblendet (Datei und Zeile auf `read("user:<owner>")` und `read("label:admin")`).
|
- **Logo melden** (`/api/logos/melden`): wie `/api/melden`, mit denselben Gründen, demselben Melder (Netz, gehasht) und **demselben Rate-Limit** (ein gemeinsamer Zähler je Anschluss für Karten und Logos: 6 Karten- und 5 Logo-Meldungen vom selben Anschluss ergeben bei der 11. ein `429`). Die Zeilen-ID der Meldung ist `sha256(logo|<logoId>|<reporterHash>)`, `reportCount` ist `countRows` der Meldungen des Logos, ab 3 wird ausgeblendet (Datei und Zeile auf `read("user:<owner>")` und `read("label:admin")`).
|
||||||
- **Logo löschen und Sichtbarkeit**: Löschen (Hochlader oder Admin) entfernt Datei, Herzen, Meldungen und zuletzt die Zeile; Herzen und Meldungen in Runden zu 100 (`equal logoId`, höchstens 50 Runden). Die Admin-Sichtbarkeit verhält sich wie bei den Karten: Ausblenden setzt Datei und Zeile auf die ausgeblendeten Rechte (`hidden: true`), Einblenden setzt sie auf `read("any")`, löscht die Meldungen und setzt `reportCount` auf 0 (die Herzen bleiben).
|
- **Logo löschen und Sichtbarkeit**: Löschen (Hochlader oder Admin) entfernt Datei, Herzen, Meldungen und zuletzt die Zeile; Herzen und Meldungen in Runden zu 100 (`equal logoId`, höchstens 50 Runden). Die Admin-Sichtbarkeit verhält sich wie bei den Karten: Ausblenden setzt Datei und Zeile auf die ausgeblendeten Rechte (`hidden: true`), Einblenden setzt sie auf `read("any")`, löscht die Meldungen und setzt `reportCount` auf 0 (die Herzen bleiben).
|
||||||
@@ -80,7 +80,7 @@ Jede Fehlerantwort trägt neben dem deutschen Text einen `code`: den Schlüssel
|
|||||||
- **Client-IP**: `X-Real-Ip` (setzt Traefik), sonst erster Eintrag von `X-Forwarded-For`, sonst die Socket-Adresse. Der Dienst veröffentlicht nie `ports:`, ist aber im Docker-Netz `webklar-network` für andere Container erreichbar, und dort gilt `X-Real-Ip` als vertrauenswürdig: ein Container im selben Netz könnte beliebige Melder-Adressen vortäuschen. Akzeptiertes Risiko (kein Code dagegen); eine Abhilfe wäre eine Traefik-Middleware mit geheimem Header, den `clientIp` prüft, oder ein eigenes Netz nur für Traefik und `aircard-api`. Vor einem AAAA-Eintrag für aircard-ios.org die Client-IP-Ermittlung prüfen.
|
- **Client-IP**: `X-Real-Ip` (setzt Traefik), sonst erster Eintrag von `X-Forwarded-For`, sonst die Socket-Adresse. Der Dienst veröffentlicht nie `ports:`, ist aber im Docker-Netz `webklar-network` für andere Container erreichbar, und dort gilt `X-Real-Ip` als vertrauenswürdig: ein Container im selben Netz könnte beliebige Melder-Adressen vortäuschen. Akzeptiertes Risiko (kein Code dagegen); eine Abhilfe wäre eine Traefik-Middleware mit geheimem Header, den `clientIp` prüft, oder ein eigenes Netz nur für Traefik und `aircard-api`. Vor einem AAAA-Eintrag für aircard-ios.org die Client-IP-Ermittlung prüfen.
|
||||||
- **Rechte** (genau diese Strings; Abweichung von Spec §7, siehe unten): sichtbar Zeile `read("any")`; ausgeblendet Zeile `read("user:<owner>")`, `read("label:admin")`; Datei hochgeladen (noch nicht veröffentlicht, vom Client gesetzt) `read("any")`, `delete("user:<owner>")`; Datei veröffentlicht `read("any")`; Datei ausgeblendet `read("user:<owner>")`, `read("label:admin")`. Veröffentlichte und ausgeblendete Dateien tragen **kein** `delete(owner)`: sonst könnte der Autor eine Datei nach dem Veröffentlichen löschen und unter derselben ID mit anderem Inhalt neu anlegen (Bild-Tausch). Zurückziehen und Admin-Aktionen löschen serverseitig mit dem Key. Meldungszeilen haben keine eigenen Rechte (Tabelle: nur `read("label:admin")`). Für Logos gilt dasselbe (Zeile und Datei sichtbar `read("any")`, ausgeblendet `read("user:<owner>")` und `read("label:admin")`, hochgeladen `read("any")` und `delete("user:<owner>")`); Herzen tragen `read("user:<id>")` des Kontos.
|
- **Rechte** (genau diese Strings; Abweichung von Spec §7, siehe unten): sichtbar Zeile `read("any")`; ausgeblendet Zeile `read("user:<owner>")`, `read("label:admin")`; Datei hochgeladen (noch nicht veröffentlicht, vom Client gesetzt) `read("any")`, `delete("user:<owner>")`; Datei veröffentlicht `read("any")`; Datei ausgeblendet `read("user:<owner>")`, `read("label:admin")`. Veröffentlichte und ausgeblendete Dateien tragen **kein** `delete(owner)`: sonst könnte der Autor eine Datei nach dem Veröffentlichen löschen und unter derselben ID mit anderem Inhalt neu anlegen (Bild-Tausch). Zurückziehen und Admin-Aktionen löschen serverseitig mit dem Key. Meldungszeilen haben keine eigenen Rechte (Tabelle: nur `read("label:admin")`). Für Logos gilt dasselbe (Zeile und Datei sichtbar `read("any")`, ausgeblendet `read("user:<owner>")` und `read("label:admin")`, hochgeladen `read("any")` und `delete("user:<owner>")`); Herzen tragen `read("user:<id>")` des Kontos.
|
||||||
- **Reihenfolge bei Mehrschritt-Aktionen** (jede Aktion ist wiederholbar): Ausblenden/Einblenden ändert zuerst die Dateirechte, beim Einblenden danach die Meldungen löschen, die Zeile zuletzt. Zurückziehen löscht Dateien, Meldungen, dann die Zeile; Logo löschen ebenso (Datei, Herzen, Meldungen, Zeile). Bleibt etwas hängen (502), heilt der nächste Versuch.
|
- **Reihenfolge bei Mehrschritt-Aktionen** (jede Aktion ist wiederholbar): Ausblenden/Einblenden ändert zuerst die Dateirechte, beim Einblenden danach die Meldungen löschen, die Zeile zuletzt. Zurückziehen löscht Dateien, Meldungen, dann die Zeile; Logo löschen ebenso (Datei, Herzen, Meldungen, Zeile). Bleibt etwas hängen (502), heilt der nächste Versuch.
|
||||||
- **Logs**: Zeilen beginnen mit `[aircard-api]`. Es gibt kein Zugriffsprotokoll je Anfrage (`/api/melden` ist ohne Anmeldung erreichbar, ein Angreifer würde sonst das Log-Volumen bestimmen): geloggt werden nur Ereignisse (veröffentlicht bzw. Logo hochgeladen, gemeldet, ausgeblendet, zurückgezogen bzw. Logo gelöscht, Admin-Aktion, fehlgeschlagenes Zurücknehmen; keine Herzen) und Serverfehler (5xx), zusammen höchstens 120 Zeilen pro Minute (der Rest wird gezählt und als Sammelzeile „N log lines suppressed“ nachgetragen). Die Zeilen nennen Route, Status, Karten- bzw. Logo-ID und Fehlertypen, nie Key, Salz, JWT, IPs oder Fehlertexte von Appwrite. Zusätzlich deckelt Compose die Logdatei (`logging: json-file`, 5 MB, 2 Dateien).
|
- **Logs**: Zeilen beginnen mit `[aircard-api]`. Es gibt kein Zugriffsprotokoll je Anfrage (`/api/melden` ist ohne Anmeldung erreichbar, ein Angreifer würde sonst das Log-Volumen bestimmen): geloggt werden nur Ereignisse (veröffentlicht bzw. Logo hochgeladen oder nach der zweiten Prüfung entfernt, gemeldet, ausgeblendet, zurückgezogen bzw. Logo gelöscht, Admin-Aktion, fehlgeschlagenes Zurücknehmen; keine Herzen) und Serverfehler (5xx), zusammen höchstens 120 Zeilen pro Minute (der Rest wird gezählt und als Sammelzeile „N log lines suppressed“ nachgetragen). Die Zeilen nennen Route, Status, Karten- bzw. Logo-ID und Fehlertypen, nie Key, Salz, JWT, IPs oder Fehlertexte von Appwrite. Zusätzlich deckelt Compose die Logdatei (`logging: json-file`, 5 MB, 2 Dateien).
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
* Test auffallen.
|
* Test auffallen.
|
||||||
* Dateien werden nach ID gesucht und liefern ihren echten `bucketId` zurück (der Bucket in der URL zählt nicht),
|
* Dateien werden nach ID gesucht und liefern ihren echten `bucketId` zurück (der Bucket in der URL zählt nicht),
|
||||||
* so lässt sich der Fall „Datei liegt in einem anderen Bucket“ nachstellen.
|
* so lässt sich der Fall „Datei liegt in einem anderen Bucket“ nachstellen.
|
||||||
|
* Testhilfen: failNext/hangNext/delayNext (Aufruf scheitert, hängt, wird verzögert) und swapContentAfterPermissions
|
||||||
|
* (Dateiinhalt ändert sich nach dem nächsten Rechte-Update).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const http = require('node:http');
|
const http = require('node:http');
|
||||||
@@ -125,6 +127,7 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
|
|||||||
const users = new Map();
|
const users = new Map();
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const injections = [];
|
const injections = [];
|
||||||
|
const swaps = new Map();
|
||||||
let sequence = 0;
|
let sequence = 0;
|
||||||
|
|
||||||
function addUser(jwt, { id, labels = [] }) {
|
function addUser(jwt, { id, labels = [] }) {
|
||||||
@@ -180,6 +183,15 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
|
|||||||
injections.push({ predicate, delayMs });
|
injections.push({ predicate, delayMs });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nach dem nächsten Rechte-Update (PUT) der Datei `id` hat sie den Inhalt `content` (einmalig). So lässt sich eine Datei
|
||||||
|
* nachstellen, die zwischen der ersten Prüfung und dem Festlegen der Rechte ausgetauscht wurde: der Anfang ist beim ersten
|
||||||
|
* Lesen gültig und danach ein anderer.
|
||||||
|
*/
|
||||||
|
function swapContentAfterPermissions(id, content) {
|
||||||
|
swaps.set(id, content);
|
||||||
|
}
|
||||||
|
|
||||||
function validateData(table, data, { partial }) {
|
function validateData(table, data, { partial }) {
|
||||||
const columns = COLUMNS[table];
|
const columns = COLUMNS[table];
|
||||||
for (const [name, value] of Object.entries(data)) {
|
for (const [name, value] of Object.entries(data)) {
|
||||||
@@ -323,6 +335,10 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
|
|||||||
const body = call.body || {};
|
const body = call.body || {};
|
||||||
if (!Array.isArray(body.permissions)) return error(400, 'general_argument_invalid');
|
if (!Array.isArray(body.permissions)) return error(400, 'general_argument_invalid');
|
||||||
file.$permissions = body.permissions;
|
file.$permissions = body.permissions;
|
||||||
|
if (swaps.has(segments[5])) {
|
||||||
|
contents.set(segments[5], swaps.get(segments[5]));
|
||||||
|
swaps.delete(segments[5]);
|
||||||
|
}
|
||||||
return reply(200, file);
|
return reply(200, file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -348,6 +364,7 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
|
|||||||
failNext,
|
failNext,
|
||||||
hangNext,
|
hangNext,
|
||||||
delayNext,
|
delayNext,
|
||||||
|
swapContentAfterPermissions,
|
||||||
row: (table, id) => tables[table].get(id) || null,
|
row: (table, id) => tables[table].get(id) || null,
|
||||||
rows: (table) => [...tables[table].values()],
|
rows: (table) => [...tables[table].values()],
|
||||||
file: (id) => files.get(id) || null,
|
file: (id) => files.get(id) || null,
|
||||||
|
|||||||
+39
-4
@@ -9,6 +9,8 @@
|
|||||||
* 401 · 400 Validierung, Datei fehlt, kein PNG, größer als 1 MiB, Maße nicht 8 × 8 bis 720 × 250 · 403 Datei nicht
|
* 401 · 400 Validierung, Datei fehlt, kein PNG, größer als 1 MiB, Maße nicht 8 × 8 bis 720 × 250 · 403 Datei nicht
|
||||||
* meine / falscher Bucket / Datei gesperrt (kein read("any")) · 409 Datei schon für ein Logo verwendet · 429 schon
|
* meine / falscher Bucket / Datei gesperrt (kein read("any")) · 409 Datei schon für ein Logo verwendet · 429 schon
|
||||||
* 30 Logos · 201 {id} (id = Datei-ID = Zeilen-ID)
|
* 30 Logos · 201 {id} (id = Datei-ID = Zeilen-ID)
|
||||||
|
* Nach dem Festlegen der Datei auf read("any") wird ihr Anfang noch einmal geprüft (der Hochlader kann sie dann nicht
|
||||||
|
* mehr ändern): fällt sie durch oder fehlt sie, werden Datei und Zeile gelöscht und die Antwort ist 400.
|
||||||
* POST /api/logos/herz Nutzer-JWT {id, herz}
|
* POST /api/logos/herz Nutzer-JWT {id, herz}
|
||||||
* 401 · 400 · 404 fehlt oder ausgeblendet · 200 {herz, likeCount}
|
* 401 · 400 · 404 fehlt oder ausgeblendet · 200 {herz, likeCount}
|
||||||
* POST /api/logos/melden ohne Anmeldung {id, reason} (wie /api/melden, Rate-Limit gemeinsam)
|
* POST /api/logos/melden ohne Anmeldung {id, reason} (wie /api/melden, Rate-Limit gemeinsam)
|
||||||
@@ -180,11 +182,35 @@ function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUse
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Liest den Anfang der Datei und verlangt einen PNG-Kopf mit Maßen von 8 × 8 bis 720 × 250. */
|
/**
|
||||||
async function checkLogoSize(fileId) {
|
* Liest den Anfang der Datei und verlangt einen PNG-Kopf mit Maßen von 8 × 8 bis 720 × 250.
|
||||||
|
* @returns {Promise<HttpError | null>} das Urteil (400 fileMissing bzw. logoFormat), null = in Ordnung; Appwrite-Fehler werfen
|
||||||
|
*/
|
||||||
|
async function logoSizeProblem(fileId) {
|
||||||
const head = await appwrite.readFileHead(BUCKET, fileId, IMAGE_HEAD_BYTES);
|
const head = await appwrite.readFileHead(BUCKET, fileId, IMAGE_HEAD_BYTES);
|
||||||
if (head === null) throw new HttpError(400, MSG.fileMissing);
|
if (head === null) return new HttpError(400, MSG.fileMissing);
|
||||||
if (!isLogoSize(head)) throw new HttpError(400, MSG.logoFormat);
|
return isLogoSize(head) ? null : new HttpError(400, MSG.logoFormat);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkLogoSize(fileId) {
|
||||||
|
const problem = await logoSizeProblem(fileId);
|
||||||
|
if (problem !== null) throw problem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Logo ist nach dem Festlegen der Rechte durchgefallen (Inhalt ausgetauscht oder Datei weg): die Datei mit dem
|
||||||
|
* Server-Key löschen, dann die Zeile. Beides wird versucht, auch wenn das erste scheitert: die Zeile macht das Logo für
|
||||||
|
* andere sichtbar. Bleibt etwas stehen, steht `rollback failed for logo <id>` im Log.
|
||||||
|
*/
|
||||||
|
async function removeRejectedLogo(id, problem) {
|
||||||
|
let clean = true;
|
||||||
|
await appwrite.deleteFile(BUCKET, id).catch(() => {
|
||||||
|
clean = false;
|
||||||
|
});
|
||||||
|
await appwrite.deleteRow(TABLE_LOGOS, id).catch(() => {
|
||||||
|
clean = false;
|
||||||
|
});
|
||||||
|
say(clean ? `logo ${id} removed after upload check (${problem.code})` : `rollback failed for logo ${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadLogo({ req, raw }) {
|
async function uploadLogo({ req, raw }) {
|
||||||
@@ -231,9 +257,14 @@ function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUse
|
|||||||
|
|
||||||
// Kanonischer Zustand: die Datei trägt genau read("any") (kein delete(owner): sonst ließe sich das Bild tauschen).
|
// Kanonischer Zustand: die Datei trägt genau read("any") (kein delete(owner): sonst ließe sich das Bild tauschen).
|
||||||
// Erst nach dem Anlegen der Zeile: ein 409 fasst die Datei nie an. Scheitert das Festlegen, wird die Zeile zurückgenommen.
|
// Erst nach dem Anlegen der Zeile: ein 409 fasst die Datei nie an. Scheitert das Festlegen, wird die Zeile zurückgenommen.
|
||||||
|
let problem = null;
|
||||||
try {
|
try {
|
||||||
const pinned = await appwrite.updateFilePermissions(BUCKET, data.fileId, visibleFilePermissions());
|
const pinned = await appwrite.updateFilePermissions(BUCKET, data.fileId, visibleFilePermissions());
|
||||||
if (pinned === null) throw new HttpError(400, MSG.fileMissing);
|
if (pinned === null) throw new HttpError(400, MSG.fileMissing);
|
||||||
|
// Jetzt hat der Hochlader kein delete mehr und kann die Datei nicht mehr ändern: den Anfang noch einmal prüfen. So
|
||||||
|
// lässt sich die Datei nicht zwischen der ersten Prüfung und dem Festlegen unter derselben ID austauschen. Ein
|
||||||
|
// Appwrite-Fehler hier wird wie ein Fehler beim Festlegen behandelt (Rücknahme unten, nichts bleibt veröffentlicht).
|
||||||
|
problem = await logoSizeProblem(data.fileId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
let rolledBack = true;
|
let rolledBack = true;
|
||||||
await appwrite.deleteRow(TABLE_LOGOS, data.fileId).catch(() => {
|
await appwrite.deleteRow(TABLE_LOGOS, data.fileId).catch(() => {
|
||||||
@@ -244,6 +275,10 @@ function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUse
|
|||||||
if (rolledBack) await appwrite.updateFilePermissions(BUCKET, data.fileId, uploadFilePermissions(user.id)).catch(() => {});
|
if (rolledBack) await appwrite.updateFilePermissions(BUCKET, data.fileId, uploadFilePermissions(user.id)).catch(() => {});
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
if (problem !== null) {
|
||||||
|
await removeRejectedLogo(data.fileId, problem);
|
||||||
|
throw problem;
|
||||||
|
}
|
||||||
say(`logo ${data.fileId} uploaded`);
|
say(`logo ${data.fileId} uploaded`);
|
||||||
return { status: 201, body: { id: data.fileId } };
|
return { status: 201, body: { id: data.fileId } };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 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}`));
|
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 ------------------------------------------------------------------------------
|
// ---- Reine Helfer ------------------------------------------------------------------------------
|
||||||
|
|
||||||
test('logoLikeId und logoReportId: erste 32 Hex von sha256, Logo-Meldungen mit dem Präfix logo|', () => {
|
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);
|
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 () => {
|
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();
|
const { upload, fake } = await setup();
|
||||||
logoFile(fake, 'logo1');
|
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 () => {
|
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();
|
const { upload, heart, report, setVisible, remove, fake, logs } = await setup();
|
||||||
logoFile(fake, 'logo1');
|
logoFile(fake, 'logo1');
|
||||||
|
logoFile(fake, 'logo2');
|
||||||
|
fake.swapContentAfterPermissions('logo2', pngHeader(4000, 4000));
|
||||||
seedLogo(fake, { id: 'logo-9', owner: OTHER });
|
seedLogo(fake, { id: 'logo-9', owner: OTHER });
|
||||||
await upload(uploadBody({ name: 'GEHEIMER Name', displayName: 'GEHEIMER Anzeigename' }));
|
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 upload(uploadBody(), 'xxx.yyy.zzz');
|
||||||
await heart('logo-9', true);
|
await heart('logo-9', true);
|
||||||
await report('logo-9', '203.0.7.9');
|
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');
|
await remove('logo1');
|
||||||
|
|
||||||
assert.ok(logs.length > 0);
|
assert.ok(logs.length > 0);
|
||||||
|
assert.ok(logs.some((line) => line.endsWith('logo logo2 removed after upload check (logoFormat)')));
|
||||||
const all = logs.join('\n');
|
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']) {
|
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)}…`);
|
assert.ok(!all.includes(secret), `Log enthält ${secret.slice(0, 12)}…`);
|
||||||
|
|||||||
Reference in new issue
Block a user