/* 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");
/* 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-steckbrief");
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-steckbrief");
await new Promise((r) => setTimeout(r, 700));
const BASIS = `http://127.0.0.1:${PORT}`;
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);