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>
1247 lines
64 KiB
JavaScript
1247 lines
64 KiB
JavaScript
'use strict';
|
||
|
||
// Tests für deploy/api/logos.js (Community-Logos): echte HTTP-Anfragen auf Port 0 gegen createApp, dahinter ein
|
||
// Fake-Appwrite im Speicher (fake-appwrite.js) und das echte appwrite.js. Aufruf: node --test "deploy/api/*.test.js"
|
||
|
||
const { test, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const crypto = require('node:crypto');
|
||
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 {
|
||
MSG,
|
||
visibleRowPermissions,
|
||
hiddenRowPermissions,
|
||
likeRowPermissions,
|
||
visibleFilePermissions,
|
||
hiddenFilePermissions,
|
||
reporterHash,
|
||
} = require('./common.js');
|
||
|
||
const SALT = 'test-salt-0123456789abcdef0123456789abcdef';
|
||
const ME = 'user-me';
|
||
const OTHER = 'user-other';
|
||
const ADMIN = 'user-admin';
|
||
const JWT_ME = 'eyJhbGciOiJI.payload-me.signature-me';
|
||
const JWT_OTHER = 'eyJhbGciOiJI.payload-other.signature-other';
|
||
const JWT_ADMIN = 'eyJhbGciOiJI.payload-admin.signature-admin';
|
||
const MIB = 1024 * 1024;
|
||
const LOGO_BUCKET = 'logo-images';
|
||
/** Rechte einer frisch hochgeladenen Logo-Datei von ME. */
|
||
const UPLOAD_PERMISSIONS = ['read("any")', 'delete("user:user-me")'];
|
||
const HIDDEN_PERMISSIONS = ['read("user:user-me")', 'read("label:admin")'];
|
||
|
||
// ---- Aufbau ---------------------------------------------------------------------------------
|
||
|
||
const cleanups = [];
|
||
afterEach(async () => {
|
||
for (const fn of cleanups.splice(0).reverse()) await fn();
|
||
});
|
||
|
||
async function setup() {
|
||
const fake = await startFakeAppwrite();
|
||
fake.addUser(JWT_ME, { id: ME });
|
||
fake.addUser(JWT_OTHER, { id: OTHER });
|
||
fake.addUser(JWT_ADMIN, { id: ADMIN, labels: ['admin'] });
|
||
|
||
const logs = [];
|
||
const clock = { value: 1_700_000_000_000 };
|
||
const appwrite = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key });
|
||
const app = createApp({ appwrite, 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));
|
||
const { port } = server.address();
|
||
|
||
cleanups.push(async () => {
|
||
server.closeAllConnections();
|
||
await new Promise((resolve) => server.close(resolve));
|
||
await fake.close();
|
||
});
|
||
|
||
const call = (method, path, options) => request(port, method, path, options);
|
||
const post = (path, body, jwt, headers = {}) =>
|
||
call('POST', path, { body, headers: { ...(jwt ? { 'X-Aircard-Jwt': jwt } : {}), ...headers } });
|
||
const upload = (body, jwt = JWT_ME) => post('/api/logos/hochladen', body, jwt);
|
||
const heart = (id, herz, jwt = JWT_ME) => post('/api/logos/herz', { id, herz }, jwt);
|
||
const report = (id, ip, reason = 'spam') => post('/api/logos/melden', { id, reason }, null, ip ? { 'X-Real-Ip': ip } : {});
|
||
const remove = (id, jwt = JWT_ME) => post('/api/logos/loeschen', { id }, jwt);
|
||
const setVisible = (id, sichtbar, jwt = JWT_ADMIN) => post('/api/admin/logo-sichtbarkeit', { id, sichtbar }, jwt);
|
||
|
||
return { fake, app, logs, clock, port, call, post, upload, heart, report, remove, setVisible };
|
||
}
|
||
|
||
function request(port, method, path, { headers = {}, body, raw, contentType = 'application/json' } = {}) {
|
||
return new Promise((resolve, reject) => {
|
||
const payload = raw !== undefined ? raw : body === undefined ? undefined : JSON.stringify(body);
|
||
const requestHeaders = { ...headers };
|
||
if (payload !== undefined && contentType) requestHeaders['Content-Type'] = contentType;
|
||
if (payload !== undefined) requestHeaders['Content-Length'] = Buffer.byteLength(payload);
|
||
const req = http.request({ host: '127.0.0.1', port, method, path, headers: requestHeaders }, (res) => {
|
||
const chunks = [];
|
||
res.on('data', (chunk) => chunks.push(chunk));
|
||
res.on('end', () => {
|
||
const text = Buffer.concat(chunks).toString('utf8');
|
||
let json = null;
|
||
try {
|
||
json = JSON.parse(text);
|
||
} catch {
|
||
// kein JSON
|
||
}
|
||
resolve({ status: res.statusCode, headers: res.headers, text, json });
|
||
});
|
||
});
|
||
req.on('error', reject);
|
||
if (payload !== undefined) req.write(payload);
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
/** Erwartet Status und genau {error: <Text des MSG-Schlüssels>, code: <Schlüssel>}. */
|
||
function expectError(res, status, code, label = code) {
|
||
assert.equal(res.status, status, `${label}: Status`);
|
||
assert.deepEqual(res.json, { error: MSG[code], code }, label);
|
||
}
|
||
|
||
function uploadBody(overrides = {}) {
|
||
return { name: 'Mein Logo', displayName: 'Kenso', mono: false, fileId: 'logo1', ...overrides };
|
||
}
|
||
|
||
/** Frisch hochgeladene Logo-Datei: PNG im Bucket logo-images mit read("any") und delete(owner). */
|
||
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 } = {}) {
|
||
fake.addFile({
|
||
id,
|
||
bucket: LOGO_BUCKET,
|
||
owner,
|
||
permissions: hidden ? hiddenFilePermissions(owner) : visibleFilePermissions(),
|
||
content: pngHeader(200, 100),
|
||
});
|
||
fake.addRow(
|
||
'logos',
|
||
id,
|
||
{ name, displayName: '', fileId: id, ownerId: owner, mono, likeCount, hidden, reportCount },
|
||
hidden ? hiddenRowPermissions(owner) : visibleRowPermissions(),
|
||
);
|
||
}
|
||
|
||
/** `count` Logo-Zeilen eines Kontos ohne Dateien (für das Limit); jede zweite ist ausgeblendet. */
|
||
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());
|
||
}
|
||
}
|
||
|
||
function seedLikes(fake, logoId, userIds) {
|
||
for (const userId of userIds) fake.addRow('logo_likes', logoLikeId(logoId, userId), { logoId, userId }, likeRowPermissions(userId));
|
||
}
|
||
|
||
function seedReports(fake, logoId, hashes) {
|
||
for (const hash of hashes) fake.addRow('logo_reports', logoReportId(logoId, hash), { logoId, reason: 'spam', reporterHash: hash }, []);
|
||
}
|
||
|
||
const sha32 = (text) => crypto.createHash('sha256').update(text).digest('hex').slice(0, 32);
|
||
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|', () => {
|
||
assert.equal(logoLikeId('l1', 'u1'), sha32('l1|u1'));
|
||
assert.equal(logoReportId('l1', 'h1'), sha32('logo|l1|h1'));
|
||
assert.match(logoLikeId('l1', 'u1'), /^[0-9a-f]{32}$/);
|
||
assert.match(logoReportId('l1', 'h1'), /^[0-9a-f]{32}$/);
|
||
assert.notEqual(logoLikeId('l1', 'u1'), logoLikeId('l1', 'u2'));
|
||
assert.notEqual(logoLikeId('l1', 'u1'), logoLikeId('l2', 'u1'));
|
||
assert.notEqual(logoReportId('l1', 'h1'), logoReportId('l1', 'h2'));
|
||
});
|
||
|
||
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));
|
||
});
|
||
|
||
test('validateLogoUpload: normalisiert Name und Anzeigename, mono fehlt = false', () => {
|
||
assert.deepEqual(validateLogoUpload({ name: ' Mein Logo ', displayName: ' Kenso ', mono: true, fileId: 'f1' }), {
|
||
ok: true,
|
||
data: { name: 'Mein Logo', displayName: 'Kenso', mono: true, fileId: 'f1' },
|
||
});
|
||
assert.deepEqual(validateLogoUpload({ name: 'A', fileId: 'f1' }), { ok: true, data: { name: 'A', displayName: '', mono: false, fileId: 'f1' } });
|
||
assert.equal(validateLogoUpload({ name: 'A', displayName: null, mono: false, fileId: 'f1' }).data.displayName, '');
|
||
assert.equal(validateLogoUpload({ name: 'A', displayName: ' ', fileId: 'f1' }).data.displayName, '');
|
||
// unbekannte Felder werden nicht übernommen
|
||
assert.deepEqual(Object.keys(validateLogoUpload({ name: 'A', fileId: 'f1', ownerId: 'x', hidden: true, likeCount: 99 }).data).sort(), ['displayName', 'fileId', 'mono', 'name']);
|
||
});
|
||
|
||
test('validateLogoUpload: Längen an den Grenzen (Name 40, Anzeigename 30) und Fehlertexte', () => {
|
||
assert.equal(validateLogoUpload({ name: 'x'.repeat(40), displayName: 'y'.repeat(30), fileId: 'f1' }).ok, true);
|
||
assert.deepEqual(validateLogoUpload({ name: 'x'.repeat(41), fileId: 'f1' }), { ok: false, message: 'Der Name ist zu lang (höchstens 40 Zeichen).' });
|
||
assert.deepEqual(validateLogoUpload({ name: 'x', displayName: 'y'.repeat(31), fileId: 'f1' }), {
|
||
ok: false,
|
||
message: 'Der Anzeigename ist zu lang (höchstens 30 Zeichen).',
|
||
});
|
||
assert.deepEqual(validateLogoUpload({ name: '', fileId: 'f1' }), { ok: false, message: MSG.logoNameMissing });
|
||
assert.deepEqual(validateLogoUpload({ fileId: 'f1' }), { ok: false, message: MSG.logoNameMissing });
|
||
assert.deepEqual(validateLogoUpload({ name: 'x', mono: 'ja', fileId: 'f1' }), { ok: false, message: MSG.badMono });
|
||
assert.deepEqual(validateLogoUpload({ name: 'x', fileId: '../x' }), { ok: false, message: 'Ungültige Datei-ID.' });
|
||
for (const body of [null, [], 'text', 7, undefined]) {
|
||
assert.deepEqual(validateLogoUpload(body), { ok: false, message: MSG.badRequest }, JSON.stringify(body));
|
||
}
|
||
});
|
||
|
||
test('createLogoRoutes liefert die fünf Routen mit Name, json und run', () => {
|
||
const routes = createLogoRoutes({
|
||
appwrite: {},
|
||
salt: SALT,
|
||
say: () => {},
|
||
withLock: (key, fn) => fn(),
|
||
rateLimit: () => ({ allowed: true }),
|
||
requireUser: async () => ({ id: ME, isAdmin: false }),
|
||
});
|
||
assert.deepEqual(
|
||
routes.map(([key]) => key),
|
||
['POST /api/logos/hochladen', 'POST /api/logos/herz', 'POST /api/logos/melden', 'POST /api/logos/loeschen', 'POST /api/admin/logo-sichtbarkeit'],
|
||
);
|
||
for (const [, route] of routes) {
|
||
assert.equal(route.json, true);
|
||
assert.equal(typeof route.run, 'function');
|
||
assert.match(route.name, /^[a-z/-]+$/);
|
||
}
|
||
});
|
||
|
||
// ---- Hochladen --------------------------------------------------------------------------------------
|
||
|
||
test('Logo hochladen: legt eine sichtbare Zeile an (Zeilen-ID = 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 }));
|
||
assert.equal(res.status, 201);
|
||
assert.deepEqual(res.json, { id: 'logo1' });
|
||
|
||
const row = fake.row('logos', 'logo1');
|
||
assert.deepEqual(row.$permissions, ['read("any")']);
|
||
assert.equal(row.name, 'Mein Logo');
|
||
assert.equal(row.displayName, 'Kenso');
|
||
assert.equal(row.fileId, 'logo1');
|
||
assert.equal(row.ownerId, ME);
|
||
assert.equal(row.mono, true);
|
||
assert.equal(row.likeCount, 0);
|
||
assert.equal(row.hidden, false);
|
||
assert.equal(row.reportCount, 0);
|
||
assert.deepEqual(fake.file('logo1').$permissions, ['read("any")'], 'kein delete(owner) mehr: das Bild lässt sich nicht tauschen');
|
||
|
||
// der Server-Key hängt an jedem Datenaufruf, das JWT nur am Konto-Aufruf
|
||
assert.equal(fake.calls.find((c) => c.path === '/v1/account').headers['x-appwrite-jwt'], JWT_ME);
|
||
for (const c of nonAccountCalls(fake)) {
|
||
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);
|
||
});
|
||
|
||
test('Logo hochladen: Anzeigename ist optional, mono fehlt = false', async () => {
|
||
const { upload, fake } = await setup();
|
||
logoFile(fake, 'logo1');
|
||
const res = await upload({ name: 'Nur Name', fileId: 'logo1' });
|
||
assert.equal(res.status, 201);
|
||
const row = fake.row('logos', 'logo1');
|
||
assert.equal(row.displayName, '');
|
||
assert.equal(row.mono, false);
|
||
});
|
||
|
||
test('Logo hochladen ohne oder mit ungültigem JWT: 401, nichts angelegt, die Datei bleibt unberührt', async () => {
|
||
const { upload, fake } = await setup();
|
||
logoFile(fake, 'logo1');
|
||
expectError(await upload(uploadBody(), null), 401, 'unauthorized');
|
||
expectError(await upload(uploadBody(), 'xxx.yyy.zzz'), 401, 'unauthorized');
|
||
expectError(await upload(uploadBody(), 'zwei teile'), 401, 'unauthorized');
|
||
expectError(await upload({}, ''), 401, 'unauthorized');
|
||
assert.equal(fake.rows('logos').length, 0);
|
||
assert.deepEqual(fake.file('logo1').$permissions, UPLOAD_PERMISSIONS);
|
||
assert.equal(fake.calls.filter((c) => c.path.startsWith('/v1/storage/')).length, 0);
|
||
assert.equal(fake.calls.filter((c) => c.path === '/v1/account').length, 1, 'fehlendes oder kaputtes JWT erreicht Appwrite gar nicht erst');
|
||
});
|
||
|
||
test('Logo hochladen mit fremder, falscher oder gesperrter Datei: 403, keine Zeile, Datei unverändert, Inhalt wird nicht gelesen', async () => {
|
||
const { upload, fake } = await setup();
|
||
logoFile(fake, 'fremd', OTHER);
|
||
logoFile(fake, 'fremd-veroeffentlicht', OTHER, { permissions: visibleFilePermissions() });
|
||
logoFile(fake, 'fremd-ausgeblendet', OTHER, { permissions: hiddenFilePermissions(OTHER) });
|
||
logoFile(fake, 'nur-lesen', ME, { permissions: ['read("any")', 'read("user:user-me")'] });
|
||
logoFile(fake, 'ohne-lesen', ME, { permissions: ['delete("user:user-me")'] });
|
||
fake.addFile({ id: 'aus-galerie', owner: ME, content: pngHeader(100, 100) }); // Bucket gallery-images
|
||
fake.addFile({ id: 'aus-karten', bucket: 'card-images', owner: ME, content: pngHeader(100, 100) });
|
||
|
||
const cases = [
|
||
['fremd', 'foreignFile'],
|
||
['fremd-veroeffentlicht', 'foreignFile'],
|
||
['fremd-ausgeblendet', 'foreignFile'],
|
||
['nur-lesen', 'foreignFile'],
|
||
['aus-galerie', 'foreignFile'],
|
||
['aus-karten', 'foreignFile'],
|
||
['ohne-lesen', 'filesTaken'],
|
||
];
|
||
const before = Object.fromEntries(cases.map(([id]) => [id, [...fake.file(id).$permissions]]));
|
||
for (const [id, code] of cases) expectError(await upload(uploadBody({ fileId: id })), 403, code, id);
|
||
|
||
assert.equal(fake.rows('logos').length, 0);
|
||
for (const [id] of cases) assert.deepEqual(fake.file(id).$permissions, before[id], `${id} unverändert`);
|
||
assert.equal(fake.calls.filter((c) => c.method === 'PUT').length, 0);
|
||
assert.equal(fake.calls.filter((c) => c.path.endsWith('/view')).length, 0, 'fremde oder gesperrte Dateien werden nicht gelesen (kein Maß-Orakel)');
|
||
});
|
||
|
||
test('Logo hochladen mit fehlender Datei: 400 "Bild nicht gefunden."', async () => {
|
||
const { upload, fake } = await setup();
|
||
expectError(await upload(uploadBody({ fileId: 'gibts-nicht' })), 400, 'fileMissing');
|
||
assert.equal(fake.rows('logos').length, 0);
|
||
});
|
||
|
||
test('Logo hochladen: nur PNG bis 1 MiB, sonst 400 logoFileType bzw. logoFileSize', async () => {
|
||
const { upload, fake } = await setup();
|
||
const attempt = (opts) => {
|
||
logoFile(fake, 'logo1', ME, opts);
|
||
return upload(uploadBody());
|
||
};
|
||
|
||
for (const mimeType of ['image/webp', 'image/jpeg', 'image/svg+xml', 'application/pdf']) {
|
||
expectError(await attempt({ mimeType }), 400, 'logoFileType', mimeType);
|
||
}
|
||
expectError(await attempt({ size: MIB + 1 }), 400, 'logoFileSize', '1 MiB + 1');
|
||
expectError(await attempt({ size: 5 * MIB }), 400, 'logoFileSize', '5 MiB');
|
||
expectError(await attempt({ size: 'groß' }), 400, 'logoFileSize', 'keine Zahl');
|
||
assert.equal(fake.rows('logos').length, 0);
|
||
assert.deepEqual(fake.file('logo1').$permissions, UPLOAD_PERMISSIONS, 'Datei bleibt unangetastet');
|
||
|
||
assert.equal((await attempt({ size: MIB })).status, 201, 'genau 1 MiB geht');
|
||
});
|
||
|
||
test('Logo hochladen: Maße von 8 × 8 bis 720 × 250 gehen, alles andere ist 400 logoFormat (Datei bleibt unverändert)', async () => {
|
||
const { upload, fake } = await setup();
|
||
for (const [width, height] of [[721, 100], [7, 7], [720, 251], [8, 7], [7, 8], [0, 0], [1, 1], [60000, 60000], [721, 251]]) {
|
||
logoFile(fake, 'logo1', ME, { width, height });
|
||
expectError(await upload(uploadBody()), 400, 'logoFormat', `${width} × ${height}`);
|
||
assert.deepEqual(fake.file('logo1').$permissions, UPLOAD_PERMISSIONS);
|
||
}
|
||
assert.equal(fake.rows('logos').length, 0);
|
||
|
||
let n = 0;
|
||
for (const [width, height] of [[8, 8], [720, 250], [720, 8], [8, 250], [200, 100]]) {
|
||
n += 1;
|
||
logoFile(fake, `ok${n}`, ME, { width, height });
|
||
assert.equal((await upload(uploadBody({ fileId: `ok${n}` }))).status, 201, `${width} × ${height}`);
|
||
}
|
||
});
|
||
|
||
test('Logo hochladen: Inhalt ohne PNG-Kopf ist 400 logoFormat (kaputt, abgeschnitten, WebP-Inhalt mit PNG-Typ)', async () => {
|
||
const { upload, fake } = await setup();
|
||
for (const [label, content] of [
|
||
['leer', Buffer.alloc(0)],
|
||
['Nullen', Buffer.alloc(100)],
|
||
['Text', Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"></svg>')],
|
||
['PNG abgeschnitten', pngHeader(200, 100).subarray(0, 20)],
|
||
['WebP-Inhalt, Typ PNG', webpHeader('VP8L', 200, 100)],
|
||
]) {
|
||
logoFile(fake, 'logo1', ME, { content });
|
||
expectError(await upload(uploadBody()), 400, 'logoFormat', label);
|
||
}
|
||
assert.equal(fake.rows('logos').length, 0);
|
||
assert.deepEqual(fake.file('logo1').$permissions, UPLOAD_PERMISSIONS);
|
||
});
|
||
|
||
test('Logo hochladen: Datei verschwindet vor dem Lesen der Maße ergibt 400 "Bild nicht gefunden.", Appwrite-Fehler dabei 502', async () => {
|
||
const { upload, fake } = await setup();
|
||
logoFile(fake, 'logo1');
|
||
fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/files/logo1/view'), { status: 404, type: 'storage_file_not_found' });
|
||
expectError(await upload(uploadBody()), 400, 'fileMissing');
|
||
fake.failNext((c) => c.path.endsWith('/files/logo1/view'), { status: 500 });
|
||
expectError(await upload(uploadBody()), 502, 'unavailable');
|
||
assert.equal(fake.rows('logos').length, 0);
|
||
assert.equal((await upload(uploadBody())).status, 201);
|
||
});
|
||
|
||
test('Logo hochladen: dieselbe Datei zweimal ergibt 409 logoTaken, Datei und Zeile bleiben wie sie sind', async () => {
|
||
const { upload, fake } = await setup();
|
||
logoFile(fake, 'logo1');
|
||
assert.equal((await upload(uploadBody())).status, 201);
|
||
|
||
// die Datei trägt jetzt nur noch read("any"): trotzdem 409 und nicht 403
|
||
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');
|
||
|
||
// dieselbe 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 () => {
|
||
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, 'anker');
|
||
expectError(await upload(uploadBody({ fileId: 'anker' })), 409, 'logoTaken');
|
||
assert.deepEqual(fake.file('anker').$permissions, UPLOAD_PERMISSIONS);
|
||
assert.equal(fake.rows('logos').length, 1);
|
||
assert.equal(filePuts(fake, 'anker').length, 0, 'ein 409 fasst die Dateirechte nie an');
|
||
});
|
||
|
||
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);
|
||
logoFile(fake, 'logo1');
|
||
expectError(await upload(uploadBody()), 429, 'tooManyLogos');
|
||
assert.equal(fake.rows('logos').length, 30);
|
||
assert.deepEqual(fake.file('logo1').$permissions, UPLOAD_PERMISSIONS, 'Datei unangetastet');
|
||
|
||
// Logos anderer Konten zählen nicht, und jedes Konto hat sein eigenes Limit
|
||
seedLogoRows(fake, OTHER, 40);
|
||
expectError(await upload(uploadBody()), 429, 'tooManyLogos');
|
||
logoFile(fake, 'logo-other', OTHER);
|
||
expectError(await upload(uploadBody({ fileId: 'logo-other' }), JWT_OTHER), 429, 'tooManyLogos');
|
||
});
|
||
|
||
test('Logo hochladen: mit 29 Logos geht das 30., nach dem Löschen eines Logos wieder eins', async () => {
|
||
const { upload, remove, fake } = await setup();
|
||
seedLogoRows(fake, ME, 29);
|
||
logoFile(fake, 'logo1');
|
||
assert.equal((await upload(uploadBody())).status, 201);
|
||
logoFile(fake, 'logo2');
|
||
expectError(await upload(uploadBody({ fileId: 'logo2' })), 429, 'tooManyLogos');
|
||
assert.equal((await remove('user-me-l0')).status, 200);
|
||
assert.equal((await upload(uploadBody({ fileId: 'logo2' }))).status, 201);
|
||
});
|
||
|
||
test('Logo hochladen: das Limit liest nur die Zeilen des Kontos (ein Aufruf mit ownerId und limit 30)', async () => {
|
||
const { upload, fake } = await setup();
|
||
seedLogoRows(fake, ME, 3);
|
||
seedLogoRows(fake, OTHER, 40);
|
||
logoFile(fake, 'logo1');
|
||
assert.equal((await upload(uploadBody())).status, 201);
|
||
const lists = fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith('/tables/logos/rows'));
|
||
assert.equal(lists.length, 1);
|
||
const search = decodeURIComponent(lists[0].search);
|
||
assert.ok(search.includes('"attribute":"ownerId","values":["user-me"]'));
|
||
assert.ok(search.includes('"method":"limit","values":[30]'));
|
||
});
|
||
|
||
test('Logo hochladen: scheitert das Festlegen der Dateirechte, wird die Zeile zurückgenommen und die Datei bekommt delete(owner) zurück', async () => {
|
||
const { upload, fake } = await setup();
|
||
logoFile(fake, 'logo1');
|
||
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/logo1'), { status: 500 });
|
||
expectError(await upload(uploadBody()), 502, 'unavailable');
|
||
assert.equal(fake.rows('logos').length, 0, 'keine halbfertige Zeile, die eine Wiederholung mit 409 blockiert');
|
||
assert.deepEqual(fake.file('logo1').$permissions, UPLOAD_PERMISSIONS);
|
||
const puts = filePuts(fake, 'logo1');
|
||
assert.equal(puts.length, 2, 'Festlegen (scheitert) und Rückgabe von delete(owner)');
|
||
assert.deepEqual(puts[1].body, { permissions: UPLOAD_PERMISSIONS });
|
||
|
||
// Datei verschwindet zwischen Prüfung und Festlegen: 400 und ebenfalls zurückgenommen
|
||
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/logo1'), { status: 404, type: 'storage_file_not_found' });
|
||
expectError(await upload(uploadBody()), 400, 'fileMissing');
|
||
assert.equal(fake.rows('logos').length, 0);
|
||
|
||
// danach geht ein neuer Versuch
|
||
assert.equal((await upload(uploadBody())).status, 201);
|
||
assert.deepEqual(fake.file('logo1').$permissions, ['read("any")']);
|
||
});
|
||
|
||
test('Logo hochladen: scheitert auch das Zurücknehmen, bleibt die Zeile stehen, die Datei wird nicht angefasst und `rollback failed for logo <id>` steht im Log', async () => {
|
||
const { upload, fake, logs } = await setup();
|
||
logoFile(fake, 'logo1');
|
||
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/logo1'), { status: 500 });
|
||
fake.failNext((c) => c.method === 'DELETE' && c.path.includes('/tables/logos/rows/'), { status: 500 });
|
||
expectError(await upload(uploadBody()), 502, 'unavailable');
|
||
assert.equal(fake.rows('logos').length, 1, 'die Zeile blieb stehen (Zurücknehmen scheiterte)');
|
||
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.ok(!logs.join('\n').includes(JWT_ME));
|
||
});
|
||
|
||
test('Logo hochladen: gelingt das Zurücknehmen, steht keine Rollback-Fehlerzeile im Log', async () => {
|
||
const { upload, fake, logs } = await setup();
|
||
logoFile(fake, 'logo1');
|
||
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/logo1'), { status: 500 });
|
||
assert.equal((await upload(uploadBody())).status, 502);
|
||
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');
|
||
|
||
for (const [label, overrides, code] of [
|
||
['Name fehlt', { name: undefined }, 'logoNameMissing'],
|
||
['Name leer', { name: '' }, 'logoNameMissing'],
|
||
['Name nur Leerzeichen', { name: ' ' }, 'logoNameMissing'],
|
||
['Name null', { name: null }, 'logoNameMissing'],
|
||
['Name nur BOM', { name: '' }, 'logoNameMissing'],
|
||
['mono "ja"', { mono: 'ja' }, 'badMono'],
|
||
['mono 1', { mono: 1 }, 'badMono'],
|
||
['mono "true"', { mono: 'true' }, 'badMono'],
|
||
['mono null', { mono: null }, 'badMono'],
|
||
['mono Liste', { mono: [true] }, 'badMono'],
|
||
]) {
|
||
expectError(await upload(uploadBody(overrides)), 400, code, label);
|
||
}
|
||
|
||
// Prüftexte mit Feldnamen oder Zahlen haben keinen festen Schlüssel: code badRequest
|
||
for (const [label, overrides, text] of [
|
||
['Name zu lang', { name: 'x'.repeat(41) }, 'Der Name ist zu lang (höchstens 40 Zeichen).'],
|
||
['Name keine Zeichenkette', { name: 42 }, 'Der Name ist ungültig.'],
|
||
['Name mit Zeilenumbruch', { name: 'a\nb' }, 'Der Name enthält ungültige Zeichen.'],
|
||
['Name mit Nullbreiten-Leerzeichen', { name: 'ab' }, 'Der Name enthält ungültige Zeichen.'],
|
||
['Name mit Rechts-nach-links-Überschreibung', { name: 'logognp' }, 'Der Name enthält ungültige Zeichen.'],
|
||
['Anzeigename zu lang', { displayName: 'x'.repeat(31) }, 'Der Anzeigename ist zu lang (höchstens 30 Zeichen).'],
|
||
['Anzeigename keine Zeichenkette', { displayName: 5 }, 'Der Anzeigename ist ungültig.'],
|
||
['Anzeigename mit Bidi-Zeichen', { displayName: 'Kenso' }, 'Der Anzeigename enthält ungültige Zeichen.'],
|
||
['Datei-ID mit ../', { fileId: '../x' }, 'Ungültige Datei-ID.'],
|
||
['Datei-ID ..', { fileId: '..' }, 'Ungültige Datei-ID.'],
|
||
['Datei-ID Zahl', { fileId: 123 }, 'Ungültige Datei-ID.'],
|
||
['Datei-ID leer', { fileId: '' }, 'Ungültige Datei-ID.'],
|
||
['Datei-ID fehlt', { fileId: undefined }, 'Ungültige Datei-ID.'],
|
||
['Datei-ID zu lang', { fileId: 'a'.repeat(37) }, 'Ungültige Datei-ID.'],
|
||
['Datei-ID mit Schrägstrich', { fileId: 'a/b' }, 'Ungültige Datei-ID.'],
|
||
]) {
|
||
const res = await upload(uploadBody(overrides));
|
||
assert.equal(res.status, 400, label);
|
||
assert.deepEqual(res.json, { error: text, code: 'badRequest' }, label);
|
||
}
|
||
|
||
assert.equal(fake.rows('logos').length, 0);
|
||
assert.equal(nonAccountCalls(fake).length, 0, 'ungültige Anfragen erreichen Appwrite (außer dem Konto) nie');
|
||
});
|
||
|
||
test('Logo hochladen: kaputtes JSON, Array oder leerer Body ist 400 badRequest', async () => {
|
||
const { call, fake } = await setup();
|
||
for (const raw of ['{kaputt', '[]', '"text"', 'null', '']) {
|
||
const res = await call('POST', '/api/logos/hochladen', { raw, headers: { 'X-Aircard-Jwt': JWT_ME } });
|
||
expectError(res, 400, 'badRequest', JSON.stringify(raw));
|
||
}
|
||
assert.equal(nonAccountCalls(fake).length, 0);
|
||
});
|
||
|
||
test('Logo hochladen: gleichzeitig dieselbe Datei oder zwei Dateien am Limit, genau eine Anfrage gelingt (Sperre je Konto)', async () => {
|
||
const { upload, fake } = await setup();
|
||
|
||
logoFile(fake, 'logo1');
|
||
fake.delayNext((c) => c.method === 'POST' && c.path.endsWith('/tables/logos/rows'), 80);
|
||
const same = await Promise.all([upload(uploadBody()), upload(uploadBody())]);
|
||
assert.deepEqual(same.map((r) => r.status).sort(), [201, 409]);
|
||
assert.equal(fake.rows('logos').length, 1);
|
||
|
||
seedLogoRows(fake, ME, 28); // mit dem Logo oben 29
|
||
logoFile(fake, 'a');
|
||
logoFile(fake, 'b');
|
||
fake.delayNext((c) => c.method === 'POST' && c.path.endsWith('/tables/logos/rows'), 80);
|
||
const limited = await Promise.all([upload(uploadBody({ fileId: 'a' })), upload(uploadBody({ fileId: 'b' }))]);
|
||
assert.deepEqual(limited.map((r) => r.status).sort(), [201, 429]);
|
||
assert.equal(fake.rows('logos').filter((row) => row.ownerId === ME).length, 30);
|
||
});
|
||
|
||
// ---- Herzen -----------------------------------------------------------------------------------------
|
||
|
||
test('Herz: an, nochmal an (bleibt 1), aus, nochmal aus (bleibt 0); Like-Zeile mit ID logoLikeId und Recht read("user:<ich>")', async () => {
|
||
const { heart, fake } = await setup();
|
||
seedLogo(fake);
|
||
|
||
let res = await heart('logo-1', true);
|
||
assert.equal(res.status, 200);
|
||
assert.deepEqual(res.json, { herz: true, likeCount: 1 });
|
||
const like = fake.row('logo_likes', logoLikeId('logo-1', ME));
|
||
assert.ok(like, 'Zeilen-ID = hash32("<logoId>|<userId>")');
|
||
assert.deepEqual(like.$permissions, ['read("user:user-me")']);
|
||
assert.equal(like.logoId, 'logo-1');
|
||
assert.equal(like.userId, ME);
|
||
assert.equal(fake.row('logos', 'logo-1').likeCount, 1);
|
||
|
||
res = await heart('logo-1', true);
|
||
assert.deepEqual(res.json, { herz: true, likeCount: 1 });
|
||
assert.equal(fake.rows('logo_likes').length, 1);
|
||
|
||
res = await heart('logo-1', false);
|
||
assert.equal(res.status, 200);
|
||
assert.deepEqual(res.json, { herz: false, likeCount: 0 });
|
||
assert.equal(fake.row('logos', 'logo-1').likeCount, 0);
|
||
assert.equal(fake.rows('logo_likes').length, 0);
|
||
|
||
res = await heart('logo-1', false);
|
||
assert.deepEqual(res.json, { herz: false, likeCount: 0 });
|
||
assert.equal(fake.row('logos', 'logo-1').likeCount, 0);
|
||
});
|
||
|
||
test('Herz: zwei Nutzer ergeben 2, jeder nimmt nur sein eigenes zurück', async () => {
|
||
const { heart, fake } = await setup();
|
||
seedLogo(fake);
|
||
assert.deepEqual((await heart('logo-1', true, JWT_ME)).json, { herz: true, likeCount: 1 });
|
||
assert.deepEqual((await heart('logo-1', true, JWT_OTHER)).json, { herz: true, likeCount: 2 });
|
||
assert.deepEqual((await heart('logo-1', true, JWT_ADMIN)).json, { herz: true, likeCount: 3 });
|
||
assert.equal(fake.row('logos', 'logo-1').likeCount, 3);
|
||
|
||
assert.deepEqual((await heart('logo-1', false, JWT_ME)).json, { herz: false, likeCount: 2 });
|
||
assert.deepEqual(fake.rows('logo_likes').map((like) => like.userId).sort(), [ADMIN, OTHER]);
|
||
assert.equal(fake.row('logos', 'logo-1').likeCount, 2);
|
||
});
|
||
|
||
test('Herz: likeCount ist immer die Zahl der Zeilen (heilt einen falschen Zählerstand), die Logo-Zeile ändert sich nur bei Abweichung', async () => {
|
||
const { heart, fake } = await setup();
|
||
seedLogo(fake, { likeCount: 7 });
|
||
seedLikes(fake, 'logo-1', ['u1', 'u2']);
|
||
seedLogo(fake, { id: 'logo-2' });
|
||
seedLikes(fake, 'logo-2', ['u1', 'u2', 'u3', 'u4']);
|
||
|
||
const res = await heart('logo-1', true);
|
||
assert.deepEqual(res.json, { herz: true, likeCount: 3 }, 'nur die Herzen dieses Logos zählen');
|
||
assert.equal(fake.row('logos', 'logo-1').likeCount, 3);
|
||
const patch = fake.calls.find((c) => c.method === 'PATCH' && c.path.endsWith('/tables/logos/rows/logo-1'));
|
||
assert.deepEqual(patch.body, { data: { likeCount: 3 } }, 'ohne permissions: die Rechte der Zeile bleiben');
|
||
assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")']);
|
||
|
||
const seen = fake.calls.length;
|
||
assert.deepEqual((await heart('logo-1', true)).json, { herz: true, likeCount: 3 });
|
||
assert.equal(fake.calls.slice(seen).filter((c) => c.method === 'PATCH').length, 0, 'Zähler stimmt schon: kein Schreibzugriff auf die Logo-Zeile');
|
||
|
||
const counts = fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith('/tables/logo_likes/rows'));
|
||
assert.ok(counts.length >= 1);
|
||
assert.ok(decodeURIComponent(counts[0].search).includes('"method":"limit","values":[1]'), 'gezählt wird über total einer Abfrage mit limit(1)');
|
||
});
|
||
|
||
test('Herz: ausgeblendetes oder fehlendes Logo ist 404 logoGone, auch beim Entfernen, und legt nichts an', async () => {
|
||
const { heart, fake } = await setup();
|
||
seedLogo(fake, { id: 'versteckt', owner: OTHER, hidden: true });
|
||
seedLikes(fake, 'versteckt', [ME]);
|
||
|
||
expectError(await heart('versteckt', true), 404, 'logoGone', 'ausgeblendet, an');
|
||
expectError(await heart('versteckt', false), 404, 'logoGone', 'ausgeblendet, aus');
|
||
expectError(await heart('gibts-nicht', true), 404, 'logoGone', 'fehlt, an');
|
||
expectError(await heart('gibts-nicht', false), 404, 'logoGone', 'fehlt, aus');
|
||
assert.equal(fake.rows('logo_likes').length, 1, 'die vorhandene Like-Zeile bleibt, nichts Neues');
|
||
assert.equal(fake.row('logos', 'versteckt').likeCount, 0);
|
||
});
|
||
|
||
test('Herz: Anmeldung und Body-Prüfung (id, herz), ungültige Anfragen erreichen Appwrite nicht', async () => {
|
||
const { heart, post, fake } = await setup();
|
||
seedLogo(fake);
|
||
|
||
expectError(await heart('logo-1', true, null), 401, 'unauthorized', 'ohne JWT');
|
||
expectError(await heart('logo-1', true, 'xxx.yyy.zzz'), 401, 'unauthorized', 'falsches JWT');
|
||
expectError(await post('/api/logos/herz', { id: 'logo-1', herz: 'ja' }), 401, 'unauthorized', 'Anmeldung vor Body-Prüfung');
|
||
for (const herz of ['ja', 'true', 1, 0, null, undefined, [true]]) {
|
||
expectError(await post('/api/logos/herz', { id: 'logo-1', herz }, JWT_ME), 400, 'badHeart', JSON.stringify(herz));
|
||
}
|
||
for (const id of ['../x', '..', 5, null, undefined, 'a/b', 'a'.repeat(37), '']) {
|
||
expectError(await heart(id, true), 400, 'badLogoId', String(id));
|
||
}
|
||
assert.equal(fake.rows('logo_likes').length, 0);
|
||
assert.equal(nonAccountCalls(fake).length, 0);
|
||
});
|
||
|
||
test('Herz: Ausfall beim Zählen endet mit 502, die Wiederholung zählt richtig (das Herz war schon angelegt)', async () => {
|
||
const { heart, fake } = await setup();
|
||
seedLogo(fake);
|
||
fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/tables/logo_likes/rows'), { status: 500 });
|
||
expectError(await heart('logo-1', true), 502, 'unavailable');
|
||
assert.equal(fake.rows('logo_likes').length, 1);
|
||
assert.equal(fake.row('logos', 'logo-1').likeCount, 0);
|
||
|
||
assert.deepEqual((await heart('logo-1', true)).json, { herz: true, likeCount: 1 });
|
||
assert.equal(fake.row('logos', 'logo-1').likeCount, 1);
|
||
});
|
||
|
||
test('Herz: gleichzeitige Herzen mehrerer Nutzer ergeben die richtige Zahl (Sperre je Logo)', async () => {
|
||
const { heart, fake } = await setup();
|
||
seedLogo(fake);
|
||
fake.delayNext((c) => c.method === 'PATCH' && c.path.endsWith('/tables/logos/rows/logo-1'), 100);
|
||
const results = await Promise.all([heart('logo-1', true, JWT_ME), heart('logo-1', true, JWT_OTHER), heart('logo-1', true, JWT_ADMIN)]);
|
||
assert.deepEqual(results.map((r) => r.status), [200, 200, 200]);
|
||
assert.equal(fake.rows('logo_likes').length, 3);
|
||
assert.equal(fake.row('logos', 'logo-1').likeCount, 3, 'ein verspäteter Zählerstand überschreibt keinen neueren');
|
||
});
|
||
|
||
// ---- Melden -----------------------------------------------------------------------------------------
|
||
|
||
test('Logo melden: drei verschiedene Netze blenden das Logo aus (Zeilen- und Dateirechte), danach 404', async () => {
|
||
const { report, fake } = await setup();
|
||
seedLogo(fake);
|
||
|
||
let res = await report('logo-1', '203.0.1.9');
|
||
assert.equal(res.status, 200);
|
||
assert.deepEqual(res.json, { ok: true });
|
||
assert.equal(fake.row('logos', 'logo-1').reportCount, 1);
|
||
assert.equal(fake.row('logos', 'logo-1').hidden, false);
|
||
res = await report('logo-1', '203.0.2.9', 'urheberrecht');
|
||
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")']);
|
||
|
||
res = await report('logo-1', '203.0.3.9', 'sonstiges');
|
||
assert.equal(res.status, 200);
|
||
assert.deepEqual(res.json, { ok: true });
|
||
const row = fake.row('logos', 'logo-1');
|
||
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.equal(fake.rows('logo_reports').length, 3);
|
||
|
||
// ausgeblendetes Logo melden: 404
|
||
expectError(await report('logo-1', '203.0.4.9'), 404, 'logoGone');
|
||
assert.equal(fake.rows('logo_reports').length, 3);
|
||
});
|
||
|
||
test('Logo melden: die Meldungen speichern nur Hash, Grund und Logo (ID = hash32("logo|<id>|<hash>"), keine Rechte)', async () => {
|
||
const { report, fake } = await setup();
|
||
seedLogo(fake);
|
||
await report('logo-1', '203.0.1.9', 'spam');
|
||
await report('logo-1', '203.0.2.9', 'unangemessen');
|
||
assert.equal(fake.row('logos', 'logo-1').hidden, false);
|
||
|
||
const hash = reporterHash(SALT, '203.0.1.9');
|
||
const stored = fake.row('logo_reports', sha32(`logo|logo-1|${hash}`));
|
||
assert.ok(stored);
|
||
assert.equal(stored.logoId, 'logo-1');
|
||
assert.equal(stored.reason, 'spam');
|
||
assert.equal(stored.reporterHash, hash);
|
||
assert.deepEqual(stored.$permissions, []);
|
||
assert.ok(!JSON.stringify(fake.rows('logo_reports')).includes('203.0.'), 'keine IP im Klartext gespeichert');
|
||
assert.equal(fake.rows('reports').length, 0, 'Logo-Meldungen landen nicht bei den Karten-Meldungen');
|
||
});
|
||
|
||
test('Logo melden: dieselbe IP mehrmals zählt einmal ({schonGemeldet:true}), ein IPv4-/24 und ein IPv6-/48 sind je ein Melder', async () => {
|
||
const { report, fake } = await setup();
|
||
seedLogo(fake);
|
||
assert.deepEqual((await report('logo-1', '203.0.9.9')).json, { ok: true });
|
||
for (let i = 0; i < 4; i += 1) {
|
||
const again = await report('logo-1', '203.0.9.9', i % 2 ? 'spam' : 'unangemessen');
|
||
assert.equal(again.status, 200);
|
||
assert.deepEqual(again.json, { schonGemeldet: true });
|
||
}
|
||
assert.deepEqual((await report('logo-1', '203.0.9.200')).json, { schonGemeldet: true }, 'selbes /24');
|
||
assert.equal(fake.row('logos', 'logo-1').reportCount, 1);
|
||
assert.equal(fake.row('logos', 'logo-1').hidden, false);
|
||
|
||
assert.deepEqual((await report('logo-1', '2001:db8:abcd:12::1')).json, { ok: true });
|
||
assert.deepEqual((await report('logo-1', '2001:db8:abcd:13::1')).json, { schonGemeldet: true }, 'selbes /48');
|
||
assert.equal(fake.row('logos', 'logo-1').reportCount, 2);
|
||
assert.equal(fake.rows('logo_reports').length, 2);
|
||
});
|
||
|
||
test('Logo melden: gültige Gründe sind unangemessen, kartendaten, urheberrecht, spam, sonstiges', async () => {
|
||
const { report, fake } = await setup();
|
||
seedLogo(fake);
|
||
seedLogo(fake, { id: 'logo-2' });
|
||
const reasons = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'];
|
||
for (let i = 0; i < reasons.length; i += 1) {
|
||
const res = await report(i < 2 ? 'logo-1' : 'logo-2', `198.51.${100 + i}.1`, reasons[i]);
|
||
assert.equal(res.status, 200, reasons[i]);
|
||
}
|
||
assert.deepEqual(fake.rows('logo_reports').map((r) => r.reason).sort(), [...reasons].sort());
|
||
});
|
||
|
||
test('Logo melden: Ungültige Anfragen (Grund, ID) zählen nicht zum Limit und erreichen Appwrite nicht', async () => {
|
||
const { report, post, fake, app } = await setup();
|
||
seedLogo(fake);
|
||
for (const reason of ['gibt-es-nicht', '', null, 7, undefined, 'SPAM']) {
|
||
const res = await post('/api/logos/melden', { id: 'logo-1', reason }, null, { 'X-Real-Ip': '203.0.1.9' });
|
||
expectError(res, 400, 'badReason', String(reason));
|
||
}
|
||
for (const id of ['../x', '..', 42, null, undefined, 'a/b', 'a'.repeat(37), '']) {
|
||
const res = await post('/api/logos/melden', { id, reason: 'spam' }, null, { 'X-Real-Ip': '203.0.1.9' });
|
||
expectError(res, 400, 'badLogoId', String(id));
|
||
}
|
||
assert.equal(fake.calls.length, 0);
|
||
assert.equal(app.rateLimitEntries(), 0);
|
||
assert.equal((await report('logo-1', '203.0.1.9')).status, 200);
|
||
});
|
||
|
||
test('Logo melden: fehlendes Logo ist 404 logoGone und legt keine Meldung an', async () => {
|
||
const { report, fake } = await setup();
|
||
expectError(await report('gibts-nicht', '203.0.1.9'), 404, 'logoGone');
|
||
assert.equal(fake.rows('logo_reports').length, 0);
|
||
});
|
||
|
||
test('Logo melden: Rate-Limit 10 pro Stunde und Anschluss, danach 429 mit Retry-After, nach einer Stunde wieder erlaubt', async () => {
|
||
const { report, fake, clock } = await setup();
|
||
seedLogo(fake);
|
||
for (let i = 0; i < 10; i += 1) assert.equal((await report('logo-1', '203.0.50.9')).status, 200, `Meldung ${i + 1}`);
|
||
const limited = await report('logo-1', '203.0.50.9');
|
||
expectError(limited, 429, 'tooManyReports');
|
||
assert.equal(limited.headers['retry-after'], '3600');
|
||
assert.equal((await report('logo-1', '203.0.51.9')).status, 200, 'eine andere IP ist nicht betroffen');
|
||
|
||
clock.value += 3600_000;
|
||
assert.equal((await report('logo-1', '203.0.50.9')).status, 200);
|
||
});
|
||
|
||
test('Logo melden: Karten und Logos teilen sich ein Rate-Limit je Anschluss (6 + 5 Meldungen, die 11. ergibt 429)', async () => {
|
||
const { report, post, fake, app } = await setup();
|
||
seedLogo(fake);
|
||
fake.addRow(
|
||
'gallery',
|
||
'card-1',
|
||
{ title: 'Karte', issuer: '', tags: '', displayName: '', imageFileId: 'ci', previewFileId: 'cp', sourceCardId: 'src', ownerId: ME, hidden: false, reportCount: 0, searchText: 'karte' },
|
||
visibleRowPermissions(),
|
||
);
|
||
const ip = { 'X-Real-Ip': '203.0.50.9' };
|
||
const reportCard = () => post('/api/melden', { id: 'card-1', reason: 'spam' }, null, ip);
|
||
|
||
for (let i = 0; i < 6; i += 1) assert.equal((await reportCard()).status, 200, `Karte ${i + 1}`);
|
||
for (let i = 0; i < 4; i += 1) assert.equal((await report('logo-1', '203.0.50.9')).status, 200, `Logo ${i + 1}`);
|
||
expectError(await report('logo-1', '203.0.50.9'), 429, 'tooManyReports', 'die 11. Meldung');
|
||
expectError(await reportCard(), 429, 'tooManyReports', 'auch Karten sind gesperrt');
|
||
assert.equal(app.rateLimitEntries(), 1, 'ein gemeinsamer Zähler');
|
||
|
||
// eine andere Adresse hat ein eigenes Limit
|
||
assert.equal((await report('logo-1', '203.0.51.9')).status, 200);
|
||
});
|
||
|
||
test('Logo melden: gleichzeitige Meldungen von drei Netzen ergeben genau drei und blenden aus (Sperre je Logo)', async () => {
|
||
const { report, fake } = await setup();
|
||
seedLogo(fake);
|
||
const results = await Promise.all([report('logo-1', '203.0.1.9'), report('logo-1', '203.0.2.9'), report('logo-1', '203.0.3.9')]);
|
||
assert.deepEqual(results.map((r) => r.status), [200, 200, 200]);
|
||
const row = fake.row('logos', 'logo-1');
|
||
assert.equal(row.reportCount, 3);
|
||
assert.equal(row.hidden, true);
|
||
assert.equal(fake.rows('logo_reports').length, 3);
|
||
});
|
||
|
||
test('Logo melden: eine doppelte Meldung heilt einen früher steckengebliebenen Zählerstand', async () => {
|
||
const { report, fake } = await setup();
|
||
seedLogo(fake);
|
||
seedReports(fake, 'logo-1', ['203.0.1.9', '203.0.2.9', '203.0.3.9'].map((ip) => reporterHash(SALT, ip)));
|
||
assert.equal(fake.row('logos', 'logo-1').hidden, false);
|
||
const res = await report('logo-1', '203.0.1.9');
|
||
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);
|
||
});
|
||
|
||
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 () => {
|
||
const { report, fake } = await setup();
|
||
seedLogo(fake);
|
||
fake.failNext((c) => c.method === 'POST' && c.path.endsWith('/tables/logo_reports/rows'), { status: 500 });
|
||
expectError(await report('logo-1', '203.0.1.9'), 502, 'unavailable');
|
||
assert.equal(fake.row('logos', 'logo-1').reportCount, 0);
|
||
assert.equal(fake.rows('logo_reports').length, 0);
|
||
|
||
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 });
|
||
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);
|
||
});
|
||
|
||
// ---- Löschen ----------------------------------------------------------------------------------------
|
||
|
||
test('Logo löschen durch den Hochlader: Datei, Herzen, Meldungen und Zeile sind weg, andere Logos bleiben', async () => {
|
||
const { remove, fake, logs } = await setup();
|
||
seedLogo(fake, { id: 'logo-1' });
|
||
seedLogo(fake, { id: 'logo-2' });
|
||
seedLikes(fake, 'logo-1', ['u1', 'u2']);
|
||
seedLikes(fake, 'logo-2', ['u1']);
|
||
seedReports(fake, 'logo-1', ['h1', 'h2']);
|
||
seedReports(fake, 'logo-2', ['h3']);
|
||
|
||
const res = await remove('logo-1');
|
||
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.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.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[order.length - 1].endsWith('logos/rows/logo-1'), order[order.length - 1]);
|
||
});
|
||
|
||
test('Logo löschen: Herzen und Meldungen werden in Runden zu 100 gelöscht, auch über 100 hinaus', async () => {
|
||
const { remove, fake } = await setup();
|
||
seedLogo(fake);
|
||
seedLikes(fake, 'logo-1', Array.from({ length: 230 }, (_, i) => `u${i}`));
|
||
seedReports(fake, 'logo-1', Array.from({ length: 120 }, (_, i) => `h${i}`));
|
||
seedLikes(fake, 'anderes-logo', ['u1']);
|
||
|
||
assert.equal((await remove('logo-1')).status, 200);
|
||
assert.equal(fake.rows('logo_likes').filter((r) => r.logoId === 'logo-1').length, 0);
|
||
assert.equal(fake.rows('logo_reports').length, 0);
|
||
assert.equal(fake.rows('logo_likes').length, 1, 'Herzen anderer Logos bleiben');
|
||
|
||
const lists = (table) => fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith(`/tables/${table}/rows`));
|
||
assert.equal(lists('logo_likes').length, 4, '100 + 100 + 30 + leere Abschlussrunde');
|
||
assert.equal(lists('logo_reports').length, 3, '100 + 20 + leere Abschlussrunde');
|
||
const search = decodeURIComponent(lists('logo_likes')[0].search);
|
||
assert.ok(search.includes('"attribute":"logoId","values":["logo-1"]'));
|
||
assert.ok(search.includes('"method":"limit","values":[100]'));
|
||
});
|
||
|
||
test('Logo löschen durch Admin geht, auch bei ausgeblendetem Logo', async () => {
|
||
const { remove, fake, logs } = await setup();
|
||
seedLogo(fake, { hidden: true, reportCount: 3 });
|
||
seedReports(fake, 'logo-1', ['h1', 'h2', 'h3']);
|
||
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.rows('logo_reports').length, 0);
|
||
assert.equal(logs.filter((line) => line.endsWith('logo logo-1 deleted by admin')).length, 1);
|
||
});
|
||
|
||
test('Logo löschen durch Fremde: 403 forbidden und nichts gelöscht', async () => {
|
||
const { remove, fake } = await setup();
|
||
seedLogo(fake);
|
||
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.equal(fake.rows('logo_likes').length, 1);
|
||
assert.equal(fake.calls.filter((c) => c.method === 'DELETE').length, 0);
|
||
});
|
||
|
||
test('Logo löschen: 401 ohne JWT, 404 logoGone bei fehlendem Logo, 400 badLogoId bei ungültiger ID', async () => {
|
||
const { remove, fake } = await setup();
|
||
seedLogo(fake);
|
||
expectError(await remove('logo-1', null), 401, 'unauthorized');
|
||
expectError(await remove('logo-1', 'xxx.yyy.zzz'), 401, 'unauthorized');
|
||
expectError(await remove('gibts-nicht'), 404, 'logoGone');
|
||
for (const id of ['../x', '..', 5, null, undefined, '']) expectError(await remove(id), 400, 'badLogoId', String(id));
|
||
assert.ok(fake.row('logos', 'logo-1'));
|
||
});
|
||
|
||
test('Logo löschen ist wiederholbar, wenn die Zeile zuletzt scheitert (die Zeile bleibt als Anker)', async () => {
|
||
const { remove, fake } = await setup();
|
||
seedLogo(fake);
|
||
seedLikes(fake, 'logo-1', ['u1']);
|
||
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.rows('logo_likes').length, 0);
|
||
|
||
const retry = await remove('logo-1');
|
||
assert.equal(retry.status, 200, 'die Datei fehlt schon, das ist kein Fehler');
|
||
assert.equal(fake.row('logos', 'logo-1'), null);
|
||
});
|
||
|
||
// ---- Admin: Sichtbarkeit ------------------------------------------------------------------------------
|
||
|
||
test('Logo-Sichtbarkeit: Nicht-Admin 403 adminOnly, ohne JWT 401, nichts ändert sich', async () => {
|
||
const { setVisible, fake } = await setup();
|
||
seedLogo(fake);
|
||
expectError(await setVisible('logo-1', false, JWT_ME), 403, 'adminOnly', 'Hochlader');
|
||
expectError(await setVisible('logo-1', false, JWT_OTHER), 403, 'adminOnly', 'Fremde');
|
||
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")']);
|
||
});
|
||
|
||
test('Admin blendet ein Logo aus: Zeilen- und Dateirechte, hidden=true, Meldungen und Herzen bleiben', async () => {
|
||
const { setVisible, fake, logs } = await setup();
|
||
seedLogo(fake, { reportCount: 1, likeCount: 2 });
|
||
seedReports(fake, 'logo-1', ['h1']);
|
||
seedLikes(fake, 'logo-1', ['u1', 'u2']);
|
||
|
||
const res = await setVisible('logo-1', false);
|
||
assert.equal(res.status, 200);
|
||
assert.deepEqual(res.json, { ok: true });
|
||
const row = fake.row('logos', 'logo-1');
|
||
assert.equal(row.hidden, true);
|
||
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.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);
|
||
});
|
||
|
||
test('Admin blendet ein Logo ein: sichtbare Rechte, hidden=false, reportCount=0, Meldungen gelöscht, Herzen bleiben, wieder meldbar', async () => {
|
||
const { setVisible, report, fake, logs } = await setup();
|
||
seedLogo(fake, { hidden: true, reportCount: 3, likeCount: 2 });
|
||
seedLogo(fake, { id: 'logo-2' });
|
||
seedReports(fake, 'logo-1', ['h1', 'h2', 'h3']);
|
||
seedReports(fake, 'logo-2', ['h4']);
|
||
seedLikes(fake, 'logo-1', ['u1', 'u2']);
|
||
|
||
const res = await setVisible('logo-1', true);
|
||
assert.equal(res.status, 200);
|
||
const row = fake.row('logos', 'logo-1');
|
||
assert.equal(row.hidden, false);
|
||
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.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);
|
||
|
||
assert.equal((await report('logo-1', '203.0.1.9')).status, 200, 'das Logo ist wieder meldbar');
|
||
});
|
||
|
||
test('Logo-Sichtbarkeit: Body-Prüfung und fehlendes Logo', async () => {
|
||
const { setVisible, post, fake } = await setup();
|
||
seedLogo(fake);
|
||
for (const sichtbar of ['ja', 'true', 1, 0, null, undefined]) {
|
||
expectError(await post('/api/admin/logo-sichtbarkeit', { id: 'logo-1', sichtbar }, JWT_ADMIN), 400, 'badVisibility', String(sichtbar));
|
||
}
|
||
for (const id of ['../x', '..', 7, null, undefined, '']) expectError(await setVisible(id, true), 400, 'badLogoId', String(id));
|
||
expectError(await setVisible('gibts-nicht', true), 404, 'logoGone');
|
||
expectError(await post('/api/admin/logo-sichtbarkeit', {}, JWT_ADMIN), 400, 'badLogoId');
|
||
});
|
||
|
||
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 });
|
||
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")']);
|
||
assert.equal((await setVisible('logo-1', false)).status, 200, 'der nächste Versuch klappt');
|
||
assert.equal(fake.row('logos', 'logo-1').hidden, true);
|
||
});
|
||
|
||
// ---- Routen, Logs, Fehler ---------------------------------------------------------------------------
|
||
|
||
test('Logo-Routen: nur POST mit application/json (sonst 404 bzw. 415), Appwrite wird dabei nicht berührt', async () => {
|
||
const { call, fake } = await setup();
|
||
for (const path of ['/api/logos/hochladen', '/api/logos/herz', '/api/logos/melden', '/api/logos/loeschen', '/api/admin/logo-sichtbarkeit']) {
|
||
for (const method of ['GET', 'PUT', 'DELETE', 'OPTIONS']) {
|
||
expectError(await call(method, path, { body: {} }), 404, 'notFound', `${method} ${path}`);
|
||
}
|
||
expectError(await call('POST', `${path}/`, { body: {} }), 404, 'notFound', `${path}/`);
|
||
for (const contentType of ['text/plain', 'application/x-www-form-urlencoded', null]) {
|
||
expectError(await call('POST', path, { raw: '{}', contentType }), 415, 'jsonOnly', `${path} ${contentType}`);
|
||
}
|
||
}
|
||
assert.equal(fake.calls.length, 0);
|
||
});
|
||
|
||
test('Logo-Routen: Body über 8 KB ist 413 tooLarge', async () => {
|
||
const { call } = await setup();
|
||
const raw = `{"id":"x","reason":"spam"}${' '.repeat(9000)}`;
|
||
expectError(await call('POST', '/api/logos/melden', { raw }), 413, 'tooLarge');
|
||
});
|
||
|
||
test('Logo-Endpunkte: Appwrite-Ausfall ist 502 unavailable, eine fehlende Tabelle 500 internal (nie „Logo nicht verfügbar“)', async () => {
|
||
const { heart, report, upload, fake } = await setup();
|
||
seedLogo(fake);
|
||
const isLogoRead = (c) => c.method === 'GET' && c.path.includes('/tables/logos/rows/');
|
||
fake.failNext(isLogoRead, { status: 503 });
|
||
expectError(await heart('logo-1', true), 502, 'unavailable');
|
||
fake.failNext(isLogoRead, { status: 429, type: 'general_rate_limit_exceeded' });
|
||
expectError(await report('logo-1', '203.0.1.9'), 502, 'unavailable');
|
||
fake.failNext(isLogoRead, { status: 404, type: 'table_not_found' });
|
||
expectError(await report('logo-1', '203.0.1.9'), 500, 'internal');
|
||
fake.failNext(isLogoRead, { status: 401, type: 'general_unauthorized_scope' });
|
||
expectError(await heart('logo-1', true), 500, 'internal');
|
||
fake.failNext((c) => c.path === '/v1/account', { status: 500 });
|
||
expectError(await upload(uploadBody()), 502, 'unavailable');
|
||
assert.equal(fake.rows('logo_likes').length, 0);
|
||
assert.equal(fake.rows('logo_reports').length, 0);
|
||
});
|
||
|
||
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');
|
||
await report('logo-9', '203.0.8.9');
|
||
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');
|
||
|
||
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)}…`);
|
||
}
|
||
for (const line of logs) assert.ok(line.startsWith('[aircard-api] '), line);
|
||
});
|
||
|
||
test('Log-Flut: abgewiesene Logo-Anfragen (400/401/404/429) und Herzen erzeugen keine Logzeilen', async () => {
|
||
const { upload, heart, report, remove, setVisible, fake, logs } = await setup();
|
||
seedLogo(fake);
|
||
for (let i = 0; i < 12; i += 1) {
|
||
await report('gibts-nicht', '203.0.5.9'); // 404, ab der 11. Meldung 429
|
||
await report('../x', '203.0.5.9'); // 400
|
||
await upload(uploadBody(), null); // 401
|
||
await upload(uploadBody({ name: '' })); // 400
|
||
await heart('logo-1', true); // Herzen werden nicht geloggt
|
||
await heart('logo-1', false);
|
||
await heart('gibts-nicht', true); // 404
|
||
await remove('logo-1', JWT_OTHER); // 403
|
||
await setVisible('logo-1', false, JWT_ME); // 403
|
||
}
|
||
assert.equal((await report('gibts-nicht', '203.0.5.9')).status, 429, 'das Rate-Limit hat wirklich gegriffen');
|
||
assert.deepEqual(logs, []);
|
||
});
|