Files
trainingsplan/tests/test_ui_auth.js
T
AI-Agent 135425c781
Image bauen und in die Registry schieben / build (push) Successful in 6s
Wizard: User erst beim Abschliessen, Reihenfolge Sprache/Design/Modus, Reset im Einzelmodus
- Benutzer wird erst beim 'Einrichtung abschliessen' angelegt (POST
  /api/register im wizardFinish); bis dahin frei umentscheidbar, im Modus-
  Schritt Hinweis + Live-Validierung (Finish deaktiviert bis ausgefuellt)
- Reihenfolge: Sprache -> Darstellung (Theme/OLED/Akzent) -> Modus
  (Einzel/Mehrbenutzer); Onboarding bleibt Sprache -> Darstellung
- Sprach-Schritt: Repo-Link + Kurztext zum Starten (nutzt den Raum)
- Wizard-Modal: min-height statt fixer Hoehe -> Modus-Schritt mit Formular
  wird nicht mehr abgeschnitten
- Server-Reset auch im Einzelmodus moeglich (nur Bestaetigungstext,
  kein Passwort noetig); Reset-Sektion erscheint dann entsprechend
- Tests: 167 Checks gruen (39+18+52+32+26)
2026-10-02 19:57:59 +00:00

263 lines
12 KiB
JavaScript

