/* ===================================================================== DIE EIGENE ADRESSE DER MODI-APP Geprueft wird nicht "die neue Adresse geht", sondern die vier Fragen, an denen dieser Umbau scheitern kann: 1. KOMMT EIN MODI AUF crew. HEREIN -- und sonst niemand? 2. IST DER WORKSPACE UNVERAENDERT? Derselbe Dienst bedient beide Adressen. Wer nicht auf den Hostnamen prueft, sperrt mit einer einzigen Zeile alle Creator, Scouts und Manager aus, sofort nach dem Neustart und ohne Vorwarnung. 3. BLEIBEN DIE PRUEFADRESSEN UNBERUEHRT? Waere die Regel "alles ausser crew", dann wuerden ab sofort sieben andere Pruefdateien messen, dass ein Modi nirgends hereinkommt -- gruen, weil sie nichts mehr finden. Ein Lauf, der nichts mehr prueft, ist schlimmer als ein roter. 4. HEISST DIE APP AUF DEM HANDY DES MODIS ANDERS? Sonst war der ganze Umzug umsonst. MIT GEGENPROBE. Ganz unten bekommt der Modi in der Datenbank eine andere Rolle -- danach MUSS ihn dieselbe Sitzung auf crew. nicht mehr hereinlassen. Ohne diesen Teil waere nicht bewiesen, dass die Pruefung ueberhaupt anschlagen kann. ===================================================================== */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { request } from "node:http"; const ordner = mkdtempSync(join(tmpdir(), "ws-crew-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); process.env.PORT = "4371"; 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"); notbremse(180_000, "pruef-crew-adresse"); await new Promise((r) => setTimeout(r, 700)); let fehler = 0; let geprueft = 0; const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; let offen = 0; const unklar = (t) => { offen++; console.log(" -- " + t); }; /* --------------------------------------------------------------------- Ein Aufruf mit frei waehlbarem Host-Kopf. Bewusst node:http und nicht fetch: `Host` gilt in der Fetch-Norm als verbotener Kopf, und ob undici das durchlaesst, ist eine Annahme, auf die diese ganze Pruefung nicht gebaut sein soll. Hier wird der Kopf nachweislich gesetzt. ------------------------------------------------------------------ */ function hole(pfad, { host, methode = "GET", rumpf = null, keks = "" } = {}) { return new Promise((fertig, schief) => { const kopf = { Host: host }; if (rumpf) { kopf["Content-Type"] = "application/json"; kopf["Content-Length"] = Buffer.byteLength(rumpf); } if (keks) kopf.Cookie = keks; const a = request({ host: "127.0.0.1", port: 4371, path: pfad, method: methode, headers: kopf }, (antwort) => { let text = ""; antwort.setEncoding("utf8"); antwort.on("data", (t) => { text += t; }); antwort.on("end", () => fertig({ status: antwort.statusCode, kopf: antwort.headers, text, keks: (antwort.headers["set-cookie"] || []) .map((z) => z.split(";")[0]).join("; "), })); }); a.on("error", schief); a.setTimeout(15_000, () => { a.destroy(new Error("Zeitueberschreitung")); }); if (rumpf) a.write(rumpf); a.end(); }); } const anmelden = (host, rolle, code) => hole("/workspace/api/anmelden", { host, methode: "POST", rumpf: JSON.stringify({ rolle, code }) }); /* ---------- Personen anlegen ---------------------------------------- */ 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 schluessel = randomBytes(32).toString("hex"); d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") .run("code_kennung_schluessel", schluessel, jetzt); const kennung = (code) => createHmac("sha256", schluessel).update(code).digest("hex"); 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, kennung(code), jetzt); return d.prepare("SELECT last_insert_rowid() AS id").get().id; } anlegen("Filipe", "admin", "CODE-DOGI-0001"); anlegen("Schulle", "manager", "CODE-MAN-0001"); const idModi = anlegen("Marina", "modi", "CODE-MODI-0001"); const CREW = "crew.dogfather-universe.com"; const WS = "workspace.dogfather-universe.com"; const HAUPT = "dogfather-universe.com"; const LOKAL = "127.0.0.1:4371"; /* ===================================================================== TEIL A -- DIE REGELN, OHNE SERVER Sie stehen als eigene Funktionen in crew-adresse.js, damit genau das hier moeglich ist: die Entscheidung pruefen, nicht ihre Auswirkung. ===================================================================== */ console.log("\n--- Die Regeln selbst ---"); const { istCrewAdresse, istOhneModiAdresse, istWorkspacePfad, sitzungPasstZurAdresse, CREW_ADRESSE } = await import("./crew-adresse.js"); ok(istCrewAdresse(CREW) === true, "crew.dogfather-universe.com ist die Modi-Adresse"); ok(istCrewAdresse("CREW.Dogfather-Universe.COM") === true, "Gross/klein macht keinen Unterschied"); ok(istCrewAdresse(CREW + ":443") === true, "ein Port angehaengt macht keinen Unterschied"); ok(istCrewAdresse(WS) === false, "workspace. ist NICHT die Modi-Adresse"); ok(istCrewAdresse(HAUPT) === false, "die Hauptseite ist NICHT die Modi-Adresse"); ok(istCrewAdresse("crew.dogfather-universe.com.boese.de") === false, "eine fremde Domain mit dem Namen darin zaehlt nicht"); ok(istCrewAdresse("") === false && istCrewAdresse(null) === false, "leer und fehlend sind nicht die Modi-Adresse"); ok(istOhneModiAdresse(WS) === true, "auf workspace. haben Modis nichts verloren"); ok(istOhneModiAdresse(HAUPT) === true, "auf der Hauptseite ebenfalls nicht"); ok(istOhneModiAdresse("www." + HAUPT) === true, "auf www. ebenfalls nicht"); ok(istOhneModiAdresse(CREW) === false, "crew. gehoert NICHT dazu (Falle 1)"); ok(istOhneModiAdresse(LOKAL) === false, "127.0.0.1 gehoert NICHT dazu (Falle 2)"); ok(istOhneModiAdresse("localhost:4100") === false, "localhost gehoert NICHT dazu (Falle 2)"); /* Die beiden Mengen duerfen sich nirgends beruehren. Ein Muster statt einer Liste haette hier sofort angeschlagen. */ const alleAdressen = [CREW, WS, HAUPT, "www." + HAUPT, LOKAL, "localhost:4100", "git." + HAUPT]; ok(alleAdressen.every((h) => !(istCrewAdresse(h) && istOhneModiAdresse(h))), "keine Adresse liegt in beiden Mengen"); ok(istWorkspacePfad("/workspace") === true, "/workspace gehoert dazu"); ok(istWorkspacePfad("/workspace/start.html") === true, "/workspace/start.html gehoert dazu"); ok(istWorkspacePfad("/workspaceXYZ") === false, "/workspaceXYZ gehoert NICHT dazu (Falle 3)"); ok(istWorkspacePfad("/workspace-alt") === false, "/workspace-alt gehoert NICHT dazu (Falle 3)"); ok(istWorkspacePfad("/assets/img/x.png") === false, "/assets liegt ausserhalb"); const ROLLEN = ["admin", "spicy", "manager", "scout", "creator", "modi"]; let passtWahr = 0, passtFalsch = 0; for (const r of ROLLEN) { const aufCrew = sitzungPasstZurAdresse(r, CREW); const aufWs = sitzungPasstZurAdresse(r, WS); const aufLokal = sitzungPasstZurAdresse(r, LOKAL); ok(aufCrew === (r === "modi"), `crew.: ${r} ${r === "modi" ? "darf" : "darf nicht"}`); ok(aufWs === (r !== "modi"), `workspace.: ${r} ${r !== "modi" ? "darf" : "darf nicht"}`); ok(aufLokal === true, `127.0.0.1: ${r} unveraendert erlaubt`); for (const w of [aufCrew, aufWs, aufLokal]) { if (w) passtWahr++; else passtFalsch++; } } /* Die Gegenprobe zur Regel selbst: Eine Funktion, die immer `true` liefert, haette alle 18 Zeilen darueber ebenfalls teilweise bestanden. Beide Antworten muessen vorkommen. */ ok(passtWahr > 0 && passtFalsch > 0, `die Regel unterscheidet wirklich (${passtWahr}x ja, ${passtFalsch}x nein)`); /* ===================================================================== TEIL B -- AM LAUFENDEN SERVER ===================================================================== */ console.log("\n--- Anmeldung je Adresse ---"); const modiAufCrew = await anmelden(CREW, "modi", "CODE-MODI-0001"); ok(modiAufCrew.status === 200, "crew.: der Modi kommt herein"); let zielModi = {}; try { zielModi = JSON.parse(modiAufCrew.text); } catch { /* bleibt leer */ } ok(zielModi.weiter === "/workspace/start.html", "crew.: er landet auf seiner Startseite"); ok(modiAufCrew.keks.includes("dfw_sitzung"), "crew.: er bekommt eine Sitzung"); /* Der Code des Modis wird bewusst mit der Kachel 'creator' geschickt -- genau so kommt er auch aus der Zugangswand, denn eine Modi-Kachel gibt es nicht. */ const modiOhneKachel = await anmelden(CREW, "creator", "CODE-MODI-0001"); ok(modiOhneKachel.status === 200, "crew.: die gewaehlte Kachel ist egal (stiller Zugang)"); const managerAufCrew = await anmelden(CREW, "manager", "CODE-MAN-0001"); ok(managerAufCrew.status === 401, "crew.: ein Manager mit gueltigem Code kommt NICHT herein"); ok(/ungueltig/.test(managerAufCrew.text), "crew.: er bekommt dieselbe Antwort wie bei einem Tippfehler"); ok(!managerAufCrew.keks.includes("dfw_sitzung"), "crew.: und keine Sitzung"); const adminAufCrew = await anmelden(CREW, "admin", "CODE-DOGI-0001"); ok(adminAufCrew.status === 401, "crew.: auch DogFather selbst kommt dort nicht herein"); const modiAufWs = await anmelden(WS, "creator", "CODE-MODI-0001"); ok(modiAufWs.status === 200, "workspace.: der Modi bekommt eine Antwort"); let zielWs = {}; try { zielWs = JSON.parse(modiAufWs.text); } catch { /* bleibt leer */ } ok(zielWs.weiter === `${CREW_ADRESSE}/workspace/start.html`, "workspace.: sie zeigt ihm die neue Adresse"); ok(!modiAufWs.keks.includes("dfw_sitzung"), "workspace.: aber KEINE Sitzung -- er meldet sich nur noch auf crew. an"); /* DER WICHTIGSTE PUNKT DIESER DATEI. Derselbe Dienst bedient beide Adressen; ein Denkfehler hier sperrt das ganze Team aus. */ const managerAufWs = await anmelden(WS, "manager", "CODE-MAN-0001"); ok(managerAufWs.status === 200 && managerAufWs.keks.includes("dfw_sitzung"), "workspace.: der Manager kommt herein wie immer -- der Workspace ist unberuehrt"); const managerLokal = await anmelden(LOKAL, "manager", "CODE-MAN-0001"); const modiLokal = await anmelden(LOKAL, "creator", "CODE-MODI-0001"); ok(managerLokal.status === 200 && managerLokal.keks.includes("dfw_sitzung"), "127.0.0.1: der Manager kommt herein (Pruefadressen unberuehrt)"); ok(modiLokal.status === 200 && modiLokal.keks.includes("dfw_sitzung"), "127.0.0.1: der Modi ebenfalls -- die sieben anderen Modi-Pruefungen messen weiter"); /* --------------------------------------------------------------------- Eine Sitzung gehoert zu genau einer Adresse. ------------------------------------------------------------------ */ console.log("\n--- Eine Sitzung, eine Adresse ---"); const keksModi = modiAufCrew.keks; const keksManager = managerAufWs.keks; const modiDaheim = await hole("/workspace/api/ich", { host: CREW, keks: keksModi }); ok(modiDaheim.status === 200, "crew.: der angemeldete Modi wird erkannt"); ok(/Marina/.test(modiDaheim.text), "crew.: und zwar als er selbst"); const modiFremd = await hole("/workspace/api/ich", { host: WS, keks: keksModi }); ok(modiFremd.status === 401, "workspace.: dieselbe Sitzung zaehlt dort nicht"); const managerDaheim = await hole("/workspace/api/ich", { host: WS, keks: keksManager }); ok(managerDaheim.status === 200, "workspace.: der Manager wird erkannt"); const managerFremd = await hole("/workspace/api/ich", { host: CREW, keks: keksManager }); ok(managerFremd.status === 401, "crew.: seine Sitzung zaehlt dort nicht"); /* Auch die Seitenschranke, nicht nur die Schnittstelle: Eine Seite, die ausgeliefert wird, waere ein Loch, selbst wenn die Daten dahinter leer blieben. */ const seiteFremd = await hole("/workspace/start.html", { host: CREW, keks: keksManager }); ok(seiteFremd.status === 302, "crew.: die Startseite schickt den Manager zur Zugangswand"); const seiteDaheim = await hole("/workspace/start.html", { host: CREW, keks: keksModi }); ok(seiteDaheim.status === 200, "crew.: dem Modi liefert sie die Seite"); /* --------------------------------------------------------------------- Die Weiche: was es auf crew. ueberhaupt gibt. ------------------------------------------------------------------ */ console.log("\n--- Die Weiche ---"); const wurzel = await hole("/", { host: CREW }); ok(wurzel.status === 302 && wurzel.kopf.location === "/workspace/", "crew.: die Hauptseite gibt es dort nicht, es geht zur Zugangswand"); const wurzelWs = await hole("/", { host: HAUPT }); ok(wurzelWs.status === 200, "die Hauptseite selbst ist unveraendert erreichbar"); const fastWorkspace = await hole("/workspaceXYZ", { host: CREW }); ok(fastWorkspace.status === 302, "crew.: /workspaceXYZ gehoert nicht dazu (Falle 3)"); const robots = await hole("/workspace/", { host: CREW }); ok(String(robots.kopf["x-robots-tag"] || "").includes("noindex"), "crew.: nicht indizieren -- die Adresse soll in keiner Suchmaschine stehen"); const robotsWs = await hole("/workspace/", { host: WS }); ok(!robotsWs.kopf["x-robots-tag"], "workspace.: dort steht der Kopf NICHT (Gegenprobe zur Weiche)"); /* --------------------------------------------------------------------- Die App heisst anders. ------------------------------------------------------------------ */ console.log("\n--- Zwei Apps, zwei Namen ---"); const manifestCrew = await hole("/workspace/app.webmanifest", { host: CREW }); const manifestWs = await hole("/workspace/app.webmanifest", { host: WS }); let mc = {}, mw = {}; try { mc = JSON.parse(manifestCrew.text); } catch { /* bleibt leer */ } try { mw = JSON.parse(manifestWs.text); } catch { /* bleibt leer */ } ok(manifestCrew.status === 200 && manifestWs.status === 200, "beide Adressen liefern ein Manifest"); ok(mc.name === "Team Dogi", `crew.: die App heisst "Team Dogi" (gelesen: ${mc.name || "?"})`); ok(mw.name === "Creator Workspace — Spicy & Dogi", `workspace.: dort heisst sie unveraendert (gelesen: ${mw.name || "?"})`); ok(Array.isArray(mc.icons) && mc.icons.length > 0 && mc.icons.every((s) => /crew-/.test(s.src)), `crew.: alle ${(mc.icons || []).length} Symbole sind die eigenen`); ok(Array.isArray(mw.icons) && mw.icons.length > 0 && mw.icons.every((s) => /workspace-/.test(s.src)), "workspace.: dort weiterhin die alten"); ok(mc.start_url === "/workspace/start.html" && mc.scope === "/workspace/", "crew.: Bereich und Startseite stimmen"); /* Die Symboldateien muessen es auch wirklich geben -- ein Manifest, das auf nichts zeigt, faellt sonst erst auf dem Startbildschirm auf. */ let symboleDa = 0; for (const s of mc.icons || []) { const pfad = String(s.src).split("?")[0]; const a = await hole(pfad, { host: CREW }); if (a.status === 200) symboleDa++; } ok((mc.icons || []).length > 0 && symboleDa === (mc.icons || []).length, `crew.: alle ${symboleDa} Symbole werden auch ausgeliefert`); /* ===================================================================== DIE GEGENPROBE Bis hierhin koennte alles auch dann gruen sein, wenn die Regel gar nicht auf die Rolle sieht. Deshalb wird sie jetzt ausgehebelt: Der Modi bekommt in der Datenbank die Rolle 'manager'. Seine Sitzung bleibt dieselbe -- aber auf crew. darf sie ab jetzt NICHT mehr gelten. ===================================================================== */ console.log("\n--- Gegenprobe: die Regel ausgehebelt ---"); d.prepare("UPDATE personen SET rolle = 'manager' WHERE id = ?").run(idModi); const nachher = await hole("/workspace/api/ich", { host: CREW, keks: keksModi }); ok(nachher.status === 401, "aus dem Modi wurde ein Manager -- seine Sitzung gilt auf crew. nicht mehr"); const nachherWs = await hole("/workspace/api/ich", { host: WS, keks: keksModi }); ok(nachherWs.status === 200, "und dieselbe Sitzung gilt jetzt auf workspace. -- die Regel sieht wirklich auf die Rolle"); d.prepare("UPDATE personen SET rolle = 'modi' WHERE id = ?").run(idModi); const zurueck = await hole("/workspace/api/ich", { host: CREW, keks: keksModi }); ok(zurueck.status === 200, "zurueckgedreht gilt sie wieder"); /* ---------- Ende ------------------------------------------------------ */ d.close(); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Windows haelt die Datei manchmal */ } console.log(`\n${fehler === 0 ? "BESTANDEN" : "FEHLGESCHLAGEN"}` + ` -- ${geprueft} geprueft, ${fehler} Fehler, ${offen} nicht nachsehbar`); process.exit(fehler === 0 ? 0 : 1);