Merge branch 'phase/032-nacharbeiten'
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
import "dotenv/config";
|
||||
|
||||
/**
|
||||
@@ -35,6 +36,30 @@ export async function anmelden(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
|
||||
}
|
||||
|
||||
/** Direkter Zugriff auf die Datenbank des gebauten Stands – fuer Vorbedingungen und Pruefungen. */
|
||||
export 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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erneute Anmeldung beim Mock-OIDC (#014). Der Mock uebernimmt nur die Claims aus dem
|
||||
* Formular; `auth_time` muss deshalb mitgeschickt werden, sonst lehnt sshkv die Bestaetigung ab.
|
||||
*/
|
||||
export async function oidcBestaetigen(page: Page): Promise<void> {
|
||||
await expect(page).toHaveURL(/\/bestaetigen\?ziel=/);
|
||||
await page.getByRole("link", { name: /erneut anmelden/ }).click();
|
||||
await page.locator('button[data-rolle="admin"]').click();
|
||||
const claims = JSON.parse(await page.locator("#claims").inputValue()) as Record<string, unknown>;
|
||||
await page.locator("#claims").fill(JSON.stringify({ ...claims, auth_time: Math.floor(Date.now() / 1000) }));
|
||||
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function hostEinrichten(page: Page, name: string, port: string): Promise<void> {
|
||||
await page.goto("/hosts/neu");
|
||||
await page.getByLabel("Name").fill(name);
|
||||
|
||||
@@ -69,3 +69,49 @@ test("Rotation mit Schrittanzeige", async ({ page }) => {
|
||||
await archivieren(page, hostA);
|
||||
await archivieren(page, hostB);
|
||||
});
|
||||
|
||||
test("Rotation abbrechen, nachdem der neue Schlüssel ausgerollt ist (#032)", async ({ page }) => {
|
||||
const kennung = Date.now().toString(36);
|
||||
const hostA = `zz-e2e-a-${kennung}`;
|
||||
const hostB = `zz-e2e-b-${kennung}`;
|
||||
const zweck = `zz-e2e Abbruch ${kennung}`;
|
||||
|
||||
zieleZuruecksetzen();
|
||||
await anmelden(page);
|
||||
await hostEinrichten(page, hostA, PORT.a);
|
||||
await hostEinrichten(page, hostB, PORT.b);
|
||||
const schluesselSeite = await kernAufbauen(page, zweck, hostA, hostB);
|
||||
await page.reload();
|
||||
|
||||
await page.getByRole("link", { name: "Rotieren" }).click();
|
||||
await page.getByRole("button", { name: "Neuen Schlüssel erzeugen und Zuweisungen übernehmen" }).click();
|
||||
await page.getByRole("button", { name: "Dry-Run starten" }).click({ timeout: 60_000 });
|
||||
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
|
||||
await expect(page.getByRole("button", { name: "Verbindungen testen" })).toBeVisible({ timeout: 60_000 });
|
||||
expect(aufZiel("ziel-a", "cat /root/.ssh/config")).toMatch(/IdentityFile ~\/\.ssh\/.*-r\d{8}$/m);
|
||||
|
||||
// Abbrechen: Rueckfrage, dann Widerruf des neuen Schluessels mit Dry-Run.
|
||||
await page.getByRole("button", { name: "Rotation abbrechen" }).click();
|
||||
const rueckfrage = page.getByRole("group", { name: "Rotation abbrechen?" });
|
||||
await expect(rueckfrage.getByText(`„${zweck}“ bleibt aktiv und unverändert`)).toBeVisible();
|
||||
await rueckfrage.getByRole("button", { name: "Neuen Schlüssel widerrufen" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Rotation wird abgebrochen" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
|
||||
await expect(page.getByRole("heading", { name: "Rotation abgebrochen" })).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
// Auf den Hosts: wie vorher. Der Alias geht wieder mit dem alten Schluessel.
|
||||
expect(aufZiel("ziel-a", "ls /root/.ssh")).not.toMatch(/-r\d{8}/);
|
||||
expect(aufZiel("ziel-a", "cat /root/.ssh/config")).not.toMatch(/-r\d{8}/);
|
||||
expect(aufZiel("ziel-a", "ssh -o BatchMode=yes zu-b id -un").trim()).toBe("root");
|
||||
|
||||
// Der alte bleibt aktiv; die Kette zeigt den widerrufenen Nachfolger, kein „Rotieren“ mehr.
|
||||
await page.goto(schluesselSeite);
|
||||
const kette = page.getByRole("list", { name: "Rotationskette" });
|
||||
await expect(kette.getByText("widerrufen", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/Die Rotation zu „.*“ wurde abgebrochen/)).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Rotieren" })).toHaveCount(0);
|
||||
|
||||
await archivieren(page, hostA);
|
||||
await archivieren(page, hostB);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
import "dotenv/config";
|
||||
import { schluesselpaarErzeugen } from "../../src/lib/ssh/openssh";
|
||||
import { datenbank, oidcBestaetigen } from "./hilfen";
|
||||
|
||||
/**
|
||||
* Schluessel im gebauten Stand mit laufendem Worker (#012–#014). Testdaten heissen zz-e2e-…
|
||||
@@ -17,16 +17,6 @@ async function oidcAnmelden(page: Page, rolle: "admin" | "operator" | "reader")
|
||||
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
function eindeutig(): string {
|
||||
return `zz-e2e-${Date.now().toString(36)}`;
|
||||
}
|
||||
@@ -104,19 +94,6 @@ test("Leser hinterlegt einen eigenen Public Key und entfernt ihn wieder", async
|
||||
await expect(page.locator("li", { hasText: name })).toHaveCount(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Erneute Anmeldung beim Mock-OIDC (#014). Der Mock uebernimmt nur die Claims aus dem
|
||||
* Formular; `auth_time` muss deshalb mitgeschickt werden, sonst lehnt sshkv die Bestaetigung ab.
|
||||
*/
|
||||
async function oidcBestaetigen(page: Page) {
|
||||
await expect(page).toHaveURL(/\/bestaetigen\?ziel=/);
|
||||
await page.getByRole("link", { name: /erneut anmelden/ }).click();
|
||||
await page.locator('button[data-rolle="admin"]').click();
|
||||
const claims = JSON.parse(await page.locator("#claims").inputValue()) as Record<string, unknown>;
|
||||
await page.locator("#claims").fill(JSON.stringify({ ...claims, auth_time: Math.floor(Date.now() / 1000) }));
|
||||
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||
}
|
||||
|
||||
test("Admin zeigt den Private Key nach Bestätigung an, lädt ihn herunter und löscht ihn", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import {
|
||||
anmelden,
|
||||
archivieren,
|
||||
aufZiel,
|
||||
datenbank,
|
||||
hostEinrichten,
|
||||
kernAufbauen,
|
||||
oidcBestaetigen,
|
||||
PORT,
|
||||
zieleZuruecksetzen,
|
||||
} from "./hilfen";
|
||||
@@ -59,3 +61,90 @@ test("Widerruf mit einem Klick", async ({ page }) => {
|
||||
await archivieren(page, hostA);
|
||||
await archivieren(page, hostB);
|
||||
});
|
||||
|
||||
/** Schluessel erzeugen, Public Key auf `host` zuweisen und ausrollen. Liefert den Key-Teil. */
|
||||
async function publicKeyAusrollen(page: Page, zweck: string, host: string): Promise<string> {
|
||||
await page.goto("/schluessel/neu");
|
||||
await page.getByLabel("Zweck").fill(zweck);
|
||||
await page.getByRole("button", { name: "Schlüssel erzeugen" }).click();
|
||||
await expect(page.getByRole("heading", { name: zweck })).toBeVisible({ timeout: 30_000 });
|
||||
const zeile = (await page.getByLabel("Public Key", { exact: true }).textContent()) ?? "";
|
||||
const keyTeil = zeile.trim().split(/\s+/)[1] ?? "";
|
||||
expect(keyTeil).toMatch(/^AAAA/);
|
||||
|
||||
await page.getByRole("button", { name: "Ausrollen" }).nth(1).click();
|
||||
const oeffentlich = page.getByRole("dialog", { name: "Public Key zuweisen" });
|
||||
await oeffentlich.getByLabel("Ziel", { exact: true }).selectOption({ label: host });
|
||||
await oeffentlich.getByRole("button", { name: "Zuweisen" }).click();
|
||||
await expect(oeffentlich).toBeHidden();
|
||||
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
|
||||
await expect(page.getByText("Ausgerollt.")).toBeVisible({ timeout: 60_000 });
|
||||
return keyTeil;
|
||||
}
|
||||
|
||||
test("Massenwiderruf mit frischer Bestätigung (#032)", async ({ page }) => {
|
||||
const kennung = Date.now().toString(36);
|
||||
const hostB = `zz-e2e-mb-${kennung}`;
|
||||
const praefix = `zz-e2e Massen ${kennung}`;
|
||||
const zwecke = [`${praefix} eins`, `${praefix} zwei`];
|
||||
|
||||
await anmelden(page);
|
||||
// Eine Bestaetigung aus der Anmeldung eben oder einem frueheren Lauf soll nicht mitzaehlen.
|
||||
await datenbank((client) =>
|
||||
client.query(`UPDATE "Benutzer" SET "bestaetigtAm" = NULL WHERE "email" = 'admin@sshkv.test'`),
|
||||
);
|
||||
await hostEinrichten(page, hostB, PORT.b);
|
||||
const keys = [];
|
||||
for (const zweck of zwecke) keys.push(await publicKeyAusrollen(page, zweck, hostB));
|
||||
const vorher = aufZiel("ziel-b", "cat /root/.ssh/authorized_keys");
|
||||
for (const key of keys) expect(vorher).toContain(key);
|
||||
|
||||
// Auswahl in der Liste – ein GET-Formular auf die Massenwiderruf-Seite.
|
||||
await page.goto(`/schluessel?q=${encodeURIComponent(praefix)}`);
|
||||
for (const zweck of zwecke)
|
||||
await page.getByRole("checkbox", { name: `${zweck} für den Massenwiderruf auswählen` }).check();
|
||||
await page.getByRole("button", { name: "Ausgewählte widerrufen" }).click();
|
||||
await expect(page).toHaveURL(/\/schluessel\/widerruf\?id=/);
|
||||
await expect(page.getByRole("heading", { name: "2 Schlüssel widerrufen?" })).toBeVisible();
|
||||
for (const zweck of zwecke)
|
||||
await expect(
|
||||
page.getByRole("region", { name: `Widerruf von ${zweck}` }).getByText(`root@${hostB}`),
|
||||
).toBeVisible();
|
||||
|
||||
// Ohne frische Bestaetigung merkt sshkv nichts vor und schickt zur erneuten Anmeldung.
|
||||
await page.getByRole("button", { name: "2 Schlüssel widerrufen" }).click();
|
||||
await page.getByRole("link", { name: /Anmeldung bestätigen/ }).click();
|
||||
await oidcBestaetigen(page);
|
||||
await expect(page).toHaveURL(/\/schluessel\/widerruf\?id=/);
|
||||
const eingeleitet = await datenbank((client) =>
|
||||
client.query(
|
||||
`SELECT count(*)::int AS n FROM "Schluessel" WHERE "name" = ANY($1) AND "widerrufEingeleitetAm" IS NOT NULL`,
|
||||
[zwecke],
|
||||
),
|
||||
);
|
||||
expect(eingeleitet.rows[0]).toEqual({ n: 0 });
|
||||
|
||||
await page.getByRole("button", { name: "2 Schlüssel widerrufen" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Widerruf läuft" })).toBeVisible();
|
||||
|
||||
// Ein gemeinsamer Dry-Run fuer beide Schluessel, dann anwenden; der Abschluss laeuft danach.
|
||||
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||
await expect(page.getByText(`root@${hostB}:/root/.ssh/authorized_keys`)).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
|
||||
await expect(page.getByRole("heading", { name: "Widerruf läuft" })).toBeHidden({ timeout: 60_000 });
|
||||
const gewaehlt = page.locator("section", {
|
||||
has: page.getByRole("heading", { name: "Gewählte Schlüssel" }),
|
||||
});
|
||||
await expect(gewaehlt.getByText("widerrufen", { exact: true })).toHaveCount(2);
|
||||
await expect(gewaehlt.getByText("Widerruf unvollständig")).toHaveCount(0);
|
||||
|
||||
const nachher = aufZiel("ziel-b", "cat /root/.ssh/authorized_keys");
|
||||
for (const key of keys) expect(nachher).not.toContain(key);
|
||||
const status = await datenbank((client) =>
|
||||
client.query(`SELECT "status" FROM "Schluessel" WHERE "name" = ANY($1)`, [zwecke]),
|
||||
);
|
||||
expect(status.rows).toEqual([{ status: "WIDERRUFEN" }, { status: "WIDERRUFEN" }]);
|
||||
|
||||
await archivieren(page, hostB);
|
||||
});
|
||||
|
||||
@@ -128,6 +128,14 @@ test("Matrix zeigt Gruppen-Zuweisung, filtert, weist zu und exportiert", async (
|
||||
`"${name} Backup";"SHA256:${name}";"${name}-h2";"root";"Public Key";"${name}-g";"restrict";"noch nicht ausgerollt"`,
|
||||
);
|
||||
expect(text).toContain(`"${name}-h3";"deploy";"Public Key";""`);
|
||||
|
||||
// „Zum Dry-Run“ springt direkt zur Dry-Run-Karte der Schluesselseite (#032).
|
||||
await matrix.getByRole("button", { name: new RegExp(`auf ${name}-h3: Public Key direkt`) }).click();
|
||||
await dialog.getByRole("link", { name: "Zum Dry-Run" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/schluessel/${ids.schluessel}#dry-run$`));
|
||||
const karte = page.locator("#dry-run");
|
||||
await expect(karte).toContainText("Dry-Run-Vorschau");
|
||||
await expect(karte).toBeInViewport();
|
||||
});
|
||||
|
||||
test("Leser sieht die Matrix, aber keinen Zuweisen-Knopf", async ({ page }) => {
|
||||
|
||||
Reference in New Issue
Block a user