Enterprise-Backup-, Recovery-, Verification-, Security- und Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme. Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes geht in keine Bewertung als "gut" ein. Umfang (Phasen 0-23): - Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll, Katalogaufbau allein aus den Manifesten — ohne Datenbank - Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck - Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell) - Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive - Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit - Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center, Ransomware-Heuristik (meldet, handelt nie) - Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing - Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository - Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64 Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs. Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md und docs/release-candidate.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
240 lines
9.0 KiB
Go
240 lines
9.0 KiB
Go
package reports
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestEmptyPeriodProducesNoFabricatedNumbers ist der Kerntest der Berichte.
|
|
//
|
|
// Ein Zeitraum ohne Sicherungslauf ist der gefaehrlichste Fall: Genau dann,
|
|
// wenn die Sicherung ausgefallen ist, darf der Bericht nicht den besten aller
|
|
// Zustaende melden. Geprueft wird jede Kennzahl, die einen Lauf voraussetzt.
|
|
//
|
|
// „Laengste Laufzeit" stand hier zunaechst als gemessene Null — die Datenbank
|
|
// liefert dank COALESCE eine, und im Bericht sah sie aus wie ein Lauf, der in
|
|
// null Sekunden durchlief. Im Nachweis gegen die laufende Anlage aufgefallen.
|
|
func TestEmptyPeriodProducesNoFabricatedNumbers(testInstance *testing.T) {
|
|
emptyStatistics := runStatistics{}
|
|
|
|
metricsRequiringARun := []Metric{
|
|
successRateMetric(emptyStatistics),
|
|
averageDurationMetric(emptyStatistics),
|
|
longestDurationMetric(emptyStatistics),
|
|
}
|
|
|
|
for _, metric := range metricsRequiringARun {
|
|
if metric.IsKnown {
|
|
testInstance.Errorf("%q gilt ohne einen einzigen Lauf als gemessen (Wert %g)",
|
|
metric.Label, metric.Value)
|
|
}
|
|
|
|
if metric.UnknownReason == "" {
|
|
testInstance.Errorf("%q nennt keinen Grund; eine leere Zelle ohne Begründung "+
|
|
"sieht aus wie ein Fehler des Werkzeugs", metric.Label)
|
|
}
|
|
|
|
if metric.Display() != "nicht gemessen" {
|
|
testInstance.Errorf("%q wird als %q dargestellt", metric.Label, metric.Display())
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSuccessRateExcludesRunningJobs prueft den Nenner der Erfolgsquote.
|
|
//
|
|
// Ein noch laufender Vorgang hat kein Ergebnis. Ihn in den Nenner zu nehmen
|
|
// zoege die Quote nach unten, solange gerade gesichert wird — die Anlage saehe
|
|
// waehrend ihrer Arbeit am schlechtesten aus.
|
|
func TestSuccessRateExcludesRunningJobs(testInstance *testing.T) {
|
|
statisticsWithRunning := runStatistics{
|
|
TotalRuns: 3, SucceededRuns: 2, RunningRuns: 1,
|
|
}
|
|
|
|
successRate := successRateMetric(statisticsWithRunning)
|
|
|
|
if !successRate.IsKnown {
|
|
testInstance.Fatal("die Erfolgsquote gilt trotz zweier abgeschlossener Läufe als ungemessen")
|
|
}
|
|
|
|
if successRate.Value != 100 {
|
|
testInstance.Errorf("die Erfolgsquote ist %.1f Prozent, erwartet wurden 100 — "+
|
|
"der laufende Vorgang zählt in den Nenner", successRate.Value)
|
|
}
|
|
}
|
|
|
|
// TestPartialFailureIsNotASuccess prueft die verbindliche Regel 1.
|
|
//
|
|
// Ein Teilfehler ist kein Erfolg. Ihn mitzuzaehlen waere die bequemste Art, eine
|
|
// Quote zu schoenen — und der Bericht ist genau das Dokument, in dem diese
|
|
// Unterscheidung gelesen wird.
|
|
func TestPartialFailureIsNotASuccess(testInstance *testing.T) {
|
|
statisticsWithPartial := runStatistics{
|
|
TotalRuns: 2, SucceededRuns: 1, PartialRuns: 1,
|
|
}
|
|
|
|
successRate := successRateMetric(statisticsWithPartial)
|
|
|
|
if successRate.Value != 50 {
|
|
testInstance.Errorf("die Erfolgsquote ist %.1f Prozent, erwartet wurden 50 — "+
|
|
"ein Teilfehler zählt als Erfolg", successRate.Value)
|
|
}
|
|
}
|
|
|
|
// TestPointInTimeReportsIgnoreRequestedPeriod prueft den Zeitbezug.
|
|
//
|
|
// Ein Zustandsbericht ueber die Belegung „vom letzten Dienstag" kann es nicht
|
|
// geben; die Belegung wird nicht historisiert. Wer einen Zeitraum angibt,
|
|
// bekaeme sonst einen Zustandsbericht mit falscher Ueberschrift.
|
|
func TestPointInTimeReportsIgnoreRequestedPeriod(testInstance *testing.T) {
|
|
definition, found := FindDefinition(TypeRepositoryCapacity)
|
|
if !found {
|
|
testInstance.Fatal("die Reportart fehlt im Katalog")
|
|
}
|
|
|
|
requestedFrom := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
requestedTo := time.Date(2020, 2, 1, 0, 0, 0, 0, time.UTC)
|
|
referenceTime := time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC)
|
|
|
|
periodFrom, periodTo, periodError := ResolvePeriod(definition,
|
|
&requestedFrom, &requestedTo, referenceTime)
|
|
if periodError != nil {
|
|
testInstance.Fatalf("der Zeitraum ließ sich nicht bestimmen: %v", periodError)
|
|
}
|
|
|
|
if !periodFrom.Equal(referenceTime) || !periodTo.Equal(referenceTime) {
|
|
testInstance.Errorf("der Zustandsbericht übernahm den angefragten Zeitraum %s bis %s",
|
|
periodFrom, periodTo)
|
|
}
|
|
}
|
|
|
|
// TestRangeReportRejectsInvertedPeriod prueft die Zeitraumpruefung.
|
|
func TestRangeReportRejectsInvertedPeriod(testInstance *testing.T) {
|
|
definition, _ := FindDefinition(TypeDailyBackup)
|
|
|
|
laterTime := time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC)
|
|
earlierTime := laterTime.Add(-time.Hour)
|
|
|
|
if _, _, periodError := ResolvePeriod(definition, &laterTime, &earlierTime, laterTime); periodError == nil {
|
|
testInstance.Error("ein Zeitraum mit vertauschten Grenzen wurde angenommen")
|
|
}
|
|
}
|
|
|
|
// TestDefaultPeriodApplies prueft den Standardzeitraum.
|
|
func TestDefaultPeriodApplies(testInstance *testing.T) {
|
|
definition, _ := FindDefinition(TypeWeeklyBackup)
|
|
referenceTime := time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC)
|
|
|
|
periodFrom, periodTo, periodError := ResolvePeriod(definition, nil, nil, referenceTime)
|
|
if periodError != nil {
|
|
testInstance.Fatalf("der Zeitraum ließ sich nicht bestimmen: %v", periodError)
|
|
}
|
|
|
|
if periodTo.Sub(periodFrom) != 7*24*time.Hour {
|
|
testInstance.Errorf("der Standardzeitraum umfasst %v, erwartet wurden 7 Tage",
|
|
periodTo.Sub(periodFrom))
|
|
}
|
|
}
|
|
|
|
// TestEveryReportTypeIsBuildable prueft die Vollstaendigkeit des Katalogs.
|
|
//
|
|
// Geprueft wird nicht die Zahl der Berichte, sondern dass jeder im Katalog
|
|
// gefuehrte auch erzeugt werden kann. Ein Test auf „neun Stueck" bestuende
|
|
// weiter, wenn ein Bericht durch einen anderen ersetzt wuerde — genau dieser
|
|
// Fehler steckte im Diagrammtest aus Phase 13.
|
|
func TestEveryReportTypeIsBuildable(testInstance *testing.T) {
|
|
buildableTypes := map[ReportType]bool{
|
|
TypeDailyBackup: true, TypeWeeklyBackup: true, TypeMonthlyBackup: true,
|
|
TypeFailedBackup: true, TypeRepositoryCapacity: true, TypeRecovery: true,
|
|
TypeSecurity: true, TypeCompliance: true, TypeRecoveryObjectives: true,
|
|
}
|
|
|
|
for _, definition := range Definitions() {
|
|
if !buildableTypes[definition.Type] {
|
|
testInstance.Errorf("die Reportart %q steht im Katalog, wird aber nicht erzeugt",
|
|
definition.Type)
|
|
}
|
|
|
|
if definition.Title == "" || definition.Description == "" {
|
|
testInstance.Errorf("der Reportart %q fehlt Titel oder Beschreibung", definition.Type)
|
|
}
|
|
|
|
if len(definition.Formats) != 3 {
|
|
testInstance.Errorf("die Reportart %q bietet %d Formate; jeder Bericht beherrscht drei",
|
|
definition.Type, len(definition.Formats))
|
|
}
|
|
|
|
// Ein Bericht ueber einen Zeitraum braucht einen Standardzeitraum,
|
|
// sonst waere er ohne ausdrueckliche Angabe leer.
|
|
if definition.PeriodKind == PeriodRange && definition.DefaultPeriod <= 0 {
|
|
testInstance.Errorf("der Reportart %q fehlt der Standardzeitraum", definition.Type)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRepositoryStatusLabelsMatchSchema prueft die Zustandsbezeichnungen.
|
|
//
|
|
// Das Repository-Widget der Phase 12 prueft auf Zustaende, die das Schema nie
|
|
// kannte, und meldete „alle erreichbar" bei vier nicht erreichbaren. Ein
|
|
// unbekannter Zustand wird deshalb durchgereicht statt auf ein freundliches
|
|
// Wort abgebildet.
|
|
func TestRepositoryStatusLabelsMatchSchema(testInstance *testing.T) {
|
|
for _, schemaStatus := range []string{"active", "read_only", "unavailable", "maintenance"} {
|
|
if repositoryStatusLabel(schemaStatus) == schemaStatus {
|
|
testInstance.Errorf("der Zustand %q wird nicht übersetzt", schemaStatus)
|
|
}
|
|
}
|
|
|
|
// Ein kuenftiger Zustand darf nicht hinter „unbekannt" verschwinden.
|
|
if repositoryStatusLabel("archived") != "archived" {
|
|
testInstance.Error("ein unbekannter Zustand wird auf ein freundliches Wort abgebildet")
|
|
}
|
|
}
|
|
|
|
// TestShareMetricDistinguishesZeroFromNothing prueft den leeren Nenner.
|
|
//
|
|
// Null von null ist nicht null Prozent, sondern gar nichts. „0 % verschlüsselt"
|
|
// meldet einen Missstand; tatsaechlich gab es nichts zu verschluesseln.
|
|
func TestShareMetricDistinguishesZeroFromNothing(testInstance *testing.T) {
|
|
emptyShare := shareMetric("Verschlüsselt", 0, 0, "Es gibt keinen Wiederherstellungspunkt.")
|
|
|
|
if emptyShare.IsKnown {
|
|
testInstance.Error("ein Anteil ohne Grundgesamtheit gilt als gemessen")
|
|
}
|
|
|
|
measuredZero := shareMetric("Verschlüsselt", 0, 10, "Es gibt keinen Wiederherstellungspunkt.")
|
|
|
|
if !measuredZero.IsKnown {
|
|
testInstance.Error("null von zehn gilt als ungemessen")
|
|
}
|
|
|
|
if measuredZero.Value != 0 {
|
|
testInstance.Errorf("null von zehn ergab %.1f Prozent", measuredZero.Value)
|
|
}
|
|
}
|
|
|
|
// TestReportTextsCarryNoMarkup prueft die Textauszeichnung der Berichte.
|
|
//
|
|
// Ein Sternchenpaar, das im Browser fett wuerde, steht in der PDF-Datei
|
|
// woertlich da — im Nachweis erschien „**keine Zertifizierung**" mitten im
|
|
// Bericht fuer Pruefungen. Das Modell ist formatunabhaengig; sein Text darf
|
|
// deshalb kein Format tragen.
|
|
func TestReportTextsCarryNoMarkup(testInstance *testing.T) {
|
|
for _, definition := range Definitions() {
|
|
if strings.Contains(definition.Description, "**") ||
|
|
strings.Contains(definition.Title, "**") {
|
|
testInstance.Errorf("die Reportart %q trägt Auszeichnung im Text", definition.Type)
|
|
}
|
|
}
|
|
|
|
// Auch die Hinweise, die erst beim Bauen entstehen.
|
|
sampleReport := &Report{}
|
|
sampleReport.AddNote("Ein Hinweis ohne Auszeichnung.")
|
|
|
|
for _, note := range sampleReport.Notes {
|
|
if strings.Contains(note, "**") {
|
|
testInstance.Errorf("der Hinweis %q trägt Auszeichnung", note)
|
|
}
|
|
}
|
|
}
|