Veröffentlichen lehnt mit 403 "Diese Bilder sind bereits veröffentlicht oder
gesperrt." ab, wenn eine Datei kein read("any") trägt (ausgeblendet) oder von
einer Galerie-Zeile des Autors schon als Bild oder Vorschau verwendet wird
(auch überkreuz, auch ausgeblendete Karten). Die Nutzungsprüfung liest die
Zeilen des Autors über den vorhandenen ownerId-Index seitenweise mit Cursor
(auf die Datei-ID-Spalten gibt es keinen Index) und läuft zusammen mit dem
Anlegen je Autor nacheinander. 92 Tests.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1251 lines
59 KiB
JavaScript
1251 lines
59 KiB
JavaScript
'use strict';
|
||
|
||
// Tests für deploy/api/app.js: 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, AppwriteError } = require('./appwrite.js');
|
||
const { startFakeAppwrite } = require('./fake-appwrite.js');
|
||
const {
|
||
createApp,
|
||
visibleRowPermissions,
|
||
hiddenRowPermissions,
|
||
visibleFilePermissions,
|
||
hiddenFilePermissions,
|
||
reporterHash,
|
||
clientIp,
|
||
normalizeIp,
|
||
validatePublish,
|
||
buildSearchText,
|
||
galleryRowId,
|
||
reportRowId,
|
||
} = require('./app.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 MIB5 = 5 * 1024 * 1024;
|
||
|
||
// ---- 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 report = (id, ip, reason = 'spam') => post('/api/melden', { id, reason }, null, ip ? { 'X-Real-Ip': ip } : {});
|
||
|
||
return { fake, app, logs, clock, port, call, post, report };
|
||
}
|
||
|
||
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 && !('Transfer-Encoding' in requestHeaders)) {
|
||
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();
|
||
});
|
||
}
|
||
|
||
function publishBody(overrides = {}) {
|
||
return {
|
||
title: 'Mein Motiv',
|
||
issuer: 'Beispielbank',
|
||
tags: ['Blau', ' Neon '],
|
||
displayName: 'Kenso',
|
||
imageFileId: 'img1',
|
||
previewFileId: 'prev1',
|
||
sourceCardId: 'card1',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function seedUploads(fake, owner = ME, { image = 'img1', preview = 'prev1' } = {}) {
|
||
fake.addFile({ id: image, owner });
|
||
fake.addFile({ id: preview, owner, mimeType: 'image/webp' });
|
||
}
|
||
|
||
/** Legt eine veröffentlichte Karte direkt im Fake an (Zeile + zwei Dateien mit passenden Rechten). */
|
||
function seedCard(fake, { id = 'card-1', owner = ME, hidden = false, reportCount = 0 } = {}) {
|
||
const imageFileId = `${id}-img`;
|
||
const previewFileId = `${id}-prev`;
|
||
fake.addFile({ id: imageFileId, owner });
|
||
fake.addFile({ id: previewFileId, owner, mimeType: 'image/webp' });
|
||
if (hidden) {
|
||
fake.file(imageFileId).$permissions = hiddenFilePermissions(owner);
|
||
fake.file(previewFileId).$permissions = hiddenFilePermissions(owner);
|
||
}
|
||
fake.addRow(
|
||
'gallery',
|
||
id,
|
||
{
|
||
title: 'Karte',
|
||
issuer: '',
|
||
tags: '',
|
||
displayName: '',
|
||
imageFileId,
|
||
previewFileId,
|
||
sourceCardId: `src-${id}`,
|
||
ownerId: owner,
|
||
hidden,
|
||
reportCount,
|
||
searchText: 'karte',
|
||
},
|
||
hidden ? hiddenRowPermissions(owner) : visibleRowPermissions(),
|
||
);
|
||
return { imageFileId, previewFileId };
|
||
}
|
||
|
||
const storageCalls = (fake) => fake.calls.filter((c) => c.path.startsWith('/v1/storage/'));
|
||
const sha32 = (text) => crypto.createHash('sha256').update(text).digest('hex').slice(0, 32);
|
||
|
||
// ---- Reine Helfer ------------------------------------------------------------------------------
|
||
|
||
test('Rechte-Strings sind genau die aus der Spec', () => {
|
||
assert.deepEqual(visibleRowPermissions(), ['read("any")']);
|
||
assert.deepEqual(hiddenRowPermissions('abc'), ['read("user:abc")', 'read("label:admin")']);
|
||
assert.deepEqual(visibleFilePermissions('abc'), ['read("any")', 'delete("user:abc")']);
|
||
assert.deepEqual(hiddenFilePermissions('abc'), ['read("user:abc")', 'read("label:admin")', 'delete("user:abc")']);
|
||
});
|
||
|
||
test('Rechte-Helfer lehnen unzulässige Nutzer-IDs ab (kein Einschleusen in Rechte-Strings)', () => {
|
||
for (const bad of ['a")', 'x","read("any', '', '../x', 5, null]) {
|
||
assert.throws(() => hiddenRowPermissions(bad));
|
||
assert.throws(() => visibleFilePermissions(bad));
|
||
assert.throws(() => hiddenFilePermissions(bad));
|
||
}
|
||
});
|
||
|
||
test('Zeilen-IDs sind deterministisch: erste 32 Hex von sha256(a|b)', () => {
|
||
assert.equal(galleryRowId('u1', 'c1'), sha32('u1|c1'));
|
||
assert.equal(reportRowId('g1', 'hash1'), sha32('g1|hash1'));
|
||
assert.match(galleryRowId('u1', 'c1'), /^[0-9a-f]{32}$/);
|
||
assert.notEqual(galleryRowId('u1', 'c1'), galleryRowId('u2', 'c1'));
|
||
});
|
||
|
||
test('reporterHash ist HMAC-SHA256 (Hex) mit dem Salz; IPv6 wird auf /64 gekürzt', () => {
|
||
assert.equal(reporterHash(SALT, '203.0.113.7'), crypto.createHmac('sha256', SALT).update('203.0.113.7').digest('hex'));
|
||
assert.notEqual(reporterHash('anderes-salz', '203.0.113.7'), reporterHash(SALT, '203.0.113.7'));
|
||
assert.equal(reporterHash(SALT, '2001:db8:abcd:12::1'), reporterHash(SALT, '2001:db8:abcd:12:ffff:eeee:dddd:2'));
|
||
assert.notEqual(reporterHash(SALT, '2001:db8:abcd:12::1'), reporterHash(SALT, '2001:db8:abcd:13::1'));
|
||
});
|
||
|
||
test('normalizeIp: IPv4 bleibt, IPv4-gemappte IPv6 wird IPv4, sonst /64', () => {
|
||
assert.equal(normalizeIp('203.0.113.7'), '203.0.113.7');
|
||
assert.equal(normalizeIp(' 203.0.113.7:5555 '), '203.0.113.7');
|
||
assert.equal(normalizeIp('::ffff:203.0.113.7'), '203.0.113.7');
|
||
assert.equal(normalizeIp('::ffff:cb00:7107'), '203.0.113.7');
|
||
assert.equal(normalizeIp('2001:0DB8:abcd:0012:0000:0000:0000:0001'), '2001:db8:abcd:12::/64');
|
||
assert.equal(normalizeIp('2001:db8::1'), '2001:db8:0:0::/64');
|
||
assert.equal(normalizeIp('::1'), '0:0:0:0::/64');
|
||
assert.equal(normalizeIp('[2001:db8:1:2::9]'), '2001:db8:1:2::/64');
|
||
assert.equal(normalizeIp('fe80::1%eth0'), 'fe80:0:0:0::/64');
|
||
assert.equal(normalizeIp('64:ff9b::192.0.2.33'), '64:ff9b:0:0::/64');
|
||
assert.equal(normalizeIp('kein-ip'), 'kein-ip');
|
||
assert.equal(normalizeIp(undefined), '');
|
||
});
|
||
|
||
test('clientIp: x-real-ip, sonst erstes x-forwarded-for, sonst Socket', () => {
|
||
const socket = { remoteAddress: '10.0.0.9' };
|
||
assert.equal(clientIp({ headers: { 'x-real-ip': '1.1.1.1', 'x-forwarded-for': '2.2.2.2' }, socket }), '1.1.1.1');
|
||
assert.equal(clientIp({ headers: { 'x-forwarded-for': ' 2.2.2.2 , 3.3.3.3' }, socket }), '2.2.2.2');
|
||
assert.equal(clientIp({ headers: {}, socket }), '10.0.0.9');
|
||
assert.equal(clientIp({ headers: { 'x-real-ip': '' }, socket }), '10.0.0.9');
|
||
});
|
||
|
||
test('buildSearchText: klein, mit Leerzeichen verbunden, höchstens 300 Zeichen', () => {
|
||
assert.equal(buildSearchText({ title: 'Mein MOTIV', issuer: 'Beispielbank', tags: ['blau', 'neon'] }), 'mein motiv beispielbank blau neon');
|
||
assert.equal(buildSearchText({ title: 'A', issuer: '', tags: [] }), 'a');
|
||
assert.equal(buildSearchText({ title: 'A', issuer: 'B', tags: 'x,y' }), 'a b x y');
|
||
assert.equal(buildSearchText({ title: 'x'.repeat(400), issuer: '', tags: [] }).length, 300);
|
||
});
|
||
|
||
test('validatePublish: normalisiert Tags und liefert saubere Daten', () => {
|
||
const result = validatePublish(publishBody({ title: ' Mein Motiv ', tags: [' Blau ', 'NEON', '', 'blau'], issuer: undefined, displayName: null }));
|
||
assert.equal(result.ok, true);
|
||
assert.deepEqual(result.data, {
|
||
title: 'Mein Motiv',
|
||
issuer: '',
|
||
tags: ['blau', 'neon'],
|
||
displayName: '',
|
||
imageFileId: 'img1',
|
||
previewFileId: 'prev1',
|
||
sourceCardId: 'card1',
|
||
});
|
||
});
|
||
|
||
test('validatePublish: Längen und Tags an den Grenzen', () => {
|
||
assert.equal(validatePublish(publishBody({ title: 'x'.repeat(60), issuer: 'y'.repeat(40), displayName: 'z'.repeat(30) })).ok, true);
|
||
assert.equal(validatePublish(publishBody({ tags: ['a', 'b', 'c', 'd', 'e'] })).ok, true);
|
||
assert.equal(validatePublish(publishBody({ tags: ['x'.repeat(20)] })).ok, true);
|
||
assert.equal(validatePublish(publishBody({ tags: ['a', 'b', 'c', 'd', 'e', 'f'] })).ok, false);
|
||
assert.equal(validatePublish(publishBody({ tags: ['x'.repeat(21)] })).ok, false);
|
||
const bad = validatePublish(publishBody({ title: '' }));
|
||
assert.equal(bad.ok, false);
|
||
assert.equal(typeof bad.message, 'string');
|
||
});
|
||
|
||
// ---- Gesundheit, Routing, Body ------------------------------------------------------------------
|
||
|
||
test('GET /api/gesundheit antwortet {ok:true} mit sicheren Kopfzeilen', async () => {
|
||
const { call } = await setup();
|
||
const res = await call('GET', '/api/gesundheit?x=1');
|
||
assert.equal(res.status, 200);
|
||
assert.deepEqual(res.json, { ok: true });
|
||
assert.match(res.headers['content-type'], /^application\/json/);
|
||
assert.equal(res.headers['cache-control'], 'no-store');
|
||
assert.equal(res.headers['x-content-type-options'], 'nosniff');
|
||
});
|
||
|
||
test('Alles andere ist 404, auch falsche Methoden', async () => {
|
||
const { call, fake } = await setup();
|
||
for (const [method, path] of [
|
||
['GET', '/'],
|
||
['GET', '/api'],
|
||
['GET', '/api/melden'],
|
||
['POST', '/api/gesundheit'],
|
||
['POST', '/api/unbekannt'],
|
||
['POST', '/api/melden/'],
|
||
['POST', '/api/admin'],
|
||
['PUT', '/api/melden'],
|
||
['OPTIONS', '/api/melden'],
|
||
['DELETE', '/api/zurueckziehen'],
|
||
['GET', '/v1/account'],
|
||
]) {
|
||
const res = await call(method, path, { body: {} });
|
||
assert.equal(res.status, 404, `${method} ${path}`);
|
||
assert.equal(typeof res.json.error, 'string');
|
||
}
|
||
assert.equal(fake.calls.length, 0);
|
||
});
|
||
|
||
test('Nur application/json: sonst 415 (auch mit Charset-Zusatz erlaubt)', async () => {
|
||
const { call, fake } = await setup();
|
||
seedCard(fake);
|
||
for (const contentType of ['text/plain', 'application/x-www-form-urlencoded', 'application/jsonx', null]) {
|
||
const res = await call('POST', '/api/melden', { raw: '{"id":"card-1","reason":"spam"}', contentType });
|
||
assert.equal(res.status, 415, String(contentType));
|
||
}
|
||
const ok = await call('POST', '/api/melden', {
|
||
raw: '{"id":"card-1","reason":"spam"}',
|
||
contentType: 'application/json; charset=utf-8',
|
||
headers: { 'X-Real-Ip': '203.0.113.1' },
|
||
});
|
||
assert.equal(ok.status, 200);
|
||
});
|
||
|
||
test('Body über 8 KB: 413 (mit Content-Length und chunked), genau 8 KB geht noch durch', async () => {
|
||
const { call, fake } = await setup();
|
||
const padded = (length) => {
|
||
const base = '{"id":"nope","reason":"spam"}';
|
||
return base + ' '.repeat(length - Buffer.byteLength(base));
|
||
};
|
||
|
||
const declared = await call('POST', '/api/melden', { raw: padded(8193) });
|
||
assert.equal(declared.status, 413);
|
||
assert.equal(typeof declared.json.error, 'string');
|
||
assert.equal(declared.headers.connection, 'close', 'nach einem zu großen Body wird die Verbindung geschlossen');
|
||
|
||
const chunked = await call('POST', '/api/veroeffentlichen', {
|
||
raw: padded(9000),
|
||
headers: { 'Transfer-Encoding': 'chunked', 'X-Aircard-Jwt': JWT_ME },
|
||
});
|
||
assert.equal(chunked.status, 413);
|
||
|
||
const exact = await call('POST', '/api/melden', { raw: padded(8192), headers: { 'X-Real-Ip': '203.0.113.2' } });
|
||
assert.equal(exact.status, 404, 'genau 8192 Byte werden verarbeitet (Karte existiert nicht)');
|
||
assert.equal(fake.calls.filter((c) => c.path.includes('/account')).length, 0, 'bei 413 wurde nichts bei Appwrite abgefragt');
|
||
});
|
||
|
||
test('Kaputtes JSON, Array oder leerer Body: 400', async () => {
|
||
const { call } = await setup();
|
||
for (const raw of ['{kaputt', '[]', '"text"', 'null', '']) {
|
||
const res = await call('POST', '/api/melden', { raw });
|
||
assert.equal(res.status, 400, JSON.stringify(raw));
|
||
assert.equal(typeof res.json.error, 'string');
|
||
}
|
||
});
|
||
|
||
// ---- Veröffentlichen --------------------------------------------------------------------------------
|
||
|
||
test('Veröffentlichen: legt eine sichtbare Zeile an (Rechte, searchText, ownerId)', async () => {
|
||
const { post, fake } = await setup();
|
||
seedUploads(fake);
|
||
|
||
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||
assert.equal(res.status, 201);
|
||
assert.equal(res.json.id, galleryRowId(ME, 'card1'));
|
||
|
||
const row = fake.row('gallery', res.json.id);
|
||
assert.deepEqual(row.$permissions, ['read("any")']);
|
||
assert.equal(row.title, 'Mein Motiv');
|
||
assert.equal(row.issuer, 'Beispielbank');
|
||
assert.equal(row.tags, 'blau,neon');
|
||
assert.equal(row.displayName, 'Kenso');
|
||
assert.equal(row.imageFileId, 'img1');
|
||
assert.equal(row.previewFileId, 'prev1');
|
||
assert.equal(row.sourceCardId, 'card1');
|
||
assert.equal(row.ownerId, ME);
|
||
assert.equal(row.hidden, false);
|
||
assert.equal(row.reportCount, 0);
|
||
assert.equal(row.searchText, 'mein motiv beispielbank blau neon');
|
||
// der Server-Key hängt an jedem Datenaufruf, das JWT nur am Konto-Aufruf
|
||
const accountCall = fake.calls.find((c) => c.path === '/v1/account');
|
||
assert.equal(accountCall.headers['x-appwrite-jwt'], JWT_ME);
|
||
assert.equal(accountCall.headers['x-appwrite-key'], undefined);
|
||
for (const c of fake.calls.filter((x) => x.path !== '/v1/account')) {
|
||
assert.equal(c.headers['x-appwrite-key'], fake.key);
|
||
assert.equal(c.headers['x-appwrite-jwt'], undefined);
|
||
}
|
||
});
|
||
|
||
test('Veröffentlichen: Bild und Vorschau dürfen PNG oder WebP sein, Anzeigename und Herausgeber sind optional', async () => {
|
||
const { post, fake } = await setup();
|
||
fake.addFile({ id: 'img1', owner: ME, mimeType: 'image/png', size: MIB5 });
|
||
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/png' });
|
||
const res = await post('/api/veroeffentlichen', { title: 'Nur Titel', imageFileId: 'img1', previewFileId: 'prev1', sourceCardId: 'card1' }, JWT_ME);
|
||
assert.equal(res.status, 201);
|
||
const row = fake.row('gallery', res.json.id);
|
||
assert.equal(row.issuer, '');
|
||
assert.equal(row.tags, '');
|
||
assert.equal(row.displayName, '');
|
||
});
|
||
|
||
test('Veröffentlichen ohne oder mit ungültigem JWT: 401 und nichts angelegt', async () => {
|
||
const { post, fake } = await setup();
|
||
seedUploads(fake);
|
||
|
||
assert.equal((await post('/api/veroeffentlichen', publishBody())).status, 401);
|
||
assert.equal((await post('/api/veroeffentlichen', publishBody(), 'xxx.yyy.zzz')).status, 401);
|
||
assert.equal((await post('/api/veroeffentlichen', publishBody(), 'zwei teile')).status, 401);
|
||
assert.equal((await post('/api/veroeffentlichen', {}, '')).status, 401);
|
||
assert.equal(fake.rows('gallery').length, 0);
|
||
assert.equal(storageCalls(fake).length, 0);
|
||
assert.equal(fake.calls.filter((c) => c.path === '/v1/account').length, 1, 'fehlendes oder kaputtes JWT erreicht Appwrite gar nicht erst');
|
||
});
|
||
|
||
test('Veröffentlichen mit fremder Datei: 403 und nichts angelegt', async () => {
|
||
const { post, fake } = await setup();
|
||
fake.addFile({ id: 'img1', owner: OTHER });
|
||
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' });
|
||
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||
assert.equal(res.status, 403);
|
||
assert.equal(typeof res.json.error, 'string');
|
||
|
||
fake.addFile({ id: 'img2', owner: ME });
|
||
fake.addFile({ id: 'prev2', owner: OTHER, mimeType: 'image/webp' });
|
||
res = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img2', previewFileId: 'prev2' }), JWT_ME);
|
||
assert.equal(res.status, 403);
|
||
|
||
// Rechte ohne delete("user:me"): nur lesen reicht nicht
|
||
fake.addFile({ id: 'img3', owner: ME, permissions: ['read("any")', 'read("user:user-me")'] });
|
||
fake.addFile({ id: 'prev3', owner: ME, mimeType: 'image/webp' });
|
||
res = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img3', previewFileId: 'prev3' }), JWT_ME);
|
||
assert.equal(res.status, 403);
|
||
|
||
assert.equal(fake.rows('gallery').length, 0);
|
||
});
|
||
|
||
test('Veröffentlichen mit Datei in falschem Bucket: 403', async () => {
|
||
const { post, fake } = await setup();
|
||
fake.addFile({ id: 'img1', owner: ME, bucket: 'card-images' });
|
||
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' });
|
||
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||
assert.equal(res.status, 403);
|
||
assert.equal(fake.rows('gallery').length, 0);
|
||
});
|
||
|
||
test('Veröffentlichen mit fehlender Datei: 400 "Bild nicht gefunden."', async () => {
|
||
const { post, fake } = await setup();
|
||
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' });
|
||
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||
assert.equal(res.status, 400);
|
||
assert.equal(res.json.error, 'Bild nicht gefunden.');
|
||
|
||
fake.addFile({ id: 'img1', owner: ME });
|
||
res = await post('/api/veroeffentlichen', publishBody({ previewFileId: 'ghost' }), JWT_ME);
|
||
assert.equal(res.status, 400);
|
||
assert.equal(res.json.error, 'Bild nicht gefunden.');
|
||
assert.equal(fake.rows('gallery').length, 0);
|
||
});
|
||
|
||
test('Veröffentlichen: nur PNG/WebP bis 5 MiB, sonst 400', async () => {
|
||
const { post, fake } = await setup();
|
||
const attempt = (image, preview) => {
|
||
fake.addFile({ id: 'img1', owner: ME, ...image });
|
||
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', ...preview });
|
||
return post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||
};
|
||
|
||
assert.equal((await attempt({ mimeType: 'image/jpeg' }, {})).status, 400);
|
||
assert.equal((await attempt({ mimeType: 'application/pdf' }, {})).status, 400);
|
||
assert.equal((await attempt({}, { mimeType: 'image/svg+xml' })).status, 400);
|
||
assert.equal((await attempt({ size: MIB5 + 1 }, {})).status, 400);
|
||
assert.equal((await attempt({}, { size: MIB5 + 1 })).status, 400);
|
||
assert.equal(fake.rows('gallery').length, 0);
|
||
|
||
assert.equal((await attempt({ size: MIB5 }, { size: MIB5 })).status, 201);
|
||
});
|
||
|
||
test('Veröffentlichen doppelt: 409 "Diese Karte ist schon veröffentlicht."', async () => {
|
||
const { post, fake } = await setup();
|
||
seedUploads(fake);
|
||
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201);
|
||
|
||
fake.addFile({ id: 'img2', owner: ME });
|
||
fake.addFile({ id: 'prev2', owner: ME, mimeType: 'image/webp' });
|
||
const again = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img2', previewFileId: 'prev2' }), JWT_ME);
|
||
assert.equal(again.status, 409);
|
||
assert.equal(again.json.error, 'Diese Karte ist schon veröffentlicht.');
|
||
assert.equal(fake.rows('gallery').length, 1);
|
||
|
||
// eine andere Person darf dieselbe sourceCardId-Zeichenfolge haben (Zeilen-ID enthält den Besitzer)
|
||
seedUploads(fake, OTHER, { image: 'img9', preview: 'prev9' });
|
||
const other = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img9', previewFileId: 'prev9' }), JWT_OTHER);
|
||
assert.equal(other.status, 201);
|
||
assert.notEqual(other.json.id, galleryRowId(ME, 'card1'));
|
||
assert.equal(fake.rows('gallery').length, 2);
|
||
});
|
||
|
||
test('Veröffentlichen nach Zurückziehen ist wieder möglich (gleiche Zeilen-ID)', async () => {
|
||
const { post, fake } = await setup();
|
||
seedUploads(fake);
|
||
const first = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||
assert.equal((await post('/api/zurueckziehen', { id: first.json.id }, JWT_ME)).status, 200);
|
||
seedUploads(fake);
|
||
const second = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||
assert.equal(second.status, 201);
|
||
assert.equal(second.json.id, first.json.id);
|
||
});
|
||
|
||
test('Veröffentlichen: Validierung liefert 400 und fasst Appwrite (Dateien, Zeilen) nicht an', async () => {
|
||
const { post, fake } = await setup();
|
||
seedUploads(fake);
|
||
const cases = [
|
||
['leerer Titel', { title: '' }, /Titel/],
|
||
['nur Leerzeichen als Titel', { title: ' ' }, /Titel/],
|
||
['Titel fehlt', { title: undefined }, /Titel/],
|
||
['Titel zu lang', { title: 'x'.repeat(61) }, /Titel/],
|
||
['Titel keine Zeichenkette', { title: 42 }, /Titel/],
|
||
['Titel mit Steuerzeichen', { title: 'a\nb' }, /Titel/],
|
||
['Herausgeber zu lang', { issuer: 'x'.repeat(41) }, /Herausgeber/],
|
||
['Anzeigename zu lang', { displayName: 'x'.repeat(31) }, /Anzeigename/],
|
||
['6 Tags', { tags: ['a', 'b', 'c', 'd', 'e', 'f'] }, /Tags/],
|
||
['Tag zu lang', { tags: ['x'.repeat(21)] }, /Tag/],
|
||
['Tags als Zeichenkette', { tags: 'a,b' }, /Tags/],
|
||
['Tag keine Zeichenkette', { tags: ['a', 5] }, /Tags/],
|
||
['Tag mit Komma', { tags: ['a,b'] }, /Komma/],
|
||
['Bild-ID mit ../', { imageFileId: '../x' }, /ID/],
|
||
['Bild-ID ..', { imageFileId: '..' }, /ID/],
|
||
['Bild-ID Zahl', { imageFileId: 123 }, /ID/],
|
||
['Bild-ID mit Schrägstrich', { imageFileId: 'a/b' }, /ID/],
|
||
['Bild-ID zu lang', { imageFileId: 'a'.repeat(37) }, /ID/],
|
||
['Bild-ID beginnt mit Punkt', { imageFileId: '.hidden' }, /ID/],
|
||
['Bild-ID leer', { imageFileId: '' }, /ID/],
|
||
['Vorschau-ID null', { previewFileId: null }, /ID/],
|
||
['Vorschau-ID mit Leerzeichen', { previewFileId: 'a b' }, /ID/],
|
||
['sourceCardId ../x', { sourceCardId: '../x' }, /ID/],
|
||
['sourceCardId Zahl', { sourceCardId: 7 }, /ID/],
|
||
['sourceCardId fehlt', { sourceCardId: undefined }, /ID/],
|
||
['Bild = Vorschau', { previewFileId: 'img1' }, /verschieden/],
|
||
];
|
||
for (const [label, overrides, pattern] of cases) {
|
||
const res = await post('/api/veroeffentlichen', publishBody(overrides), JWT_ME);
|
||
assert.equal(res.status, 400, label);
|
||
assert.match(res.json.error, pattern, label);
|
||
}
|
||
assert.equal(fake.rows('gallery').length, 0);
|
||
assert.equal(storageCalls(fake).length, 0, 'ungültige IDs erreichen Appwrite nie');
|
||
assert.equal((await post('/api/veroeffentlichen', publishBody({ tags: 'a,b' }), JWT_ME)).status, 400);
|
||
});
|
||
|
||
test('Veröffentlichen: Tags werden serverseitig erneut normalisiert', async () => {
|
||
const { post, fake } = await setup();
|
||
seedUploads(fake);
|
||
const res = await post('/api/veroeffentlichen', publishBody({ tags: [' BLAU ', 'Neon', '', ' ', 'blau'] }), JWT_ME);
|
||
assert.equal(res.status, 201);
|
||
assert.equal(fake.row('gallery', res.json.id).tags, 'blau,neon');
|
||
});
|
||
|
||
test('Veröffentlichen: Appwrite-Ausfall ergibt 502 mit deutschem Text, nichts angelegt', async () => {
|
||
const { post, fake } = await setup();
|
||
seedUploads(fake);
|
||
fake.failNext((c) => c.path === '/v1/account', { status: 500 });
|
||
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||
assert.equal(res.status, 502);
|
||
assert.equal(res.json.error, 'Der Dienst ist gerade nicht erreichbar.');
|
||
|
||
fake.failNext((c) => c.method === 'POST' && c.path.endsWith('/rows'), { status: 500 });
|
||
res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||
assert.equal(res.status, 502);
|
||
assert.equal(fake.rows('gallery').length, 0);
|
||
});
|
||
|
||
// ---- Melden ---------------------------------------------------------------------------------------
|
||
|
||
test('Melden: drei verschiedene IPs blenden die Karte aus (Zeilen- und Dateirechte)', async () => {
|
||
const { report, fake } = await setup();
|
||
const { imageFileId, previewFileId } = seedCard(fake);
|
||
|
||
let res = await report('card-1', '203.0.113.1');
|
||
assert.deepEqual(res.json, { ok: true });
|
||
assert.equal(fake.row('gallery', 'card-1').reportCount, 1);
|
||
assert.equal(fake.row('gallery', 'card-1').hidden, false);
|
||
res = await report('card-1', '203.0.113.2', 'urheberrecht');
|
||
assert.deepEqual(res.json, { ok: true });
|
||
assert.equal(fake.row('gallery', 'card-1').reportCount, 2);
|
||
assert.deepEqual(fake.row('gallery', 'card-1').$permissions, ['read("any")']);
|
||
|
||
res = await report('card-1', '203.0.113.3', 'kartendaten');
|
||
assert.equal(res.status, 200);
|
||
assert.deepEqual(res.json, { ok: true });
|
||
|
||
const row = fake.row('gallery', 'card-1');
|
||
assert.equal(row.hidden, true);
|
||
assert.equal(row.reportCount, 3);
|
||
assert.deepEqual(row.$permissions, ['read("user:user-me")', 'read("label:admin")']);
|
||
const hiddenFile = ['read("user:user-me")', 'read("label:admin")', 'delete("user:user-me")'];
|
||
assert.deepEqual(fake.file(imageFileId).$permissions, hiddenFile);
|
||
assert.deepEqual(fake.file(previewFileId).$permissions, hiddenFile);
|
||
assert.equal(fake.rows('reports').length, 3);
|
||
|
||
// ausgeblendete Karte melden: 404
|
||
res = await report('card-1', '203.0.113.4');
|
||
assert.equal(res.status, 404);
|
||
assert.equal(res.json.error, 'Diese Karte ist nicht (mehr) verfügbar.');
|
||
assert.equal(fake.rows('reports').length, 3);
|
||
});
|
||
|
||
test('Melden: zwei Melder reichen nicht, die Meldungen speichern nur Hash, Grund und Karte', async () => {
|
||
const { report, fake } = await setup();
|
||
seedCard(fake);
|
||
await report('card-1', '203.0.113.1', 'spam');
|
||
await report('card-1', '203.0.113.2', 'sonstiges');
|
||
assert.equal(fake.row('gallery', 'card-1').hidden, false);
|
||
|
||
const hash = reporterHash(SALT, '203.0.113.1');
|
||
const stored = fake.row('reports', reportRowId('card-1', hash));
|
||
assert.ok(stored, 'Zeilen-ID = sha256(galleryId|reporterHash)');
|
||
assert.equal(stored.galleryId, 'card-1');
|
||
assert.equal(stored.reason, 'spam');
|
||
assert.equal(stored.reporterHash, hash);
|
||
assert.deepEqual(stored.$permissions, []);
|
||
assert.ok(!JSON.stringify(fake.rows('reports')).includes('203.0.113'), 'keine IP im Klartext gespeichert');
|
||
});
|
||
|
||
test('Melden: dieselbe IP fünfmal zählt einmal, Antworten sind schonGemeldet', async () => {
|
||
const { report, fake } = await setup();
|
||
seedCard(fake);
|
||
|
||
const first = await report('card-1', '203.0.113.9');
|
||
assert.deepEqual(first.json, { ok: true });
|
||
for (let i = 0; i < 4; i += 1) {
|
||
const again = await report('card-1', '203.0.113.9', i % 2 ? 'spam' : 'unangemessen');
|
||
assert.equal(again.status, 200);
|
||
assert.deepEqual(again.json, { schonGemeldet: true });
|
||
}
|
||
assert.equal(fake.row('gallery', 'card-1').reportCount, 1);
|
||
assert.equal(fake.row('gallery', 'card-1').hidden, false);
|
||
assert.equal(fake.rows('reports').length, 1);
|
||
});
|
||
|
||
test('Melden: Rate-Limit 10 pro Stunde und IP, danach 429, nach einer Stunde wieder erlaubt', async () => {
|
||
const { report, fake, clock } = await setup();
|
||
seedCard(fake);
|
||
|
||
for (let i = 0; i < 10; i += 1) {
|
||
const res = await report('card-1', '203.0.113.50');
|
||
assert.equal(res.status, 200, `Meldung ${i + 1}`);
|
||
}
|
||
let res = await report('card-1', '203.0.113.50');
|
||
assert.equal(res.status, 429);
|
||
assert.equal(res.json.error, 'Zu viele Meldungen – bitte später nochmal.');
|
||
assert.equal(res.headers['retry-after'], '3600');
|
||
|
||
// eine andere IP ist nicht betroffen
|
||
assert.equal((await report('card-1', '203.0.113.51')).status, 200);
|
||
|
||
// knapp vor Ablauf noch gesperrt, nach einer Stunde wieder frei
|
||
clock.value += 3600_000 - 1;
|
||
assert.equal((await report('card-1', '203.0.113.50')).status, 429);
|
||
clock.value += 1;
|
||
res = await report('card-1', '203.0.113.50');
|
||
assert.equal(res.status, 200);
|
||
assert.deepEqual(res.json, { schonGemeldet: true });
|
||
});
|
||
|
||
test('Melden: abgelaufene Rate-Limit-Einträge werden entfernt', async () => {
|
||
const { report, fake, clock, app } = await setup();
|
||
seedCard(fake);
|
||
for (const ip of ['203.0.113.1', '203.0.113.2', '203.0.113.3']) await report('card-1', ip);
|
||
assert.equal(app.rateLimitEntries(), 3);
|
||
|
||
clock.value += 3600_000 + 60_000;
|
||
await report('card-1', '203.0.113.4');
|
||
assert.equal(app.rateLimitEntries(), 1, 'die drei alten Einträge sind weg, nur der neue bleibt');
|
||
});
|
||
|
||
test('Melden: Ungültige Anfragen (Grund, ID) zählen nicht zum Limit und erreichen Appwrite nicht', async () => {
|
||
const { report, post, fake, app } = await setup();
|
||
seedCard(fake);
|
||
for (const reason of ['gibt-es-nicht', '', null, 7, undefined, 'SPAM']) {
|
||
const res = await post('/api/melden', { id: 'card-1', reason }, null, { 'X-Real-Ip': '203.0.113.1' });
|
||
assert.equal(res.status, 400, String(reason));
|
||
assert.equal(typeof res.json.error, 'string');
|
||
}
|
||
for (const id of ['../x', '..', 42, null, undefined, 'a/b', 'a'.repeat(37), '']) {
|
||
const res = await post('/api/melden', { id, reason: 'spam' }, null, { 'X-Real-Ip': '203.0.113.1' });
|
||
assert.equal(res.status, 400, String(id));
|
||
}
|
||
assert.equal(fake.calls.length, 0);
|
||
assert.equal(app.rateLimitEntries(), 0);
|
||
assert.equal((await report('card-1', '203.0.113.1')).status, 200);
|
||
});
|
||
|
||
test('Melden: nicht vorhandene Karte ist 404', async () => {
|
||
const { report, fake } = await setup();
|
||
const res = await report('gibts-nicht', '203.0.113.1');
|
||
assert.equal(res.status, 404);
|
||
assert.equal(res.json.error, 'Diese Karte ist nicht (mehr) verfügbar.');
|
||
assert.equal(fake.rows('reports').length, 0);
|
||
});
|
||
|
||
test('Melden: gültige Gründe sind unangemessen, kartendaten, urheberrecht, spam, sonstiges', async () => {
|
||
const { report, fake } = await setup();
|
||
seedCard(fake);
|
||
const reasons = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'];
|
||
for (let i = 0; i < reasons.length; i += 1) {
|
||
const res = await report('card-1', `198.51.100.${i + 1}`, reasons[i]);
|
||
assert.equal(res.status, 200, reasons[i]);
|
||
if (i === 2) break; // ab 3 Meldern ist die Karte weg
|
||
}
|
||
const stored = fake.rows('reports').map((r) => r.reason).sort();
|
||
assert.deepEqual(stored, ['kartendaten', 'unangemessen', 'urheberrecht']);
|
||
});
|
||
|
||
test('Melden: IPv6-Adressen aus demselben /64 zählen als ein Melder', async () => {
|
||
const { report, fake } = await setup();
|
||
seedCard(fake);
|
||
assert.deepEqual((await report('card-1', '2001:db8:abcd:12::1')).json, { ok: true });
|
||
assert.deepEqual((await report('card-1', '2001:db8:abcd:12:aaaa:bbbb:cccc:dddd')).json, { schonGemeldet: true });
|
||
assert.deepEqual((await report('card-1', '2001:db8:abcd:13::1')).json, { ok: true });
|
||
assert.equal(fake.row('gallery', 'card-1').reportCount, 2);
|
||
});
|
||
|
||
test('Melden: ohne X-Real-Ip gilt das erste X-Forwarded-For, sonst die Socket-Adresse', async () => {
|
||
const { post, fake } = await setup();
|
||
seedCard(fake);
|
||
const viaForwarded = (ip) => post('/api/melden', { id: 'card-1', reason: 'spam' }, null, { 'X-Forwarded-For': `${ip}, 10.0.0.1` });
|
||
assert.deepEqual((await viaForwarded('203.0.113.77')).json, { ok: true });
|
||
assert.deepEqual((await viaForwarded('203.0.113.77')).json, { schonGemeldet: true });
|
||
assert.deepEqual((await viaForwarded('203.0.113.78')).json, { ok: true });
|
||
// ohne beide Header zählt die Socket-Adresse (127.0.0.1) als dritter Melder
|
||
assert.deepEqual((await post('/api/melden', { id: 'card-1', reason: 'spam' })).json, { ok: true });
|
||
assert.equal(fake.row('gallery', 'card-1').hidden, true, 'drei verschiedene Quellen');
|
||
});
|
||
|
||
test('Melden: gleichzeitige Meldungen von drei IPs ergeben genau drei und blenden aus', async () => {
|
||
const { report, fake } = await setup();
|
||
seedCard(fake);
|
||
const results = await Promise.all([report('card-1', '203.0.113.1'), report('card-1', '203.0.113.2'), report('card-1', '203.0.113.3')]);
|
||
assert.deepEqual(results.map((r) => r.status), [200, 200, 200]);
|
||
const row = fake.row('gallery', 'card-1');
|
||
assert.equal(row.reportCount, 3);
|
||
assert.equal(row.hidden, true);
|
||
assert.equal(fake.rows('reports').length, 3);
|
||
});
|
||
|
||
test('Melden: eine verspätete Zählerstand-Änderung überschreibt neuere Stände nicht (Sperre je Karte)', async () => {
|
||
const { report, fake } = await setup();
|
||
seedCard(fake);
|
||
// Die erste Meldung schreibt ihren Zähler (1) erst nach 150 ms; währenddessen kommen zwei weitere.
|
||
fake.delayNext((c) => c.method === 'PATCH' && c.body && c.body.data && c.body.data.reportCount === 1, 150);
|
||
const first = report('card-1', '203.0.113.1');
|
||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||
const others = await Promise.all([report('card-1', '203.0.113.2'), report('card-1', '203.0.113.3')]);
|
||
const firstResult = await first;
|
||
assert.deepEqual([firstResult.status, ...others.map((r) => r.status)], [200, 200, 200]);
|
||
|
||
const row = fake.row('gallery', 'card-1');
|
||
assert.equal(row.reportCount, 3);
|
||
assert.equal(row.hidden, true);
|
||
assert.deepEqual(row.$permissions, ['read("user:user-me")', 'read("label:admin")']);
|
||
});
|
||
|
||
test('Melden: eine doppelte Meldung heilt einen früher steckengebliebenen Zählerstand', async () => {
|
||
const { report, fake } = await setup();
|
||
seedCard(fake);
|
||
// drei Meldungen liegen schon vor, der Zähler an der Karte kam aber nie an (z. B. Ausfall mitten im Ablauf)
|
||
for (const ip of ['203.0.113.1', '203.0.113.2', '203.0.113.3']) {
|
||
const hash = reporterHash(SALT, ip);
|
||
fake.addRow('reports', reportRowId('card-1', hash), { galleryId: 'card-1', reason: 'spam', reporterHash: hash }, []);
|
||
}
|
||
assert.equal(fake.row('gallery', 'card-1').hidden, false);
|
||
const res = await report('card-1', '203.0.113.1');
|
||
assert.deepEqual(res.json, { schonGemeldet: true });
|
||
assert.equal(fake.row('gallery', 'card-1').hidden, true);
|
||
assert.equal(fake.row('gallery', 'card-1').reportCount, 3);
|
||
});
|
||
|
||
test('Melden: Ausfall beim Anlegen der Meldung ergibt 502 und zählt nicht', async () => {
|
||
const { report, fake } = await setup();
|
||
seedCard(fake);
|
||
fake.failNext((c) => c.method === 'POST' && c.path.endsWith('/tables/reports/rows'), { status: 500 });
|
||
const res = await report('card-1', '203.0.113.1');
|
||
assert.equal(res.status, 502);
|
||
assert.equal(fake.row('gallery', 'card-1').reportCount, 0);
|
||
assert.equal(fake.rows('reports').length, 0);
|
||
});
|
||
|
||
test('Melden: Ausfall beim Ausblenden endet mit 502, ein erneuter Versuch blendet aus', async () => {
|
||
const { report, fake } = await setup();
|
||
seedCard(fake, { reportCount: 0 });
|
||
await report('card-1', '203.0.113.1');
|
||
await report('card-1', '203.0.113.2');
|
||
fake.failNext((c) => c.method === 'PATCH' && c.path.includes('/tables/gallery/rows/'), { status: 500 });
|
||
const res = await report('card-1', '203.0.113.3');
|
||
assert.equal(res.status, 502);
|
||
assert.equal(fake.row('gallery', 'card-1').hidden, false);
|
||
const retry = await report('card-1', '203.0.113.3');
|
||
assert.equal(retry.status, 200);
|
||
assert.equal(fake.row('gallery', 'card-1').hidden, true);
|
||
});
|
||
|
||
// ---- Zurückziehen ----------------------------------------------------------------------------------
|
||
|
||
test('Zurückziehen durch den Autor löscht Zeile, beide Dateien und die Meldungen', async () => {
|
||
const { post, fake } = await setup();
|
||
const { imageFileId, previewFileId } = seedCard(fake);
|
||
seedCard(fake, { id: 'card-2' });
|
||
for (const [id, hash] of [['card-1', 'h1'], ['card-1', 'h2'], ['card-2', 'h3']]) {
|
||
fake.addRow('reports', reportRowId(id, hash), { galleryId: id, reason: 'spam', reporterHash: hash }, []);
|
||
}
|
||
|
||
const res = await post('/api/zurueckziehen', { id: 'card-1' }, JWT_ME);
|
||
assert.equal(res.status, 200);
|
||
assert.deepEqual(res.json, { ok: true });
|
||
assert.equal(fake.row('gallery', 'card-1'), null);
|
||
assert.equal(fake.file(imageFileId), null);
|
||
assert.equal(fake.file(previewFileId), null);
|
||
assert.deepEqual(fake.rows('reports').map((r) => r.galleryId), ['card-2']);
|
||
assert.ok(fake.row('gallery', 'card-2'), 'andere Karten bleiben');
|
||
assert.ok(fake.file('card-2-img'));
|
||
});
|
||
|
||
test('Zurückziehen durch Admin geht, auch bei ausgeblendeter Karte', async () => {
|
||
const { post, fake } = await setup();
|
||
seedCard(fake, { hidden: true, reportCount: 3 });
|
||
const res = await post('/api/zurueckziehen', { id: 'card-1' }, JWT_ADMIN);
|
||
assert.equal(res.status, 200);
|
||
assert.equal(fake.row('gallery', 'card-1'), null);
|
||
assert.equal(fake.file('card-1-img'), null);
|
||
});
|
||
|
||
test('Zurückziehen durch Fremde: 403 und nichts gelöscht', async () => {
|
||
const { post, fake } = await setup();
|
||
seedCard(fake);
|
||
const res = await post('/api/zurueckziehen', { id: 'card-1' }, JWT_OTHER);
|
||
assert.equal(res.status, 403);
|
||
assert.equal(typeof res.json.error, 'string');
|
||
assert.ok(fake.row('gallery', 'card-1'));
|
||
assert.ok(fake.file('card-1-img'));
|
||
assert.ok(fake.file('card-1-prev'));
|
||
});
|
||
|
||
test('Zurückziehen: 401 ohne JWT, 404 bei fehlender Karte, 400 bei ungültiger ID', async () => {
|
||
const { post, fake } = await setup();
|
||
seedCard(fake);
|
||
assert.equal((await post('/api/zurueckziehen', { id: 'card-1' })).status, 401);
|
||
assert.equal((await post('/api/zurueckziehen', { id: 'card-1' }, 'xxx.yyy.zzz')).status, 401);
|
||
assert.equal((await post('/api/zurueckziehen', { id: 'nope' }, JWT_ME)).status, 404);
|
||
for (const id of ['../x', '..', 5, null, undefined]) {
|
||
assert.equal((await post('/api/zurueckziehen', { id }, JWT_ME)).status, 400, String(id));
|
||
}
|
||
assert.ok(fake.row('gallery', 'card-1'));
|
||
});
|
||
|
||
test('Zurückziehen ist wiederholbar, wenn schon eine Datei fehlt (Wiederholung nach Teilausfall)', async () => {
|
||
const { post, fake } = await setup();
|
||
seedCard(fake);
|
||
fake.failNext((c) => c.method === 'DELETE' && c.path.includes('/tables/gallery/rows/'), { status: 500 });
|
||
assert.equal((await post('/api/zurueckziehen', { id: 'card-1' }, JWT_ME)).status, 502);
|
||
assert.ok(fake.row('gallery', 'card-1'), 'Zeile bleibt, bis alles andere weg ist');
|
||
assert.equal(fake.file('card-1-img'), null);
|
||
const retry = await post('/api/zurueckziehen', { id: 'card-1' }, JWT_ME);
|
||
assert.equal(retry.status, 200);
|
||
assert.equal(fake.row('gallery', 'card-1'), null);
|
||
});
|
||
|
||
// ---- Admin ------------------------------------------------------------------------------------------
|
||
|
||
test('Admin-Sichtbarkeit: Nicht-Admin 403, ohne JWT 401', async () => {
|
||
const { post, fake } = await setup();
|
||
seedCard(fake);
|
||
assert.equal((await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, JWT_ME)).status, 403);
|
||
assert.equal((await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, JWT_OTHER)).status, 403);
|
||
assert.equal((await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false })).status, 401);
|
||
assert.deepEqual(fake.row('gallery', 'card-1').$permissions, ['read("any")']);
|
||
assert.equal(fake.row('gallery', 'card-1').hidden, false);
|
||
});
|
||
|
||
test('Admin blendet aus: Zeilen- und Dateirechte, hidden=true, Meldungen bleiben', async () => {
|
||
const { post, fake } = await setup();
|
||
seedCard(fake, { reportCount: 1 });
|
||
fake.addRow('reports', reportRowId('card-1', 'h1'), { galleryId: 'card-1', reason: 'spam', reporterHash: 'h1' }, []);
|
||
|
||
const res = await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, JWT_ADMIN);
|
||
assert.equal(res.status, 200);
|
||
const row = fake.row('gallery', 'card-1');
|
||
assert.equal(row.hidden, true);
|
||
assert.equal(row.reportCount, 1);
|
||
assert.deepEqual(row.$permissions, ['read("user:user-me")', 'read("label:admin")']);
|
||
const hiddenFile = ['read("user:user-me")', 'read("label:admin")', 'delete("user:user-me")'];
|
||
assert.deepEqual(fake.file('card-1-img').$permissions, hiddenFile);
|
||
assert.deepEqual(fake.file('card-1-prev').$permissions, hiddenFile);
|
||
assert.equal(fake.rows('reports').length, 1);
|
||
});
|
||
|
||
test('Admin blendet ein: sichtbare Rechte, hidden=false, reportCount=0, Meldungen gelöscht', async () => {
|
||
const { post, fake, report } = await setup();
|
||
seedCard(fake, { hidden: true, reportCount: 3 });
|
||
seedCard(fake, { id: 'card-2' });
|
||
for (const [id, hash] of [['card-1', 'h1'], ['card-1', 'h2'], ['card-1', 'h3'], ['card-2', 'h4']]) {
|
||
fake.addRow('reports', reportRowId(id, hash), { galleryId: id, reason: 'spam', reporterHash: hash }, []);
|
||
}
|
||
|
||
const res = await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: true }, JWT_ADMIN);
|
||
assert.equal(res.status, 200);
|
||
const row = fake.row('gallery', 'card-1');
|
||
assert.equal(row.hidden, false);
|
||
assert.equal(row.reportCount, 0);
|
||
assert.deepEqual(row.$permissions, ['read("any")']);
|
||
assert.deepEqual(fake.file('card-1-img').$permissions, ['read("any")', 'delete("user:user-me")']);
|
||
assert.deepEqual(fake.file('card-1-prev').$permissions, ['read("any")', 'delete("user:user-me")']);
|
||
assert.deepEqual(fake.rows('reports').map((r) => r.galleryId), ['card-2']);
|
||
|
||
// die Karte ist wieder meldbar
|
||
assert.equal((await report('card-1', '203.0.113.1')).status, 200);
|
||
});
|
||
|
||
test('Admin-Sichtbarkeit: Body-Prüfung und fehlende Karte', async () => {
|
||
const { post, fake } = await setup();
|
||
seedCard(fake);
|
||
for (const body of [
|
||
{ id: 'card-1' },
|
||
{ id: 'card-1', sichtbar: 'ja' },
|
||
{ id: 'card-1', sichtbar: 1 },
|
||
{ id: '../x', sichtbar: true },
|
||
{ id: 7, sichtbar: true },
|
||
{},
|
||
]) {
|
||
assert.equal((await post('/api/admin/sichtbarkeit', body, JWT_ADMIN)).status, 400, JSON.stringify(body));
|
||
}
|
||
assert.equal((await post('/api/admin/sichtbarkeit', { id: 'nope', sichtbar: true }, JWT_ADMIN)).status, 404);
|
||
});
|
||
|
||
// ---- Logs und Fehler --------------------------------------------------------------------------------
|
||
|
||
test('Logs enthalten nie Key, Salz, JWT oder IPs im Klartext, aber das Präfix [aircard-api]', async () => {
|
||
const { post, report, fake, logs } = await setup();
|
||
seedUploads(fake);
|
||
seedCard(fake, { id: 'card-9' });
|
||
await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||
await post('/api/veroeffentlichen', publishBody(), 'xxx.yyy.zzz');
|
||
await report('card-9', '203.0.113.7');
|
||
await report('card-9', '203.0.113.8');
|
||
await report('card-9', '203.0.113.9');
|
||
await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: true }, JWT_ADMIN);
|
||
fake.failNext((c) => c.method === 'GET' && c.path.includes('/rows/'), { status: 500 });
|
||
await post('/api/zurueckziehen', { id: 'card-9' }, JWT_ME);
|
||
await post('/api/zurueckziehen', { id: 'card-9' }, JWT_ME);
|
||
|
||
assert.ok(logs.length > 0);
|
||
const all = logs.join('\n');
|
||
for (const secret of [fake.key, SALT, JWT_ME, JWT_OTHER, JWT_ADMIN, 'xxx.yyy.zzz', '203.0.113', '127.0.0.1', reporterHash(SALT, '203.0.113.7')]) {
|
||
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 Anfragen (400/401/404/413/415/429) erzeugen keine Logzeilen', async () => {
|
||
const { call, post, report, logs } = await setup();
|
||
const big = `{"id":"x","reason":"spam"}${' '.repeat(9000)}`;
|
||
for (let i = 0; i < 12; i += 1) {
|
||
await report('gibts-nicht', '203.0.113.5'); // 404, ab der 11. Meldung 429
|
||
await call('POST', '/api/melden', { raw: '{"id":"x","reason":"spam"}', contentType: 'text/plain' }); // 415
|
||
await call('POST', '/api/melden', { raw: big }); // 413
|
||
await call('POST', '/api/melden', { raw: '{kaputt' }); // 400
|
||
await post('/api/melden', { id: '../x', reason: 'spam' }); // 400
|
||
await post('/api/veroeffentlichen', publishBody()); // 401
|
||
await post('/api/zurueckziehen', { id: 'x' }, 'zwei teile'); // 401
|
||
await call('GET', '/api/gibts-nicht'); // 404
|
||
}
|
||
const lastLimited = await report('gibts-nicht', '203.0.113.5');
|
||
assert.equal(lastLimited.status, 429, 'das Rate-Limit hat wirklich gegriffen');
|
||
assert.deepEqual(logs, []);
|
||
});
|
||
|
||
test('Log-Budget: höchstens 120 Zeilen pro Minute, der Rest wird gezählt und als Sammelzeile nachgetragen', async () => {
|
||
const logs = [];
|
||
const clock = { value: 1_700_000_000_000 };
|
||
const broken = {
|
||
accountFromJwt: async () => {
|
||
throw new AppwriteError(500, 'general_server_error', 'kaputt');
|
||
},
|
||
};
|
||
const app = createApp({ appwrite: broken, salt: SALT, now: () => clock.value, log: (line) => logs.push(String(line)) });
|
||
const server = http.createServer(app);
|
||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||
cleanups.push(() => {
|
||
server.closeAllConnections();
|
||
return new Promise((resolve) => server.close(resolve));
|
||
});
|
||
const { port } = server.address();
|
||
const hit = () => request(port, 'POST', '/api/zurueckziehen', { body: { id: 'x' }, headers: { 'X-Aircard-Jwt': JWT_ME } });
|
||
|
||
for (let i = 0; i < 300; i += 1) assert.equal((await hit()).status, 502);
|
||
assert.equal(logs.length, 120, 'nach 120 Zeilen ist Schluss');
|
||
|
||
clock.value += 60_000;
|
||
await hit();
|
||
assert.equal(logs.length, 122, 'Sammelzeile + die neue Zeile');
|
||
assert.match(logs[120], /^\[aircard-api\] \S+ 180 log lines suppressed/);
|
||
assert.match(logs[121], /appwrite error status=500 type=general_server_error/);
|
||
});
|
||
|
||
test('Appwrite-Fehler: 502 bei 5xx und 429, 500 bei allem anderen (falscher Key, fehlende Tabelle)', async () => {
|
||
const { report, fake } = await setup();
|
||
seedCard(fake);
|
||
const isRowRead = (c) => c.method === 'GET' && c.path.includes('/tables/gallery/rows/');
|
||
const expectations = [
|
||
[{ status: 500, type: 'general_server_error' }, 502, 'Der Dienst ist gerade nicht erreichbar.'],
|
||
[{ status: 429, type: 'general_rate_limit_exceeded' }, 502, 'Der Dienst ist gerade nicht erreichbar.'],
|
||
[{ status: 404, type: 'table_not_found' }, 500, 'Interner Fehler.'],
|
||
[{ status: 404, type: 'database_not_found' }, 500, 'Interner Fehler.'],
|
||
[{ status: 404, type: 'general_route_not_found' }, 500, 'Interner Fehler.'],
|
||
[{ status: 401, type: 'general_unauthorized_scope' }, 500, 'Interner Fehler.'],
|
||
];
|
||
for (const [failure, status, message] of expectations) {
|
||
fake.failNext(isRowRead, failure);
|
||
const res = await report('card-1', '203.0.113.1');
|
||
assert.equal(res.status, status, `${failure.status} ${failure.type}`);
|
||
assert.equal(res.json.error, message);
|
||
}
|
||
assert.equal(fake.rows('reports').length, 0);
|
||
});
|
||
|
||
test('Fehlende Tabelle oder Bucket ist kein „Karte nicht verfügbar“, sondern ein 500', async () => {
|
||
const fake = await startFakeAppwrite();
|
||
fake.addUser(JWT_ME, { id: ME });
|
||
const appwrite = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key, database: 'falsche-db' });
|
||
const app = createApp({ appwrite, salt: SALT, log: () => {} });
|
||
const server = http.createServer(app);
|
||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||
cleanups.push(async () => {
|
||
server.closeAllConnections();
|
||
await new Promise((resolve) => server.close(resolve));
|
||
await fake.close();
|
||
});
|
||
const { port } = server.address();
|
||
const res = await request(port, 'POST', '/api/melden', { body: { id: 'card-1', reason: 'spam' } });
|
||
assert.equal(res.status, 500);
|
||
assert.equal(res.json.error, 'Interner Fehler.');
|
||
});
|
||
|
||
test('Veröffentlichen legt beide Dateien auf genau die sichtbaren Rechte fest (kanonischer Zustand)', async () => {
|
||
const { post, fake } = await setup();
|
||
fake.addFile({ id: 'img1', owner: ME, permissions: ['read("any")', 'delete("user:user-me")', 'update("user:user-me")', 'read("users")'] });
|
||
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', permissions: ['read("any")', 'delete("user:user-me")', 'write("user:user-me")'] });
|
||
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||
assert.equal(res.status, 201);
|
||
assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-me")']);
|
||
assert.deepEqual(fake.file('prev1').$permissions, ['read("any")', 'delete("user:user-me")']);
|
||
});
|
||
|
||
test('Ein 409 beim Veröffentlichen fasst die Dateirechte der neu hochgeladenen Dateien nicht an', async () => {
|
||
const { post, fake } = await setup();
|
||
seedUploads(fake);
|
||
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201);
|
||
|
||
// Dieselbe private Karte nochmal, mit frisch hochgeladenen Dateien (z. B. zweiter Tab): 409, Dateien bleiben unverändert.
|
||
const extra = ['read("any")', 'delete("user:user-me")', 'update("user:user-me")'];
|
||
fake.addFile({ id: 'img2', owner: ME, permissions: extra });
|
||
fake.addFile({ id: 'prev2', owner: ME, mimeType: 'image/webp', permissions: extra });
|
||
const res = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img2', previewFileId: 'prev2' }), JWT_ME);
|
||
assert.equal(res.status, 409);
|
||
assert.deepEqual(fake.file('img2').$permissions, extra);
|
||
assert.deepEqual(fake.file('prev2').$permissions, extra);
|
||
assert.equal(fake.calls.filter((c) => c.method === 'PUT' && /\/files\/(img2|prev2)$/.test(c.path)).length, 0, 'kein PUT auf die neuen Dateien');
|
||
});
|
||
|
||
const TAKEN = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.';
|
||
|
||
test('Dateien einer ausgeblendeten Karte lassen sich nicht unter anderer sourceCardId neu veröffentlichen (403)', async () => {
|
||
const { post, fake } = await setup();
|
||
const { imageFileId, previewFileId } = seedCard(fake, { id: 'hid', hidden: true, reportCount: 3 });
|
||
const hiddenFile = ['read("user:user-me")', 'read("label:admin")', 'delete("user:user-me")'];
|
||
|
||
const res = await post('/api/veroeffentlichen', publishBody({ imageFileId, previewFileId, sourceCardId: 'andere-karte' }), JWT_ME);
|
||
assert.equal(res.status, 403);
|
||
assert.equal(res.json.error, TAKEN);
|
||
assert.equal(fake.rows('gallery').length, 1, 'keine neue Zeile');
|
||
assert.equal(fake.row('gallery', 'hid').hidden, true);
|
||
assert.deepEqual(fake.file(imageFileId).$permissions, hiddenFile);
|
||
assert.deepEqual(fake.file(previewFileId).$permissions, hiddenFile);
|
||
assert.equal(fake.calls.filter((c) => c.method === 'PUT').length, 0, 'Dateirechte bleiben unangetastet');
|
||
|
||
// auch mit der ursprünglichen sourceCardId bleibt es bei 403 (Dateien gesperrt) und nicht 409
|
||
const same = await post('/api/veroeffentlichen', publishBody({ imageFileId, previewFileId, sourceCardId: 'src-hid' }), JWT_ME);
|
||
assert.equal(same.status, 403);
|
||
});
|
||
|
||
test('Dateien einer sichtbaren Karte lassen sich nicht unter anderer sourceCardId neu veröffentlichen (403)', async () => {
|
||
const { post, fake } = await setup();
|
||
const { imageFileId, previewFileId } = seedCard(fake, { id: 'vis' });
|
||
fake.addFile({ id: 'fresh-img', owner: ME });
|
||
fake.addFile({ id: 'fresh-prev', owner: ME, mimeType: 'image/webp' });
|
||
|
||
for (const [label, overrides] of [
|
||
['beide Dateien', { imageFileId, previewFileId }],
|
||
['nur das Bild', { imageFileId, previewFileId: 'fresh-prev' }],
|
||
['nur die Vorschau', { imageFileId: 'fresh-img', previewFileId }],
|
||
]) {
|
||
const res = await post('/api/veroeffentlichen', publishBody({ ...overrides, sourceCardId: 'andere-karte' }), JWT_ME);
|
||
assert.equal(res.status, 403, label);
|
||
assert.equal(res.json.error, TAKEN, label);
|
||
}
|
||
assert.equal(fake.rows('gallery').length, 1);
|
||
|
||
// frische Dateien gehen
|
||
const ok = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'fresh-img', previewFileId: 'fresh-prev', sourceCardId: 'andere-karte' }), JWT_ME);
|
||
assert.equal(ok.status, 201);
|
||
});
|
||
|
||
test('Überkreuz-Verwendung: Bild einer Karte als Vorschau der neuen (und umgekehrt) wird abgelehnt', async () => {
|
||
const { post, fake } = await setup();
|
||
seedCard(fake, { id: 'vis' }); // Bild vis-img, Vorschau vis-prev
|
||
fake.addFile({ id: 'fresh-img', owner: ME });
|
||
fake.addFile({ id: 'fresh-prev', owner: ME, mimeType: 'image/webp' });
|
||
// vis-img (PNG) darf Vorschau sein, vis-prev (WebP) darf Bild sein: Format allein verhindert die Kreuzung nicht
|
||
const asPreview = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'fresh-img', previewFileId: 'vis-img', sourceCardId: 'x1' }), JWT_ME);
|
||
assert.equal(asPreview.status, 403);
|
||
assert.equal(asPreview.json.error, TAKEN);
|
||
const asImage = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'vis-prev', previewFileId: 'fresh-prev', sourceCardId: 'x2' }), JWT_ME);
|
||
assert.equal(asImage.status, 403);
|
||
assert.equal(asImage.json.error, TAKEN);
|
||
assert.equal(fake.rows('gallery').length, 1);
|
||
});
|
||
|
||
test('Dateien ohne read("any") (nicht frisch hochgeladen) werden abgelehnt (403)', async () => {
|
||
const { post, fake } = await setup();
|
||
fake.addFile({ id: 'img1', owner: ME, permissions: ['delete("user:user-me")'] });
|
||
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' });
|
||
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||
assert.equal(res.status, 403);
|
||
assert.equal(res.json.error, TAKEN);
|
||
|
||
fake.addFile({ id: 'img1', owner: ME });
|
||
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', permissions: ['read("user:user-me")', 'delete("user:user-me")'] });
|
||
res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||
assert.equal(res.status, 403);
|
||
assert.equal(fake.rows('gallery').length, 0);
|
||
});
|
||
|
||
test('Prüfung der Dateinutzung blättert durch alle Karten des Autors (250 Karten) und fragt nur dessen Zeilen ab', async () => {
|
||
const { post, fake } = await setup();
|
||
for (let i = 0; i < 250; i += 1) {
|
||
const owner = i % 50 === 0 ? OTHER : ME;
|
||
fake.addRow(
|
||
'gallery',
|
||
`row${i}`,
|
||
{ title: `K${i}`, imageFileId: `f${i}a`, previewFileId: `f${i}b`, sourceCardId: `s${i}`, ownerId: owner, hidden: false, reportCount: 0 },
|
||
['read("any")'],
|
||
);
|
||
}
|
||
fake.addFile({ id: 'f230a', owner: ME });
|
||
fake.addFile({ id: 'f230b', owner: ME, mimeType: 'image/webp' });
|
||
const taken = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'f230a', previewFileId: 'f230b', sourceCardId: 'neu1' }), JWT_ME);
|
||
assert.equal(taken.status, 403, 'Karte 230 liegt auf der dritten Seite');
|
||
assert.equal(taken.json.error, TAKEN);
|
||
const lists = fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith('/tables/gallery/rows'));
|
||
assert.equal(lists.length, 3, 'drei Seiten à 100');
|
||
for (const call of lists) assert.ok(decodeURIComponent(call.search).includes('"attribute":"ownerId","values":["user-me"]'));
|
||
|
||
seedUploads(fake);
|
||
const ok = await post('/api/veroeffentlichen', publishBody({ sourceCardId: 'neu2' }), JWT_ME);
|
||
assert.equal(ok.status, 201);
|
||
|
||
// Karten anderer Autoren zählen nicht (deren Dateien kann dieser Autor ohnehin nicht verwenden)
|
||
assert.ok(fake.row('gallery', 'row0'));
|
||
});
|
||
|
||
test('Gleichzeitiges Veröffentlichen derselben Dateien unter zwei sourceCardIds: genau eines gelingt', async () => {
|
||
const { post, fake } = await setup();
|
||
seedUploads(fake);
|
||
// Das Anlegen der ersten Zeile verzögern, damit beide Anfragen die Prüfung vor dem Anlegen erreichen würden.
|
||
fake.delayNext((c) => c.method === 'POST' && c.path.endsWith('/tables/gallery/rows'), 80);
|
||
const results = await Promise.all([
|
||
post('/api/veroeffentlichen', publishBody({ sourceCardId: 'c1' }), JWT_ME),
|
||
post('/api/veroeffentlichen', publishBody({ sourceCardId: 'c2' }), JWT_ME),
|
||
]);
|
||
assert.deepEqual(results.map((r) => r.status).sort(), [201, 403]);
|
||
assert.equal(fake.rows('gallery').length, 1);
|
||
});
|
||
|
||
test('Veröffentlichen: scheitert das Festlegen der Dateirechte, wird die Zeile zurückgenommen', async () => {
|
||
const { post, fake } = await setup();
|
||
seedUploads(fake);
|
||
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/prev1'), { status: 500 });
|
||
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||
assert.equal(res.status, 502);
|
||
assert.equal(fake.rows('gallery').length, 0, 'keine halbfertige Zeile, die eine Wiederholung mit 409 blockiert');
|
||
|
||
// Datei verschwindet zwischen Prüfung und Festlegen: 400 und ebenfalls zurückgenommen
|
||
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/img1'), { status: 404, type: 'storage_file_not_found' });
|
||
res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||
assert.equal(res.status, 400);
|
||
assert.equal(res.json.error, 'Bild nicht gefunden.');
|
||
assert.equal(fake.rows('gallery').length, 0);
|
||
|
||
// danach geht ein neuer Versuch
|
||
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201);
|
||
});
|
||
|
||
test('Dateirechte setzen wartet auf beide Aufrufe, auch wenn einer scheitert (Sperre bleibt bis dahin)', async () => {
|
||
const { post, fake } = await setup();
|
||
seedCard(fake);
|
||
fake.delayNext((c) => c.method === 'PUT' && c.path.endsWith('/files/card-1-img'), 150);
|
||
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/card-1-prev'), { status: 500 });
|
||
|
||
const res = await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, JWT_ADMIN);
|
||
assert.equal(res.status, 502);
|
||
// Die Antwort kam erst, nachdem auch der langsame PUT durch war.
|
||
assert.deepEqual(fake.file('card-1-img').$permissions, ['read("user:user-me")', 'read("label:admin")', 'delete("user:user-me")']);
|
||
assert.deepEqual(fake.file('card-1-prev').$permissions, ['read("any")', 'delete("user:user-me")']);
|
||
assert.equal(fake.row('gallery', 'card-1').hidden, false, 'die Zeile wird erst nach den Dateien geändert');
|
||
});
|
||
|
||
test('Ein gültiges JWT nur in Authorization oder X-Appwrite-JWT wird ignoriert (401)', async () => {
|
||
const { post, fake } = await setup();
|
||
seedUploads(fake);
|
||
seedCard(fake);
|
||
const ignored = [
|
||
{ Authorization: `Bearer ${JWT_ME}` },
|
||
{ 'X-Appwrite-JWT': JWT_ME },
|
||
{ 'X-Appwrite-JWT': JWT_ADMIN, Authorization: `Bearer ${JWT_ADMIN}` },
|
||
];
|
||
for (const headers of ignored) {
|
||
assert.equal((await post('/api/veroeffentlichen', publishBody(), null, headers)).status, 401);
|
||
assert.equal((await post('/api/zurueckziehen', { id: 'card-1' }, null, headers)).status, 401);
|
||
assert.equal((await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, null, headers)).status, 401);
|
||
}
|
||
assert.equal(fake.calls.filter((c) => c.path === '/v1/account').length, 0, 'Appwrite wurde nie nach dem Konto gefragt');
|
||
assert.equal(fake.rows('gallery').length, 1);
|
||
assert.ok(fake.row('gallery', 'card-1'));
|
||
});
|
||
|
||
test('Konto-Antwort mit ungültiger $id ergibt 401, ohne labels oder mit falschem Typ ist die Person kein Admin', async () => {
|
||
const { post, fake } = await setup();
|
||
seedCard(fake);
|
||
for (const [jwt, account] of [
|
||
['bad.id.one', { $id: 'x","read("any' }],
|
||
['bad.id.two', { $id: 42 }],
|
||
['bad.id.three', { $id: '../x' }],
|
||
['bad.id.four', { $id: '' }],
|
||
['bad.id.five', {}],
|
||
['bad.id.six', { $id: 'a'.repeat(37) }],
|
||
]) {
|
||
fake.addRawUser(jwt, account);
|
||
assert.equal((await post('/api/zurueckziehen', { id: 'card-1' }, jwt)).status, 401, JSON.stringify(account));
|
||
}
|
||
assert.ok(fake.row('gallery', 'card-1'));
|
||
|
||
fake.addRawUser('no.labels.jwt', { $id: 'user-nolabels' });
|
||
fake.addRawUser('string.labels.jwt', { $id: 'user-stringlabels', labels: 'admin' });
|
||
fake.addRawUser('object.labels.jwt', { $id: 'user-objectlabels', labels: { admin: true } });
|
||
for (const jwt of ['no.labels.jwt', 'string.labels.jwt', 'object.labels.jwt']) {
|
||
assert.equal((await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, jwt)).status, 403, jwt);
|
||
assert.equal((await post('/api/zurueckziehen', { id: 'card-1' }, jwt)).status, 403, jwt);
|
||
}
|
||
assert.equal(fake.row('gallery', 'card-1').hidden, false);
|
||
});
|
||
|
||
test('Unerwartete Fehler im Appwrite-Objekt ergeben 500, ohne die Anfrage hängen zu lassen', async () => {
|
||
const logs = [];
|
||
const broken = {
|
||
accountFromJwt: async () => {
|
||
throw new Error('kaputt GEHEIM');
|
||
},
|
||
};
|
||
const app = createApp({ appwrite: broken, salt: SALT, log: (line) => logs.push(line) });
|
||
const server = http.createServer(app);
|
||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||
cleanups.push(() => {
|
||
server.closeAllConnections();
|
||
return new Promise((resolve) => server.close(resolve));
|
||
});
|
||
const res = await request(server.address().port, 'POST', '/api/zurueckziehen', {
|
||
body: { id: 'x' },
|
||
headers: { 'X-Aircard-Jwt': JWT_ME },
|
||
});
|
||
assert.equal(res.status, 500);
|
||
assert.equal(typeof res.json.error, 'string');
|
||
assert.ok(!logs.join('\n').includes('GEHEIM'), 'Fehlertexte werden nicht ins Log übernommen');
|
||
});
|
||
|
||
test('createApp verlangt Appwrite-Objekt und Salz', () => {
|
||
assert.throws(() => createApp({ salt: SALT }));
|
||
assert.throws(() => createApp({ appwrite: {}, salt: '' }));
|
||
assert.throws(() => createApp({ appwrite: {} }));
|
||
});
|