/* ===================================================================== DER SUPPORT (24.09.2026) Filipe: „da fehlt auch eine support kachel. die jeder sieht. jeder benutzen kann. jeder kann da probleme von der seite melden und hinweisen. fotos mit schicken mit einem text. … also bei dogfather und der rechten hand soll die kachel anders gebaut sein weil die sind die die sich um die probleme kümmern." GEPRUEFT WIRD, WAS ER BESTELLT HAT — und drei Dinge, die er nicht bestellt hat, weil sie sonst niemandem auffallen: * JEDE Rolle sieht die Kachel und kommt auf die Seite. Nicht vier von acht -- genau daran ist heute der Benachrichtigungs-Knopf gescheitert. * Eine fremde Meldung bleibt fremd. Wer meldet, sieht seine eigene; alles andere gehoert der Leitung. * Eine als Bild getarnte HTML-Datei kommt nicht herein. Das ist der Standardfehler bei selbstgebauten Uploads und von aussen nicht zu sehen. ===================================================================== */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; const ordner = mkdtempSync(join(tmpdir(), "ws-sup-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); import { notbremse } from "./helfer-notbremse.mjs"; const { eigenerPort } = await import("./helfer-port.mjs"); const PORT = await eigenerPort(import.meta, "die Supportpruefung"); 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 }); }; await import("./index.js"); notbremse(240_000, "pruef-support"); await new Promise((r) => setTimeout(r, 900)); const BASIS = `http://127.0.0.1:${PORT}`; let fehler = 0, geprueft = 0; const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; const melde = (t) => console.log(t); const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); const d = new DatabaseSync(process.env.WORKSPACE_DB); const jetzt = new Date().toISOString(); const kennSchluessel = randomBytes(32).toString("hex"); d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") .run("code_kennung_schluessel", kennSchluessel, jetzt); 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," + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)") .run(name, rolle, hash, salt, 32768, createHmac("sha256", kennSchluessel).update(code).digest("hex"), jetzt); return d.prepare("SELECT last_insert_rowid() AS id").get().id; } /* ALLE ACHT ROLLEN. „Die jeder sieht" heisst jede -- und die Liste steht hier vollstaendig, damit eine fehlende auffaellt. */ const ROLLEN = [ ["admin", "Filipe"], ["hand", "VanVan"], ["linke", "Funny"], ["modi", "Miss"], ["spicy", "Spicy Media"], ["manager", "Patrick"], ["scout", "Kevin"], ["creator", "Luna"], ["gast", "Community-Mitglied"], ]; const CODES = {}; for (const [i, [rolle, name]] of ROLLEN.entries()) { CODES[rolle] = `CODE-S${String(i).padStart(2, "0")}-000001`; anlegen(name, rolle, CODES[rolle]); } d.close(); /* ---------- Anmelden ohne Browser ------------------------------------ Die Sitzung kommt aus einem echten Anmelde-Aufruf; danach reist sie als Keks mit. Ein Browser waere hier Aufwand ohne Gewinn -- geprueft werden Wege, nicht Aussehen. */ /* DIE COMMUNITY KOMMT NUR AUF EINER ADRESSE HEREIN (gemessen, nicht geraten): `crew.dogfather-universe.com`. Auf 127.0.0.1 antwortet die Anmeldung mit 401 -- beim ersten Lauf stand deshalb "gast: kommt nicht herein", und das sah aus wie ein schwerer Befund am Support. Es war einer an meiner Messung. Versucht wird zuerst ohne Host, dann mit -- so faellt auch auf, wenn eine Rolle PLOETZLICH einen braucht. */ const CREW_HOST = "crew.dogfather-universe.com"; /* ZWEI DINGE, DIE ICH ERST NACH DREI FEHLLAEUFEN GEMESSEN HABE -- beide stehen in pruef-treff.mjs, das sie seit dem 15.09. richtig macht: 1. `fetch` VERWIRFT DEN Host-KOPF. Er steht auf der Liste der verbotenen Kopfzeilen; Node setzt stattdessen die Adresse des Ziels. Wer eine Hausadresse braucht, muss node:http nehmen. 2. DIE COMMUNITY BESTAETIGT IHR ALTER (`alter_ok`). Ohne das Feld antwortet die Anmeldung mit 401 -- und das sah aus wie ein Zugangsproblem am Support. Beim ersten Lauf stand deshalb dreimal rot, und kein einziges Mal lag es am Support. */ const { request } = await import("node:http"); function roh(pfad, host, koerper) { return new Promise((fertig, schief) => { const daten = koerper ? JSON.stringify(koerper) : null; const a = request({ host: "127.0.0.1", port: PORT, path: pfad, method: daten ? "POST" : "GET", headers: { ...(host ? { Host: host } : {}), Origin: host ? `https://${host}` : BASIS, ...(daten ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(daten) } : {}), }, }, (antwort) => { let text = ""; antwort.on("data", (t) => { text += t; }); antwort.on("end", () => fertig({ code: antwort.statusCode, kopf: antwort.headers, text, })); }); a.on("error", schief); if (daten) a.write(daten); a.end(); }); } async function anmeldeVersuch(rolle, host) { const r = await roh("/workspace/api/anmelden", host, rolle === "gast" ? { rolle, code: CODES[rolle], alter_ok: true } : { rolle, code: CODES[rolle] }); if (r.code !== 200) return null; const keks = [].concat(r.kopf["set-cookie"] || []) .map((z) => z.split(";")[0]).join("; "); return keks || null; } const HOST_FUER = {}; async function anmelden(rolle) { const ohne = await anmeldeVersuch(rolle, null); if (ohne) { HOST_FUER[rolle] = null; return ohne; } const mit = await anmeldeVersuch(rolle, CREW_HOST); if (mit) { HOST_FUER[rolle] = CREW_HOST; return mit; } return null; } /** Ein winziges, gueltiges PNG (1x1, schwarz) -- echte Kopfbytes, * damit die Erkennung wirklich etwas zu erkennen hat. */ const PNG = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk" + "YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64"); const kekse = {}; melde("\n=== Alle neun Rollen kommen herein ==="); for (const [rolle] of ROLLEN) { kekse[rolle] = await anmelden(rolle); ok(!!kekse[rolle], `${rolle}: angemeldet`); } /* Fuer Rollen OHNE Hausadresse genuegt fetch -- es ist kuerzer und kann Buffer als Rumpf. Wer eine Adresse braucht, geht ueber node:http (siehe oben, `fetch` verwirft den Host-Kopf). */ async function hol(rolle, pfad, einst = {}) { if (HOST_FUER[rolle]) { const r = await rohMitKeks(rolle, pfad, einst); return r; } return fetch(BASIS + pfad, { ...einst, headers: { Cookie: kekse[rolle] || "", Origin: BASIS, ...(einst.headers || {}) }, }); } /** Derselbe Aufruf ueber node:http -- mit Hausadresse und Sitzung. * Gibt etwas zurueck, das sich wie eine fetch-Antwort benutzen * laesst: `status`, `ok`, `json()`, `headers.get()`. */ function rohMitKeks(rolle, pfad, einst = {}) { return new Promise((fertig, schief) => { const rumpf = einst.body === undefined ? null : (Buffer.isBuffer(einst.body) ? einst.body : Buffer.from(String(einst.body))); const a = request({ host: "127.0.0.1", port: PORT, path: pfad, method: einst.method || "GET", headers: { Host: HOST_FUER[rolle], Origin: `https://${HOST_FUER[rolle]}`, Cookie: kekse[rolle] || "", ...(rumpf ? { "Content-Length": rumpf.length } : {}), ...(einst.headers || {}), }, }, (antwort) => { const teile = []; antwort.on("data", (t) => teile.push(t)); antwort.on("end", () => { const text = Buffer.concat(teile).toString("utf8"); fertig({ status: antwort.statusCode, ok: antwort.statusCode >= 200 && antwort.statusCode < 300, headers: { get: (n) => antwort.headers[String(n).toLowerCase()] || null }, json: async () => JSON.parse(text), text: async () => text, }); }); }); a.on("error", schief); if (rumpf) a.write(rumpf); a.end(); }); } /* ===================================================================== 1. DIE KACHEL SIEHT JEDER ===================================================================== */ melde("\n=== Die Kachel steht bei jeder Rolle ==="); { const ohne = []; for (const [rolle] of ROLLEN) { /* DIE ROUTE HEISST /api/ich, nicht /api/start. Beim ersten Lauf stand hier ein erfundener Name, und alle neun Rollen meldeten "Kachel fehlt (HTTP 404)" -- ein Messfehler, der wie ein Totalausfall aussah. */ const a = await hol(rolle, "/workspace/api/ich"); if (!a.ok) { ohne.push(`${rolle} (HTTP ${a.status})`); continue; } const r = await a.json(); /* `bereiche` ist null, wenn die Oberflaeche ihre eigene Liste nimmt -- dann steht die Kachel in assets/js/bereiche.js und wird eine Pruefung weiter unten geprueft. */ if (r.bereiche === null) { ohne.push(`${rolle} (eigene Liste im Browser)`); continue; } const da = (r.bereiche || []).some((k) => k?.ziel === "support.html"); if (!da) ohne.push(rolle); } /* DIE AGENTUR-ROLLEN bekommen `bereiche: null` und ihre Liste aus dem Browser. Das ist kein Mangel, aber es muss genannt werden -- sonst sieht "alle haben sie" gruen aus, obwohl vier gar nicht geprueft wurden. */ const ausBrowser = ohne.filter((x) => x.includes("eigene Liste")); const echtOhne = ohne.filter((x) => !x.includes("eigene Liste")); ok(echtOhne.length === 0, "jede Rolle mit Server-Liste hat die Support-Kachel" + (echtOhne.length ? ` -- FEHLT bei: ${echtOhne.join(", ")}` : "")); melde(` (${ausBrowser.length} Rollen nehmen ihre Liste aus dem Browser)`); /* UND DIE BROWSER-LISTE AUCH. Ohne diese Zeile waere die Haelfte der Rollen ungeprueft -- und genau dort hat heute schon einmal etwas gefehlt. */ const { readFileSync } = await import("node:fs"); const { fileURLToPath } = await import("node:url"); const bereicheJs = readFileSync(fileURLToPath( new URL("../workspace/assets/js/bereiche.js", import.meta.url)), "utf8"); ok(/ziel:\s*'support\.html'/.test(bereicheJs), "auch die Kachelliste im Browser kennt den Support"); } /* ===================================================================== 2. AUF DIE SEITE DARF JEDER ===================================================================== */ melde("\n=== Die Seite ist fuer jede Rolle offen ==="); { const zu = []; for (const [rolle] of ROLLEN) { const a = await hol(rolle, "/workspace/support.html", { redirect: "manual" }); if (a.status !== 200) zu.push(`${rolle}=${a.status}`); } ok(zu.length === 0, "support.html antwortet jeder Rolle mit 200" + (zu.length ? ` -- nicht bei: ${zu.join(", ")}` : "")); } /* ===================================================================== 3. MELDEN — mit Text, mit Bild, und was nicht geht ===================================================================== */ melde("\n=== Melden ==="); let meldungMitBild = 0; { /* Ohne Text geht nichts -- sonst stehen leere Zeilen im Eingang. */ const leer = await hol("creator", "/workspace/api/support", { method: "POST", headers: { "x-text": encodeURIComponent("hm") }, }); ok(leer.status === 400, `ein Wort reicht nicht (${leer.status})`); const nurText = await hol("creator", "/workspace/api/support", { method: "POST", headers: { "x-text": encodeURIComponent("Der Haken auf der Aufgabenseite geht nicht.") }, }); ok(nurText.status === 201, `Text allein genuegt (${nurText.status})`); const mitBild = await hol("modi", "/workspace/api/support", { method: "POST", headers: { "x-text": encodeURIComponent("Hier stimmt die Farbe nicht, siehe Bild."), "x-seite": "/workspace/chat.html", "Content-Type": "application/octet-stream", }, body: PNG, }); ok(mitBild.status === 201, `mit Bild geht auch (${mitBild.status})`); if (mitBild.status === 201) { const j = await mitBild.json(); meldungMitBild = j.meldung?.id || 0; ok(!!j.meldung?.bild, "und die Antwort nennt die Adresse des Bildes"); } else { ok(false, "kein Bild in der Antwort"); } /* DER STANDARDFEHLER BEI UPLOADS: eine HTML-Datei mit Bildnamen. Sie liefe im Browser als Seite DIESER Domain, mit Zugriff auf die Sitzung des Empfaengers. */ const getarnt = await hol("scout", "/workspace/api/support", { method: "POST", headers: { "x-text": encodeURIComponent("Test mit einer getarnten Datei."), "Content-Type": "image/png", }, body: Buffer.from(""), }); ok(getarnt.status === 415, `als Bild getarntes HTML wird abgelehnt (${getarnt.status})`); /* Und ein PDF ebenfalls -- dateiErkennen kennt es, der Support will es nicht. */ const pdf = await hol("scout", "/workspace/api/support", { method: "POST", headers: { "x-text": encodeURIComponent("Test mit einem PDF im Anhang."), "Content-Type": "application/pdf", }, body: Buffer.concat([Buffer.from("%PDF-1.4\n"), Buffer.alloc(64, 0x20)]), }); ok(pdf.status === 415, `ein PDF ist kein Foto (${pdf.status})`); } /* ===================================================================== 4. WER SIEHT WAS ===================================================================== */ melde("\n=== Wer sieht was ==="); { const alsCreator = await (await hol("creator", "/workspace/api/support")).json(); ok(alsCreator.leitung === false, "ein Creator ist nicht die Leitung"); ok(alsCreator.meldungen.length === 1, `und sieht genau seine eigene (${alsCreator.meldungen.length})`); const alsModi = await (await hol("modi", "/workspace/api/support")).json(); ok(alsModi.meldungen.length === 1 && alsModi.meldungen[0].id === meldungMitBild, "ein Modi sieht seine eigene und nicht die des Creators"); for (const rolle of ["admin", "hand"]) { const r = await (await hol(rolle, "/workspace/api/support")).json(); ok(r.leitung === true && r.meldungen.length >= 2, `${rolle} sieht alle (${r.meldungen.length})`); } for (const rolle of ["linke", "modi", "spicy", "manager", "scout", "creator", "gast"]) { const r = await (await hol(rolle, "/workspace/api/support")).json(); ok(r.leitung !== true, `${rolle} ist NICHT die Leitung`); } /* DAS BILD EINER FREMDEN MELDUNG bleibt fremd. */ const fremd = await hol("creator", `/workspace/api/support/${meldungMitBild}/bild`); ok(fremd.status === 404, `das Bild einer fremden Meldung gibt es fuer andere nicht (${fremd.status})`); const eigen = await hol("modi", `/workspace/api/support/${meldungMitBild}/bild`); ok(eigen.status === 200, `der Melder sieht sein eigenes Bild (${eigen.status})`); ok(eigen.headers.get("content-type") === "image/png", `ausgeliefert wird der ERKANNTE Typ (${eigen.headers.get("content-type")})`); ok((eigen.headers.get("x-content-type-options") || "") === "nosniff", "mit nosniff"); ok(/sandbox/.test(eigen.headers.get("content-security-policy") || ""), "und einer Richtlinie, die jede Ausfuehrung nimmt"); const leitungSieht = await hol("hand", `/workspace/api/support/${meldungMitBild}/bild`); ok(leitungSieht.status === 200, "die Leitung sieht es auch"); } /* ===================================================================== 5. SICH KUEMMERN ===================================================================== */ melde("\n=== Sich kuemmern ==="); { /* Wer nicht Leitung ist, kann nichts aendern -- 404 und nicht 403: Wer keine Zustaendigkeit hat, soll nicht erfahren, dass es sie gibt. */ const versuch = await hol("creator", `/workspace/api/support/${meldungMitBild}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ stand: "erledigt", antwort: "einfach mal zu" }), }); ok(versuch.status === 404, `ein Creator kann nichts erledigen (${versuch.status})`); const dran = await hol("hand", `/workspace/api/support/${meldungMitBild}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ stand: "in_arbeit" }), }); ok(dran.status === 200, `die rechte Hand uebernimmt (${dran.status})`); /* ZUMACHEN OHNE SATZ GEHT NICHT. Der Melder sieht nur diesen Satz. */ const ohneSatz = await hol("hand", `/workspace/api/support/${meldungMitBild}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ stand: "erledigt" }), }); ok(ohneSatz.status === 400, `erledigen ohne Satz wird abgelehnt (${ohneSatz.status})`); const zu = await hol("hand", `/workspace/api/support/${meldungMitBild}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ stand: "erledigt", antwort: "Behoben, die Farbe stimmt wieder." }), }); ok(zu.status === 200, `mit Satz geht es (${zu.status})`); /* ERLEDIGT IST ERLEDIGT -- auch fuer die Leitung. */ const nochmal = await hol("admin", `/workspace/api/support/${meldungMitBild}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ stand: "in_arbeit", antwort: "doch nicht" }), }); ok(nochmal.status === 409, `eine erledigte Meldung wird nicht wieder geoeffnet (${nochmal.status})`); /* Und der Melder sieht die Antwort. */ const beimMelder = await (await hol("modi", "/workspace/api/support")).json(); const meine = beimMelder.meldungen.find((m) => m.id === meldungMitBild); ok(meine?.stand === "erledigt" && /Behoben/.test(meine?.antwort || ""), "der Melder sieht Stand und Antwort"); } /* ===================================================================== 6. GEGENPROBEN — kann diese Pruefung ueberhaupt rot werden? ===================================================================== */ melde("\n=== Gegenproben (jede MUSS anschlagen) ==="); { /* Eine Meldung, die es nicht gibt. */ const nichts = await hol("admin", "/workspace/api/support/999999"); ok(nichts.status === 404, "eine erfundene Nummer gibt es nicht"); /* Ohne Anmeldung kommt niemand an die Liste. */ const ohne = await fetch(BASIS + "/workspace/api/support"); ok(ohne.status === 401 || ohne.status === 302 || ohne.status === 404, `ohne Anmeldung keine Liste (${ohne.status})`); /* Und ohne Anmeldung auch nicht an ein Bild. */ const ohneBild = await fetch(BASIS + `/workspace/api/support/${meldungMitBild}/bild`); ok(ohneBild.status !== 200, `ohne Anmeldung kein Bild (${ohneBild.status})`); } try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } console.log(""); console.log(`${geprueft} Pruefungen, ${fehler} Fehler`); console.log(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); process.exit(fehler ? 1 : 0);