Image bauen und in die Registry schieben / build (push) Successful in 6s
- Wizard: Sprach-Hinweis gekuerzt, Repo-Link nur beim Erst-Setup (nicht im Onboarding), Hinweis 'erster Benutzer wird Administrator' - Erster Benutzer (Admin) ist automatisch onboarded und erbt die festgelegten Sprache/Design-Einstellungen (kein erneuter Wizard); Einstellungen werden nach Admin-Anlage sofort frisch gerendert - Set-Zeile mobil-tauglich: nowrap, width 100%, kg/reps-Inputs flexibel (flex 1, min-width 0), Einheit kompakt im Feld, 'S1' statt 'Set 1', Reset als SVG-Icon, kleinere Buttons - kein horizontales Scrollen - Tests: 169 Checks gruen (39+18+52+32+28)
269 lines
12 KiB
JavaScript
269 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);
|
|
// Admin ist automatisch eingestellt (kein Onboarding), Sprache fuer Tests auf Deutsch
|
|
await api("PUT", "/api/settings", { language: "de" }, reg.body.token);
|
|
|
|
// --- 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",
|
|
Array.from(gate().querySelectorAll(".btn"))
|
|
.some(b => b.textContent === "Anmelden")
|
|
&& gate().querySelector(".auth-sub").textContent.includes("Anmelden, um deine Trainingsdaten zu sehen"));
|
|
// 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 === "Anmelden").click();
|
|
await sleep(500);
|
|
const authErr = gate().querySelector(".auth-error");
|
|
check("ohne Token: Inline-Fehler bei falschem Kennwort",
|
|
authErr && !authErr.hidden
|
|
&& authErr.textContent.includes("Kennwort inkorrekt"),
|
|
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 === "Anmelden").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);
|
|
check("Login: Admin ohne Onboarding direkt in Heute-Ansicht",
|
|
winNoToken.document.querySelector("#setup-gate").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"));
|
|
|
|
// Berta (normaler User) durchlaeuft einmaliges Onboarding (Sprache zuerst)
|
|
const loginB = await api("POST", "/api/login",
|
|
{ username: "berta", password: "geheim" });
|
|
const winB = await loadApp(html, appJs, loginB.body.token);
|
|
await sleep(1200);
|
|
const bg = () => winB.document.querySelector("#setup-gate");
|
|
check("Berta: Onboarding erscheint (einmalig)",
|
|
bg() && !bg().hidden);
|
|
Array.from(bg().querySelectorAll(".wizard-nav .btn"))
|
|
.find(b => b.textContent === "Weiter").click();
|
|
await sleep(150);
|
|
Array.from(bg().querySelectorAll(".wizard-nav .btn"))
|
|
.find(b => b.textContent === "Einrichtung abschließen").click();
|
|
await sleep(400);
|
|
check("Berta: nach Onboarding Heute-Ansicht",
|
|
bg().hidden && winB.document.querySelectorAll(".act-card").length === 3);
|
|
|
|
// --- 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); }); |