Files
dogfather-universe/server/pruef-spenden.mjs
T
DogFatherGitandClaude Opus 5 d2f02ba84e Spenden: Stufen gestaltbar, Bild hochladen, Größe je Stufe
Filipes Wunsch: „mach paar fertige und so dass ich auch hochladen
kann. auch so dass ich das anders gestalten kann oder die größe
verändern kann. ... auch spezielle sachen bei speziellen spenden."

WAS ES SCHON GAB, WAS FEHLTE
Drei Stufen ab Werk, fünf gezeichnete Zeichen, Farbe und Dauer je
Stufe — und sogar schon ein Feld für ein eigenes Bild. Es fehlte der
Weg, das alles zu ÄNDERN: Um eine Stufe umzubenennen, hätte jemand in
die Datenbank greifen müssen.

DIE GRÖSSE IST DAS „SPEZIELLE BEI SPEZIELLEN SPENDEN"
Je Stufe, nicht einmal für alle: Eine Rudel-Legende darf größer
dastehen als ein Danke. Eine einzige Größe für alle wäre wieder eine
Preisliste. Umgesetzt als EINE Schriftgröße, alles darin in `em` —
nicht `transform: scale()`, denn die Karte kommt schon mit
`translateX()` herein, und zwei `transform` an derselben Stelle
schließen einander aus; außerdem wird Text beim Skalieren matschig.
Nur nach oben (1 bis 2,5), und das ist eine ehrliche Grenze: Das
kleinste Wort auf der Karte steht bei 11,52 px, die Hausgrenze ist
11,5. Ein Faktor von 0,8 machte daraus 9,2 px. Kleiner geht an der
richtigen Stelle — die OBS-Tafel hat ihren eigenen Regler in der
Adresse, dort ist es eine Videoeinblendung und kein Text zum Lesen.

AUSPROBIEREN, OHNE EINE SPENDE ANZULEGEN
Der naheliegende Weg wäre gewesen: eine Spende eintragen und danach
löschen. Das ist verboten — in ein laufendes System kommen keine
Testdaten, und „gelöscht" heißt bei Geld nicht „war nie da". Die
Probe schreibt deshalb NICHTS und geht nur an den, der drückt; eine
Probe im ganzen Saal wäre eine Spende, die es nicht gab.

DREI FEHLER, DIE DIE PRÜFUNG GEFUNDEN HAT

1. `protokolliere()` wurde an 17 Stellen falsch herum gerufen —
   `(personId, aktion, detail, ip)` statt `(aktion, {…})`. JavaScript
   beschwert sich nicht: Das zweite Argument war ein Text, und einen
   Text zu zerlegen ergibt lauter `undefined`. Auf dem echten Server
   nachgemessen: 39 Protokollzeilen mit Aktionen wie „16.0", alle
   ohne Person, ohne Detail, ohne IP. Betroffen waren Material,
   Hilfe, Bühne, Reaction und Spenden — also jede Änderung an
   Dogi-Media und jede Maßnahme im Live-Chat, ausgerechnet das,
   wofür es ein Protokoll gibt. Alle 17 berichtigt, und
   pruef-struktur wacht jetzt darüber (mit Gegenprobe).

2. Beim Speichern der Leiter bekam jede Stufe eine NEUE Kennung
   (DELETE + INSERT). Ein Bild-Hochladen gegen die eben noch gültige
   Kennung antwortete mit 404 — im Alltag trifft das jeden, der einen
   zweiten Bildschirm offen hat. Jetzt werden vorhandene Zeilen
   geändert statt ersetzt; das Bild bleibt von selbst daran hängen.

3. `ab_cent` ist eindeutig. Zwei Stufen ihre Beträge tauschen zu
   lassen scheiterte mit „UNIQUE constraint failed", obwohl das
   Ergebnis in Ordnung gewesen wäre: Beim Umschreiben stößt die
   Leiter auf sich selbst. Jetzt in drei Schritten — löschen,
   geparkte Zwischenwerte, endgültige Werte —, und das ist nach
   außen nie sichtbar.

