From 3ee09eebc7bb4ad485666f0964c85e523b9fd673 Mon Sep 17 00:00:00 2001 From: Ramon Date: Thu, 6 Aug 2026 16:44:01 +0200 Subject: [PATCH] fix(stacks): geslaagde wijziging niet meer terugdraaien MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Het audit-logboek las request.remote_addr uit in de achtergrondthread van de job. De RuntimeError die daarop volgde belandde in de foutafhandeling, die de zojuist gemaakte backup terugzette — inclusief het oude compose-bestand. - Adres vóór de threadstart vastleggen bij herconfigureren, koppelen, backup maken en terugrollen. - UniFi: init-script probeert de beheerders 'unifi' en 'root' en herkent een server zonder authenticatie, zodat ook een oud Mongo-volume gerepareerd wordt; bestaande rollen blijven staan en 'backup'/'restore' worden aangevuld. - UniFi: healthcheck van Mongo zonder inloggegevens, anders bleef de container ongezond en startte de reparatiecontainer nooit. - Backups: mongodump/mongorestore gebruiken de beheerder uit compose in plaats van de verzonnen gebruiker 'app'. - Appkaart: knop die de webinterface opent, met het adres uit /api/stacks. - Joblog: geen dubbele regels meer doordat polls elkaar konden overlappen. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT --- CHANGELOG.md | 28 ++++++++ VERSION | 2 +- apps/unifi-network/files/compose.yaml | 13 +++- apps/unifi-network/files/init-mongo.sh | 84 ++++++++++++++++++++---- server-up/app.py | 49 ++++++++++++-- server-up/core/backups.py | 9 ++- server-up/templates/index.html | 19 +++++- server-up/translations/en.json | 1 + server-up/translations/nl.json | 1 + tests/js/joblog.test.js | 66 +++++++++++++++++++ tests/test_apps.py | 38 ++++++++++- tests/test_backups.py | 31 +++++++++ tests/test_reconfigure.py | 91 ++++++++++++++++++++++++++ tests/test_stack_api.py | 38 +++++++++++ tests/test_ui_logica.py | 11 ++++ 15 files changed, 452 insertions(+), 29 deletions(-) create mode 100644 tests/js/joblog.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 04b0595..db157c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,31 @@ +# v0.8.21-beta — Wijzigingen blijven staan, UniFi komt online + +Een geslaagde instellingenwijziging werd meteen weer teruggedraaid: het +audit-logboek las het IP-adres van de aanvrager pas uit in de achtergrondthread, +waar geen request meer bestaat. De `RuntimeError` die daarop volgde kwam in de +foutafhandeling terecht, die netjes de backup terugzette — inclusief het oude +compose-bestand. De MongoDB-reparatie van v0.8.20 kon zo nooit landen. + +- **Het adres wordt vóór de job vastgelegd.** Herconfigureren, koppelen, een + backup maken en terugrollen loggen weer wie de actie startte, zonder crash. +- **UniFi repareert ook een oud database-volume.** Het init-script probeert + achtereenvolgens de beheerders `unifi` en `root` en herkent een server zonder + authenticatie, in plaats van te veronderstellen dat `root` bestaat. +- **De healthcheck van Mongo gebruikt geen inloggegevens meer.** Op een volume + zonder de gebruiker `root` bleef de container ongezond, waardoor de + reparatiecontainer nooit startte. +- **Bestaande rollen blijven staan.** De reparatie trekt alleen het wachtwoord + gelijk en vult ontbrekende rollen aan — inclusief `backup` en `restore`, die + mongodump en mongorestore nodig hebben. +- **Backups van MongoDB werken.** Het dumpcommando gebruikt de beheerder uit + het compose-bestand in plaats van de verzonnen gebruiker `app`. +- **Elke app met een webinterface krijgt een openknop op de kaart.** Het adres + stond alleen in de modal met inloggegevens, waar je niet kijkt als je de app + gewoon wilt openen. +- **Geen dubbele regels meer in het joblog.** Bij een trage stap vertrokken + meerdere polls met dezelfde offset, waardoor hetzelfde blok tot vijf keer in + beeld kwam. + # v0.8.20-beta — UniFi herstelt zijn MongoDB-gebruiker Een eerder geïnitialiseerd Mongo-volume voerde het init-script niet opnieuw uit. diff --git a/VERSION b/VERSION index 3c2e9ad..88e6b40 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.20-beta +0.8.21-beta diff --git a/apps/unifi-network/files/compose.yaml b/apps/unifi-network/files/compose.yaml index a1d8b98..ade0e5f 100644 --- a/apps/unifi-network/files/compose.yaml +++ b/apps/unifi-network/files/compose.yaml @@ -30,7 +30,10 @@ services: image: mongo:4.4.29 container_name: << service_name >>-mongo environment: - - MONGO_INITDB_ROOT_USERNAME=root + # Dezelfde beheerdersnaam als het allereerste sjabloon gebruikte. Zo + # passen een vers volume en een volume van een oudere installatie op + # dezelfde inloggegevens — ook die van de backup (mongodump). + - MONGO_INITDB_ROOT_USERNAME=unifi - MONGO_INITDB_ROOT_PASSWORD=<< mongo_password >> - MONGO_USER=unifi - MONGO_PASS=<< mongo_password >> @@ -40,9 +43,13 @@ services: - << service_name >>_mongodata:/data/db - ./init-mongo.sh:/docker-entrypoint-initdb.d/init-mongo.sh:ro healthcheck: + # Bewust zonder inloggegevens: `ping` mag ook zonder aanmelden. Een + # volume dat door een oudere versie van dit sjabloon is aangemaakt kent + # de gebruiker 'root' niet, en dan bleef deze container voor altijd + # ongezond — waardoor de reparatiecontainer hieronder nooit startte. test: - CMD-SHELL - - 'mongo --quiet --username root --password "$${MONGO_INITDB_ROOT_PASSWORD}" --authenticationDatabase admin --eval "db.adminCommand({ ping: 1 })"' + - 'mongo --quiet --eval "db.adminCommand({ ping: 1 })"' interval: 10s timeout: 5s retries: 12 @@ -53,7 +60,7 @@ services: image: mongo:4.4.29 container_name: << service_name >>-mongo-init environment: - - MONGO_INITDB_ROOT_USERNAME=root + - MONGO_INITDB_ROOT_USERNAME=unifi - MONGO_INITDB_ROOT_PASSWORD=<< mongo_password >> - MONGO_USER=unifi - MONGO_PASS=<< mongo_password >> diff --git a/apps/unifi-network/files/init-mongo.sh b/apps/unifi-network/files/init-mongo.sh index 1debf7a..3025451 100755 --- a/apps/unifi-network/files/init-mongo.sh +++ b/apps/unifi-network/files/init-mongo.sh @@ -18,32 +18,88 @@ if [[ -n "${MONGO_HOST:-}" ]]; then fi "${mongo_init_bin}" "${mongo_args[@]}" < naam && alle.indexOf(naam) === i); + +function aanmelden() { + for (const naam of kandidaten) { + try { + if (adminDb.auth(naam, beheerWachtwoord)) { + return naam; + } + } catch (e) { + // Onbekende gebruiker of verkeerd wachtwoord: volgende kandidaat. + } + } + try { + // Draait deze server nog zonder authenticatie, dan mogen we zo verder. + adminDb.getUsers(); + return "(zonder aanmelden)"; + } catch (e) { + return ""; + } +} + +const beheerder = aanmelden(); +if (!beheerder) { + print(""); + print("FOUT: geen enkele MongoDB-beheerder kon inloggen op dit volume."); + print("Het wachtwoord in .env hoort niet bij de database die er al staat."); + print("Zet het oorspronkelijke wachtwoord terug in .env, of verwijder het"); + print("volume om opnieuw te beginnen (dat wist wel je UniFi-gegevens)."); + print(""); quit(18); } +print("MongoDB-beheerder: " + beheerder); const roles = [ { db: "admin", role: "clusterMonitor" }, - { db: "${MONGO_DBNAME}", role: "dbOwner" }, - { db: "${MONGO_DBNAME}_stat", role: "dbOwner" }, - { db: "${MONGO_DBNAME}_audit", role: "dbOwner" }, - { db: "${MONGO_DBNAME}_restore", role: "dbOwner" } + // Zonder deze twee kan Server Up geen mongodump/mongorestore draaien voor + // de backup van deze stack. + { db: "admin", role: "backup" }, + { db: "admin", role: "restore" }, + { db: appDatabase, role: "dbOwner" }, + { db: appDatabase + "_stat", role: "dbOwner" }, + { db: appDatabase + "_audit", role: "dbOwner" }, + { db: appDatabase + "_restore", role: "dbOwner" } ]; function ensureUser(database) { const userDb = db.getSiblingDB(database); - const existing = userDb.getUser("${MONGO_USER}"); - if (existing) { - userDb.updateUser("${MONGO_USER}", { pwd: "${MONGO_PASS}", roles: roles }); - } else { - userDb.createUser({ user: "${MONGO_USER}", pwd: "${MONGO_PASS}", roles: roles }); + const bestaand = userDb.getUser(appNaam); + if (!bestaand) { + userDb.createUser({ user: appNaam, pwd: appWachtwoord, roles: roles }); + print("Gebruiker " + appNaam + " aangemaakt in " + database + "."); + return; } + // Alleen het wachtwoord gelijktrekken en ontbrekende rollen aanvullen. De + // rollen overschrijven zou de beheerder van een oudere installatie + // degraderen, waarna onder meer de backup geen dump meer kan maken. + userDb.updateUser(appNaam, { pwd: appWachtwoord }); + const missend = roles.filter( + (r) => !(bestaand.roles || []).some((b) => b.role === r.role && b.db === r.db)); + if (missend.length) { + userDb.grantRolesToUser(appNaam, missend); + } + print("Gebruiker " + appNaam + " bijgewerkt in " + database + "."); } -ensureUser("${MONGO_AUTHSOURCE}"); -if ("${MONGO_AUTHSOURCE}" !== "${MONGO_DBNAME}") { - ensureUser("${MONGO_DBNAME}"); +ensureUser(authBron); +if (authBron !== appDatabase) { + ensureUser(appDatabase); } EOF <% endraw %> diff --git a/server-up/app.py b/server-up/app.py index 0bafae2..296f50c 100644 --- a/server-up/app.py +++ b/server-up/app.py @@ -449,13 +449,15 @@ def api_update_rollback(): return jsonify(ok=False, msg="Geen vorige versie bekend"), 400 jid, q = jobs.create("update:rollback") + # De job draait in een eigen thread; `request` bestaat daar niet meer. + ip = request.remote_addr def _run(jid): qq = jobs.get_queue(jid) ok, msg = selfupdate.rollback(log_fn=lambda m: jobs.log(qq, "dim", m)) jobs.log(qq, "ok" if ok else "error", msg) audit.log("update", "rollback", "ok" if ok else "error", - ref=st.get("previous_tag", ""), ip=request.remote_addr) + ref=st.get("previous_tag", ""), ip=ip) jobs.finish(jid, "done" if ok else "error") jobs.done(qq) @@ -671,6 +673,32 @@ def api_stacks(): return h["logo_url"], h["icon"] return "", "" + # Het browseradres per stack. De bronmetadata wordt per sjabloon onthouden: + # dezelfde app twee keer geïnstalleerd zou anders twee keer de git-cache + # doorzoeken. + host = request.host.split(":")[0] + bron_meta_cache: dict[str, dict] = {} + + def _bron_meta(meta: dict) -> dict: + sleutel = f"{meta.get('repo_id', '')}/{meta.get('source', '')}" + if sleutel not in bron_meta_cache: + uit = {} + try: + src = _find_stack_src(meta.get("source", ""), meta.get("repo_id", "")) + if src: + uit = boilerplates.metadata(src) + except Exception: + uit = {} + bron_meta_cache[sleutel] = uit + return bron_meta_cache[sleutel] + + def _web_url(d, meta: dict) -> str: + try: + return _stack_webadres(meta, _bron_meta(meta), + docker.read_compose(d), host) + except Exception: + return "" + out = [] for d in sorted(lib.iterdir()): if not d.is_dir() or d.name.startswith("."): @@ -693,6 +721,10 @@ def api_stacks(): "containers": ct, "logo_url": logo_url, "icon": icon, "network": meta.get("network", ""), "ip": meta.get("ip", ""), + # Waar de webinterface van deze app draait; leeg als de app er geen + # heeft. Stond eerder alleen in de modal met inloggegevens, waar je + # hem niet zoekt als je gewoon de app wilt openen. + "web_url": _web_url(d, meta) if heeft_compose else "", # Bij installatie vastgelegd; ontbreekt dat (oudere installatie), # dan leiden we het alsnog af uit de opgeslagen naam. "connected": bool(meta.get("connected")), @@ -1132,6 +1164,11 @@ def api_stack_reconfigure(name): missing=ontbreekt), 400 jid, q = jobs.create(f"reconfigure:{name}") + # De job draait in een eigen thread; `request` bestaat daar niet meer. + # Uitlezen ná de start gaf een RuntimeError precies nadat de stack met de + # nieuwe instellingen al draaide — en de foutafhandeling zette dan de + # backup terug, dus een geslaagde wijziging werd alsnog teruggedraaid. + ip = request.remote_addr def _run(jid): qq = jobs.get_queue(jid) @@ -1220,7 +1257,7 @@ def api_stack_reconfigure(name): "Herstart met de nieuwe instellingen" if rc == 0 else "Starten mislukt \u2014 zet de backup terug via Backups") audit.log("stacks", "reconfigure", "ok" if rc == 0 else "error", - ref=name, ip=request.remote_addr) + ref=name, ip=ip) jobs.finish(jid, "done" if rc == 0 else "error") except Exception as e: jobs.log(qq, "error", f"{type(e).__name__}: {e}") @@ -1251,6 +1288,8 @@ def api_stack_connect(name): gedeeld_net = (cfg.load().get("SHARED_NETWORK") or "serverup").strip() jid, q = jobs.create(f"connect:{name}") + # De job draait in een eigen thread; `request` bestaat daar niet meer. + ip = request.remote_addr def _run(jid): qq = jobs.get_queue(jid) @@ -1283,7 +1322,7 @@ def api_stack_connect(name): jobs.log(qq, "ok", f"Gekoppeld aan '{gedeeld_net}' \u2014 andere apps zijn nu " "bereikbaar op hun containernaam.") - audit.log("stacks", "connect", "ok", ref=name, ip=request.remote_addr) + audit.log("stacks", "connect", "ok", ref=name, ip=ip) jobs.finish(jid, "done") except Exception as e: jobs.log(qq, "error", str(e)) @@ -2213,6 +2252,8 @@ def api_backups(): @app.route("/api/backups//create", methods=["POST"]) def api_backup_create(stack): jid, q = jobs.create(f"backup:{stack}") + # De job draait in een eigen thread; `request` bestaat daar niet meer. + ip = request.remote_addr def _run(jid): qq = jobs.get_queue(jid) @@ -2222,7 +2263,7 @@ def api_backup_create(stack): log_fn=lambda m: jobs.log(qq, "dim", m)) backups.prune(stack, log_fn=lambda m: jobs.log(qq, "dim", m)) jobs.log(qq, "ok", f"Backup klaar ({meta['file']})") - audit.log("backups", "create", "ok", ref=stack, ip=request.remote_addr) + audit.log("backups", "create", "ok", ref=stack, ip=ip) jobs.finish(jid, "done") except backups.BackupError as e: jobs.log(qq, "error", str(e)) diff --git a/server-up/core/backups.py b/server-up/core/backups.py index fe61edd..c214876 100644 --- a/server-up/core/backups.py +++ b/server-up/core/backups.py @@ -159,7 +159,10 @@ _DUMPERS = { "postgis": ("POSTGRES_USER", "POSTGRES_DB", "POSTGRES_PASSWORD"), "mariadb": ("MARIADB_USER", "MARIADB_DATABASE", "MARIADB_PASSWORD"), "mysql": ("MYSQL_USER", "MYSQL_DATABASE", "MYSQL_PASSWORD"), - "mongo": (None, None, "MONGO_INITDB_ROOT_PASSWORD"), + # De beheerdersnaam stond hier op None; dan viel het dump-commando terug op + # de verzonnen gebruiker 'app' en mislukte elke mongodump met + # "Authentication failed". + "mongo": ("MONGO_INITDB_ROOT_USERNAME", None, "MONGO_INITDB_ROOT_PASSWORD"), } @@ -235,7 +238,7 @@ def _dump_commando(dienst: dict) -> tuple[list[str], dict]: return ([hulp, "-u", gb or "root", f"-p{ww}", "--single-transaction", db or gb], {}) return (["sh", "-c", - f"mongodump --archive --username='{gb or 'app'}' " + f"mongodump --archive --username='{gb or 'root'}' " f"--password='{ww}' --authenticationDatabase=admin"], {}) @@ -798,7 +801,7 @@ def _herstel_commando(dienst: dict) -> list[str]: hulp = "mariadb" if s == "mariadb" else "mysql" return ["sh", "-c", f"{hulp} -u '{gb or 'root'}' -p'{ww}' '{db or gb}'"] return ["sh", "-c", - f"mongorestore --archive --drop --username='{gb or 'app'}' " + f"mongorestore --archive --drop --username='{gb or 'root'}' " f"--password='{ww}' --authenticationDatabase=admin"] diff --git a/server-up/templates/index.html b/server-up/templates/index.html index 1f45aa7..ddf78c0 100644 --- a/server-up/templates/index.html +++ b/server-up/templates/index.html @@ -395,6 +395,13 @@ tailwind.config = { + + + + @@ -2926,8 +2933,18 @@ function app() { this.job = { id: jid, lines: [], status: 'running', timer: null }; this.termOpen = true; let off = 0; + // De offset schuift pas op als het antwoord binnen is. Zonder deze vlag + // vertrekken er bij een trage stap meerdere polls met dezelfde offset en + // komt hetzelfde blok regels twee, drie of vijf keer in beeld. + let bezig = false; this.job.timer = setInterval(async () => { - const d = await this.rpc(`/api/jobs/${jid}?offset=${off}`); + if (bezig) return; + bezig = true; + let d; + try { + d = await this.rpc(`/api/jobs/${jid}?offset=${off}`); + } finally { bezig = false; } + if (!d) return; (d.lines || []).forEach(l => { this.job.lines.push(l); off++; }); if (d.status && d.status !== 'running') { clearInterval(this.job.timer); this.job.timer = null; diff --git a/server-up/translations/en.json b/server-up/translations/en.json index 6e90a78..016b6ee 100644 --- a/server-up/translations/en.json +++ b/server-up/translations/en.json @@ -303,6 +303,7 @@ "manual_edit_warning": "You are editing the result yourself. If you change a field after this, your edit will be overwritten.", "no_credentials_hint": "This app has no preset credentials: you create an account yourself on first visit.", "nothing_here": "(empty)", + "open_web": "Open web interface", "nothing_to_choose": "Nothing to configure here.", "one_page": "All on one page", "preview": "View", diff --git a/server-up/translations/nl.json b/server-up/translations/nl.json index 95c849c..222bdad 100644 --- a/server-up/translations/nl.json +++ b/server-up/translations/nl.json @@ -303,6 +303,7 @@ "manual_edit_warning": "Je bewerkt het resultaat zelf. Wijzig je hierna nog een veld, dan wordt je aanpassing overschreven.", "no_credentials_hint": "Deze app heeft geen vooraf ingestelde inloggegevens: je maakt bij het eerste bezoek zelf een account aan.", "nothing_here": "(leeg)", + "open_web": "Webinterface openen", "nothing_to_choose": "Hier valt niets in te stellen.", "one_page": "Alles op één pagina", "preview": "Bekijken", diff --git a/tests/js/joblog.test.js b/tests/js/joblog.test.js new file mode 100644 index 0000000..18d7da5 --- /dev/null +++ b/tests/js/joblog.test.js @@ -0,0 +1,66 @@ +// Draait de echte Alpine-component uit index.html en toetst dat pollJob niet +// twee keer tegelijk vraagt. Wordt aangeroepen door tests/test_ui_logica.py; +// los draaien kan met: node tests/js/joblog.test.js +const fs = require('fs'); +const html = fs.readFileSync(__dirname + '/../../server-up/templates/index.html', 'utf8'); +const start = html.indexOf('function app() {'); +const eind = html.indexOf('', start); +const bron = html.slice(start, eind); +global.localStorage = { getItem: () => null, setItem: () => {} }; +global.location = { hash: '', search: '' }; +global.window = { matchMedia: () => ({matches:false, addEventListener(){}}), + addEventListener(){}, innerWidth: 1200 }; +global.document = { documentElement: {classList:{add(){},remove(){}}}, + addEventListener(){}, querySelector: () => null }; +global.navigator = { clipboard: { writeText: async () => {} }, language: 'nl' }; +global.crypto = require('crypto').webcrypto; +eval(bron); + +let fouten = 0; +const check = (naam, waar) => { if (!waar) { fouten++; console.log(' ✖ ' + naam); } + else console.log(' ✓ ' + naam); }; +const rustig = () => new Promise(r => setTimeout(r, 0)); + +(async () => { + const c = app(); + c.t = (k) => k; + c.$nextTick = (fn) => fn(); + c.$refs = {}; + + // De tikken van setInterval zelf in de hand houden: zo kunnen we een tweede + // tik afvuren terwijl het antwoord op de eerste nog onderweg is — precies + // wat er gebeurt bij een trage stap zoals een backup terugzetten. + let tik = null; + global.setInterval = (fn) => { tik = fn; return 1; }; + global.clearInterval = () => {}; + + const openstaand = []; + c.rpc = () => new Promise(res => openstaand.push(res)); + + c.pollJob('abc123'); + tik(); await rustig(); + tik(); await rustig(); + tik(); await rustig(); + + check('maar één verzoek tegelijk onderweg', openstaand.length === 1); + + openstaand.forEach(res => res({lines: [{level: 'dim', text: 'Backup wordt teruggezet…'}], + status: 'running'})); + await rustig(); + + check('de regel staat er één keer', + c.job.lines.filter(l => l.text === 'Backup wordt teruggezet…').length === 1); + + // Daarna schuift de offset op en mag er weer gevraagd worden. + tik(); await rustig(); + check('volgende tik vraagt verder vanaf de nieuwe offset', openstaand.length === 2); + + let klaar = ''; + c.job.timer = 1; + openstaand[1]({lines: [], status: 'done'}); + await rustig(); + check('afgeronde job stopt de poll', c.job.status === 'done'); + + console.log(fouten === 0 ? '\nAlle joblogcontroles geslaagd' : `\n${fouten} FOUTEN`); + process.exit(fouten ? 1 : 0); +})(); diff --git a/tests/test_apps.py b/tests/test_apps.py index c4da160..5b3f531 100644 --- a/tests/test_apps.py +++ b/tests/test_apps.py @@ -3,6 +3,7 @@ Deze suite draait over de echte templates, niet over voorbeelden. Zo valt een kapot template op vóórdat iemand het probeert te installeren. """ +import re import subprocess import sys import tempfile @@ -377,6 +378,24 @@ def test_huidige_versie_heeft_een_changelog_sectie(): assert f"# v{versie}" in changelog, f"geen changelog-sectie voor {versie}" +def _controleer_javascript_van_initscript(script: str): + """`bash -n` ziet de JavaScript in de heredoc niet — node wel. + + Een typefout daarin blijkt anders pas op de server, wanneer de reparatie + van de database stilletjes niets doet. + """ + import shutil + node = shutil.which("node") + if not node: + pytest.skip("node niet beschikbaar") + binnen = script.split("<>:\n image: nginx\n' + ' ports:\n - "<< port >>:80"\n', encoding="utf-8") + + r = client.post("/api/store/install", + json={"stack": "keuzeapp", "repo_id": "server-up", + "instance": "kz", "values": {"port": poort}}, + headers={"X-CSRF-Token": csrf}) + assert r.status_code == 200, r.get_json() + _wacht_op_job(client, r.get_json()["job_id"]) + + +def test_geslaagde_wijziging_wordt_niet_teruggedraaid(client, env, monkeypatch): + """De job draait in een eigen thread, waar `request` niet meer bestaat. + + Het adres voor het audit-logboek werd daar alsnog uitgelezen: een + RuntimeError precies nádat de stack met de nieuwe instellingen al draaide. + De foutafhandeling zette vervolgens de backup terug, dus een geslaagde + wijziging werd alsnog ongedaan gemaakt. + """ + import app as app_module + csrf = login(client) + _installeer_keuzeapp(client, csrf) + + monkeypatch.setattr(app_module.docker, "validate_compose", + lambda *a, **k: (True, "")) + monkeypatch.setattr(app_module.docker, "compose_down", lambda *a, **k: 0) + monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 0) + monkeypatch.setattr(app_module.backups, "create", + lambda *a, **k: {"file": "kz_test.tar.gz"}) + monkeypatch.setattr(app_module.backups, "restore", + lambda *a, **k: pytest.fail("backup werd teruggezet")) + + r = client.post("/api/stacks/kz/reconfigure", + json={"values": {"service_name": "keuzeapp", "port": 9124}, + "connect_apps": False}, + headers={"X-CSRF-Token": csrf}) + assert r.status_code == 200, r.get_json() + d = _wacht_op_job(client, r.get_json()["job_id"]) + + tekst = "\n".join(r["text"] for r in d.get("lines", [])) + assert "RuntimeError" not in tekst, tekst + assert d.get("status") == "done", tekst + compose = next((p for p in (env["lib"] / "kz").iterdir() + if p.name in ("compose.yaml", "compose.yml", + "docker-compose.yml", "docker-compose.yaml"))) + assert '"9124:80"' in compose.read_text() + + +def test_wijziging_wordt_vastgelegd_met_het_adres_van_de_aanvrager( + client, env, monkeypatch): + """Het IP-adres hoort in het audit-logboek te belanden, niet verloren te + gaan doordat we het pas in de achtergrondthread opvragen.""" + import app as app_module + from core import audit + audit.init() + csrf = login(client) + _installeer_keuzeapp(client, csrf) + + monkeypatch.setattr(app_module.docker, "validate_compose", + lambda *a, **k: (True, "")) + monkeypatch.setattr(app_module.docker, "compose_down", lambda *a, **k: 0) + monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 0) + monkeypatch.setattr(app_module.backups, "create", + lambda *a, **k: {"file": "kz_test.tar.gz"}) + + r = client.post("/api/stacks/kz/reconfigure", + json={"values": {"service_name": "keuzeapp", "port": 9125}, + "connect_apps": False}, + headers={"X-CSRF-Token": csrf}) + _wacht_op_job(client, r.get_json()["job_id"]) + + regels = [x for x in audit.query(50, src="stacks") + if x["action"] == "reconfigure"] + assert regels and regels[0]["status"] == "ok" + assert regels[0]["ip"] + + # ── Koppelen ───────────────────────────────────────────────────────────────── def test_connect_van_onbekende_stack(client): diff --git a/tests/test_stack_api.py b/tests/test_stack_api.py index c3474ed..a183158 100644 --- a/tests/test_stack_api.py +++ b/tests/test_stack_api.py @@ -117,3 +117,41 @@ def test_stack_meldt_wat_er_niet_meer_draait(client, env, monkeypatch): (mq / ".serverup.json").write_text(_json.dumps({"source": "mosquitto"})) st = {s["name"]: s for s in client.get("/api/stacks").get_json()} assert st["z2m"]["depends_missing"] == [] + + +def test_stacklijst_geeft_het_webadres_van_de_app(client, env): + """Het adres stond alleen in de modal met inloggegevens; op de kaart hoort + een knop te staan die de app opent — dus moet de lijst het adres kennen.""" + import json + d = env["lib"] / "unifi-network" + d.mkdir(parents=True, exist_ok=True) + (d / "docker-compose.yml").write_text( + "services:\n" + " unifi-mongo:\n" + " image: mongo:4.4.29\n" + " unifi:\n" + " image: lscr.io/linuxserver/unifi-network-application:latest\n" + " ports:\n" + ' - "8443:8443"\n' + ' - "8080:8080"\n') + (d / ".serverup.json").write_text(json.dumps({ + "source": "unifi-network", "values": {"port_ui": 8443}, + "web": {"scheme": "https", "port": 8443}})) + login(client) + + per_naam = {s["name"]: s for s in client.get("/api/stacks").get_json()} + assert per_naam["unifi-network"]["web_url"].startswith("https://") + assert per_naam["unifi-network"]["web_url"].endswith(":8443") + + +def test_stack_zonder_gepubliceerde_poort_heeft_geen_webadres(client, env): + import json + d = env["lib"] / "worker" + d.mkdir(parents=True, exist_ok=True) + (d / "docker-compose.yml").write_text( + "services:\n worker:\n image: busybox\n") + (d / ".serverup.json").write_text(json.dumps({"source": "worker"})) + login(client) + + per_naam = {s["name"]: s for s in client.get("/api/stacks").get_json()} + assert per_naam["worker"]["web_url"] == "" diff --git a/tests/test_ui_logica.py b/tests/test_ui_logica.py index 05a4f12..930806c 100644 --- a/tests/test_ui_logica.py +++ b/tests/test_ui_logica.py @@ -21,6 +21,17 @@ def test_invulmenu_logica(): assert r.returncode == 0, r.stdout + r.stderr +@pytest.mark.skipif(not shutil.which("node"), reason="node ontbreekt") +def test_joblog_vraagt_niet_twee_keer_tegelijk(): + """Elke 400 ms een nieuwe poll starten terwijl de vorige nog loopt, laat + meerdere verzoeken met dezelfde offset vertrekken. Bij een trage stap — + een backup terugzetten bijvoorbeeld — kwam hetzelfde blok logregels dan + twee tot vijf keer in beeld.""" + r = subprocess.run(["node", str(WORTEL / "tests" / "js" / "joblog.test.js")], + capture_output=True, text=True, timeout=60, cwd=str(WORTEL)) + assert r.returncode == 0, r.stdout + r.stderr + + @pytest.mark.skipif(not shutil.which("node"), reason="node ontbreekt") def test_installatiemodal_in_een_echte_dom(): """Rendert de modal met Alpine en klikt op Volgende.