Filipe, mit dem Bildschirmfoto des Bretts "Mitmachen": "ich will dass die leute sich da anonym bei mir melden koennen ... wie ein fragebogen wieso die person modi werden will warum sie sollte, positiv negative sachen ... und dan soll ich annehmen ablehnen, in warteschlange stellen oder sofort kommunizieren koennen. und wenn ich annehme dan soll diese person automatisch zu talente kategorie weiter geleitet werden." Seine zwei Entscheidungen auf Rueckfrage: "Vertraulich, aber nicht namenlos" und "Du und die rechte Hand". WAS ES GIBT 15 Fragen in 6 Gruppen, 9 davon Pflicht. Vier davon standen nicht auf seinem Zettel und kommen aus der Recherche zu Moderator-Bewerbungen: Alter, frühere Sperren, "ein Freund bricht die Regeln - was machst du?" und "was kannst du nicht so gut?". Die letzte ist die aussagekraeftigste: Wer darauf "nichts" schreibt, hat sich gerade selbst beantwortet. Vier Wege: Reden - Warteschlange - Annehmen - Ablehnen. "Reden" steht vorne, weil die haeufigste richtige Antwort auf eine Bewerbung eine Rueckfrage ist und keine Entscheidung; stuende "Annehmen" oben, wuerde es geklickt. "Sofort kommunizieren" ist ein Satz, den sie liest. Kein Chat: Ein Zugang aus der Community steht ausdruecklich in keiner Chatliste (AUSSEN_ROLLEN). Der Satz ist damit der einzige Weg, dieser Person etwas zu sagen -- deshalb ist er bei "Reden" und "Ablehnen" Pflicht, und WELCHE Wege ihn verlangen, steht im Katalog und nicht zweimal. Nach "Annehmen" entsteht ein Talent auf der Stufe "Angesprochen". Die ersten beiden Stufen fragen, ob ueberhaupt Interesse besteht; wer sich selbst meldet, hat das beantwortet. WAS DABEI HERAUSKAM, DAS NICHT GESUCHT WAR aufgaben.js entschied mit `p.rolle === 'modi' || p.rolle === 'hand'`, wer eine Katalogaufgabe bekommen kann. Diese Datei ist ohne Anmeldung aus dem offenen Netz lesbar -- damit stand der verborgene Zugang darin nachzulesen. Jetzt entscheidet es der Server und schickt ein Ja/Nein. Gefunden von der Wortleck-Pruefung, nicht vom Auge. pruef-struktur meldete teilen.html seit heute frueh als "von nirgendwo verlinkt" -- und lag falsch: Auf sie zeigt `share_target.action` im Manifest. Die Manifeste sind jetzt Verweisquelle, nicht die eine Seite eine Ausnahme. Und im Bildschirmfoto stand der Schriftzug der Buehne mitten im Text einer Anfrage: `color-mix(..., transparent)` mischt mit DURCHSICHTIG. Deckend ist `#ffffff 6%`, so wie es .t-karte macht. Dieselbe Stelle hat mich heute schon einmal erwischt. GEPRUEFT pruef-bewerbung 77, davon 14 im Browser. Vier Gegenproben, jede trifft genau ihr Ziel: Bruecke ohne "genau einmal" -> 2 rot, Vertraulichkeit weg -> 5 rot, Nachricht-Pflicht weg -> 2 rot, Knopf ohne Rollenfrage -> 1 rot. Dazu unveraendert gruen: rollen 315, modi-verborgen 80, modi-katalog 49, entwicklung 43, treff, bereiche-lesend, haus-seiten, alle-wege, rechtetafel, css-klassen, struktur, deutsche-texte, start-ansicht, sicht, aufgabenbrett, womit, kanaele.
581 lines
28 KiB
JavaScript
581 lines
28 KiB
JavaScript
/* =====================================================================
|
||
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 { portMussFreiSein } from "./helfer-port.mjs";
|
||
|
||
const PORT = await portMussFreiSein(4504, "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("");
|
||
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);
|