UND DREI, DIE IN MEINER MESSUNG STECKTEN
Die Messung hat eine noch laufende Karte aus dem vorigen Abschnitt
erwischt und daraus drei Fehler gemeldet, die keine waren —
darunter „die Probe läuft im ganzen Saal". Sie zählte außerdem die
versteckten Dateifelder als zu kleine Tippziele. Jetzt räumt sie
vorher auf, wartet auf die Karte MIT DER ERWARTETEN GRÖSSE (die
Karten laufen in einer Schlange — einen Knoten zu entfernen beendet
sie nicht) und lässt die Einblendung zur Ruhe kommen, bevor sie misst.
Ein Bildschirmfoto aus der Einblendphase sah aus, als stünde die
Karte links heraus; nachgemessen: links 18 px, ganz im Bild.

GEMESSEN, NICHT ANGENOMMEN
Karte bei Größe 1: Schrift 16 px, Betrag 25,92 px. Bei Größe 2:
32 px und 51,84 px — Faktor exakt 2,00. Hätte eine einzige Regel noch
in `rem` gestanden, wäre die Karte ungleichmäßig gewachsen, und auf
einem Bild sieht beides nur „größer" aus.

AUCH DAS BILD IST GEPRÜFT
Es liegt am Bühnen-Router und nicht am Spenden-Router: Die
Spendentafel in OBS hat keine Anmeldung, und ein 401 als JSON in
einem `<img>` ergibt ein kaputtes Bild ohne jeden Hinweis. Ohne
Schlüssel, aber mit 128 Bit zufälligem Dateinamen — dieselbe
Größenordnung wie der Bühnenschlüssel, und es ist ein Zierbild, das
ohnehin im Stream steht. Kein Ausbruch aus dem Ordner (vier Wege
geprüft, gemessen wird die Wirkung und nicht der Statuscode).

NACHGETRAGEN AUS BLOCK 4
`reaktion_meldungen` fehlte im Löschkonzept — eine bestehende Prüfung
hat es gefunden. 30 Tage nach dem Erledigen; meistens sind sie
ohnehin früher weg, weil der Live-Chat beim Beenden gelöscht wird und
die Meldungen daran hängen. Wer meldet, muss sich darauf verlassen
können, dass daraus keine dauerhafte Liste wird.

GEPRUEFT
pruef-spenden: 95 Prüfungen (vorher 46), 0 Fehler.
pruef-reaktion 260, pruef-buehne 36, pruef-aufbewahrung 45,
pruef-struktur, pruef-meldungen, pruef-css-klassen, pruef-tippziele,
pruef-deutsche-texte, pruef-auskunft alle grün.
mess-reaktion und mess-buehne: Rückgabewert 0, kein ACHTUNG.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-28 10:14:26 +02:00

623 lines
30 KiB
JavaScript

