'use strict'; /** * Nur für Tests: Appwrite im Speicher (Teilmenge der REST-API von 1.8.1) als echter HTTP-Server auf Port 0. * * GET /v1/account Header X-Appwrite-JWT (+ Projekt, ohne Key) * GET|POST /v1/tablesdb/{db}/tables/{t}/rows POST {rowId, data, permissions}; doppelt -> 409 * GET|PATCH|DELETE .../rows/{id} PATCH {data?, permissions?} * GET|PUT|DELETE /v1/storage/buckets/{b}/files/{f} PUT {permissions} * GET /v1/storage/buckets/{b}/files/{f}/view Dateiinhalt (Bytes aus `content`) * * Die Tabelle `cards` ist nur zum Lesen da (Zeilen legt der Test mit `addCard` an). Es gibt außerdem die Tabellen * `gallery`, `reports`, `logos`, `logo_likes` und `logo_reports` und die Buckets `gallery-images`, `card-images`, `logo-images`. * * Abfragen: `queries[]=` mit den Methoden equal, cursorAfter und limit. Die Antwort enthält wie bei Appwrite `total`: * die Zahl aller Treffer der equal-Abfragen, unabhängig von limit und cursorAfter (darauf baut `countRows`). * Zeilen werden gegen die Spaltenlängen der echten Tabellen geprüft (document_invalid_structure), damit zu lange Werte im * Test auffallen. * Dateien werden nach ID gesucht und liefern ihren echten `bucketId` zurück (der Bucket in der URL zählt nicht), * so lässt sich der Fall „Datei liegt in einem anderen Bucket“ nachstellen. */ const http = require('node:http'); const BUCKETS = ['gallery-images', 'card-images', 'logo-images']; /** PNG-Anfang (Signatur + IHDR) mit den Maßen; reicht für die Größenprüfung der API. */ function pngHeader(width, height) { const head = Buffer.alloc(33); Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(head, 0); head.writeUInt32BE(13, 8); head.write('IHDR', 12, 'latin1'); head.writeUInt32BE(width, 16); head.writeUInt32BE(height, 20); head[24] = 8; // Bit-Tiefe head[25] = 6; // RGBA return head; } /** WebP-Anfang mit den Maßen; kind: 'VP8 ' (verlustbehaftet), 'VP8L' (verlustfrei) oder 'VP8X' (erweitert). */ function webpHeader(kind, width, height) { const head = Buffer.alloc(40); head.write('RIFF', 0, 'latin1'); head.writeUInt32LE(32, 4); head.write('WEBP', 8, 'latin1'); head.write(kind, 12, 'latin1'); if (kind === 'VP8 ') { head.writeUInt32LE(10, 16); Buffer.from([0x30, 0x01, 0x00, 0x9d, 0x01, 0x2a]).copy(head, 20); head.writeUInt16LE(width & 0x3fff, 26); head.writeUInt16LE(height & 0x3fff, 28); } else if (kind === 'VP8L') { head.writeUInt32LE(5, 16); head[20] = 0x2f; head.writeUInt32LE(((width - 1) & 0x3fff) | (((height - 1) & 0x3fff) << 14), 21); } else if (kind === 'VP8X') { head.writeUInt32LE(10, 16); head[20] = 0x10; // Alpha-Flag head.writeUIntLE(width - 1, 24, 3); head.writeUIntLE(height - 1, 27, 3); } else { throw new Error(`Unbekannte WebP-Art: ${kind}`); } return head; } const COLUMNS = { gallery: { title: { size: 60, required: true }, issuer: { size: 40 }, tags: { size: 120 }, displayName: { size: 30 }, imageFileId: { size: 36, required: true }, previewFileId: { size: 36, required: true }, sourceCardId: { size: 36 }, ownerId: { size: 36, required: true }, hidden: { type: 'boolean' }, reportCount: { type: 'integer' }, searchText: { size: 300 }, }, reports: { galleryId: { size: 36, required: true }, reason: { enum: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true }, reporterHash: { size: 64, required: true }, }, // Spalten wie in web/scripts/appwrite-setup.mjs (Community-Logos) logos: { name: { size: 40, required: true }, displayName: { size: 30 }, fileId: { size: 36, required: true }, ownerId: { size: 36, required: true }, mono: { type: 'boolean' }, likeCount: { type: 'integer', min: 0 }, hidden: { type: 'boolean' }, reportCount: { type: 'integer', min: 0 }, }, logo_likes: { logoId: { size: 36, required: true }, userId: { size: 36, required: true }, }, logo_reports: { logoId: { size: 36, required: true }, reason: { enum: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true }, reporterHash: { size: 64, required: true }, }, }; /** Standardwerte der Spalten, die Appwrite beim Anlegen einsetzt, wenn die Anfrage sie weglässt. */ const DEFAULTS = { gallery: { hidden: false, reportCount: 0 }, logos: { mono: false, likeCount: 0, hidden: false, reportCount: 0 }, }; async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret-value', database = 'aircard' } = {}) { const tables = { gallery: new Map(), reports: new Map(), cards: new Map(), logos: new Map(), logo_likes: new Map(), logo_reports: new Map(), }; const files = new Map(); const contents = new Map(); const users = new Map(); const calls = []; const injections = []; let sequence = 0; function addUser(jwt, { id, labels = [] }) { users.set(jwt, { $id: id, email: `${id}@example.test`, labels }); } /** Beliebige Konto-Antwort für `GET /account` (z. B. ohne labels oder mit ungültiger ID). */ function addRawUser(jwt, account) { users.set(jwt, account); } /** * Datei mit Metadaten und Inhalt. `role` bestimmt den Standardinhalt: 'image' = Kartenbild 1536 x 969, 'preview' = * Vorschau 480 x 303 (Standard: WebP ist die Vorschau, alles andere das Bild); `content` überschreibt ihn. */ function addFile({ id, bucket = 'gallery-images', owner, permissions, mimeType = 'image/png', size = 1000, role, content }) { const kind = role || (mimeType === 'image/webp' ? 'preview' : 'image'); const [width, height] = kind === 'preview' ? [480, 303] : [1536, 969]; files.set(id, { $id: id, bucketId: bucket, name: `${id}.png`, $permissions: permissions || ['read("any")', `delete("user:${owner}")`], mimeType, sizeOriginal: size, }); contents.set(id, content || (mimeType === 'image/webp' ? webpHeader('VP8 ', width, height) : pngHeader(width, height))); } /** Private Karte (Tabelle `cards`) mit den Rechten, die Appwrite dem Besitzer beim Anlegen gibt. */ function addCard(id, owner, permissions) { return addRow('cards', id, { name: 'Karte' }, permissions || [`read("user:${owner}")`, `update("user:${owner}")`, `delete("user:${owner}")`]); } function addRow(table, id, data, permissions) { const row = { $id: id, $createdAt: new Date(1_700_000_000_000 + (++sequence) * 1000).toISOString(), $permissions: permissions, ...data }; tables[table].set(id, row); return row; } /** Nächster passender Aufruf (predicate(call) -> boolean) scheitert mit Status/Typ. */ function failNext(predicate, { status = 500, type = 'general_server_error' } = {}) { injections.push({ predicate, status, type, hang: false }); } /** Nächster passender Aufruf bekommt nie eine Antwort. */ function hangNext(predicate) { injections.push({ predicate, hang: true }); } /** Nächster passender Aufruf wird erst nach `delayMs` bearbeitet (Zustand ändert sich erst dann). */ function delayNext(predicate, delayMs) { injections.push({ predicate, delayMs }); } function validateData(table, data, { partial }) { const columns = COLUMNS[table]; for (const [name, value] of Object.entries(data)) { const column = columns[name]; if (!column) return `Unknown attribute: ${name}`; if (column.type === 'boolean' && typeof value !== 'boolean') return `Invalid ${name}`; if (column.type === 'integer' && (!Number.isInteger(value) || (column.min !== undefined && value < column.min))) return `Invalid ${name}`; if (column.enum && !column.enum.includes(value)) return `Invalid ${name}`; if (column.size !== undefined) { if (typeof value !== 'string') return `Invalid ${name}`; if (value.length > column.size) return `Invalid ${name}: too long`; } } if (!partial) { for (const [name, column] of Object.entries(columns)) { if (column.required && data[name] === undefined) return `Missing required attribute: ${name}`; } } return null; } const server = http.createServer((req, res) => { const chunks = []; req.on('data', (chunk) => chunks.push(chunk)); req.on('end', async () => { const raw = Buffer.concat(chunks).toString('utf8'); const [pathname, search = ''] = req.url.split('?'); const call = { method: req.method, path: pathname, search, headers: req.headers, body: raw === '' ? null : safeParse(raw), }; calls.push(call); const reply = (status, payload) => { if (status === 204) { res.writeHead(204); res.end(); return; } const text = JSON.stringify(payload); res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(text) }); res.end(text); }; const error = (status, type, message = type) => reply(status, { message, code: status, type }); const injectionIndex = injections.findIndex((entry) => entry.predicate(call)); if (injectionIndex !== -1) { const [entry] = injections.splice(injectionIndex, 1); if (entry.hang) return; if (entry.delayMs) { await new Promise((resolve) => setTimeout(resolve, entry.delayMs)); } else { return error(entry.status, entry.type); } } const segments = pathname.split('/').filter(Boolean).map(decodeSegment); if (segments[0] !== 'v1' || req.headers['x-appwrite-project'] !== project) { return error(401, 'general_unauthorized_scope', 'Missing or wrong project'); } // GET /v1/account (JWT, ohne Key) if (segments.length === 2 && segments[1] === 'account' && req.method === 'GET') { const user = users.get(req.headers['x-appwrite-jwt']); if (!user) return error(401, 'user_jwt_invalid', 'Invalid JWT'); return reply(200, user); } if (req.headers['x-appwrite-key'] !== key) return error(401, 'general_unauthorized_scope', 'Missing or wrong key'); // Zeilen if (segments[1] === 'tablesdb' && segments[3] === 'tables' && segments[5] === 'rows') { const table = segments[4]; if (segments[2] !== database) return error(404, 'database_not_found'); if (!tables[table]) return error(404, 'table_not_found'); const store = tables[table]; if (segments.length === 6 && req.method === 'POST') { const body = call.body || {}; if (typeof body.rowId !== 'string' || body.rowId === '') return error(400, 'general_argument_invalid', 'rowId is required'); if (store.has(body.rowId)) return error(409, 'row_already_exists'); const data = body.data || {}; const problem = validateData(table, data, { partial: false }); if (problem) return error(400, 'document_invalid_structure', problem); const row = addRow(table, body.rowId, { ...DEFAULTS[table], ...data }, body.permissions || []); return reply(201, row); } if (segments.length === 6 && req.method === 'GET') { const found = runQueries([...store.values()], search); if (found === null) return error(400, 'general_query_invalid'); return reply(200, found); } if (segments.length === 7) { const row = store.get(segments[6]); if (!row) return error(404, 'row_not_found'); if (req.method === 'GET') return reply(200, row); if (req.method === 'DELETE') { store.delete(segments[6]); return reply(204); } if (req.method === 'PATCH') { const body = call.body || {}; const data = body.data || {}; const problem = validateData(table, data, { partial: true }); if (problem) return error(400, 'document_invalid_structure', problem); Object.assign(row, data); if (Array.isArray(body.permissions)) row.$permissions = body.permissions; return reply(200, row); } } } // Dateiinhalt: GET .../files/{id}/view if (segments[1] === 'storage' && segments[2] === 'buckets' && segments[4] === 'files' && segments.length === 7 && segments[6] === 'view' && req.method === 'GET') { if (!BUCKETS.includes(segments[3])) return error(404, 'storage_bucket_not_found'); const file = files.get(segments[5]); if (!file) return error(404, 'storage_file_not_found'); const content = contents.get(segments[5]); res.writeHead(200, { 'Content-Type': file.mimeType, 'Content-Length': content.length }); res.end(content); return; } // Dateien if (segments[1] === 'storage' && segments[2] === 'buckets' && segments[4] === 'files' && segments.length === 6) { if (!BUCKETS.includes(segments[3])) return error(404, 'storage_bucket_not_found'); const file = files.get(segments[5]); if (!file) return error(404, 'storage_file_not_found'); if (req.method === 'GET') return reply(200, file); if (req.method === 'DELETE') { files.delete(segments[5]); contents.delete(segments[5]); return reply(204); } if (req.method === 'PUT') { const body = call.body || {}; if (!Array.isArray(body.permissions)) return error(400, 'general_argument_invalid'); file.$permissions = body.permissions; return reply(200, file); } } return error(404, 'general_route_not_found'); }); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const { port } = server.address(); return { url: `http://127.0.0.1:${port}`, endpoint: `http://127.0.0.1:${port}/v1`, project, key, calls, addUser, addRawUser, addFile, addCard, addRow, failNext, hangNext, delayNext, row: (table, id) => tables[table].get(id) || null, rows: (table) => [...tables[table].values()], file: (id) => files.get(id) || null, close: async () => { server.closeAllConnections(); await new Promise((resolve) => server.close(resolve)); }, }; } function decodeSegment(segment) { try { return decodeURIComponent(segment); } catch { return segment; } } function safeParse(text) { try { return JSON.parse(text); } catch { return { _unparsable: text }; } } /** * Wendet equal, cursorAfter und limit an (Reihenfolge = Anlegereihenfolge); null bei unbekannter Abfrage. * Liefert {total, rows}: `total` zählt alle Treffer der equal-Abfragen, `rows` ist die Seite nach cursorAfter und limit. */ function runQueries(rows, search) { let result = rows; let limit = 25; let cursor = null; for (const part of search.split('&').filter(Boolean)) { const eq = part.indexOf('='); const name = decodeURIComponent(part.slice(0, eq)); if (name !== 'queries[]') return null; let query; try { query = JSON.parse(decodeURIComponent(part.slice(eq + 1))); } catch { return null; } if (query.method === 'equal') { result = result.filter((row) => query.values.includes(row[query.attribute])); } else if (query.method === 'limit') { limit = query.values[0]; } else if (query.method === 'cursorAfter') { cursor = query.values[0]; } else { return null; } } const total = result.length; if (cursor !== null) { const index = result.findIndex((row) => row.$id === cursor); if (index === -1) return null; result = result.slice(index + 1); } return { total, rows: result.slice(0, limit) }; } module.exports = { startFakeAppwrite, pngHeader, webpHeader };