/* Prueft Bewertung und Rueckmeldungen. Der wichtigste Fall steht in der Mitte: Ein Creator darf sich NICHT selbst bewerten. Koennte er es, stuende alles auf gruen und die ganze Ampel waere wertlos. Und "noch verbessern" ohne Begruendung muss abgelehnt werden -- eine Bewertung, mit der der Creator nichts anfangen kann, ist nicht streng, sondern nur entmutigend. */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; const ordner = mkdtempSync(join(tmpdir(), "ws-ampel-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); /* DER PORT MUSS MEINER SEIN (nachgeruestet 19.09.2026). Ohne diese Zeile startet der eigene Server bei belegtem Port STILL nicht -- und alles Folgende misst gegen einen fremden Stand. Das ist der dritte Ausgang: belegt ist etwas anderes als kaputt. */ const PORT = await eigenerPort(import.meta, "pruef-ampel"); process.env.PORT = `${PORT}`; process.env.SITE_ACCESS_SECRET = "lokaler-test"; process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; const express = (await import("express")).default; const ec = express.response.cookie; express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; import { notbremse } from "./helfer-notbremse.mjs"; import { eigenerPort } from "./helfer-port.mjs"; await import("./index.js"); /* DIE NOTBREMSE. Muss NACH index.js stehen: Der Server haengt dort zwei Auffangnetze ein, die einen Absturz nur protokollieren -- richtig fuer den Betrieb, fatal fuer eine Pruefung. Ohne diese Zeile bleibt der Prozess nach einem Fehler ewig stehen, weil der Server ihn am Leben haelt (siehe helfer-notbremse.mjs). */ notbremse(480_000, "pruef-ampel"); await new Promise((r) => setTimeout(r, 700)); const BASIS = `http://127.0.0.1:${PORT}`; setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 250_000).unref?.(); /* ORTSZEIT, nicht UTC. Die Anwendung rechnet "heute" nach der Uhr des Benutzers; toISOString() liefert dagegen UTC und damit zwischen Mitternacht und 2 Uhr den VORTAG. In diesem Fenster schlug diese Pruefung fehl, ohne dass etwas kaputt war. */ const tagText = (d = new Date()) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}` + `-${String(d.getDate()).padStart(2, "0")}`; let fehler = 0; const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; await fetch(BASIS + "/workspace/api/anmelden", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes } = await import("node:crypto"); for (let i = 0; i < 60; i++) { try { const p = new DatabaseSync(process.env.WORKSPACE_DB); try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } } catch { await new Promise((r) => setTimeout(r, 250)); } } const d = new DatabaseSync(process.env.WORKSPACE_DB); const jetzt = new Date().toISOString(); function anlegen(name, rolle, code) { const salt = randomBytes(16).toString("hex"); const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") .run(name, rolle, hash, salt, 32768, jetzt); return d.prepare("SELECT last_insert_rowid() AS id").get().id; } const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001"); const idTili = anlegen("Tili", "creator", "CODE-ANNA-0001"); const idFremd = anlegen("Fremd", "creator", "CODE-FREM-0001"); d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idTili, idScout, jetzt); d.prepare(`INSERT INTO eintraege (bereich, art, titel, text, datum, status, creator_id, erstellt, erstellt_von) VALUES ('live','vorbereitung','Ton geprüft','Eine Minute aufnehmen.',?,'offen',?,?,?)`) .run(tagText(), idTili, jetzt, idScout); const eintragId = d.prepare("SELECT last_insert_rowid() AS id").get().id; d.prepare(`INSERT INTO eintraege (bereich, art, titel, text, datum, status, creator_id, erstellt, erstellt_von) VALUES ('live','vorbereitung','Fremder Eintrag','x',?,'offen',?,?,?)`) .run(tagText(), idFremd, jetzt, idDogi); const fremdEintrag = d.prepare("SELECT last_insert_rowid() AS id").get().id; d.close(); const keks = async (rolle, code) => { const a = await fetch(BASIS + "/workspace/api/anmelden", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rolle, code }) }); return (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; "); }; const kDogi = await keks("admin", "CODE-DOGI-0001"); const kScout = await keks("scout", "CODE-SCOU-0001"); const kTili = await keks("creator", "CODE-ANNA-0001"); const ruf = (pfad, k, o = {}) => fetch(BASIS + pfad, { ...o, headers: { Cookie: k, ...(o.headers || {}) } }); const senden = (pfad, k, art, koerper) => ruf(pfad, k, { method: art, headers: { "Content-Type": "application/json" }, body: JSON.stringify(koerper || {}) }); /* ---------- Bewerten ------------------------------------------------------ */ console.log("\n=== Bewerten darf nur die Betreuung"); { /* DER WICHTIGSTE FALL. Koennte ein Creator sich selbst auf gruen setzen, waere die ganze Ampel wertlos. */ const selbst = await senden(`/workspace/api/eintrag/${eintragId}/ampel`, kTili, "PUT", { stufe: "gut" }); ok(selbst.status === 403, `ein Creator kann sich NICHT selbst bewerten (${selbst.status})`); const f = await selbst.json(); ok(/Betreuung/.test(f.fehler || ""), `und erfaehrt, wer es kann ("${f.fehler}")`); const durchBetreuer = await senden(`/workspace/api/eintrag/${eintragId}/ampel`, kScout, "PUT", { stufe: "gut" }); ok(durchBetreuer.ok, `sein Betreuer kann es (${durchBetreuer.status})`); const r = await durchBetreuer.json(); ok(r.ampel.stufe === "gut", `die Stufe steht (${r.ampel.stufe})`); ok(r.ampel.von === idScout, `mit dem Namen des Bewertenden (${r.ampel.von})`); ok(!!r.ampel.am, "und dem Zeitpunkt"); /* Ein Scout nicht bei einem fremden Creator. */ const fremd = await senden(`/workspace/api/eintrag/${fremdEintrag}/ampel`, kScout, "PUT", { stufe: "gut" }); ok(fremd.status === 404, `nicht bei einem fremden Creator (${fremd.status})`); } console.log("\n=== 'Noch verbessern' braucht einen Grund"); { /* Eine Bewertung, mit der der Creator nichts anfangen kann, ist nicht streng, sondern nur entmutigend. */ const ohne = await senden(`/workspace/api/eintrag/${eintragId}/ampel`, kScout, "PUT", { stufe: "verbessern" }); ok(ohne.status === 400, `ohne Begruendung abgelehnt (${ohne.status})`); const f = await ohne.json(); ok(/verbessert/.test(f.fehler || ""), `mit einer Begruendung, warum ("${f.fehler}")`); const mit = await senden(`/workspace/api/eintrag/${eintragId}/ampel`, kScout, "PUT", { stufe: "verbessern", begruendung: "Der Ton rauscht im Hintergrund." }); ok(mit.ok, `mit Begruendung geht es (${mit.status})`); const r = await mit.json(); ok(r.ampel.begruendung === "Der Ton rauscht im Hintergrund.", "und sie wird gespeichert"); /* "gut" darf ohne Grund gesetzt werden -- da gibt es nichts zu erklaeren. */ const gut = await senden(`/workspace/api/eintrag/${eintragId}/ampel`, kScout, "PUT", { stufe: "gut" }); ok(gut.ok, `"Passt so" braucht keinen Grund (${gut.status})`); } /* ---------- Der Creator SIEHT die Bewertung ------------------------------- */ console.log("\n=== Der Creator sieht, wo er steht"); { await senden(`/workspace/api/eintrag/${eintragId}/ampel`, kScout, "PUT", { stufe: "verbessern", begruendung: "Mikro naeher heranstellen." }); const a = await (await ruf(`/workspace/api/eintrag/${eintragId}/rueckmeldung`, kTili)).json(); ok(a.ampel?.stufe === "verbessern", `er sieht die Stufe (${a.ampel?.stufe})`); ok(a.ampel?.begruendung === "Mikro naeher heranstellen.", "und den Grund"); ok(a.darf_bewerten === false, `aber darf sie nicht setzen (${a.darf_bewerten})`); } /* ---------- Nachrichten --------------------------------------------------- */ console.log("\n=== Nachrichten -- beide Seiten"); { const vomCreator = await senden(`/workspace/api/eintrag/${eintragId}/nachricht`, kTili, "POST", { text: "Ich habe ein anderes Mikro bestellt." }); ok(vomCreator.ok, `ein Creator DARF antworten (${vomCreator.status})`); const vomBetreuer = await senden(`/workspace/api/eintrag/${eintragId}/nachricht`, kScout, "POST", { text: "Gut, sag Bescheid wenn es da ist." }); ok(vomBetreuer.ok, `die Betreuung auch (${vomBetreuer.status})`); const { nachrichten } = await vomBetreuer.json(); ok(nachrichten.length === 2, `beide stehen im Verlauf (${nachrichten.length})`); ok(nachrichten[0].von_name === "Tili" && nachrichten[1].von_name === "Patrick", "in der richtigen Reihenfolge und mit Namen"); const leer = await senden(`/workspace/api/eintrag/${eintragId}/nachricht`, kTili, "POST", { text: " " }); ok(leer.status === 400, `eine leere Nachricht wird abgelehnt (${leer.status})`); /* Nur die EIGENE laesst sich zuruecknehmen. Fremde zu loeschen hiesse, ein Gespraech nachtraeglich umzuschreiben. */ const meine = nachrichten.find((n) => n.von === idTili); const fremdeWeg = await ruf(`/workspace/api/eintrag/${eintragId}/nachricht/${meine.id}`, kScout, { method: "DELETE" }); ok(fremdeWeg.status === 403, `eine fremde Nachricht laesst sich nicht loeschen (${fremdeWeg.status})`); const eigeneWeg = await ruf(`/workspace/api/eintrag/${eintragId}/nachricht/${meine.id}`, kTili, { method: "DELETE" }); ok(eigeneWeg.ok, `die eigene schon (${eigeneWeg.status})`); /* Auch die Leitung kann fremde Nachrichten nicht loeschen -- ein Gespraech gehoert denen, die es gefuehrt haben. */ const rest = await (await ruf(`/workspace/api/eintrag/${eintragId}/rueckmeldung`, kDogi)).json(); const vonPatrick = rest.nachrichten.find((n) => n.von === idScout); const dogiWeg = await ruf(`/workspace/api/eintrag/${eintragId}/nachricht/${vonPatrick.id}`, kDogi, { method: "DELETE" }); ok(dogiWeg.status === 403, `auch DogFather nicht (${dogiWeg.status})`); } /* ---------- Fremde Eintraege bleiben unsichtbar --------------------------- */ console.log("\n=== Fremdes bleibt fremd"); { const a = await ruf(`/workspace/api/eintrag/${fremdEintrag}/rueckmeldung`, kTili); ok(a.status === 404, `ein Creator kommt nicht an einen fremden Eintrag -- und erfaehrt nicht, dass es ihn gibt (${a.status})`); const b = await senden(`/workspace/api/eintrag/${fremdEintrag}/nachricht`, kTili, "POST", { text: "hallo" }); ok(b.status === 404, `und kann dort auch nichts schreiben (${b.status})`); } /* ---------- Sammelabfrage -------------------------------------------------- */ console.log("\n=== Sammelabfrage"); { const a = await (await ruf( `/workspace/api/eintrag/ampeln?ids=${eintragId},${fremdEintrag}`, kTili)).json(); ok(!!a.ampeln[eintragId], "der eigene Eintrag ist dabei"); ok(!a.ampeln[fremdEintrag], "der fremde nicht -- auch nicht in der Sammelabfrage"); } /* ---------- Im Browser ----------------------------------------------------- */ console.log("\n=== Die Seite"); const { chromium } = await import( "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const browser = await chromium.launch(); for (const [rolle, code, name, darfBewerten] of [ ["scout", "CODE-SCOU-0001", "Patrick", true], ["creator", "CODE-ANNA-0001", "Tili", false], ]) { const kontext = await browser.newContext({ viewport: { width: 1280, height: 1000 } }); const konsole = []; const seite = await kontext.newPage(); seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); }); seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message)); await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); await seite.click(`.rolle[data-rolle="${rolle}"]`); await seite.fill("#code", code); await seite.click("button[type=submit]"); await seite.waitForURL("**/start.html", { timeout: 15000 }); await seite.goto(BASIS + "/workspace/bereich.html?b=live", { waitUntil: "networkidle" }); await seite.waitForTimeout(1100); const r = await seite.evaluate(() => ({ ampeln: document.querySelectorAll(".ampel").length, stufe: document.querySelector(".ampel")?.dataset.stufe, marke: document.querySelector(".ampel__marke")?.textContent, grund: document.querySelector(".ampel__grund")?.textContent, knopf: !!document.querySelector(".ampel .schritt"), })); ok(r.ampeln >= 1, `${name}: die Ampel steht an der Karte (${r.ampeln})`); ok(r.stufe === "verbessern", ` mit der richtigen Stufe (${r.stufe})`); ok(/verbessern/i.test(r.marke || ""), ` und lesbarem Text ("${r.marke}")`); ok(/Mikro/.test(r.grund || ""), ` der Grund steht dabei ("${r.grund}")`); /* Aufklappen: Wer bewerten darf, sieht die Knoepfe -- wer nicht, den Hinweis. Einem Creator einen Knopf zu zeigen, der beim Klicken 403 sagt, waere schlechter als kein Knopf. */ await seite.click(".ampel .schritt"); await seite.waitForTimeout(600); const auf = await seite.evaluate(() => ({ kasten: !!document.querySelector(".rueckmeldung"), bewerten: document.querySelectorAll(".ampel__knopf").length, hinweis: !!document.querySelector(".rueckmeldung__hinweis"), nachrichten: document.querySelectorAll(".nachricht").length, schreibfeld: !!document.querySelector(".nachricht-neu textarea"), })); ok(auf.kasten, " der Rueckmeldebereich klappt auf"); if (darfBewerten) { ok(auf.bewerten === 2, ` ${name} sieht die Bewertungsknoepfe (${auf.bewerten})`); ok(!auf.hinweis, " und keinen Hinweis, dass er nicht darf"); } else { ok(auf.bewerten === 0, ` ${name} sieht KEINE Bewertungsknoepfe (${auf.bewerten})`); ok(auf.hinweis, " sondern den Hinweis, wer bewertet"); } ok(auf.schreibfeld, " aber beide koennen schreiben"); ok(auf.nachrichten >= 1, ` und sehen den Verlauf (${auf.nachrichten})`); ok(konsole.length === 0, ` keine Fehler in der Konsole${konsole.length ? " -> " + konsole.join(" | ") : ""}`); if (rolle === "scout") await seite.screenshot({ path: "pruef-ampel.png", fullPage: false }); await kontext.close(); } await browser.close(); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } console.log(fehler ? `\n${fehler} Prüfung(en) fehlgeschlagen.` : "\nAlles in Ordnung."); process.exit(fehler ? 1 : 0);