Files
DogFatherGit 077925ab25 Dreizehn rote Pruefungen, eine Ursache: openssl steht nicht im PATH
DER BEFUND

Im naechtlichen Lauf standen dreizehn Pruefungen mit derselben Zeile
rot:

    [uncaughtException] Error: spawnSync openssl ENOENT

Kein Programmfehler. Die Pruefungen brauchen einen https-Vorbau (der
Sitzungskeks des Hauses ist `secure` und kommt ueber http nicht an),
und dafuer erzeugen sie ein Wegwerf-Zertifikat mit openssl.

GEMESSEN, NICHT VERMUTET

  openssl liegt hier:   C:\Program Files\Git\mingw64\bin\openssl.exe
                        C:\Program Files\Git\usr\bin\openssl.exe
  im System-PATH steht: C:\Program Files\Git\cmd   (nur git.exe)
  im Benutzer-PATH:     nichts mit Git

Wer aus Git Bash startet, erbt die mingw-Pfade und merkt nie etwas.
Die Aufgabenplanung startet `node.exe` DIREKT, ohne Shell -- und
bekommt sie nicht. Bei mir gruen, nachts rot, und der Grund steht
nicht im Code.

Das Schlimmste daran ist nicht der Ausfall: Dreizehn dauerhaft rote
Zeilen in einer Notiz, die Filipe morgens liest, gewoehnen einem das
Hinsehen ab -- und decken dabei die echten Befunde zu.

DIE REPARATUR

`server/helfer-openssl.mjs` sucht openssl erst im PATH (ohne `where`
oder `which` -- beides sind selbst Programme und koennen genauso
fehlen), danach an den Orten, an denen es auf einem Windows-Rechner
mit Git wirklich liegt.

Dazu `zertifikatBauen(schluessel, zertifikat, host)`. Die acht
Aufrufformen im Haus waren identisch bis auf die Variablennamen
(schl/zert, schluesselDatei/zertDatei, zKey/zCrt, sk/zt, key/crt) --
eine Stelle traegt alle neunundzwanzig. Zwei Tage zuvor war eine
davon um ein `-addext` aermer als die anderen; gemerkt hat es
niemand, weil der Browser den fehlenden Alternativnamen erst bei
einer Weiterleitung anmahnt. Eine Stelle kann nicht von sich selbst
abweichen.

KEIN EINTRAG IN DEN SYSTEM-PATH. `mingw64\bin` enthaelt rund hundert
Programme mit Unix-Namen (`find`, `sort`, `link`), die gleichnamige
Windows-Befehle verdecken. Das fuer eine Pruefung zu aendern haette
an ganz anderen Stellen Fehler verursacht, die niemand hierher
zurueckverfolgt.

GEGENPROBE IN BEIDE RICHTUNGEN, mit dem PATH des Nachtlaufs:

  vorher (direkter Aufruf):  ABSTURZ: spawnSync openssl ENOENT
  nachher (ueber den Helfer): Zertifikat gebaut, 1236 Bytes

Und eine echte Pruefung unter denselben Bedingungen:

  PATH ohne mingw64 -> pruef-chat-neu-stelle
  63 Pruefungen, 0 Fehler, 0 Abstuerze

Genau diese Datei stand im Nachtlauf mit ENOENT rot.

EINE WACHE DAGEGEN

pruef-struktur prueft ab jetzt, dass niemand openssl wieder direkt
ruft -- der naechste merkt es dort und nicht erst in einem
Nachtlauf, den niemand liest. Drei Gegenproben.

Beim ersten Anlauf schlug sie auf ihre EIGENEN Probetexte an: Sie
liest alle Serverdateien, und dazu gehoert sie selbst. Die Texte
werden jetzt zusammengesetzt. Derselbe Selbsttreffer ist mir heute
schon zweimal passiert.

DER DRITTE AUSGANG BLEIBT, WO ER HINGEHOERT

`helfer-kachel-echtfarbe.mjs` faengt den Fehler weiterhin ab und
meldet `moeglich: false` mit Grund, statt abzubrechen. Eine
Sicherung abzuschaffen, weil ihr Anlass gerade behoben ist, ist der
Anfang des naechsten stillen Fehlschlags.

NEBENBEI: pruef-community-sicht

Sie war rot mit "ZU VIEL: reaktion.html" -- mein eigener Rueckstand
vom 28.09. Die Reaction steht als Kachel im Community-Bereich; die
Liste war nicht nachgezogen. Jetzt 10 / 0.

Die Liste bleibt bewusst von Hand gepflegt: Sie ist eine ABSICHT,
keine Ableitung. Aus rechte.js gelesen verglichen sich zwei Kopien
derselben Quelle -- immer gruen, nie ein Beweis.

GEPRUEFT
  pruef-struktur         ALLES IN ORDNUNG (mit der neuen Wache)
  pruef-community-sicht  10 / 0
  pruef-chat-neu-stelle  63 / 0  (mit dem PATH des Nachtlaufs)
  pruef-notizen, -teamlage-karten, -werdegang: EXIT 0, keine Abstuerze
  mess-quer              EXIT 0
  29 Dateien: node --check auf allen, kein direkter Aufruf mehr
2026-09-29 16:14:13 +02:00

728 lines
33 KiB
JavaScript

