diff --git a/server/pruef-push-zu.mjs b/server/pruef-push-zu.mjs new file mode 100644 index 00000000..f72b74a7 --- /dev/null +++ b/server/pruef-push-zu.mjs @@ -0,0 +1,306 @@ +/* KOMMT EINE BENACHRICHTIGUNG AN, WENN DIE APP ZU IST? (02.10.2026) + ===================================================================== + + Filipe: „mach noch einen check ob alles mit den benachrichtigungen + jetzt perfekt klappt und dass die leute sie auch bekommen wenn die + app zu ist." + + WARUM ES DAFUER EINE EIGENE PRUEFUNG BRAUCHT + + Drei Pruefungen gibt es schon, und sie decken viel ab: + + pruef-push die Verschluesselung, gegen die Testvektoren + aus RFC 8291 und RFC 8292 gerechnet + pruef-push-weg Zustellung, TTL, VAPID-Kopf, der 410-Fall + pruef-push-ziel wer was bekommt und wer nicht + + Keine davon beantwortet die Frage. Sie hoeren alle beim Push-Dienst + auf -- und genau danach faengt der Teil an, um den es Filipe geht: + Der Browser muss den Service Worker AUFWECKEN, obwohl keine Seite + offen ist, und der muss eine Meldung anzeigen. + + WAS HIER WIRKLICH PASSIERT -- und das ist der Punkt: + + 1. Die Seite wird geoeffnet, der Service Worker meldet sich an. + 2. ALLE SEITEN WERDEN GESCHLOSSEN. Danach gibt es kein Fenster + mehr, kein Skript laeuft, nichts haengt an einer Verbindung. + 3. Dem Browser wird ueber das DevTools-Protokoll ein echter + Push zugestellt (`ServiceWorker.deliverPushMessage`) -- + derselbe Weg, den Apple und Google benutzen. + 4. Erst DANACH wird wieder eine Seite geoeffnet und gefragt, + welche Meldungen dastehen (`registration.getNotifications()`). + + Eine Meldung, die in Schritt 4 dasteht, kann nur in Schritt 3 + entstanden sein -- und da war nichts offen. + + WAS DAMIT NICHT BEWIESEN IST, und das gehoert dazu: Ob ein + bestimmtes Handy sie auch ANZEIGT. Das haengt an den Einstellungen + des Geraets (Nicht stoeren, Berechtigung entzogen, auf dem iPhone + die Installation auf dem Startbildschirm). Geprueft wird der Weg + bis zum Browser, nicht die Laune des Telefons. + + DREI AUSGAENGE: 0 in Ordnung · 1 Befunde · 2 konnte nicht nachsehen. + ===================================================================== */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-pushzu-")); +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 Pruefung bei geschlossener App"); +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(300_000, "pruef-push-zu"); +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 { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }), +}).catch(() => {}); +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 salt = randomBytes(16).toString("hex"); +const hash = scryptSync("CODE-DOGI-0001", 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("Filipe", "admin", hash, salt, 32768, new Date().toISOString()); +d.close(); + +const { chromium } = await import("playwright"); +/* ==== MIT FENSTER, UND ZWAR NOTWENDIG ======================== + + Gemessen, nicht vermutet -- fuenf Aufbauten, eine Antwort: + + headless (Vorgabe), grant mit origin -> denied + headless (Vorgabe), grant ohne origin -> denied + headless (Vorgabe), permissions im Kontext -> denied + headless=old -> denied + mit Fenster (headless: false) -> GRANTED + + Ein kopfloser Chromium verweigert Benachrichtigungen, egal wie + man die Erlaubnis erteilt. Das ist eine Eigenschaft des + Browsers und kein Befund am Haus -- beim ersten Anlauf sah es + genau danach aus („nach dem Push steht 0 Meldungen da"). + + Diese Pruefung oeffnet deshalb ein Fenster. Es ist kurz da und + geht wieder zu. */ +const browser = await chromium.launch({ headless: false }); +const kontext = await browser.newContext({ + viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true, + /* Ohne diese Erlaubnis zeigt der Browser gar nichts an -- und die + Pruefung haette gemessen, dass eine Berechtigung fehlt. */ + permissions: ["notifications"], +}); +/* DIE ERLAUBNIS BRAUCHT EINE HERKUNFT. Ohne `origin` meldet die + Seite `Notification.permission === "denied"` -- gemessen, nicht + vermutet, und es sah aus wie ein Befund am Haus. */ +await kontext.grantPermissions(["notifications"], { origin: BASIS }); +const seite = await kontext.newPage(); +await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); +await seite.click('.rolle[data-rolle="admin"]'); +await seite.fill("#code", "CODE-DOGI-0001"); +await seite.click("button[type=submit]"); +await seite.waitForURL("**/start.html", { timeout: 20000 }); + +/* ---- 1. Der Service Worker meldet sich an --------------------------- */ +const angemeldet = await seite.evaluate(async () => { + if (!("serviceWorker" in navigator)) return { keiner: true }; + const reg = await navigator.serviceWorker.register("/workspace/sw.js", + { scope: "/workspace/" }); + await navigator.serviceWorker.ready; + return { bereich: reg.scope, aktiv: !!reg.active }; +}); +ok(!angemeldet.keiner && angemeldet.aktiv === true, + `der Service Worker ist angemeldet und aktiv (${angemeldet.bereich})`); + +/* ---- 1b. DARF DER BROWSER UEBERHAUPT? ------------------------------- + + DER DRITTE AUSGANG, und er ist hier keine Formsache: Ohne erteilte + Berechtigung zeigt `showNotification` gar nichts an, und alles + darunter waere rot -- mit einem Befund, der dem Haus gehoert, + obwohl er dem Browser gehoert. Genau so sah mein erster Lauf aus. + + „Konnte nicht nachsehen" ist etwas anderes als „es geht nicht". */ +const erlaubnis = await seite.evaluate(() => Notification.permission); +if (erlaubnis !== "granted") { + console.log(`\n KONNTE NICHT NACHSEHEN: Der Browser erlaubt keine ` + + `Benachrichtigungen (Notification.permission = "${erlaubnis}").\n` + + " Gemessen am 02.10.2026: Ein kopfloser Chromium sagt immer „denied“,\n" + + " egal wie die Erlaubnis erteilt wird. Diese Pruefung braucht ein\n" + + " Fenster. Das ist KEIN Befund am Haus.\n"); + await browser.close(); + try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + process.exit(2); +} +ok(true, `der Browser erlaubt Benachrichtigungen (${erlaubnis})`); + +/* ---- 2. Seine Nummer, ueber das DevTools-Protokoll ------------------ + + `deliverPushMessage` braucht die Registrierungsnummer. Sie steht + nirgends im Browser zur Verfuegung -- nur das Protokoll kennt sie. + + DAS MESSINSTRUMENT IST EINE LEERE SEITE, und das ist mit Absicht so + und keine Schlamperei: `ServiceWorker.enable` gibt es nur an einer + SEITE, nicht am Browser (nachgemessen, nicht geraten -- am Browser + antwortet das Protokoll „wasn't found"). Eine Sitzung an der + App-Seite stirbt mit ihr, und genau die soll ja zugemacht werden. + + `about:blank` gehoert nicht zum Haus: kein Skript von dort, kein + Fenster der App, keine Verbindung. Dass zum Zeitpunkt der + Zustellung KEINE Seite des Workspace offen ist, wird unten + ausdruecklich nachgezaehlt -- das ist die Behauptung, nicht „der + Browser ist zu". */ +const traeger = await kontext.newPage(); +await traeger.goto("about:blank"); +const cdp = await kontext.newCDPSession(traeger); +let nummer = null; +const herkunft = BASIS; +cdp.on("ServiceWorker.workerRegistrationUpdated", (e) => { + for (const r of e.registrations || []) { + if (String(r.scopeURL || "").startsWith(herkunft)) nummer = r.registrationId; + } +}); +await cdp.send("ServiceWorker.enable"); +for (let i = 0; i < 40 && !nummer; i++) await new Promise((r) => setTimeout(r, 150)); +if (!nummer) { + /* DRITTER AUSGANG. Ohne Nummer ist die Frage nicht zu beantworten -- + und das ist etwas anderes als „es geht nicht". */ + console.log("\n KONNTE NICHT NACHSEHEN: Der Browser nennt keine " + + "Registrierungsnummer fuer den Service Worker.\n Ohne sie laesst sich " + + "kein Push zustellen. Das ist kein Befund am Haus.\n"); + await browser.close(); + try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + process.exit(2); +} +ok(true, `der Browser kennt seine Registrierung (#${nummer})`); + +/* ---- 3. ALLE SEITEN ZU ---------------------------------------------- */ +/* NUR DIE SEITEN DES HAUSES -- der leere Traeger bleibt, er ist das + Messgeraet. Gezaehlt wird genau das, was behauptet wird. */ +for (const s of kontext.pages()) { + if (s !== traeger) await s.close(); +} +const nochOffen = kontext.pages().filter((s) => s.url().startsWith(herkunft)); +ok(nochOffen.length === 0, + `keine einzige Seite des Workspace ist noch offen (${nochOffen.length});` + + ` offen bleibt nur ${kontext.pages().map((s) => s.url()).join(", ")}`); + +/* ---- 4. Und jetzt kommt der Push ------------------------------------ */ +const INHALT = { + titel: "Neue Nachricht", + text: "VanVan hat dir geschrieben.", + ziel: "/workspace/chat.html", + art: "chat", + haus: "crew", +}; +await cdp.send("ServiceWorker.deliverPushMessage", { + origin: herkunft, + registrationId: nummer, + data: JSON.stringify(INHALT), +}); +/* Dem Service Worker Zeit zum Aufwachen geben. Er wird dafuer vom + Browser frisch gestartet -- das ist der ganze Zweck der Uebung. */ +await new Promise((r) => setTimeout(r, 2500)); + +/* ---- 5. Nachsehen, was dasteht -------------------------------------- + Erst JETZT wieder eine Seite. Alles, was sie findet, ist entstanden, + waehrend nichts offen war. */ +const neueSeite = await kontext.newPage(); +await neueSeite.goto(BASIS + "/workspace/start.html", { waitUntil: "domcontentloaded" }); +const gefunden = await neueSeite.evaluate(async () => { + const reg = await navigator.serviceWorker.ready; + const liste = await reg.getNotifications(); + return liste.map((n) => ({ + titel: n.title, text: n.body, marke: n.tag, + symbol: n.icon || "", abzeichen: n.badge || "", + ziel: (n.data && n.data.ziel) || "", + })); +}); + +ok(gefunden.length >= 1, + `nach dem Push steht ${gefunden.length} Meldung(en) da — bei geschlossener App`); +const m = gefunden[0] || {}; +ok(m.titel === INHALT.titel, `mit dem richtigen Titel („${m.titel}")`); +ok(m.text === INHALT.text, ` und dem richtigen Text („${m.text}")`); +ok(m.ziel === INHALT.ziel, ` sie weiss, wohin sie fuehrt (${m.ziel})`); +/* DAS SYMBOL GEHOERT ZUM HAUS. Eine Crew-Meldung mit dem Gesicht der + Agentur waere die Trennung der zwei Haeuser, die in der Hosentasche + wieder zusammenfaellt. */ +ok(/crew-192\.png$/.test(m.symbol), + ` und traegt das Gesicht des richtigen Hauses (${m.symbol.split("/").pop()})`); +ok(/abzeichen-96\.png$/.test(m.abzeichen), + ` mit dem Abzeichen fuer die Statusleiste (${m.abzeichen.split("/").pop()})`); + +/* ---- 6. GEGENPROBE: auch ohne Daten darf er nicht schweigen --------- + Manche Dienste schicken im Zweifel eine leere Nachricht. Ein + Browser, der eine Push-Berechtigung hat, MUSS dann trotzdem etwas + anzeigen -- sonst entzieht er sie nach mehrmaligem Schweigen wieder, + und ab da kommt gar nichts mehr an. Das ist der Fehler, der sich + selbst verschlimmert. */ +await neueSeite.evaluate(async () => { + const reg = await navigator.serviceWorker.ready; + for (const n of await reg.getNotifications()) n.close(); +}); +for (const s of kontext.pages()) { + if (s !== traeger) await s.close(); +} +await cdp.send("ServiceWorker.deliverPushMessage", { + origin: herkunft, registrationId: nummer, data: "", +}); +await new Promise((r) => setTimeout(r, 2500)); +const zweiteSeite = await kontext.newPage(); +await zweiteSeite.goto(BASIS + "/workspace/start.html", { waitUntil: "domcontentloaded" }); +const leer = await zweiteSeite.evaluate(async () => { + const reg = await navigator.serviceWorker.ready; + return (await reg.getNotifications()).map((n) => ({ titel: n.title, text: n.body })); +}); +ok(leer.length >= 1, + `auch ein Push OHNE Daten zeigt etwas an (${leer.length}) — sonst entzieht` + + " der Browser irgendwann die Berechtigung"); +ok((leer[0]?.titel || "").length > 0 && (leer[0]?.text || "").length > 0, + ` und zwar mit Vorgabetext („${leer[0]?.titel}" · „${leer[0]?.text}")`); + +/* ---- 7. GEGENPROBE ZUR GEGENPROBE ---------------------------------- + Kann diese Messung ueberhaupt „nichts da" sagen? Ohne diese Zeile + waere „es steht etwas da" auch dann gruen, wenn `getNotifications()` + immer dasselbe zurueckgaebe. */ +await zweiteSeite.evaluate(async () => { + const reg = await navigator.serviceWorker.ready; + for (const n of await reg.getNotifications()) n.close(); +}); +await new Promise((r) => setTimeout(r, 400)); +const nachAufraeumen = await zweiteSeite.evaluate(async () => { + const reg = await navigator.serviceWorker.ready; + return (await reg.getNotifications()).length; +}); +ok(nachAufraeumen === 0, + `Gegenprobe: weggeraeumt ist weggeraeumt (${nachAufraeumen}) — die Messung` + + " kann also auch „nichts“ sagen"); + +await browser.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +console.log(`\n${geprueft} Pruefungen, ${fehler} Fehler`); +console.log(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG"); +process.exit(fehler ? 1 : 0);