import { afterEach, describe, expect, it } from "vitest"; import { GeheimnisFehler, entschluesseln, verschluesseln } from "@/lib/geheimnisse"; // `tests/setup-env.ts` setzt einen Standardwert. Tests, die daran drehen, stellen // ihn danach wieder her, damit die Reihenfolge der Tests nichts aendert. const APP_SECRET_VORHER = process.env["APP_SECRET"]; function appSecretSetzen(wert: string): void { process.env["APP_SECRET"] = wert; } afterEach(() => { if (APP_SECRET_VORHER === undefined) { delete process.env["APP_SECRET"]; } else { process.env["APP_SECRET"] = APP_SECRET_VORHER; } }); /** Aendert ein Byte im angegebenen Teil des Geheimtexts, ohne das Format zu verletzen. */ function teilVerfaelschen(geheimtext: string, index: number): string { const teile = geheimtext.split("."); const teil = teile[index]; if (teil === undefined || teil.length === 0) { throw new Error(`Teil ${index} fehlt im Geheimtext.`); } // Das erste base64-Zeichen tragt die oberen Bits des ersten Bytes, eine // Aenderung dort wirkt also sicher -- anders als am gepolsterten Ende. const ersetzt = teil.startsWith("A") ? "B" : "A"; teile[index] = ersetzt + teil.slice(1); return teile.join("."); } describe("Geheimnisse verschluesseln und entschluesseln", () => { it("ist verlustfrei fuer einfachen Text", () => { const klartext = "bind-passwort-123"; expect(entschluesseln(verschluesseln(klartext))).toBe(klartext); }); it.each([ ["Umlaute und Eszett", "Grueße aus Bad Münstereifel: ÄÖÜäöüß"], ["Emoji", "Schlüssel 🔐 im Tresor 🗄️ mit Häkchen ✅"], ["leerer Text", ""], ["nur Leerzeichen", " "], ["Zeilenumbrueche", "erste Zeile\nzweite Zeile\r\ndritte"], ["Punkte im Klartext", "v1.das.sieht.aus.wie.ein.geheimtext"], ["base64-artiger Text", "YWJjZGVmZ2g="], ])("ist verlustfrei fuer %s", (_fall, klartext) => { expect(entschluesseln(verschluesseln(klartext))).toBe(klartext); }); it("ist verlustfrei fuer lange Texte", () => { const lang = "Zertifikat-".repeat(10_000); const geheim = verschluesseln(lang); expect(geheim.length).toBeGreaterThan(lang.length / 2); expect(entschluesseln(geheim)).toBe(lang); }); it("traegt das Versionspraefix und vier durch Punkt getrennte Teile", () => { const teile = verschluesseln("etwas").split("."); expect(teile).toHaveLength(4); expect(teile[0]).toBe("v1"); // 12 Byte IV und 16 Byte Auth-Tag, base64 kodiert. expect(Buffer.from(teile[1] ?? "", "base64")).toHaveLength(12); expect(Buffer.from(teile[2] ?? "", "base64")).toHaveLength(16); }); it("liefert fuer denselben Klartext zwei verschiedene Geheimtexte", () => { // Jeder Aufruf zieht einen eigenen IV; sonst waere an gleichen Geheimtexten // ablesbar, welche Konten dasselbe Passwort benutzen. const klartext = "immer-dasselbe-passwort"; const erster = verschluesseln(klartext); const zweiter = verschluesseln(klartext); expect(erster).not.toBe(zweiter); expect(erster.split(".")[1]).not.toBe(zweiter.split(".")[1]); expect(entschluesseln(erster)).toBe(klartext); expect(entschluesseln(zweiter)).toBe(klartext); }); it("enthaelt den Klartext nicht im Ergebnis", () => { const klartext = "geheimes-bind-passwort"; expect(verschluesseln(klartext)).not.toContain(klartext); }); }); describe("Geheimnisse gegen Verfaelschung", () => { it("wirft, wenn ein Byte im Geheimtext veraendert wurde", () => { const verfaelscht = teilVerfaelschen(verschluesseln("bind-passwort-123"), 3); expect(() => entschluesseln(verfaelscht)).toThrow(GeheimnisFehler); expect(() => entschluesseln(verfaelscht)).toThrow(/Auth-Tag passt nicht/); }); it("wirft, wenn das Auth-Tag veraendert wurde", () => { const verfaelscht = teilVerfaelschen(verschluesseln("bind-passwort-123"), 2); expect(() => entschluesseln(verfaelscht)).toThrow(GeheimnisFehler); }); it("wirft, wenn der IV veraendert wurde", () => { const verfaelscht = teilVerfaelschen(verschluesseln("bind-passwort-123"), 1); expect(() => entschluesseln(verfaelscht)).toThrow(GeheimnisFehler); }); it("wirft, wenn der Geheimtext abgeschnitten wurde", () => { const geheim = verschluesseln("bind-passwort-123"); const ohneLetztenTeil = geheim.split(".").slice(0, 3).join("."); expect(() => entschluesseln(ohneLetztenTeil)).toThrow(GeheimnisFehler); expect(() => entschluesseln(ohneLetztenTeil)).toThrow(/unbekanntes Format/); }); it.each([ ["leerer Wert", ""], ["Klartext ohne Trenner", "einfach-nur-text"], ["zu wenige Teile", "v1.abc"], ["zu viele Teile", "v1.a.b.c.d"], ["unbekannte Version", "v9.AAAAAAAAAAAAAAAA.AAAAAAAAAAAAAAAAAAAAAA==.AAAA"], ["IV zu kurz", "v1.AAAA.AAAAAAAAAAAAAAAAAAAAAA==.AAAA"], ["Auth-Tag zu kurz", "v1.AAAAAAAAAAAAAAAA.AAAA.AAAA"], ])("wirft mit verstaendlicher Meldung bei %s", (_fall, geheimtext) => { let gefangen: unknown; try { entschluesseln(geheimtext); } catch (fehler) { gefangen = fehler; } expect(gefangen).toBeInstanceOf(GeheimnisFehler); expect((gefangen as GeheimnisFehler).message).toMatch(/v1\.\.\./); }); it("wirft, wenn APP_SECRET zwischen Verschluesseln und Entschluesseln wechselt", () => { const geheim = verschluesseln("bind-passwort-123"); appSecretSetzen("ein-voellig-anderes-geheimnis-mit-genug-zeichen"); expect(() => entschluesseln(geheim)).toThrow(GeheimnisFehler); }); }); describe("APP_SECRET pruefen", () => { it.each([ ["fehlendes", ""], ["viel zu kurzes", "kurz"], ["knapp zu kurzes (31 Zeichen)", "a".repeat(31)], ])("wirft bei %s APP_SECRET mit Hinweis auf openssl", (_fall, wert) => { appSecretSetzen(wert); expect(() => verschluesseln("etwas")).toThrow(GeheimnisFehler); expect(() => verschluesseln("etwas")).toThrow(/openssl rand -base64 48/); expect(() => verschluesseln("etwas")).toThrow(/mindestens 32 Zeichen/); }); it("nimmt genau 32 Zeichen an", () => { appSecretSetzen("b".repeat(32)); expect(entschluesseln(verschluesseln("grenzfall"))).toBe("grenzfall"); }); it("stellt den Standardwert aus setup-env wieder her", () => { // Haelt fest, dass die Aufraeumroutine oben greift -- sonst wuerden die // Tests je nach Reihenfolge unterschiedlich ausgehen. expect(process.env["APP_SECRET"]).toBe(APP_SECRET_VORHER); expect(entschluesseln(verschluesseln("nach dem Aufraeumen"))).toBe("nach dem Aufraeumen"); }); });