Files
AirCardIOS/deploy/api/appwrite.test.js
T
ClaudeandClaude Sonnet 5.5 7bbe0a9fe6 fix(api): Dateien bereits veröffentlichter oder gesperrter Karten nicht wiederverwenden
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>
2026-10-04 14:27:29 +00:00

347 lines
16 KiB
JavaScript

'use strict';
// Tests für deploy/api/appwrite.js gegen einen Fake-Appwrite im Speicher (fake-appwrite.js).
// Aufruf: node --test "deploy/api/*.test.js"
const { test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const { createAppwrite, AppwriteError, queryEqual, queryLimit, queryCursorAfter } = require('./appwrite.js');
const { startFakeAppwrite } = require('./fake-appwrite.js');
const fakes = [];
afterEach(async () => {
for (const fake of fakes.splice(0)) await fake.close();
});
async function setup(options = {}) {
const fake = await startFakeAppwrite();
fakes.push(fake);
const appwrite = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key, ...options });
return { fake, appwrite };
}
const lastCall = (fake) => fake.calls[fake.calls.length - 1];
test('Zeilen: anlegen, lesen, ändern, löschen mit Projekt- und Key-Header', async () => {
const { fake, appwrite } = await setup();
const created = await appwrite.createRow('gallery', 'row1', { title: 'T', imageFileId: 'i', previewFileId: 'p', ownerId: 'o' }, ['read("any")']);
assert.equal(created.$id, 'row1');
assert.equal(lastCall(fake).method, 'POST');
assert.equal(lastCall(fake).path, '/v1/tablesdb/aircard/tables/gallery/rows');
assert.deepEqual(lastCall(fake).body, {
rowId: 'row1',
data: { title: 'T', imageFileId: 'i', previewFileId: 'p', ownerId: 'o' },
permissions: ['read("any")'],
});
assert.equal(lastCall(fake).headers['x-appwrite-project'], fake.project);
assert.equal(lastCall(fake).headers['x-appwrite-key'], fake.key);
assert.match(lastCall(fake).headers['content-type'], /^application\/json/);
assert.equal((await appwrite.getRow('gallery', 'row1')).title, 'T');
assert.equal(lastCall(fake).path, '/v1/tablesdb/aircard/tables/gallery/rows/row1');
await appwrite.updateRow('gallery', 'row1', { hidden: true });
assert.equal(lastCall(fake).method, 'PATCH');
assert.deepEqual(lastCall(fake).body, { data: { hidden: true } }, 'ohne permissions-Feld, wenn keine übergeben werden');
assert.deepEqual(fake.row('gallery', 'row1').$permissions, ['read("any")']);
await appwrite.updateRow('gallery', 'row1', { reportCount: 3 }, ['read("user:o")']);
assert.deepEqual(lastCall(fake).body, { data: { reportCount: 3 }, permissions: ['read("user:o")'] });
assert.deepEqual(fake.row('gallery', 'row1').$permissions, ['read("user:o")']);
assert.equal(await appwrite.deleteRow('gallery', 'row1'), true);
assert.equal(fake.row('gallery', 'row1'), null);
});
test('Fehlende Zeile/Datei: getRow und getFile liefern null, deleteRow/deleteFile/updateFilePermissions sind tolerant', async () => {
const { appwrite } = await setup();
assert.equal(await appwrite.getRow('gallery', 'nope'), null);
assert.equal(await appwrite.getFile('gallery-images', 'nope'), null);
assert.equal(await appwrite.deleteRow('gallery', 'nope'), false);
assert.equal(await appwrite.deleteFile('gallery-images', 'nope'), false);
assert.equal(await appwrite.updateFilePermissions('gallery-images', 'nope', ['read("any")']), null);
});
test('Doppelte Zeilen-ID wirft AppwriteError mit Status 409 und Typ row_already_exists', async () => {
const { appwrite } = await setup();
const data = { galleryId: 'g', reason: 'spam', reporterHash: 'h' };
await appwrite.createRow('reports', 'r1', data, []);
await assert.rejects(
() => appwrite.createRow('reports', 'r1', data, []),
(err) => err instanceof AppwriteError && err.status === 409 && err.type === 'row_already_exists',
);
});
test('Andere Fehler (400, 401, 500) werden als AppwriteError geworfen', async () => {
const { fake, appwrite } = await setup();
await assert.rejects(
() => appwrite.createRow('gallery', 'x', { title: 'x'.repeat(61), imageFileId: 'i', previewFileId: 'p', ownerId: 'o' }, []),
(err) => err instanceof AppwriteError && err.status === 400 && err.type === 'document_invalid_structure',
);
fake.failNext((call) => call.method === 'GET', { status: 500, type: 'general_server_error' });
await assert.rejects(
() => appwrite.getRow('gallery', 'x'),
(err) => err instanceof AppwriteError && err.status === 500,
);
const wrongKey = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: 'falscher-key' });
await assert.rejects(
() => wrongKey.getRow('gallery', 'x'),
(err) => err instanceof AppwriteError && err.status === 401,
);
});
test('listRows schickt die Queries als queries[]-Parameter und liefert die Zeilen', async () => {
const { fake, appwrite } = await setup();
for (const id of ['a', 'b', 'c']) {
await appwrite.createRow('reports', id, { galleryId: id === 'c' ? 'other' : 'g1', reason: 'spam', reporterHash: id }, []);
}
const rows = await appwrite.listRows('reports', [queryEqual('galleryId', ['g1']), queryLimit(100)]);
assert.deepEqual(rows.map((row) => row.$id).sort(), ['a', 'b']);
const { search } = lastCall(fake);
const params = search.split('&').map((part) => decodeURIComponent(part));
assert.deepEqual(params, [
'queries[]={"method":"equal","attribute":"galleryId","values":["g1"]}',
'queries[]={"method":"limit","values":[100]}',
]);
assert.equal(lastCall(fake).headers['x-appwrite-key'], fake.key);
});
test('queryEqual, queryLimit und queryCursorAfter liefern Appwrite-JSON-Strings', () => {
assert.equal(queryEqual('galleryId', ['x']), '{"method":"equal","attribute":"galleryId","values":["x"]}');
assert.equal(queryLimit(100), '{"method":"limit","values":[100]}');
assert.equal(queryCursorAfter('abc'), '{"method":"cursorAfter","values":["abc"]}');
});
test('listRows blättert mit cursorAfter', async () => {
const { appwrite } = await setup();
for (const id of ['a', 'b', 'c', 'd', 'e']) {
await appwrite.createRow('reports', id, { galleryId: 'g1', reason: 'spam', reporterHash: id }, []);
}
const first = await appwrite.listRows('reports', [queryEqual('galleryId', ['g1']), queryLimit(2)]);
assert.deepEqual(first.map((row) => row.$id), ['a', 'b']);
const second = await appwrite.listRows('reports', [queryEqual('galleryId', ['g1']), queryLimit(2), queryCursorAfter('b')]);
assert.deepEqual(second.map((row) => row.$id), ['c', 'd']);
const last = await appwrite.listRows('reports', [queryEqual('galleryId', ['g1']), queryLimit(2), queryCursorAfter('d')]);
assert.deepEqual(last.map((row) => row.$id), ['e']);
});
test('Dateien: lesen, Rechte setzen, löschen', async () => {
const { fake, appwrite } = await setup();
fake.addFile({ id: 'f1', owner: 'u1', mimeType: 'image/webp', size: 1234 });
const file = await appwrite.getFile('gallery-images', 'f1');
assert.equal(file.mimeType, 'image/webp');
assert.equal(file.sizeOriginal, 1234);
assert.equal(file.bucketId, 'gallery-images');
assert.deepEqual(file.$permissions, ['read("any")', 'delete("user:u1")']);
assert.equal(lastCall(fake).path, '/v1/storage/buckets/gallery-images/files/f1');
await appwrite.updateFilePermissions('gallery-images', 'f1', ['read("user:u1")']);
assert.equal(lastCall(fake).method, 'PUT');
assert.deepEqual(lastCall(fake).body, { permissions: ['read("user:u1")'] });
assert.deepEqual(fake.file('f1').$permissions, ['read("user:u1")']);
assert.equal(await appwrite.deleteFile('gallery-images', 'f1'), true);
assert.equal(fake.file('f1'), null);
});
test('accountFromJwt: JWT-Header ohne Key; 401 gibt null, andere Fehler werfen', async () => {
const { fake, appwrite } = await setup();
fake.addUser('aaa.bbb.ccc', { id: 'u1', labels: ['admin'] });
const user = await appwrite.accountFromJwt('aaa.bbb.ccc');
assert.equal(user.$id, 'u1');
assert.deepEqual(user.labels, ['admin']);
const call = lastCall(fake);
assert.equal(call.path, '/v1/account');
assert.equal(call.headers['x-appwrite-jwt'], 'aaa.bbb.ccc');
assert.equal(call.headers['x-appwrite-project'], fake.project);
assert.equal(call.headers['x-appwrite-key'], undefined, 'das Konto wird nie mit dem Server-Key abgefragt');
assert.equal(await appwrite.accountFromJwt('xxx.yyy.zzz'), null);
fake.failNext((c) => c.path === '/v1/account', { status: 500 });
await assert.rejects(() => appwrite.accountFromJwt('aaa.bbb.ccc'), (err) => err instanceof AppwriteError && err.status === 500);
});
test('Pfadsegmente: jede ID wird kodiert, "." und ".." werden abgelehnt, bevor eine Anfrage rausgeht', async () => {
const { fake, appwrite } = await setup();
await appwrite.getRow('gallery', '../x');
assert.equal(lastCall(fake).path, '/v1/tablesdb/aircard/tables/gallery/rows/..%2Fx');
await appwrite.getFile('gallery-images', 'a b/c');
assert.equal(lastCall(fake).path, '/v1/storage/buckets/gallery-images/files/a%20b%2Fc');
const before = fake.calls.length;
for (const bad of ['..', '.', '', 42, null, undefined]) {
await assert.rejects(() => appwrite.getRow('gallery', bad), /Pfadsegment/);
await assert.rejects(() => appwrite.deleteFile('gallery-images', bad), /Pfadsegment/);
}
await assert.rejects(() => appwrite.getRow('..', 'x'), /Pfadsegment/);
assert.equal(fake.calls.length, before, 'es ging keine Anfrage raus');
});
test('Zeitlimit: Appwrite-Aufrufe tragen standardmäßig AbortSignal.timeout(10000)', async () => {
let seenSignal = null;
const appwrite = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async (url, init) => {
seenSignal = init.signal;
return new Response(JSON.stringify({ $id: 'x' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
},
});
await appwrite.getRow('gallery', 'x');
assert.ok(seenSignal instanceof AbortSignal);
assert.equal(seenSignal.aborted, false);
});
test('Zeitlimit: hängender Appwrite-Aufruf endet mit AppwriteError (timeout)', async () => {
const { fake, appwrite } = await setup({ timeoutMs: 80 });
fake.hangNext((call) => call.path.endsWith('/rows/slow'));
await assert.rejects(
() => appwrite.getRow('gallery', 'slow'),
(err) => err instanceof AppwriteError && err.type === 'timeout' && err.status === 0,
);
});
test('Netzfehler: AppwriteError (unreachable) ohne Key im Text', async () => {
const appwrite = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'GEHEIMER-KEY-123',
fetchImpl: async () => {
throw new TypeError('fetch failed GEHEIMER-KEY-123');
},
});
await assert.rejects(
() => appwrite.getRow('gallery', 'x'),
(err) => err instanceof AppwriteError && err.type === 'unreachable' && !String(err.message).includes('GEHEIMER-KEY-123'),
);
});
test('404 gilt nur mit dem erwarteten Typ als „fehlt“, jedes andere 404 ist ein Fehler', async () => {
const { fake, appwrite } = await setup();
const rejectsWith404 = (promise, type) =>
assert.rejects(promise, (err) => err instanceof AppwriteError && err.status === 404 && err.type === type);
// erwartete Typen: fehlt
assert.equal(await appwrite.getRow('gallery', 'x'), null);
assert.equal(await appwrite.deleteRow('gallery', 'x'), false);
assert.equal(await appwrite.getFile('gallery-images', 'x'), null);
assert.equal(await appwrite.deleteFile('gallery-images', 'x'), false);
assert.equal(await appwrite.updateFilePermissions('gallery-images', 'x', []), null);
// unbekannte Tabelle / Datenbank / Bucket: Fehler
await rejectsWith404(appwrite.getRow('keine-tabelle', 'x'), 'table_not_found');
await rejectsWith404(appwrite.deleteRow('keine-tabelle', 'x'), 'table_not_found');
await rejectsWith404(appwrite.listRows('keine-tabelle', []), 'table_not_found');
await rejectsWith404(appwrite.getFile('kein-bucket', 'x'), 'storage_bucket_not_found');
await rejectsWith404(appwrite.updateFilePermissions('kein-bucket', 'x', []), 'storage_bucket_not_found');
await rejectsWith404(appwrite.deleteFile('kein-bucket', 'x'), 'storage_bucket_not_found');
const otherDatabase = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key, database: 'andere-db' });
await rejectsWith404(otherDatabase.getRow('gallery', 'x'), 'database_not_found');
// Typ von der falschen Art (Zeile statt Datei und umgekehrt) und unbekannte Route
fake.failNext((c) => c.method === 'GET', { status: 404, type: 'storage_file_not_found' });
await rejectsWith404(appwrite.getRow('gallery', 'x'), 'storage_file_not_found');
fake.failNext((c) => c.method === 'GET', { status: 404, type: 'row_not_found' });
await rejectsWith404(appwrite.getFile('gallery-images', 'x'), 'row_not_found');
fake.failNext((c) => c.method === 'GET', { status: 404, type: 'general_route_not_found' });
await rejectsWith404(appwrite.getRow('gallery', 'x'), 'general_route_not_found');
});
test('404 ohne JSON-Antwort (z. B. von einem Proxy) ist ein Fehler, kein „fehlt“', async () => {
const appwrite = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async () => new Response('404 page not found', { status: 404 }),
});
for (const call of [
() => appwrite.getRow('gallery', 'x'),
() => appwrite.getFile('gallery-images', 'x'),
() => appwrite.deleteRow('gallery', 'x'),
() => appwrite.deleteFile('gallery-images', 'x'),
() => appwrite.updateFilePermissions('gallery-images', 'x', []),
]) {
await assert.rejects(call, (err) => err instanceof AppwriteError && err.status === 404 && err.type === 'unknown');
}
});
test('listRows: eine Antwort ohne rows-Array wirft einen AppwriteError statt still [] zu liefern', async () => {
for (const payload of [{ total: 0 }, { rows: 'x' }, { rows: null }, [], 'text', null]) {
const appwrite = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async () => new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
await assert.rejects(
() => appwrite.listRows('reports', []),
(err) => err instanceof AppwriteError && err.type === 'invalid_response' && err.status === 502,
JSON.stringify(payload),
);
}
const empty = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async () => new Response(JSON.stringify({ total: 0, rows: [] }), { status: 200 }),
});
assert.deepEqual(await empty.listRows('reports', []), [], 'eine echte leere Liste bleibt eine leere Liste');
});
test('Alle Appwrite-Aufrufe verwenden redirect: "error" (der Key folgt keiner Weiterleitung)', async () => {
const seen = [];
const appwrite = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async (url, init) => {
seen.push(init.redirect);
return new Response(JSON.stringify({ rows: [], $id: 'x' }), { status: 200 });
},
});
await appwrite.getRow('gallery', 'x');
await appwrite.listRows('gallery', []);
await appwrite.createRow('gallery', 'x', {}, []);
await appwrite.updateRow('gallery', 'x', {});
await appwrite.deleteRow('gallery', 'x');
await appwrite.getFile('gallery-images', 'x');
await appwrite.updateFilePermissions('gallery-images', 'x', []);
await appwrite.deleteFile('gallery-images', 'x');
await appwrite.accountFromJwt('a.b.c');
assert.equal(seen.length, 9);
assert.ok(seen.every((value) => value === 'error'));
});
test('Eine Weiterleitung von Appwrite wird nicht verfolgt (echter Server antwortet 302)', async () => {
const target = http.createServer((req, res) => {
target.hits = (target.hits || 0) + 1;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('{}');
});
await new Promise((resolve) => target.listen(0, '127.0.0.1', resolve));
const redirector = http.createServer((req, res) => {
res.writeHead(302, { Location: `http://127.0.0.1:${target.address().port}/gesammelt` });
res.end();
});
await new Promise((resolve) => redirector.listen(0, '127.0.0.1', resolve));
try {
const appwrite = createAppwrite({ endpoint: `http://127.0.0.1:${redirector.address().port}/v1`, project: 'p', key: 'GEHEIMER-KEY' });
await assert.rejects(() => appwrite.getRow('gallery', 'x'), (err) => err instanceof AppwriteError && err.type === 'unreachable');
assert.equal(target.hits || 0, 0, 'das Ziel der Weiterleitung wurde nie angefragt');
} finally {
redirector.closeAllConnections();
target.closeAllConnections();
await new Promise((resolve) => redirector.close(resolve));
await new Promise((resolve) => target.close(resolve));
}
});