/* =====================================================================
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, readFileSync } 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 ------------------------------------ */
/** `roher` schickt Bytes statt JSON.
*
* Gebraucht fuer das Hochladen eines Bildes: Die Route nimmt
* `express.raw()` entgegen, und ein als JSON verpacktes Bild waere
* etwas anderes als das, was ein Browser schickt -- die Pruefung
* wuerde dann einen Weg pruefen, den es gar nicht gibt. */
function roh(host, pfad, { method = "GET", keks = "", rumpf = null, roher = null, origin } = {}) {
return new Promise((fertig, schief) => {
const daten = roher !== null ? roher
: (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": roher !== null ? "application/octet-stream" : "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();
}
/* =======================================================================
8. DIE GESTALTUNG
Filipe: „mach paar fertige und so dass ich auch hochladen kann.
auch so dass ich das anders gestalten kann oder die groesse
veraendern kann. ... auch spezielle sachen bei speziellen spenden."
======================================================================= */
melde("");
melde("=== 8. Die Gestaltung der Karten ===");
{
/* Wie viele Spenden es gibt -- gebraucht fuer den Nachweis, dass
die Probe KEINE anlegt. Gezaehlt wird in der Datenbank und nicht
in einer Liste der Oberflaeche: Eine Liste zeigt nur, was sie
zeigen soll. */
const { DatabaseSync: DBZ } = await import("node:sqlite");
const dz = new DBZ(process.env.WORKSPACE_DB, { readOnly: true });
const zaehle = () => dz.prepare("SELECT COUNT(*) AS n FROM spenden").get().n;
const { VORLAGEN, STUFEN_MAX, GROESSE_MIN, GROESSE_MAX, DAUER_MS_MIN, DAUER_MS_MAX }
= await import("./spenden-tabellen.js");
/* ---- Die Vorlagen sind an BEIDEN Enden dieselben ----
Ein Name, den nur eine Seite kennt, waere eine Vorlage, die sich
auswaehlen laesst und dann als Herz erscheint. */
{
const karte = readFileSync(
join(HIER, "..", "workspace", "assets", "js", "spendenkarte.js"), "utf8");
const block = karte.match(/const VORLAGEN = \{([\s\S]*?)\n \};/);
const inKarte = [...(block ? block[1] : "").matchAll(/^\s{4}([a-z]+):/gm)].map((m) => m[1]);
ok(inKarte.length >= 5, `die Karte kennt ${inKarte.length} Zeichnungen`);
const fehlt = VORLAGEN.filter((v) => !inKarte.includes(v));
const zuviel = inKarte.filter((v) => !VORLAGEN.includes(v));
ok(fehlt.length === 0 && zuviel.length === 0,
(fehlt.length || zuviel.length)
? `UNGLEICH: fehlt ${fehlt.join(",")} / zuviel ${zuviel.join(",")}`
: `Server und Karte kennen dieselben ${VORLAGEN.length} Zeichnungen`);
ok(!VORLAGEN.includes("wolke"), "Gegenprobe: eine erfundene Zeichnung steht in keiner Liste");
}
/* ---- Wer gestalten darf ---- */
const guteLeiter = {
stufen: [
{ ab_cent: 100, name: "Danke", vorlage: "herz", farbe: "#8ec9ff", dauer_ms: 6000, groesse: 1 },
{ ab_cent: 2000, name: "Legende", vorlage: "krone", farbe: "#e0a13a", dauer_ms: 11000, groesse: 1.45 },
],
};
for (const rolle of ["linke", "modi", "gast"]) {
const a = await roh(CREW, "/workspace/api/spenden/stufen", {
method: "PUT", keks: k[rolle], rumpf: guteLeiter,
});
ok(a.code === 403, `${rolle}: gestaltet nicht (${a.code})`);
}
/* ---- Was eine Leiter sein muss ---- */
const schlecht = [
[{ stufen: [] }, 400, "keine_stufe", "eine leere Leiter"],
[{ stufen: null }, 400, "stufen_fehlen", "gar keine Leiter"],
[{ stufen: Array.from({ length: STUFEN_MAX + 1 }, (x, i) => ({
ab_cent: (i + 1) * 100, name: `S${i}`, vorlage: "herz",
farbe: "#8ec9ff", dauer_ms: 5000, groesse: 1 })) }, 400, "zu_viele_stufen",
`${STUFEN_MAX + 1} Stufen`],
[{ stufen: [{ ab_cent: 0, name: "X", vorlage: "herz", farbe: "#8ec9ff", dauer_ms: 5000, groesse: 1 }] },
400, "stufe_betrag_unklar", "null Cent"],
[{ stufen: [{ ab_cent: 100, name: " ", vorlage: "herz", farbe: "#8ec9ff", dauer_ms: 5000, groesse: 1 }] },
400, "stufe_name_fehlt", "ein leerer Name"],
[{ stufen: [{ ab_cent: 100, name: "X", vorlage: "wolke", farbe: "#8ec9ff", dauer_ms: 5000, groesse: 1 }] },
400, "vorlage_unbekannt", "eine erfundene Zeichnung"],
/* DIE FARBE IST DIE HEIKELSTE STELLE: Sie landet als CSS-Wert in
der Karte. Ein freier Text waere dort eine Einschleusung. */
[{ stufen: [{ ab_cent: 100, name: "X", vorlage: "herz",
farbe: "red; background:url(javascript:1)", dauer_ms: 5000, groesse: 1 }] },
400, "farbe_unklar", "eine eingeschleuste Farbe"],
[{ stufen: [{ ab_cent: 100, name: "X", vorlage: "herz", farbe: "blau", dauer_ms: 5000, groesse: 1 }] },
400, "farbe_unklar", "ein Farbwort"],
[{ stufen: [{ ab_cent: 100, name: "X", vorlage: "herz", farbe: "#8ec9ff",
dauer_ms: DAUER_MS_MAX + 1000, groesse: 1 }] }, 400, "dauer_unklar", "zu lange"],
[{ stufen: [{ ab_cent: 100, name: "X", vorlage: "herz", farbe: "#8ec9ff",
dauer_ms: DAUER_MS_MIN - 500, groesse: 1 }] }, 400, "dauer_unklar", "zu kurz"],
[{ stufen: [{ ab_cent: 100, name: "X", vorlage: "herz", farbe: "#8ec9ff",
dauer_ms: 5000, groesse: GROESSE_MAX + 1 }] }, 400, "groesse_unklar", "zu gross"],
[{ stufen: [{ ab_cent: 100, name: "X", vorlage: "herz", farbe: "#8ec9ff",
dauer_ms: 5000, groesse: GROESSE_MIN - 0.5 }] }, 400, "groesse_unklar", "zu klein"],
[{ stufen: [
{ ab_cent: 500, name: "A", vorlage: "herz", farbe: "#8ec9ff", dauer_ms: 5000, groesse: 1 },
{ ab_cent: 500, name: "B", vorlage: "welle", farbe: "#a98bff", dauer_ms: 5000, groesse: 1 },
] }, 409, "stufe_doppelt", "zwei Stufen beim selben Betrag"],
];
for (const [rumpf, code, wort, was] of schlecht) {
const a = await roh(CREW, "/workspace/api/spenden/stufen", {
method: "PUT", keks: k.admin, rumpf,
});
ok(a.code === code && a.j?.fehler === wort,
`${was} wird abgelehnt (${a.code}/${a.j?.fehler})`);
}
/* NACH ALL DEN ABLEHNUNGEN MUSS DIE ALTE LEITER NOCH STEHEN.
Eine Pruefung, die nur den Fehlercode ansieht, uebersieht genau
das: Ein Abbruch mitten im Schreiben haette die Leiter halb
geleert zurueckgelassen -- und dann faellt in der naechsten
Sendung eine Spende in gar keine Stufe. */
const heil = await roh(CREW, "/workspace/api/spenden", { keks: k.admin });
ok((heil.j?.stufen || []).length >= 3,
`die alte Leiter steht unveraendert (${heil.j?.stufen?.length} Stufen)`);
/* ---- Und was geht ---- */
const gut = await roh(CREW, "/workspace/api/spenden/stufen", {
method: "PUT", keks: k.admin, rumpf: guteLeiter,
});
ok(gut.code === 200 && gut.j?.stufen?.length === 2,
`zwei Stufen werden gespeichert (${gut.code})`);
ok(gut.j?.stufen?.[0]?.ab_cent === 100 && gut.j?.stufen?.[1]?.ab_cent === 2000,
"und stehen aufsteigend");
ok(gut.j?.stufen?.[1]?.groesse === 1.45,
`die Groesse kommt an (${gut.j?.stufen?.[1]?.groesse})`);
/* ---- UMSORTIEREN MUSS GEHEN ----
`ab_cent` ist eindeutig. Wer zwei vorhandene Stufen ihre Betraege
TAUSCHEN laesst, stoesst beim Umschreiben auf sich selbst --
obwohl das Ergebnis voellig in Ordnung waere. Gemessen ist genau
das passiert („UNIQUE constraint failed"), und der ganze Vorgang
brach mit 503 ab. */
{
const jetzige = gut.j.stufen;
const getauscht = await roh(CREW, "/workspace/api/spenden/stufen", {
method: "PUT", keks: k.admin,
rumpf: { stufen: [
{ ...jetzige[0], ab_cent: 2000 },
{ ...jetzige[1], ab_cent: 100 },
] },
});
ok(getauscht.code === 200, `zwei Stufen tauschen ihre Betraege (${getauscht.code})`);
ok(getauscht.j?.stufen?.[0]?.name === jetzige[1].name,
`und die Leiter steht richtig herum (${getauscht.j?.stufen?.[0]?.name})`);
/* Und wieder zurueck -- der Rest des Abschnitts rechnet damit. */
await roh(CREW, "/workspace/api/spenden/stufen", {
method: "PUT", keks: k.admin, rumpf: { stufen: jetzige },
});
}
/* SPEZIELLE SACHEN BEI SPEZIELLEN SPENDEN: Die Stufe entscheidet
sich am Betrag, und zwar immer die hoechste passende. */
const { stufeFuer } = await import("./workspace-spenden.js");
ok(stufeFuer(100)?.name === "Danke", "1 EUR -> Danke");
ok(stufeFuer(1999)?.name === "Danke", "19,99 EUR -> immer noch Danke");
ok(stufeFuer(2000)?.name === "Legende", "20 EUR -> Legende");
ok(stufeFuer(50000)?.name === "Legende", "500 EUR -> Legende");
ok(stufeFuer(99) === null, "unter der ersten Stufe gibt es keine");
/* ---- Die Probe legt KEINE Spende an ----
Der naheliegende Weg waere gewesen, eine Spende einzutragen und
danach zu loeschen. In ein laufendes System kommen keine
Testdaten -- und „geloescht" heisst bei Geld nicht „war nie da". */
const vorher = zaehle();
const probe = await roh(CREW, "/workspace/api/spenden/probe", {
method: "POST", keks: k.admin, rumpf: { betrag_cent: 2000 },
});
ok(probe.code === 200 && probe.j?.karte?.stufe?.name === "Legende",
`die Probe zeigt die richtige Stufe (${probe.j?.karte?.stufe?.name})`);
ok(probe.j?.karte?.stand === "probe", "und ist als Probe gekennzeichnet");
ok(zaehle() === vorher,
`und es entsteht KEINE Spende daraus (${vorher} -> ${zaehle()})`);
const probeNein = await roh(CREW, "/workspace/api/spenden/probe", {
method: "POST", keks: k.modi, rumpf: { betrag_cent: 2000 },
});
ok(probeNein.code === 403, `ein Modi probiert nicht (${probeNein.code})`);
/* ---- Das Bild ---- */
const id = gut.j.stufen[1].id;
const keinBild = await roh(CREW, `/workspace/api/spenden/stufe/${id}/bild`, {
method: "POST", keks: k.admin, roher: Buffer.from("das ist kein Bild"),
});
ok(keinBild.code === 400 && keinBild.j?.fehler === "kein_bild",
`Text statt Bild wird abgelehnt (${keinBild.code})`);
/* EIN ECHTES PNG -- der kleinste gueltige, den es gibt. Ein
erfundener Kopf wuerde nur beweisen, dass die Pruefung dieselbe
Annahme macht wie der Code. */
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64");
const hoch = await roh(CREW, `/workspace/api/spenden/stufe/${id}/bild`, {
method: "POST", keks: k.admin, roher: png,
});
ok(hoch.code === 200 && /^\/workspace\/api\/buehne\/spendenbild\/[a-f0-9]{32}\.png$/
.test(hoch.j?.bild || ""), `ein PNG wird angenommen (${hoch.j?.bild})`);
/* DAS BILD MUSS OHNE ANMELDUNG ZU HOLEN SEIN. Die Spendentafel in
OBS hat keine Sitzung -- ein 401 als JSON in einem <img> ergibt
ein kaputtes Bild ohne jeden Hinweis. */
const ohne = await roh(CREW, hoch.j.bild, {});
ok(ohne.code === 200, `und ohne Anmeldung abrufbar (${ohne.code})`);
ok(String(ohne.kopf["content-type"]).startsWith("image/png"),
`als Bild und nicht als etwas anderes (${ohne.kopf["content-type"]})`);
ok(ohne.kopf["x-content-type-options"] === "nosniff",
"der Browser darf den Typ nicht selbst raten");
/* KEIN AUSBRUCH AUS DEM ORDNER.
GEPRUEFT WIRD, WAS HERAUSKOMMT -- nicht, welche Zahl dabeisteht.
„../../workspace.db" wird vom Server zu „/workspace/api/workspace.db"
zusammengefaltet, bevor diese Route ueberhaupt drankommt; die
Antwort ist dann 401 von einer ganz anderen Stelle. Auf 404 zu
bestehen hiesse, eine Nebensaechlichkeit zu pruefen und den
eigentlichen Punkt zu verfehlen: Es darf keine Datei
herauskommen. */
for (const boes of ["../../workspace.db", "..%2f..%2fworkspace.db",
"%2e%2e%2f%2e%2e%2fworkspace.db", "nicht-vorhanden.png"]) {
const a = await roh(CREW, `/workspace/api/buehne/spendenbild/${boes}`, {});
ok(a.code !== 200 && !a.text.includes("SQLite format"),
`„${boes}" liefert keine Datei (${a.code})`);
}
/* GEGENPROBE: Die richtige Adresse liefert sehr wohl etwas --
sonst waere die Pruefung oben eine, die immer bestaetigt. */
{
const a = await roh(CREW, hoch.j.bild, {});
ok(a.code === 200 && a.text.length > 0,
"Gegenprobe: die richtige Adresse liefert das Bild");
}
/* DAS BILD UEBERLEBT DAS NAECHSTE SPEICHERN. Wer nur den Namen
aendert, soll sein Bild nicht verlieren. */
const nochmal = await roh(CREW, "/workspace/api/spenden/stufen", {
method: "PUT", keks: k.admin,
rumpf: { stufen: gut.j.stufen.map((z) => ({ ...z, name: z.name + "!" })) },
});
const jetztBild = (nochmal.j?.stufen || []).find((z) => z.ab_cent === 2000)?.bild;
ok(jetztBild === hoch.j.bild,
`nach dem Umbenennen haengt das Bild noch dran (${jetztBild ? "ja" : "nein"})`);
/* DIE KENNUNG MUSS DAS SPEICHERN UEBERLEBT HABEN. Vorher wurde
beim Speichern geloescht und neu eingefuegt -- jede Stufe bekam
dabei eine neue Kennung, und ein Bild-Hochladen gegen die eben
noch gueltige antwortete mit 404. Im Alltag trifft das jeden,
der einen zweiten Bildschirm offen hat. */
const nachId = (nochmal.j?.stufen || []).find((z) => z.ab_cent === 2000)?.id;
ok(nachId === id, `die Kennung bleibt ueber das Speichern hinweg (${id} -> ${nachId})`);
const weg = await roh(CREW, `/workspace/api/spenden/stufe/${id}/bild`,
{ method: "DELETE", keks: k.admin });
ok(weg.code === 200, `das Bild laesst sich wegnehmen (${weg.code})`);
const fort = await roh(CREW, hoch.j.bild, {});
ok(fort.code === 404, `und die Datei ist danach wirklich fort (${fort.code})`);
/* ---- Was die Oberflaeche braucht, kommt vom Server ---- */
const lage2 = await roh(CREW, "/workspace/api/spenden", { keks: k.admin });
const g2 = lage2.j?.gestaltung;
ok(!!g2 && Array.isArray(g2.vorlagen) && g2.stufen_max === STUFEN_MAX
&& g2.groesse_min === GROESSE_MIN && g2.groesse_max === GROESSE_MAX,
"die Grenzen stehen im Stand und nicht nur in der Oberflaeche");
}
melde("");
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
melde(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG");
process.exit(fehler === 0 ? 0 : 1);