Der gefaehrlichste Handgriff im Haus wird jetzt am Bildschirm geprueft

pruef-personen-loeschen gibt es seit Langem -- sie kennt aber nur die
Schnittstelle: kein Browser, kein Knopf, kein Dialog. Der Weg, den ein
Mensch geht, um eine Person zu loeschen, war nie geprueft. Und das ist
der eine Handgriff, den man nicht zuruecknehmen kann.

pruef-nachfrage hat jetzt einen Browserteil (33/0 statt 17/0):
  der Dialog geht auf und nennt den Namen im Titel
  er sagt, dass es endgueltig ist, was passiert und was bleibt
  ein FALSCH abgetippter Name schliesst ihn NICHT und sagt, warum
  der Abbruchknopf loescht nichts
  richtig abgetippt wird geloescht (Liste 2 -> 1)
  Abmelden fragt nach und nennt den Grund (Zugangscode)
  Esc bricht ab -- man bleibt angemeldet

BEIM BAUEN ZWEIMAL DANEBENGEGRIFFEN, beide Male gemessen statt
vermutet:
  Die Liste zeigte nur eine Person, obwohl vier in der Datenbank
    standen. Das sah nach einem Befund aus. Die Rollen-Abschnitte sind
    standardmaessig ZU -- nur der eigene ist offen, und das ist richtig
    so. Die Pruefung klappt ihn jetzt auf.
  Vorher wurde mit einer festen Wartezeit gearbeitet. Jetzt wird auf
    den Zustand gewartet, nicht auf die Uhr.

