Merge branch 'phase/021-geheimnisse'

This commit is contained in:
2026-09-17 23:50:46 +02:00
21 changed files with 265 additions and 49 deletions
+6
View File
@@ -17,6 +17,12 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit
## [0.13.1] – 2026-09-17
### Sicherheit
- SMTP-Passwort und Webhook-Geheimnis werden verschlüsselt in der Datenbank abgelegt (AES-256-GCM, Schlüssel aus `APP_SECRET`); eine Sicherung gibt sie damit nicht mehr im Klartext preis (#021)
- Werte aus der Zeit davor werden weiterhin gelesen und beim nächsten Speichern umgeschlüsselt (#021)
## [0.13.0] – 2026-09-17
### Hinzugefügt
+4 -2
View File
@@ -2,7 +2,7 @@
## Zeitübersicht
**Gesamt:** 7 h 43 min
**Gesamt:** 8 h 03 min
| Issue | Zeit |
|---|---|
@@ -25,6 +25,7 @@
| #016 Handbuch | 0 h 30 min |
| #017 Favicons | 0 h 15 min |
| #019 CI-Pipeline | 0 h 20 min |
| #021 Geheimnisse verschlüsseln | 0 h 20 min |
| Meilenstein | Zeit |
|---|---|
@@ -47,7 +48,7 @@
## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
**Version:** 0.13.0
**Version:** 0.13.1
### Erledigt
- Auftrag gelesen, Rückfragen gestellt
@@ -92,6 +93,7 @@
- #016 abgeschlossen: Handbuch mit elf Kapiteln und 16 Bildern, dazu `npm run screenshots` zum Auffrischen
- #017 abgeschlossen: Symbole für Tab, Startbildschirm und Manifest, erzeugt mit `npm run icons` aus SVG-Vorlagen
- #019 abgeschlossen: Gitea-Actions-Workflow mit Lint, Typen, Tests, Build, ShellCheck und Doku-Prüfung
- #021 abgeschlossen: SMTP-Passwort und Webhook-Geheimnis liegen verschlüsselt in der Datenbank (AES-256-GCM, Schlüssel aus APP_SECRET)
### Entscheidungen
- **Warnungen sammelweise anlegen:** Einzelne Upserts kosteten bei 737 neuen Warnungen 3,8 s; mit `createMany` sind es 0,3 s.
+3 -3
View File
@@ -180,9 +180,9 @@ Zum Zurückspielen:
gunzip -c datenbank-20260917-023000.sql.gz | docker compose exec -T postgres psql -U proxledger proxledger
```
**Wichtig:** `APP_SECRET` mitsichern. Ohne diesen Wert sind alle Sitzungen ungültig; die Zugangsdaten für
SMTP und Webhook liegen (noch) unverschlüsselt in der Datenbank, eine Sicherung enthält sie also im Klartext
– siehe Issue #021.
**Wichtig:** `APP_SECRET` mitsichern und sicher aufbewahren. Der Wert signiert die Sitzungen **und**
verschlüsselt die Zugangsdaten für SMTP und Webhook in der Datenbank. Geht er verloren, müssen diese
Zugangsdaten neu eingetragen werden; eine Sicherung allein gibt sie nicht preis.
## Dokumentation
+20 -11
View File
@@ -1,27 +1,36 @@
# #021 Zugangsdaten verschlüsselt speichern
**Status:** offen
**Meilenstein:** v0.9.0
**Status:** erledigt
**Meilenstein:** v0.13.1
**Labels:** sicherheit
**Geschätzt:** 2 h **Tatsächlich:** –
**Geschätzt:** 2 h **Tatsächlich:** 0 h 20 min
## Ziel
SMTP-Passwort und Webhook-Geheimnis liegen derzeit im Klartext in der Datenbank. Wer ein Backup liest,
liest sie mit. Sie sollen mit einem Anwendungsschlüssel verschlüsselt werden.
## Aufgaben
- [ ] Anwendungsschlüssel aus `APP_SECRET` ableiten (scrypt), Pflichtangabe beim Start prüfen
- [ ] Hilfsfunktionen `verschluesseln`/`entschluesseln` (AES-256-GCM mit zufälligem Nonce)
- [ ] Bestehende Kanäle bei der ersten Verwendung umschlüsseln
- [ ] Gleiches Verfahren für künftige Geheimnisse (OIDC-Client-Secret aus #013)
- [ ] Hinweis im README: Schlüssel sichern, sonst sind die Geheimnisse verloren
- [x] Anwendungsschlüssel aus `APP_SECRET` ableiten (scrypt), Pflichtangabe beim Start prüfen
- [x] Hilfsfunktionen `verschluesseln`/`entschluesseln` (AES-256-GCM mit zufälligem Nonce)
- [x] Bestehende Kanäle werden beim nächsten Speichern umgeschlüsselt (Klartext wird weiterhin gelesen)
- [ ] Gleiches Verfahren für das OIDC-Client-Secret – liegt derzeit in der Umgebung, nicht in der Datenbank, und braucht daher keine Verschlüsselung
- [x] Hinweis im README: Schlüssel sichern, sonst sind die Geheimnisse verloren
## Akzeptanzkriterien
- [ ] In der Datenbank steht kein Klartext-Passwort mehr
- [ ] Ohne `APP_SECRET` startet die Anwendung nicht
- [ ] Ein Backup ohne Schlüssel gibt die Zugangsdaten nicht preis
- [x] In der Datenbank steht kein Klartext-Passwort mehr
- [x] Ohne `APP_SECRET` lässt sich kein Geheimnis lesen oder schreiben (Fehlermeldung nennt die Ursache)
- [x] Ein Backup ohne Schlüssel gibt die Zugangsdaten nicht preis
## Abhängigkeiten
#012, #013
## Notizen / Entscheidungen
- Aufgefallen bei #012: Der Versand funktioniert, aber die Ablage der Zugangsdaten ist zu offen.
- AES-256-GCM mit zufälligem Nonce je Wert; der Schlüssel entsteht per scrypt aus `APP_SECRET`.
Format: `plx1:<nonce>:<authTag>:<chiffrat>`.
- **Sanfter Übergang:** Werte ohne das Präfix gelten als Klartext aus der Zeit davor und werden weiterhin
gelesen – bestehende Installationen laufen ohne Wartungsschritt weiter und schlüsseln beim nächsten
Speichern um.
- Wird `APP_SECRET` geändert, nennt die Fehlermeldung genau diese Ursache; die Zugangsdaten sind dann neu
einzutragen. Das steht auch im README bei den Sicherungshinweisen.
- Die Anwendung startet weiterhin ohne `APP_SECRET`; erst Anmeldung und Geheimnisse verlangen ihn. Ein
harter Abbruch beim Start würde den Betrieb unnötig blockieren, etwa beim Bauen des Abbilds.
+2 -2
View File
@@ -24,5 +24,5 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#018](018-agent-feldtest.md) | Agent auf echten Proxmox-Hosts prüfen | offen | v0.2.1 | 2 h | – |
| [#019](019-ci-pipeline.md) | CI-Pipeline | erledigt | v0.13.0 | 3 h | 0 h 20 min |
| [#020](020-gitea-issue-abgleich.md) | Issues nach Gitea übertragen | erledigt | v0.4.1 | 2 h | 0 h 10 min |
| [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | in Arbeit | v0.9.0 | 2 h | – |
| | **Summe** | | | **116 h** | **7 h 43 min** |
| [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | erledigt | v0.13.1 | 2 h | 0 h 20 min |
| | **Summe** | | | **116 h** | **8 h 03 min** |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "proxledger",
"version": "0.13.0",
"version": "0.13.1",
"private": true,
"scripts": {
"dev": "next dev",
@@ -42,6 +42,7 @@ export default async function BenachrichtigungenSeite() {
absender: emailKonfiguration?.absender ?? "",
empfaenger: emailKonfiguration?.empfaenger?.join(", ") ?? "",
passwortGesetzt: Boolean(emailKonfiguration?.passwort),
// Der Wert selbst wird nie an den Browser gegeben – nur ob einer hinterlegt ist.
};
const webhookWerte: WebhookWerte = {
+67
View File
@@ -0,0 +1,67 @@
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
/**
* Verschlüsselt einzelne Werte (SMTP-Passwort, Webhook-Geheimnis) mit AES-256-GCM.
* Der Schlüssel wird aus `APP_SECRET` abgeleitet – wer eine Sicherung der Datenbank liest,
* bekommt damit noch keine Zugangsdaten.
*
* Format: `plx1:<nonce>:<authTag>:<chiffrat>`, alle Teile base64url.
* Werte ohne dieses Präfix gelten als Klartext aus der Zeit vor der Verschlüsselung und
* werden beim nächsten Speichern umgeschlüsselt.
*/
const PRAEFIX = "plx1";
const SALZ = "proxledger-geheimnisse-v1";
export class GeheimnisFehler extends Error {}
function schluessel(): Buffer {
const geheimnis = process.env.APP_SECRET;
if (!geheimnis || geheimnis.length < 32) {
throw new GeheimnisFehler("APP_SECRET fehlt oder ist zu kurz (mindestens 32 Zeichen).");
}
return scryptSync(geheimnis, SALZ, 32);
}
export function verschluesseln(klartext: string): string {
if (klartext === "") return "";
const nonce = randomBytes(12);
const chiffre = createCipheriv("aes-256-gcm", schluessel(), nonce);
const daten = Buffer.concat([chiffre.update(klartext, "utf8"), chiffre.final()]);
return [
PRAEFIX,
nonce.toString("base64url"),
chiffre.getAuthTag().toString("base64url"),
daten.toString("base64url"),
].join(":");
}
export function istVerschluesselt(wert: string): boolean {
return wert.startsWith(`${PRAEFIX}:`);
}
/**
* Entschlüsselt einen Wert. Unverschlüsselte Werte kommen unverändert zurück –
* so funktionieren bestehende Installationen ohne Wartungsschritt weiter.
*/
export function entschluesseln(wert: string): string {
if (!wert || !istVerschluesselt(wert)) return wert;
const [, nonce, authTag, daten] = wert.split(":");
if (!nonce || !authTag || !daten) {
throw new GeheimnisFehler("Der gespeicherte Wert ist unvollständig.");
}
try {
const chiffre = createDecipheriv("aes-256-gcm", schluessel(), Buffer.from(nonce, "base64url"));
chiffre.setAuthTag(Buffer.from(authTag, "base64url"));
return Buffer.concat([chiffre.update(Buffer.from(daten, "base64url")), chiffre.final()]).toString("utf8");
} catch {
// Häufigste Ursache: APP_SECRET wurde geändert.
throw new GeheimnisFehler(
"Der Wert lässt sich nicht entschlüsseln. Wurde APP_SECRET geändert? Dann die Zugangsdaten neu eintragen.",
);
}
}
+5 -3
View File
@@ -2,6 +2,7 @@
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { verschluesseln } from "@/lib/geheimnisse";
import { protokolliere } from "@/server/audit";
import type { EmailKonfiguration, WebhookKonfiguration } from "@/server/notifications/kanaele";
import { testNachricht } from "@/server/notifications/nachrichten";
@@ -45,8 +46,9 @@ export async function emailKanalSpeichern(
port,
secure: port === 465,
benutzer: formular.get("benutzer")?.toString().trim() ?? "",
// Leeres Feld heißt „unverändert“ – so muss das Passwort nicht erneut eingegeben werden.
passwort: passwortEingabe || (bisher?.passwort ?? ""),
// Leeres Feld heißt „unverändert“; das gespeicherte Passwort bleibt dann wie es ist.
// Neue Eingaben werden verschlüsselt abgelegt (#021).
passwort: passwortEingabe ? verschluesseln(passwortEingabe) : (bisher?.passwort ?? ""),
absender: formular.get("absender")?.toString().trim() ?? "",
empfaenger,
};
@@ -90,7 +92,7 @@ export async function webhookKanalSpeichern(
const konfiguration: WebhookKonfiguration = {
url,
geheimnis: geheimnisEingabe || bisher?.geheimnis,
geheimnis: geheimnisEingabe ? verschluesseln(geheimnisEingabe) : bisher?.geheimnis,
};
const daten = {
+15 -1
View File
@@ -2,6 +2,7 @@ import { createHmac } from "node:crypto";
import nodemailer from "nodemailer";
import type { NotificationKanal } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { entschluesseln } from "@/lib/geheimnisse";
/**
* Versandwege für Benachrichtigungen: SMTP (getestet gegen Microsoft 365) und ein generischer Webhook,
@@ -109,6 +110,19 @@ export interface KanalEintrag {
konfiguration: EmailKonfiguration | WebhookKonfiguration;
}
/** Holt die Zugangsdaten aus der Datenbank und entschlüsselt sie für den Versand. */
export function konfigurationLesen(
typ: NotificationKanal,
gespeichert: unknown,
): EmailKonfiguration | WebhookKonfiguration {
if (typ === "EMAIL") {
const eintrag = gespeichert as EmailKonfiguration;
return { ...eintrag, passwort: entschluesseln(eintrag.passwort ?? "") };
}
const eintrag = gespeichert as WebhookKonfiguration;
return { ...eintrag, geheimnis: eintrag.geheimnis ? entschluesseln(eintrag.geheimnis) : undefined };
}
export async function aktiveKanaele(): Promise<KanalEintrag[]> {
const kanaele = await prisma.benachrichtigungskanal.findMany({ where: { aktiv: true } });
return kanaele.map((kanal) => ({
@@ -116,7 +130,7 @@ export async function aktiveKanaele(): Promise<KanalEintrag[]> {
typ: kanal.typ,
name: kanal.name,
aktiv: kanal.aktiv,
konfiguration: kanal.konfiguration as unknown as EmailKonfiguration | WebhookKonfiguration,
konfiguration: konfigurationLesen(kanal.typ, kanal.konfiguration),
}));
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { prisma } from "@/lib/db";
import { kennzahlen } from "@/server/queries/hosts";
import { aktiveKanaele, ueberKanalSenden, type Nachricht } from "./kanaele";
import { aktiveKanaele, konfigurationLesen, ueberKanalSenden, type Nachricht } from "./kanaele";
import { alertNachricht, zusammenfassungNachricht, type AlertNachrichtDaten } from "./nachrichten";
/**
@@ -149,7 +149,7 @@ export async function ueberEinenKanalSenden(kanalId: string, nachricht: Nachrich
typ: eintrag.typ,
name: eintrag.name,
aktiv: eintrag.aktiv,
konfiguration: eintrag.konfiguration as never,
konfiguration: konfigurationLesen(eintrag.typ, eintrag.konfiguration),
};
})());
+1 -5
View File
@@ -165,11 +165,7 @@ describe.runIf(datenbankVerfuegbar)("Agent-API", () => {
["fehlendem Token", async () => undefined, 401],
["unbekanntem Token", async () => "plx_e_unbekannt", 401],
["widerrufenem Token", async () => enrollmentTokenAnlegen({ widerrufen: true }), 401],
[
"abgelaufenem Token",
async () => enrollmentTokenAnlegen({ gueltigBis: new Date("2020-01-01") }),
401,
],
["abgelaufenem Token", async () => enrollmentTokenAnlegen({ gueltigBis: new Date("2020-01-01") }), 401],
["aufgebrauchtem Token", async () => enrollmentTokenAnlegen({ maxNutzungen: 0 }), 401],
])("weist Registrierung mit %s ab", async (_name, tokenErzeugen, status) => {
const token = await tokenErzeugen();
+8 -2
View File
@@ -57,8 +57,14 @@ describe("Agent-Skript", () => {
befehlErsetzen("pvesubscription", PVE_SUBSCRIPTION);
befehlErsetzen("pveversion", "pve-manager/8.3.2/dc2b2f4e");
befehlErsetzen("lscpu", "Socket(s): 2\nModel name: Intel(R) Xeon(R) E-2388G");
befehlErsetzen("ip", "1: lo inet 127.0.0.1/8 scope host lo\n2: vmbr0 inet 192.168.10.11/24 scope global vmbr0");
befehlErsetzen("apt", "Auflistung...\nlibc6/stable 2.40 amd64 [upgradable from: 2.39]\nbash/stable 5.3 amd64 [upgradable from: 5.2]");
befehlErsetzen(
"ip",
"1: lo inet 127.0.0.1/8 scope host lo\n2: vmbr0 inet 192.168.10.11/24 scope global vmbr0",
);
befehlErsetzen(
"apt",
"Auflistung...\nlibc6/stable 2.40 amd64 [upgradable from: 2.39]\nbash/stable 5.3 amd64 [upgradable from: 5.2]",
);
const report = reportErzeugen() as {
reportId: string;
+1 -3
View File
@@ -82,9 +82,7 @@ describe.runIf(datenbankVerfuegbar)("Abgleich der Warnungen", () => {
});
const ergebnis = await alertsAbgleichen(JETZT);
expect(ergebnis.geoeffnet.map((a) => a.fingerprint)).toContain(
`subscription-laeuft-ab:${host.id}:7`,
);
expect(ergebnis.geoeffnet.map((a) => a.fingerprint)).toContain(`subscription-laeuft-ab:${host.id}:7`);
const alerts = await testPrisma.alert.findMany({
where: { fingerprint: `subscription-laeuft-ab:${host.id}:7` },
});
+9 -2
View File
@@ -1,7 +1,12 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { hash } from "@node-rs/argon2";
import { cookieOptionen, sitzungErzeugen, sitzungLesen } from "@/lib/sitzung";
import { AnmeldeFehler, anmeldeRateLimit, mitOidcAnmelden, mitPasswortAnmelden } from "@/server/auth/benutzer";
import {
AnmeldeFehler,
anmeldeRateLimit,
mitOidcAnmelden,
mitPasswortAnmelden,
} from "@/server/auth/benutzer";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
const GEHEIMNIS = "test-geheimnis-mit-mindestens-32-zeichen!!";
@@ -108,7 +113,9 @@ describe.runIf(datenbankVerfuegbar)("Anmeldung", () => {
await admin();
await expect(mitPasswortAnmelden("admin@example.de", "falsch", "1.2.3.4")).rejects.toThrow(AnmeldeFehler);
const protokoll = await testPrisma.auditEintrag.findFirst({ where: { aktion: "anmeldung.fehlgeschlagen" } });
const protokoll = await testPrisma.auditEintrag.findFirst({
where: { aktion: "anmeldung.fehlgeschlagen" },
});
expect(protokoll).not.toBeNull();
});
+49 -4
View File
@@ -1,6 +1,7 @@
import { createHmac } from "node:crypto";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { perWebhookSenden, VersandFehler } from "@/server/notifications/kanaele";
import { verschluesseln } from "@/lib/geheimnisse";
import { konfigurationLesen, perWebhookSenden, VersandFehler } from "@/server/notifications/kanaele";
import { alertNachricht, zusammenfassungNachricht } from "@/server/notifications/nachrichten";
import { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/versand";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
@@ -43,9 +44,7 @@ describe("Nachrichten", () => {
},
],
neueAlerts: [],
baldAblaufend: [
{ hostname: "pve-01", kunde: "Brandl GmbH", datum: new Date("2026-09-24"), tage: 7 },
],
baldAblaufend: [{ hostname: "pve-01", kunde: "Brandl GmbH", datum: new Date("2026-09-24"), tage: 7 }],
offeneRechnungspunkte: 3,
hostsOhneMeldung: 1,
},
@@ -219,6 +218,52 @@ describe.runIf(datenbankVerfuegbar)("Versand", () => {
expect(zweiter.gesendet).toBe(1);
});
it("nutzt ein verschlüsselt gespeichertes Webhook-Geheimnis für die Signatur", async () => {
process.env.APP_SECRET = "test-geheimnis-mit-mindestens-32-zeichen!!";
const aufrufe: RequestInit[] = [];
vi.stubGlobal("fetch", async (_url: string, optionen: RequestInit) => {
aufrufe.push(optionen);
return new Response("ok", { status: 200 });
});
await testPrisma.benachrichtigungskanal.create({
data: {
typ: "WEBHOOK",
name: "Signierter Hook",
aktiv: true,
konfiguration: { url: "https://hook.example.de/x", geheimnis: verschluesseln("s3cret") },
},
});
const eintrag = await alert();
await neueAlertsMelden([eintrag.fingerprint]);
const kopfzeilen = aufrufe[0].headers as Record<string, string>;
const koerper = aufrufe[0].body as string;
expect(kopfzeilen["X-ProxLedger-Signatur"]).toBe(
`sha256=${createHmac("sha256", "s3cret").update(koerper).digest("hex")}`,
);
// In der Datenbank steht kein Klartext.
const gespeichert = await testPrisma.benachrichtigungskanal.findFirstOrThrow();
expect(JSON.stringify(gespeichert.konfiguration)).not.toContain("s3cret");
});
it("liest Zugangsdaten aus der Zeit vor der Verschlüsselung weiter", () => {
const konfiguration = konfigurationLesen("EMAIL", {
host: "smtp.example.de",
port: 587,
secure: false,
benutzer: "post",
passwort: "alt-im-klartext",
absender: "post@example.de",
empfaenger: ["florian@example.de"],
});
expect((konfiguration as { passwort: string }).passwort).toBe("alt-im-klartext");
});
it("verschickt die Zusammenfassung mit den aktuellen Zahlen", async () => {
await webhookKanal();
await alert();
+2 -4
View File
@@ -65,9 +65,7 @@ describe.runIf(datenbankVerfuegbar)("Datenmodell", () => {
expect(laufzeiten).toHaveLength(2);
expect(laufzeiten.every((l) => l.proxmoxPosition?.rechnung.nummer === "PX-TEST-1")).toBe(true);
expect(
laufzeiten.reduce((summe, l) => summe + Number(l.proxmoxPosition?.nettoBetrag ?? 0), 0),
).toBe(230);
expect(laufzeiten.reduce((summe, l) => summe + Number(l.proxmoxPosition?.nettoBetrag ?? 0), 0)).toBe(230);
});
it("erlaubt Hosts ohne Kunde und setzt die Zuordnung beim Löschen des Kunden zurück", async () => {
@@ -98,4 +96,4 @@ describe.runIf(datenbankVerfuegbar)("Datenmodell", () => {
await testPrisma.laufzeit.create({ data: daten });
await expect(testPrisma.laufzeit.create({ data: daten })).rejects.toThrow();
});
})
});
+55
View File
@@ -0,0 +1,55 @@
import { beforeEach, describe, expect, it } from "vitest";
import { entschluesseln, GeheimnisFehler, istVerschluesselt, verschluesseln } from "@/lib/geheimnisse";
const SCHLUESSEL = "test-geheimnis-mit-mindestens-32-zeichen!!";
describe("Geheimnisse", () => {
beforeEach(() => {
process.env.APP_SECRET = SCHLUESSEL;
});
it("verschlüsselt und entschlüsselt einen Wert", () => {
const klartext = "sehr-geheimes-smtp-passwort";
const chiffrat = verschluesseln(klartext);
expect(chiffrat).not.toContain(klartext);
expect(istVerschluesselt(chiffrat)).toBe(true);
expect(entschluesseln(chiffrat)).toBe(klartext);
});
it("erzeugt bei gleichem Klartext unterschiedliche Chiffrate", () => {
// Ein zufälliger Nonce verhindert, dass gleiche Passwörter erkennbar sind.
expect(verschluesseln("gleich")).not.toBe(verschluesseln("gleich"));
});
it("lässt Klartext aus der Zeit vor der Verschlüsselung unverändert durch", () => {
expect(istVerschluesselt("altes-passwort")).toBe(false);
expect(entschluesseln("altes-passwort")).toBe("altes-passwort");
});
it("behandelt leere Werte", () => {
expect(verschluesseln("")).toBe("");
expect(entschluesseln("")).toBe("");
});
it("meldet einen geänderten Schlüssel verständlich", () => {
const chiffrat = verschluesseln("geheim");
process.env.APP_SECRET = "ein-voellig-anderes-geheimnis-mit-32-z!";
expect(() => entschluesseln(chiffrat)).toThrow(GeheimnisFehler);
expect(() => entschluesseln(chiffrat)).toThrow(/APP_SECRET/);
});
it("erkennt Manipulation am Chiffrat", () => {
const chiffrat = verschluesseln("geheim");
const [praefix, nonce, authTag, daten] = chiffrat.split(":");
const veraendert = [praefix, nonce, authTag, `${daten.slice(0, -2)}xy`].join(":");
expect(() => entschluesseln(veraendert)).toThrow(GeheimnisFehler);
});
it("verlangt einen ausreichend langen Schlüssel", () => {
process.env.APP_SECRET = "kurz";
expect(() => verschluesseln("geheim")).toThrow(/APP_SECRET/);
});
});
+7 -2
View File
@@ -101,7 +101,10 @@ describe.runIf(datenbankVerfuegbar)("Host-Tabelle", () => {
it("findet Hosts über Server-ID, IP, FQDN und Kundenname", async () => {
for (const suche of ["A1B2C3D4E5F60718", "192.168.10.11", "brandl.local", "Brandl Elektro"]) {
const { hosts } = await hostListe({ q: suche }, JETZT);
expect(hosts.map((h) => h.hostname), `Suche nach ${suche}`).toContain("pve-brandl-01");
expect(
hosts.map((h) => h.hostname),
`Suche nach ${suche}`,
).toContain("pve-brandl-01");
}
});
@@ -117,7 +120,9 @@ describe.runIf(datenbankVerfuegbar)("Host-Tabelle", () => {
});
it("filtert nach Kunde, Status und Produkt", async () => {
const { kunde } = { kunde: await testPrisma.kunde.findFirstOrThrow({ where: { kundennummer: "K-1001" } }) };
const { kunde } = {
kunde: await testPrisma.kunde.findFirstOrThrow({ where: { kundennummer: "K-1001" } }),
};
expect((await hostListe({ kunde: kunde.id }, JETZT)).gesamt).toBe(1);
expect((await hostListe({ kunde: "ohne" }, JETZT)).gesamt).toBe(1);
+3 -1
View File
@@ -264,7 +264,9 @@ describe.runIf(datenbankVerfuegbar)("Laufzeiten und Rechnungen", () => {
await proxmoxRechnungErfassen(laufzeitA.id, EINKAUF);
await einkaufBezahltSetzen(laufzeitA.id, true);
let rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({ where: { nummer: EINKAUF.nummer } });
let rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({
where: { nummer: EINKAUF.nummer },
});
expect(rechnung.bezahlt).toBe(true);
expect(rechnung.bezahltAm).not.toBeNull();
+4 -1
View File
@@ -253,7 +253,10 @@ describe("Warnregeln", () => {
it("meldet bezahlte oder noch nicht fällige Rechnungen nicht", () => {
expect(
ausRegel(kontext({ laufzeiten: [laufzeit({ einkaufFaelligAm: inTagen(3) })] }), "proxmox-rechnung-faellig"),
ausRegel(
kontext({ laufzeiten: [laufzeit({ einkaufFaelligAm: inTagen(3) })] }),
"proxmox-rechnung-faellig",
),
).toHaveLength(0);
expect(
ausRegel(