/* =====================================================================
DER NOTIZBLOCK (26.09.2026)
Filipe: „für die modis, rechte und linke hand und dogfather eine
kachel hinzufügst, sie soll: Notizen, heißen. ich will dass du das
auch wie ein notizblock erstellst."
---------------------------------------------------------------------
DREI DINGE WERDEN GEPRÜFT, UND ZWEI DAVON HAT NIEMAND BESTELLT
1. WER DARF ÜBERHAUPT. Genau die vier Rollen, und niemand sonst —
auch nicht über die Schnittstelle, auch nicht von der anderen
Adresse aus. Die Rollenmenge wird dabei nicht abgeschrieben,
sondern aus dem Programm gelesen und gegen die Rechtetafel
gehalten: Zwei Listen für dieselbe Frage laufen auseinander.
2. NIEMAND SIEHT DIE NOTIZEN EINES ANDEREN. Auch DogFather nicht.
Das ist die Zusage, die auf der Kachel steht („nur du siehst
ihn"), und die einzige, deren Bruch man nicht bemerken würde:
Eine Notiz, die jemand Fremdes liest, sieht für den Schreiber
genauso aus wie eine, die niemand liest.
3. DIE DECKSCHICHT LIEGT AUF DEN PIXEL GENAU. Das Blatt besteht
aus einem unsichtbaren Schreibfeld und einer sichtbaren
Deckschicht darüber. Ändert eine Auszeichnung die BREITE eines
Zeichens (Fettdruck, andere Schriftgröße, Sperrung), brechen
beide Schichten an verschiedenen Stellen um — und der Cursor
steht neben dem Text. Das sieht aus wie ein Browserfehler und
wäre einer in notizen.css. Gemessen wird deshalb am echten
Umbruch, nicht an der Absicht.
---------------------------------------------------------------------
WARUM DIESE PRÜFUNG ÜBER https SPRICHT
Derselbe Grund wie bei pruef-handy-teamdogi: `dogfather-universe.com`
steht in der fest eingebauten HSTS-Liste von Chromium, und ohne die
Team-Adresse sieht man die Trennung nicht: Auf einer Prüfadresse
(127.0.0.1) ist `haus` weder „crew" noch „agentur" — dort ist der
Block offen (so wie jedes andere Modul auch), und nur auf
`workspace.` wird er abgewiesen. Beides wird unten gemessen.
Aufrufen mit: node server/pruef-notizen.mjs
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { request } from "node:http";
const ordner = mkdtempSync(join(tmpdir(), "ws-notiz-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
import { notbremse } from "./helfer-notbremse.mjs";
import { zertifikatBauen } from "./helfer-openssl.mjs";
const { eigenerPort } = await import("./helfer-port.mjs");
const PORT = await eigenerPort(import.meta, "die Notizblockpruefung");
process.env.PORT = `${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(260_000, "pruef-notizen");
await new Promise((r) => setTimeout(r, 900));
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);
process.on("exit", () => { try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } });
const CREW = "crew.dogfather-universe.com";
const AGENTUR = "workspace.dogfather-universe.com";
/* ---------- Zugänge anlegen -------------------------------------------- */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const nun = 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, nun);
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"), nun);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const ROLLEN = [["admin", "Filipe"], ["hand", "VanVan"], ["linke", "Funny"],
["modi", "Miss"], ["gast", "Zuschauerin"], ["creator", "Luna"]];
const CODES = {};
const IDS = {};
for (const [i, [rolle, name]] of ROLLEN.entries()) {
CODES[rolle] = `CODE-N${String(i).padStart(2, "0")}-000001`;
IDS[rolle] = anlegen(name, rolle, CODES[rolle]);
}
d.close();
/* ---------- Sprechen mit Hausadresse ------------------------------------
`fetch` kann die Host-Kopfzeile nicht setzen (verbotene Kopfzeile),
und an ihr entscheidet das Haus alles. Deshalb node:http. */
function roh(host, pfad, { method = "GET", keks = "", rumpf = null, origin } = {}) {
return new Promise((fertig, schief) => {
const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf));
const a = request({
host: "127.0.0.1", port: PORT, path: pfad, method,
headers: {
Host: host,
Origin: origin === undefined ? `https://${host}` : origin,
...(keks ? { Cookie: keks } : {}),
...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}),
},
}, (antwort) => {
const teile = [];
antwort.on("data", (s) => teile.push(s));
antwort.on("end", () => {
const text = Buffer.concat(teile).toString("utf8");
let j = null; try { j = JSON.parse(text); } catch { /* kein JSON */ }
fertig({ code: antwort.statusCode, text, j, kopf: antwort.headers });
});
});
a.on("error", schief);
if (daten) a.write(daten);
a.end();
});
}
async function anmelden(rolle, host) {
const a = await roh(host, "/workspace/api/anmelden", {
method: "POST", rumpf: { rolle, code: CODES[rolle] },
});
const keks = []
.concat(a.kopf["set-cookie"] || [])
.map((z) => z.split(";")[0]).join("; ");
return a.code === 200 && keks ? keks : null;
}
/* =======================================================================
1. WER DARF — und woher die Liste kommt
======================================================================= */
melde("=== 1. Wer einen Block hat ===");
const { ROLLEN_MIT_BLOCK } = await import("./workspace-notizen.js");
const { SEITEN } = await import("./rechte.js");
{
const ausTafel = [...(SEITEN["/workspace/notizen.html"] || [])].sort();
const ausProgramm = [...ROLLEN_MIT_BLOCK].sort();
ok(ausTafel.length === 4,
`die Rechtetafel nennt vier Rollen (${ausTafel.join(", ") || "keine"})`);
/* BEIDE SEITEN GEGENEINANDER. Die Tafel bewacht die Seite, die
Menge im Modul die Daten. Wer nur eine pflegt, baut entweder eine
Tuer ohne Raum oder einen Raum ohne Tuer -- und beides faellt
niemandem auf. */
ok(JSON.stringify(ausTafel) === JSON.stringify(ausProgramm),
`Tafel und Schnittstelle nennen dieselben Rollen (${ausProgramm.join(", ")})`,
);
ok(ausTafel.includes("modi") && ausTafel.includes("hand")
&& ausTafel.includes("linke") && ausTafel.includes("admin"),
"und es sind genau die vier aus dem Auftrag");
}
const kekse = {};
for (const [rolle] of ROLLEN) kekse[rolle] = await anmelden(rolle, CREW);
ok(["admin", "hand", "linke", "modi"].every((r) => kekse[r]),
"die vier melden sich auf der Team-Adresse an");
melde("");
melde("=== 2. Und wer nicht ===");
for (const rolle of ["gast", "creator"]) {
if (!kekse[rolle]) { ok(true, `${rolle}: kommt auf dieser Adresse gar nicht herein`); continue; }
const a = await roh(CREW, "/workspace/api/notizblock", { keks: kekse[rolle] });
ok(a.code === 404, `${rolle}: bekommt 404 an der Schnittstelle (${a.code})`);
}
{
/* DIE ANDERE ADRESSE. DogFather arbeitet in beiden Haeusern -- und
genau deshalb ist er hier der richtige Pruefstein: Bei ihm
scheitert es nicht an der Rolle, sondern an der Adresse. */
const keksAgentur = await anmelden("admin", AGENTUR);
ok(!!keksAgentur, "DogFather meldet sich auch auf der Agenturadresse an");
const a = await roh(AGENTUR, "/workspace/api/notizblock", { keks: keksAgentur });
ok(a.code === 404, `und bekommt dort 404 auf seinen Block (${a.code})`);
const s = await roh(AGENTUR, "/workspace/notizen.html", { keks: keksAgentur });
ok(s.code === 404, `auch die Seite selbst gibt es dort nicht (${s.code})`);
const s2 = await roh(CREW, "/workspace/notizen.html", { keks: kekse.admin });
ok(s2.code === 200, `auf der Team-Adresse schon (${s2.code})`);
}
/* ---- Und die Pruefadresse ist das ANDERE Ende derselben Regel --------
(27.09.2026, nachgetragen, nachdem pruef-handy einen 404 gemeldet
hatte.)
Auf 127.0.0.1 hat niemand ein Haus. Die erste Fassung der Schranke
fragte „ist das Haus crew?" und wies damit auch die Pruefadresse ab
-- die Seite lud, blieb leer und schrieb einen Fehler in die
Konsole. Gefragt wird jetzt „ist es das ANDERE Haus?", so wie es
hausWo() im ganzen Haus tut.
Beide Enden gehoeren in dieselbe Pruefung: Wer nur das eine misst,
kann die Schranke jederzeit wieder zu scharf stellen, ohne dass
etwas rot wird. */
{
const keksProbe = await anmelden("admin", "127.0.0.1");
ok(!!keksProbe, "auf der Pruefadresse meldet sich DogFather an");
const a = await roh("127.0.0.1", "/workspace/api/notizblock", { keks: keksProbe });
ok(a.code === 200 && Array.isArray(a.j?.notizen),
`und bekommt dort seinen Block (${a.code}) -- ohne Haus wird nicht gesperrt`);
const s3 = await roh("127.0.0.1", "/workspace/notizen.html", { keks: keksProbe });
ok(s3.code === 200, `auch die Seite selbst (${s3.code})`);
}
{
const ohne = await roh(CREW, "/workspace/api/notizblock");
ok(ohne.code === 401, `ohne Anmeldung: 401 (${ohne.code})`);
}
/* =======================================================================
3. SCHREIBEN, LESEN, ÄNDERN
======================================================================= */
melde("");
melde("=== 3. Ein Blatt ===");
let blattId = null;
{
const leer = await roh(CREW, "/workspace/api/notizblock", { keks: kekse.modi });
ok(leer.code === 200 && Array.isArray(leer.j?.notizen) && leer.j.notizen.length === 0,
`am Anfang ist der Block leer (${leer.j?.notizen?.length})`);
ok(Array.isArray(leer.j?.farben) && leer.j.farben.length >= 4,
`die Papierfarben kommen vom Server (${leer.j?.farben?.length})`);
const neu = await roh(CREW, "/workspace/api/notizblock", {
method: "POST", keks: kekse.modi, rumpf: { text: "Erste Zeile\nZweite Zeile" },
});
blattId = neu.j?.notiz?.id;
ok(neu.code === 201 && blattId > 0, `ein Blatt anlegen geht (${neu.code})`);
ok(neu.j?.notiz?.titel === "Erste Zeile",
`der Titel wird aus der ersten Zeile abgeleitet ("${neu.j?.notiz?.titel}")`);
const aend = await roh(CREW, `/workspace/api/notizblock/${blattId}`, {
method: "PATCH", keks: kekse.modi, rumpf: { text: "# Einkauf\n[ ] Milch\n[x] Brot" },
});
ok(aend.code === 200 && typeof aend.j?.geaendert === "string",
`aendern geht und gibt den Zeitstempel zurueck (${aend.code})`);
const nach = await roh(CREW, "/workspace/api/notizblock", { keks: kekse.modi });
ok(nach.j?.notizen?.[0]?.text?.includes("[x] Brot"), "und der Text steht drin");
ok(nach.j?.notizen?.[0]?.titel === "Einkauf",
`die Raute zaehlt nicht zum Titel ("${nach.j?.notizen?.[0]?.titel}")`);
}
{
const falsch = await roh(CREW, `/workspace/api/notizblock/${blattId}`, {
method: "PATCH", keks: kekse.modi, rumpf: { farbe: "knallorange" },
});
ok(falsch.code === 400, `eine erfundene Papierfarbe wird abgelehnt (${falsch.code})`);
const echt = await roh(CREW, `/workspace/api/notizblock/${blattId}`, {
method: "PATCH", keks: kekse.modi, rumpf: { farbe: "rosa" },
});
ok(echt.code === 200, `eine echte nicht (${echt.code})`);
}
{
const lang = "x".repeat(30000);
const a = await roh(CREW, `/workspace/api/notizblock/${blattId}`, {
method: "PATCH", keks: kekse.modi, rumpf: { text: lang },
});
const nach = await roh(CREW, "/workspace/api/notizblock", { keks: kekse.modi });
const drin = nach.j?.notizen?.find((n) => n.id === blattId)?.text || "";
/* ABGESCHNITTEN UND NICHT ABGELEHNT. Wer zwanzigtausend Zeichen
getippt hat, soll nicht alles verlieren, weil er hundert zu viel
hatte -- er soll merken, dass hier Schluss ist. */
ok(a.code === 200 && drin.length === nach.j.text_max,
`zu langer Text wird gekuerzt statt abgelehnt (${drin.length} von ${nach.j?.text_max})`);
await roh(CREW, `/workspace/api/notizblock/${blattId}`, {
method: "PATCH", keks: kekse.modi, rumpf: { text: "# Einkauf\n[ ] Milch\n[x] Brot" },
});
}
{
const fremd = await roh(CREW, `/workspace/api/notizblock/${blattId}`, {
method: "PATCH", keks: kekse.modi, rumpf: { text: "x" }, origin: "https://boese.example",
});
ok(fremd.code === 403, `eine Anfrage von einer fremden Seite wird abgewiesen (${fremd.code})`);
}
/* =======================================================================
4. NIEMAND SIEHT DIE NOTIZEN EINES ANDEREN
Die Zusage steht auf der Kachel. Sie ist auch die einzige, deren
Bruch der Schreiber nicht bemerken würde.
======================================================================= */
melde("");
melde("=== 4. Niemand sonst ===");
for (const rolle of ["admin", "hand", "linke"]) {
const liste = await roh(CREW, "/workspace/api/notizblock", { keks: kekse[rolle] });
const fremdeDrin = (liste.j?.notizen || []).some((n) => n.id === blattId);
ok(liste.code === 200 && !fremdeDrin,
`${rolle}: sieht das Blatt des Modis nicht in seiner Liste`);
const holen = await roh(CREW, `/workspace/api/notizblock/${blattId}`, {
method: "PATCH", keks: kekse[rolle], rumpf: { text: "hier war ich" },
});
ok(holen.code === 404, `${rolle}: kann es auch nicht aendern (${holen.code})`);
const weg = await roh(CREW, `/workspace/api/notizblock/${blattId}`, {
method: "DELETE", keks: kekse[rolle],
});
ok(weg.code === 404, `${rolle}: und nicht wegwerfen (${weg.code})`);
}
{
/* GEGENPROBE. Ohne sie wuerde die Messung oben auch dann gruen, wenn
das Blatt gar nicht existiert. */
const eigen = await roh(CREW, `/workspace/api/notizblock/${blattId}`, {
method: "PATCH", keks: kekse.modi, rumpf: { angeheftet: true },
});
ok(eigen.code === 200, `Gegenprobe: dem Modi selbst gelingt genau dasselbe (${eigen.code})`);
const nach = await roh(CREW, "/workspace/api/notizblock", { keks: kekse.modi });
ok(nach.j?.notizen?.[0]?.angeheftet === true, "und das Anheften kommt an");
}
/* =======================================================================
5. DER PAPIERKORB
======================================================================= */
melde("");
melde("=== 5. Der Papierkorb ===");
{
const weg = await roh(CREW, `/workspace/api/notizblock/${blattId}`, {
method: "DELETE", keks: kekse.modi,
});
ok(weg.code === 200, `wegtun geht (${weg.code})`);
const block = await roh(CREW, "/workspace/api/notizblock", { keks: kekse.modi });
ok(!(block.j?.notizen || []).some((n) => n.id === blattId), "und es ist vom Block verschwunden");
ok(block.j?.papierkorb === 1, `der Papierkorb zaehlt es (${block.j?.papierkorb})`);
const korb = await roh(CREW, "/workspace/api/notizblock/papierkorb", { keks: kekse.modi });
const drin = (korb.j?.notizen || []).find((n) => n.id === blattId);
ok(!!drin, "es liegt im Papierkorb");
ok(typeof drin?.endgueltig_am === "string"
&& Date.parse(drin.endgueltig_am) > Date.now(),
`und es steht dabei, wann es von selbst geht (${drin?.endgueltig_am?.slice(0, 10)})`);
const zurueck = await roh(CREW, `/workspace/api/notizblock/${blattId}/zurueck`, {
method: "POST", keks: kekse.modi,
});
ok(zurueck.code === 200, `zurueckholen geht (${zurueck.code})`);
const block2 = await roh(CREW, "/workspace/api/notizblock", { keks: kekse.modi });
ok((block2.j?.notizen || []).some((n) => n.id === blattId), "und es ist wieder da");
}
{
/* ENDGUELTIG NUR AUS DEM PAPIERKORB. Vom Block aus gibt es diesen
Weg nicht -- wer danebentippt, soll nicht alles verlieren. */
const zuFrueh = await roh(CREW, `/workspace/api/notizblock/${blattId}/endgueltig`, {
method: "DELETE", keks: kekse.modi,
});
ok(zuFrueh.code === 404,
`endgueltig loeschen geht vom Block aus NICHT (${zuFrueh.code})`);
await roh(CREW, `/workspace/api/notizblock/${blattId}`, { method: "DELETE", keks: kekse.modi });
const jetzt = await roh(CREW, `/workspace/api/notizblock/${blattId}/endgueltig`, {
method: "DELETE", keks: kekse.modi,
});
ok(jetzt.code === 200, `aus dem Papierkorb schon (${jetzt.code})`);
const korb = await roh(CREW, "/workspace/api/notizblock/papierkorb", { keks: kekse.modi });
ok((korb.j?.notizen || []).length === 0, "und dann ist es wirklich weg");
}
/* =======================================================================
6. DAS AUFRÄUMEN HÄLT SEINE FRIST
Eine Frist ohne Werkzeug ist ein Satz in einem Kommentar. Geprüft
wird deshalb an echten Daten, in beide Richtungen.
======================================================================= */
melde("");
melde("=== 6. Dreissig Tage ===");
{
const { notizenAufraeumen } = await import("./workspace-notizen.js");
const { PAPIERKORB_TAGE } = await import("./notiz-tabellen.js");
const { db } = await import("./workspace.js");
const alt = new Date(Date.now() - (PAPIERKORB_TAGE + 2) * 86400000).toISOString();
const frisch = new Date(Date.now() - 86400000).toISOString();
const einfuegen = (weg) => db().prepare(
"INSERT INTO notizen (person_id, text, farbe, angeheftet, erstellt, geaendert, weg_am)"
+ " VALUES (?,?,?,0,?,?,?) RETURNING id")
.get(IDS.modi, "Probe", "block", nun, nun, weg).id;
const idAlt = einfuegen(alt);
const idNeu = einfuegen(frisch);
const idOffen = db().prepare(
"INSERT INTO notizen (person_id, text, farbe, angeheftet, erstellt, geaendert)"
+ " VALUES (?,?,?,0,?,?) RETURNING id").get(IDS.modi, "Auf dem Block", "block", nun, nun).id;
const weg = notizenAufraeumen();
const da = (id) => !!db().prepare("SELECT 1 FROM notizen WHERE id = ?").get(id);
ok(weg === 1, `genau eines wird geraeumt (${weg})`);
ok(!da(idAlt), `das alte ist weg (nach ${PAPIERKORB_TAGE} Tagen)`);
ok(da(idNeu), "das von gestern liegt noch da");
/* DIE WICHTIGERE HAELFTE: Was auf dem Block liegt, wird NIE
geraeumt. Ohne diese Zeile waere ein Aufraeumer, der alles
loescht, hier gruen. */
ok(da(idOffen), "und was gar nicht weggelegt wurde, bleibt unberuehrt");
db().prepare("DELETE FROM notizen WHERE id IN (?,?)").run(idNeu, idOffen);
}
{
/* Steht die Frist auch in der Aufbewahrungsliste? Sie ist die
einzige Stelle im Haus, die wirklich raeumt -- am 24.09. war
genau dieser Eintrag beim Support vergessen worden, und die
Meldungen waeren fuer immer liegen geblieben. */
const { FRISTEN } = await import("./workspace-aufbewahrung.js");
const eintrag = (FRISTEN || []).find((e) => e.schluessel === "notizen_papierkorb");
ok(!!eintrag, "der Papierkorb steht in der Aufbewahrungsliste");
ok(typeof eintrag?.raeumen === "function",
"und hat ein Werkzeug, das ihn wirklich raeumt");
}
/* =======================================================================
7. DAS BLATT AUF DEM BILDSCHIRM
Jetzt der Teil, den keine Schnittstelle beantworten kann.
======================================================================= */
melde("");
melde("=== 7. Das Blatt auf dem Bildschirm ===");
const { execFileSync } = await import("node:child_process");
const { createServer: httpsServer } = await import("node:https");
const { request: httpAnfrage } = await import("node:http");
const schluesselDatei = join(ordner, "pruef.key");
const zertDatei = join(ordner, "pruef.crt");
zertifikatBauen(schluesselDatei, zertDatei, CREW);
const HTTPS_PORT = PORT + 1;
const vorbau = httpsServer({
key: readFileSync(schluesselDatei), cert: readFileSync(zertDatei),
}, (von, nach) => {
const weiter = httpAnfrage({
host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
headers: { ...von.headers, host: `${CREW}:${HTTPS_PORT}` },
}, (antwort) => {
nach.writeHead(antwort.statusCode || 502, antwort.headers);
antwort.pipe(nach);
});
weiter.on("error", () => { try { nach.writeHead(502); nach.end(); } catch { /* egal */ } });
von.pipe(weiter);
});
await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r));
const BASIS = `https://${CREW}:${HTTPS_PORT}`;
const pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch({
args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`,
"--ignore-certificate-errors"],
});
const kontext = await browser.newContext({
viewport: { width: 1440, height: 900 }, ignoreHTTPSErrors: true,
});
await kontext.addCookies((kekse.modi || "").split("; ").filter(Boolean).map((k) => {
const [n, ...r] = k.split("=");
return { name: n, value: r.join("="), domain: CREW, path: "/" };
}));
const seite = await kontext.newPage();
const konsole = [];
seite.on("pageerror", (f) => konsole.push(String(f?.message || f)));
seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
await seite.goto(`${BASIS}/workspace/notizen.html`, { waitUntil: "networkidle" });
await seite.waitForTimeout(700);
ok(await seite.locator("#block").count() === 1, "die Seite kommt an");
/* Ein Blatt anlegen und Text hineinschreiben, der alle Auszeichnungen
enthaelt -- und eine SEHR lange Zeile, die umbrechen MUSS. Genau an
ihr faellt eine Breitenaenderung auf: Brechen die zwei Schichten an
verschiedenen Stellen um, sind sie verschieden hoch. */
const PROBE = [
"# Eine Ueberschrift",
"[ ] Offener Punkt",
"[x] Erledigter Punkt",
"- Ein Aufzaehlungspunkt",
"> Ein Zitat",
"Ein Link: https://example.org/pfad",
"Eine sehr lange Zeile, die auf jeden Fall umbrechen muss, damit man sieht, "
+ "ob beide Schichten an derselben Stelle umbrechen, und deshalb ist sie so "
+ "lang wie sie ist, mit vielen Woertern hintereinander ohne Punkt und Komma "
+ "damit der Umbruch wirklich mitten im Satz passiert und nicht am Ende.",
].join("\n");
await seite.click("#neu");
await seite.waitForTimeout(400);
await seite.fill("#feld", PROBE);
await seite.waitForTimeout(400);
{
const m = await seite.evaluate(() => {
const f = document.getElementById("feld");
const dk = document.getElementById("deck");
const cf = getComputedStyle(f), cd = getComputedStyle(dk);
const gleich = (n) => cf[n] === cd[n];
return {
hoeheFeld: f.scrollHeight, hoeheDeck: dk.scrollHeight,
breiteFeld: f.clientWidth, breiteDeck: dk.clientWidth,
schriftGleich: ["fontFamily", "fontSize", "fontWeight", "fontStyle",
"letterSpacing", "wordSpacing", "lineHeight", "whiteSpace",
"overflowWrap", "tabSize", "paddingLeft", "paddingRight",
"paddingTop"].filter((n) => !gleich(n)),
/* Und dasselbe fuer JEDES ausgezeichnete Stueck im Deck: Wenn
eine Auszeichnung die Schrift veraendert, steht es hier. */
stuecke: [...dk.querySelectorAll("span, a")].map((s) => {
const c = getComputedStyle(s);
return ["fontFamily", "fontSize", "fontWeight", "fontStyle",
"letterSpacing", "wordSpacing"]
.filter((n) => c[n] !== cf[n])
.map((n) => `${s.className || s.tagName}.${n}=${c[n]}`);
}).flat(),
haken: dk.querySelectorAll(".n-hk").length,
hakenAn: dk.querySelectorAll('.n-hk[data-an="ja"]').length,
links: dk.querySelectorAll("a.n-link").length,
ueber: dk.querySelectorAll(".n-h").length,
text: dk.textContent,
};
});
ok(m.schriftGleich.length === 0,
`beide Schichten schreiben gleich (${m.schriftGleich.join(", ") || "alles gleich"})`);
ok(m.stuecke.length === 0,
`keine Auszeichnung aendert die Schrift (${m.stuecke.slice(0, 3).join(", ") || "keine"})`);
ok(m.breiteFeld === m.breiteDeck,
`beide sind gleich breit (${m.breiteFeld} / ${m.breiteDeck})`);
/* DIE EIGENTLICHE MESSUNG. Zwei Bildpunkte Spielraum fuers Runden;
ein verschobener Umbruch kostet eine ganze Zeile (30 px). */
ok(Math.abs(m.hoeheFeld - m.hoeheDeck) <= 2,
`und gleich hoch -- der Umbruch sitzt an derselben Stelle `
+ `(${m.hoeheFeld} / ${m.hoeheDeck})`);
ok(m.text.replace(/\s+$/, "") === PROBE,
"die Deckschicht zeigt genau den Text, Zeichen fuer Zeichen");
ok(m.haken === 2 && m.hakenAn === 1,
`zwei Kaestchen, eines angehakt (${m.haken}/${m.hakenAn})`);
ok(m.links === 1, `die Adresse ist anklickbar (${m.links})`);
ok(m.ueber === 1, `die Ueberschrift ist ausgezeichnet (${m.ueber})`);
}
/* GEGENPROBE ZUR HOEHENMESSUNG. Ohne sie wuerde die Zeile oben auch
dann gruen, wenn die Messung gar nichts ansieht. Ein Fettdruck auf
der Deckschicht MUSS sie rot machen. */
{
const unterschied = await seite.evaluate(() => {
const f = document.getElementById("feld");
const dk = document.getElementById("deck");
const vorher = Math.abs(f.scrollHeight - dk.scrollHeight);
dk.style.fontWeight = "800";
dk.style.letterSpacing = "0.4px";
const nachher = Math.abs(f.scrollHeight - dk.scrollHeight);
dk.style.fontWeight = ""; dk.style.letterSpacing = "";
return { vorher, nachher };
});
ok(unterschied.nachher > 2,
`Gegenprobe: Fettdruck auf der Deckschicht bricht die Deckung `
+ `(${unterschied.vorher} -> ${unterschied.nachher} px Unterschied)`);
}
/* Ein Klick auf ein Kaestchen schaltet es um. */
{
const vorher = await seite.inputValue("#feld");
await seite.locator("#deck .n-hk").first().click();
await seite.waitForTimeout(250);
const nachher = await seite.inputValue("#feld");
ok(vorher.includes("[ ] Offener Punkt") && nachher.includes("[x] Offener Punkt"),
"ein Klick auf das Kaestchen setzt den Haken");
ok(nachher.length === vorher.length,
`und der Text bleibt zeichengenau gleich lang (${nachher.length}/${vorher.length})`);
await seite.locator("#deck .n-hk").first().click();
await seite.waitForTimeout(250);
ok((await seite.inputValue("#feld")).includes("[ ] Offener Punkt"),
"noch ein Klick nimmt ihn wieder weg");
}
/* Die Tastatur fuehrt die Liste fort. */
{
await seite.fill("#feld", "- Erster Punkt");
await seite.click("#feld");
await seite.keyboard.press("Control+End");
await seite.keyboard.press("Enter");
await seite.keyboard.type("Zweiter");
await seite.waitForTimeout(200);
const t = await seite.inputValue("#feld");
ok(t === "- Erster Punkt\n- Zweiter",
`Enter setzt die Aufzaehlung fort ("${t.replace(/\n/g, "\\n")}")`);
await seite.fill("#feld", "[x] Fertig");
await seite.click("#feld");
await seite.keyboard.press("Control+End");
await seite.keyboard.press("Enter");
await seite.keyboard.type("Noch offen");
await seite.waitForTimeout(200);
const t2 = await seite.inputValue("#feld");
/* DER HAKEN WIRD NICHT MITGENOMMEN -- die naechste Aufgabe ist noch
nicht erledigt. Das ist die Sorte Kleinigkeit, die den
Unterschied macht zwischen „funktioniert" und „denkt mit". */
ok(t2 === "[x] Fertig\n[ ] Noch offen",
`und uebernimmt einen gesetzten Haken NICHT ("${t2.replace(/\n/g, "\\n")}")`);
}
/* Es sichert sich von selbst -- gemessen am Neuladen, nicht an der
Anzeige. Eine Anzeige, die „gesichert" sagt, ist eine Behauptung. */
{
const wort = `Selbstgesichert-${Date.now()}`;
await seite.fill("#feld", wort);
await seite.waitForTimeout(1400);
const stand = (await seite.locator("#stand").textContent() || "").trim();
/* GEFRAGT WIRD DER SERVER, nicht die Seite nach dem Neuladen.
Der erste Anlauf lud neu und sah ins Schreibfeld -- und wurde
rot, obwohl alles richtig war: Die Seite schlaegt nach dem Laden
das OBERSTE Blatt auf, und das war ein anderes (ein angeheftetes
aus Abschnitt 4). Gemessen wurde also ein Blatt, in das nie
jemand getippt hat. Eine Messung, die das Falsche ansieht, ist
schlimmer als keine -- sie schickt einen los, einen Fehler zu
suchen, den es nicht gibt. */
const drueben = await roh(CREW, "/workspace/api/notizblock", { keks: kekse.modi });
const gefunden = (drueben.j?.notizen || []).some((n) => String(n.text).includes(wort));
ok(gefunden, `der Text ist ohne Knopfdruck beim Server angekommen `
+ `(Anzeige sagte "${stand}")`);
ok(stand === "gesichert", `und die Anzeige sagt es auch ("${stand}")`);
}
/* Und auf dem Handy: Das Blatt legt sich ueber die Liste, der
Zurueck-Knopf holt sie wieder. */
{
/* EIN EIGENER KONTEXT MIT FINGER (29.09.2026).
Hier stand `kontext.newPage()` plus `setViewportSize`. Das
aendert nur die Groesse: `hasTouch` gehoert zum KONTEXT und
laesst sich danach nicht mehr setzen. Ohne ihn meldet der
Browser `pointer: fine`, und KEINE Regel aus
`@media (pointer: coarse)` greift -- dort stehen die
44-Pixel-Beruehrziele. Ein Handy-Bild mit Mauszeiger zeigt eine
Seite, die es auf keinem Handy gibt.
Die Anmeldung wandert als Keks mit, statt sie zu wiederholen --
eine zweite Anmeldung waere eine zweite Stelle, die veralten
kann. */
const handyKontext = await browser.newContext({
viewport: { width: 390, height: 780 }, hasTouch: true, isMobile: true });
await handyKontext.addCookies(await kontext.cookies());
const handy = await handyKontext.newPage();
await handy.goto(`${BASIS}/workspace/notizen.html`, { waitUntil: "networkidle" });
await handy.waitForTimeout(700);
const listeDa = await handy.locator(".n-zettel").count();
ok(listeDa >= 1, `auf dem Handy steht zuerst der Stapel (${listeDa} Blaetter)`);
ok(await handy.locator("#blatt").isHidden(),
"und kein Blatt liegt davor -- sonst kaeme man nie an die Liste");
await handy.locator(".n-zettel").first().click();
await handy.waitForTimeout(400);
ok(await handy.locator("#blatt").isVisible(), "ein Tipp schlaegt das Blatt auf");
ok(await handy.locator("#zurueck").isVisible(),
"und der Zurueck-Knopf erscheint");
/* KEIN WAAGERECHTES SCHIEBEN. Die Hausgrenze ist die Breite des
Schirms; ein Blatt, das darueber hinausragt, macht die ganze
Seite schiebbar. */
const breit = await handy.evaluate(() =>
document.documentElement.scrollWidth - document.documentElement.clientWidth);
ok(breit <= 1, `nichts ragt seitlich hinaus (${breit} px)`);
await handy.locator("#zurueck").click();
await handy.waitForTimeout(400);
ok(await handy.locator("#blatt").isHidden(), "der Zurueck-Knopf schliesst es wieder");
await handyKontext.close();
}
/* =======================================================================
8. ZWEI WEGE MIT DEMSELBEN NAMEN -- EINE HAUSWEITE PRUEFUNG
Sie steht in dieser Datei, weil sie hier gefunden wurde, und sie
gilt fuer das ganze Haus.
BEIM BAUEN DIESES BLOCKS: Die Route DELETE /workspace/api/notizen/:id
gab es schon -- in workspace-aufgaben.js, fuer die Notizen AN einer
Aufgabe. Weil aufgabenRouter frueher eingehaengt wird, hat sie jeden
Loeschversuch des Blocks abgefangen und mit 404 beantwortet.
VON AUSSEN SAH DAS AUS WIE EIN FEHLER IM NEUEN MODUL: Anlegen ging,
Aendern ging, Loeschen nicht. Man sucht dann im eigenen Code, und
dort ist nichts. Express meldet so etwas nicht -- es nimmt die
erste Route, die passt, und schweigt ueber die zweite.
Geprueft wird der Routenbaum von express selbst, nicht eine Liste.
Findet sich ein Paar aus Methode und Pfad zweimal, steht hier, wo.
======================================================================= */
melde("");
melde("=== 8. Kein Weg heisst zweimal gleich ===");
{
const app = (await import("./index.js")).default;
const wege = new Map();
const doppelt = [];
const sammeln = (schicht, vorn = "") => {
if (schicht.route) {
const pfad = vorn + (schicht.route.path || "");
for (const m of Object.keys(schicht.route.methods || {})) {
if (m === "_all") continue;
const name = `${m.toUpperCase()} ${pfad}`;
if (wege.has(name)) doppelt.push(name); else wege.set(name, true);
}
} else if (schicht.handle?.stack) {
for (const s of schicht.handle.stack) sammeln(s, vorn);
}
};
const stapel = app?._router?.stack || app?.router?.stack || [];
for (const s of stapel) sammeln(s);
/* DIE ZAHL GEHOERT IN DIE BEDINGUNG. Faende diese Messung gar keine
Wege (weil express seinen Baum eines Tages anders ablegt), waere
„keine Dubletten" trivial wahr -- und bliebe es fuer immer. */
ok(wege.size > 200, `${wege.size} Wege im Baum gefunden`);
ok(doppelt.length === 0,
doppelt.length ? `doppelt vergeben: ${[...new Set(doppelt)].join(", ")}`
: "kein Paar aus Methode und Pfad kommt zweimal vor");
/* GEGENPROBE: Die Messung MUSS eine Dublette finden koennen. */
const probe = new Map(); const gefunden = [];
for (const n of ["GET /x", "GET /x"]) {
if (probe.has(n)) gefunden.push(n); else probe.set(n, true);
}
ok(gefunden.length === 1, "Gegenprobe: zwei gleiche Wege wuerden auffallen");
}
ok(konsole.length === 0, `keine Fehler in der Konsole (${konsole.slice(0, 2).join(" | ") || "keine"})`);
await browser.close();
await new Promise((r) => vorbau.close(r));
melde("");
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
melde(fehler ? "FEHLGESCHLAGEN" : "ALLES IN ORDNUNG");
process.exit(fehler ? 1 : 0);