/* Prueft den eigenen Steckbrief: Bild, Satz ueber sich, Kanaele -- und vor allem die Stellen, an denen Profilbilder typischerweise scheitern. Der wichtigste Teil ist der letzte: Ein umbenanntes SVG mit Skript darin MUSS abgelehnt werden. Wuerde es durchkommen und der Browser liefe es aus, koennte es im Namen der Domain handeln -- mit der Sitzung des Betrachters. */ import { mkdtempSync, rmSync, readdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; const ordner = mkdtempSync(join(tmpdir(), "ws-sb-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); process.env.PORT = "4196"; 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"; 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-steckbrief"); await new Promise((r) => setTimeout(r, 700)); const BASIS = "http://127.0.0.1:4196"; setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 200_000).unref?.(); 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"); 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.close(); /* ---------- Anmelden ohne Browser ---------------------------------------- */ async function anmelden(code, rolle) { const a = await fetch(BASIS + "/workspace/api/anmelden", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rolle, code }) }); const keks = (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; "); return keks; } const kDogi = await anmelden("CODE-DOGI-0001", "admin"); const kScout = await anmelden("CODE-SCOU-0001", "scout"); const kTili = await anmelden("CODE-ANNA-0001", "creator"); const hole = (pfad, keks, optionen = {}) => fetch(BASIS + pfad, { ...optionen, headers: { Cookie: keks, ...(optionen.headers || {}) } }); /* ---------- Der eigene Steckbrief ---------------------------------------- */ console.log("\n=== Jeder hat sein eigenes Profil"); for (const [name, keks, rolle] of [["Filipe", kDogi, "admin"], ["Patrick", kScout, "scout"], ["Tili", kTili, "creator"]]) { const a = await hole("/workspace/api/steckbrief/mein", keks); const { steckbrief, kanaele } = await a.json(); ok(a.ok && steckbrief.name === name && steckbrief.rolle === rolle, `${name} (${rolle}) hat ein eigenes Profil`); ok(kanaele?.length === 4, ` und vier Kanäle zur Auswahl (${kanaele?.length})`); } /* ---------- Kanäle eintragen --------------------------------------------- */ console.log("\n=== Kanäle"); const patch = (keks, koerper) => hole("/workspace/api/steckbrief/mein", keks, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(koerper) }); { const a = await patch(kTili, { tiktok: "tili.live", ueber_mich: "Ich streame abends." }); const { steckbrief } = await a.json(); const tt = steckbrief.kanaele.find((k) => k.art === "tiktok"); ok(a.ok && tt?.handle === "tili.live", `TikTok-Name gespeichert (${tt?.handle})`); ok(tt?.adresse === "https://www.tiktok.com/@tili.live", `und daraus wird ein Verweis (${tt?.adresse})`); ok(steckbrief.ueber_mich === "Ich streame abends.", "der Satz über sich steht auch"); } /* Was Leute wirklich einfuegen: ein @ davor oder gleich die ganze Adresse. Beides muss angenommen und zurueckgefuehrt werden -- eine Fehlermeldung fuer ein vorangestelltes @ waere schlechte Arbeit. */ for (const [eingabe, erwartet] of [ ["@tili.live", "tili.live"], ["https://www.tiktok.com/@tili.live", "tili.live"], ["https://www.tiktok.com/@tili.live?lang=de&is_from_webapp=1", "tili.live"], ["https://www.tiktok.com/@tili.live#etwas", "tili.live"], [" tili.live ", "tili.live"], ]) { const a = await patch(kTili, { tiktok: eingabe }); const antwort = await a.json().catch(() => ({})); const tt = antwort.steckbrief?.kanaele.find((k) => k.art === "tiktok"); /* Nicht destrukturieren, ohne den Fehlerfall abzufangen: Beim ersten Lauf schlug genau hier die Pruefung selbst mit einem Absturz fehl, statt "FEHL" zu melden -- und ein abgestuerzter Lauf sagt nichts darueber, was danach noch geprueft worden waere. */ ok(a.ok && tt?.handle === erwartet, `"${eingabe.slice(0, 42)}" wird zu "${tt?.handle ?? antwort.fehler}"`); } /* Unsinn muss abgelehnt werden -- sonst steht in der Liste ein Verweis, der ins Leere fuehrt. */ { const a = await patch(kTili, { tiktok: "hat leerzeichen und ümläute" }); ok(a.status === 400, `ein unmöglicher Name wird abgelehnt (${a.status})`); const b = await patch(kTili, { tiktok: "" }); const { steckbrief } = await b.json(); ok(b.ok && !steckbrief.kanaele.some((k) => k.art === "tiktok"), "und leeren heisst löschen, nicht Fehler"); } /* ---------- Wer darf wen sehen? ------------------------------------------ */ console.log("\n=== Sichtbarkeit"); { const alle = await (await hole("/workspace/api/steckbrief", kDogi)).json(); ok(alle.steckbriefe.length === 4, `DogFather sieht alle vier (${alle.steckbriefe.length})`); const scout = await (await hole("/workspace/api/steckbrief", kScout)).json(); const namen = scout.steckbriefe.map((s) => s.name).sort(); ok(namen.includes("Patrick") && namen.includes("Tili"), `der Scout sieht sich und seinen Creator (${namen.join(", ")})`); ok(!namen.includes("Fremd"), " aber nicht den fremden Creator"); const tili = await (await hole("/workspace/api/steckbrief", kTili)).json(); const tnamen = tili.steckbriefe.map((s) => s.name).sort(); ok(tnamen.includes("Tili") && tnamen.includes("Patrick"), `der Creator sieht sich und seinen Betreuer (${tnamen.join(", ")})`); ok(!tnamen.includes("Fremd"), " aber keinen anderen Creator"); /* 404, nicht 403: Wer eine Person nicht sehen darf, soll nicht erfahren, dass es sie gibt. */ const einzeln = await hole(`/workspace/api/steckbrief/${idFremd}`, kTili); ok(einzeln.status === 404, `ein fremdes Profil ist 404, nicht 403 (${einzeln.status})`); } /* Niemand schreibt einem anderen ins Profil -- auch DogFather nicht. Es gibt schlicht keinen Weg dafuer. */ { const a = await hole(`/workspace/api/steckbrief/${idTili}`, kDogi, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ueber_mich: "fremd geschrieben" }) }); ok(a.status === 404 || a.status === 405, `auch DogFather kann fremde Profile nicht beschreiben (${a.status})`); } /* ---------- Das Bild ------------------------------------------------------ */ console.log("\n=== Profilbild"); /* Ein GROSSES echtes PNG (1200x1200). Der erste Anlauf nahm ein 1x1-Pixel-Bild -- klein, schnell, von Hand gebaut. Es hat aber den schlimmsten Fehler durchgelassen, den es beim Profilbild gibt: Die Regel .wer__bild in der Kopfleiste fehlte komplett, ein ohne Groessenangabe nimmt seine natuerliche Groesse an, und das Bild lag ueber der halben Startseite. Ein 1x1-Bild kann nichts ueberdecken -- die Pruefung war gruen, der Fehler war da, und gesehen hat ihn Filipe. Deshalb jetzt ein Bild in der Groesse, die wirklich hochgeladen wird. Erzeugt statt eingecheckt: keine Binaerdatei im Repo, und die Groesse steht sichtbar hier. */ const echtesPng = await (async () => { const { chromium: c } = await import( "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const b = await c.launch(); const s2 = await b.newPage(); await s2.setContent(""); const daten = await s2.evaluate(() => { const c2 = document.createElement("canvas"); c2.width = 1200; c2.height = 1200; const g = c2.getContext("2d"); g.fillStyle = "#c0392b"; g.fillRect(0, 0, 1200, 1200); g.fillStyle = "#ffffff"; g.fillRect(200, 200, 800, 800); return c2.toDataURL("image/png"); }); await b.close(); return Buffer.from(daten.split(",")[1], "base64"); })(); const bildHoch = (keks, puffer) => hole("/workspace/api/steckbrief/bild", keks, { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: puffer }); let bildPfad = null; { const a = await bildHoch(kTili, echtesPng); const antwort = await a.json(); bildPfad = antwort.bild; ok(a.ok && /^\/workspace\/api\/steckbrief\/bild\/[a-f0-9]{32}\.png$/.test(bildPfad || ""), `ein PNG wird angenommen und bekommt einen Zufallsnamen (${bildPfad})`); const bild = await hole(bildPfad, kTili); ok(bild.ok && bild.headers.get("content-type") === "image/png", `und wird als Bild ausgeliefert (${bild.headers.get("content-type")})`); ok(bild.headers.get("x-content-type-options") === "nosniff", "mit nosniff -- der Browser darf den Typ nicht selbst raten"); ok(/default-src 'none'/.test(bild.headers.get("content-security-policy") || ""), "und einer eigenen, alles verbietenden Inhaltsregel"); } /* DER WICHTIGSTE FALL. Ein SVG mit Skript, als Bild hochgeladen. Es traegt einen harmlosen Content-Type -- der ist eine BEHAUPTUNG des Absenders, keine Tatsache. Geprueft wird die Signatur der Datei. */ { const boeses = Buffer.from( '', "utf8"); const a = await bildHoch(kTili, boeses); ok(a.status === 400, `ein SVG mit Skript wird abgelehnt (${a.status})`); /* Auch ein umbenanntes Textstueck mit PNG-Endung kommt nicht durch -- der Server sieht ja gar keinen Dateinamen, nur den Inhalt. */ const b = await bildHoch(kTili, Buffer.from("GIF89a das ist kein erlaubtes Bild", "utf8")); ok(b.status === 400, `und ein GIF ebenfalls nicht (${b.status})`); } /* Der Weg zum Bild darf nicht verlassen werden koennen. */ for (const versuch of ["..%2f..%2fworkspace.db", "....//workspace.db", "abc.png", "x".repeat(32) + ".png"]) { const a = await hole("/workspace/api/steckbrief/bild/" + versuch, kTili); ok(a.status === 404, `"${versuch.slice(0, 24)}" führt nirgendwohin (${a.status})`); } /* Ein zweites Bild ersetzt das erste UND raeumt es weg -- sonst sammelten sich bei jedem Wechsel Dateien an, die niemand mehr findet. */ { const vorher = bildPfad; const a = await bildHoch(kTili, echtesPng); const { bild } = await a.json(); ok(bild !== vorher, "ein neues Bild bekommt einen neuen Namen"); const alt = await hole(vorher, kTili); ok(alt.status === 404, `und das alte ist wirklich weg (${alt.status})`); const dateien = readdirSync(join(ordner, "profilbilder")); ok(dateien.length === 1, `es liegt genau eine Datei auf der Platte (${dateien.length})`); } /* Zu gross: Die Grenze liegt bei 4 MB. */ { const a = await bildHoch(kTili, Buffer.alloc(5 * 1024 * 1024, 1)); ok(a.status === 413 || a.status === 400, `ein 5-MB-Bild wird abgewiesen (${a.status})`); } /* ---------- Das Bild erscheint in der Kopfleiste -------------------------- */ console.log("\n=== Es kommt oben an"); { const ich = await (await hole("/workspace/api/ich", kTili)).json(); ok(/^\/workspace\/api\/steckbrief\/bild\//.test(ich.bild || ""), `/api/ich liefert das Bild mit (${ich.bild})`); const ohne = await (await hole("/workspace/api/ich", kDogi)).json(); ok(ohne.bild === null, "und null, solange keines gesetzt ist"); /* Ohne Anmeldung gibt es gar nichts -- auch kein Bild. */ const fremd = await fetch(BASIS + "/workspace/api/steckbrief/mein"); ok(fremd.status === 401, `ohne Anmeldung: 401 (${fremd.status})`); } /* ---------- 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(); { 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="creator"]'); await seite.fill("#code", "CODE-ANNA-0001"); await seite.click("button[type=submit]"); await seite.waitForURL("**/start.html", { timeout: 15000 }); await seite.goto(BASIS + "/workspace/profil.html", { waitUntil: "networkidle" }); await seite.waitForTimeout(900); const r = await seite.evaluate(() => ({ sichtbar: !document.getElementById("steckbrief")?.hidden, name: document.getElementById("sb-name")?.textContent, rolle: document.getElementById("sb-rolle")?.textContent, kanaele: [...document.querySelectorAll("#sb-kanaele .kanal")].map((k) => k.dataset.art), hatBild: !!document.querySelector("#sb-bild img"), bildRund: getComputedStyle(document.getElementById("sb-bild")).borderRadius, obenBild: !!document.querySelector(".wer__bild"), ueber: document.getElementById("sb-ueber")?.value, ueber504: document.documentElement.scrollWidth - document.documentElement.clientWidth, })); console.log(" Kanäle:", r.kanaele.join(", ")); ok(r.sichtbar, "der Steckbrief steht auf der Profilseite"); ok(r.name === "Tili", `mit dem eigenen Namen (${r.name})`); ok(r.rolle === "Creator", `und der eigenen Rolle (${r.rolle})`); ok(r.kanaele.length === 4, `vier Kanalfelder (${r.kanaele.length})`); ok(r.kanaele[0] === "tiktok", `TikTok steht an erster Stelle (${r.kanaele[0]})`); ok(r.hatBild, "das hochgeladene Bild wird angezeigt"); ok(/50%/.test(r.bildRund), `und ist rund (${r.bildRund})`); ok(r.obenBild, "es steht auch oben rechts in der Kopfleiste"); /* UND ES BLEIBT DORT. Das eigentliche Ergebnis des Fehlers vom 01.09.2026: Ein 1200 px grosses Bild in einem 28 px grossen Feld muss 28 px gross sein -- nicht 1200. Gemessen wird das echte Rechteck, nicht die Absicht. */ const bildMass = await seite.evaluate(() => { const b = document.querySelector(".wer__bild"); if (!b) return null; const r = b.getBoundingClientRect(); const eltern = b.parentElement.getBoundingClientRect(); return { b: Math.round(r.width), h: Math.round(r.height), eb: Math.round(eltern.width), eh: Math.round(eltern.height), ueber: Math.round(Math.max(r.right - eltern.right, r.bottom - eltern.bottom)) }; }); ok(bildMass && bildMass.b <= bildMass.eb + 1 && bildMass.h <= bildMass.eh + 1, `das Bild bleibt in seinem Feld (${bildMass?.b}x${bildMass?.h} in ` + `${bildMass?.eb}x${bildMass?.eh})`); ok(bildMass && bildMass.ueber <= 1, `und ragt nirgends heraus (${bildMass?.ueber}px)`); /* Auch die ganze Seite darf davon nichts merken. */ const seitenUeber = await seite.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); ok(seitenUeber <= 0, `die Seite laeuft dadurch nicht ueber (${seitenUeber}px)`); ok(r.ueber504 <= 0, `nichts steht über (${r.ueber504}px)`); ok(konsole.length === 0, `keine Fehler in der Konsole${konsole.length ? " -> " + konsole.join(" | ") : ""}`); /* Speichern über die Oberfläche -- nicht nur über die Schnittstelle. */ await seite.fill("#sb-tiktok", "@neuer.name"); await seite.fill("#sb-ueber", "Neuer Text über mich."); await seite.click("#sb-speichern"); await seite.waitForTimeout(800); const n = await seite.evaluate(() => ({ tiktok: document.getElementById("sb-tiktok")?.value, auf: document.querySelector('.kanal[data-art="tiktok"] .kanal__auf')?.getAttribute("href"), stand: document.getElementById("sb-stand")?.textContent, })); ok(n.tiktok === "neuer.name", `das @ wird beim Speichern entfernt (${n.tiktok})`); ok(n.auf === "https://www.tiktok.com/@neuer.name", `und der Verweis erscheint (${n.auf})`); ok(/gespeichert/i.test(n.stand || ""), `mit Rückmeldung ("${n.stand}")`); await seite.screenshot({ path: "pruef-steckbrief.png", fullPage: false }); await kontext.close(); } /* --------------------------------------------------------------------- ZWEI SEITEN, ZWEI DINGE (Fund vom 01.09.2026) Auf profil.html steht die Betreuungsakte eines ANDEREN Menschen. Der eigene Steckbrief mittendrin ergab zwei Personen auf einem Bildschirm: oben "Profil: SpongBobSchwammKopf", darunter "MEIN PROFIL: Dogfather". Niemand konnte sagen, welche Angabe zu wem gehoert. Deshalb: Scouts, Manager und DogFather haben den Steckbrief auf einer EIGENEN Seite. Auf der Akte darf er bei ihnen nicht mehr auftauchen. Ein Creator behaelt beides zusammen -- er hat nur eine Seite und sieht dort ausschliesslich sich selbst. --------------------------------------------------------------------- */ console.log("\n=== Steckbrief und Creator-Akte sind getrennt"); for (const [rolle, code, name, erwartetAufAkte] of [ ["admin", "CODE-DOGI-0001", "Filipe", false], ["scout", "CODE-SCOU-0001", "Patrick", false], ["creator", "CODE-ANNA-0001", "Tili", true], ]) { const kontext = await browser.newContext({ viewport: { width: 1280, height: 900 } }); const seite = await kontext.newPage(); 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.waitForTimeout(700); /* 1. Die Kacheln auf der Startseite. */ const kacheln = await seite.evaluate(() => [...document.querySelectorAll(".kachel__name")].map((k) => k.textContent)); if (erwartetAufAkte) { ok(!kacheln.includes("Mein Steckbrief") && kacheln.includes("Mein Profil"), `${name} (${rolle}): eine Kachel "Mein Profil" (${kacheln.filter((k) => /Profil|Steckbrief/.test(k)).join(", ")})`); } else { ok(kacheln.includes("Mein Steckbrief") && kacheln.includes("Creator-Profile"), `${name} (${rolle}): getrennte Kacheln (${kacheln.filter((k) => /Profil|Steckbrief/.test(k)).join(", ")})`); } /* 2. Auf der Akte -- steht dort der eigene Steckbrief? */ await seite.goto(BASIS + "/workspace/profil.html", { waitUntil: "networkidle" }); await seite.waitForTimeout(900); const aufAkte = await seite.evaluate(() => document.getElementById("steckbrief")?.hidden === false); ok(aufAkte === erwartetAufAkte, ` und auf der Akte ${erwartetAufAkte ? "steht" : "steht KEIN"} eigener Steckbrief (${aufAkte})`); /* 3. Die eigene Seite zeigt ihn -- fuer jede Rolle. */ await seite.goto(BASIS + "/workspace/steckbrief.html", { waitUntil: "networkidle" }); await seite.waitForTimeout(900); const eigen = await seite.evaluate(() => ({ da: document.getElementById("steckbrief")?.hidden === false, name: document.getElementById("sb-name")?.textContent, })); ok(eigen.da && eigen.name === name, ` die eigene Seite zeigt ${name} (${eigen.name})`); await kontext.close(); } /* Der alte Block an dieser Stelle prüfte, ob ein Scout seinen Steckbrief auf profil.html sieht. Seit der Trennung sieht er ihn dort ausdruecklich NICHT mehr -- die Pruefung darueber deckt beide Seiten je Rolle ab und ersetzt ihn vollstaendig. Eine Pruefung stehen zu lassen, die das Gegenteil des gewollten Verhaltens verlangt, waere schlimmer als keine. */ 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);