diff --git a/docs/appwrite-console.md b/docs/appwrite-console.md new file mode 100644 index 0000000..2b3a7d7 --- /dev/null +++ b/docs/appwrite-console.md @@ -0,0 +1,71 @@ +# Appwrite-Console: Projekt „AirCard“ einrichten + +Klickliste für Kenso. Dauer: etwa 5 Minuten. Console: + +Alles passiert in der bestehenden Organisation. Das Schema (Datenbank, Tabelle, Bucket) legt danach das Skript `web/scripts/appwrite-setup.mjs` an – dafür musst du nichts anklicken. + +## 1. Projekt anlegen + +1. In der Console anmelden und die bestehende Organisation öffnen. +2. **Create project** klicken. +3. Name: `AirCard`. +4. Beim Feld **Project ID** auf das Stift-Symbol (bzw. „Edit“) klicken und `aircard` eintragen. Die ID lässt sich später nicht mehr ändern; sie muss genau `aircard` heißen. +5. **Create** klicken. + +## 2. Web-Plattformen eintragen + +Projekt `AirCard` → **Overview** → **Integrations** → **Platforms** → **Add platform** → **Web**. + +1. Name `AirCard Website`, Hostname `aircard-ios.org` → **Create**. +2. Noch einmal **Add platform** → **Web**: Name `Entwicklung`, Hostname `localhost` → **Create**. + +## 3. Auth-Einstellungen + +Linke Leiste → **Auth** → **Settings**. + +1. Bei **Auth methods** nur **Email/Password** eingeschaltet lassen. Ausschalten: **Magic URL**, **Email OTP**, **Phone**, **Anonymous**, **JWT** (steht dort auch **Invites**: ebenfalls aus). +2. **Session duration**: `30` Tage → **Update**. +3. **Max sessions** (Sitzungen pro Nutzer): `5` → **Update**. +4. **Limit** (Nutzerlimit): `2000` → **Update**. +5. **Password dictionary**: einschalten. + +Keine E-Mail-Bestätigung nötig, Mindestlänge 8 Zeichen ist Standard – dafür ist nichts einzustellen. Die „Passwort vergessen“-Mail geht über den globalen Server-SMTP (Absender WEBklar), auch dafür ist nichts einzustellen. + +## 4. Nicht benötigte Dienste abschalten + +Linke Leiste → **Settings** → Reiter **Services**. + +1. Ausschalten: **Functions**, **Sites**, **Messaging**, **Teams**, **GraphQL**. +2. Alles andere (Account, Avatars, Databases, Locale, Storage …) bleibt an. + +## 5. API-Key `aircard-provision` anlegen + +Dieser Key gehört nur dem Setup-Skript. Die Website selbst braucht ihn nicht. + +1. **Overview** → **Integrations** → **API keys** → **Create API key**. +2. Name: `aircard-provision`. Ablauf (Expiration): **Never**. +3. Unter **Scopes** genau diese acht ankreuzen (Gruppe Database bzw. Storage): + - `databases.read` und `databases.write` + - `tables.read` und `tables.write` + - `columns.read` und `columns.write` + - `buckets.read` und `buckets.write` +4. **Create** klicken. +5. Auf der Seite des neuen Keys den **Secret**-Wert kopieren (Auge-Symbol zum Einblenden, dann kopieren). + +## 6. Key auf dem Server eintragen + +Der Key gehört in die Datei `/home/webklar/apps/.env`, nirgendwo sonst. + +1. Auf dem Server die Datei öffnen, z. B. `nano /home/webklar/apps/.env`. +2. Ans Ende springen und eine neue Zeile anfügen, ohne Anführungszeichen und ohne Leerzeichen um das `=`: + + ``` + AIRCARD_APPWRITE_API_KEY= + ``` + +3. Speichern (in `nano`: Strg+O, Enter, Strg+X). Andere Zeilen nicht verändern. +4. Den Key **nicht im Chat teilen**, nicht in Screenshots zeigen und nirgends einchecken. Claude liest ihn nie selbst, das Skript bekommt ihn über `node --env-file=/home/webklar/apps/.env`. + +## 7. Bescheid geben + +Schreib Claude „Console fertig“. Danach legt Claude Datenbank `aircard`, Tabelle `cards` und Bucket `card-images` mit dem Skript an und prüft sie. diff --git a/web/scripts/appwrite-setup.mjs b/web/scripts/appwrite-setup.mjs new file mode 100644 index 0000000..a891740 --- /dev/null +++ b/web/scripts/appwrite-setup.mjs @@ -0,0 +1,448 @@ +#!/usr/bin/env node +/** + * AirCard – Appwrite-Schema-Setup (idempotent) + * + * Legt im selbst gehosteten Appwrite (Projekt `aircard`) Datenbank, Tabelle + * `cards` (TablesDB) mit vier Spalten und den Bucket `card-images` an. + * Reines `fetch`, keine Abhängigkeiten, Node 22+. + * + * Aufruf (vom Repo-Wurzelverzeichnis): + * node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs [--check] + * + * Umgebung: + * AIRCARD_APPWRITE_API_KEY Projekt-API-Key `aircard-provision` (Pflicht) + * AIRCARD_APPWRITE_ENDPOINT optional, Standard https://appwrite.webklar.com/v1 + * + * Ohne --check: fehlende Objekte werden angelegt. Bestehende Objekte werden + * nur verglichen – Abweichungen werden gemeldet, nie geändert. + * Mit --check: es wird nichts angelegt; Exit 1, wenn etwas fehlt oder abweicht. + * + * Ausgabe: eine Zeile je Objekt – + * OK: … FEHLT: … ANGELEGT: … ABWEICHUNG: ist , erwartet + * + * Exit-Codes: 0 = Schema vollständig, 1 = fehlt/weicht ab, 2 = Abbruch + * (fehlender Key, ungültiger Aufruf, Server-/Zugriffsfehler). + * + * Der API-Key wird nie ausgegeben (weder Wert noch Header). + */ + +import { pathToFileURL } from 'node:url'; + +export const DEFAULT_ENDPOINT = 'https://appwrite.webklar.com/v1'; +const PROJECT = 'aircard'; + +// --------------------------------------------------------------------------- +// Soll-Zustand +// --------------------------------------------------------------------------- + +export const DESIRED = { + database: { id: 'aircard', name: 'AirCard' }, + table: { + id: 'cards', + name: 'Cards', + permissions: ['create("users")'], + rowSecurity: true, + columns: [ + { key: 'name', size: 80, required: true }, + { key: 'config', size: 2000, required: true }, + { key: 'imageFileId', size: 36, required: true }, + { key: 'previewFileId', size: 36, required: true }, + ], + }, + bucket: { + id: 'card-images', + name: 'Card Images', + permissions: ['create("users")'], + fileSecurity: true, + maximumFileSize: 5242880, + allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'], + compression: 'none', + antivirus: false, + }, +}; + +// --------------------------------------------------------------------------- +// Reine Helfer (ohne Netzwerk, einzeln testbar) +// --------------------------------------------------------------------------- + +/** 404 heißt „Objekt fehlt" – außer der ganze Projekt-Kontext fehlt (project_not_found). */ +export function isMissing(status, body) { + return status === 404 && body?.type !== 'project_not_found'; +} + +const fmt = (v) => { + if (typeof v === 'string') return v; + if (Array.isArray(v)) return `[${v.join(', ')}]`; + return JSON.stringify(v) ?? 'undefined'; +}; + +const sorted = (list) => [...(list ?? [])].sort(); +const sameList = (a, b) => JSON.stringify(sorted(a)) === JSON.stringify(sorted(b)); + +function mismatch(field, actual, expected) { + return `${field} ist ${fmt(actual)}, erwartet ${fmt(expected)}`; +} + +/** Vergleich der Tabelle: `$permissions` (Reihenfolge egal) und `rowSecurity`. */ +export function compareTable(actual, desired) { + const out = []; + if (!sameList(actual.$permissions, desired.permissions)) { + out.push(mismatch('$permissions', sorted(actual.$permissions), sorted(desired.permissions))); + } + if (actual.rowSecurity !== desired.rowSecurity) { + out.push(mismatch('rowSecurity', actual.rowSecurity, desired.rowSecurity)); + } + return out; +} + +/** Vergleich einer Spalte: `size`, `required`, `status` (erwartet `available`). */ +export function compareColumn(actual, desired) { + const out = []; + if (actual.size !== desired.size) out.push(mismatch('size', actual.size, desired.size)); + if (actual.required !== desired.required) out.push(mismatch('required', actual.required, desired.required)); + if (actual.status !== 'available') out.push(mismatch('status', actual.status, 'available')); + return out; +} + +/** Vergleich des Buckets: Rechte, `fileSecurity`, `maximumFileSize`, `allowedFileExtensions` (sortiert). */ +export function compareBucket(actual, desired) { + const out = []; + if (!sameList(actual.$permissions, desired.permissions)) { + out.push(mismatch('$permissions', sorted(actual.$permissions), sorted(desired.permissions))); + } + if (actual.fileSecurity !== desired.fileSecurity) { + out.push(mismatch('fileSecurity', actual.fileSecurity, desired.fileSecurity)); + } + if (actual.maximumFileSize !== desired.maximumFileSize) { + out.push(mismatch('maximumFileSize', actual.maximumFileSize, desired.maximumFileSize)); + } + if (!sameList(actual.allowedFileExtensions, desired.allowedFileExtensions)) { + out.push(mismatch('allowedFileExtensions', sorted(actual.allowedFileExtensions), sorted(desired.allowedFileExtensions))); + } + return out; +} + +// --------------------------------------------------------------------------- +// Zugriff auf die Appwrite-REST-API +// --------------------------------------------------------------------------- + +const REQUEST_TIMEOUT_MS = 20_000; + +/** Abbruch mit verständlicher Meldung (Exit 2). Enthält nie den Key. */ +class SetupError extends Error {} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** `api(method, path, body?)` → `{ status, body }` für Aufrufe gegen Endpoint + Pfad. */ +function createApi({ endpoint, key, project }) { + const base = endpoint.replace(/\/+$/, ''); + return async function api(method, path, body) { + let res; + try { + res = await fetch(`${base}${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + 'X-Appwrite-Project': project, + 'X-Appwrite-Key': key, + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (err) { + // Nur Fehlercode bzw. -name ausgeben, nie das Request-Objekt. + throw new SetupError(`Appwrite nicht erreichbar (${base}): ${err?.cause?.code ?? err?.name ?? 'unbekannter Fehler'}.`); + } + const text = await res.text(); + let json; + try { + json = text ? JSON.parse(text) : undefined; + } catch { + json = { message: text.replace(/\s+/g, ' ').slice(0, 200) }; + } + if (typeof json?.message === 'string') json.message = json.message.split(key).join('***'); + return { status: res.status, body: json }; + }; +} + +/** Klare deutsche Meldung für eine unerwartete Antwort. */ +function describeFailure(status, body, what) { + const detail = [body?.type, body?.message].filter((part) => typeof part === 'string' && part).join(': '); + const tail = detail ? ` Server: ${detail}` : ''; + if (status === 404 && body?.type === 'project_not_found') { + return `Das Projekt „${PROJECT}“ existiert auf dem Server nicht. Bitte zuerst docs/appwrite-console.md abarbeiten (Projekt mit der ID „${PROJECT}“ anlegen).`; + } + if (status === 401 || status === 403) { + return `${what}: Zugriff verweigert (HTTP ${status}). API-Key und Scopes prüfen (databases, tables, columns, buckets – jeweils read/write).${tail}`; + } + if (status >= 500) return `${what}: Serverfehler (HTTP ${status}).${tail}`; + return `${what}: unerwartete Antwort (HTTP ${status}).${tail}`; +} + +/** GET: Objekt oder `null` (fehlt). Jeder andere Fehler bricht ab. */ +async function getObject(ctx, path, what) { + const res = await ctx.api('GET', path); + if (res.status === 200) return res.body; + if (isMissing(res.status, res.body)) return null; + throw new SetupError(describeFailure(res.status, res.body, what)); +} + +async function createObject(ctx, path, body, what) { + const res = await ctx.api('POST', path, body); + if (res.status >= 200 && res.status < 300) return res.body; + throw new SetupError(describeFailure(res.status, res.body, what)); +} + +// --------------------------------------------------------------------------- +// Objekte sicherstellen +// --------------------------------------------------------------------------- + +const DB_ID = DESIRED.database.id; +const columnPath = (key) => `/tablesdb/${DB_ID}/tables/${DESIRED.table.id}/columns/${key}`; + +/** Gibt zurück, ob die Datenbank (jetzt) existiert. */ +export async function ensureDatabase(ctx) { + const { id, name } = DESIRED.database; + const label = `Datenbank ${id}`; + if (await getObject(ctx, `/tablesdb/${id}`, label)) { + ctx.ok(label); + return true; + } + if (ctx.check) { + ctx.missing(label); + return false; + } + await createObject(ctx, '/tablesdb', { databaseId: id, name }, label); + ctx.created(label); + return true; +} + +/** Gibt zurück, ob die Tabelle (jetzt) existiert. `parentExists`: Datenbank vorhanden. */ +export async function ensureTable(ctx, parentExists) { + const t = DESIRED.table; + const label = `Tabelle ${t.id}`; + const actual = parentExists ? await getObject(ctx, `/tablesdb/${DB_ID}/tables/${t.id}`, label) : null; + if (actual) { + const deviations = compareTable(actual, t); + if (deviations.length) ctx.deviations(t.id, deviations); + else ctx.ok(label); + return true; + } + if (ctx.check) { + ctx.missing(label); + return false; + } + await createObject( + ctx, + `/tablesdb/${DB_ID}/tables`, + { tableId: t.id, name: t.name, permissions: t.permissions, rowSecurity: t.rowSecurity }, + label, + ); + ctx.created(label); + return true; +} + +/** + * Wartet (bis `ctx.timeoutMs`), bis alle genannten Spalten `available` sind. + * Gibt eine Map Schlüssel → aktueller Spaltenzustand zurück. + */ +export async function waitAvailable(ctx, tableId, keys) { + const deadline = Date.now() + ctx.timeoutMs; + const done = new Map(); + let pending = [...keys]; + for (;;) { + const still = []; + for (const key of pending) { + const label = `Spalte ${tableId}.${key}`; + const actual = await getObject(ctx, columnPath(key), label); + if (!actual) throw new SetupError(`${label} ist beim Warten auf „available“ verschwunden.`); + if (actual.status === 'available') { + done.set(key, actual); + } else if (actual.status === 'failed' || actual.status === 'stuck') { + throw new SetupError(`${label}: Status „${actual.status}“. Spalte in der Console prüfen, ggf. löschen, und das Skript erneut starten.`); + } else { + still.push(key); + } + } + if (!still.length) return done; + if (Date.now() >= deadline) { + throw new SetupError( + `Spalte(n) ${still.map((key) => `${tableId}.${key}`).join(', ')} nicht innerhalb von ${ctx.timeoutMs / 1000} s verfügbar. Das Skript später erneut starten.`, + ); + } + pending = still; + await sleep(ctx.pollMs); + } +} + +/** + * Spalten nacheinander anlegen, danach auf `available` warten – damit sind + * sie bereit, bevor jemand schreibt. `parentExists`: Tabelle vorhanden. + */ +export async function ensureColumns(ctx, parentExists) { + const t = DESIRED.table; + const entries = []; + for (const col of t.columns) { + const label = `Spalte ${t.id}.${col.key}`; + const actual = parentExists ? await getObject(ctx, columnPath(col.key), label) : null; + if (actual) { + entries.push({ col, label, actual, created: false }); + } else if (ctx.check) { + ctx.missing(label); + } else { + await createObject( + ctx, + `/tablesdb/${DB_ID}/tables/${t.id}/columns/string`, + { key: col.key, size: col.size, required: col.required }, + label, + ); + ctx.created(label); + entries.push({ col, label, actual: null, created: true }); + } + } + + if (!ctx.check) { + const waiting = entries.filter((e) => e.created || e.actual.status === 'processing'); + if (waiting.length) { + const ready = await waitAvailable(ctx, t.id, waiting.map((e) => e.col.key)); + for (const e of waiting) e.actual = ready.get(e.col.key); + } + } + + for (const e of entries) { + const deviations = compareColumn(e.actual, e.col); + if (deviations.length) ctx.deviations(`${t.id}.${e.col.key}`, deviations); + else if (!e.created) ctx.ok(e.label); + } +} + +export async function ensureBucket(ctx) { + const b = DESIRED.bucket; + const label = `Bucket ${b.id}`; + const actual = await getObject(ctx, `/storage/buckets/${b.id}`, label); + if (actual) { + const deviations = compareBucket(actual, b); + if (deviations.length) ctx.deviations(b.id, deviations); + else ctx.ok(label); + return; + } + if (ctx.check) { + ctx.missing(label); + return; + } + await createObject( + ctx, + '/storage/buckets', + { + bucketId: b.id, + name: b.name, + permissions: b.permissions, + fileSecurity: b.fileSecurity, + maximumFileSize: b.maximumFileSize, + allowedFileExtensions: b.allowedFileExtensions, + compression: b.compression, + antivirus: b.antivirus, + }, + label, + ); + ctx.created(label); +} + +// --------------------------------------------------------------------------- +// Ablauf +// --------------------------------------------------------------------------- + +/** + * Führt das Setup (oder mit `check` nur die Prüfung) aus und gibt den Exit-Code + * zurück: 0 vollständig, 1 fehlt/weicht ab, 2 Abbruch. + */ +export async function main({ + endpoint = DEFAULT_ENDPOINT, + key, + project = PROJECT, + check = false, + log = console.log, + pollMs = 1000, + timeoutMs = 30_000, +} = {}) { + const stats = { missing: 0, deviations: 0 }; + const ctx = { + api: createApi({ endpoint, key, project }), + check, + pollMs, + timeoutMs, + ok: (label) => log(`OK: ${label}`), + created: (label) => log(`ANGELEGT: ${label}`), + missing: (label) => { + stats.missing += 1; + log(`FEHLT: ${label}`); + }, + deviations: (prefix, list) => { + for (const text of list) { + stats.deviations += 1; + log(`ABWEICHUNG: ${prefix}.${text}`); + } + }, + }; + + log(`Appwrite ${endpoint}, Projekt ${project}, ${check ? 'nur prüfen (--check)' : 'prüfen und fehlende Objekte anlegen'}`); + try { + const databaseExists = await ensureDatabase(ctx); + const tableExists = await ensureTable(ctx, databaseExists); + await ensureColumns(ctx, tableExists); + await ensureBucket(ctx); + } catch (err) { + if (!(err instanceof SetupError)) throw err; + log(`ABBRUCH: ${err.message}`); + return 2; + } + + if (stats.missing || stats.deviations) { + const parts = []; + if (stats.missing) parts.push(`${stats.missing} fehlend`); + if (stats.deviations) parts.push(`${stats.deviations} Abweichung(en)`); + log(`UNVOLLSTÄNDIG: ${parts.join(', ')}.`); + if (stats.missing) log('Zum Anlegen das Skript ohne --check starten.'); + if (stats.deviations) log('Abweichungen werden nie automatisch geändert – bitte in der Console korrigieren.'); + return 1; + } + log('OK: Schema vollständig'); + return 0; +} + +// --------------------------------------------------------------------------- +// Kommandozeile +// --------------------------------------------------------------------------- + +async function cli() { + const args = process.argv.slice(2); + const unknown = args.filter((arg) => arg !== '--check'); + if (unknown.length) { + console.error(`Unbekannte Option: ${unknown.join(' ')}`); + console.error('Aufruf: node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs [--check]'); + process.exitCode = 2; + return; + } + const key = process.env.AIRCARD_APPWRITE_API_KEY?.trim(); + if (!key) { + console.error('Fehler: AIRCARD_APPWRITE_API_KEY fehlt.'); + console.error('Den API-Key „aircard-provision“ als Zeile AIRCARD_APPWRITE_API_KEY=… in /home/webklar/apps/.env eintragen (siehe docs/appwrite-console.md)'); + console.error('und das Skript mit --env-file=/home/webklar/apps/.env starten.'); + process.exitCode = 2; + return; + } + process.exitCode = await main({ + endpoint: process.env.AIRCARD_APPWRITE_ENDPOINT || DEFAULT_ENDPOINT, + key, + check: args.includes('--check'), + }); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + await cli(); + } catch (err) { + console.error(`Unerwarteter Fehler: ${err?.message ?? err}`); + process.exitCode = 2; + } +} diff --git a/web/scripts/appwrite-setup.test.mjs b/web/scripts/appwrite-setup.test.mjs new file mode 100644 index 0000000..055c9aa --- /dev/null +++ b/web/scripts/appwrite-setup.test.mjs @@ -0,0 +1,579 @@ +// Tests für web/scripts/appwrite-setup.mjs. Aufruf: node --test web/scripts/appwrite-setup.test.mjs +// Nur Node-Bordmittel. Der "Appwrite-Server" ist ein Fake auf Port 0 (Speicher im Prozess), +// es wird nie ein echter Server oder ein echter Key benutzt. + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { + DESIRED, + DEFAULT_ENDPOINT, + isMissing, + compareTable, + compareColumn, + compareBucket, + main, +} from './appwrite-setup.mjs'; + +const SCRIPT = fileURLToPath(new URL('./appwrite-setup.mjs', import.meta.url)); +const KEY = 'test-key-0123456789abcdef-geheim'; + +// --------------------------------------------------------------------------- +// Fake-Appwrite (1.8-ähnliche REST-Formen, alles im Speicher) +// --------------------------------------------------------------------------- + +function createFakeAppwrite({ project = 'aircard', key = KEY, stuckColumns = false, inject = null } = {}) { + const state = { + databases: new Map(), + tables: new Map(), // "db/table" + columns: new Map(), // "db/table/key" + buckets: new Map(), + requests: [], + }; + + const send = (res, status, body) => { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + const fail = (res, status, type, message) => send(res, status, { message, code: status, type, version: '1.8.1' }); + + const server = http.createServer(async (req, res) => { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const raw = Buffer.concat(chunks).toString(); + const body = raw ? JSON.parse(raw) : undefined; + const path = new URL(req.url, 'http://fake').pathname; + state.requests.push({ method: req.method, path, body, headers: req.headers }); + + if (req.headers['x-appwrite-project'] !== project) { + return fail(res, 404, 'project_not_found', 'Project with the requested ID could not be found.'); + } + if (req.headers['x-appwrite-key'] !== key) { + return fail(res, 401, 'user_unauthorized', 'The current user is not authorized to perform the requested action.'); + } + const injected = inject?.(req.method, path); + if (injected) return fail(res, injected.status, injected.type, injected.message ?? 'injected'); + + const seg = path.replace(/^\/v1/, '').split('/').filter(Boolean); + const m = req.method; + + // POST /tablesdb + if (m === 'POST' && seg.length === 1 && seg[0] === 'tablesdb') { + if (state.databases.has(body.databaseId)) return fail(res, 409, 'database_already_exists', 'exists'); + const db = { $id: body.databaseId, name: body.name, enabled: true }; + state.databases.set(db.$id, db); + return send(res, 201, db); + } + // GET /tablesdb/:db + if (m === 'GET' && seg.length === 2 && seg[0] === 'tablesdb') { + const db = state.databases.get(seg[1]); + return db ? send(res, 200, db) : fail(res, 404, 'database_not_found', 'Database not found'); + } + // POST /tablesdb/:db/tables + if (m === 'POST' && seg.length === 3 && seg[0] === 'tablesdb' && seg[2] === 'tables') { + if (!state.databases.has(seg[1])) return fail(res, 404, 'database_not_found', 'Database not found'); + const id = `${seg[1]}/${body.tableId}`; + if (state.tables.has(id)) return fail(res, 409, 'table_already_exists', 'exists'); + const table = { + $id: body.tableId, name: body.name, $permissions: body.permissions ?? [], + rowSecurity: body.rowSecurity ?? true, enabled: true, + }; + state.tables.set(id, table); + return send(res, 201, table); + } + // GET /tablesdb/:db/tables/:t + if (m === 'GET' && seg.length === 4 && seg[0] === 'tablesdb' && seg[2] === 'tables') { + if (!state.databases.has(seg[1])) return fail(res, 404, 'database_not_found', 'Database not found'); + const table = state.tables.get(`${seg[1]}/${seg[3]}`); + return table ? send(res, 200, table) : fail(res, 404, 'table_not_found', 'Table not found'); + } + // POST /tablesdb/:db/tables/:t/columns/string + if (m === 'POST' && seg.length === 6 && seg[2] === 'tables' && seg[4] === 'columns' && seg[5] === 'string') { + if (!state.tables.has(`${seg[1]}/${seg[3]}`)) return fail(res, 404, 'table_not_found', 'Table not found'); + const id = `${seg[1]}/${seg[3]}/${body.key}`; + if (state.columns.has(id)) return fail(res, 409, 'column_already_exists', 'exists'); + const col = { key: body.key, type: 'string', size: body.size, required: body.required, array: false, status: 'processing', gets: 0 }; + state.columns.set(id, col); + const { gets, ...out } = col; + return send(res, 202, out); + } + // GET /tablesdb/:db/tables/:t/columns/:key + if (m === 'GET' && seg.length === 6 && seg[2] === 'tables' && seg[4] === 'columns') { + if (!state.tables.has(`${seg[1]}/${seg[3]}`)) return fail(res, 404, 'table_not_found', 'Table not found'); + const col = state.columns.get(`${seg[1]}/${seg[3]}/${seg[5]}`); + if (!col) return fail(res, 404, 'column_not_found', 'Column not found'); + col.gets += 1; + const status = stuckColumns ? 'processing' : col.gets >= 2 ? 'available' : 'processing'; + const { gets, ...out } = col; + return send(res, 200, { ...out, status }); + } + // POST /storage/buckets + if (m === 'POST' && path === '/v1/storage/buckets') { + if (state.buckets.has(body.bucketId)) return fail(res, 409, 'storage_bucket_already_exists', 'exists'); + const bucket = { + $id: body.bucketId, name: body.name, $permissions: body.permissions ?? [], + fileSecurity: body.fileSecurity, maximumFileSize: body.maximumFileSize, + allowedFileExtensions: body.allowedFileExtensions, compression: body.compression, + antivirus: body.antivirus, enabled: true, + }; + state.buckets.set(bucket.$id, bucket); + return send(res, 201, bucket); + } + // GET /storage/buckets/:id + if (m === 'GET' && seg.length === 3 && seg[0] === 'storage' && seg[1] === 'buckets') { + const bucket = state.buckets.get(seg[2]); + return bucket ? send(res, 200, bucket) : fail(res, 404, 'storage_bucket_not_found', 'Bucket not found'); + } + return fail(res, 404, 'general_route_not_found', 'Route not found'); + }); + + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + resolve({ + state, + endpoint: `http://127.0.0.1:${port}/v1`, + posts: () => state.requests.filter((r) => r.method === 'POST'), + otherWrites: () => state.requests.filter((r) => !['GET', 'POST'].includes(r.method)), + close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(r); }), + }); + }); + }); +} + +async function withFake(opts, fn) { + const fake = await createFakeAppwrite(opts); + try { + return await fn(fake); + } finally { + await fake.close(); + } +} + +async function run(fake, opts = {}) { + const lines = []; + const code = await main({ + endpoint: fake.endpoint, + key: KEY, + project: 'aircard', + check: false, + log: (line) => lines.push(line), + pollMs: 1, + timeoutMs: 2000, + ...opts, + }); + return { code, lines }; +} + +const startsWith = (lines, prefix) => lines.filter((l) => l.startsWith(prefix)); + +function runCli(args, env) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [SCRIPT, ...args], { + env: { PATH: process.env.PATH, ...env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (d) => { stdout += d; }); + child.stderr.on('data', (d) => { stderr += d; }); + child.on('error', reject); + child.on('close', (code) => resolve({ code, stdout, stderr })); + }); +} + +// --------------------------------------------------------------------------- +// Reine Helfer +// --------------------------------------------------------------------------- + +describe('DESIRED', () => { + test('entspricht dem Schema aus der Spec', () => { + assert.deepEqual(DESIRED.database, { id: 'aircard', name: 'AirCard' }); + assert.equal(DESIRED.table.id, 'cards'); + assert.equal(DESIRED.table.name, 'Cards'); + assert.deepEqual(DESIRED.table.permissions, ['create("users")']); + assert.equal(DESIRED.table.rowSecurity, true); + assert.deepEqual( + DESIRED.table.columns.map((c) => [c.key, c.size, c.required]), + [['name', 80, true], ['config', 2000, true], ['imageFileId', 36, true], ['previewFileId', 36, true]], + ); + assert.deepEqual(DESIRED.bucket, { + id: 'card-images', + name: 'Card Images', + permissions: ['create("users")'], + fileSecurity: true, + maximumFileSize: 5242880, + allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'], + compression: 'none', + antivirus: false, + }); + }); + + test('Standard-Endpoint ist der Server von WEBklar', () => { + assert.equal(DEFAULT_ENDPOINT, 'https://appwrite.webklar.com/v1'); + }); +}); + +describe('isMissing', () => { + test('404 mit anderem Typ gilt als fehlend', () => { + assert.equal(isMissing(404, { type: 'database_not_found' }), true); + assert.equal(isMissing(404, { type: 'table_not_found' }), true); + assert.equal(isMissing(404, { type: 'column_not_found' }), true); + assert.equal(isMissing(404, { message: 'kein JSON' }), true); + assert.equal(isMissing(404, undefined), true); + }); + + test('404 project_not_found ist NICHT "fehlt"', () => { + assert.equal(isMissing(404, { type: 'project_not_found' }), false); + }); + + test('alle anderen Status sind nicht "fehlt"', () => { + assert.equal(isMissing(200, {}), false); + assert.equal(isMissing(401, { type: 'user_unauthorized' }), false); + assert.equal(isMissing(403, {}), false); + assert.equal(isMissing(500, { type: 'general_server_error' }), false); + }); +}); + +describe('compareTable', () => { + const ok = () => ({ $id: 'cards', name: 'Cards', $permissions: ['create("users")'], rowSecurity: true }); + + test('passende Tabelle: keine Abweichung', () => { + assert.deepEqual(compareTable(ok(), DESIRED.table), []); + }); + + test('Rechte werden unabhängig von der Reihenfolge verglichen', () => { + const desired = { ...DESIRED.table, permissions: ['create("users")', 'read("any")'] }; + const actual = { ...ok(), $permissions: ['read("any")', 'create("users")'] }; + assert.deepEqual(compareTable(actual, desired), []); + }); + + test('abweichende oder zusätzliche Rechte werden gemeldet', () => { + const wrong = compareTable({ ...ok(), $permissions: ['read("any")'] }, DESIRED.table); + assert.equal(wrong.length, 1); + assert.match(wrong[0], /^\$permissions ist \[read\("any"\)\], erwartet \[create\("users"\)\]$/); + const extra = compareTable({ ...ok(), $permissions: ['create("users")', 'read("any")'] }, DESIRED.table); + assert.equal(extra.length, 1); + assert.match(extra[0], /^\$permissions /); + }); + + test('rowSecurity aus wird gemeldet', () => { + assert.deepEqual(compareTable({ ...ok(), rowSecurity: false }, DESIRED.table), [ + 'rowSecurity ist false, erwartet true', + ]); + }); + + test('mehrere Abweichungen ergeben mehrere Meldungen', () => { + assert.equal(compareTable({ ...ok(), rowSecurity: false, $permissions: [] }, DESIRED.table).length, 2); + }); +}); + +describe('compareColumn', () => { + const desired = DESIRED.table.columns[0]; // name, 80, required + const ok = () => ({ key: 'name', type: 'string', size: 80, required: true, status: 'available' }); + + test('passende Spalte: keine Abweichung', () => { + assert.deepEqual(compareColumn(ok(), desired), []); + }); + + test('size, required und status werden gemeldet', () => { + assert.deepEqual(compareColumn({ ...ok(), size: 100 }, desired), ['size ist 100, erwartet 80']); + assert.deepEqual(compareColumn({ ...ok(), required: false }, desired), ['required ist false, erwartet true']); + assert.deepEqual(compareColumn({ ...ok(), status: 'processing' }, desired), ['status ist processing, erwartet available']); + }); + + test('fehlende Felder (z. B. falscher Spaltentyp ohne size) werden gemeldet', () => { + const [msg] = compareColumn({ key: 'name', type: 'integer', required: true, status: 'available' }, desired); + assert.equal(msg, 'size ist undefined, erwartet 80'); + }); +}); + +describe('compareBucket', () => { + const ok = () => ({ + $id: 'card-images', name: 'Card Images', $permissions: ['create("users")'], + fileSecurity: true, maximumFileSize: 5242880, + allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'], compression: 'none', antivirus: false, + }); + + test('passender Bucket: keine Abweichung', () => { + assert.deepEqual(compareBucket(ok(), DESIRED.bucket), []); + }); + + test('Endungen werden sortiert verglichen', () => { + assert.deepEqual(compareBucket({ ...ok(), allowedFileExtensions: ['webp', 'jpeg', 'png', 'jpg'] }, DESIRED.bucket), []); + }); + + test('fehlende oder zusätzliche Endungen werden gemeldet (sortiert ausgegeben)', () => { + assert.deepEqual(compareBucket({ ...ok(), allowedFileExtensions: ['png', 'webp'] }, DESIRED.bucket), [ + 'allowedFileExtensions ist [png, webp], erwartet [jpeg, jpg, png, webp]', + ]); + assert.equal(compareBucket({ ...ok(), allowedFileExtensions: [] }, DESIRED.bucket).length, 1); + assert.equal(compareBucket({ ...ok(), allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp', 'gif'] }, DESIRED.bucket).length, 1); + }); + + test('fileSecurity und maximumFileSize werden gemeldet', () => { + assert.deepEqual(compareBucket({ ...ok(), fileSecurity: false }, DESIRED.bucket), ['fileSecurity ist false, erwartet true']); + assert.deepEqual(compareBucket({ ...ok(), maximumFileSize: 30000000 }, DESIRED.bucket), [ + 'maximumFileSize ist 30000000, erwartet 5242880', + ]); + }); + + test('Rechte werden unabhängig von der Reihenfolge verglichen und Abweichungen gemeldet', () => { + const desired = { ...DESIRED.bucket, permissions: ['create("users")', 'read("any")'] }; + assert.deepEqual(compareBucket({ ...ok(), $permissions: ['read("any")', 'create("users")'] }, desired), []); + assert.equal(compareBucket({ ...ok(), $permissions: ['read("any")'] }, DESIRED.bucket).length, 1); + }); +}); + +// --------------------------------------------------------------------------- +// Ablauf gegen den Fake-Server +// --------------------------------------------------------------------------- + +describe('main gegen Fake-Server', () => { + const EXPECTED_OBJECTS = [ + 'Datenbank aircard', + 'Tabelle cards', + 'Spalte cards.name', + 'Spalte cards.config', + 'Spalte cards.imageFileId', + 'Spalte cards.previewFileId', + 'Bucket card-images', + ]; + + test('--check auf leerem Projekt: Exit 1, alle 7 Objekte FEHLT, nichts angelegt', async () => { + await withFake({}, async (fake) => { + const { code, lines } = await run(fake, { check: true }); + assert.equal(code, 1); + assert.deepEqual(startsWith(lines, 'FEHLT'), EXPECTED_OBJECTS.map((o) => `FEHLT: ${o}`)); + assert.equal(startsWith(lines, 'OK').length, 0); + assert.equal(fake.posts().length, 0); + assert.equal(fake.otherWrites().length, 0); + }); + }); + + test('Anlegen, erneut prüfen, erneut anlegen (idempotent)', async () => { + await withFake({}, async (fake) => { + // Anlegen + const first = await run(fake); + assert.equal(first.code, 0); + assert.deepEqual(startsWith(first.lines, 'ANGELEGT'), EXPECTED_OBJECTS.map((o) => `ANGELEGT: ${o}`)); + assert.equal(startsWith(first.lines, 'FEHLT').length, 0); + assert.equal(startsWith(first.lines, 'ABWEICHUNG').length, 0); + assert.ok(first.lines.includes('OK: Schema vollständig')); + + // Bodies und Reihenfolge exakt wie im Brief + assert.deepEqual( + fake.posts().map((r) => [r.path, r.body]), + [ + ['/v1/tablesdb', { databaseId: 'aircard', name: 'AirCard' }], + ['/v1/tablesdb/aircard/tables', { tableId: 'cards', name: 'Cards', permissions: ['create("users")'], rowSecurity: true }], + ['/v1/tablesdb/aircard/tables/cards/columns/string', { key: 'name', size: 80, required: true }], + ['/v1/tablesdb/aircard/tables/cards/columns/string', { key: 'config', size: 2000, required: true }], + ['/v1/tablesdb/aircard/tables/cards/columns/string', { key: 'imageFileId', size: 36, required: true }], + ['/v1/tablesdb/aircard/tables/cards/columns/string', { key: 'previewFileId', size: 36, required: true }], + ['/v1/storage/buckets', { + bucketId: 'card-images', name: 'Card Images', permissions: ['create("users")'], fileSecurity: true, + maximumFileSize: 5242880, allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'], + compression: 'none', antivirus: false, + }], + ], + ); + + // Jede Anfrage trägt die drei Header + for (const r of fake.state.requests) { + assert.equal(r.headers['content-type'], 'application/json'); + assert.equal(r.headers['x-appwrite-project'], 'aircard'); + assert.equal(r.headers['x-appwrite-key'], KEY); + } + // Der Key taucht nie in der Ausgabe auf + assert.ok(!first.lines.join('\n').includes(KEY)); + + // Alle Spalten wurden vor dem Ende ausgewartet (Status available) + for (const col of fake.state.columns.values()) assert.ok(col.gets >= 2, `Spalte ${col.key} nicht bis available geprüft`); + + // Zweite Prüfung + const postsBefore = fake.posts().length; + const check = await run(fake, { check: true }); + assert.equal(check.code, 0); + assert.deepEqual(startsWith(check.lines, 'OK: ').filter((l) => l !== 'OK: Schema vollständig'), EXPECTED_OBJECTS.map((o) => `OK: ${o}`)); + assert.ok(check.lines.includes('OK: Schema vollständig')); + + // Zweites Anlegen legt nichts doppelt an + const second = await run(fake); + assert.equal(second.code, 0); + assert.equal(startsWith(second.lines, 'ANGELEGT').length, 0); + assert.ok(second.lines.includes('OK: Schema vollständig')); + assert.equal(fake.posts().length, postsBefore); + assert.equal(fake.otherWrites().length, 0); + }); + }); + + test('Abweichungen werden gemeldet, aber nicht verändert (Exit 1)', async () => { + await withFake({}, async (fake) => { + assert.equal((await run(fake)).code, 0); + fake.state.tables.get('aircard/cards').rowSecurity = false; + fake.state.tables.get('aircard/cards').$permissions = ['read("any")']; + fake.state.columns.get('aircard/cards/name').size = 100; + fake.state.columns.get('aircard/cards/config').required = false; + fake.state.buckets.get('card-images').maximumFileSize = 30000000; + fake.state.buckets.get('card-images').allowedFileExtensions = ['png', 'gif']; + fake.state.buckets.get('card-images').fileSecurity = false; + const requestsBefore = fake.state.requests.length; + + for (const check of [true, false]) { + const { code, lines } = await run(fake, { check }); + assert.equal(code, 1); + const dev = startsWith(lines, 'ABWEICHUNG'); + assert.ok(dev.includes('ABWEICHUNG: cards.rowSecurity ist false, erwartet true'), dev.join('\n')); + assert.ok(dev.includes('ABWEICHUNG: cards.name.size ist 100, erwartet 80'), dev.join('\n')); + assert.ok(dev.includes('ABWEICHUNG: cards.config.required ist false, erwartet true'), dev.join('\n')); + assert.ok(dev.includes('ABWEICHUNG: card-images.maximumFileSize ist 30000000, erwartet 5242880'), dev.join('\n')); + assert.ok(dev.includes('ABWEICHUNG: card-images.fileSecurity ist false, erwartet true'), dev.join('\n')); + assert.ok(dev.some((l) => l.startsWith('ABWEICHUNG: card-images.allowedFileExtensions ist [gif, png]')), dev.join('\n')); + assert.ok(dev.some((l) => l.startsWith('ABWEICHUNG: cards.$permissions ist [read("any")]')), dev.join('\n')); + assert.equal(dev.length, 7); + assert.ok(!lines.includes('OK: Schema vollständig')); + } + // Nur lesende Anfragen seit der Manipulation + assert.ok(fake.state.requests.slice(requestsBefore).every((r) => r.method === 'GET')); + }); + }); + + test('404 project_not_found bricht klar ab und meldet nichts als fehlend', async () => { + await withFake({ project: 'anderes-projekt' }, async (fake) => { + for (const check of [true, false]) { + const { code, lines } = await run(fake, { check }); + assert.equal(code, 2); + assert.equal(startsWith(lines, 'FEHLT').length, 0); + assert.equal(startsWith(lines, 'ANGELEGT').length, 0); + const abort = startsWith(lines, 'ABBRUCH'); + assert.equal(abort.length, 1); + assert.match(abort[0], /Projekt/); + assert.match(abort[0], /aircard/); + assert.match(abort[0], /docs\/appwrite-console\.md/); + assert.ok(!lines.join('\n').includes(KEY)); + } + assert.equal(fake.posts().length, 0); + }); + }); + + test('401 (falscher Key) bricht ab, ohne den Key auszugeben', async () => { + await withFake({ key: 'ein-ganz-anderer-key' }, async (fake) => { + const { code, lines } = await run(fake); + assert.equal(code, 2); + const abort = startsWith(lines, 'ABBRUCH'); + assert.equal(abort.length, 1); + assert.match(abort[0], /401/); + assert.match(abort[0], /API-Key/); + const all = lines.join('\n'); + assert.ok(!all.includes(KEY)); + assert.ok(!all.includes('ein-ganz-anderer-key')); + assert.equal(fake.posts().length, 0); + }); + }); + + test('Steht der Key im Servertext, wird er in der Meldung geschwärzt', async () => { + const inject = (method, path) => (method === 'GET' && path === '/v1/tablesdb/aircard' + ? { status: 401, type: 'user_unauthorized', message: `Ungültiger Key ${KEY} abgelehnt` } + : null); + await withFake({ inject }, async (fake) => { + const { code, lines } = await run(fake); + assert.equal(code, 2); + const all = lines.join('\n'); + assert.ok(!all.includes(KEY)); + assert.match(all, /Ungültiger Key \*\*\* abgelehnt/); + }); + }); + + test('5xx bricht ab', async () => { + const inject = (method, path) => (method === 'GET' && path === '/v1/tablesdb/aircard' + ? { status: 503, type: 'general_service_unavailable', message: 'Service temporarily unavailable' } + : null); + await withFake({ inject }, async (fake) => { + const { code, lines } = await run(fake); + assert.equal(code, 2); + const abort = startsWith(lines, 'ABBRUCH'); + assert.equal(abort.length, 1); + assert.match(abort[0], /503/); + assert.equal(startsWith(lines, 'FEHLT').length, 0); + assert.equal(fake.posts().length, 0); + }); + }); + + test('Spalte wird nicht verfügbar: Abbruch nach Wartezeit', async () => { + await withFake({ stuckColumns: true }, async (fake) => { + const { code, lines } = await run(fake, { timeoutMs: 60 }); + assert.equal(code, 2); + const abort = startsWith(lines, 'ABBRUCH'); + assert.equal(abort.length, 1); + assert.match(abort[0], /cards\.name/); + assert.match(abort[0], /nicht .*verfügbar/); + // nach dem Abbruch wurde kein Bucket mehr angelegt + assert.equal(fake.state.buckets.size, 0); + }); + }); + + test('Server nicht erreichbar: Abbruch mit klarer Meldung', async () => { + const probe = http.createServer(); + await new Promise((r) => probe.listen(0, '127.0.0.1', r)); + const { port } = probe.address(); + await new Promise((r) => probe.close(r)); + const lines = []; + const code = await main({ + endpoint: `http://127.0.0.1:${port}/v1`, key: KEY, project: 'aircard', check: true, + log: (l) => lines.push(l), pollMs: 1, timeoutMs: 100, + }); + assert.equal(code, 2); + assert.match(startsWith(lines, 'ABBRUCH')[0], /nicht erreichbar/); + assert.ok(!lines.join('\n').includes(KEY)); + }); +}); + +// --------------------------------------------------------------------------- +// Aufruf als Kommandozeile +// --------------------------------------------------------------------------- + +describe('CLI', () => { + test('ohne AIRCARD_APPWRITE_API_KEY: deutsche Fehlermeldung, Exit 2', async () => { + const { code, stdout, stderr } = await runCli([], {}); + assert.equal(code, 2); + assert.equal(stdout, ''); + assert.match(stderr, /AIRCARD_APPWRITE_API_KEY/); + assert.match(stderr, /fehlt/); + }); + + test('unbekannte Option: Exit 2', async () => { + const { code, stderr } = await runCli(['--foo'], { AIRCARD_APPWRITE_API_KEY: KEY }); + assert.equal(code, 2); + assert.match(stderr, /Unbekannte Option/); + assert.ok(!stderr.includes(KEY)); + }); + + test('--check gegen Fake-Server über Umgebungsvariablen: Exit 1, Key nicht in der Ausgabe', async () => { + await withFake({}, async (fake) => { + const { code, stdout, stderr } = await runCli(['--check'], { + AIRCARD_APPWRITE_API_KEY: KEY, + AIRCARD_APPWRITE_ENDPOINT: fake.endpoint, + }); + assert.equal(code, 1); + assert.match(stdout, /FEHLT: Datenbank aircard/); + assert.match(stdout, /FEHLT: Bucket card-images/); + assert.ok(!(stdout + stderr).includes(KEY)); + assert.equal(fake.posts().length, 0); + }); + }); + + test('Anlegen und danach --check über die Kommandozeile: Exit 0', async () => { + await withFake({}, async (fake) => { + const env = { AIRCARD_APPWRITE_API_KEY: KEY, AIRCARD_APPWRITE_ENDPOINT: fake.endpoint }; + const apply = await runCli([], env); + assert.equal(apply.code, 0, apply.stdout + apply.stderr); + const check = await runCli(['--check'], env); + assert.equal(check.code, 0, check.stdout + check.stderr); + assert.match(check.stdout, /OK: Schema vollständig/); + }); + }); +}); diff --git a/web/vite.config.ts b/web/vite.config.ts index a9f5456..418936c 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -3,5 +3,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'happy-dom', + // scripts/*.test.mjs laufen mit `node --test` (siehe web/scripts/appwrite-setup.test.mjs). + include: ['src/**/*.test.ts'], }, });