/* UI-Test Auth-Gate (jsdom) gegen einen echten Server.
*
* Lauf: NODE_PATH=<...>/node_modules node tests/test_ui_auth.js
*/
const path = require("path");
const BASE = path.join(__dirname, "..");
const { JSDOM } = require("jsdom");
const { spawn } = require("child_process");
const fs = require("fs");
const http = require("http");
const STATE = "/tmp/tp_state_ui_auth";
const PORT = 8237;
const BASE_URL = `http://127.0.0.1:${PORT}`;
let pass = 0, fail = 0;
function check(name, cond, detail = "") {
if (cond) { pass++; console.log(" ok " + name); }
else { fail++; console.log("FAIL " + name + " " + detail); }
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
function api(method, p, body, token) {
return new Promise((resolve, reject) => {
const data = body ? JSON.stringify(body) : null;
const req = http.request(BASE_URL + p, {
method,
headers: Object.assign(data ? {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(data),
} : {}, token ? { Authorization: "Bearer " + token } : {}),
}, res => {
let d = "";
res.on("data", c => d += c);
res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(d || "{}") }));
});
if (data) req.write(data);
req.end();
req.on("error", reject);
});
}
async function loadApp(html, appJs, token) {
const dom = new JSDOM(html, { runScripts: "dangerously", url: BASE_URL + "/" });
const { window } = dom;
window.errors = [];
window.addEventListener("error", e => window.errors.push(e.message || String(e)));
window.fetch = (url, opts) => fetch(BASE_URL + url, opts);
if (token) window.localStorage.setItem("tp_token", token);
window.eval(appJs);
return window;
}
async function main() {
fs.rmSync(STATE, { recursive: true, force: true });
fs.mkdirSync(STATE, { recursive: true });
const server = spawn("python3", [path.join(BASE, "server.py")], {
env: { ...process.env, TRAINING_STATE_DIR: STATE, PORT: String(PORT) },
cwd: BASE, stdio: "ignore",
});
try {
for (let i = 0; i < 60; i++) {
try { await api("GET", "/api/meta"); break; }
catch (_) { await sleep(200); }
}
const html = fs.readFileSync(path.join(BASE, "index.html"), "utf8")
.replace(/<script src="\/app.js"><\/script>/, "");
const appJs = fs.readFileSync(path.join(BASE, "app.js"), "utf8");
// --- Szenario 0: Ersteinrichtungs-Wizard (offener Modus) ---------------
const winSetup = await loadApp(html, appJs, null);
await sleep(1000);
const sgate = () => winSetup.document.querySelector("#setup-gate");
check("Setup: Gate im offenen Modus sichtbar",
!!sgate() && !sgate().hidden);
check("Setup: Sprache kommt zuerst",
sgate().querySelector(".wizard-title")
&& sgate().querySelector(".wizard-title").textContent === "Sprache festlegen");
Array.from(sgate().querySelectorAll(".day-btn"))
.find(b => b.textContent === "English").click();
await sleep(200);
check("Setup: Wizard wechselt auf Englisch",
Array.from(sgate().querySelectorAll(".wizard-nav .btn"))
.some(b => b.textContent === "Next"));
Array.from(sgate().querySelectorAll(".wizard-nav .btn"))
.find(b => b.textContent === "Next").click();
await sleep(200);
check("Setup: Darstellung kommt als zweites",
sgate().querySelector(".wizard-title").textContent === "Appearance"
&& !!sgate().querySelector(".accent-row")
&& !!sgate().querySelector(".toggle-row"));
Array.from(sgate().querySelectorAll(".wizard-nav .btn"))
.find(b => b.textContent === "Next").click();
await sleep(200);
const setupOptions = () => Array.from(sgate().querySelectorAll(".setup-option"));
check("Setup: zwei gleichwertige Optionen",
setupOptions().some(b => b.textContent.includes("Multi-user mode"))
&& setupOptions().some(b => b.textContent.includes("Single-user mode")));
check("Setup: Mehrbenutzer-Infotext",
setupOptions().some(b => b.textContent.includes("administrator")
&& b.textContent.toLowerCase().includes("single user")));
setupOptions().find(b => b.textContent.includes("Multi-user mode")).click();
await sleep(150);
check("Setup: Formular nach Mehrbenutzermodus-Klick",
!!sgate().querySelector("input#setup-user")
&& !!sgate().querySelector("input#setup-pass"));
// User wird erst beim Abschliessen erstellt (nicht beim Tippen)
sgate().querySelector("#setup-user").value = "alex";
sgate().querySelector("#setup-pass").value = "geheim";
sgate().querySelector("#setup-user")
.dispatchEvent(new winSetup.window.Event("input", { bubbles: true }));
sgate().querySelector("#setup-pass")
.dispatchEvent(new winSetup.window.Event("input", { bubbles: true }));
await sleep(150);
check("Setup: User wird erst beim Abschliessen erstellt",
(await api("GET", "/api/meta")).body.users === 0);
// Einzelmodus waehlen -> abschliessen (User wird NICHT erstellt)
setupOptions().find(b => b.textContent.includes("Single-user mode")).click();
Array.from(sgate().querySelectorAll(".wizard-nav .btn"))
.find(b => b.textContent === "Finish setup").click();
await sleep(400);
check("Setup: Gate zu + setupDone + Sprache persistiert",
sgate().hidden
&& (await api("GET", "/api/settings")).body.setupDone === true
&& (await api("GET", "/api/settings")).body.language === "en");
const reg = await api("POST", "/api/register", { username: "alex", password: "geheim" });
check("API: Register", reg.status === 200 && !!reg.body.token, reg.body);
// --- Szenario 1: ohne Token -> Auth-Gate, Inline-Fehler, Login in-place ---
const winNoToken = await loadApp(html, appJs, null);
await sleep(1200);
const gate = () => winNoToken.document.querySelector("#auth-gate");
check("ohne Token: Auth-Gate sichtbar",
gate() && !gate().hidden && winNoToken.document.body.classList.contains("no-auth"));
check("ohne Token: Login-Formular + Felder",
!!gate().querySelector('input#auth-user')
&& !!gate().querySelector('input#auth-pass')
&& !!gate().querySelector(".btn-primary"));
check("ohne Token: Login-Screen in Server-Sprache (en)",
Array.from(gate().querySelectorAll(".btn"))
.some(b => b.textContent === "Log in")
&& gate().querySelector(".auth-sub").textContent.includes("Log in to see"));
// falsches Kennwort -> Inline-Fehler
gate().querySelector('input#auth-user').value = "alex";
gate().querySelector('input#auth-pass').value = "falsch";
Array.from(gate().querySelectorAll(".btn")).find(b => b.textContent === "Log in").click();
await sleep(500);
const authErr = gate().querySelector(".auth-error");
check("ohne Token: Inline-Fehler bei falschem Kennwort",
authErr && !authErr.hidden
&& authErr.textContent.includes("Incorrect username or password"),
authErr && authErr.textContent);
// korrektes Kennwort -> Login wechselt OHNE Reload in-place
gate().querySelector('input#auth-pass').value = "geheim";
Array.from(gate().querySelectorAll(".btn")).find(b => b.textContent === "Log in").click();
let loggedIn = false;
for (let i = 0; i < 60; i++) {
const og = winNoToken.document.querySelector("#setup-gate");
if (og && !og.hidden && winNoToken.document.body.classList.contains("no-auth")) {
loggedIn = true; // Onboarding-Gate erscheint -> Anmeldung war erfolgreich
break;
}
if (winNoToken.document.querySelectorAll(".act-card").length === 3
&& winNoToken.document.querySelector("#logout-btn") === null) {
loggedIn = true;
break;
}
await sleep(100);
}
check("Login: wechselt ohne Reload in den eingeloggten Zustand", loggedIn);
// Onboarding abschliessen (im selben Fenster)
const ogate = () => winNoToken.document.querySelector("#setup-gate");
if (ogate() && !ogate().hidden) {
Array.from(ogate().querySelectorAll(".day-btn"))
.find(b => b.textContent === "German").click();
await sleep(150);
Array.from(ogate().querySelectorAll(".wizard-nav .btn"))
.find(b => b.textContent === "Weiter").click();
await sleep(200);
Array.from(ogate().querySelectorAll(".wizard-nav .btn"))
.find(b => b.textContent === "Einrichtung abschließen").click();
await sleep(400);
}
check("Login: Onboarding abgeschlossen, Heute-Ansicht sichtbar",
ogate() && ogate().hidden
&& winNoToken.document.querySelectorAll(".act-card").length === 3);
check("Login: kein Auth-Gate mehr",
winNoToken.document.querySelector("#auth-gate").hidden);
// --- Szenario 2: frisches Fenster mit Token -> direkt eingeloggt ------
const winTok = await loadApp(html, appJs, reg.body.token);
await sleep(1200);
let tokOk = false;
for (let i = 0; i < 50; i++) {
if (winTok.document.querySelectorAll(".act-card").length === 3) {
tokOk = true;
break;
}
await sleep(100);
}
check("mit Token: Heute-Ansicht geladen (kein Onboarding mehr)", tokOk);
check("mit Token: kein Auth-Gate", winTok.document.querySelector("#auth-gate").hidden);
// --- Szenario 3: Einstellungen mit Konto- und Benutzer-Bereich --------
winTok.document.querySelector('.tab[data-view="settings"]').click();
let secTitles = [];
for (let i = 0; i < 40; i++) {
secTitles = Array.from(winTok.document.querySelectorAll(".data-section h3"))
.map(h => h.textContent);
if (secTitles.includes("Konto") && secTitles.includes("Benutzer")) break;
await sleep(100);
}
check("Einstellungen: Konto-Bereich (Kennwort aendern)",
secTitles.includes("Konto")
&& Array.from(winTok.document.querySelectorAll(".data-section"))
.some(s => s.querySelector("h3").textContent === "Konto"
&& !!s.querySelector('input[placeholder="Aktuelles Kennwort"]')
&& !!s.querySelector('input[placeholder="Neues Kennwort"]')
&& Array.from(s.querySelectorAll(".btn"))
.some(b => b.textContent === "Abmelden")));
check("Einstellungen: nur ein Benutzer -> Hinweis statt Liste", await (async () => {
for (let i = 0; i < 40; i++) {
if (Array.from(winTok.document.querySelectorAll(".user-row"))
.some(r => r.textContent.includes("einzige"))) return true;
await sleep(100);
}
return false;
})());
// Zweiten Benutzer anlegen -> Liste zeigt ihn, Admin bleibt draussen
const createdB = await api("POST", "/api/users",
{ username: "berta", password: "geheim" }, reg.body.token);
check("API: Owner legt Berta an", createdB.status === 200, createdB.body);
winTok.document.querySelector('.tab[data-view="today"]').click();
winTok.document.querySelector('.tab[data-view="settings"]').click();
await sleep(400);
const rows = () => Array.from(winTok.document.querySelectorAll(".user-row"));
check("Einstellungen: Berta in Liste, Admin nicht aufgelistet",
rows().length === 1 && rows()[0].textContent.includes("berta")
&& !rows().some(r => r.textContent.includes("alex")));
check("Einstellungen: Kennwort-Knopf pro Benutzer",
Array.from(rows()[0].querySelectorAll(".btn"))
.some(b => b.textContent === "Kennwort ändern"));
// --- Szenario 4: API-Login-Faelle --------------------------------------
check("API: Login mit falschem Passwort -> 401",
(await api("POST", "/api/login", { username: "alex", password: "falsch" })).status === 401);
check("API: Login korrekt -> Token",
(await api("POST", "/api/login", { username: "alex", password: "geheim" })).status === 200);
const errs = winNoToken.errors.concat(winTok.errors);
if (errs.length) {
console.log("JS-Fehler:", errs.slice(0, 5).join(" | "));
fail += errs.length;
}
} finally {
server.kill("SIGTERM");
}
console.log(`\n${pass} ok, ${fail} fail`);
process.exit(fail ? 1 : 0);
}
main().catch(e => { console.error(e); process.exit(1); });