syncova-backup/packages/reports/generator_test.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
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>
2026-08-17 09:10:54 +02:00

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)
}
}
}