Merge branch 'phase/032-nacharbeiten'

This commit is contained in:
2026-10-12 17:56:33 +02:00
27 changed files with 789 additions and 113 deletions
+23 -1
View File
@@ -236,8 +236,30 @@ describe("Protokoll", () => {
expect(pruefung?.ergebnis).toBe(zweiter.id);
});
it("vergibt IDs ohne Zweierschritte (#032)", async () => {
// Frueher zogen Spalten-Default und Trigger je eine Nummer aus der Sequenz.
const [a, b, c] = [await eintragen("eins"), await eintragen("zwei"), await eintragen("drei")];
expect(b.id).toBe(a.id + 1n);
expect(c.id).toBe(a.id + 2n);
const [pruefung] = await prisma.$queryRaw<
{ ergebnis: bigint | null }[]
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
expect(pruefung?.ergebnis).toBeNull();
});
it("ueberschreibt eine mitgegebene ID unter der Sperre (#032)", async () => {
const erster = await eintragen("eins");
const zweiter = await prisma.protokoll.create({
data: { id: 999n, akteurName: "Test", aktion: "zwei", ergebnis: "ERFOLG" },
});
expect(zweiter.id).toBe(erster.id + 1n);
expect(zweiter.vorgaengerHash).toBe(erster.hash);
});
it("bleibt bei gleichzeitigem Schreiben eine lueckenlose Kette", async () => {
await Promise.all(Array.from({ length: 20 }, (_, i) => eintragen(`parallel-${i}`)));
const zeilen = await Promise.all(Array.from({ length: 20 }, (_, i) => eintragen(`parallel-${i}`)));
const ids = zeilen.map((z) => z.id).sort((x, y) => (x < y ? -1 : 1));
expect(ids.at(-1)! - ids[0]!).toBe(19n);
const [pruefung] = await prisma.$queryRaw<
{ ergebnis: bigint | null }[]
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
+25
View File
@@ -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);
+46
View File
@@ -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 -24
View File
@@ -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,
}) => {
+90 -1
View File
@@ -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);
});
+8
View File
@@ -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 }) => {
+81 -28
View File
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { rotationsDatum } from "@/lib/rotation";
import {
rotationAbbrechen,
rotationAltWiderrufen,
rotationStarten,
rotationUebernehmen,
@@ -55,38 +56,44 @@ async function trockenlaufUndAusrollen(schluesselId: string) {
expect(ausrollen.fehler).toBeNull();
}
/** Dienst-Key wie nach dem Assistenten: Private Key auf A (Alias zu-b), Public Key auf B, ausgerollt. */
async function alterSchluesselAusgerollt(aId: string, bId: string) {
const paar = await schluesselpaarVersiegeltErzeugen("ED25519", "sshkv:backup");
const alt = await prisma.schluessel.create({
data: {
id: paar.id,
name: "Backup",
slug: "backup",
typ: "ED25519",
kategorie: "DIENST",
publicKey: paar.publicKeyZeile,
fingerprint: paar.fingerprint,
kommentar: "sshkv:backup",
...paar.versiegelt,
},
select: { id: true, publicKey: true },
});
await privateVerwendungAnlegen(
{
schluesselId: alt.id,
hostId: aId,
zielbenutzer: "root",
pfad: "~/.ssh/id_backup",
sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 },
knownHostsFuer: [bId],
},
AKTEUR,
);
await publicVerwendungAnlegen({ schluesselId: alt.id, hostId: bId, zielbenutzer: "root" }, AKTEUR);
await trockenlaufUndAusrollen(alt.id);
return alt;
}
describe("Rotation", () => {
it("tauscht den Schluessel aus, ohne dass der Alias aufhoert zu funktionieren", async () => {
const a = await eingerichteterHost(zielA, "ziel-a");
const b = await eingerichteterHost(zielB, "ziel-b");
const paar = await schluesselpaarVersiegeltErzeugen("ED25519", "sshkv:backup");
const alt = await prisma.schluessel.create({
data: {
id: paar.id,
name: "Backup",
slug: "backup",
typ: "ED25519",
kategorie: "DIENST",
publicKey: paar.publicKeyZeile,
fingerprint: paar.fingerprint,
kommentar: "sshkv:backup",
...paar.versiegelt,
},
select: { id: true, publicKey: true },
});
await privateVerwendungAnlegen(
{
schluesselId: alt.id,
hostId: a.id,
zielbenutzer: "root",
pfad: "~/.ssh/id_backup",
sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 },
knownHostsFuer: [b.id],
},
AKTEUR,
);
await publicVerwendungAnlegen({ schluesselId: alt.id, hostId: b.id, zielbenutzer: "root" }, AKTEUR);
await trockenlaufUndAusrollen(alt.id);
const alt = await alterSchluesselAusgerollt(a.id, b.id);
// 1–2: Nachfolger erzeugen, Zuweisungen uebernehmen.
await vorgangImWorker(await rotationStarten(alt.id, AKTEUR));
@@ -123,4 +130,50 @@ describe("Rotation", () => {
const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un");
expect(ssh.stdout.trim()).toBe("root");
});
it("bricht ab: der ausgerollte neue Schluessel verschwindet, der alte arbeitet weiter (#032)", async () => {
const a = await eingerichteterHost(zielA, "ziel-a");
const b = await eingerichteterHost(zielB, "ziel-b");
const alt = await alterSchluesselAusgerollt(a.id, b.id);
const altVorher = await prisma.schluessel.findUniqueOrThrow({ where: { id: alt.id } });
await vorgangImWorker(await rotationStarten(alt.id, AKTEUR));
await rotationUebernehmen(alt.id, AKTEUR, { privatErlaubt: true });
const neu = await prisma.schluessel.findFirstOrThrow({
where: { vorgaengerId: alt.id },
select: { id: true, publicKey: true },
});
await trockenlaufUndAusrollen(neu.id);
const neuerPfad = `id_backup-r${rotationsDatum().kompakt}`;
expect((await befehlAlsRoot(zielA, "cat /root/.ssh/config")).stdout).toContain(
`IdentityFile ~/.ssh/${neuerPfad}`,
);
// Abbrechen = Widerruf des neuen Schluessels mit Dry-Run und Anwenden.
await rotationAbbrechen(alt.id, AKTEUR, { privatErlaubt: true });
await trockenlaufUndAusrollen(neu.id);
expect((await widerrufAbschliessen([neu.id], AKTEUR))[0]?.status).toBe("WIDERRUFEN");
const authorized = (await befehlAlsRoot(zielB, "cat /root/.ssh/authorized_keys")).stdout;
expect(authorized).not.toContain(neu.publicKey.split(" ")[1]!);
expect(authorized).toContain(alt.publicKey.split(" ")[1]!);
const dateien = (await befehlAlsRoot(zielA, "ls /root/.ssh")).stdout.split("\n");
expect(dateien).toContain("id_backup");
expect(dateien).not.toContain(neuerPfad);
// Der Alias zeigt wieder auf die alte Datei – und funktioniert.
const config = (await befehlAlsRoot(zielA, "cat /root/.ssh/config")).stdout;
expect(config.match(/^Host zu-b$/gm)).toHaveLength(1);
expect(config).toContain("IdentityFile ~/.ssh/id_backup\n");
const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un");
expect(ssh.stdout.trim()).toBe("root");
// Der alte Schluessel ist unveraendert, seine Zuweisungen sind ausgerollt.
expect(await prisma.schluessel.findUniqueOrThrow({ where: { id: alt.id } })).toEqual(altVorher);
const zustaende = [
...(await prisma.publicVerwendung.findMany({ where: { schluesselId: alt.id } })),
...(await prisma.privateVerwendung.findMany({ where: { schluesselId: alt.id } })),
].map((v) => v.zustand);
expect(zustaende).toEqual(["AUSGEROLLT", "AUSGEROLLT"]);
expect((await rotationsStand(alt.id)).abgebrochen).toBe(true);
});
});
+1 -1
View File
@@ -75,7 +75,7 @@ beforeEach(async () => {
describe("protokollSeite", () => {
it("blaettert ueber die ID, neueste zuerst, ohne Luecken und Doppelte", async () => {
// IDs sind nicht lueckenlos (der Trigger zieht die Sequenz selbst) – nur die Ordnung zaehlt.
// IDs koennen Luecken haben (Rollbacks, alte Zeilen vor #032) – nur die Ordnung zaehlt.
const ids: string[] = [];
for (let i = 0; i < SEITENGROESSE + 7; i += 1)
ids.unshift(String((await eintragen({ aktion: "host.angelegt" })).id));
+117 -1
View File
@@ -8,13 +8,14 @@ import { rotationsDatum, rotationsName, rotationsPfad } from "@/lib/rotation";
import { rotationsKette } from "@/server/queries/schluessel";
import {
RotationsFehler,
rotationAbbrechen,
rotationAltWiderrufen,
rotationGesperrt,
rotationStarten,
rotationUebernehmen,
rotationsStand,
} from "@/server/services/rotation";
import { widerrufAbschliessen } from "@/server/services/widerruf";
import { widerrufAbschliessen, widerrufEinleiten } from "@/server/services/widerruf";
import { masterKeyVergessen } from "@/worker/geheimnisse";
import { datenbankLeeren } from "./hilfen/db";
import { datenbankAlsText, vorgangImWorker, warteschlangeStoppen } from "./hilfen/vorgang";
@@ -258,3 +259,118 @@ describe("Rotation Schritt fuer Schritt", () => {
await expect(rotationStarten(person.id, admin)).rejects.toThrow(/neuen Public Key/);
});
});
describe("Rotation abbrechen (#032)", () => {
/** Rotation bis Schritt 3: Nachfolger uebernommen und ausgerollt. */
async function laufendeRotation() {
const altId = await alterSchluessel();
const neuId = await nachfolgerErzeugen(altId);
await rotationUebernehmen(altId, admin, { privatErlaubt: true });
const jetzt = new Date();
await prisma.publicVerwendung.updateMany({
where: { schluesselId: neuId },
data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt },
});
await prisma.privateVerwendung.updateMany({
where: { schluesselId: neuId },
data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt },
});
return { altId, neuId };
}
async function verwendungen(schluesselId: string) {
const [oeffentlich, privat] = await Promise.all([
prisma.publicVerwendung.findMany({ where: { schluesselId }, select: { zustand: true } }),
prisma.privateVerwendung.findMany({ where: { schluesselId }, select: { zustand: true } }),
]);
return [...oeffentlich, ...privat].map((v) => v.zustand).sort();
}
it("widerruft den neuen Schluessel, der alte bleibt unveraendert", async () => {
const { altId, neuId } = await laufendeRotation();
const altVorher = await prisma.schluessel.findUniqueOrThrow({ where: { id: altId } });
await expect(rotationAbbrechen(altId, admin, { privatErlaubt: false })).rejects.toThrow(/Admin/);
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
// Ausgerollt heisst: erst Dry-Run und Anwenden entfernen ihn – vorgemerkt, nicht sofort weg.
expect(await verwendungen(neuId)).toEqual(["ENTFERNEN", "ENTFERNEN"]);
expect(await verwendungen(altId)).toEqual(["AUSGEROLLT", "AUSGEROLLT"]);
expect(await prisma.schluessel.findUniqueOrThrow({ where: { id: altId } })).toEqual(altVorher);
const stand = await rotationsStand(altId);
expect(stand.abgebrochen).toBe(true);
// Kein Weiterdrehen mehr, ein zweiter Abbruch aendert nichts.
await expect(rotationUebernehmen(altId, admin, { privatErlaubt: true })).rejects.toThrow(/abgebrochen/);
await expect(rotationAltWiderrufen(altId, admin, { privatErlaubt: true })).rejects.toThrow(/abgebrochen/);
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
expect(await prisma.protokoll.count({ where: { aktion: "schluessel.rotation.abgebrochen" } })).toBe(1);
const eintrag = await prisma.protokoll.findFirstOrThrow({
where: { aktion: "schluessel.rotation.abgebrochen" },
select: { fingerprint: true, details: true },
});
expect(eintrag).toMatchObject({ fingerprint: "SHA256:alt", details: { nachfolgerId: neuId } });
// Nach dem Anwenden: der Nachfolger ist WIDERRUFEN (nicht ROTIERT), die Kette bleibt.
await prisma.publicVerwendung.updateMany({
where: { schluesselId: neuId },
data: { zustand: "ENTFERNT" },
});
await prisma.privateVerwendung.updateMany({
where: { schluesselId: neuId },
data: { zustand: "ENTFERNT" },
});
expect((await widerrufAbschliessen([neuId], admin))[0]?.status).toBe("WIDERRUFEN");
expect((await rotationsKette(altId)).map((g) => [g.id, g.status])).toEqual([
[altId, "AKTIV"],
[neuId, "WIDERRUFEN"],
]);
expect(
(await prisma.schluessel.findUniqueOrThrow({ where: { id: neuId }, select: { vorgaengerId: true } }))
.vorgaengerId,
).toBe(altId);
await expect(rotationStarten(altId, admin)).rejects.toThrow(/abgebrochen/);
});
it("setzt nie ausgerollte Zuweisungen des neuen Schluessels sofort auf ENTFERNT", async () => {
const altId = await alterSchluessel();
const neuId = await nachfolgerErzeugen(altId);
await rotationUebernehmen(altId, admin, { privatErlaubt: true });
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
expect(await verwendungen(neuId)).toEqual(["ENTFERNT", "ENTFERNT"]);
expect((await widerrufAbschliessen([neuId], admin))[0]?.status).toBe("WIDERRUFEN");
expect(await verwendungen(altId)).toEqual(["AUSGEROLLT", "AUSGEROLLT"]);
});
it("geht nicht mehr, sobald der alte Schluessel entfernt wird", async () => {
const { altId } = await laufendeRotation();
const privat = await prisma.privateVerwendung.findFirstOrThrow({
where: { schluessel: { vorgaengerId: altId } },
select: { id: true },
});
await prisma.vorgang.create({
data: { art: "VERBINDUNGSTEST", status: "ERLEDIGT", eingabe: { privatVerwendungId: privat.id } },
});
await rotationAltWiderrufen(altId, admin, { privatErlaubt: true });
await expect(rotationAbbrechen(altId, admin, { privatErlaubt: true })).rejects.toThrow(
/nicht mehr abbrechen/,
);
await expect(rotationAbbrechen(b.id, admin, { privatErlaubt: true })).rejects.toThrow(/gibt es nicht/);
});
it("macht aus dem Widerruf des alten Schluessels kein ROTIERT, solange der Abbruch laeuft", async () => {
const { altId, neuId } = await laufendeRotation();
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
await widerrufEinleiten([altId], admin, { privatErlaubt: true });
await prisma.publicVerwendung.updateMany({
where: { schluesselId: altId },
data: { zustand: "ENTFERNT" },
});
await prisma.privateVerwendung.updateMany({
where: { schluesselId: altId },
data: { zustand: "ENTFERNT" },
});
expect((await widerrufAbschliessen([altId], admin))[0]?.status).toBe("WIDERRUFEN");
expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: neuId } })).status).toBe("AKTIV");
});
});
+10
View File
@@ -9,6 +9,7 @@ import {
ablaufZustand,
fingerprintKurz,
freierSlug,
idsAusSuchparameter,
slugAus,
typAnzeige,
erzeugenSchema,
@@ -66,6 +67,15 @@ function kern(privateKey: string): string {
}
describe("Regeln (rein)", () => {
it("liest IDs aus dem Suchparameter, auch UUIDs vom Worker (#032)", () => {
const uuid = "5f703ff1-3877-4af4-af08-84338237fcd8";
expect(idsAusSuchparameter([uuid, "cm1abc"])).toEqual([uuid, "cm1abc"]);
expect(idsAusSuchparameter(`a,b,a`)).toEqual(["a", "b"]);
expect(idsAusSuchparameter(["x y", "../etc", ""])).toEqual([]);
expect(idsAusSuchparameter(undefined)).toEqual([]);
expect(idsAusSuchparameter(["a", "b", "c"], 2)).toEqual(["a", "b"]);
});
it.each([
["Backup srv-backup01 → srv-pve01", "backup-srv-backup01-srv-pve01"],
["Größter Schlüssel für Ärzte", "groesster-schluessel-fuer-aerzte"],