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 () => {