Files
sshkeyvault/tests/e2e/anmeldung.spec.ts
T
florian.wildeandClaude Opus 5.5 d1ba822a4a feat(auth): Anmeldeseiten, TOTP-Einrichtung und Bestaetigung (#004)
Anmeldung, Einrichtung des zweiten Faktors fuer den Notfall-Admin,
erneute Bestaetigung vor kritischen Aktionen und die Seite fuer
fehlende Rechte. Die Huelle zeigt jetzt den echten Benutzer, und jede
Seite prueft die Sitzung selbst; ein struktureller Test haelt das fest.

Die Ablaeufe sind im gebauten Stand gegen den Mock-OIDC geprueft
(pnpm test:e2e): Admin, Konto ohne Gruppe, Notfall-Admin mit TOTP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:28:51 +02:00

98 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test, type Page } from "@playwright/test";
import pg from "pg";
import "dotenv/config";
import { base32Dekodieren, totpCode, totpSchritt } from "../../src/lib/totp";
/**
* Anmeldung im gebauten Stand (#004): OIDC ueber den Mock-Anbieter und Notfallzugang mit
* TOTP-Einrichtung. Laeuft gegen die Entwicklungsdatenbank und setzt dort vorher das TOTP
* des Notfall-Admins zurueck – deshalb nie gegen einen Betrieb richten.
*/
const NOTFALL_PASSWORT = process.env["SSHKV_BREAKGLASS_PASSWORD"] ?? "";
async function datenbank<T>(arbeit: (client: pg.Client) => Promise<T>): Promise<T> {
const client = new pg.Client({ connectionString: process.env["DATABASE_URL"] });
await client.connect();
try {
return await arbeit(client);
} finally {
await client.end();
}
}
async function oidcAnmelden(page: Page, rolle: "admin" | "operator" | "reader" | "ohne") {
await page.goto("/");
await expect(page).toHaveURL(/\/anmelden/);
await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click();
await page.locator(`button[data-rolle="${rolle}"]`).click();
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
}
test.beforeAll(async () => {
await datenbank(async (client) => {
await client.query(
`UPDATE "Benutzer" SET "totpGeheimnis" = NULL, "totpEingerichtetAm" = NULL, "totpLetzterSchritt" = NULL WHERE "istNotfall"`,
);
await client.query(`DELETE FROM "Ratenbegrenzung"`);
});
});
test("ohne Sitzung geht es zur Anmeldung, mit Ziel", async ({ page }) => {
await page.goto("/hosts");
await expect(page).toHaveURL(/\/anmelden\?ziel=%2Fhosts/);
await expect(page.getByRole("heading", { name: "Anmelden" })).toBeVisible();
});
test("OIDC-Admin landet in der Übersicht und kann sich abmelden", async ({ page }) => {
await oidcAnmelden(page, "admin");
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
await expect(page.getByText("Admin (Mock)")).toBeVisible();
await expect(page.getByText("Admin · über Mock-OIDC")).toBeVisible();
await page.getByRole("button", { name: "Abmelden" }).click();
await expect(page.getByText("Sie wurden abgemeldet.")).toBeVisible();
await page.goto("/");
await expect(page).toHaveURL(/\/anmelden/);
});
test("OIDC ohne sshkv-Gruppe wird abgewiesen", async ({ page }) => {
await oidcAnmelden(page, "ohne");
await expect(page).toHaveURL(/fehler=keine-gruppe/);
await expect(page.getByText(/keiner SSHKeyVault-Gruppe zugeordnet/)).toBeVisible();
});
test("Notfall-Admin richtet TOTP ein und bestätigt sich erneut", async ({ page }) => {
test.skip(NOTFALL_PASSWORT.length < 16, "SSHKV_BREAKGLASS_PASSWORD fehlt in .env");
await page.goto("/anmelden");
await page.getByText("Notfallzugang", { exact: true }).click();
await page.getByLabel("Anmeldename").fill("notfall");
await page.getByLabel("Passwort").fill(NOTFALL_PASSWORT);
await page.getByRole("button", { name: "Mit Notfallzugang anmelden" }).click();
await expect(page).toHaveURL(/\/anmelden\/totp/);
await expect(page.getByRole("img", { name: /QR-Code/ })).toBeVisible();
const geheimnis = ((await page.locator("code").first().textContent()) ?? "").trim();
expect(geheimnis).toMatch(/^[A-Z2-7]{32}$/);
const code = () => totpCode(base32Dekodieren(geheimnis), totpSchritt(Date.now()));
await page.getByLabel("Code zur Bestätigung").fill("000000");
await page.getByRole("button", { name: /TOTP einrichten/ }).click();
await expect(page.getByText(/Der Code passt nicht/)).toBeVisible();
await page.getByLabel("Code zur Bestätigung").fill(code());
await page.getByRole("button", { name: /TOTP einrichten/ }).click();
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
await expect(page.getByText("Admin · über Notfallzugang")).toBeVisible();
// Erneute Bestaetigung: derselbe Code gilt nicht noch einmal, also auf den naechsten
// Zeitschritt warten.
await page.goto("/bestaetigen?ziel=%2Fhosts");
const schritt = totpSchritt(Date.now());
await expect
.poll(() => totpSchritt(Date.now()), { timeout: 35_000, intervals: [1000] })
.toBeGreaterThan(schritt);
await page.getByLabel("Passwort").fill(NOTFALL_PASSWORT);
await page.getByLabel("Code aus der Authenticator-App").fill(code());
await page.getByRole("button", { name: "Bestätigen" }).click();
await expect(page).toHaveURL(/\/hosts$/);
});