@@ -225,6 +265,11 @@ export function Rotation({ daten }: { daten: RotationsDaten }) {
)}
+ {/* Abbrechen geht, solange der alte Schluessel noch nicht entfernt wird (#032). */}
+ {neu && schritt >= 1 && schritt <= 4 && !daten.widerruf && (
+
diff --git a/src/components/rotation/RotationAbbrechen.tsx b/src/components/rotation/RotationAbbrechen.tsx
new file mode 100644
index 0000000..ecf6185
--- /dev/null
+++ b/src/components/rotation/RotationAbbrechen.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { Undo2 } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useState, useTransition } from "react";
+import { Button, Formularfehler } from "@/components/ui";
+import { rotationAbbrechenAktion } from "@/server/actions/rotation";
+
+/**
+ * „Rotation abbrechen“ (#032) – auf der Rotationsseite und auf der Seite des Nachfolgers. Der
+ * Klick merkt den neuen Schluessel nur zum Widerruf vor; entfernt wird er erst nach Dry-Run und
+ * „Anwenden“. Deshalb erst eine Rueckfrage, die genau das sagt.
+ */
+export function RotationAbbrechen({
+ altId,
+ altName,
+ neuName,
+}: {
+ altId: string;
+ altName: string;
+ neuName: string;
+}) {
+ const router = useRouter();
+ const [offen, setOffen] = useState(false);
+ const [fehler, setFehler] = useState();
+ const [laeuft, starten] = useTransition();
+
+ if (!offen) {
+ return (
+
+ } onClick={() => setOffen(true)}>
+ Rotation abbrechen
+
+
+ );
+ }
+
+ return (
+
+
Rotation abbrechen?
+
+
+ Der neue Schlüssel „{neuName}“ wird widerrufen – wo er schon ausgerollt ist, zeigt der Dry-Run, was
+ entfernt wird.
+
+ „{altName}“ bleibt aktiv und unverändert; ein config-Alias zeigt danach wieder auf ihn.
+ Die Verknüpfung bleibt in der Rotationskette sichtbar, der Abbruch steht im Protokoll.
+
+
+
+ setOffen(false)}>
+ Zurück
+
+ }
+ disabled={laeuft}
+ onClick={() =>
+ starten(async () => {
+ setFehler(undefined);
+ const antwort = await rotationAbbrechenAktion(altId);
+ if (antwort.fehler) {
+ setFehler(antwort.fehler);
+ return;
+ }
+ setOffen(false);
+ router.refresh();
+ })
+ }
+ >
+ {laeuft ? "Wird vorgemerkt …" : "Neuen Schlüssel widerrufen"}
+
+
+
+ );
+}
diff --git a/src/lib/protokoll-aktionen.ts b/src/lib/protokoll-aktionen.ts
index 3cc6a6e..274a2ba 100644
--- a/src/lib/protokoll-aktionen.ts
+++ b/src/lib/protokoll-aktionen.ts
@@ -83,6 +83,7 @@ export const AKTIONEN: Readonly> = {
"schluessel.widerrufen": a("Schlüssel widerrufen", "widerruf"),
"schluessel.rotiert": a("Schlüssel rotiert", "widerruf"),
"schluessel.rotation.uebernommen": a("Rotation: Zuweisungen übernommen", "widerruf"),
+ "schluessel.rotation.abgebrochen": a("Rotation abgebrochen", "widerruf"),
// Zielkonten (#018)
"zielkonto.angelegt": a("Zielkonto angelegt", "zielkonto"),
diff --git a/src/server/actions/rotation.ts b/src/server/actions/rotation.ts
index 72ba2f8..ac81103 100644
--- a/src/server/actions/rotation.ts
+++ b/src/server/actions/rotation.ts
@@ -6,7 +6,12 @@ import { akteurVon } from "@/server/actions/formular";
import { darf } from "@/server/auth/rechte";
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { FachFehler } from "@/server/services/fachfehler";
-import { rotationAltWiderrufen, rotationStarten, rotationUebernehmen } from "@/server/services/rotation";
+import {
+ rotationAbbrechen,
+ rotationAltWiderrufen,
+ rotationStarten,
+ rotationUebernehmen,
+} from "@/server/services/rotation";
import { VerwendungsFehler } from "@/server/services/verwendungen";
/**
@@ -69,3 +74,22 @@ export async function rotationAltWiderrufenAktion(schluesselId: string): Promise
return alsAntwort(fehler);
}
}
+
+/**
+ * Laufende Rotation abbrechen (#032): merkt den neuen Schluessel zum Widerruf vor. Gleiche Rechte
+ * wie die Rotation selbst; Private Keys entfernt nur, wer sie ausrollen darf (Service).
+ */
+export async function rotationAbbrechenAktion(schluesselId: string): Promise {
+ try {
+ const { benutzer } = await berechtigt();
+ const altId = id.parse(schluesselId);
+ await rotationAbbrechen(altId, akteurVon(benutzer), {
+ privatErlaubt: darf(benutzer.rolle, "privateKeyAusrollen"),
+ });
+ revalidatePath(`/schluessel/${altId}`);
+ revalidatePath(`/schluessel/${altId}/rotieren`);
+ return {};
+ } catch (fehler) {
+ return alsAntwort(fehler);
+ }
+}
diff --git a/src/server/queries/schluessel.ts b/src/server/queries/schluessel.ts
index d4cdb97..2817f39 100644
--- a/src/server/queries/schluessel.ts
+++ b/src/server/queries/schluessel.ts
@@ -58,8 +58,9 @@ export const SCHLUESSEL_DETAIL_SELECT = {
person: { select: { id: true, name: true } },
vorlage: { select: { id: true, name: true } },
erstelltVon: { select: { name: true } },
- vorgaenger: { select: { id: true, name: true, status: true } },
- nachfolger: { select: { id: true, name: true, status: true } },
+ vorgaenger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } },
+ // Ein Nachfolger im Widerruf ist eine abgebrochene Rotation (#032).
+ nachfolger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } },
publicVerwendungen: {
where: { zustand: { not: "ENTFERNT" } },
orderBy: { angelegtAm: "asc" },
diff --git a/src/server/services/rotation.ts b/src/server/services/rotation.ts
index a490ea7..a7e2532 100644
--- a/src/server/services/rotation.ts
+++ b/src/server/services/rotation.ts
@@ -45,7 +45,7 @@ const ALT_SELECT = {
vorlageId: true,
masterKeyVersion: true,
widerrufEingeleitetAm: true,
- nachfolger: { select: { id: true, name: true, status: true } },
+ nachfolger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } },
_count: { select: { privateVerwendungen: { where: { zustand: { in: [...AKTIVE] } } } } },
} satisfies Prisma.SchluesselSelect;
@@ -92,6 +92,13 @@ function gesperrtPruefen(alt: Alt) {
export async function rotationStarten(altId: string, akteur: Akteur): Promise {
const alt = await altLaden(altId);
gesperrtPruefen(alt);
+ if (alt.nachfolger && rotationAbgebrochen(alt.nachfolger)) {
+ // vorgaengerId ist eindeutig und bleibt fuer die Historie stehen (#032): ein zweiter
+ // Nachfolger passt nicht in die Kette.
+ throw new RotationsFehler(
+ "Die Rotation dieses Schlüssels wurde abgebrochen; ein zweiter Nachfolger ist nicht vorgesehen. Bitte einen neuen Schlüssel anlegen, genauso zuweisen und diesen danach widerrufen.",
+ );
+ }
if (alt.nachfolger) throw new RotationsFehler("Für diesen Schlüssel gibt es schon einen Nachfolger.");
const name = rotationsName(alt.name, rotationsDatum().anzeige, NAME_MAX);
const slug = await eindeutigerSlug(null, name);
@@ -111,8 +118,19 @@ export async function rotationStarten(altId: string, akteur: Akteur): Promise {
+ const alt = await altLaden(altId);
+ const neu = alt.nachfolger;
+ if (!neu) throw new RotationsFehler("Für diesen Schlüssel läuft keine Rotation.");
+ if (alt.status === "ROTIERT") throw new RotationsFehler("Die Rotation ist bereits abgeschlossen.");
+ // Idempotent: ein zweiter Klick (oder ein Neuladen) setzt den laufenden Abbruch nur fort.
+ if (rotationAbgebrochen(neu)) return;
+ if (alt.widerrufEingeleitetAm) {
+ throw new RotationsFehler(
+ "Der alte Schlüssel wird bereits entfernt – die Rotation lässt sich nicht mehr abbrechen.",
+ );
+ }
+ await widerrufEinleiten([neu.id], akteur, {
+ privatErlaubt: optionen.privatErlaubt,
+ zusaetzlich: async (tx) => {
+ await protokollieren(
+ {
+ akteur,
+ aktion: "schluessel.rotation.abgebrochen",
+ ergebnis: "ERFOLG",
+ fingerprint: alt.fingerprint,
+ details: { name: alt.name, nachfolgerId: neu.id, nachfolger: neu.name },
+ },
+ tx,
+ );
+ },
+ });
+}
diff --git a/src/server/services/widerruf.ts b/src/server/services/widerruf.ts
index 53bb957..11160d5 100644
--- a/src/server/services/widerruf.ts
+++ b/src/server/services/widerruf.ts
@@ -37,7 +37,7 @@ const SCHLUESSEL_KOPF = {
status: true,
fingerprint: true,
widerrufEingeleitetAm: true,
- nachfolger: { select: { id: true, status: true } },
+ nachfolger: { select: { id: true, status: true, widerrufEingeleitetAm: true } },
} satisfies Prisma.SchluesselSelect;
type SchluesselKopf = Prisma.SchluesselGetPayload<{ select: typeof SCHLUESSEL_KOPF }>;
@@ -167,6 +167,11 @@ export async function zielkontoKandidaten(schluesselIds: readonly string[]): Pro
export type EinleitenOptionen = {
/** Darf der Akteur Private Keys entfernen (privateKeyAusrollen)? Prueft die Aktion per darf(). */
readonly privatErlaubt: boolean;
+ /**
+ * Laeuft in derselben Transaktion wie das Vormerken – etwa der Protokolleintrag eines
+ * Rotationsabbruchs (#032), damit es keinen Abbruch ohne Eintrag gibt.
+ */
+ readonly zusaetzlich?: (tx: Prisma.TransactionClient) => Promise;
};
/**
@@ -264,6 +269,7 @@ export async function widerrufEinleiten(
tx,
);
}
+ await optionen.zusaetzlich?.(tx);
});
}
@@ -406,7 +412,9 @@ export async function widerrufAbschliessen(
continue;
}
// Ein aktiver Nachfolger heisst: der Widerruf ist der letzte Schritt einer Rotation (#023).
- const status = s.nachfolger?.status === "AKTIV" ? "ROTIERT" : "WIDERRUFEN";
+ // Ein Nachfolger im Widerruf ist eine abgebrochene Rotation (#032) – ersetzt ist dann nichts.
+ const status =
+ s.nachfolger?.status === "AKTIV" && !s.nachfolger.widerrufEingeleitetAm ? "ROTIERT" : "WIDERRUFEN";
await prisma.$transaction(async (tx) => {
const geaendert = await tx.schluessel.updateMany({
where: { id: s.id, status: { in: ["AKTIV", "ABGELAUFEN"] } },
diff --git a/tests/e2e/rotation.spec.ts b/tests/e2e/rotation.spec.ts
index 43a35dd..108f443 100644
--- a/tests/e2e/rotation.spec.ts
+++ b/tests/e2e/rotation.spec.ts
@@ -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);
+});
diff --git a/tests/integration/rotation.test.ts b/tests/integration/rotation.test.ts
index 2b368f9..27e141b 100644
--- a/tests/integration/rotation.test.ts
+++ b/tests/integration/rotation.test.ts
@@ -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);
+ });
});
diff --git a/tests/rotation.test.ts b/tests/rotation.test.ts
index 9ee8d34..4658893 100644
--- a/tests/rotation.test.ts
+++ b/tests/rotation.test.ts
@@ -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");
+ });
+});