diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1c7ee26..6edb93c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
- Geführte Rotation des Deployment-Keys in fünf Schritten (erzeugen, verteilen, prüfen, umstellen, aufräumen) – kein Host steht dabei ohne funktionierenden Key da; nicht erreichbare Hosts lassen sich bewusst übergehen (#031)
- Laufende Schlüsselrotation abbrechen – der Nachfolger wird per Dry-Run wieder entfernt (#032)
- „Zum Dry-Run“ in der Zuweisungsmatrix springt direkt zur Dry-Run-Karte (#032)
+- Nach einer abgebrochenen Rotation lässt sich derselbe Schlüssel erneut rotieren (#033)
### Geändert
- Protokoll-IDs werden lückenlos vergeben (#032)
diff --git a/docs/issues/032-nacharbeiten.md b/docs/issues/032-nacharbeiten.md
index ecdcc20..782ac03 100644
--- a/docs/issues/032-nacharbeiten.md
+++ b/docs/issues/032-nacharbeiten.md
@@ -42,6 +42,7 @@ Kleinere offene Punkte aus den Berichten der Phasen 4 und 5 erledigen.
derselbe Schlüssel deshalb nicht erneut über sshkv rotieren; Seite und Fehlermeldung sagen das
(neuen Schlüssel anlegen, zuweisen, alten widerrufen). Eine erneute Rotation bräuchte eine
1:n-Relation mit eindeutigem Teilindex „ein nicht widerrufener Nachfolger je Vorgänger“.
+ → Behoben in #033 (partieller Index statt 1:1).
- **Protokoll-IDs:** Migration `20261012173000_protokoll_id_lueckenlos` setzt den Spalten-Default auf den
Platzhalter `0` (Prisma: `@default(0)`, `create` ohne ID geht weiter); die ID zieht nur noch der
Trigger unter der Advisory-Sperre aus der weiterhin zugeordneten Sequenz. Bestehende Zeilen bleiben
diff --git a/docs/issues/033-erneut-rotieren.md b/docs/issues/033-erneut-rotieren.md
new file mode 100644
index 0000000..f15c574
--- /dev/null
+++ b/docs/issues/033-erneut-rotieren.md
@@ -0,0 +1,27 @@
+# #033 Erneut rotieren nach abgebrochener Rotation
+**Status:** erledigt
+**Meilenstein:** v1.0.0
+**Labels:** backend, datenbank
+**Geschätzt:** 1 h **Tatsächlich:** 0,5 h
+
+## Ziel
+Nach einer abgebrochenen Rotation (#032) lässt sich derselbe Schlüssel erneut rotieren, statt dass ein neuer Schlüssel angelegt, zugewiesen und der alte widerrufen werden muss.
+
+## Aufgaben
+- [x] `vorgaengerId` nicht mehr eindeutig; stattdessen partieller Index: je Vorgänger höchstens ein Nachfolger, der weder widerrufen ist noch gerade widerrufen wird
+- [x] Relation 1:n (`nachfolgerListe`); maßgeblich ist der jüngste Nachfolger (`JUENGSTER`, `mitNachfolger`)
+- [x] Neue Rotation erst, wenn der Abbruch abgeschlossen ist (Nachfolger WIDERRUFEN)
+- [x] Schlüsselseite: „Rotieren“ wieder anbieten, Hinweistext angepasst
+
+## Akzeptanzkriterien
+- [x] DB-Test: Abbruch → während des Abbruchs keine neue Rotation → danach zweiter Nachfolger, Kette zeigt ihn
+- [x] DB-Test: die Datenbank lehnt einen zweiten offenen Nachfolger ab
+- [x] E2E: nach dem Abbruch führt „Rotieren“ wieder zu Schritt 1
+
+## Abhängigkeiten
+#023, #032
+
+## Notizen / Entscheidungen
+- **Abgebrochene Nachfolger bleiben** mit `vorgaengerId` in der Historie; die Rotationskette folgt dem jüngsten Nachfolger.
+- **Kein neuer Rotations-Zustand:** Ist der jüngste Nachfolger vollständig widerrufen und der alte nicht ROTIERT, steht die Rotation wieder bei Schritt 0.
+- Prisma kennt partielle Indizes nicht; `migrate diff` bleibt trotzdem leer, weil der Index nur in der Migration steht (wie beim Instanz-Standard).
diff --git a/prisma/migrations/20261012183000_mehrere_nachfolger/migration.sql b/prisma/migrations/20261012183000_mehrere_nachfolger/migration.sql
new file mode 100644
index 0000000..c737b0d
--- /dev/null
+++ b/prisma/migrations/20261012183000_mehrere_nachfolger/migration.sql
@@ -0,0 +1,11 @@
+-- Nach einer abgebrochenen Rotation darf derselbe Schluessel erneut rotiert werden (#033).
+-- Die Kette bleibt eindeutig: je Vorgaenger hoechstens ein Nachfolger, der nicht widerrufen
+-- ist oder gerade widerrufen wird.
+DROP INDEX "Schluessel_vorgaengerId_key";
+
+CREATE INDEX "Schluessel_vorgaengerId_idx" ON "Schluessel"("vorgaengerId");
+
+CREATE UNIQUE INDEX "schluessel_ein_offener_nachfolger" ON "Schluessel" ("vorgaengerId")
+ WHERE "vorgaengerId" IS NOT NULL
+ AND "widerrufEingeleitetAm" IS NULL
+ AND "status" <> 'WIDERRUFEN';
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index f7f103e..2b2e521 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -318,10 +318,13 @@ model Schluessel {
/// Widerruf eingeleitet (#024): Verwendungen stehen auf ENTFERNEN, der Status wechselt erst
/// auf WIDERRUFEN/ROTIERT, wenn alle Entfernungen erfolgreich waren.
widerrufEingeleitetAm DateTime?
- /// Rotationskette: der Schluessel, den dieser abgeloest hat.
- vorgaengerId String? @unique
+ /// Rotationskette: der Schluessel, den dieser abgeloest hat. Nach einer abgebrochenen Rotation
+ /// darf ein weiterer Nachfolger folgen; je Vorgaenger hoechstens ein nicht widerrufener
+ /// (partieller Index, #033).
+ vorgaengerId String?
vorgaenger Schluessel? @relation("Rotation", fields: [vorgaengerId], references: [id], onDelete: SetNull)
- nachfolger Schluessel? @relation("Rotation")
+ /// Alle Nachfolger, auch abgebrochene. Massgeblich ist der juengste (`nachfolgerVon`).
+ nachfolgerListe Schluessel[] @relation("Rotation")
erstelltVonId String?
erstelltVon Benutzer? @relation("SchluesselErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull)
/// true, wenn eine Person den Key selbst hinterlegt hat (Self-Service).
@@ -334,6 +337,8 @@ model Schluessel {
hostsAlsDeployment Host[] @relation("HostDeploymentSchluessel")
gruppenAlsDeployment Hostgruppe[] @relation("GruppeDeploymentSchluessel")
vorgaenge Vorgang[]
+
+ @@index([vorgaengerId])
}
/// Soll: Public Key in authorized_keys eines Benutzers auf einem Host oder einer Hostgruppe.
diff --git a/src/app/(app)/schluessel/[id]/page.tsx b/src/app/(app)/schluessel/[id]/page.tsx
index d1cb64e..0b9ffc6 100644
--- a/src/app/(app)/schluessel/[id]/page.tsx
+++ b/src/app/(app)/schluessel/[id]/page.tsx
@@ -246,9 +246,12 @@ export default async function SchluesselSeite({
const rotationLaeuft = s.nachfolger?.status === "AKTIV" && s.status !== "ROTIERT";
const abgebrocheneRotation = s.status !== "ROTIERT" && rotationAbgebrochen(s.nachfolger);
const darfRotieren = verwaltenRecht && rechte.public && (aktivePrivat === 0 || rechte.privat);
- // Nach einem abgeschlossenen Abbruch (#032) bleibt der Nachfolger in der Kette; ein zweiter
- // passt nicht hinein (vorgaengerId ist eindeutig) – deshalb kein „Rotieren“ mehr.
- const rotierbar = darfRotieren && (rotationLaeuft || (!rotationsSperre && !imWiderruf && !s.nachfolger));
+ // Nach einem abgeschlossenen Abbruch (#032) darf erneut rotiert werden (#033); solange der
+ // abgebrochene Nachfolger noch entfernt wird, nicht.
+ const rotierbar =
+ darfRotieren &&
+ (rotationLaeuft ||
+ (!rotationsSperre && !imWiderruf && (!s.nachfolger || s.nachfolger.status === "WIDERRUFEN")));
// Abbrechen auf dem Nachfolger: gleiche Rechte wie die Rotation, Private Keys nur mit Recht.
const darfAbbrechen =
Boolean(rotationDesVorgaengers && !rotationDesVorgaengers.widerrufEingeleitetAm) &&
@@ -543,8 +546,10 @@ export default async function SchluesselSeite({
Die Rotation zu „{s.nachfolger.name}“ wurde abgebrochen
{s.nachfolger.status === "WIDERRUFEN" ? "" : " – der neue Schlüssel wird gerade entfernt"};
- dieser Schlüssel bleibt aktiv. Ein zweiter Nachfolger ist nicht vorgesehen: für eine neue
- Rotation einen neuen Schlüssel anlegen, genauso zuweisen und diesen danach widerrufen.
+ dieser Schlüssel bleibt aktiv
+ {s.nachfolger.status === "WIDERRUFEN"
+ ? " und lässt sich erneut rotieren."
+ : ". Danach lässt er sich erneut rotieren."}
)}
{rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf && (
diff --git a/src/app/(app)/schluessel/[id]/rotieren/page.tsx b/src/app/(app)/schluessel/[id]/rotieren/page.tsx
index 961f1ac..fca75b6 100644
--- a/src/app/(app)/schluessel/[id]/rotieren/page.tsx
+++ b/src/app/(app)/schluessel/[id]/rotieren/page.tsx
@@ -80,6 +80,10 @@ export default async function RotierenSeite({ params }: { params: Promise<{ id:
tests: stand.neu ? stand.tests : [],
widerruf,
abbruch,
+ letzterAbbruch:
+ !stand.neu && stand.alt.nachfolger?.status === "WIDERRUFEN"
+ ? { id: stand.alt.nachfolger.id, name: stand.alt.nachfolger.name }
+ : null,
darfPrivat: darf(benutzer.rolle, "privateKeyAusrollen"),
}}
/>
diff --git a/src/components/rotation/Rotation.tsx b/src/components/rotation/Rotation.tsx
index 5ec4600..975c26a 100644
--- a/src/components/rotation/Rotation.tsx
+++ b/src/components/rotation/Rotation.tsx
@@ -53,6 +53,8 @@ export type RotationsDaten = {
* solange sein Widerruf laeuft; null, wenn er abgeschlossen ist.
*/
abbruch: { offen: OffeneEntfernung[] | null } | null;
+ /** Zuletzt abgebrochener Nachfolger, wenn die Rotation danach wieder am Anfang steht (#033). */
+ letzterAbbruch: { id: string; name: string } | null;
};
export function Rotation({ daten }: { daten: RotationsDaten }) {
@@ -115,8 +117,8 @@ export function Rotation({ daten }: { daten: RotationsDaten }) {
>
);
- if (daten.abbruch && neu) {
- return daten.abbruch.offen ? (
+ if (daten.abbruch?.offen && neu) {
+ return (
- ) : (
-
-
- Der neue Schlüssel{" "}
-
- {neu.name}
- {" "}
- ist widerrufen und von allen Hosts entfernt.{" "}
-
- {alt.name}
- {" "}
- bleibt aktiv.
-
-
);
}
return (
+ {daten.letzterAbbruch && schritt === 0 && (
+
+
+ Der neue Schlüssel{" "}
+
+ {daten.letzterAbbruch.name}
+ {" "}
+ ist widerrufen und von allen Hosts entfernt.{" "}
+
+ {alt.name}
+ {" "}
+ bleibt aktiv und lässt sich erneut rotieren.
+
+
+ )}
{schritt <= 1 && (
diff --git a/src/lib/rotation.ts b/src/lib/rotation.ts
index 3665525..28774af 100644
--- a/src/lib/rotation.ts
+++ b/src/lib/rotation.ts
@@ -35,3 +35,20 @@ export function rotationsPfad(pfad: string, datumKompakt: string, belegt: Readon
for (let nummer = 2; belegt.has(kandidat); nummer += 1) kandidat = `${basis}-r${datumKompakt}-${nummer}`;
return kandidat;
}
+
+/** Laedt nur den juengsten Nachfolger – nach einem Abbruch kann es mehrere geben (#033). */
+export const JUENGSTER = { orderBy: { angelegtAm: "desc" }, take: 1 } as const;
+
+/**
+ * Macht aus `nachfolgerListe` (hoechstens ein Element, siehe JUENGSTER) wieder `nachfolger`.
+ * Massgeblich ist immer der juengste: ein laufender oder – wenn keiner laeuft – der zuletzt
+ * abgebrochene.
+ */
+export type MitNachfolger = Omit & {
+ nachfolger: T["nachfolgerListe"][number] | null;
+};
+
+export function mitNachfolger(s: T): MitNachfolger {
+ const { nachfolgerListe, ...rest } = s;
+ return { ...rest, nachfolger: nachfolgerListe[0] ?? null };
+}
diff --git a/src/server/queries/schluessel.ts b/src/server/queries/schluessel.ts
index 2817f39..9389013 100644
--- a/src/server/queries/schluessel.ts
+++ b/src/server/queries/schluessel.ts
@@ -1,6 +1,7 @@
import type { Prisma } from "@/generated/prisma/client";
import type { SchluesselKategorie, SchluesselStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
+import { JUENGSTER, mitNachfolger } from "@/lib/rotation";
/**
* Lesemodelle fuer Schluessel (#012). Alles hier darf jede angemeldete Rolle sehen.
@@ -60,7 +61,10 @@ export const SCHLUESSEL_DETAIL_SELECT = {
erstelltVon: { select: { name: 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 } },
+ nachfolgerListe: {
+ select: { id: true, name: true, status: true, widerrufEingeleitetAm: true },
+ ...JUENGSTER,
+ },
publicVerwendungen: {
where: { zustand: { not: "ENTFERNT" } },
orderBy: { angelegtAm: "asc" },
@@ -164,7 +168,7 @@ export type SchluesselListenEintrag = Awaited
export async function schluesselDetail(id: string) {
const schluessel = await prisma.schluessel.findUnique({ where: { id }, select: SCHLUESSEL_DETAIL_SELECT });
if (!schluessel) return null;
- const { masterKeyVersion, ...rest } = schluessel;
+ const { masterKeyVersion, ...rest } = mitNachfolger(schluessel);
return { ...rest, privatGespeichert: masterKeyVersion !== null };
}
@@ -210,7 +214,7 @@ export async function rotationsKette(id: string, maximal = 25): Promise;
+type AltRoh = Prisma.SchluesselGetPayload<{ select: typeof ALT_SELECT }>;
+type Alt = MitNachfolger;
/**
* Warum sshkv diesen Schluessel nicht selbst rotieren kann – oder null. Ein Personen-Key (oder
@@ -77,7 +88,7 @@ export function rotationGesperrt(
async function altLaden(id: string): Promise {
const alt = await prisma.schluessel.findUnique({ where: { id }, select: ALT_SELECT });
if (!alt) throw new RotationsFehler("Diesen Schlüssel gibt es nicht.");
- return alt;
+ return mitNachfolger(alt);
}
function gesperrtPruefen(alt: Alt) {
@@ -92,14 +103,15 @@ 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.
+ // Ein vollstaendig abgebrochener (widerrufener) Nachfolger bleibt in der Historie; danach darf
+ // ein neuer folgen (#033). Der partielle Index haelt die Kette trotzdem eindeutig.
+ if (alt.nachfolger && alt.nachfolger.status !== "WIDERRUFEN") {
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.",
+ rotationAbgebrochen(alt.nachfolger)
+ ? "Der Abbruch der letzten Rotation läuft noch – bitte erst den neuen Schlüssel fertig entfernen (Dry-Run und Anwenden)."
+ : "Für diesen Schlüssel gibt es schon einen Nachfolger.",
);
}
- 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);
// Ein vergangenes Ablaufdatum wuerde der Nachfolger sonst erben – er waere sofort abgelaufen.
@@ -272,7 +284,8 @@ export type RotationsStand = Awaited>;
export async function rotationsStand(altId: string) {
const alt = await altLaden(altId);
const gesperrt = rotationGesperrt({ ...alt, privatZuweisungen: alt._count.privateVerwendungen });
- const neu = alt.nachfolger;
+ // Nach einem abgeschlossenen Abbruch beginnt die Rotation von vorn (#033).
+ const neu = alt.nachfolger?.status === "WIDERRUFEN" && alt.status !== "ROTIERT" ? null : alt.nachfolger;
if (!neu) return { alt, gesperrt, neu: null, schritt: 0 as const, abgebrochen: false };
const abgebrochen = rotationAbgebrochen(neu);
diff --git a/src/server/services/widerruf.ts b/src/server/services/widerruf.ts
index 11160d5..b570a12 100644
--- a/src/server/services/widerruf.ts
+++ b/src/server/services/widerruf.ts
@@ -3,6 +3,7 @@ import { prisma } from "@/lib/db";
import { protokollieren } from "@/server/services/protokoll";
import { VerwendungsFehler } from "@/server/services/verwendungen";
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
+import { JUENGSTER, type MitNachfolger, mitNachfolger } from "@/lib/rotation";
/**
* Widerruf und Massenwiderruf (#024).
@@ -37,10 +38,11 @@ const SCHLUESSEL_KOPF = {
status: true,
fingerprint: true,
widerrufEingeleitetAm: true,
- nachfolger: { select: { id: true, status: true, widerrufEingeleitetAm: true } },
+ nachfolgerListe: { select: { id: true, status: true, widerrufEingeleitetAm: true }, ...JUENGSTER },
} satisfies Prisma.SchluesselSelect;
-type SchluesselKopf = Prisma.SchluesselGetPayload<{ select: typeof SCHLUESSEL_KOPF }>;
+type KopfRoh = Prisma.SchluesselGetPayload<{ select: typeof SCHLUESSEL_KOPF }>;
+type SchluesselKopf = MitNachfolger;
/** Warum ein Schluessel nicht widerrufen werden kann – oder null. */
export function widerrufGesperrt(s: Pick): string | null {
@@ -61,7 +63,7 @@ async function schluesselLaden(ids: readonly string[]): Promise
});
} catch (fehler) {
if (istEindeutigkeitsFehler(fehler)) {
- // vorgaengerId ist eindeutig: zwei Rotationen desselben Schluessels gleichzeitig (#023).
+ // Je Vorgaenger nur ein offener Nachfolger (partieller Index, #023/#033): zwei Rotationen
+ // desselben Schluessels gleichzeitig.
if (eingabe.vorgaengerId) throw new VorgangsFehler("Für diesen Schlüssel läuft bereits eine Rotation.");
throw new VorgangsFehler(
`Der Kurzname „${eingabe.slug}“ wurde inzwischen vergeben. Bitte erneut erzeugen.`,
diff --git a/tests/datenbankregeln.test.ts b/tests/datenbankregeln.test.ts
index a78b077..a3e01d5 100644
--- a/tests/datenbankregeln.test.ts
+++ b/tests/datenbankregeln.test.ts
@@ -26,6 +26,7 @@ const REGELN = [
const INDIZES = [
"schluessel_ein_instanzstandard",
+ "schluessel_ein_offener_nachfolger",
"publicverwendung_eindeutig_host",
"publicverwendung_eindeutig_gruppe",
"privateverwendung_eindeutig",
diff --git a/tests/e2e/rotation.spec.ts b/tests/e2e/rotation.spec.ts
index 108f443..6d7d84b 100644
--- a/tests/e2e/rotation.spec.ts
+++ b/tests/e2e/rotation.spec.ts
@@ -105,12 +105,16 @@ test("Rotation abbrechen, nachdem der neue Schlüssel ausgerollt ist (#032)", as
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.
+ // Der alte bleibt aktiv; die Kette zeigt den widerrufenen Nachfolger – und er laesst sich
+ // erneut rotieren (#033).
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 expect(page.getByText(/Die Rotation zu „.*“ wurde abgebrochen.*erneut rotieren/)).toBeVisible();
+ await page.getByRole("link", { name: "Rotieren" }).click();
+ await expect(
+ page.getByRole("button", { name: "Neuen Schlüssel erzeugen und Zuweisungen übernehmen" }),
+ ).toBeVisible();
await archivieren(page, hostA);
await archivieren(page, hostB);
diff --git a/tests/integration/rotation.test.ts b/tests/integration/rotation.test.ts
index 27e141b..7f7dcf3 100644
--- a/tests/integration/rotation.test.ts
+++ b/tests/integration/rotation.test.ts
@@ -174,6 +174,7 @@ describe("Rotation", () => {
...(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);
+ // Abbruch abgeschlossen: die Rotation steht wieder am Anfang (#033).
+ expect(await rotationsStand(alt.id)).toMatchObject({ neu: null, schritt: 0, abgebrochen: false });
});
});
diff --git a/tests/rotation.test.ts b/tests/rotation.test.ts
index 4658893..d6799d6 100644
--- a/tests/rotation.test.ts
+++ b/tests/rotation.test.ts
@@ -97,8 +97,10 @@ async function alterSchluessel() {
async function nachfolgerErzeugen(altId: string): Promise {
const vorgangId = await rotationStarten(altId, admin);
await vorgangImWorker(vorgangId);
+ // Nach einem Abbruch gibt es mehrere Nachfolger; der juengste ist der gerade erzeugte (#033).
const neu = await prisma.schluessel.findFirstOrThrow({
where: { vorgaengerId: altId },
+ orderBy: { angelegtAm: "desc" },
select: { id: true },
});
return neu.id;
@@ -311,6 +313,9 @@ describe("Rotation abbrechen (#032)", () => {
});
expect(eintrag).toMatchObject({ fingerprint: "SHA256:alt", details: { nachfolgerId: neuId } });
+ // Solange der Abbruch laeuft, keine neue Rotation (#033).
+ await expect(rotationStarten(altId, admin)).rejects.toThrow(/läuft noch/);
+
// Nach dem Anwenden: der Nachfolger ist WIDERRUFEN (nicht ROTIERT), die Kette bleibt.
await prisma.publicVerwendung.updateMany({
where: { schluesselId: neuId },
@@ -329,7 +334,37 @@ describe("Rotation abbrechen (#032)", () => {
(await prisma.schluessel.findUniqueOrThrow({ where: { id: neuId }, select: { vorgaengerId: true } }))
.vorgaengerId,
).toBe(altId);
- await expect(rotationStarten(altId, admin)).rejects.toThrow(/abgebrochen/);
+
+ // Danach laesst sich derselbe Schluessel erneut rotieren (#033).
+ expect(await rotationsStand(altId)).toMatchObject({ neu: null, schritt: 0, abgebrochen: false });
+ const neu2 = await nachfolgerErzeugen(altId);
+ expect(neu2).not.toBe(neuId);
+ expect(await rotationsStand(altId)).toMatchObject({ neu: { id: neu2 }, schritt: 1, abgebrochen: false });
+ expect((await rotationsKette(altId)).map((g) => g.id)).toEqual([altId, neu2]);
+ await expect(rotationStarten(altId, admin)).rejects.toThrow(/schon einen Nachfolger/);
+ });
+
+ it("erlaubt in der Datenbank nur einen offenen Nachfolger je Schluessel (#033)", async () => {
+ const altId = await alterSchluessel();
+ const nachfolger = (n: string, daten: object = {}) =>
+ prisma.schluessel.create({
+ data: {
+ name: n,
+ slug: n,
+ typ: "ED25519",
+ kategorie: "DIENST",
+ publicKey: zeile("ssh-ed25519", ed25519Blob(), n),
+ fingerprint: `SHA256:${n}`,
+ kommentar: n,
+ vorgaengerId: altId,
+ ...daten,
+ },
+ select: { id: true },
+ });
+ await nachfolger("abgebrochen", { status: "WIDERRUFEN", widerrufEingeleitetAm: new Date() });
+ await nachfolger("im-abbruch", { widerrufEingeleitetAm: new Date() });
+ await nachfolger("offen");
+ await expect(nachfolger("zweiter-offen")).rejects.toThrow();
});
it("setzt nie ausgerollte Zuweisungen des neuen Schluessels sofort auf ENTFERNT", async () => {