Neu: server/helfer-nachfrage.mjs (bestaetige/verwerfe/nachfrageText)
kennt beide Wege -- den <dialog> und den confirm()-Notnagel fuer
Safari vor 15.4. Ohne den zweiten liesse sich der Notnagel nie pruefen.
This commit is contained in:
2026-09-19 20:24:28 +02:00
parent 1207b79a33
commit e5eed8506c
38 changed files with 678 additions and 514 deletions
+164
View File
@@ -58,6 +58,7 @@
import { readFileSync, readdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { bestaetige, nachfrageText } from "./helfer-nachfrage.mjs";
const HIER = dirname(fileURLToPath(import.meta.url));
const WURZEL = join(HIER, "..");
@@ -268,6 +269,169 @@ pruef("Gegenprobe: der Klammerzähler findet das Ende auch bei Verschachtelung",
aufrufe("frageNach({ titel: f({a:1}), was: 'x' }) ; danach")[0]
=== "{ titel: f({a:1}), was: 'x' }");
/* =====================================================================
UND JETZT AM ECHTEN BILDSCHIRM
Alles oben ist Textsuche. Sie beweist, dass der Aufruf dasteht --
nicht, dass der Dialog aufgeht, dass "Doch nicht" wirklich nichts
tut und dass das Abtippen wirklich sperrt.
Geprüft wird ausgerechnet der gefährlichste Handgriff im Haus: eine
Person löschen. `pruef-personen-loeschen` gibt es zwar, aber sie
kennt nur die Schnittstelle -- kein Browser, kein Knopf, kein
Dialog. Der Weg, den ein Mensch geht, war nie geprüft.
===================================================================== */
if (process.env.OHNE_BROWSER === "ja") {
console.log("\n (Browserteil übersprungen — OHNE_BROWSER=ja)");
} else {
const { mkdtempSync, rmSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { portMussFreiSein } = await import("./helfer-port.mjs");
const { notbremse } = await import("./helfer-notbremse.mjs");
const ordner = mkdtempSync(join(tmpdir(), "ws-nachfrage-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
const PORT = await portMussFreiSein(4329, "die Nachfragepruefung");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
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-nachfrage");
await new Promise((r) => setTimeout(r, 1000));
const BASIS = `http://127.0.0.1:${PORT}`;
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 pb = new DatabaseSync(process.env.WORKSPACE_DB);
try { pb.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { pb.close(); }
} catch { await new Promise((r) => setTimeout(r, 250)); }
}
const db = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const anlegen = (name, rolle, code) => {
const salt = randomBytes(16).toString("hex");
const h = scryptSync(code, salt, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
db.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,1,?)").run(name, rolle, h, salt, 32768, jetzt);
};
anlegen("Filipe", "admin", "CODE-DOGI-0001");
anlegen("Testolina", "creator", "CODE-CREA-0001");
db.close();
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const seite = await ctx.newPage();
const abstuerze = [];
seite.on("pageerror", (f) => abstuerze.push(f.message));
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
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: 25000 });
await seite.goto(BASIS + "/workspace/personen.html", { waitUntil: "networkidle" });
await seite.waitForTimeout(900);
console.log("\n --- Am echten Bildschirm: eine Person löschen ---");
const wieViele = () => seite.evaluate(() =>
[...document.querySelectorAll(".person")].length);
/* DEN ABSCHNITT AUFKLAPPEN. Die Rollen-Abschnitte sind
standardmäßig ZU -- nur der eigene ist offen. Das ist so gewollt
(eine Liste mit fünf offenen Abschnitten ist unlesbar), und eine
Prüfung, die das nicht weiß, misst eine leere Liste und hält das
für einen Befund. Genau das ist beim Bauen passiert. */
await seite.waitForSelector('.gruppe[data-rolle="creator"] .gruppe__kopf',
{ timeout: 15_000 });
await seite.click('.gruppe[data-rolle="creator"] .gruppe__kopf');
await seite.waitForFunction(
() => document.querySelectorAll(".person").length >= 2,
{ timeout: 15_000 }).catch(() => {});
const vorher = await wieViele();
const namen2 = await seite.evaluate(() =>
[...document.querySelectorAll(".person")]
.map((e) => e.textContent.trim().slice(0, 30)).join(" | "));
pruef("die Liste steht da", vorher >= 2, `${vorher} Personen: ${namen2}`);
const loeschKnopf = seite.locator(".person", { hasText: "Testolina" })
.locator("button", { hasText: "Löschen" }).first();
await loeschKnopf.click();
let dialogDa = true;
try { await seite.waitForSelector("dialog.nachfrage[open]", { timeout: 4000 }); }
catch { dialogDa = false; }
pruef("der Dialog geht auf", dialogDa);
if (dialogDa) {
const inhalt = await nachfrageText(seite);
pruef("er nennt den Namen im Titel", /Testolina/.test(inhalt.titel), inhalt.titel);
pruef("er sagt, dass es endgültig ist", inhalt.endgueltig);
pruef("er sagt, WAS passiert", inhalt.was.length > 10, inhalt.was);
pruef("und was BLEIBT", inhalt.bleibt.length > 10, inhalt.bleibt);
/* DER RIEGEL: Ein falsch abgetippter Name darf NICHTS loeschen. */
await seite.fill("#nachfrage-tipp", "Testolinaa");
await seite.click('dialog.nachfrage [data-teil="ja"]');
await seite.waitForTimeout(500);
const nochOffen = await seite.evaluate(() =>
!!document.querySelector("dialog.nachfrage[open]"));
pruef("ein falsch abgetippter Name schließt den Dialog NICHT", nochOffen);
const fehlertext = await seite.evaluate(() =>
document.querySelector('dialog.nachfrage [data-teil="fehler"]')?.textContent?.trim() || "");
pruef("und sagt, was erwartet wird", /Testolina/.test(fehlertext), fehlertext);
pruef("die Person ist noch da", await wieViele() === vorher);
/* Der Knopf daneben muss wirklich nichts tun. */
await seite.click('dialog.nachfrage [data-teil="nein"]');
await seite.waitForTimeout(400);
pruef("der Abbruchknopf schließt den Dialog",
!await seite.evaluate(() => !!document.querySelector("dialog.nachfrage[open]")));
pruef("und löscht nichts", await wieViele() === vorher);
/* Und jetzt richtig. */
await bestaetige(seite, () => loeschKnopf.click(), { tippen: "Testolina" });
await seite.waitForTimeout(1400);
const nachher = await wieViele();
pruef("mit richtig abgetipptem Namen wird gelöscht",
nachher === vorher - 1, `${vorher} → ${nachher}`);
}
/* Und Esc bricht ab -- auf einem Dialog OHNE Abtippen. */
await seite.goto(BASIS + "/workspace/start.html", { waitUntil: "networkidle" });
await seite.waitForTimeout(700);
await seite.click("#abmelden");
let abmeldeDialog = true;
try { await seite.waitForSelector("dialog.nachfrage[open]", { timeout: 4000 }); }
catch { abmeldeDialog = false; }
pruef("auch Abmelden fragt nach", abmeldeDialog);
if (abmeldeDialog) {
const t = await nachfrageText(seite);
pruef("und nennt den Grund: der Zugangscode", /Zugangscode/.test(t.was), t.was);
await seite.keyboard.press("Escape");
await seite.waitForTimeout(800);
pruef("Esc bricht ab – man bleibt angemeldet",
seite.url().includes("start.html"), seite.url());
}
pruef("keine Abstürze im Browser", abstuerze.length === 0, abstuerze[0] || "");
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
}
console.log(`\n ${ok} bestanden, ${fehl} fehlgeschlagen`);
console.log(` (${gezaehlt} Rückfragen in ${nutzer.size} Dateien, `
+ `${mitNutzung} Seiten)\n`);