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
1093 lines
52 KiB
JavaScript
1093 lines
52 KiB
JavaScript
/* =====================================================================
|
|
pruef-material.mjs — BILDER UND VIDEOS ZUM POSTEN, JEDES NUR EINMAL
|
|
=====================================================================
|
|
|
|
Auftrag vom 22.09.2026:
|
|
|
|
"sobald ein bild oder ein video was von uns gepostet wurde,
|
|
runtergeladen wurde, soll direkt blockiert werden so dass man es
|
|
nicht mehr runterladen kann ... damit nie ein bild zwei mal
|
|
gepostet wird ... und die rechte hand und dogfather, nur die
|
|
beiden sollen auch immer sehen koennen wer das bild oder video
|
|
runtergeladen hat."
|
|
|
|
---------------------------------------------------------------------
|
|
WAS HIER GEMESSEN WIRD — und warum gerade das:
|
|
|
|
(1) DIE EINMALIGKEIT. Der entscheidende Nachweis ist NICHT der
|
|
gleichzeitige Zugriff: Node arbeitet die beiden Anfragen
|
|
ohnehin nacheinander ab, und `node:sqlite` ist synchron -- der
|
|
parallele Fall kann hier gar nicht schiefgehen. Was schiefgehen
|
|
KANN, ist die Reihenfolge "erst nachsehen, dann schreiben" ohne
|
|
Bedingung im UPDATE. Genau das faengt der ZWEITE Versuch nach
|
|
dem ersten ab: Er muss 409 bekommen, und der Vermerk darf sich
|
|
nicht mehr aendern. Der gleichzeitige Fall steht trotzdem hier --
|
|
er kostet nichts und faellt auf, falls jemand spaeter ein
|
|
`await` zwischen Lesen und Schreiben setzt.
|
|
|
|
(2) DASS DIE DATEI DANACH WIRKLICH ZU IST. Ein Vermerk in der
|
|
Datenbank ist keine Sperre, solange die Datei weiter
|
|
ausgeliefert wird. Geprueft wird der Abruf, nicht das Feld.
|
|
|
|
(3) DASS DIE VORSCHAU DIESELBE GRENZE HAT. Sonst waere sie der Weg,
|
|
ein vergebenes Bild doch noch zu bekommen (Rechtsklick,
|
|
speichern) -- und die ganze Einmaligkeit eine Behauptung.
|
|
|
|
(4) WER WEN SIEHT — fuer alle fuenf Rollen, nicht nur fuer die zwei,
|
|
die duerfen. Und nicht nur die Angabe `sieht_wer`, sondern das
|
|
Feld selbst: Eine Liste, die "nein" sagt und die Namen trotzdem
|
|
mitschickt, waere das Leck.
|
|
|
|
(5) DASS DIE COMMUNITY NICHTS EINSTELLT — und das Team schon. Ohne
|
|
die zweite Haelfte waere die erste auch dann gruen, wenn gar
|
|
nichts mehr ginge.
|
|
|
|
---------------------------------------------------------------------
|
|
WARUM node:http UND NICHT fetch: Das Team und die Community melden
|
|
sich an verschiedenen Waenden an, und welche gilt, entscheidet der
|
|
Host-Kopf. `fetch` in Node setzt den selbst und ignoriert einen
|
|
gesetzten -- die Anmeldung ginge an die falsche Wand.
|
|
===================================================================== */
|
|
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { request as httpAnfrage } from "node:http";
|
|
import { eigenerPort } from "./helfer-port.mjs";
|
|
import { notbremse } from "./helfer-notbremse.mjs";
|
|
import { zertifikatBauen } from "./helfer-openssl.mjs";
|
|
|
|
const PORT = await eigenerPort(import.meta, "pruef-material");
|
|
|
|
/* DATEN_ORDNER wird in workspace.js aus dem Pfad der Datenbank
|
|
abgeleitet (`dirname(DB_PFAD)`) -- das Material landet damit von
|
|
selbst im Wegwerfordner. Eine eigene Umgebungsvariable dafuer waere
|
|
eine zweite Wahrheit, die beim naechsten Umbau auseinanderlaeuft. */
|
|
const ordner = mkdtempSync(join(tmpdir(), "ws-material-"));
|
|
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
|
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");
|
|
/* 240 -> 480 s (22.09.2026): Seit Abschnitt 13 faehrt ein echter
|
|
Browser mit. Eine Notbremse, die vor dem letzten Abschnitt
|
|
zuschlaegt, macht aus einer langsamen Pruefung eine rote. */
|
|
notbremse(480_000, "pruef-material");
|
|
await new Promise((r) => setTimeout(r, 900));
|
|
|
|
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
|
|
process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
|
|
|
|
let fehler = 0, geprueft = 0;
|
|
const melde = (t) => console.log(t);
|
|
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
|
|
|
const CREW = "crew.dogfather-universe.com";
|
|
|
|
/* ---------------------------------------------------------------------
|
|
PERSONEN
|
|
--------------------------------------------------------------------- */
|
|
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);
|
|
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", schluessel).update(code).digest("hex"), jetzt);
|
|
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
|
}
|
|
anlegen("Filipe", "admin", "CODE-DOGI-0001");
|
|
anlegen("Rieke", "hand", "CODE-HAND-0001");
|
|
anlegen("Lino", "linke", "CODE-LINK-0001");
|
|
anlegen("Frida", "modi", "CODE-MODI-0001");
|
|
anlegen("Kessi", "gast", "CODE-GAST-0001");
|
|
anlegen("Sam", "gast", "CODE-GAST-0002");
|
|
d.close();
|
|
|
|
/* ---------------------------------------------------------------------
|
|
DER DRAHT
|
|
--------------------------------------------------------------------- */
|
|
function roh(pfad, wie = {}) {
|
|
return new Promise((fertig, schief) => {
|
|
const daten = wie.roh !== undefined
|
|
? wie.roh
|
|
: (wie.body === undefined ? null : Buffer.from(JSON.stringify(wie.body)));
|
|
const a = httpAnfrage({
|
|
host: "127.0.0.1", port: PORT, path: pfad,
|
|
method: wie.methode || (daten ? "POST" : "GET"),
|
|
headers: {
|
|
Host: CREW,
|
|
...(daten ? {
|
|
"Content-Type": wie.typ || "application/json",
|
|
"Content-Length": daten.length,
|
|
} : {}),
|
|
...(wie.kopf || {}),
|
|
...(wie.keks ? { Cookie: wie.keks } : {}),
|
|
Origin: `https://${CREW}`,
|
|
Referer: `https://${CREW}/workspace/`,
|
|
},
|
|
}, (antwort) => {
|
|
const stuecke = [];
|
|
antwort.on("data", (s) => stuecke.push(s));
|
|
antwort.on("end", () => {
|
|
const puffer = Buffer.concat(stuecke);
|
|
const text = puffer.toString("utf8");
|
|
fertig({
|
|
code: antwort.statusCode, kopf: antwort.headers,
|
|
bytes: puffer.length, text,
|
|
json: (() => { try { return JSON.parse(text); } catch { return null; } })(),
|
|
});
|
|
});
|
|
});
|
|
a.on("error", schief);
|
|
if (daten) a.write(daten);
|
|
a.end();
|
|
});
|
|
}
|
|
|
|
async function anmelden(rolle, code) {
|
|
/* Die Community bestaetigt beim ersten Mal ihr Alter (seit 15.09.). */
|
|
const r = await roh("/workspace/api/anmelden", {
|
|
body: rolle === "gast" ? { rolle, code, alter_ok: true } : { rolle, code },
|
|
});
|
|
const kekse = [].concat(r.kopf?.["set-cookie"] || []);
|
|
return { keks: kekse.map((c) => c.split(";")[0]).join("; "), code: r.code };
|
|
}
|
|
|
|
const chef = await anmelden("admin", "CODE-DOGI-0001");
|
|
const hand = await anmelden("hand", "CODE-HAND-0001");
|
|
const linke = await anmelden("linke", "CODE-LINK-0001");
|
|
const modi = await anmelden("modi", "CODE-MODI-0001");
|
|
const kessi = await anmelden("gast", "CODE-GAST-0001");
|
|
const sam = await anmelden("gast", "CODE-GAST-0002");
|
|
|
|
melde("");
|
|
melde("=== 0. Alle sechs kommen ueberhaupt herein ===");
|
|
for (const [wer, s] of [["DogFather", chef], ["rechte Hand", hand],
|
|
["linke Hand", linke], ["Modi", modi], ["Kessi", kessi], ["Sam", sam]]) {
|
|
ok(s.code === 200 && s.keks.length > 0, `${wer} ist angemeldet (${s.code})`);
|
|
}
|
|
|
|
/* Ein echtes, winziges PNG (1x1). Der Server prueft die Art -- ein
|
|
erfundener Byteblock haette den falschen Typ und die Pruefung waere
|
|
aus dem falschen Grund gruen. */
|
|
const PNG = Buffer.from(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
|
"base64");
|
|
|
|
function einstellen(keks, name, text = "", fenster = {}) {
|
|
return roh("/workspace/api/material", {
|
|
methode: "POST", roh: PNG, typ: "image/png", keks,
|
|
kopf: {
|
|
"X-Datei-Name": encodeURIComponent(name),
|
|
"X-Text": encodeURIComponent(text),
|
|
/* NUR MITSCHICKEN, WENN ES ETWAS ZU SCHICKEN GIBT. Ein leerer
|
|
Kopf ist etwas anderes als ein fehlender, und die Pruefung
|
|
soll beides treffen koennen. */
|
|
...(fenster.ab !== undefined ? { "X-Gilt-Ab": fenster.ab } : {}),
|
|
...(fenster.bis !== undefined ? { "X-Gilt-Bis": fenster.bis } : {}),
|
|
},
|
|
});
|
|
}
|
|
const nehmen = (id, keks) =>
|
|
roh(`/workspace/api/material/${id}/nehmen`, { methode: "POST", keks });
|
|
const liste = (keks) => roh("/workspace/api/material", { keks });
|
|
|
|
/* =====================================================================
|
|
1. WER ETWAS BEREITSTELLEN DARF
|
|
===================================================================== */
|
|
melde("");
|
|
melde("=== 1. Wer etwas bereitstellen darf ===");
|
|
{
|
|
for (const [wer, s] of [["DogFather", chef], ["die rechte Hand", hand],
|
|
["die linke Hand", linke], ["ein Modi", modi]]) {
|
|
const a = await einstellen(s.keks, `Von ${wer}.png`, "Fuer TikTok");
|
|
ok(a.code === 201 && Number.isInteger(a.json?.id),
|
|
`${wer} stellt etwas bereit (${a.code}, Nummer ${a.json?.id})`);
|
|
}
|
|
|
|
/* DIE GEGENPROBE, und sie ist Filipes ausdruecklicher Satz:
|
|
"die community soll keine posten koennen in die kategorie". */
|
|
const g = await einstellen(kessi.keks, "Von der Community.png");
|
|
ok(g.code === 403, `die Community NICHT (${g.code})`);
|
|
ok(g.json?.fehler === "nur_team_stellt_ein",
|
|
`und bekommt einen Grund, zu dem es einen Satz gibt (${g.json?.fehler})`);
|
|
|
|
/* Und es ist auch wirklich nichts angekommen -- eine 403, nach der
|
|
die Datei trotzdem in der Liste steht, waere die schlimmere Sorte. */
|
|
const l = await liste(chef.keks);
|
|
const vonKessi = (l.json?.material || []).filter((m) => m.von === "Kessi").length;
|
|
ok(vonKessi === 0, `und es liegt auch nichts von ihr da (${vonKessi})`);
|
|
}
|
|
|
|
/* =====================================================================
|
|
2. WAS NICHT HINEINGEHOERT
|
|
===================================================================== */
|
|
melde("");
|
|
melde("=== 2. Was nicht hineingehoert ===");
|
|
{
|
|
const a = await roh("/workspace/api/material", {
|
|
methode: "POST", roh: Buffer.from("MZ-Programm"),
|
|
typ: "application/octet-stream", keks: chef.keks,
|
|
kopf: { "X-Datei-Name": encodeURIComponent("schadprogramm.exe") },
|
|
});
|
|
ok(a.code === 400, `ein Programm wird abgelehnt (${a.code})`);
|
|
ok(a.json?.fehler === "art_nicht_erlaubt", `mit Grund (${a.json?.fehler})`);
|
|
|
|
const b = await roh("/workspace/api/material", {
|
|
methode: "POST", roh: Buffer.alloc(0), typ: "image/png", keks: chef.keks,
|
|
});
|
|
ok(b.code === 400 && b.json?.fehler === "datei_fehlt",
|
|
`eine leere Datei ebenso (${b.code}, ${b.json?.fehler})`);
|
|
}
|
|
|
|
/* =====================================================================
|
|
3. DER KERN: JEDES NUR EINMAL
|
|
===================================================================== */
|
|
melde("");
|
|
melde("=== 3. Jedes nur einmal ===");
|
|
{
|
|
const a = await einstellen(chef.keks, "Umkaempft.png", "Wer zuerst kommt");
|
|
const id = a.json?.id;
|
|
ok(Number.isInteger(id), `ein Stueck zum Wettlauf liegt bereit (${id})`);
|
|
|
|
/* Beide Anfragen gehen los, bevor die erste beantwortet ist. */
|
|
const [x, y] = await Promise.all([nehmen(id, kessi.keks), nehmen(id, sam.keks)]);
|
|
const gewonnen = [x, y].filter((r) => r.code === 200).length;
|
|
const verloren = [x, y].filter((r) => r.code === 409).length;
|
|
ok(gewonnen === 1, `genau einer bekommt es (${gewonnen} von 2)`);
|
|
ok(verloren === 1, `und genau einer bekommt "schon vergeben" (${verloren})`);
|
|
ok([x, y].find((r) => r.code === 409)?.json?.fehler === "schon_genommen",
|
|
`mit Grund (${[x, y].find((r) => r.code === 409)?.json?.fehler})`);
|
|
|
|
/* DER EIGENTLICHE NACHWEIS: Wer danach kommt, bekommt nichts -- und
|
|
der Vermerk darf sich nicht mehr aendern. Ein Weg ohne Bedingung
|
|
im UPDATE wuerde hier den zweiten Namen eintragen, und die Liste
|
|
saehe danach voellig normal aus. */
|
|
const vorName = (await liste(chef.keks)).json?.material
|
|
?.find((m) => m.id === id)?.genommen_von;
|
|
const z = await nehmen(id, modi.keks);
|
|
ok(z.code === 409, `ein Dritter danach ebenso wenig (${z.code})`);
|
|
const nachName = (await liste(chef.keks)).json?.material
|
|
?.find((m) => m.id === id)?.genommen_von;
|
|
ok(!!vorName && vorName === nachName,
|
|
`und der Vermerk bleibt stehen (${vorName} -> ${nachName})`);
|
|
}
|
|
|
|
/* =====================================================================
|
|
4. DIE DATEI IST DANACH WIRKLICH ZU
|
|
===================================================================== */
|
|
melde("");
|
|
melde("=== 4. Die Datei ist danach wirklich zu ===");
|
|
{
|
|
const a = await einstellen(chef.keks, "Abgeschlossen.png");
|
|
const id = a.json?.id;
|
|
|
|
const vorher = await roh(`/workspace/api/material/${id}/datei`, { keks: kessi.keks });
|
|
ok(vorher.code === 403,
|
|
`vor dem Nehmen bekommt sie niemand (${vorher.code})`);
|
|
|
|
const genommen = await nehmen(id, kessi.keks);
|
|
ok(genommen.code === 200, `Kessi nimmt es (${genommen.code})`);
|
|
|
|
const seine = await roh(`/workspace/api/material/${id}/datei`, { keks: kessi.keks });
|
|
ok(seine.code === 200 && seine.bytes === PNG.length,
|
|
`und bekommt die Datei (${seine.code}, ${seine.bytes} von ${PNG.length} Bytes)`);
|
|
ok(/attachment/.test(String(seine.kopf?.["content-disposition"] || "")),
|
|
"als Download, nicht zum Anschauen im Browser");
|
|
|
|
/* DER RIEGEL. Ein Vermerk in der Datenbank ist keine Sperre,
|
|
solange die Datei weiter ausgeliefert wird. */
|
|
for (const [wer, s] of [["ein anderer aus der Community", sam],
|
|
["ein Modi", modi], ["die linke Hand", linke],
|
|
["sogar die rechte Hand", hand]]) {
|
|
const r = await roh(`/workspace/api/material/${id}/datei`, { keks: s.keks });
|
|
ok(r.code === 403, `${wer} bekommt sie nicht (${r.code})`);
|
|
}
|
|
|
|
/* UND DIE VORSCHAU HAT DIESELBE GRENZE -- sonst waere sie der Weg,
|
|
es doch noch zu bekommen. */
|
|
const schauFremd = await roh(`/workspace/api/material/${id}/vorschau`, { keks: sam.keks });
|
|
ok(schauFremd.code === 403,
|
|
`auch die Vorschau ist fuer andere zu (${schauFremd.code})`);
|
|
const schauSeine = await roh(`/workspace/api/material/${id}/vorschau`, { keks: kessi.keks });
|
|
ok(schauSeine.code === 200 && schauSeine.bytes === PNG.length,
|
|
`wer es genommen hat, sieht es weiter (${schauSeine.code})`);
|
|
}
|
|
|
|
/* =====================================================================
|
|
5. SOLANGE ES FREI IST, DARF JEDER HINSEHEN
|
|
=====================================================================
|
|
Die Gegenprobe zu Abschnitt 4: Waere die Vorschau schlicht immer
|
|
gesperrt, waeren alle Zeilen oben ebenfalls gruen -- und niemand
|
|
koennte je entscheiden, ob ein Bild zu seinem Kanal passt. */
|
|
melde("");
|
|
melde("=== 5. Solange es frei ist, darf jeder hinsehen ===");
|
|
{
|
|
const a = await einstellen(chef.keks, "Noch frei.png");
|
|
const id = a.json?.id;
|
|
for (const [wer, s] of [["die Community", kessi], ["ein Modi", modi],
|
|
["die linke Hand", linke], ["die rechte Hand", hand]]) {
|
|
const r = await roh(`/workspace/api/material/${id}/vorschau`, { keks: s.keks });
|
|
ok(r.code === 200 && r.bytes === PNG.length,
|
|
`${wer} sieht die Vorschau (${r.code}, ${r.bytes} Bytes)`);
|
|
}
|
|
}
|
|
|
|
/* =====================================================================
|
|
6. WER SIEHT, WER ES GENOMMEN HAT
|
|
=====================================================================
|
|
"die rechte hand und dogfather, NUR die beiden" -- gemessen fuer
|
|
alle fuenf Rollen, und nicht nur an der Angabe `sieht_wer`, sondern
|
|
am Feld selbst. */
|
|
melde("");
|
|
melde("=== 6. Wer sieht, wer es genommen hat ===");
|
|
{
|
|
const soll = { admin: true, hand: true, linke: false, modi: false, gast: false };
|
|
const kekse = { admin: chef, hand, linke, modi, gast: kessi };
|
|
let vergebenGesamt = 0;
|
|
for (const [rolle, darf] of Object.entries(soll)) {
|
|
const a = await liste(kekse[rolle].keks);
|
|
ok(a.code === 200, `${rolle}: die Liste kommt (${a.code})`);
|
|
ok(a.json?.sieht_wer === darf,
|
|
` und "sieht_wer" ist ${darf} (${a.json?.sieht_wer})`);
|
|
|
|
const vergeben = (a.json?.material || []).filter((m) => m.genommen);
|
|
const mitNamen = vergeben.filter((m) => m.genommen_von !== undefined).length;
|
|
vergebenGesamt = Math.max(vergebenGesamt, vergeben.length);
|
|
ok(vergeben.length > 0 && (darf ? mitNamen === vergeben.length : mitNamen === 0),
|
|
` ${vergeben.length} vergeben, davon ${mitNamen} mit Namen`);
|
|
}
|
|
/* DIE ZAHL GEHOERT IN DIE BEDINGUNG: Ohne vergebene Stuecke waere
|
|
"keine Namen dabei" trivial wahr. */
|
|
ok(vergebenGesamt >= 2,
|
|
`es lagen ueberhaupt vergebene Stuecke vor (${vergebenGesamt})`);
|
|
}
|
|
|
|
/* =====================================================================
|
|
7. JEDER SIEHT, WAS ER SELBST GENOMMEN HAT
|
|
===================================================================== */
|
|
melde("");
|
|
melde("=== 7. Jeder sieht, was er selbst genommen hat ===");
|
|
{
|
|
/* SAM MUSS ERST WIRKLICH ETWAS HABEN. Im Wettlauf oben hat Kessi
|
|
gewonnen -- ohne diesen Schritt vergliche die Gegenprobe unten
|
|
zwei Nullen und waere gruen, ohne irgendetwas gesehen zu haben.
|
|
Genau der gruene Haken ueber der Null. */
|
|
const fuerSam = await einstellen(chef.keks, "Fuer Sam.png");
|
|
const holt = await nehmen(fuerSam.json?.id, sam.keks);
|
|
ok(holt.code === 200, `Sam nimmt sich eines (${holt.code})`);
|
|
|
|
const meine = ((await liste(kessi.keks)).json?.material || [])
|
|
.filter((m) => m.von_mir_genommen);
|
|
ok(meine.length >= 1, `Kessi sieht ihre eigenen (${meine.length})`);
|
|
|
|
const fremde = ((await liste(sam.keks)).json?.material || [])
|
|
.filter((m) => m.von_mir_genommen);
|
|
const ids = new Set(meine.map((m) => m.id));
|
|
const verwechselt = fremde.filter((m) => ids.has(m.id)).length;
|
|
ok(verwechselt === 0,
|
|
`und keines davon steht bei Sam als "von mir" (${verwechselt})`);
|
|
|
|
/* Gegenprobe gegen die Liste des DogFathers: Sams eigene Sicht muss
|
|
zu dem passen, was dort mit seinem Namen steht. */
|
|
const samHat = ((await liste(chef.keks)).json?.material || [])
|
|
.filter((m) => m.genommen_von === "Sam").length;
|
|
ok(samHat > 0 && fremde.length === samHat,
|
|
`Sams eigene Sicht deckt sich mit der Liste des DogFathers (${fremde.length} / ${samHat})`);
|
|
}
|
|
|
|
/* =====================================================================
|
|
8. DIE AGENTUR HAT HIER NICHTS
|
|
===================================================================== */
|
|
melde("");
|
|
melde("=== 8. Die Grenzen des Bereichs ===");
|
|
{
|
|
const { darfSeite } = await import("./rechte.js");
|
|
for (const r of ["admin", "hand", "linke", "modi", "gast"]) {
|
|
ok(darfSeite({ rolle: r, haus: "crew" }, "/workspace/material.html"),
|
|
`${r} darf die Seite oeffnen`);
|
|
}
|
|
for (const r of ["creator", "manager", "scout", "spicy"]) {
|
|
ok(!darfSeite({ rolle: r, haus: "agentur" }, "/workspace/material.html"),
|
|
`${r} aus der Agentur nicht`);
|
|
}
|
|
|
|
/* Und die Kachel gibt es -- sonst waere die Seite da und niemand
|
|
faende sie. Abgeleitet aus derselben Quelle wie die Startseite. */
|
|
const w = await import("./workspace.js");
|
|
for (const r of ["gast", "modi", "hand", "linke", "admin"]) {
|
|
const treffer = [...(w.bereicheFuer({ rolle: r, haus: "crew" }) || [])]
|
|
.filter((k) => k.ziel === "material.html");
|
|
/* HEISST SEIT DEM 22.09.2026 "Dogi-Media" (Filipe: "ersetz das wort
|
|
material durch Dogi-Media"). Der DATEINAME bleibt material.html:
|
|
Eine Adresse umzubenennen bricht jedes Lesezeichen und jeden
|
|
Verweis, und der sichtbare Name ist das, was zaehlt -- dieselbe
|
|
Entscheidung wie bei "Unsere Seiten" -> "Draussen". */
|
|
ok(treffer.length === 1 && treffer[0].name === "Dogi-Media",
|
|
`${r} hat genau eine Kachel dorthin (${treffer.length}, "${treffer[0]?.name}")`);
|
|
}
|
|
}
|
|
|
|
/* =====================================================================
|
|
9. VON WANN BIS WANN (22.09.2026)
|
|
=====================================================================
|
|
Filipe: "mach kategorien welche sind benutzt welche nicht welche sind
|
|
noch offen, welche laufen von wan bis wann, das muss man auch machen
|
|
koennen und detailliert perfektioniert."
|
|
|
|
GERECHNET WIRD RELATIV ZU HEUTE, nie mit einem festen Datum. Ein
|
|
Test, der "2026-10-01" als Zukunft annimmt, ist am 2. Oktober rot,
|
|
ohne dass irgendetwas kaputt waere -- und ein Fehlalarm, den niemand
|
|
nachstellen kann, schaltet den ganzen Satz ab.
|
|
===================================================================== */
|
|
melde("");
|
|
melde("=== 9. Der Zeitraum: gesetzt, gespeichert, gerechnet ===");
|
|
const { tagLokal } = await import("./helfer-tag.mjs");
|
|
const HEUTE = tagLokal(0);
|
|
const GESTERN = tagLokal(-1);
|
|
const VORGESTERN = tagLokal(-2);
|
|
const MORGEN = tagLokal(1);
|
|
const IN10 = tagLokal(10);
|
|
|
|
{
|
|
/* Vier Stuecke, eines je Zustand -- und zwar wirklich vier, nicht
|
|
drei und eine Annahme. Die Zaehlung unten prueft das. */
|
|
const faelle = [
|
|
["ohne-fenster.png", {}, "frei"],
|
|
["laeuft-schon.png", { ab: VORGESTERN, bis: IN10 }, "frei"],
|
|
["kommt-noch.png", { ab: MORGEN, bis: IN10 }, "spaeter"],
|
|
["ist-vorbei.png", { ab: VORGESTERN, bis: GESTERN }, "abgelaufen"],
|
|
];
|
|
for (const [name, f, erwartet] of faelle) {
|
|
const r = await einstellen(chef.keks, name, "Probe", f);
|
|
ok(r.code === 201 && Number.isInteger(r.json?.id),
|
|
`"${name}" angelegt (${r.code})`);
|
|
}
|
|
|
|
const l = await liste(chef.keks);
|
|
const finde = (n) => (l.json?.material || []).find((m) => m.name === n);
|
|
|
|
for (const [name, f, erwartet] of faelle) {
|
|
const m = finde(name);
|
|
ok(!!m && m.zustand === erwartet,
|
|
`"${name}" steht als "${erwartet}" da (${m?.zustand})`);
|
|
ok(!!m && (m.gilt_ab || null) === (f.ab || null)
|
|
&& (m.gilt_bis || null) === (f.bis || null),
|
|
`"${name}" gibt sein Fenster unveraendert zurueck `
|
|
+ `(${m?.gilt_ab} / ${m?.gilt_bis})`);
|
|
}
|
|
|
|
/* DER HEUTIGE TAG KOMMT MIT UND IST EIN ECHTES DATUM.
|
|
Genau hier lag der Fehler, der diese Pruefung ausgeloest hat:
|
|
`tagLokal()` ohne Argument gab "NaN-NaN-NaN" zurueck. Beim
|
|
Zeichenkettenvergleich gewinnt das grosse N gegen jede Ziffer --
|
|
also galt jedes Stueck mit Enddatum sofort als abgelaufen, und
|
|
"Kommt noch" gab es nie. Kein Absturz, keine Meldung.
|
|
Deshalb wird die FORM geprueft, nicht nur die Anwesenheit. */
|
|
ok(/^\d{4}-\d{2}-\d{2}$/.test(String(l.json?.heute || "")),
|
|
`die Liste nennt den heutigen Tag als Datum ("${l.json?.heute}")`);
|
|
ok(l.json?.heute === HEUTE,
|
|
`und es ist wirklich heute (${l.json?.heute} vs ${HEUTE})`);
|
|
|
|
/* GEGENPROBE: Wuerde "heute" nicht stimmen, muesste die Rechnung
|
|
ein anderes Ergebnis liefern. Sonst misst der Test nur, dass
|
|
irgendetwas zurueckkommt. */
|
|
const { zustandVon } = await import("./workspace-material.js");
|
|
ok(zustandVon({ gilt_ab: MORGEN }, HEUTE) === "spaeter"
|
|
&& zustandVon({ gilt_ab: MORGEN }, "NaN-NaN-NaN") !== "spaeter",
|
|
"mit einem kaputten Tag faellt die Rechnung um -- die Probe kann also anschlagen");
|
|
|
|
/* ALLE VIER ZUSTAENDE KOMMEN AUCH WIRKLICH VOR. Ohne diese Zeile
|
|
waeren drei gruene Haken auch dann gruen, wenn ein Zustand gar
|
|
nicht mehr erreichbar ist. */
|
|
const NOETIG = ["frei", "spaeter", "abgelaufen"];
|
|
const gezaehlt = new Set((l.json?.material || []).map((m) => m.zustand));
|
|
const fehlend = NOETIG.filter((z) => !gezaehlt.has(z));
|
|
/* DIE ZAHL IM SATZ WIRD GEZAEHLT, NICHT GETIPPT. Der erste Entwurf
|
|
schrieb "drei Zustaende sind besetzt" und zaehlte daneben vier auf
|
|
-- ein Satz, der schon beim ersten Lesen nicht zu seiner eigenen
|
|
Klammer passte. */
|
|
ok(fehlend.length === 0,
|
|
`alle ${NOETIG.length} Zustaende aus diesem Abschnitt sind besetzt; `
|
|
+ `gefunden: ${[...gezaehlt].sort().join(", ")}`
|
|
+ (fehlend.length ? ` -- es fehlt: ${fehlend.join(", ")}` : ""));
|
|
}
|
|
|
|
melde("");
|
|
melde("=== 9a. Die Umstellung merkt, wenn sie nicht durchkam ===");
|
|
{
|
|
/* WARUM DAS HIER STEHT: Im ersten Entwurf setzte `tabellen()` den
|
|
Vermerk "erledigt" UNBEDINGT -- auch wenn das ALTER scheiterte,
|
|
dessen Fehler daneben still in die Konsole ging. Ein einziger
|
|
misslungener Versuch haette die Umstellung fuer immer als getan
|
|
vermerkt, und danach scheitert JEDE Liste an `m.gilt_ab`: 503 fuer
|
|
alle, mit einer Meldung, die nicht sagt, woran es liegt.
|
|
|
|
Geprueft wird an einer Wegwerfdatei, nicht am laufenden Haus --
|
|
eine Tabelle ohne die Spalten laesst sich sonst gar nicht
|
|
herstellen, ohne etwas kaputtzumachen. */
|
|
const probe = new (await import("node:sqlite")).DatabaseSync(join(ordner, "probe-schema.db"));
|
|
probe.exec("CREATE TABLE material (id INTEGER PRIMARY KEY, dateiname TEXT)");
|
|
|
|
const NOETIG = ["gilt_ab", "gilt_bis"];
|
|
const fehlt = () => {
|
|
const da = new Set(probe.prepare("PRAGMA table_info(material)").all().map((c) => c.name));
|
|
return NOETIG.filter((n) => !da.has(n));
|
|
};
|
|
|
|
ok(fehlt().length === 2,
|
|
`vor der Umstellung fehlen beide Spalten (${fehlt().join(", ")})`);
|
|
|
|
probe.exec("ALTER TABLE material ADD COLUMN gilt_ab TEXT");
|
|
ok(fehlt().length === 1 && fehlt()[0] === "gilt_bis",
|
|
`nach der halben Umstellung fehlt genau eine (${fehlt().join(", ")}) `
|
|
+ `-- genau dieser Fall haette "erledigt" heissen koennen`);
|
|
|
|
probe.exec("ALTER TABLE material ADD COLUMN gilt_bis TEXT");
|
|
ok(fehlt().length === 0, "nach der ganzen keine mehr");
|
|
probe.close();
|
|
|
|
/* UND AM LAUFENDEN HAUS: Die Spalten sind wirklich da. Die Zeile
|
|
darueber beweist nur, dass die Rechnung stimmt -- nicht, dass sie
|
|
hier angewandt wurde. */
|
|
const { DatabaseSync: DS } = await import("node:sqlite");
|
|
const echt = new DS(process.env.WORKSPACE_DB);
|
|
const spalten = echt.prepare("PRAGMA table_info(material)").all().map((c) => c.name);
|
|
echt.close();
|
|
ok(NOETIG.every((n) => spalten.includes(n)),
|
|
`und in der laufenden Ablage stehen sie auch (${spalten.length} Spalten: `
|
|
+ `${spalten.join(", ")})`);
|
|
}
|
|
|
|
melde("");
|
|
melde("=== 9b. Ein Fenster, das nicht geht ===");
|
|
{
|
|
const verkehrt = await einstellen(chef.keks, "verkehrt.png", "", { ab: IN10, bis: GESTERN });
|
|
ok(verkehrt.code === 400 && verkehrt.json?.fehler === "fenster_verkehrt",
|
|
`Ende vor Anfang wird abgelehnt (${verkehrt.code}/${verkehrt.json?.fehler})`);
|
|
|
|
const quatsch = await einstellen(chef.keks, "quatsch.png", "", { ab: "morgen frueh" });
|
|
ok(quatsch.code === 400 && quatsch.json?.fehler === "datum_unbrauchbar",
|
|
`ein Datum, das keines ist, wird abgelehnt (${quatsch.code}/${quatsch.json?.fehler})`);
|
|
|
|
/* GEGENPROBE zu beiden: Dasselbe ohne Fenster muss durchgehen --
|
|
sonst koennte die Ablehnung auch an etwas ganz anderem liegen. */
|
|
const sauber = await einstellen(chef.keks, "sauber.png", "", { ab: HEUTE, bis: IN10 });
|
|
ok(sauber.code === 201, `dasselbe mit gueltigem Fenster geht (${sauber.code})`);
|
|
|
|
/* UND NICHTS DAVON IST LIEGENGEBLIEBEN. Eine abgelehnte Anfrage darf
|
|
keine halbe Datei im Ordner hinterlassen. */
|
|
const l = await liste(chef.keks);
|
|
const namen = (l.json?.material || []).map((m) => m.name);
|
|
ok(!namen.includes("verkehrt.png") && !namen.includes("quatsch.png"),
|
|
"die abgelehnten Stuecke stehen nirgends in der Liste");
|
|
}
|
|
|
|
/* =====================================================================
|
|
10. BEARBEITEN -- wer darf, und was bleibt unberuehrt
|
|
=====================================================================
|
|
Filipe: "wenn wir in der kategorie material was hochladen will ich
|
|
dass die rechte und linke hand es auch bearbeiten und loeschen
|
|
koennen. dogfather auch fals es ein fehler gab."
|
|
===================================================================== */
|
|
melde("");
|
|
melde("=== 10. Bearbeiten ===");
|
|
const aendern = (id, keks, was) =>
|
|
roh(`/workspace/api/material/${id}`, { methode: "PATCH", keks, body: was });
|
|
{
|
|
/* Das Stueck gehoert der RECHTEN HAND -- damit ist jede Aenderung
|
|
durch jemand anderen eine FREMDE Aenderung. Haette es DogFather
|
|
eingestellt, waere sein eigener Zugriff kein Nachweis. */
|
|
const r = await einstellen(hand.keks, "fremdes-stueck.png", "Erster Text");
|
|
const id = r.json?.id;
|
|
ok(Number.isInteger(id), `Stueck der rechten Hand liegt (${r.code})`);
|
|
|
|
for (const [wer, s2] of [["DogFather", chef], ["rechte Hand", hand], ["linke Hand", linke]]) {
|
|
const a = await aendern(id, s2.keks, { text: `Geaendert von ${wer}` });
|
|
ok(a.code === 200 && a.json?.text === `Geaendert von ${wer}`,
|
|
`${wer} darf fremden Text aendern (${a.code})`);
|
|
}
|
|
|
|
for (const [wer, s2] of [["Modi", modi], ["Kessi (Community)", kessi]]) {
|
|
const a = await aendern(id, s2.keks, { text: "heimlich" });
|
|
ok(a.code === 403 || a.code === 404,
|
|
`${wer} darf nicht (${a.code}/${a.json?.fehler})`);
|
|
}
|
|
|
|
/* GEGENPROBE ZUR ABLEHNUNG: Der Text muss danach noch der von vorhin
|
|
sein. Eine 403, nach der die Aenderung trotzdem drinsteht, waere
|
|
der eigentliche Schaden -- und sie saehe von aussen richtig aus. */
|
|
const l = await liste(chef.keks);
|
|
const m = (l.json?.material || []).find((x) => x.id === id);
|
|
ok(m?.text === "Geaendert von linke Hand",
|
|
`und der Text ist unveraendert geblieben ("${m?.text}")`);
|
|
|
|
/* DAS FENSTER LAESST SICH NACHTRAEGLICH SETZEN UND WIEDER LEEREN.
|
|
Das Leeren ist der Fall, den man vergisst: Wer ein Enddatum
|
|
falsch getippt hat, muss es LOESCHEN koennen, nicht nur
|
|
ueberschreiben. */
|
|
const setzen = await aendern(id, hand.keks, { gilt_ab: MORGEN, gilt_bis: IN10 });
|
|
ok(setzen.code === 200 && setzen.json?.zustand === "spaeter",
|
|
`Fenster nachtraeglich gesetzt, Zustand "${setzen.json?.zustand}"`);
|
|
const leeren = await aendern(id, hand.keks, { gilt_ab: "", gilt_bis: "" });
|
|
ok(leeren.code === 200 && !leeren.json?.gilt_ab && !leeren.json?.gilt_bis
|
|
&& leeren.json?.zustand === "frei",
|
|
`und wieder geleert, Zustand "${leeren.json?.zustand}"`);
|
|
|
|
const verkehrt = await aendern(id, hand.keks, { gilt_ab: IN10, gilt_bis: GESTERN });
|
|
ok(verkehrt.code === 400 && verkehrt.json?.fehler === "fenster_verkehrt",
|
|
`ein verkehrtes Fenster wird auch beim Aendern abgelehnt (${verkehrt.code})`);
|
|
|
|
/* WAS NICHT MITGESCHICKT WIRD, BLEIBT. Sonst loescht jeder, der nur
|
|
den Text aendert, nebenbei das Fenster -- und merkt es nie. */
|
|
await aendern(id, hand.keks, { gilt_ab: MORGEN, gilt_bis: IN10 });
|
|
const nurText = await aendern(id, hand.keks, { text: "Nur der Text" });
|
|
ok(nurText.code === 200 && nurText.json?.gilt_ab === MORGEN
|
|
&& nurText.json?.gilt_bis === IN10,
|
|
`eine Aenderung nur am Text laesst das Fenster stehen `
|
|
+ `(${nurText.json?.gilt_ab} / ${nurText.json?.gilt_bis})`);
|
|
}
|
|
|
|
melde("");
|
|
melde("=== 10b. Was vergeben ist, bleibt, wie es ist ===");
|
|
{
|
|
const r = await einstellen(chef.keks, "schon-weg.png", "Steht so da");
|
|
const id = r.json?.id;
|
|
const n = await nehmen(id, modi.keks);
|
|
ok(n.code === 200, `ein Modi hat es genommen (${n.code})`);
|
|
|
|
const a = await aendern(id, chef.keks, { text: "nachtraeglich umgeschrieben" });
|
|
ok(a.code === 409 && a.json?.fehler === "schon_im_umlauf",
|
|
`selbst DogFather aendert es nicht mehr (${a.code}/${a.json?.fehler})`);
|
|
|
|
const l = await liste(chef.keks);
|
|
const m = (l.json?.material || []).find((x) => x.id === id);
|
|
ok(m?.text === "Steht so da", `der Text von damals steht noch ("${m?.text}")`);
|
|
ok(m?.zustand === "benutzt", `und der Zustand heisst "benutzt" (${m?.zustand})`);
|
|
|
|
/* Der vierte Zustand wird hier NEU erzeugt und nicht nur mitgezaehlt:
|
|
Weiter oben gibt es schon vergebene Stuecke aus Abschnitt 6, aber
|
|
dass AUSGERECHNET DIESES nach dem Nehmen "benutzt" heisst, ist die
|
|
Aussage -- und die gilt nur fuer das Stueck von eben. */
|
|
ok((l.json?.material || []).filter((x) => x.zustand === "benutzt").length >= 1,
|
|
"es gibt mindestens ein Stueck im Zustand \"benutzt\"");
|
|
}
|
|
|
|
/* =====================================================================
|
|
11. ENTFERNEN -- dieselbe Runde, andere Tuer
|
|
===================================================================== */
|
|
melde("");
|
|
melde("=== 11. Entfernen ===");
|
|
const wegnehmen = (id, keks) =>
|
|
roh(`/workspace/api/material/${id}`, { methode: "DELETE", keks });
|
|
{
|
|
for (const [wer, s2] of [["DogFather", chef], ["rechte Hand", hand], ["linke Hand", linke]]) {
|
|
/* Jedes Mal ein frisches Stueck von jemand anderem -- sonst
|
|
entfernte der Zweite nur noch eine Nummer, die es nicht gibt,
|
|
und bekaeme 404 statt eines echten Nachweises. */
|
|
const r = await einstellen(hand.keks, `weg-${wer}.png`, "");
|
|
const id = r.json?.id;
|
|
const w = await wegnehmen(id, s2.keks);
|
|
ok(w.code === 200, `${wer} darf ein fremdes Stueck entfernen (${w.code})`);
|
|
|
|
const l = await liste(chef.keks);
|
|
ok(!(l.json?.material || []).some((x) => x.id === id),
|
|
`und es ist danach wirklich weg`);
|
|
}
|
|
|
|
const r = await einstellen(hand.keks, "bleibt-liegen.png", "");
|
|
const id = r.json?.id;
|
|
for (const [wer, s2] of [["Modi", modi], ["Kessi (Community)", kessi]]) {
|
|
const w = await wegnehmen(id, s2.keks);
|
|
ok(w.code === 403 || w.code === 404,
|
|
`${wer} darf nicht entfernen (${w.code}/${w.json?.fehler})`);
|
|
}
|
|
const l = await liste(chef.keks);
|
|
ok((l.json?.material || []).some((x) => x.id === id),
|
|
"und das Stueck liegt danach immer noch da");
|
|
}
|
|
|
|
/* =====================================================================
|
|
12. WAS DIE LISTE UEBER DIE RECHTE SAGT
|
|
=====================================================================
|
|
Die Oberflaeche rechnet NICHT nach, wer was darf -- sie liest
|
|
`darf_ich` und `darf_verwalten`. Waeren die Angaben falsch, staenden
|
|
Knoepfe da, die beim Druecken eine Absage bringen; oder sie fehlten
|
|
dort, wo jemand darf. Beides ist schlimmer als ein Serverfehler,
|
|
weil es aussieht, als sei alles in Ordnung.
|
|
===================================================================== */
|
|
melde("");
|
|
melde("=== 12. Was die Liste ueber die Rechte sagt ===");
|
|
{
|
|
const r = await einstellen(hand.keks, "rechte-probe.png", "");
|
|
const id = r.json?.id;
|
|
|
|
const darf = { admin: true, hand: true, linke: true };
|
|
for (const [wer, s2, rolle] of [["DogFather", chef, "admin"], ["rechte Hand", hand, "hand"],
|
|
["linke Hand", linke, "linke"], ["Modi", modi, "modi"], ["Kessi", kessi, "gast"]]) {
|
|
const l = await liste(s2.keks);
|
|
const soll = darf[rolle] === true;
|
|
ok(l.json?.darf_verwalten === soll,
|
|
`${wer}: darf_verwalten = ${l.json?.darf_verwalten} (erwartet ${soll})`);
|
|
const m = (l.json?.material || []).find((x) => x.id === id);
|
|
ok(!!m && m.darf_ich === soll,
|
|
`${wer}: darf_ich am fremden Stueck = ${m?.darf_ich} (erwartet ${soll})`);
|
|
}
|
|
|
|
/* GEGENPROBE: Am EIGENEN Stueck muss `darf_ich` auch fuer jemanden
|
|
wahr sein, der fremde nicht anfassen darf. Sonst koennte das Feld
|
|
schlicht `darf_verwalten` wiederholen und niemandem auffallen.
|
|
Ein Modi stellt nichts ein -- also nimmt die Probe die rechte
|
|
Hand, deren eigenes Stueck das hier ist. */
|
|
const l = await liste(hand.keks);
|
|
const m = (l.json?.material || []).find((x) => x.id === id);
|
|
ok(m?.darf_ich === true, "die rechte Hand darf ihr eigenes Stueck (darf_ich = true)");
|
|
}
|
|
|
|
/* =====================================================================
|
|
13. AM ECHTEN BILDSCHIRM
|
|
=====================================================================
|
|
Die zwoelf Abschnitte davor messen die Schnittstelle. Keiner von
|
|
ihnen haette gemerkt, wenn der Knopf "Bearbeiten" gar nicht
|
|
erscheint, das Formular hinter der Karte verschwindet oder die
|
|
Datumsfelder am Handy aus dem Bild laufen.
|
|
|
|
Genau daran ist die Teilnehmerwahl am 05.09. gescheitert: vierzig
|
|
gruene Pruefungen, und auf dem Bildschirm klebten die Namen
|
|
aneinander. Niemand hatte gefragt, ob das Ergebnis auch AUSSIEHT wie
|
|
gedacht.
|
|
|
|
WARUM EIN HTTPS-VORBAU: Team Dogi wohnt auf crew.dogfather-universe.com,
|
|
und welche Wand gilt, entscheidet der Host-Kopf. Chromium setzt den
|
|
selbst und hebt diese Domain ausserdem von sich aus auf https --
|
|
also bekommt sie hier einen echten, selbst unterschriebenen
|
|
Vorbau davor. Derselbe Weg wie in pruef-willkommen.
|
|
===================================================================== */
|
|
melde("");
|
|
melde("=== 13. Am echten Bildschirm ===");
|
|
{
|
|
const { execFileSync } = await import("node:child_process");
|
|
const { createServer: httpsServer } = await import("node:https");
|
|
const { readFileSync } = await import("node:fs");
|
|
|
|
const schluessel = join(ordner, "pruef.key");
|
|
const zertifikat = join(ordner, "pruef.crt");
|
|
zertifikatBauen(schluessel, zertifikat, CREW);
|
|
|
|
const HTTPS_PORT = PORT + 1;
|
|
const vorbau = httpsServer({
|
|
key: readFileSync(schluessel), cert: readFileSync(zertifikat),
|
|
}, (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);
|
|
});
|
|
von.pipe(weiter);
|
|
});
|
|
await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r));
|
|
const SEITE = `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"],
|
|
});
|
|
|
|
/* Ein sauberer Ausgangszustand fuer die Augen: ein Stueck mit
|
|
laufendem Fenster, eines fuer spaeter, eines abgelaufen. Alles im
|
|
Wegwerfordner -- an die echte Datenbank kommt hier nichts. */
|
|
await einstellen(chef.keks, "schau-laeuft.png", "Laeuft gerade", { ab: VORGESTERN, bis: IN10 });
|
|
await einstellen(chef.keks, "schau-spaeter.png", "Kommt noch", { ab: MORGEN, bis: IN10 });
|
|
await einstellen(chef.keks, "schau-vorbei.png", "War einmal", { ab: VORGESTERN, bis: GESTERN });
|
|
|
|
const meckern = [];
|
|
async function seiteAls(rolle, code, breite) {
|
|
const ctx = await browser.newContext({ viewport: { width: breite, height: 950 } });
|
|
const s2 = await ctx.newPage();
|
|
s2.on("pageerror", (e) => meckern.push(`${rolle}: Absturz ${String(e).slice(0, 120)}`));
|
|
s2.on("console", (m) => {
|
|
if (m.type() === "error" && !/favicon|ERR_CERT|net::ERR/i.test(m.text())) {
|
|
meckern.push(`${rolle}: Konsole ${m.text().slice(0, 120)}`);
|
|
}
|
|
});
|
|
await s2.goto(`${SEITE}/workspace/`, { waitUntil: "networkidle" });
|
|
await s2.click(`.rolle[data-rolle="${rolle}"]`);
|
|
await s2.fill("#code", code);
|
|
await s2.click("button[type=submit]");
|
|
await s2.waitForTimeout(900);
|
|
await s2.goto(`${SEITE}/workspace/material.html`, { waitUntil: "networkidle" });
|
|
await s2.waitForTimeout(1200);
|
|
return { ctx, s: s2 };
|
|
}
|
|
|
|
try {
|
|
/* ---- DogFather am Rechner ------------------------------------ */
|
|
const a = await seiteAls("admin", "CODE-DOGI-0001", 1280);
|
|
try {
|
|
const bild = await a.s.evaluate(() => {
|
|
const knoepfe = [...document.querySelectorAll("#m-kat .schritt")];
|
|
const karte = document.querySelector(".m-karte");
|
|
const zeit = document.querySelector(".m-karte__zeit");
|
|
return {
|
|
ueberschrift: document.querySelector("h1")?.textContent?.trim(),
|
|
kategorien: knoepfe.map((b) => ({
|
|
wort: b.textContent.replace(/\d+$/, "").trim(),
|
|
zahl: b.querySelector(".m-kat__zahl")?.textContent,
|
|
gedrueckt: b.getAttribute("aria-pressed"),
|
|
})),
|
|
/* Sieht der Knopf nach Knopf aus? Ein `.schritt` ohne
|
|
Polsterung waere nackter Text -- genau der Fehler von der
|
|
Teilnehmerwahl. */
|
|
knopfHoehe: knoepfe[0] ? Math.round(knoepfe[0].getBoundingClientRect().height) : 0,
|
|
verwalten: !!karte?.querySelector(".m-karte__verwalten"),
|
|
verwaltenKnoepfe: [...(karte?.querySelectorAll(".m-karte__verwalten button") || [])]
|
|
.map((b) => b.textContent.trim()),
|
|
zeitMarke: zeit?.querySelector(".m-karte__zeit-marke")?.textContent?.trim() || null,
|
|
zeitFarbe: zeit ? getComputedStyle(zeit.querySelector(".m-karte__zeit-marke")).color : null,
|
|
zeitText: zeit?.textContent?.replace(/\s+/g, " ").trim() || null,
|
|
fensterFelder: document.querySelectorAll(".m-neu__fenster input[type=date]").length,
|
|
};
|
|
});
|
|
|
|
ok(bild.ueberschrift === "Dogi-Media",
|
|
`die Seite heisst "${bild.ueberschrift}"`);
|
|
ok(bild.kategorien.length === 5,
|
|
`fuenf Kategorieknoepfe stehen da (${bild.kategorien.length}: `
|
|
+ `${bild.kategorien.map((k) => `${k.wort} ${k.zahl}`).join(" | ")})`);
|
|
ok(bild.kategorien[0]?.gedrueckt === "true",
|
|
`"Alle" ist beim Aufschlagen gewaehlt (${bild.kategorien[0]?.gedrueckt})`);
|
|
ok(bild.knopfHoehe >= 26,
|
|
`und sie sehen aus wie Knoepfe, nicht wie Text (${bild.knopfHoehe} px hoch)`);
|
|
ok(bild.fensterFelder === 2,
|
|
`der Einstellkasten hat zwei Datumsfelder (${bild.fensterFelder})`);
|
|
ok(bild.verwalten && bild.verwaltenKnoepfe.join("/") === "Bearbeiten/Entfernen",
|
|
`DogFather sieht beide Verwaltungsknoepfe (${bild.verwaltenKnoepfe.join(", ")})`);
|
|
ok(!!bild.zeitMarke && !!bild.zeitText,
|
|
`der Zeitraum steht an der Karte ("${bild.zeitText}")`);
|
|
ok(bild.zeitFarbe !== null && bild.zeitFarbe !== "rgb(0, 0, 0)",
|
|
`und die Zustandsmarke hat ihre Farbe (${bild.zeitFarbe})`);
|
|
|
|
/* ---- Der Filter filtert wirklich, und beide Listen ---- */
|
|
const vorher = await a.s.evaluate(() => ({
|
|
oben: document.querySelectorAll("#frei .m-karte").length,
|
|
unten: document.querySelectorAll("#weg .m-karte").length,
|
|
}));
|
|
await a.s.click('#m-kat .schritt[data-wert="spaeter"]');
|
|
await a.s.waitForTimeout(400);
|
|
const nachher = await a.s.evaluate(() => ({
|
|
oben: document.querySelectorAll("#frei .m-karte").length,
|
|
unten: document.querySelectorAll("#weg .m-karte").length,
|
|
gedrueckt: document.querySelector('#m-kat .schritt[data-wert="spaeter"]')
|
|
?.getAttribute("aria-pressed"),
|
|
/* AM ZUSTAND GEMESSEN, NICHT AM DATEINAMEN. Der erste Entwurf
|
|
prueft, ob jeder Name "spaeter" enthaelt -- und wurde rot,
|
|
weil ein Stueck aus Abschnitt 10 voellig zu Recht dabei war:
|
|
Dort hat die rechte Hand ihm ein Fenster in der Zukunft
|
|
gegeben. Die Pruefung hatte recht behalten wollen, nicht
|
|
gemessen. */
|
|
zustaende: [...document.querySelectorAll("#frei .m-karte")]
|
|
.map((k) => k.querySelector(".m-karte__zeit")?.dataset.zustand || "ohne"),
|
|
}));
|
|
ok(nachher.gedrueckt === "true" && nachher.oben < vorher.oben && nachher.oben > 0,
|
|
`"Kommt noch" filtert die obere Liste (${vorher.oben} -> ${nachher.oben})`);
|
|
ok(nachher.unten === 0 && vorher.unten > 0,
|
|
`und die untere gleich mit (${vorher.unten} -> ${nachher.unten}) `
|
|
+ `-- ein Filter, der nur die halbe Seite betrifft, sieht kaputt aus`);
|
|
ok(nachher.zustaende.length > 0
|
|
&& nachher.zustaende.every((z) => z === "spaeter"),
|
|
`uebrig bleibt nur, was spaeter gilt `
|
|
+ `(${nachher.zustaende.length} Karten, alle: ${[...new Set(nachher.zustaende)].join(", ")})`);
|
|
|
|
/* Noch einmal auf denselben Knopf heisst "alle". */
|
|
await a.s.click('#m-kat .schritt[data-wert="spaeter"]');
|
|
await a.s.waitForTimeout(400);
|
|
const zurueck = await a.s.evaluate(() =>
|
|
document.querySelectorAll("#frei .m-karte").length);
|
|
ok(zurueck === vorher.oben,
|
|
`ein zweiter Druck fuehrt zurueck zu allen (${zurueck} von ${vorher.oben})`);
|
|
|
|
/* ---- BEARBEITEN: Formular IN der Karte, vorbelegt ---- */
|
|
await a.s.click(".m-karte .m-karte__verwalten button");
|
|
await a.s.waitForTimeout(400);
|
|
const form = await a.s.evaluate(() => {
|
|
const k = document.querySelector(".m-karte");
|
|
const f = k?.querySelector(".m-bearb");
|
|
if (!f) return null;
|
|
return {
|
|
inKarte: !!f && k.contains(f),
|
|
text: f.querySelector("textarea")?.value,
|
|
kartenText: k.querySelector(".m-karte__text")?.textContent,
|
|
datumsfelder: f.querySelectorAll("input[type=date]").length,
|
|
ab: f.querySelectorAll("input[type=date]")[0]?.value,
|
|
knoepfe: [...f.querySelectorAll("button")].map((b) => b.textContent.trim()),
|
|
};
|
|
});
|
|
ok(!!form && form.inKarte, "das Formular steht in der Karte, nicht davor");
|
|
ok(!!form && form.text === form.kartenText,
|
|
`das Textfeld ist mit dem bisherigen Text vorbelegt ("${form?.text}")`);
|
|
ok(!!form && form.datumsfelder === 2 && !!form.ab,
|
|
`und die zwei Datumsfelder auch (${form?.ab})`);
|
|
ok(!!form && form.knoepfe.join("/") === "Speichern/Abbrechen",
|
|
`Speichern und Abbrechen stehen da (${form?.knoepfe.join(", ")})`);
|
|
|
|
/* GEGENPROBE ZUM VORBELEGEN: Genau das konnte `frageNach` nicht,
|
|
und genau deshalb steht hier ein eigenes Formular. Ein leeres
|
|
Feld waere kein Bearbeiten, sondern ein Neuschreiben. */
|
|
|
|
/* ---- Ein verkehrtes Fenster wird schon hier gesagt ---- */
|
|
const felder = ".m-karte .m-bearb input[type=date]";
|
|
await a.s.evaluate((wahl) => {
|
|
const d2 = document.querySelectorAll(wahl);
|
|
d2[0].value = "2030-01-01"; d2[1].value = "2029-01-01";
|
|
}, felder);
|
|
await a.s.click(".m-karte .m-bearb button[type=submit]");
|
|
await a.s.waitForTimeout(500);
|
|
const gemeckert = await a.s.evaluate(() =>
|
|
document.querySelector(".m-karte .m-bearb__fehler")?.textContent?.trim());
|
|
ok(!!gemeckert && gemeckert.length > 10,
|
|
`ein verkehrtes Fenster wird im Formular gesagt ("${gemeckert}")`);
|
|
|
|
/* ---- Speichern geht wirklich durch ---- */
|
|
await a.s.evaluate(() => {
|
|
const f = document.querySelector(".m-karte .m-bearb");
|
|
f.querySelector("textarea").value = "Am Bildschirm geaendert";
|
|
for (const d2 of f.querySelectorAll("input[type=date]")) d2.value = "";
|
|
});
|
|
await a.s.click(".m-karte .m-bearb button[type=submit]");
|
|
await a.s.waitForTimeout(1200);
|
|
const danach = await a.s.evaluate(() => ({
|
|
formWeg: !document.querySelector(".m-karte .m-bearb"),
|
|
text: document.querySelector(".m-karte .m-karte__text")?.textContent,
|
|
}));
|
|
ok(danach.formWeg, "nach dem Speichern ist das Formular zu");
|
|
ok(danach.text === "Am Bildschirm geaendert",
|
|
`und der neue Text steht auf der Karte ("${danach.text}")`);
|
|
} finally { await a.ctx.close(); }
|
|
|
|
/* ---- Ein Modi am Handy ----------------------------------------
|
|
ZWEI ANNAHMEN HIER WAREN FALSCH, und beide standen vier Zeilen
|
|
weiter oben im selben Lauf richtig da:
|
|
|
|
"ein Modi stellt etwas bereit (201, Nummer 4)"
|
|
|
|
Ein Modi GEHOERT zum Team Dogi. Er darf einstellen, und an
|
|
seinen EIGENEN Stuecken darf er selbstverstaendlich auch
|
|
aendern. Die erste Fassung verlangte "kein einziger
|
|
Verwaltungsknopf" und "kein Einstellkasten" -- das waere keine
|
|
Pruefung gewesen, sondern der Wunsch, recht zu behalten.
|
|
|
|
Was wirklich zaehlt, ist die Grenze: seine eigenen ja, fremde
|
|
nein. Und die wird nicht geschaetzt, sondern gegen die Liste
|
|
gerechnet, die der Server ihm selbst schickt. */
|
|
const b = await seiteAls("modi", "CODE-MODI-0001", 390);
|
|
try {
|
|
const seinStand = (await liste(modi.keks)).json || {};
|
|
const darfIds = new Set((seinStand.material || [])
|
|
.filter((m) => m.darf_ich && !m.genommen).map((m) => String(m.id)));
|
|
const fremdIds = new Set((seinStand.material || [])
|
|
.filter((m) => !m.darf_ich && !m.genommen).map((m) => String(m.id)));
|
|
|
|
const handy = await b.s.evaluate(() => ({
|
|
karten: document.querySelectorAll(".m-karte").length,
|
|
mitVerwalten: [...document.querySelectorAll(".m-karte")]
|
|
.filter((k) => k.querySelector(".m-karte__verwalten"))
|
|
.map((k) => k.dataset.id),
|
|
einstellen: !document.getElementById("neu-block").hidden,
|
|
breite: document.documentElement.scrollWidth,
|
|
sichtbar: document.documentElement.clientWidth,
|
|
/* Jeder Kategorieknopf muss am Finger zu treffen sein. */
|
|
kleinsteKat: Math.min(...[...document.querySelectorAll("#m-kat .schritt")]
|
|
.map((x) => Math.round(x.getBoundingClientRect().height))),
|
|
}));
|
|
|
|
ok(handy.karten > 0, `der Modi sieht Stuecke (${handy.karten})`);
|
|
|
|
const gezeigt = new Set(handy.mitVerwalten);
|
|
const zuViel = [...gezeigt].filter((id) => fremdIds.has(id));
|
|
const zuWenig = [...darfIds].filter((id) => !gezeigt.has(id));
|
|
/* DIE ANZAHL GEHOERT IN DIE BEDINGUNG: Ohne `darfIds.size > 0`
|
|
waeren beide Listen leer und die Zeile gruen, ohne dass je
|
|
ein Knopf geprueft wurde. */
|
|
ok(darfIds.size > 0 && fremdIds.size > 0
|
|
&& zuViel.length === 0 && zuWenig.length === 0,
|
|
`Verwaltungsknoepfe genau an seinen eigenen `
|
|
+ `(${gezeigt.size} gezeigt, ${darfIds.size} erlaubt, `
|
|
+ `${fremdIds.size} fremde daneben)`
|
|
+ (zuViel.length ? ` -- zu viel an: ${zuViel.join(", ")}` : "")
|
|
+ (zuWenig.length ? ` -- es fehlt an: ${zuWenig.join(", ")}` : ""));
|
|
|
|
ok(handy.einstellen === true,
|
|
"der Einstellkasten steht da -- ein Modi gehoert zum Team und stellt bereit");
|
|
ok(handy.breite <= handy.sichtbar + 1,
|
|
`nichts laeuft seitlich aus dem Bild (${handy.breite} von ${handy.sichtbar} px)`);
|
|
ok(handy.kleinsteKat >= 26,
|
|
`die Kategorieknoepfe sind am Finger zu treffen (${handy.kleinsteKat} px)`);
|
|
} finally { await b.ctx.close(); }
|
|
|
|
/* ---- Und die Community: gar kein Kasten -----------------------
|
|
DAS IST DIE GEGENPROBE zur Zeile darueber. Waere der Kasten
|
|
einfach immer da, waere "er steht da" genauso gruen. */
|
|
const c = await seiteAls("gast", "CODE-GAST-0001", 390);
|
|
try {
|
|
const gast = await c.s.evaluate(() => ({
|
|
karten: document.querySelectorAll(".m-karte").length,
|
|
einstellen: !document.getElementById("neu-block").hidden,
|
|
verwalten: document.querySelectorAll(".m-karte__verwalten").length,
|
|
breite: document.documentElement.scrollWidth,
|
|
sichtbar: document.documentElement.clientWidth,
|
|
}));
|
|
ok(gast.karten > 0, `die Community sieht die Stuecke (${gast.karten})`);
|
|
ok(gast.einstellen === false,
|
|
"aber keinen Einstellkasten -- ein Formular, das eine Absage bringt, ist schlimmer als keins");
|
|
ok(gast.verwalten === 0,
|
|
`und keinen einzigen Verwaltungsknopf (${gast.verwalten})`);
|
|
ok(gast.breite <= gast.sichtbar + 1,
|
|
`auch bei ihr laeuft nichts aus dem Bild (${gast.breite} von ${gast.sichtbar} px)`);
|
|
} finally { await c.ctx.close(); }
|
|
|
|
ok(meckern.length === 0, meckern.length
|
|
? `der Browser meldet: ${meckern.slice(0, 3).join(" | ")}`
|
|
: "kein Absturz und keine Fehlermeldung im Browser");
|
|
} finally {
|
|
await browser.close();
|
|
await new Promise((r) => vorbau.close(r));
|
|
}
|
|
}
|
|
|
|
/* =====================================================================
|
|
ERGEBNIS
|
|
===================================================================== */
|
|
melde("");
|
|
/* Die Zahl gehoert in die Bedingung, nicht nur in den Text: Ein Lauf,
|
|
der nichts geprueft hat, ist kein bestandener Lauf. */
|
|
/* Von 40 ueber 90 auf 145 angehoben (22.09.2026), als das Bearbeiten,
|
|
das Zeitfenster und der Blick am echten Bildschirm dazukamen. Die
|
|
Zahl ist keine Schaetzung: Sie steht knapp unter dem, was der Lauf
|
|
danach tatsaechlich gemeldet hat (159). Bei 90 waere der ganze
|
|
Browserabschnitt still ausfallen koennen, ohne dass die Zahl etwas
|
|
gesagt haette -- und genau das soll sie fangen. */
|
|
if (geprueft < 150) {
|
|
console.log(` FEHL nur ${geprueft} Pruefungen gelaufen -- erwartet waren 150+`);
|
|
fehler++;
|
|
}
|
|
melde(fehler === 0
|
|
? `Alles in Ordnung. ${geprueft} Pruefungen, 0 Fehler.`
|
|
: `${fehler} FEHLER bei ${geprueft} Pruefungen.`);
|
|
melde("");
|
|
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
|
process.exit(fehler ? 1 : 0);
|