diff --git a/README.md b/README.md index 3c0bcd7..99e30b7 100644 --- a/README.md +++ b/README.md @@ -146,8 +146,32 @@ docker build -t refboard-ayon:latest . Der E2E (Ticket-Ausstellung → Exchange → Board-Zugriff → Single-Use → 2. Nutzer am selben Board) ist mit dem Mock grün getestet. +## Status: PRODUKTIV (seit 05.09.2026) + +- DNS + Let's-Encrypt-Zertifikat aktiv (`refboard.niklashmotion.art`) +- Launcher-Klick → eingeloggt im Task-Board — produktiv im Einsatz + +## Fehler & Lektionen (Session 04.–05.09.2026) + +| # | Fehler | Ursache | Fix/Lektion | +|---|---|---|---| +| 1 | Launcher zeigte fast keine Actions mehr (`KeyError: 'url'` in `_get_webactions`) | `SimpleActionManifest(icon=…)` erzeugte `{"type":"url"}` **ohne** `url`-Feld; eine einzige kaputte Action kippte **alle** Webactions im Client (try/except um den ganzen Fetch) | Icon-Feld weggelassen; Lektion: Action-Icons brauchen `{"type":"material-symbols","name":…}` oder vollständige URL | +| 2 | `/session` → 500 `AttributeError: 'UserAttribModel' object has no attribute 'name'` | AYON-User-attrib heißt `fullName`, nicht `name` | `user.attrib.fullName` | +| 3 | Exchange → 500 `uuidv4 is not defined` | Import im neuen db.js-Code vergessen | Lektion: py_compile fängt undefined names nicht — Smoke-Test im Container statt nur Syntax-Check | +| 4 | RefBoard konnte AYON nicht erreichen nach `compose up` | Externes Docker-Netz nur top-level deklariert, Service nicht attached; manuelles `docker network connect` überlebt kein Recreate | Netz **im Service-Block** als `external: true` deklarieren | +| 5 | Exchange → 403 trotz „richtigem" Secret | `REFBOARD_API_KEY`-Wert enthält `=`; Extraktion mit `cut -d= -f2` bricht am ersten `=` → falscher (gekürzter) Wert in AYON-Secret | Werte mit `sed "s/^KEY=//"` extrahieren; Lektion: Secret-Sync immer per Längen-/Hash-Vergleich verifizieren | +| 6 | Settings-POST (204) änderte die DB **nicht** | AYON speichert Settings als Ganzes, filtert aber „identity"-Felder (Werte == Defaults) heraus — ein Feld, das dem Default entspricht, ist nie speicherbar | Lektion: Settings-API ist Full-Replace + Identity-Filter; gezielte Feld-Fixe über die API sind nicht immer möglich | +| 7 | App-Kacheln fehlten in jeder Task (Launcher leer bis auf 3 Actions) | **Ynput-Bug in applications 1.4.4:** Runtime liest Pydantic-Defaults (`profiles=[]`), nicht `DEFAULT_VALUES` (mit `all_applications`-Profil); das UI zeigt die Defaults trotzdem — Diskrepanz UI/Runtime | Umgehend: Profil per API explizit setzen (Full-Replace aus Backup + Profil-Feld); langfristig: Ynput-Issue | +| 8 | Ticket-Wiederverwendung zeigte 502 statt 401 | RefBoard behandelte 401/403 vom Addon als Infrastruktur-Fehler | 401/403 aus `/exchange` → sauberes 401 „Invalid or expired ticket" | +| 9 | Nach Secret-Löschung + Neuanlage Exchange-Fehler | AYON-Secret-Wert ≠ Container-Env (Container läuft mit altem Env bis Recreate) | Nach Secret-Änderungen: `docker compose up -d --force-recreate` | + ## Noch offen -- A-Record `refboard` → `89.57.47.213` (IONOS-Panel, einzeln anlegen) -- danach: `certbot --nginx -d refboard.niklashmotion.art` -- Addon v0.2.0 installieren + Bundle (siehe oben) +- ~~A-Record~~ ✓ erledigt (05.09.) +- ~~Zertifikat~~ ✓ erledigt (05.09.) +- Nuke-17.0-Pfad-Mismatch in den Applications-Settings + (`/usr/local/Nuke17.0v1` erwartet, installiert ist `/opt/Nuke17.0v3`) — Fix im + Studio-Settings-WebUI: Executable auf `/usr/local/bin/nuke-ayon` setzen +- Alt-Container `refboard` (:8002, standalone) stilllegen, sobald Bestands- + Boards nicht mehr gebraucht werden +- Ynput-Issue zu #7 (applications 1.4.4 Runtime-Defaults) optional melden diff --git a/deploy/compose.yaml b/deploy/compose.yaml new file mode 100644 index 0000000..1ff0054 --- /dev/null +++ b/deploy/compose.yaml @@ -0,0 +1,45 @@ +# Produktive Deployment-Konfiguration (orange-desktop). +# Stack-Pfad: /media/orange/RocketChat/refboard-ayon/ +# compose.yaml (diese Datei) + .env + data/ + repo/ +# DNS: refboard.niklashmotion.art → 89.57.47.213 (nginx vhost: /etc/nginx/sites-enabled/refboard.conf) +# +# WICHTIG (Lessons siehe README): +# - ayon-private_default MUSS als external network IM Service-Block hängen +# (top-level reicht nicht — überlebt kein `up --force-recreate`) +# - REFBOARD_API_KEY muss EXAKT dem AYON-Secret 'refboard_api_key' entsprechen; +# der Wert enthält '=' → nie mit cut -d= extrahieren, immer sed "s/^KEY=//" +# - nach Secret-Änderungen: docker compose up -d --force-recreate + +name: refboard-ayon + +services: + refboard-ayon: + image: refboard-ayon:latest + build: ./repo + container_name: refboard-ayon + environment: + PORT: 8000 + NODE_ENV: production + JWT_SECRET: ${JWT_SECRET:?set in .env} + JWT_EXPIRES_IN: 7d + DB_PATH: /app/data/refboard.db + STORAGE_BACKEND: ${STORAGE_BACKEND:-fs} + STORAGE_DATA_DIR: /app/data/storage + REFBOARD_API_KEY: ${REFBOARD_API_KEY:?set in .env} + AYON_EXCHANGE_URL: ${AYON_EXCHANGE_URL:?set in .env} + CORS_ORIGIN: ${CORS_ORIGIN:-*} + MAX_FILE_SIZE_MB: 200 + ports: + - "0.0.0.0:8003:8000" + volumes: + - ./data:/app/data + restart: unless-stopped + networks: + - default + - ayon-private_default + +networks: + default: + name: refboard-ayon_default + ayon-private_default: + external: true diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..d0a6478 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,28 @@ +# Tests + +E2E-Testdoppel ohne echtes AYON-Addon (Phase-1-Verifikation, Stand 04.09.2026). + +## Ablauf auf orange + +```bash +# 1. Mock-Addon als Container (simuliert /issue + /exchange des AYON-Addons) +docker run -d --name mock-exchange --network refboard-ayon_default \ + -e REFBOARD_API_KEY= \ + -v $PWD/tests/mock-exchange.js:/mock.js:ro \ + node:20-alpine node /mock.js + +# 2. AYON_EXCHANGE_URL in .env auf http://mock-exchange:9000/exchange setzen +# + docker compose up -d --force-recreate + +# 3. E2E im refboard-ayon-Container ausführen +docker cp tests/e2e.sh refboard-ayon:/tmp/e2e.sh +docker exec refboard-ayon sh /tmp/e2e.sh +``` + +Erwartet: 8 Schritte grün (Ticket → Exchange → /me → Board 200 → Reuse 401 → +2. User am selben Board → fehlendes Ticket 400 → SPA /b 200). + +## Echter Stack + +Im Produktivbetrieb zeigt `AYON_EXCHANGE_URL` auf das echte Addon +(`http://ayon-private-server-1:5000/api/addons/refboard/0.2.0/exchange`). diff --git a/tests/e2e.sh b/tests/e2e.sh new file mode 100644 index 0000000..bafb925 --- /dev/null +++ b/tests/e2e.sh @@ -0,0 +1,52 @@ +#!/bin/sh +# E2E test: ticket exchange -> session -> board access -> single-use -> collab +set -e +B=http://localhost:8000 +M=http://mock-exchange:9000 + +echo "--- 1. Issue ticket (mock addon) ---" +TICKET=$(wget -qO- --header="Content-Type: application/json" \ + --post-data='{"ayon_user":"niklas","display_name":"Niklas","project":"lumenfjord","task":"assets/chair/modeling"}' \ + $M/issue | sed 's/.*"ticket":"\([^"]*\)".*/\1/') +echo "ticket: ${TICKET:0:8}..." + +echo "--- 2. Browser exchange at RefBoard ---" +EX=$(wget -qO- --header="Content-Type: application/json" \ + --post-data="{\"ticket\":\"$TICKET\"}" $B/api/auth/ayon/exchange) +echo "$EX" | head -c 300; echo +TOKEN=$(echo "$EX" | sed 's/.*"token":"\([^"]*\)".*/\1/') +BOARD_URL=$(echo "$EX" | sed 's/.*"board_url":"\([^"]*\)".*/\1/') +echo "board_url: $BOARD_URL" + +echo "--- 3. /me with session ---" +wget -qO- --header="Authorization: Bearer $TOKEN" $B/api/auth/me +echo + +echo "--- 4. Fetch board ---" +BOARD_ID=${BOARD_URL##*/} +wget -qO- --header="Authorization: Bearer $TOKEN" $B/api/boards/$BOARD_ID | head -c 200 +echo + +echo "--- 5. Ticket reuse must fail (single-use) ---" +CODE=$(wget -qO- --header="Content-Type: application/json" \ + --post-data="{\"ticket\":\"$TICKET\"}" $B/api/auth/ayon/exchange 2>&1 | tail -1) +echo "reuse response: $CODE" + +echo "--- 6. Second user joins SAME board ---" +T2T=$(wget -qO- --header="Content-Type: application/json" \ + --post-data='{"ayon_user":"artist2","display_name":"Artist Two","project":"lumenfjord","task":"assets/chair/modeling"}' \ + $M/issue | sed 's/.*"ticket":"\([^"]*\)".*/\1/') +EX2=$(wget -qO- --header="Content-Type: application/json" \ + --post-data="{\"ticket\":\"$T2T\"}" $B/api/auth/ayon/exchange) +T2=$(echo "$EX2" | sed 's/.*"token":"\([^"]*\)".*/\1/') +echo "user2 board access:" +wget -qO- --header="Authorization: Bearer $T2" $B/api/boards/$BOARD_ID | head -c 120 +echo + +echo "--- 7. Missing ticket -> 400 ---" +wget -qO- --header="Content-Type: application/json" --post-data="{}" $B/api/auth/ayon/exchange 2>&1 | tail -1 || true + +echo "--- 8. Frontend SPA route /b served ---" +wget -qO- $B/b | head -c 80 +echo +echo "E2E DONE" diff --git a/tests/mock-exchange.js b/tests/mock-exchange.js new file mode 100644 index 0000000..778e2c6 --- /dev/null +++ b/tests/mock-exchange.js @@ -0,0 +1,54 @@ +// Mock AYON addon exchange endpoint for E2E testing. +// POST /issue {ayon_user, display_name, project, task} -> {ticket} +// POST /exchange {ticket} -> identity payload (single-use, TTL 120s) +// NOT FOR PRODUCTION — test double only. +const http = require('http'); +const crypto = require('crypto'); + +const tickets = new Map(); // ticket -> {payload, expires} +const TTL = 120000; + +const json = (res, code, body) => { + const data = JSON.stringify(body); + res.writeHead(code, { 'Content-Type': 'application/json' }); + res.end(data); +}; + +function readBody(req) { + return new Promise((resolve) => { + let raw = ''; + req.on('data', (c) => (raw += c)); + req.on('end', () => { + try { resolve(JSON.parse(raw || '{}')); } catch { resolve({}); } + }); + }); +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://x'); + // Simple API key check on /exchange (mirrors the real addon) + if (url.pathname === '/exchange') { + const key = process.env.REFBOARD_API_KEY || 'testkey'; + if (req.headers['x-api-key'] !== key) { + return json(res, 401, { error: 'bad api key' }); + } + const body = await readBody(req); + const t = tickets.get(body.ticket); + if (!t || t.expires < Date.now()) { + tickets.delete(body.ticket); + return json(res, 404, { error: 'unknown or expired ticket' }); + } + tickets.delete(body.ticket); // single-use + return json(res, 200, t.payload); + } + if (url.pathname === '/issue') { + const body = await readBody(req); + const ticket = crypto.randomBytes(24).toString('hex'); + tickets.set(ticket, { payload: body, expires: Date.now() + TTL }); + return json(res, 200, { ticket }); + } + if (url.pathname === '/health') return json(res, 200, { ok: true }); + json(res, 404, { error: 'not found' }); +}); + +server.listen(9000, () => console.log('mock exchange on 9000'));