/* ===================================================================== DIE SPENDEN (28.09.2026) Geprueft wird der ganze Weg aus Filipes Ansage: Betragsknoepfe -> PayPal -> melden -> wartet -> bestaetigen -> Karte im Bild --------------------------------------------------------------------- WARUM DIESE PRUEFUNG UEBER DIE HAUSADRESSE SPRICHT Dieselbe Begruendung wie bei der Reaction: Die Community meldet sich ueber den stillen Zugang an, und der ist auf einer Pruefadresse absichtlich abgeschaltet. `fetch` kann die Host-Kopfzeile nicht setzen, deshalb node:http. Aufrufen mit: node server/pruef-spenden.mjs ===================================================================== */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { request } from "node:http"; const HIER = dirname(fileURLToPath(import.meta.url)); const ordner = mkdtempSync(join(tmpdir(), "ws-spend-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); import { notbremse } from "./helfer-notbremse.mjs"; const { eigenerPort } = await import("./helfer-port.mjs"); const PORT = await eigenerPort(import.meta, "die Spenden-Pruefung"); process.env.PORT = `${PORT}`; process.env.SITE_ACCESS_SECRET = "lokaler-test"; process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; const express = (await import("express")).default; const ec = express.response.cookie; express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; await import("./index.js"); notbremse(200_000, "pruef-spenden"); await new Promise((r) => setTimeout(r, 900)); let fehler = 0, geprueft = 0; const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; const melde = (t) => console.log(t); process.on("exit", () => { try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } }); const CREW = "crew.dogfather-universe.com"; const AGENTUR = "workspace.dogfather-universe.com"; /* ---------- Zugaenge anlegen -------------------------------------------- */ const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); const d = new DatabaseSync(process.env.WORKSPACE_DB); const nun = new Date().toISOString(); const kennSchluessel = randomBytes(32).toString("hex"); d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") .run("code_kennung_schluessel", kennSchluessel, nun); /* 200 Tage alt: Der Treff hat Stufen, und wer neu ist, darf weniger. Fuer die Reaction spielt das keine Rolle -- aber ein Zugang von heute waere ein Sonderfall, den niemand im Blick hat. */ const ALT = new Date(Date.now() - 200 * 86400000).toISOString(); function anlegen(name, rolle, code) { const salt = randomBytes(16).toString("hex"); const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n," + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)") .run(name, rolle, hash, salt, 32768, createHmac("sha256", kennSchluessel).update(code).digest("hex"), ALT); return d.prepare("SELECT last_insert_rowid() AS id").get().id; } const ROLLEN = [["admin", "Filipe"], ["hand", "VanVan"], ["linke", "Funny"], ["modi", "Miss"], ["gast", "Nachtfalter"], ["gast2", "Lenny"], ["creator", "Luna"]]; const CODES = {}; const IDS = {}; for (const [i, [rolle, name]] of ROLLEN.entries()) { CODES[rolle] = `CODE-R${String(i).padStart(2, "0")}-000001`; IDS[rolle] = anlegen(name, rolle === "gast2" ? "gast" : rolle, CODES[rolle]); } d.close(); /* ---------- Sprechen mit Hausadresse ------------------------------------ */ function roh(host, pfad, { method = "GET", keks = "", rumpf = null, origin } = {}) { return new Promise((fertig, schief) => { const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf)); const a = request({ host: "127.0.0.1", port: PORT, path: pfad, method, headers: { Host: host, Origin: origin === undefined ? `https://${host}` : origin, ...(keks ? { Cookie: keks } : {}), ...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}), }, }, (antwort) => { const teile = []; antwort.on("data", (s) => teile.push(s)); antwort.on("end", () => { const text = Buffer.concat(teile).toString("utf8"); let j = null; try { j = JSON.parse(text); } catch { /* kein JSON */ } fertig({ code: antwort.statusCode, text, j, kopf: antwort.headers }); }); }); a.on("error", schief); if (daten) a.write(daten); a.end(); }); } async function anmelden(rolle, host) { const a = await roh(host, "/workspace/api/anmelden", { method: "POST", rumpf: { rolle: rolle === "gast2" ? "gast" : rolle, code: CODES[rolle], alter_ok: true }, }); const keks = [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; "); return a.code === 200 && keks ? keks : null; } const k = {}; for (const [rolle] of ROLLEN) k[rolle] = await anmelden(rolle, CREW); /* ======================================================================= 1. DAS RECHNEN MIT GELD ======================================================================= */ melde("=== 1. Rechnen mit Geld ==="); { /* NIE MIT KOMMAZAHLEN. 0.1 + 0.2 ist in jeder Programmiersprache nicht 0.3, und bei Geld faellt das irgendwann jemandem auf -- meistens dem, der zahlt. */ const { mitBetrag, stufeFuer } = await import("./workspace-spenden.js"); ok(mitBetrag("https://paypal.me/probe", 500) === "https://paypal.me/probe/5EUR", `runde Betraege ohne Nachkommastellen (${mitBetrag("https://paypal.me/probe", 500)})`); ok(mitBetrag("https://paypal.me/probe", 750) === "https://paypal.me/probe/7.50EUR", `krumme mit zweien (${mitBetrag("https://paypal.me/probe", 750)})`); ok(mitBetrag("https://paypal.me/probe/", 200) === "https://paypal.me/probe/2EUR", "ein Schraegstrich am Ende macht keinen doppelten"); /* AN EINER FREMDEN ADRESSE WIRD NICHTS ANGEHAENGT. Ein Betrag an einer Seite, die ihn nicht versteht, fuehrt zu einer Seite, auf der etwas anderes steht als auf dem Knopf. */ ok(mitBetrag("https://example.com/spenden", 500) === null, "an einer fremden Adresse wird nichts angehaengt"); ok(mitBetrag("", 500) === null, "und an gar keiner auch nicht"); /* DIE HOECHSTE STUFE, DIE NOCH PASST. */ ok(stufeFuer(50) === null, "unter der kleinsten Stufe gibt es keine"); ok(stufeFuer(100)?.name === "Danke", `1 EUR ergibt ${stufeFuer(100)?.name}`); ok(stufeFuer(499)?.name === "Danke", "4,99 EUR bleibt darunter"); ok(stufeFuer(500)?.name === "Starke Runde", `5 EUR ergibt ${stufeFuer(500)?.name}`); ok(stufeFuer(5000)?.name === "Rudel-Legende", `50 EUR ergibt ${stufeFuer(5000)?.name}`); } /* ======================================================================= 2. DIE AGENTUR KENNT DIE SPENDEN NICHT ======================================================================= */ melde(""); melde("=== 2. Getrennte Haeuser ==="); { const kCreator = await anmelden("creator", AGENTUR); ok(!!kCreator, "eine Creatorin meldet sich im Agenturhaus an"); const a = await roh(AGENTUR, "/workspace/api/spenden", { keks: kCreator }); ok(a.code === 404, `und sieht die Spenden nicht (${a.code})`); /* GEGENPROBE: Im eigenen Haus geht derselbe Weg. Sonst koennte die 404 auch daran liegen, dass es den Weg gar nicht gibt. */ const b = await roh(CREW, "/workspace/api/spenden", { keks: k.gast }); ok(b.code === 200, `im Teamhaus schon (${b.code})`); } /* ======================================================================= 3. DIE BETRAGSKNOEPFE ======================================================================= */ melde(""); melde("=== 3. Die Betragsknoepfe ==="); { const ohne = await roh(CREW, "/workspace/api/spenden", { keks: k.gast }); ok(Array.isArray(ohne.j?.knoepfe) && ohne.j.knoepfe.length === 0, `ohne hinterlegte Adresse gibt es keine Knoepfe (${ohne.j?.knoepfe?.length})`); ok(ohne.j?.paypal === null, "und auch keinen Weg"); /* Jetzt eine eintragen -- ueber den echten Weg, nicht in die Datenbank. Genau diese Pruefungen sollen ja mitlaufen. */ const wege = await roh(CREW, "/workspace/api/unterstuetzung", { keks: k.admin }); const pp = (wege.j?.wege || wege.j?.liste || []).find((w) => w.schluessel === "paypal"); ok(!!pp, "der PayPal-Weg steht in der Unterstuetzen-Liste"); await roh(CREW, `/workspace/api/unterstuetzung/${pp.id}`, { method: "PUT", keks: k.admin, rumpf: { ziel: "https://paypal.me/probehase", knopf: "Dogi was ausgeben", sichtbar: true }, }); const mit = await roh(CREW, "/workspace/api/spenden", { keks: k.gast }); ok(mit.j?.knoepfe?.length === 4, `jetzt gibt es vier (${mit.j?.knoepfe?.length})`); ok(mit.j?.knoepfe?.[0]?.ziel === "https://paypal.me/probehase/2EUR", `mit dem Betrag in der Adresse (${mit.j?.knoepfe?.[0]?.ziel})`); ok(mit.j?.paypal?.knopf === "Dogi was ausgeben", "und die Aufschrift kommt von der Unterstuetzen-Seite"); /* WER NICHT DIE LEITUNG IST, SIEHT KEINE FREMDEN BETRAEGE. */ ok(mit.j?.offen === null && mit.j?.letzte === null, "ein Zuschauer bekommt die Liste fremder Spenden NICHT"); const alsChef = await roh(CREW, "/workspace/api/spenden", { keks: k.admin }); ok(Array.isArray(alsChef.j?.offen), "die Leitung schon"); } /* ======================================================================= 4. MELDEN — EINE BEHAUPTUNG, KEINE TATSACHE ======================================================================= */ melde(""); melde("=== 4. Melden ==="); { const mist = await roh(CREW, "/workspace/api/spenden/melden", { method: "POST", keks: k.gast, rumpf: { betrag_cent: 0 }, }); ok(mist.code === 400 && mist.j?.fehler === "betrag_unklar", `null Euro ist kein Betrag (${mist.code}/${mist.j?.fehler})`); const zuViel = await roh(CREW, "/workspace/api/spenden/melden", { method: "POST", keks: k.gast, rumpf: { betrag_cent: 99999999 }, }); ok(zuViel.code === 400, `und eine Million auch nicht (${zuViel.code})`); const a = await roh(CREW, "/workspace/api/spenden/melden", { method: "POST", keks: k.gast, rumpf: { betrag_cent: 1500, gruss: "Danke fuer alles" }, }); ok(a.code === 201 && a.j?.wartet === true, `die Meldung wird angenommen -- und sie WARTET (${a.code})`); /* EINE JE MINUTE. Nicht gegen Betrug -- die Leitung bestaetigt ja -- sondern gegen den Doppelklick und gegen eine Liste, die man nicht mehr durchsieht. */ const zweite = await roh(CREW, "/workspace/api/spenden/melden", { method: "POST", keks: k.gast, rumpf: { betrag_cent: 1500 }, }); ok(zweite.code === 429, `zweimal in derselben Minute nicht (${zweite.code})`); const beiDerLeitung = await roh(CREW, "/workspace/api/spenden", { keks: k.admin }); const wartet = (beiDerLeitung.j?.offen || [])[0]; ok(wartet?.betrag_cent === 1500 && wartet?.stand === "offen", `sie steht im Pult und wartet (${wartet?.betrag_cent} Cent, ${wartet?.stand})`); ok(wartet?.name === "Nachtfalter", `mit dem Namen der Person (${wartet?.name})`); ok(wartet?.stufe?.name === "Starke Runde", `und der Stufe, die zum Betrag passt (${wartet?.stufe?.name})`); const beiIhr = await roh(CREW, "/workspace/api/spenden", { keks: k.gast }); ok(beiIhr.j?.offen === null, "sie selbst bekommt keine Liste"); } /* ======================================================================= 5. NUR DIE LEITUNG ENTSCHEIDET ======================================================================= */ melde(""); melde("=== 5. Wer ueber Geld entscheidet ==="); { const offen = await roh(CREW, "/workspace/api/spenden", { keks: k.admin }); const id = offen.j.offen[0].id; for (const rolle of ["linke", "modi", "gast"]) { const a = await roh(CREW, `/workspace/api/spenden/${id}/bestaetigen`, { method: "POST", keks: k[rolle], }); ok(a.code === 403 && a.j?.fehler === "nur_leitung", `${rolle}: bestaetigt nichts (${a.code}/${a.j?.fehler})`); const b = await roh(CREW, "/workspace/api/spenden", { method: "POST", keks: k[rolle], rumpf: { betrag_cent: 100, name: "X" }, }); ok(b.code === 403, `${rolle}: traegt auch nichts ein (${b.code})`); } /* DER BETRAG DARF BEIM BESTAETIGEN BERICHTIGT WERDEN. Die Leitung hat die PayPal-Meldung vor sich und weiss es besser als die Behauptung. */ const ja = await roh(CREW, `/workspace/api/spenden/${id}/bestaetigen`, { method: "POST", keks: k.hand, rumpf: { betrag_cent: 1200 }, }); ok(ja.code === 200 && ja.j?.stand === "bestaetigt" && ja.j?.betrag_cent === 1200, `die rechte Hand bestaetigt und berichtigt (${ja.j?.betrag_cent} Cent)`); /* ZWEIMAL BESTAETIGEN IST KEIN FEHLER -- aber auch kein Grund, die Karte noch einmal zu zeigen. Zwei Leute tippen gleichzeitig. */ const nochmal = await roh(CREW, `/workspace/api/spenden/${id}/bestaetigen`, { method: "POST", keks: k.admin, }); ok(nochmal.code === 200 && nochmal.j?.betrag_cent === 1200, "ein zweites Mal aendert nichts und ist kein Fehler"); const danach = await roh(CREW, "/workspace/api/spenden", { keks: k.admin }); ok((danach.j?.offen || []).length === 0, "danach wartet nichts mehr"); ok((danach.j?.letzte || []).some((z) => z.id === id), "und sie steht bei den gezeigten"); } /* ======================================================================= 6. VON HAND EINTRAGEN — DER WEG, DER IMMER GEHT ======================================================================= */ melde(""); melde("=== 6. Von Hand eintragen ==="); { const ohneName = await roh(CREW, "/workspace/api/spenden", { method: "POST", keks: k.admin, rumpf: { betrag_cent: 500 }, }); ok(ohneName.code === 400 && ohneName.j?.fehler === "name_fehlt", `ohne Namen keine Karte -- er steht ja darauf (${ohneName.code})`); const a = await roh(CREW, "/workspace/api/spenden", { method: "POST", keks: k.admin, rumpf: { betrag_cent: 5000, name: "Miss", gruss: "Weiter so!" }, }); ok(a.code === 201 && a.j?.stand === "bestaetigt", `eingetragen gilt sofort (${a.code}/${a.j?.stand})`); ok(a.j?.stufe?.name === "Rudel-Legende" && a.j?.stufe?.dauer_ms === 11000, `mit der hoechsten Stufe und ihrer Dauer (${a.j?.stufe?.name}, ${a.j?.stufe?.dauer_ms} ms)`); const wieder = await roh(CREW, `/workspace/api/spenden/${a.j.id}/nochmal`, { method: "POST", keks: k.admin, }); ok(wieder.code === 200, `und laesst sich noch einmal zeigen (${wieder.code})`); const ablehnen = await roh(CREW, `/workspace/api/spenden/${a.j.id}/ablehnen`, { method: "POST", keks: k.admin, }); ok(ablehnen.code === 409 && ablehnen.j?.fehler === "nicht_offen", `was schon lief, laesst sich nicht mehr ablehnen (${ablehnen.code})`); } /* ======================================================================= 7. DIESELBE ZAHLUNG NUR EINMAL ======================================================================= */ melde(""); melde("=== 7. Keine Spende zweimal ==="); { /* Ein Webhook wird bei einer Stoerung wiederholt. Ohne diese Sperre stuende dieselbe Spende zweimal im Bild -- und zwar ausgerechnet dann, wenn das Netz hakt. */ const { DatabaseSync: DB2 } = await import("node:sqlite"); const d2 = new DB2(process.env.WORKSPACE_DB); const nun = new Date().toISOString(); const rein = (nummer) => d2.prepare( "INSERT INTO spenden (betrag_cent, waehrung, name, herkunft, stand, fremd_id, erstellt)" + " VALUES (?,?,?,?,?,?,?)").run(300, "EUR", "Webhook", "paypal", "bestaetigt", nummer, nun); rein("PAY-1"); let zweimal = false; try { rein("PAY-1"); zweimal = true; } catch { /* genau so soll es sein */ } ok(!zweimal, "dieselbe Zahlungsnummer laesst sich kein zweites Mal eintragen"); /* GEGENPROBE: Ohne Nummer duerfen beliebig viele nebeneinander stehen -- sonst koennte man nur eine Spende von Hand eintragen. */ let ohneNummer = 0; for (let i = 0; i < 3; i++) { d2.prepare("INSERT INTO spenden (betrag_cent, waehrung, name, herkunft, stand, erstellt)" + " VALUES (?,?,?,?,?,?)").run(100, "EUR", "Ohne", "hand", "bestaetigt", nun); ohneNummer++; } ok(ohneNummer === 3, "Gegenprobe: ohne Nummer stehen beliebig viele nebeneinander"); d2.close(); } melde(""); melde(`${geprueft} Pruefungen, ${fehler} Fehler`); melde(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG"); process.exit(fehler === 0 ? 0 : 1);