/* ===================================================================== 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("anna@example.com", "Anna Alpha", "einlangespasswort"); const kundeB = await legeKundeAn("bert@example.com", "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: "anna@example.com", 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: "gibtesnicht@example.com", 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: "anna@example.com", 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: "bert@example.com", 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("anna@example.com"); 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: "bert@example.com", 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: "anna@example.com", passwort: "falsch" + i }), letzte); } pruefe("nach fuenf Fehlversuchen wird gesperrt", letzte.code === 429, "war " + letzte.code); const andere = res(); await P.portalAnmelden(req({ email: "bert@example.com", 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);