/* ===================================================================== DIE MODI-ANFRAGE (17.09.2026) Filipe: „ich will dass die leute sich da anonym bei mir melden können und eine anfrage machen können für modi ... und dan soll ich annehmen ablehnen, in warteschlange stellen oder sofort kommunizieren können. und wenn ich annehme dan soll diese person automatisch zu talente kategorie weiter geleitet werden." --------------------------------------------------------------------- WAS DIESE PRÜFUNG WIRKLICH ABSICHERN MUSS Nicht „kann man ein Formular abschicken" -- das sieht man. Sondern die drei Zusagen, deren Bruch NIEMAND meldet: 1. VERTRAULICH. Hier stehen Alter, Schwächen und alte Sperren. Wer das liest, entscheidet mit; wer es nicht lesen darf, darf auch nicht erfahren, DASS es etwas gibt. Also 404, nicht 403 -- und im Protokoll steht keine Zeile Inhalt. 2. DIE BRÜCKE TRÄGT GENAU EINMAL. „Annehmen" legt ein Talent an. Zweimal angenommen (Doppelklick, Zurück-Taste, zwei Geräte) darf kein zweites Talent ergeben -- sonst steht dieselbe Person doppelt im Trichter, und das merkt man erst, wenn man sie zweimal anspricht. 3. EINE ENTSCHEIDUNG OHNE SATZ IST KEINE. „Reden" und „Ablehnen" verlangen eine Nachricht. Sie ist der einzige Weg, dieser Person etwas zu sagen: Ein Zugang aus der Community steht ausdrücklich nicht in den Chatlisten (AUSSEN_ROLLEN). --------------------------------------------------------------------- WARUM node:http UND NICHT fetch -- siehe pruef-treff.mjs: Die Community meldet sich an einer anderen Wand an, und welche Wand gilt, entscheidet der Host-Kopf. `fetch` in Node setzt den selbst. ===================================================================== */ import { mkdtempSync } 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"; const PORT = await eigenerPort(import.meta, "pruef-bewerbung"); const ordner = mkdtempSync(join(tmpdir(), "ws-bewerb-")); 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 }); }; import { notbremse } from "./helfer-notbremse.mjs"; await import("./index.js"); notbremse(300_000, "pruef-bewerbung"); await new Promise((r) => setTimeout(r, 700)); 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, offen = 0; const melde = (t) => console.log(t); const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; /* Der dritte Ausgang: konnte nicht nachsehen. Mit Grund -- sonst weiss man danach so wenig wie vorher. */ const unklar = (t) => { offen++; console.log(" -- " + t); }; const CREW = "crew.dogfather-universe.com"; const HAUS = "workspace.dogfather-universe.com"; const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); const fragen = await import("./workspace-bewerbung-fragen.js"); const rollen = await import("./workspace-bewerbung-rollen.js"); 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("Frida", "modi", "CODE-MODI-0001"); const idKessi = anlegen("Kessi", "gast", "CODE-GAST-0001"); const idBenno = anlegen("Benno", "gast", "CODE-GAST-0002"); const idCora = anlegen("Cora", "gast", "CODE-GAST-0003"); d.close(); function roh(pfad, host, kopf = {}, koerper = null, methode = null) { return new Promise((fertig, schief) => { const a = httpAnfrage({ host: "127.0.0.1", port: PORT, path: pfad, method: methode || (koerper ? "POST" : "GET"), headers: { Host: host, ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf }, }, (antwort) => { let text = ""; antwort.on("data", (s) => { text += s; }); antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers })); }); a.on("error", schief); if (koerper) a.write(JSON.stringify(koerper)); a.end(); }); } const alsJson = (r) => { try { return JSON.parse(r.text); } catch { return {}; } }; async function anmelden(rolle, code, host) { const r = await roh("/workspace/api/anmelden", host, {}, 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 kDogi = await anmelden("admin", "CODE-DOGI-0001", CREW); const kHand = await anmelden("hand", "CODE-HAND-0001", CREW); const kModi = await anmelden("modi", "CODE-MODI-0001", CREW); const kKessi = await anmelden("gast", "CODE-GAST-0001", CREW); const kBenno = await anmelden("gast", "CODE-GAST-0002", CREW); const kCora = await anmelden("gast", "CODE-GAST-0003", CREW); const holen = (pfad, k, host = CREW) => roh(pfad, host, { Cookie: k.keks }); const schicken = (pfad, k, koerper, methode = "POST", host = CREW) => roh(pfad, host, { Cookie: k.keks }, koerper, methode); /* Ein vollstaendig ausgefuellter Bogen -- die Pflichtliste kommt aus dem Katalog und nicht aus einer Abschrift hier. Wer eine Pflichtfrage ergaenzt, bekommt sie automatisch mit; eine Liste hier waere die, die beim naechsten Mal fehlt. */ function bogen(zusatz = {}) { const a = {}; for (const s of fragen.PFLICHT) a[s] = "Antwort auf " + s + " - lang genug fuer eine Pruefung."; a.name = zusatz.name || "Kessi"; return { ...a, ...zusatz }; } /* ======================================================================= 1. DER KATALOG ======================================================================= */ melde(""); melde("=== 1. Der Fragebogen selbst ==="); ok(fragen.BEWERBUNG_FRAGEN.length >= 12, `der Bogen hat ${fragen.BEWERBUNG_FRAGEN.length} Fragen`); ok(fragen.PFLICHT.length >= 6 && fragen.PFLICHT.length < fragen.BEWERBUNG_FRAGEN.length, `davon ${fragen.PFLICHT.length} Pflicht – und nicht alle`); { /* JEDE FRAGE GEHOERT IN EINE GRUPPE. Eine Frage ohne Gruppe wird von der Oberflaeche stillschweigend nicht gezeichnet -- sie faellt nicht auf, sie fehlt einfach. */ const bekannt = new Set(fragen.BEWERBUNG_GRUPPEN.map((g) => g.schluessel)); const heimatlos = fragen.BEWERBUNG_FRAGEN.filter((f) => !bekannt.has(f.gruppe)); ok(heimatlos.length === 0, heimatlos.length ? `ohne Gruppe: ${heimatlos.map((f) => f.schluessel).join(", ")}` : "jede Frage steht in einer bekannten Gruppe"); const leer = fragen.BEWERBUNG_GRUPPEN.filter( (g) => !fragen.BEWERBUNG_FRAGEN.some((f) => f.gruppe === g.schluessel)); ok(leer.length === 0, leer.length ? `leere Gruppen: ${leer.map((g) => g.schluessel).join(", ")}` : "und jede Gruppe hat Fragen"); } { const s = fragen.BEWERBUNG_FRAGEN.map((f) => f.schluessel); ok(new Set(s).size === s.length, "die Schlüssel sind eindeutig"); } ok(fragen.BEWERBUNG_WEGE.length === 4, `vier Wege: ${fragen.BEWERBUNG_WEGE.map((w) => w.name).join(" · ")}`); ok(fragen.BEWERBUNG_WEGE[0].schluessel === "gespraech", "„Reden“ steht vorne – die häufigste richtige Antwort ist eine Rückfrage"); ok(fragen.BEWERBUNG_WEGE.filter((w) => w.weiter === "talent").length === 1, "genau ein Weg führt zu den Talenten"); /* ======================================================================= 2. WER DEN BOGEN ÜBERHAUPT BEKOMMT ======================================================================= */ melde(""); melde("=== 2. Wer ihn bekommt ==="); { const a = alsJson(await holen("/workspace/api/bewerbung", kKessi)); ok(a.darf === true, "die Community darf eine Anfrage stellen"); ok(Array.isArray(a.fragen) && a.fragen.length === fragen.BEWERBUNG_FRAGEN.length, `und bekommt alle ${a.fragen?.length} Fragen mitgeliefert`); ok(typeof a.wer_liest === "string" && a.wer_liest.length > 10, `und erfährt vorher, wer es liest („${String(a.wer_liest).slice(0, 40)}…“)`); } for (const [name, k] of [["ein Modi", kModi], ["die rechte Hand", kHand], ["DogFather", kDogi]]) { const a = alsJson(await holen("/workspace/api/bewerbung", k)); ok(a.darf === false, `${name} bekommt kein Formular – wer dabei ist, bewirbt sich nicht`); } /* ======================================================================= 3. ABSCHICKEN ======================================================================= */ melde(""); melde("=== 3. Abschicken ==="); { /* Eine Pflichtantwort fehlt. Der Server sagt WELCHE -- sonst sucht man in fuenfzehn Feldern. */ const unvoll = bogen(); delete unvoll[fragen.PFLICHT[2]]; const r = await schicken("/workspace/api/bewerbung", kKessi, { antworten: unvoll }); const a = alsJson(r); ok(r.code === 400, `eine fehlende Pflichtantwort wird abgewiesen (${r.code})`); ok(Array.isArray(a.fehlt) && a.fehlt.includes(fragen.PFLICHT[2]), `und der Server sagt, welche (${JSON.stringify(a.fehlt)})`); const zahl = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }) .prepare("SELECT COUNT(*) n FROM bewerbungen").get().n; ok(zahl === 0, "und es liegt nichts halb Fertiges in der Datenbank"); } { const r = await schicken("/workspace/api/bewerbung", kKessi, { antworten: bogen({ name: "Kessi", warum: "Ich bin fast jeden Abend da." }) }); ok(r.code === 201, `ein vollständiger Bogen geht durch (${r.code})`); } { const r = await schicken("/workspace/api/bewerbung", kKessi, { antworten: bogen() }); ok(r.code === 409, `eine zweite Anfrage geht nicht, solange die erste offen ist (${r.code})`); } { /* FREMDE HERKUNFT. Ein Formular auf einer fremden Seite koennte den Weg im Browser eines Angemeldeten ausloesen. */ const r = await roh("/workspace/api/bewerbung", CREW, { Cookie: kBenno.keks, Origin: "https://boese.example" }, { antworten: bogen() }); ok(r.code === 403, `aus fremder Herkunft geht nichts (${r.code})`); const gut = await schicken("/workspace/api/bewerbung", kBenno, { antworten: bogen({ name: "Benno" }) }); ok(gut.code === 201, `– und die Gegenprobe: aus dem eigenen Haus geht es sehr wohl (${gut.code})`); } /* ======================================================================= 4. VERTRAULICH ======================================================================= */ melde(""); melde("=== 4. Wer sie lesen darf ==="); for (const [name, k] of [["ein Modi", kModi], ["die Community selbst", kKessi]]) { const r = await holen("/workspace/api/bewerbungen", k); ok(r.code === 404, `${name} bekommt den Eingang nicht – und zwar 404, nicht „kein Zugriff“ (${r.code})`); } for (const [name, k] of [["DogFather", kDogi], ["die rechte Hand", kHand]]) { const r = await holen("/workspace/api/bewerbungen", k); const a = alsJson(r); ok(r.code === 200 && a.bewerbungen?.length === 2, `${name} sieht beide Anfragen (${r.code}, ${a.bewerbungen?.length})`); } { const a = alsJson(await holen("/workspace/api/bewerbungen", kDogi)); const eine = a.bewerbungen.find((b) => b.nenn_name === "Kessi"); ok(eine?.antworten?.warum === "Ich bin fast jeden Abend da.", "die Antworten kommen vollständig mit"); ok(typeof eine?.tage === "number", `und dabei steht, wie lange sie schon liegt (${eine?.tage} Tage)`); } { /* IM PROTOKOLL STEHT KEIN INHALT. Das Protokoll lesen mehr Leute als die Anfrage selbst. */ const p = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }) .prepare("SELECT aktion, detail FROM protokoll WHERE aktion LIKE 'bewerbung%'").all(); ok(p.length >= 2, `das Protokoll hält die Anfragen fest (${p.length} Zeilen)`); const verraten = p.filter((z) => /jeden Abend|Antwort auf/.test(String(z.detail || ""))); ok(verraten.length === 0, verraten.length ? `Inhalt im Protokoll: ${JSON.stringify(verraten[0])}` : "aber keine Zeile Inhalt – nur die Nummer"); } /* ======================================================================= 5. ENTSCHEIDEN — und der Satz, der bei der Person ankommt ======================================================================= */ melde(""); melde("=== 5. Entscheiden ==="); const idKessiAnfrage = alsJson(await holen("/workspace/api/bewerbungen", kDogi)) .bewerbungen.find((b) => b.nenn_name === "Kessi").id; { const r = await schicken(`/workspace/api/bewerbung/${idKessiAnfrage}/weg`, kDogi, { weg: "gespraech", grund: "" }, "PUT"); ok(r.code === 400, `„Reden“ ohne einen Satz geht nicht (${r.code})`); /* WARUM DAS EINE ECHTE REGEL IST und keine Gaengelei: Es gibt keinen zweiten Weg, dieser Person etwas zu sagen. Ein Zugang aus der Community steht nicht in den Chatlisten. */ ok(rollen.BEWERBUNG_ROLLEN.has("gast"), "denn eine Anfrage kommt aus der Community – und die steht in keiner Chatliste"); } { const satz = "Wie viele Abende in der Woche schaffst du realistisch?"; const r = await schicken(`/workspace/api/bewerbung/${idKessiAnfrage}/weg`, kDogi, { weg: "gespraech", grund: satz }, "PUT"); ok(r.code === 200, `mit Satz geht es (${r.code})`); const eigen = alsJson(await holen("/workspace/api/bewerbung", kKessi)); ok(eigen.meine?.zustand === "gespraech" && eigen.meine?.grund === satz, "und der Satz steht auf ihrer eigenen Seite – das ist das „sofort kommunizieren“"); ok(eigen.offen === true, "die Anfrage bleibt dabei offen – „Reden“ ist keine Entscheidung"); } { /* DIE RECHTE HAND ENTSCHEIDET MIT -- Filipes Antwort auf die Rueckfrage vom 17.09.: „Du und die rechte Hand." */ const r = await schicken(`/workspace/api/bewerbung/${idKessiAnfrage}/weg`, kHand, { weg: "warteschlange", grund: "" }, "PUT"); ok(r.code === 200, `die rechte Hand darf ebenfalls entscheiden (${r.code})`); const r2 = await schicken(`/workspace/api/bewerbung/${idKessiAnfrage}/weg`, kModi, { weg: "angenommen", grund: "" }, "PUT"); ok(r2.code === 404, `ein Modi nicht – und erfährt auch nicht, dass es sie gibt (${r2.code})`); } { const r = await schicken(`/workspace/api/bewerbung/${idKessiAnfrage}/weg`, kDogi, { weg: "voellig-erfunden", grund: "x" }, "PUT"); ok(r.code === 400, `ein erfundener Weg wird abgewiesen (${r.code})`); } /* ======================================================================= 6. DIE BRÜCKE ZU DEN TALENTEN ======================================================================= */ melde(""); melde("=== 6. Aus einer Anfrage wird ein Talent ==="); { const vorher = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }) .prepare("SELECT COUNT(*) n FROM eintraege WHERE bereich = 'talente'").get().n; ok(vorher === 0, "vorher steht niemand bei den Talenten"); const r = await schicken(`/workspace/api/bewerbung/${idKessiAnfrage}/weg`, kDogi, { weg: "angenommen", grund: "Willkommen." }, "PUT"); const a = alsJson(r); ok(r.code === 200 && a.eintrag_id > 0, `„Annehmen“ legt ein Talent an (${r.code})`); const db2 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); const e = db2.prepare( "SELECT titel, bereich FROM eintraege WHERE id = ?").get(a.eintrag_id); ok(e?.bereich === "talente" && e?.titel === "Kessi", `und zwar unter dem Namen, den sie selbst genannt hat („${e?.titel}“)`); const st = db2.prepare("SELECT stufe FROM talent_stufe WHERE eintrag_id = ?").get(a.eintrag_id); /* AUF „ANGESPROCHEN" -- die ersten beiden Stufen fragen, ob ueberhaupt Interesse besteht. Wer sich selbst meldet, hat das beantwortet. */ ok(st?.stufe === "angesprochen", `auf der Stufe, die zur Selbstmeldung passt („${st?.stufe}“)`); /* GENAU EINMAL. Doppelklick, Zurueck-Taste, zweites Geraet -- eine zweite Karte im Trichter faellt erst auf, wenn man dieselbe Person zweimal anspricht. */ const nochmal = await schicken(`/workspace/api/bewerbung/${idKessiAnfrage}/weg`, kDogi, { weg: "angenommen", grund: "Willkommen." }, "PUT"); const b = alsJson(nochmal); const nachher = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }) .prepare("SELECT COUNT(*) n FROM eintraege WHERE bereich = 'talente'").get().n; ok(nachher === 1, `zweimal annehmen ergibt kein zweites Talent (${nachher})`); ok(b.eintrag_id === a.eintrag_id, "sondern denselben Eintrag"); } { const eigen = alsJson(await holen("/workspace/api/bewerbung", kKessi)); ok(eigen.meine?.zustand === "angenommen" && eigen.offen === false, "und sie sieht auf ihrer Seite, dass sie dabei ist"); } /* ======================================================================= 7. ABLEHNEN — mit Grund, und danach eine Frist ======================================================================= */ melde(""); melde("=== 7. Ablehnen ==="); const idBennoAnfrage = alsJson(await holen("/workspace/api/bewerbungen", kDogi)) .bewerbungen.find((b) => b.nenn_name === "Benno").id; { const r = await schicken(`/workspace/api/bewerbung/${idBennoAnfrage}/weg`, kDogi, { weg: "abgelehnt", grund: "nein" }, "PUT"); ok(r.code === 400, `eine Ablehnung ohne Satz geht nicht (${r.code})`); const r2 = await schicken(`/workspace/api/bewerbung/${idBennoAnfrage}/weg`, kDogi, { weg: "abgelehnt", grund: "Gerade zu wenig Zeit – melde dich im Frühjahr nochmal." }, "PUT"); ok(r2.code === 200, `mit Satz geht es (${r2.code})`); const eigen = alsJson(await holen("/workspace/api/bewerbung", kBenno)); ok(String(eigen.meine?.grund || "").includes("Frühjahr"), "und der Satz steht auf seiner Seite – nicht nur in der Akte"); ok(eigen.sperre?.tage > 0, `danach läuft eine Frist (${eigen.sperre?.tage} von ${eigen.sperre?.gesamt} Tagen)`); const neu = await schicken("/workspace/api/bewerbung", kBenno, { antworten: bogen({ name: "Benno" }) }); ok(neu.code === 429, `und eine neue Anfrage geht in dieser Frist nicht (${neu.code})`); ok(/Tagen/.test(alsJson(neu).fehler || ""), `die Absage sagt, WANN es wieder geht („${String(alsJson(neu).fehler).slice(0, 44)}…“)`); } { /* DIE GEGENPROBE ZUR FRIST: Wer nie abgelehnt wurde, kommt durch. Sonst hiesse „429" nur, dass gerade niemand etwas schicken kann. */ const r = await schicken("/workspace/api/bewerbung", kCora, { antworten: bogen({ name: "Cora" }) }); ok(r.code === 201, `wer nie abgelehnt wurde, kann schicken (${r.code})`); } /* ======================================================================= 8. DIE WEGE DORTHIN ======================================================================= */ melde(""); melde("=== 8. Wie man hinkommt ==="); { /* Der Knopf auf dem Brett „Mitmachen" -- vom Server, nicht aus einer Datei, die jeder herunterladen kann. */ const a = alsJson(await holen("/workspace/api/bereich/mitmachen", kCora)); ok(a.weiter?.ziel === "bewerben.html", "auf „Mitmachen“ steht für die Community der Weg zur Anfrage"); const b = alsJson(await holen("/workspace/api/bereich/mitmachen", kModi)); ok(!b.weiter, "ein Modi sieht ihn nicht – ein Knopf, den der Server ablehnt, wäre schlimmer als keiner"); const c = alsJson(await holen("/workspace/api/bereich/regeln", kCora)); ok(!c.weiter, "und auf den anderen Brettern steht er auch nicht"); } { const a = alsJson(await holen("/workspace/api/bewerbungen-zahl", kDogi)); ok(a.offen === 1 && a.neu === 1, `die Talente-Seite bekommt nur die Zahl (offen ${a.offen}, neu ${a.neu})`); const r = await holen("/workspace/api/bewerbungen-zahl", kModi); ok(r.code === 404, `und auch die nur die beiden (${r.code})`); } { /* DIE SEITEN SELBST. Sie haben keine Kachel -- also muss die Adressregel sie kennen, sonst landet man auf der Startseite. */ for (const [name, k, seite, erwartet] of [ ["die Community", kKessi, "bewerben.html", 200], ["ein Modi", kModi, "bewerben.html", 302], ["DogFather", kDogi, "bewerbungen.html", 200], ["die rechte Hand", kHand, "bewerbungen.html", 200], ["ein Modi", kModi, "bewerbungen.html", 302], ["die Community", kKessi, "bewerbungen.html", 302], ]) { const r = await holen("/workspace/" + seite, k); ok(r.code === erwartet, `${name} → ${seite}: ${r.code}`); } /* AUCH AUF DER AGENTURADRESSE -- DogFather arbeitet auf beiden. */ const r = await roh("/workspace/bewerbungen.html", HAUS, { Cookie: kDogi.keks }); ok(r.code === 200 || r.code === 302, `auf workspace. antwortet die Seite ebenfalls (${r.code})`); } /* ======================================================================= 9. DIE OBERFLÄCHE ======================================================================= */ melde(""); /* ======================================================================= 8b. WER SICH KUEMMERT (17.09.2026) Filipe wollte die Arbeit mit der rechten Hand teilen. Gemessen: Es gab dafuer nichts -- beide sehen jede Anfrage, niemand ist benannt. Bei einer Anfrage ist das teurer als anderswo: Dort wartet ein Mensch auf eine Antwort. DREI DINGE WERDEN GEPRUEFT, und das dritte ist das eigentliche: 1. Uebernehmen und abgeben mit demselben Knopf. 2. Der Zustaendige steht in der Liste. 3. Eine FREMDE Uebernahme wird abgewiesen -- sonst nimmt einer dem anderen die Anfrage aus der Hand, ohne dass es jemand merkt. Und der Riegel: Wer den Eingang nicht sehen darf, darf sich auch nicht zustaendig erklaeren. Diese Zeile hat beim Schreiben GEFEHLT (der Router setzt `angemeldet` fuer den ganzen Pfad, `nurLeitung` aber je Route) -- gefunden beim Nachsehen der Nachbarzeilen, nicht beim Schreiben. ======================================================================= */ melde(""); melde("=== 8b. Wer sich kuemmert ==="); { let liste = alsJson(await holen("/workspace/api/bewerbungen", kDogi)); const offen = (liste.bewerbungen || []).find((b) => b.zustand === "neu") || (liste.bewerbungen || [])[0]; ok(!!offen, `eine Anfrage zum Uebernehmen (${offen?.nenn_name})`); /* DIE ANZAHL GEHOERT IN DIE BEDINGUNG. Beim ersten Lauf war die Liste leer, und diese Zeile war trotzdem gruen -- `undefined` ist nun einmal kein Zustaendiger. Eine Pruefung, die ohne Daten bestaetigt, bestaetigt nichts. */ ok(!!offen && !offen.zustaendig_id, "um die sich noch niemand kuemmert"); { const r = await schicken(`/workspace/api/bewerbung/${offen.id}/kuemmern`, kDogi, null, "PUT"); ok(r.code === 200 && alsJson(r).zustaendig === true, `uebernehmen geht (${r.code})`); liste = alsJson(await holen("/workspace/api/bewerbungen", kDogi)); const b = (liste.bewerbungen || []).find((x) => x.id === offen.id); ok(b?.zustaendig_name === "Filipe", `und es steht in der Liste (${b?.zustaendig_name})`); ok(!!b?.zustaendig_seit, `mit dem Zeitpunkt (${b?.zustaendig_seit?.slice(0, 10)})`); } /* DER FALL, AUF DEN ES ANKOMMT */ { const r = await schicken(`/workspace/api/bewerbung/${offen.id}/kuemmern`, kHand, null, "PUT"); ok(r.code === 409, `die rechte Hand nimmt sie ihm nicht aus der Hand (${r.code})`); ok((alsJson(r).fehler || "").includes("Filipe"), `und erfaehrt, wer sich kuemmert ("${alsJson(r).fehler}")`); liste = alsJson(await holen("/workspace/api/bewerbungen", kDogi)); const b = (liste.bewerbungen || []).find((x) => x.id === offen.id); ok(b?.zustaendig_name === "Filipe", "der Zustaendige bleibt unveraendert"); } /* Abgeben -- und dann kann der andere */ { await schicken(`/workspace/api/bewerbung/${offen.id}/kuemmern`, kDogi, null, "PUT"); liste = alsJson(await holen("/workspace/api/bewerbungen", kDogi)); let b = (liste.bewerbungen || []).find((x) => x.id === offen.id); ok(!b?.zustaendig_id, "derselbe Knopf gibt wieder ab"); const r = await schicken(`/workspace/api/bewerbung/${offen.id}/kuemmern`, kHand, null, "PUT"); ok(r.code === 200, `jetzt kann die rechte Hand (${r.code})`); liste = alsJson(await holen("/workspace/api/bewerbungen", kDogi)); b = (liste.bewerbungen || []).find((x) => x.id === offen.id); ok(b?.zustaendig_name === "Rieke", `und steht dort (${b?.zustaendig_name})`); /* Zurueck auf Anfang, damit Abschnitt 9 die Karten unveraendert vorfindet -- eine Pruefung, die der naechsten den Zustand verstellt, macht deren Ergebnis unlesbar. */ await schicken(`/workspace/api/bewerbung/${offen.id}/kuemmern`, kHand, null, "PUT"); } /* DER RIEGEL. Wer den Eingang nicht sehen darf, darf sich auch nicht zustaendig erklaeren -- sonst haengt ein Name an einer Anfrage, der dort nichts zu suchen hat, und sie gilt als betreut, waehrend sich niemand kuemmert. */ for (const [wer, keks] of [["ein Modi", kModi], ["ein Zugang aus der Community", kKessi]]) { const r = await schicken(`/workspace/api/bewerbung/${offen.id}/kuemmern`, keks, null, "PUT"); ok(r.code === 404, `${wer} kann sich nicht zustaendig erklaeren (${r.code})`); } { const r = await schicken("/workspace/api/bewerbung/999999/kuemmern", kDogi, null, "PUT"); ok(r.code === 404, `eine erfundene Nummer wird abgewiesen (${r.code})`); } } melde(""); melde("=== 9. Im echten Browser ==="); { let chromium; try { ({ chromium } = await import( "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs")); } catch { chromium = null; } if (!chromium) { unklar("kein Playwright gefunden – die Oberfläche wurde nicht angesehen"); } else { const browser = await chromium.launch(); try { const sicht = await browser.newContext({ viewport: { width: 390, height: 844 } }); const kekse = (k) => k.keks.split("; ").map((c) => { const [name, ...rest] = c.split("="); return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" }; }); /* ---- Der Fragebogen ---- */ await sicht.addCookies(kekse(kCora)); const s1 = await sicht.newPage(); const konsole = []; s1.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); }); await s1.goto(`http://127.0.0.1:${PORT}/workspace/bewerben.html`, { waitUntil: "domcontentloaded" }); await s1.waitForTimeout(900); const f = await s1.evaluate(() => ({ stand: document.getElementById('stand')?.textContent?.trim() || '', bogenDa: !document.getElementById('bogen')?.hidden, felder: document.querySelectorAll('.bw-feld__eingabe').length, pflicht: document.querySelectorAll('.bw-feld__pflicht').length, gruppen: document.querySelectorAll('.bw-gruppe').length, })); /* Cora hat gerade geschickt -- sie sieht den Stand, nicht das Formular. Das ist die richtige Antwort und zugleich der Beweis, dass die Seite den Zustand liest. */ ok(f.bogenDa === false && /liegt vor/.test(f.stand), `wer schon geschickt hat, sieht den Stand statt des Formulars („${f.stand.slice(0, 32)}…“)`); ok(konsole.length === 0, konsole.length ? `Fehler in der Konsole: ${konsole[0]}` : "ohne Fehler in der Konsole"); await s1.close(); await sicht.close(); /* Ein unverbrauchter Zugang, um das Formular selbst zu sehen. */ const idDina = (() => { const dd = new DatabaseSync(process.env.WORKSPACE_DB); const salt = randomBytes(16).toString("hex"); const hash = scryptSync("CODE-GAST-0004", salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); dd.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" + " VALUES (?,?,?,?,?,?,1,?)") .run("Dina", "gast", hash, salt, 32768, createHmac("sha256", schluessel).update("CODE-GAST-0004").digest("hex"), jetzt); const id = dd.prepare("SELECT last_insert_rowid() AS id").get().id; dd.close(); return id; })(); const kDina = await anmelden("gast", "CODE-GAST-0004", CREW); const sicht2 = await browser.newContext({ viewport: { width: 390, height: 844 } }); await sicht2.addCookies(kekse(kDina)); const s2 = await sicht2.newPage(); await s2.goto(`http://127.0.0.1:${PORT}/workspace/bewerben.html`, { waitUntil: "domcontentloaded" }); await s2.waitForTimeout(900); const g = await s2.evaluate(() => ({ felder: document.querySelectorAll('.bw-feld__eingabe').length, pflicht: document.querySelectorAll('.bw-feld__pflicht').length, gruppen: document.querySelectorAll('.bw-gruppe').length, werLiest: document.getElementById('wer-liest')?.textContent || '', })); ok(g.felder === fragen.BEWERBUNG_FRAGEN.length, `der Bogen zeichnet alle ${g.felder} Fragen`); ok(g.pflicht === fragen.PFLICHT.length, `und markiert genau die ${g.pflicht} Pflichtfragen`); ok(g.gruppen === fragen.BEWERBUNG_GRUPPEN.length, `in ${g.gruppen} Abschnitten`); ok(/DogFather/.test(g.werLiest), "über dem Absenden steht, wer es liest"); /* ABSCHICKEN OHNE PFLICHTANTWORT -- die Seite markiert, sie schickt nicht. */ await s2.click('#abschicken'); await s2.waitForTimeout(400); const h = await s2.evaluate(() => ({ markiert: document.querySelectorAll('.bw-feld--fehlt').length, meldung: document.getElementById('fehler')?.textContent || '', })); ok(h.markiert === fragen.PFLICHT.length, `ein leerer Bogen markiert die ${h.markiert} fehlenden Felder`); ok(/fehlen|fehlt/.test(h.meldung), `und sagt es in Worten („${h.meldung.slice(0, 40)}“)`); const zahlNachher = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }) .prepare("SELECT COUNT(*) n FROM bewerbungen WHERE person_id = ?").get(idDina).n; ok(zahlNachher === 0, "und es wurde nichts geschickt"); if (process.env.SCHIRM) { await s2.screenshot({ path: "server/pruef-bewerbung-bogen.png", fullPage: true }); melde(" Bild: server/pruef-bewerbung-bogen.png"); } await s2.close(); await sicht2.close(); /* ---- Der Eingang ---- */ const sicht3 = await browser.newContext({ viewport: { width: 1280, height: 900 } }); await sicht3.addCookies(kekse(kDogi)); const s3 = await sicht3.newPage(); const konsole3 = []; s3.on("console", (m) => { if (m.type() === "error") konsole3.push(m.text()); }); await s3.goto(`http://127.0.0.1:${PORT}/workspace/bewerbungen.html`, { waitUntil: "domcontentloaded" }); await s3.waitForTimeout(900); const e = await s3.evaluate(() => ({ karten: document.querySelectorAll('.bw-karte').length, wege: document.querySelectorAll('.bw-karte .bw-weg').length, zahlen: document.querySelectorAll('#zahlen .bilanz-zahl').length, antworten: document.querySelectorAll('.bw-antwort').length, })); ok(e.karten === 3, `der Eingang zeigt alle drei Anfragen (${e.karten})`); ok(e.wege >= 12, `mit vier Wegen je Karte (${e.wege} Knöpfe)`); ok(e.zahlen === 3, `und drei Zahlen oben (${e.zahlen})`); ok(e.antworten > 10, `die Antworten stehen darin (${e.antworten} Zeilen)`); ok(konsole3.length === 0, konsole3.length ? `Fehler in der Konsole: ${konsole3[0]}` : "ohne Fehler in der Konsole"); if (process.env.SCHIRM) { await s3.screenshot({ path: "server/pruef-bewerbung.png", fullPage: true }); melde(" Bild: server/pruef-bewerbung.png"); } await s3.close(); await sicht3.close(); } finally { await browser.close(); } } } melde(""); melde(`${geprueft} geprüft, ${fehler} Fehler` + (offen ? `, ${offen} konnte(n) nicht nachgesehen werden` : "")); process.exit(fehler ? 1 : 0);