Vollstaendiger Durchlauf ueber alle Pruefungen. Dabei kamen fuenf Dinge ans Licht, die teils seit Wochen offen standen. 1. WCAG-KONTRAST -- der einzige Punkt, der echte Besucher betraf Die lila Schilder erreichten nur 4,08:1, verlangt sind 4,5:1 fuer normalen Text. 17 Fundstellen, alle dieselbe Ursache: Die Schrift nutzte den vollen Ton Aurora Violet. Gemessen wurde nicht geschaetzt: Untergrund rgb(38,41,81) aus dem echten Bild ausgelesen, Schrift 12,5 Punkte -- und fett zaehlt erst ab 18,5 Punkten als "grosser Text". Neu ist --wd-lila-hell (#C197FF, 6,02:1). Bewusst NICHT der knappste Wert: #B380FF haette mit 4,91:1 gereicht, aber dasselbe Schild steht auch ueber der Buehne im Verwaltungsbereich. Ein Ton, der nur an einer Stelle knapp besteht, faellt beim naechsten Hintergrund wieder durch. Dasselbe Muster wie --wd-blau-hell, das genau deshalb existiert. 2. GEHEIMNIS-TEST -- der Test war veraltet, nicht der Code Er verlangte ".env hat Vorrang", die Umsetzung macht das Gegenteil. Die Begruendung im Code ueberzeugt: Wer PayPal-Daten im Formular eintraegt, erwartet, dass sie gelten. Andernfalls koennte ein alter Wert in der .env sie stumm ueberstimmen -- und man sucht stundenlang. Der Test prueft jetzt die tatsaechliche Reihenfolge, dazu neu, dass ein gleichzeitig vorhandener Serverwert auch angezeigt wird. Ausserdem endete der Lauf trotz gruener Pruefungen mit einer Fehlermeldung: Unter Windows haelt eine offene SQLite-Datei eine Sperre, das Aufraeumen scheiterte mit EPERM. Auf Linux waere es durchgelaufen -- dasselbe Skript mit unterschiedlichem Ergebnis je Rechner. Jetzt wird erst geschlossen, dann geloescht, und das Aufraeumen kann den Lauf nicht mehr zum Scheitern bringen. 3. PORTAL-TEST -- ebenfalls veraltet Er suchte "1500,00" und schlug fehl, seit der Formatierer den Tausenderpunkt setzt. "1.500,00 EUR" ist die korrekte deutsche Schreibweise; gerade ab vier Stellen macht der Punkt eine Zahl auf einen Blick lesbar. 4. ZWEI SEITEN OHNE WOERTERBUCH -- eine Entscheidung, keine Luecke "diagnose" ist ein Betriebswerkzeug. "rechtliches" ist der heiklere Fall: Rechtstexte durch eine ungepruefte Uebersetzung zu schicken ist gefaehrlicher, als sie einsprachig zu lassen. Ein Fehler in einer Widerrufsbelehrung wirkt gegen den Verfasser. Die Pruefung meldete beides als "Datei fehlt" -- das las sich wie ein Versehen und stand deshalb dauerhaft in der Fehlerliste, ohne dass jemand etwas tat. Jetzt sind beide benannt und begruendet. 5. TAG-UNGLEICHGEWICHT -- eine Fehlmessung Die Pruefung zaehlte auch HTML-Schnipsel, die als Zeichenketten im JavaScript stehen. Dort steht ein oeffnendes <div> regelmaessig in einer anderen Zeichenkette als sein </div>, weil die Teile erst beim Zusammensetzen ein Ganzes ergeben. Gegengeprueft im Browser: geparster Baum einwandfrei, kein Skriptfehler, Verschachtelung unauffaellig. Es war nie ein Strukturfehler. Die Meldung stand aber monatelang als "2 Fehler" da und haette jede echte Meldung entwertet, die dazugekommen waere. STAND Browser 717 Pruefungen 0 offen Server 566 Pruefungen 0 offen i18n keine Fehler WCAG 0 Fundstellen (vorher 17) Co-Authored-By: Claude Opus 5 <[email protected]>
297 lines
14 KiB
JavaScript
297 lines
14 KiB
JavaScript
/* =====================================================================
|
|
test-webdesign-portal.mjs — Mandantentrennung im Kundenportal
|
|
|
|
Das ist der wichtigste Test des ganzen Bereichs.
|
|
|
|
Masterplan S.12: "Jeder Kunde sieht ausschließlich die eigenen
|
|
Projekte, Dateien, Nachrichten, Kosten, Freigaben und Betreuungsdaten."
|
|
|
|
Geprüft wird das mit ZWEI echten Kunden, die beide ein Projekt haben.
|
|
Kunde B versucht anschließend systematisch, an die Daten von Kunde A zu
|
|
kommen — mit dessen echten Kennungen, die er (etwa aus einer weiter-
|
|
geleiteten E-Mail) kennen könnte. Jeder dieser Versuche MUSS mit
|
|
"nicht gefunden" enden, nicht mit Daten.
|
|
|
|
Aufruf (aus server-internal/, braucht better-sqlite3):
|
|
node test-webdesign-portal.mjs
|
|
===================================================================== */
|
|
|
|
import { mkdtempSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
process.env.DB_PATH = join(mkdtempSync(join(tmpdir(), "wd-portal-")), "test.db");
|
|
|
|
const { initDb, db } = await import("./db.js");
|
|
initDb();
|
|
|
|
const P = await import("./routes/webdesign-portal.js");
|
|
const { jetzt, neueId } = await import("./lib/webdesign-helfer.js");
|
|
|
|
let ok = 0, schlecht = 0;
|
|
function pruefe(name, b, zusatz = "") {
|
|
if (b) { ok++; console.log(" ok " + name); }
|
|
else { schlecht++; console.log(" FEHLT " + name + " " + zusatz); }
|
|
}
|
|
|
|
function res() {
|
|
const a = { code: 200, daten: null };
|
|
a.status = (c) => { a.code = c; return a; };
|
|
a.json = (d) => { a.daten = d; return a; };
|
|
return a;
|
|
}
|
|
function req(koerper, { token = null, params = {}, query = {} } = {}) {
|
|
return {
|
|
body: koerper, params, query,
|
|
headers: token ? { authorization: "Bearer " + token } : {},
|
|
socket: { remoteAddress: "203.0.113.1" },
|
|
};
|
|
}
|
|
|
|
/* ---------- Zwei Kunden mit je einem Projekt anlegen ---------- */
|
|
async function legeKundeAn(email, name, passwort) {
|
|
const id = neueId();
|
|
db.prepare(
|
|
`INSERT INTO wd_kunden (id, email, name, sprache, passwort_hash, email_bestaetigt,
|
|
freigeschaltet, gesperrt, erstellt_am) VALUES (?,?,?,'de',?,1,1,0,?)`
|
|
).run(id, email, name, await P.passwortHashen(passwort), jetzt());
|
|
return id;
|
|
}
|
|
function legeProjektAn(kundeId, nummer, titel) {
|
|
const id = neueId();
|
|
db.prepare(
|
|
`INSERT INTO wd_projekte (id, nummer, kunde_id, titel, paket, status, preis_cent,
|
|
anzahlung_cent, waehrung, erstellt_am) VALUES (?,?,?,?,'website','design',150000,45000,'EUR',?)`
|
|
).run(id, nummer, kundeId, titel, jetzt());
|
|
return id;
|
|
}
|
|
|
|
console.log("\n=== Kundenportal: Mandantentrennung ===\n");
|
|
|
|
const kundeA = await legeKundeAn("[email protected]", "Anna Alpha", "einlangespasswort");
|
|
const kundeB = await legeKundeAn("[email protected]", "Bert Beta", "nochlaengerespasswort");
|
|
const projektA = legeProjektAn(kundeA, "P-2608-0001", "Annas Website");
|
|
const projektB = legeProjektAn(kundeB, "P-2608-0002", "Berts Website");
|
|
|
|
// Vertrauliche Inhalte an Annas Projekt
|
|
db.prepare(`INSERT INTO wd_nachrichten (id, projekt_id, kunde_id, autor, text, intern, erstellt_am)
|
|
VALUES (?,?,?,'dogfather','GEHEIM-FUER-ANNA',0,?)`).run(neueId(), projektA, kundeA, jetzt());
|
|
db.prepare(`INSERT INTO wd_nachrichten (id, projekt_id, kunde_id, autor, text, intern, erstellt_am)
|
|
VALUES (?,?,?,'dogfather','INTERNE-NOTIZ-NICHT-FUER-KUNDEN',1,?)`).run(neueId(), projektA, kundeA, jetzt());
|
|
db.prepare(`INSERT INTO wd_dateien (id, projekt_id, kunde_id, anzeigename, speichername, typ, groesse,
|
|
kategorie, hochgeladen_von, fuer_kunde, erstellt_am)
|
|
VALUES (?,?,?,'Annas Angebot.pdf','x1.pdf','application/pdf',1000,'angebot','dogfather',1,?)`)
|
|
.run(neueId(), projektA, kundeA, jetzt());
|
|
db.prepare(`INSERT INTO wd_dateien (id, projekt_id, kunde_id, anzeigename, speichername, typ, groesse,
|
|
kategorie, hochgeladen_von, fuer_kunde, erstellt_am)
|
|
VALUES (?,?,?,'INTERNE-ARBEITSDATEI.psd','x2.psd','image/vnd.adobe.photoshop',9000,'sonstiges','dogfather',0,?)`)
|
|
.run(neueId(), projektA, kundeA, jetzt());
|
|
|
|
const aenderungA = neueId();
|
|
db.prepare(`INSERT INTO wd_aenderungen (id, projekt_id, kunde_id, titel, beschreibung, status,
|
|
zusatzpreis_cent, erstellt_am) VALUES (?,?,?,'Annas Wunsch','Text','angeboten',20000,?)`)
|
|
.run(aenderungA, projektA, kundeA, jetzt());
|
|
|
|
/* ---------- 1. Anmeldung ---------- */
|
|
console.log("1. Anmeldung");
|
|
let tokenA = null, tokenB = null;
|
|
{
|
|
let r = res();
|
|
await P.portalAnmelden(req({ email: "[email protected]", passwort: "falschesPasswort" }), r);
|
|
pruefe("falsches Passwort wird abgewiesen", r.code === 401);
|
|
pruefe("Meldung verraet NICHT, ob die Adresse existiert",
|
|
/E-Mail oder Passwort/.test(r.daten?.error || ""),
|
|
"-> sonst kann man Kundenadressen durchprobieren");
|
|
|
|
r = res();
|
|
await P.portalAnmelden(req({ email: "[email protected]", passwort: "irgendwas" }), r);
|
|
pruefe("unbekannte Adresse ergibt DIESELBE Meldung",
|
|
r.code === 401 && /E-Mail oder Passwort/.test(r.daten?.error || ""));
|
|
|
|
r = res();
|
|
await P.portalAnmelden(req({ email: "[email protected]", passwort: "einlangespasswort" }), r);
|
|
pruefe("richtiges Passwort meldet an", r.daten?.ok === true, JSON.stringify(r.daten));
|
|
tokenA = r.daten?.token;
|
|
pruefe("Sitzungstoken kommt zurueck", !!tokenA);
|
|
pruefe("Passwort steht NICHT in der Antwort", !JSON.stringify(r.daten).includes("passwort"));
|
|
|
|
r = res();
|
|
await P.portalAnmelden(req({ email: "[email protected]", passwort: "nochlaengerespasswort" }), r);
|
|
tokenB = r.daten?.token;
|
|
pruefe("zweiter Kunde meldet sich an", !!tokenB);
|
|
|
|
const gespeichert = db.prepare(`SELECT passwort_hash FROM wd_kunden WHERE email = ?`).get("[email protected]");
|
|
pruefe("Passwort liegt nur als scrypt-Hash in der Datenbank",
|
|
gespeichert.passwort_hash.startsWith("scrypt$") && !gespeichert.passwort_hash.includes("einlangespasswort"));
|
|
}
|
|
|
|
/* ---------- 2. Ohne Anmeldung geht nichts ---------- */
|
|
console.log("\n2. Ohne Anmeldung");
|
|
{
|
|
for (const [name, fn, r0] of [
|
|
["Uebersicht", P.portalUebersicht, req(null)],
|
|
["Projekt", P.portalProjekt, req(null, { params: { id: projektA } })],
|
|
["Aenderung einreichen", P.portalAenderungEinreichen, req({ projektId: projektA, titel: "x", beschreibung: "y" })],
|
|
["Nachricht", P.portalNachricht, req({ projektId: projektA, text: "hallo" })],
|
|
["Profil", P.portalProfilSpeichern, req({ name: "Hacker" })],
|
|
]) {
|
|
const r = res();
|
|
await fn(r0, r);
|
|
pruefe(name + " -> 401", r.code === 401);
|
|
pruefe(name + " -> keine Daten", !r.daten?.projekte && !r.daten?.projekt);
|
|
}
|
|
|
|
const r = res();
|
|
await P.portalUebersicht(req(null, { token: "voellig-erfundenes-token" }), r);
|
|
pruefe("erfundenes Token -> 401", r.code === 401);
|
|
}
|
|
|
|
/* ---------- 3. DER KERNTEST: B kommt nicht an A ---------- */
|
|
console.log("\n3. Kunde B versucht an Kunde A zu kommen");
|
|
{
|
|
let r = res();
|
|
await P.portalUebersicht(req(null, { token: tokenB }), r);
|
|
const eigene = JSON.stringify(r.daten);
|
|
pruefe("B sieht sein eigenes Projekt", eigene.includes("P-2608-0002"));
|
|
pruefe("B sieht NICHT Annas Projekt", !eigene.includes("P-2608-0001"),
|
|
"-> Uebersicht filtert auf die eigene kunde_id");
|
|
pruefe("B sieht NICHT Annas Namen", !eigene.includes("Annas Website"));
|
|
|
|
// Direkter Zugriff mit Annas echter Projektkennung
|
|
r = res();
|
|
await P.portalProjekt(req(null, { token: tokenB, params: { id: projektA } }), r);
|
|
pruefe("B kann Annas Projekt NICHT oeffnen", r.code === 404,
|
|
"-> mit der echten Kennung! Das ist der Kern der Trennung");
|
|
pruefe("keine Daten im Fehlerfall", !r.daten?.projekt);
|
|
pruefe("nichts Vertrauliches in der Antwort",
|
|
!JSON.stringify(r.daten).includes("GEHEIM-FUER-ANNA"));
|
|
|
|
// Nachricht in Annas Projekt schreiben
|
|
r = res();
|
|
await P.portalNachricht(req({ projektId: projektA, text: "eingeschmuggelt" }, { token: tokenB }), r);
|
|
pruefe("B kann in Annas Projekt NICHTS schreiben", r.code === 404);
|
|
pruefe("es wurde auch wirklich nichts gespeichert",
|
|
db.prepare(`SELECT COUNT(*) AS n FROM wd_nachrichten WHERE text = 'eingeschmuggelt'`).get().n === 0);
|
|
|
|
// Änderungswunsch an Annas Projekt hängen
|
|
r = res();
|
|
await P.portalAenderungEinreichen(req({ projektId: projektA, titel: "fremd", beschreibung: "fremd" }, { token: tokenB }), r);
|
|
pruefe("B kann an Annas Projekt keine Idee haengen", r.code === 404);
|
|
|
|
// Annas Zusatzangebot annehmen
|
|
r = res();
|
|
await P.portalAenderungEntscheiden(req({ annehmen: true }, { token: tokenB, params: { id: aenderungA } }), r);
|
|
pruefe("B kann Annas Zusatzangebot NICHT annehmen", r.code === 404,
|
|
"-> sonst koennte man Fremden Kosten aufhalsen");
|
|
pruefe("Annas Angebot ist unveraendert",
|
|
db.prepare(`SELECT status FROM wd_aenderungen WHERE id = ?`).get(aenderungA).status === "angeboten");
|
|
}
|
|
|
|
/* ---------- 4. A sieht das Richtige, aber nicht das Interne ---------- */
|
|
console.log("\n4. Was Anna sehen darf");
|
|
{
|
|
const r = res();
|
|
await P.portalProjekt(req(null, { token: tokenA, params: { id: projektA } }), r);
|
|
const t = JSON.stringify(r.daten);
|
|
pruefe("Anna oeffnet ihr Projekt", r.daten?.ok === true);
|
|
pruefe("sieht die Nachricht an sie", t.includes("GEHEIM-FUER-ANNA"));
|
|
pruefe("sieht INTERNE Notizen NICHT", !t.includes("INTERNE-NOTIZ-NICHT-FUER-KUNDEN"),
|
|
"-> intern = 1 bleibt intern");
|
|
pruefe("sieht ihre Angebotsdatei", t.includes("Annas Angebot.pdf"));
|
|
pruefe("sieht INTERNE Arbeitsdateien NICHT", !t.includes("INTERNE-ARBEITSDATEI"),
|
|
"-> fuer_kunde = 0 bleibt unsichtbar");
|
|
/* 1.500,00 -- MIT Tausenderpunkt.
|
|
Der Test suchte frueher nach "1500,00" und schlug fehl, seit der
|
|
Formatierer den Punkt setzt. Er war veraltet, nicht der Code:
|
|
"1.500,00 EUR" ist die korrekte deutsche Schreibweise, und gerade
|
|
bei Betraegen ab vier Stellen ist der Punkt das, was eine Zahl
|
|
ueberhaupt auf einen Blick lesbar macht. */
|
|
pruefe("Betrag wird lesbar mitgeliefert", /1\.500,00/.test(t),
|
|
"-> mit Tausenderpunkt");
|
|
pruefe("Speichername der Datei wird NICHT verraten", !t.includes("x1.pdf"),
|
|
"-> interner Dateiname geht niemanden etwas an");
|
|
}
|
|
|
|
/* ---------- 5. Zusatzangebot nur entscheidbar, wenn beziffert ---------- */
|
|
console.log("\n5. Zusatzangebote");
|
|
{
|
|
const offen = neueId();
|
|
db.prepare(`INSERT INTO wd_aenderungen (id, projekt_id, kunde_id, titel, beschreibung, status, erstellt_am)
|
|
VALUES (?,?,?,'Noch nicht beziffert','Text','eingereicht',?)`)
|
|
.run(offen, projektA, kundeA, jetzt());
|
|
|
|
let r = res();
|
|
await P.portalAenderungEntscheiden(req({ annehmen: true }, { token: tokenA, params: { id: offen } }), r);
|
|
pruefe("nicht beziffertes Anliegen kann nicht angenommen werden", r.code === 409,
|
|
"-> sonst entstuende eine Zahlungspflicht ohne Preis");
|
|
|
|
r = res();
|
|
await P.portalAenderungEntscheiden(req({ annehmen: true }, { token: tokenA, params: { id: aenderungA } }), r);
|
|
pruefe("beziffertes Angebot kann angenommen werden", r.daten?.ok === true);
|
|
pruefe("Status steht auf angenommen",
|
|
db.prepare(`SELECT status FROM wd_aenderungen WHERE id = ?`).get(aenderungA).status === "angenommen");
|
|
}
|
|
|
|
/* ---------- 6. Gesperrter Zugang ---------- */
|
|
console.log("\n6. Sperre wirkt sofort");
|
|
{
|
|
db.prepare(`UPDATE wd_kunden SET gesperrt = 1 WHERE id = ?`).run(kundeA);
|
|
const r = res();
|
|
await P.portalUebersicht(req(null, { token: tokenA }), r);
|
|
pruefe("laufende Sitzung endet bei Sperre sofort", r.code === 401,
|
|
"-> nicht erst nach zwoelf Stunden");
|
|
db.prepare(`UPDATE wd_kunden SET gesperrt = 0 WHERE id = ?`).run(kundeA);
|
|
}
|
|
|
|
/* ---------- 7. Einladung und Passwort ---------- */
|
|
console.log("\n7. Einladung");
|
|
{
|
|
const rohToken = "einladung-test-token-1234567890";
|
|
const { tokenHash } = await import("./lib/webdesign-helfer.js");
|
|
db.prepare(`INSERT INTO wd_kunden_token (token_hash, kunde_id, zweck, laeuft_ab, erstellt_am)
|
|
VALUES (?,?,'einladung',?,?)`)
|
|
.run(tokenHash(rohToken), kundeB, new Date(Date.now() + 86400000).toISOString(), jetzt());
|
|
|
|
let r = res();
|
|
await P.portalPasswortSetzen(req({ token: rohToken, passwort: "kurz" }), r);
|
|
pruefe("zu kurzes Passwort wird abgelehnt", r.code === 400);
|
|
pruefe("Begruendung nennt die Mindestlaenge", /\d+ Zeichen/.test(r.daten?.error || ""));
|
|
|
|
r = res();
|
|
await P.portalPasswortSetzen(req({ token: rohToken, passwort: "mein blauer stuhl steht krumm" }), r);
|
|
pruefe("langes Passwort wird gesetzt", r.daten?.ok === true);
|
|
|
|
r = res();
|
|
await P.portalPasswortSetzen(req({ token: rohToken, passwort: "noch ein anderes passwort" }), r);
|
|
pruefe("derselbe Link ein zweites Mal wird abgelehnt", r.code === 400,
|
|
"-> sonst bliebe ein dauerhaft gueltiger Generalschluessel liegen");
|
|
|
|
r = res();
|
|
await P.portalUebersicht(req(null, { token: tokenB }), r);
|
|
pruefe("alte Sitzungen enden beim Passwortwechsel", r.code === 401,
|
|
"-> wer das Passwort aendert, will oft genau jemanden aussperren");
|
|
|
|
r = res();
|
|
await P.portalAnmelden(req({ email: "[email protected]", passwort: "mein blauer stuhl steht krumm" }), r);
|
|
pruefe("Anmeldung mit dem neuen Passwort klappt", r.daten?.ok === true);
|
|
}
|
|
|
|
/* ---------- 8. Sperre nach zu vielen Fehlversuchen ---------- */
|
|
console.log("\n8. Fehlversuche");
|
|
{
|
|
let letzte = null;
|
|
for (let i = 0; i < 6; i++) {
|
|
letzte = res();
|
|
await P.portalAnmelden(req({ email: "[email protected]", passwort: "falsch" + i }), letzte);
|
|
}
|
|
pruefe("nach fuenf Fehlversuchen wird gesperrt", letzte.code === 429, "war " + letzte.code);
|
|
|
|
const andere = res();
|
|
await P.portalAnmelden(req({ email: "[email protected]", passwort: "mein blauer stuhl steht krumm" }), andere);
|
|
pruefe("ein ANDERER Kunde ist davon nicht betroffen", andere.daten?.ok === true,
|
|
"-> die Sperre haengt an der Adresse, nicht an der IP");
|
|
}
|
|
|
|
console.log(`\n=== ${ok} bestanden, ${schlecht} gescheitert ===\n`);
|
|
process.exit(schlecht === 0 ? 0 : 1);
|