Umsetzung des "Website Masterplan" (16 Seiten) unter /webdesign. Zugangsschutz mit EIGENER Schranke (server/webdesign-gate.js) statt gate.js: gate.js laesst seit dem oeffentlichen Start am 21.08.2026 jeden durch, weil die Pruefung auf SITE_PUBLIC_LAUNCH_AT als allererste Zeile steht. Haette man /webdesign dahintergehaengt, waere der ausdruecklich nicht-oeffentliche Bereich inklusive Preisen und spaeteren Kundendaten ab der ersten Sekunde fuer jeden lesbar gewesen. Eigenes Sitzungs-Cookie, bereich="webdesign" im Token, damit ein gueltiges Universe-Cookie hier NICHT gilt. 37/37 Tests. Sieben oeffentliche Seiten in fuenf Sprachen (de, de-CH mit echtem Dialekt, en, fr, pt). Preise, Zeitrahmen, Paketnamen und die 30-%-Regel stehen an genau EINER Stelle in wd-core.js -- der Masterplan verlangt "ueberall widerspruchsfrei", und vier Kopien laufen bei der ersten Preisaenderung auseinander. Als App installierbar auf Handy und PC. Der Service Worker speichert bewusst KEINE HTML-Seite zwischen: nach dem Abmelden wuerden sonst geschuetzte Seiten weiter ausgeliefert, ohne dass der Server je gefragt wird. 18/18 Tests. Handy-Abnahme ueber alle Seiten in fuenf Breiten (320-1440) und fuenf Sprachen: 40/40. Der Test fand 35 echte Fehler (Touch-Ziele unter 44px), behoben im Designsystem statt einzeln pro Seite. PayPal (Wunsch 22.08.2026 "sofort auf meinem paypal"): Orders API mit intent=CAPTURE, also sofortiger Einzug statt blosser Reservierung. Gebuehr und Nettobetrag getrennt gespeichert. Betraege durchgehend als Ganzzahl in Cent. Gefaelschte Webhooks werden abgewiesen. Fail closed solange Zugangsdaten fehlen. 28/28 Tests gegen einen nachgebauten PayPal-Server. Datenbank: 15 Tabellen mit Praefix wd_, fachlich vollstaendig vom Universe getrennt. Co-Authored-By: Claude Opus 5 <[email protected]>
194 lines
8.3 KiB
JavaScript
194 lines
8.3 KiB
JavaScript
/* =====================================================================
|
|
test-webdesign-anfragen.mjs — Selbsttest des Anfrage-Endpunkts.
|
|
|
|
Läuft gegen eine Wegwerf-Datenbank im Arbeitsspeicher. Prüft vor allem
|
|
die Fälle, die man beim Klicken NICHT sieht: Spamschutz, Pflichtfelder,
|
|
Längenbegrenzung, und dass die Verwaltungs-Endpunkte ohne Anmeldung
|
|
wirklich dichtmachen.
|
|
|
|
Aufruf (aus server-internal/): node test-webdesign-anfragen.mjs
|
|
===================================================================== */
|
|
|
|
import { mkdtempSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
/* Eigene Datenbankdatei in einem Temporärordner: db.js liest DB_PATH beim
|
|
Import. Eine echte Datei statt ":memory:", weil better-sqlite3 mit WAL
|
|
im Arbeitsspeicher nicht arbeitet. */
|
|
process.env.DB_PATH = join(mkdtempSync(join(tmpdir(), "wd-test-")), "test.db");
|
|
|
|
const { initDb, db } = await import("./db.js");
|
|
initDb();
|
|
|
|
const { anfrageAbsenden, anfragenListe, anfrageDetail, anfrageStatus, anfrageArchivieren } =
|
|
await import("./routes/webdesign-anfragen.js");
|
|
|
|
let ok = 0, schlecht = 0;
|
|
function pruefe(name, bedingung, zusatz = "") {
|
|
if (bedingung) { ok++; console.log(" ok " + name); }
|
|
else { schlecht++; console.log(" FEHLT " + name + " " + zusatz); }
|
|
}
|
|
|
|
/* Minimaler Ersatz für Express-Request/Response. */
|
|
function antwortAttrappe() {
|
|
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 anfrageAttrappe(koerper, { ip = "203.0.113.5", auth = null, params = {}, query = {} } = {}) {
|
|
return {
|
|
body: koerper,
|
|
params,
|
|
query,
|
|
headers: {
|
|
"cf-connecting-ip": ip,
|
|
...(auth ? { authorization: "Bearer " + auth } : {}),
|
|
},
|
|
socket: { remoteAddress: ip },
|
|
};
|
|
}
|
|
|
|
const gueltig = () => ({
|
|
name: "Testfirma Meier",
|
|
email: "[email protected]",
|
|
paket: "website",
|
|
ziel: "Wir wollen endlich online gefunden werden und Anfragen bekommen.",
|
|
datenschutz: true,
|
|
// Formular vor über einer Minute geöffnet -> Zeitprüfung ist zufrieden
|
|
geoeffnetAm: Date.now() - 60000,
|
|
});
|
|
|
|
console.log("\n=== Anfrageformular ===\n");
|
|
|
|
/* ---------- 1. Gültige Anfrage ---------- */
|
|
console.log("1. Gueltige Anfrage");
|
|
{
|
|
const res = antwortAttrappe();
|
|
await anfrageAbsenden(anfrageAttrappe(gueltig()), res);
|
|
pruefe("wird angenommen", res.daten?.ok === true, JSON.stringify(res.daten));
|
|
pruefe("Nummer im Format A-JJMM-0001", /^A-\d{4}-\d{4}$/.test(res.daten?.nummer || ""), res.daten?.nummer);
|
|
pruefe("Zusammenfassung kommt zurueck", !!res.daten?.zusammenfassung?.eingegangen);
|
|
|
|
const zeile = db.prepare(`SELECT * FROM wd_anfragen WHERE nummer = ?`).get(res.daten.nummer);
|
|
pruefe("liegt in der Datenbank", !!zeile);
|
|
pruefe("Status startet auf 'neu'", zeile?.status === "neu");
|
|
pruefe("E-Mail wird kleingeschrieben gespeichert", zeile?.email === "[email protected]");
|
|
pruefe("IP wird nur als Hash gespeichert",
|
|
!!zeile?.ip_hash && !String(zeile.ip_hash).includes("203.0.113"),
|
|
"-> die echte IP darf nirgends stehen");
|
|
pruefe("Aenderungsverlauf wurde geschrieben",
|
|
!!db.prepare(`SELECT 1 FROM wd_verlauf WHERE bereich='anfrage' AND objekt_id=?`).get(zeile.id));
|
|
}
|
|
|
|
/* ---------- 2. Pflichtfelder ---------- */
|
|
console.log("\n2. Pflichtfelder");
|
|
{
|
|
const faelle = [
|
|
["ohne Name", { name: "" }, "name"],
|
|
["ohne E-Mail", { email: "" }, "email"],
|
|
["unsinnige E-Mail", { email: "keine-email" }, "email"],
|
|
["ohne Paket", { paket: "" }, "paket"],
|
|
["unbekanntes Paket", { paket: "raumschiff" }, "paket"],
|
|
["ohne Ziel", { ziel: "" }, "ziel"],
|
|
["ohne Datenschutz-Zustimmung", { datenschutz: false }, "datenschutz"],
|
|
];
|
|
for (const [name, ueberschreibung, feld] of faelle) {
|
|
const res = antwortAttrappe();
|
|
await anfrageAbsenden(anfrageAttrappe({ ...gueltig(), ...ueberschreibung }, { ip: "203.0.113.6" }), res);
|
|
pruefe(name + " -> abgelehnt", res.code === 400 && !!res.daten?.felder?.[feld],
|
|
JSON.stringify(res.daten?.felder));
|
|
}
|
|
|
|
const res = antwortAttrappe();
|
|
await anfrageAbsenden(anfrageAttrappe({ name: "", email: "", paket: "", ziel: "", datenschutz: false }, { ip: "203.0.113.7" }), res);
|
|
pruefe("alle Fehler kommen auf einmal", Object.keys(res.daten?.felder || {}).length >= 5,
|
|
"-> sonst schickt man das Formular fuenfmal ab");
|
|
}
|
|
|
|
/* ---------- 3. Spamschutz ---------- */
|
|
console.log("\n3. Spamschutz");
|
|
{
|
|
const vorher = db.prepare(`SELECT COUNT(*) AS n FROM wd_anfragen`).get().n;
|
|
|
|
const res1 = antwortAttrappe();
|
|
await anfrageAbsenden(anfrageAttrappe({ ...gueltig(), website: "http://spam.example" }, { ip: "203.0.113.8" }), res1);
|
|
pruefe("Honigtopf: meldet Erfolg", res1.daten?.ok === true);
|
|
pruefe("Honigtopf: speichert aber NICHTS",
|
|
db.prepare(`SELECT COUNT(*) AS n FROM wd_anfragen`).get().n === vorher,
|
|
"-> absichtlich hoeflich, damit das Skript nicht umbaut");
|
|
|
|
const res2 = antwortAttrappe();
|
|
await anfrageAbsenden(anfrageAttrappe({ ...gueltig(), geoeffnetAm: Date.now() - 500 }, { ip: "203.0.113.9" }), res2);
|
|
pruefe("zu schnell ausgefuellt: speichert nichts",
|
|
db.prepare(`SELECT COUNT(*) AS n FROM wd_anfragen`).get().n === vorher);
|
|
|
|
// Mengenbegrenzung: dieselbe IP mehrfach
|
|
let letzte = null;
|
|
for (let i = 0; i < 7; i++) {
|
|
letzte = antwortAttrappe();
|
|
await anfrageAbsenden(anfrageAttrappe(gueltig(), { ip: "203.0.113.99" }), letzte);
|
|
}
|
|
pruefe("nach fuenf Anfragen pro Stunde wird gebremst", letzte.code === 429, "war " + letzte.code);
|
|
|
|
const andere = antwortAttrappe();
|
|
await anfrageAbsenden(anfrageAttrappe(gueltig(), { ip: "198.51.100.1" }), andere);
|
|
pruefe("andere Absender sind NICHT mitgesperrt", andere.daten?.ok === true);
|
|
}
|
|
|
|
/* ---------- 4. Laengen und Sonderzeichen ---------- */
|
|
console.log("\n4. Laengen und Sonderzeichen");
|
|
{
|
|
const res = antwortAttrappe();
|
|
await anfrageAbsenden(anfrageAttrappe({
|
|
...gueltig(),
|
|
name: "N".repeat(5000),
|
|
ziel: "Z".repeat(50000),
|
|
funktionen: Array.from({ length: 500 }, (_, i) => "funktion-" + i),
|
|
details: Object.fromEntries(Array.from({ length: 500 }, (_, i) => ["k" + i, "v".repeat(9000)])),
|
|
}, { ip: "198.51.100.2" }), res);
|
|
pruefe("ueberlange Eingaben werden angenommen und gekuerzt", res.daten?.ok === true);
|
|
|
|
const zeile = db.prepare(`SELECT * FROM wd_anfragen WHERE nummer = ?`).get(res.daten.nummer);
|
|
pruefe("Name auf 120 Zeichen begrenzt", zeile.name.length === 120, String(zeile.name.length));
|
|
pruefe("Ziel auf 4000 Zeichen begrenzt", zeile.ziel.length === 4000, String(zeile.ziel.length));
|
|
pruefe("Funktionsliste auf 40 Eintraege begrenzt", JSON.parse(zeile.funktionen).length === 40);
|
|
pruefe("Detailfelder auf 40 begrenzt", Object.keys(JSON.parse(zeile.details)).length === 40);
|
|
|
|
const res2 = antwortAttrappe();
|
|
await anfrageAbsenden(anfrageAttrappe({
|
|
...gueltig(),
|
|
name: "Bösewicht <script>alert(1)</script>",
|
|
ziel: "Zeile1\nZeile2\tmit Tabulator",
|
|
}, { ip: "198.51.100.3" }), res2);
|
|
const z2 = db.prepare(`SELECT * FROM wd_anfragen WHERE nummer = ?`).get(res2.daten.nummer);
|
|
pruefe("HTML wird als Text gespeichert, nicht ausgefuehrt", z2.name.includes("<script>"),
|
|
"-> Entschaerfung passiert bei der Anzeige, nicht beim Speichern");
|
|
pruefe("Zeilenumbruch und Tabulator bleiben erhalten",
|
|
z2.ziel.includes("\n") && z2.ziel.includes("\t"));
|
|
}
|
|
|
|
/* ---------- 5. Verwaltung ist ohne Anmeldung dicht ---------- */
|
|
console.log("\n5. Verwaltung ohne Anmeldung");
|
|
{
|
|
for (const [name, fn, req] of [
|
|
["Liste", anfragenListe, anfrageAttrappe(null, { query: {} })],
|
|
["Detail", anfrageDetail, anfrageAttrappe(null, { params: { id: "x" } })],
|
|
["Status aendern", anfrageStatus, anfrageAttrappe({ status: "angebot" }, { params: { id: "x" } })],
|
|
["Archivieren", anfrageArchivieren, anfrageAttrappe({}, { params: { id: "x" } })],
|
|
]) {
|
|
const res = antwortAttrappe();
|
|
await fn(req, res);
|
|
pruefe(name + " -> 401 ohne Anmeldung", res.code === 401, "war " + res.code);
|
|
pruefe(name + " -> keine Daten im Fehlerfall", !res.daten?.anfragen && !res.daten?.anfrage);
|
|
}
|
|
|
|
const res = antwortAttrappe();
|
|
await anfragenListe(anfrageAttrappe(null, { auth: "voellig-erfundenes-token" }), res);
|
|
pruefe("erfundenes Token wird abgelehnt", res.code === 401);
|
|
}
|
|
|
|
console.log(`\n=== ${ok} bestanden, ${schlecht} gescheitert ===\n`);
|
|
process.exit(schlecht === 0 ? 0 : 1);
|