Die Reiter standen neben dem Titel. Das las sich wie eine einzige lange
Zeile, in der der Titel nur der erste von sechs Knöpfen zu sein schien --
man sah nicht auf einen Blick, in welchem Bereich man war. Jetzt zwei
Zeilen: oben WO man ist, darunter WOHIN man kann.
Die Verwaltung öffnete bisher mit der Anfragenliste. Eine Liste ist eine
Ablage: Sie zeigt, WAS es gibt, nicht was zu TUN ist. Neu ist ein
Cockpit, das in drei Stufen antwortet -- was auf mich wartet, was beim
Kunden liegt, wie es ums Geld steht -- und dann laufende Projekte mit
Fortschritt sowie den Verlauf.
Zwei Grundsätze machen die Zahlen brauchbar: Getrennt nach 'wartet auf
mich' und 'wartet auf den Kunden' (zwölf offene Punkte sind entspannt,
wenn elf beim Kunden liegen). Und das ALTER färbt, nicht die Menge --
vier neue Anfragen sind kein Problem, eine seit sechs Tagen liegende
schon. Ein offener Widerruf ist immer rot, weil eine gesetzliche Frist
läuft.
Jede Kachel ist ein echter <button> und führt in den passenden Reiter.
Farbe ist nie der einzige Träger: Neben jedem farbigen Zustand steht der
Text ('älteste seit 8 Tagen').
Drei Fehler dabei gefunden und behoben:
- Der Titel wurde nur beim Klicken gesetzt. Frisch geladen zeigte die
Seite das Cockpit, während darüber noch 'Projektanfragen' stand. Die
Zuordnung Reiter->Titel liegt jetzt ausserhalb des Klick-Zuhörers.
- '.wd h2' überstimmte '.vw-ub-h': 44px Überschrift über 33px Zahl, die
Seite las sich wie ein Plakat. Dieselbe Spezifitätsfalle wie früher
bei '.wd a'.
- Eine Antwort mit ok:true aber ohne Inhalt riss die ganze Verwaltung
mit. Wird jetzt abgefangen -- ein halber Server ist ein realistischer
Fall.
Geprüft: 22 Serverprüfungen gegen eine echte Datenbank (inkl. vier
Fällen, die belegen, dass die Rechteprüfung wirklich greift), 56 im
Browser auf Computer und Handy, 69 im bestehenden Verwaltungstest,
0 Fundstellen im Design-/WCAG-Test, 5 im Farbsystemtest.
Co-Authored-By: Claude Opus 5 <[email protected]>
168 lines
8.3 KiB
JavaScript
168 lines
8.3 KiB
JavaScript
/* Prüft das Cockpit gegen eine echte Datenbank.
|
|
|
|
Die Übersicht besteht fast nur aus SQL. Ein Tippfehler in einem
|
|
Spaltennamen wirft erst beim Aufruf — also erst, wenn Filipe die Seite
|
|
öffnet und eine leere Verwaltung sieht. Deshalb läuft hier jede
|
|
einzelne Abfrage einmal gegen ein frisch angelegtes Schema.
|
|
|
|
Zusätzlich wird die Trennung "wartet auf mich" / "wartet auf den
|
|
Kunden" mit echten Datensätzen belegt. Genau das ist der Wert der
|
|
Seite -- eine Zahl, die beides zusammenwirft, ist wertlos.
|
|
*/
|
|
import { mkdtempSync, rmSync, writeSync, readdirSync, readFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { randomBytes, createHmac } from "node:crypto";
|
|
|
|
const L = (...a) => writeSync(1, a.join(" ") + "\n"); // better-sqlite3 bricht am Ende ab
|
|
const tmp = mkdtempSync(join(tmpdir(), "wd-ueb-"));
|
|
process.env.DB_PATH = join(tmp, "t.db");
|
|
process.env.ENCRYPTION_KEY = randomBytes(32).toString("base64");
|
|
process.env.WEBDESIGN_API_SECRET = randomBytes(24).toString("hex");
|
|
|
|
const { initDb, db } = await import("./db.js");
|
|
initDb();
|
|
const geheim = await import("./lib/webdesign-geheimnisse.js");
|
|
await geheim.geheimnisseLaden();
|
|
const { uebersicht } = await import("./routes/webdesign-zahlungen.js");
|
|
|
|
let ok = 0, fehler = 0;
|
|
const pruefe = (n, b, z) => b ? (ok++, L(" ok " + n))
|
|
: (fehler++, L(" FEHL " + n + (z !== undefined ? " -> " + z : "")));
|
|
|
|
/* Ein ECHTER Ausweis, nicht an der Rechtepruefung vorbei.
|
|
|
|
Es waere bequemer gewesen, darf() im Test zu ueberschreiben. Dann
|
|
wuerde der Test aber auch dann gruen bleiben, wenn der Endpunkt seine
|
|
Rechtepruefung ganz verliert -- und genau das ist der Fehler, den man
|
|
auf keinen Fall uebersehen will. Deshalb wird hier ein gueltiger
|
|
Ausweis mit demselben Verfahren erzeugt, das die Zugangswand benutzt. */
|
|
function ausweis(rolle = "dogfather", gueltigMs = 60_000) {
|
|
const nutzlast = Buffer.from(JSON.stringify({
|
|
bereich: "wd-admin", rolle, exp: Date.now() + gueltigMs,
|
|
})).toString("base64url");
|
|
const sig = createHmac("sha256", process.env.WEBDESIGN_API_SECRET)
|
|
.update(nutzlast).digest("base64url");
|
|
return nutzlast + "." + sig;
|
|
}
|
|
/* Ein technisch einwandfrei unterschriebener Ausweis, der aber fuer die
|
|
oeffentliche Zugangswand ausgestellt wurde. Er muss abgelehnt werden --
|
|
sonst kaeme jeder Besucher, der die Zugangswand passiert hat, auch in
|
|
die Verwaltung. */
|
|
function fremderAusweis() {
|
|
const nutzlast = Buffer.from(JSON.stringify({
|
|
bereich: "wd-gate", rolle: "dogfather", exp: Date.now() + 60_000,
|
|
})).toString("base64url");
|
|
const sig = createHmac("sha256", process.env.WEBDESIGN_API_SECRET)
|
|
.update(nutzlast).digest("base64url");
|
|
return nutzlast + "." + sig;
|
|
}
|
|
const req = { headers: { authorization: "Bearer " + ausweis() }, cookies: {} };
|
|
let antwort = null;
|
|
const res = { status(){return this;}, json(o){antwort=o;return this;},
|
|
setHeader(){}, end(){}, writeHead(){return this;} };
|
|
|
|
/* --- Daten anlegen --- */
|
|
const jetzt = new Date().toISOString();
|
|
const alt = new Date(Date.now() - 6 * 864e5).toISOString();
|
|
db.prepare(`INSERT INTO wd_kunden (id,email,name,erstellt_am) VALUES ('k1','[email protected]','Testkunde',?)`).run(jetzt);
|
|
const anf = db.prepare(`INSERT INTO wd_anfragen (id,nummer,name,email,paket,status,erstellt_am)
|
|
VALUES (?,?,?,?,'start',?,?)`);
|
|
anf.run('a1','A-1','X','[email protected]','neu',alt);
|
|
anf.run('a2','A-2','Y','[email protected]','erledigt',jetzt);
|
|
const prj = db.prepare(`INSERT INTO wd_projekte (id,nummer,kunde_id,titel,paket,status,wartet_auf,preis_cent,erstellt_am)
|
|
VALUES (?,?,'k1',?,'start','bau',?,?,?)`);
|
|
prj.run('p1','P-1','Bei mir','dogfather',100000,jetzt);
|
|
prj.run('p2','P-2','Beim Kunden','kunde',50000,jetzt);
|
|
const auf = db.prepare(`INSERT INTO wd_aufgaben (id,projekt_id,kunde_id,titel,status,wer_dran,erstellt_am)
|
|
VALUES (?,'p1','k1',?,?,?,?)`);
|
|
auf.run('t1','Fertig','erledigt','dogfather',jetzt);
|
|
auf.run('t2','Offen beim Kunden','offen','kunde',jetzt);
|
|
db.prepare(`INSERT INTO wd_postfach (id,kunde_id,autor,text,gelesen_admin,erstellt_am)
|
|
VALUES ('n1','k1','kunde','Hallo',0,?)`).run(alt);
|
|
db.prepare(`INSERT INTO wd_zahlungen (id,nummer,kunde_id,art,zweck_text,betrag_cent,status,erstellt_am)
|
|
VALUES ('z1','Z-1','k1','anzahlung','Anzahlung',30000,'offen',?)`).run(jetzt);
|
|
db.prepare(`INSERT INTO wd_zahlungen (id,nummer,kunde_id,art,zweck_text,betrag_cent,netto_cent,gebuehr_cent,status,bezahlt_am,erstellt_am)
|
|
VALUES ('z2','Z-2','k1','rest','Rest',70000,67500,2500,'bezahlt',?,?)`).run(jetzt, jetzt);
|
|
|
|
L("\n1. LAEUFT DIE ABFRAGE UEBERHAUPT DURCH?");
|
|
try {
|
|
await uebersicht(req, res);
|
|
pruefe("kein SQL-Fehler -- jede Spalte existiert wirklich", true);
|
|
} catch (e) {
|
|
pruefe("kein SQL-Fehler -- jede Spalte existiert wirklich", false, e.message);
|
|
L(`\n===== ${ok} bestanden, ${fehler} fehlgeschlagen =====`);
|
|
process.exitCode = 1;
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
process.exit(1);
|
|
}
|
|
pruefe("es kommt eine Antwort zurueck", !!antwort, JSON.stringify(antwort).slice(0, 120));
|
|
|
|
if (antwort && antwort.ok) {
|
|
L("\n2. WAS AUF MICH WARTET");
|
|
pruefe("die erledigte Anfrage wird NICHT mitgezaehlt",
|
|
antwort.beiMir.anfragen.n === 1, antwort.beiMir.anfragen.n);
|
|
pruefe("das Alter der aeltesten Anfrage kommt mit",
|
|
antwort.beiMir.anfragen.aeltestes === alt, antwort.beiMir.anfragen.aeltestes);
|
|
pruefe("die ungelesene Kundennachricht zaehlt",
|
|
antwort.beiMir.nachrichten.n === 1, antwort.beiMir.nachrichten.n);
|
|
pruefe("ein Projekt wartet auf mich", antwort.beiMir.projekte === 1, antwort.beiMir.projekte);
|
|
|
|
L("\n3. WAS AUF DEN KUNDEN WARTET -- sauber getrennt");
|
|
pruefe("das andere Projekt wartet auf den Kunden",
|
|
antwort.beiKunde.projekte === 1, antwort.beiKunde.projekte);
|
|
pruefe("die erledigte Aufgabe zaehlt nicht mit",
|
|
antwort.beiKunde.aufgaben === 1, antwort.beiKunde.aufgaben);
|
|
pruefe("die offene Zahlung erscheint mit Betrag",
|
|
antwort.beiKunde.zahlungen.n === 1 && /300/.test(antwort.beiKunde.zahlungenText),
|
|
antwort.beiKunde.zahlungenText);
|
|
pruefe("die BEZAHLTE Zahlung erscheint hier NICHT",
|
|
antwort.beiKunde.zahlungen.summe === 30000, antwort.beiKunde.zahlungen.summe);
|
|
|
|
L("\n4. GELD -- Gebuehr aus der Spalte, nicht geschaetzt");
|
|
pruefe("Umsatz im Monat stimmt", /700/.test(antwort.geld.monat.bruttoText), antwort.geld.monat.bruttoText);
|
|
pruefe("die PayPal-Gebuehr wird ehrlich ausgewiesen",
|
|
/25/.test(antwort.geld.gebuehrenText), antwort.geld.gebuehrenText);
|
|
pruefe("das Netto ist weniger als das Brutto",
|
|
antwort.geld.monat.netto < antwort.geld.monat.brutto,
|
|
antwort.geld.monat.netto + " < " + antwort.geld.monat.brutto);
|
|
|
|
L("\n5. PROJEKTLISTE MIT FORTSCHRITT");
|
|
const p1 = antwort.projekte.find((p) => p.id === "p1");
|
|
pruefe("das Projekt taucht auf", !!p1);
|
|
pruefe("der Kundenname haengt dran", p1 && p1.kunde_name === "Testkunde", p1 && p1.kunde_name);
|
|
pruefe("Aufgaben werden gezaehlt: 1 von 2 fertig",
|
|
p1 && p1.aufg_gesamt === 2 && p1.aufg_fertig === 1,
|
|
p1 && p1.aufg_fertig + "/" + p1.aufg_gesamt);
|
|
|
|
L("\n6. BESTAND");
|
|
pruefe("Kunden gezaehlt", antwort.bestand.kunden === 1, antwort.bestand.kunden);
|
|
pruefe("Projekte gezaehlt", antwort.bestand.projekte === 2, antwort.bestand.projekte);
|
|
} else {
|
|
pruefe("Zugriff erlaubt (sonst kann nichts geprueft werden)", false,
|
|
antwort && antwort.error);
|
|
}
|
|
|
|
L("\n7. DIE RECHTEPRUEFUNG GREIFT WIRKLICH");
|
|
/* Ohne diese drei Faelle koennte der Endpunkt seine Rechtepruefung
|
|
verlieren, ohne dass ein Test rot wird -- die Zahlen oben kaemen ja
|
|
weiterhin richtig heraus. Der gefaehrlichste Fehler waere also genau
|
|
der, den die uebrigen Pruefungen nicht sehen. */
|
|
for (const [name, kopf] of [
|
|
["ohne Ausweis kommt nichts heraus", {}],
|
|
["mit gefaelschter Unterschrift kommt nichts heraus",
|
|
{ authorization: "Bearer " + ausweis().split(".")[0] + ".XXXX" }],
|
|
["mit abgelaufenem Ausweis kommt nichts heraus",
|
|
{ authorization: "Bearer " + ausweis("dogfather", -1000) }],
|
|
["ein Ausweis fuer einen ANDEREN Bereich zaehlt nicht",
|
|
{ authorization: "Bearer " + fremderAusweis() }],
|
|
]) {
|
|
antwort = null;
|
|
await uebersicht({ headers: kopf, cookies: {} }, res);
|
|
pruefe(name, antwort && antwort.ok === false, JSON.stringify(antwort).slice(0, 80));
|
|
}
|
|
|
|
L(`\n===== ${ok} bestanden, ${fehler} fehlgeschlagen =====`);
|
|
process.exitCode = fehler ? 1 : 0;
|
|
rmSync(tmp, { recursive: true, force: true });
|