syncova-backup/packages/reports/export_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

363 lines
12 KiB
Go

package reports
import (
"bytes"
"encoding/json"
"strings"
"testing"
"time"
)
// buildSampleReport erzeugt einen Bericht mit allen Bestandteilen.
func buildSampleReport() *Report {
referenceTime := time.Date(2026, 8, 12, 9, 30, 0, 0, time.UTC)
sampleReport := &Report{
Type: TypeDailyBackup,
Title: "Tagesbericht Sicherungen",
Description: "Alle Sicherungsläufe eines Tages mit Ergebnis und Größe.",
PeriodFrom: referenceTime.Add(-24 * time.Hour),
PeriodTo: referenceTime,
GeneratedAt: referenceTime,
GeneratedBy: "pruefer",
Sections: []Section{
{
Title: "Überblick",
Metrics: []Metric{
KnownMetric("Läufe insgesamt", 12, UnitCount),
KnownMetric("Gesicherte Datenmenge", 1503238553, UnitBytes),
UnknownMetric("Erfolgsquote", UnitPercent,
"Im gewählten Zeitraum gab es keinen Lauf."),
TextMetric("Protokollierung", "aktiv"),
},
Tables: []Table{
{
Title: "Läufe",
Columns: []string{"Auftrag", "Ergebnis", "Größe"},
Rows: [][]string{
{"Dateiserver Dokumente", "erfolgreich", "1,4 GiB"},
{"Datenbank Hauptsystem", "TEILWEISE FEHLGESCHLAGEN", "22,0 MiB"},
},
},
{
Title: "Übersprungene Aufträge",
Columns: []string{"Auftrag", "Grund"},
EmptyNotice: "Kein Auftrag wurde übersprungen.",
},
},
},
},
Notes: []string{"Die Größe nach Kompression wird nicht festgehalten."},
}
return sampleReport
}
// TestCSVLeavesUnmeasuredValuesEmpty ist der wichtigste Test des Exports.
//
// Eine Null in einer Tabellenkalkulation wird summiert, gemittelt und in ein
// Diagramm gezeichnet. Sie ueberlebt jede muendliche Erlaeuterung, mit der man
// sie einordnen wollte — deshalb darf eine ungemessene Kennzahl niemals als
// Zahl in der Datei landen.
func TestCSVLeavesUnmeasuredValuesEmpty(testInstance *testing.T) {
outputBuffer := &bytes.Buffer{}
if writeError := WriteCSV(outputBuffer, buildSampleReport()); writeError != nil {
testInstance.Fatalf("die ausgabe schlug fehl: %v", writeError)
}
csvContent := outputBuffer.String()
for _, csvLine := range strings.Split(csvContent, "\n") {
if !strings.HasPrefix(csvLine, "Erfolgsquote") {
continue
}
if strings.Contains(csvLine, ",0,") || strings.HasSuffix(strings.TrimSpace(csvLine), ",0") {
testInstance.Errorf("die ungemessene Erfolgsquote steht als Null in der Datei: %q", csvLine)
}
if !strings.Contains(csvLine, "nicht gemessen") {
testInstance.Errorf("die Zeile nennt den Grund nicht: %q", csvLine)
}
return
}
testInstance.Error("die Zeile der Erfolgsquote fehlt in der Ausgabe")
}
// TestCSVWritesRawNumbersNotFormattedText prueft die Rechenbarkeit.
//
// Eine Tabellenkalkulation soll rechnen koennen. „1,4 GiB" ist Text und macht
// jede Summe unmoeglich.
func TestCSVWritesRawNumbersNotFormattedText(testInstance *testing.T) {
outputBuffer := &bytes.Buffer{}
if writeError := WriteCSV(outputBuffer, buildSampleReport()); writeError != nil {
testInstance.Fatalf("die ausgabe schlug fehl: %v", writeError)
}
if !strings.Contains(outputBuffer.String(), "1503238553") {
testInstance.Error("die Datenmenge steht nicht als Rohwert in der Datei")
}
}
// TestCSVStartsWithByteOrderMark prueft die Lesbarkeit unter Windows.
//
// Ohne ihn zeigt Excel „Sicherungslÿufe" — bei einem Bericht mit deutschen
// Beschriftungen also bei jeder Datei.
func TestCSVStartsWithByteOrderMark(testInstance *testing.T) {
outputBuffer := &bytes.Buffer{}
if writeError := WriteCSV(outputBuffer, buildSampleReport()); writeError != nil {
testInstance.Fatalf("die ausgabe schlug fehl: %v", writeError)
}
if !bytes.HasPrefix(outputBuffer.Bytes(), []byte{0xEF, 0xBB, 0xBF}) {
testInstance.Error("der Datei fehlt die Byte-Order-Markierung")
}
}
// TestCSVNamesEmptyTables prueft die Aussage einer leeren Tabelle.
//
// „Kein Auftrag wurde uebersprungen" ist eine Aussage, eine leere Flaeche ist
// keine.
func TestCSVNamesEmptyTables(testInstance *testing.T) {
outputBuffer := &bytes.Buffer{}
if writeError := WriteCSV(outputBuffer, buildSampleReport()); writeError != nil {
testInstance.Fatalf("die ausgabe schlug fehl: %v", writeError)
}
if !strings.Contains(outputBuffer.String(), "Kein Auftrag wurde übersprungen.") {
testInstance.Error("die leere Tabelle wird ohne Hinweis ausgegeben")
}
}
// TestPDFHasValidStructure prueft, dass die Datei ein PDF ist.
//
// „Es kommen Bytes heraus" ist kein Nachweis. Geprueft werden Kopf, Endmarke,
// Querverweistabelle und die Angabe der Objektzahl — genau die Bestandteile,
// an denen ein Betrachter eine Datei zurueckweist.
func TestPDFHasValidStructure(testInstance *testing.T) {
outputBuffer := &bytes.Buffer{}
if writeError := WritePDF(outputBuffer, buildSampleReport()); writeError != nil {
testInstance.Fatalf("die ausgabe schlug fehl: %v", writeError)
}
pdfBytes := outputBuffer.Bytes()
if !bytes.HasPrefix(pdfBytes, []byte("%PDF-1.4")) {
testInstance.Error("der Kopf fehlt")
}
if !bytes.HasSuffix(bytes.TrimRight(pdfBytes, "\n"), []byte("%%EOF")) {
testInstance.Error("die Endmarke fehlt")
}
for _, requiredPart := range []string{"/Type /Catalog", "/Type /Pages", "/Type /Page",
"/BaseFont /Helvetica", "/Encoding /WinAnsiEncoding", "xref", "trailer", "startxref"} {
if !bytes.Contains(pdfBytes, []byte(requiredPart)) {
testInstance.Errorf("dem Dokument fehlt %q", requiredPart)
}
}
}
// TestPDFCrossReferenceOffsetsArePrecise prueft die Querverweistabelle.
//
// Sie ist der einzige Teil eines PDF, bei dem ein Fehler nicht auffaellt,
// solange man die Datei nur ansieht: Ein falscher Offset bricht das Dokument
// erst im Betrachter des Empfaengers — und dann heisst es, der Bericht sei
// beschaedigt angekommen.
func TestPDFCrossReferenceOffsetsArePrecise(testInstance *testing.T) {
outputBuffer := &bytes.Buffer{}
if writeError := WritePDF(outputBuffer, buildSampleReport()); writeError != nil {
testInstance.Fatalf("die ausgabe schlug fehl: %v", writeError)
}
pdfBytes := outputBuffer.Bytes()
// Gesucht wird "\nxref\n", nicht "xref\n": Letzteres steckt auch in
// "startxref", und LastIndex faende genau das — der Test praeglte dann eine
// Tabelle mit null Eintraegen und bestuende immer.
crossReferenceStart := bytes.LastIndex(pdfBytes, []byte("\nxref\n"))
if crossReferenceStart < 0 {
testInstance.Fatal("die Querverweistabelle fehlt")
}
crossReferenceStart++
// Jeder Eintrag verweist auf den Beginn eines Objekts. Steht dort nicht
// „<zahl> 0 obj", zeigt der Verweis ins Leere.
tableLines := strings.Split(string(pdfBytes[crossReferenceStart:]), "\n")
checkedEntries := 0
for _, tableLine := range tableLines[2:] {
if !strings.HasSuffix(tableLine, " 00000 n ") {
continue
}
var objectOffset int
if _, scanError := parseOffset(tableLine, &objectOffset); scanError != nil {
testInstance.Fatalf("der Eintrag %q ist nicht lesbar: %v", tableLine, scanError)
}
if objectOffset >= len(pdfBytes) {
testInstance.Fatalf("der Verweis %d liegt hinter dem Dateiende (%d Byte)",
objectOffset, len(pdfBytes))
}
remainder := pdfBytes[objectOffset:]
if !bytes.Contains(remainder[:min(24, len(remainder))], []byte(" 0 obj")) {
testInstance.Errorf("der Verweis %d zeigt nicht auf einen Objektbeginn, sondern auf %q",
objectOffset, string(remainder[:min(24, len(remainder))]))
}
checkedEntries++
}
if checkedEntries < 6 {
testInstance.Errorf("nur %d Verweise geprüft; die Tabelle ist unvollständig", checkedEntries)
}
}
// parseOffset liest den Byte-Offset eines Querverweiseintrags.
func parseOffset(tableLine string, objectOffset *int) (int, error) {
return sscanDecimal(strings.TrimSpace(strings.Split(tableLine, " ")[0]), objectOffset)
}
// sscanDecimal liest eine Dezimalzahl mit fuehrenden Nullen.
func sscanDecimal(rawNumber string, target *int) (int, error) {
parsedValue := 0
for _, digitRune := range rawNumber {
if digitRune < '0' || digitRune > '9' {
return 0, errNotANumber
}
parsedValue = parsedValue*10 + int(digitRune-'0')
}
*target = parsedValue
return 1, nil
}
// errNotANumber meldet einen unlesbaren Eintrag.
var errNotANumber = errorString("der Eintrag enthält keine Zahl")
// errorString ist ein Fehler aus einer festen Zeichenkette.
type errorString string
// Error liefert den Fehlertext.
func (message errorString) Error() string { return string(message) }
// TestPDFEncodesGermanUmlauts prueft die Zeichenkodierung.
//
// Ohne Umwandlung nach WinAnsi zeigt der Betrachter „Sicherungsläufe" — und
// zwar erst beim Empfaenger, nicht beim Erzeuger.
func TestPDFEncodesGermanUmlauts(testInstance *testing.T) {
umlautReport := buildSampleReport()
umlautReport.Title = "Prüfbericht Größe"
outputBuffer := &bytes.Buffer{}
if writeError := WritePDF(outputBuffer, umlautReport); writeError != nil {
testInstance.Fatalf("die ausgabe schlug fehl: %v", writeError)
}
// „ü" ist in UTF-8 zwei Byte (0xC3 0xBC), in WinAnsi eines (0xFC).
if bytes.Contains(outputBuffer.Bytes(), []byte{0xC3, 0xBC}) {
testInstance.Error("das Dokument enthält UTF-8-Bytes statt WinAnsi")
}
if !bytes.Contains(outputBuffer.Bytes(), []byte{0xFC}) {
testInstance.Error("der Umlaut fehlt im Dokument")
}
}
// TestPDFBreaksIntoPages prueft den Seitenumbruch.
//
// Ein Monatsbericht ueber hundert Auftraege passt nicht auf eine Seite. Ohne
// Umbruch schriebe der Satz unter den Seitenrand — und dort ist nichts zu
// sehen, ohne dass eine Fehlermeldung darauf hinwiese.
func TestPDFBreaksIntoPages(testInstance *testing.T) {
longReport := buildSampleReport()
manyRows := make([][]string, 0, 200)
for rowIndex := range 200 {
manyRows = append(manyRows, []string{
"Auftrag " + trimNumber(float64(rowIndex)), "erfolgreich", "1,0 GiB",
})
}
longReport.Sections[0].Tables[0].Rows = manyRows
outputBuffer := &bytes.Buffer{}
if writeError := WritePDF(outputBuffer, longReport); writeError != nil {
testInstance.Fatalf("die ausgabe schlug fehl: %v", writeError)
}
pageCount := bytes.Count(outputBuffer.Bytes(), []byte("/Type /Page "))
if pageCount < 3 {
testInstance.Errorf("200 Zeilen ergaben %d Seiten; der Umbruch greift nicht", pageCount)
}
// Die letzte Zeile muss enthalten sein, sonst wurde still abgeschnitten.
if !bytes.Contains(outputBuffer.Bytes(), []byte("Auftrag 199")) {
testInstance.Error("die letzte Zeile fehlt im Dokument")
}
}
// TestJSONKeepsMeasuredZero prueft, dass eine gemessene Null erhalten bleibt.
//
// Derselbe Fehler wie in Phase 13: Ein `omitempty` liesse einen gemessenen Wert
// von null aus der Antwort verschwinden, und der Leser saehe „gemessen" ohne
// Wert.
func TestJSONKeepsMeasuredZero(testInstance *testing.T) {
zeroReport := buildSampleReport()
zeroReport.Sections[0].Metrics = []Metric{KnownMetric("Gescheiterte Läufe", 0, UnitCount)}
outputBuffer := &bytes.Buffer{}
if writeError := WriteJSON(outputBuffer, zeroReport); writeError != nil {
testInstance.Fatalf("die ausgabe schlug fehl: %v", writeError)
}
var decodedReport map[string]any
if decodeError := json.Unmarshal(outputBuffer.Bytes(), &decodedReport); decodeError != nil {
testInstance.Fatalf("die Ausgabe ist kein gültiges JSON: %v", decodeError)
}
firstSection := decodedReport["sections"].([]any)[0].(map[string]any)
firstMetric := firstSection["metrics"].([]any)[0].(map[string]any)
if _, hasValue := firstMetric["value"]; !hasValue {
testInstance.Error("die gemessene Null fehlt in der Ausgabe")
}
if firstMetric["is_known"] != true {
testInstance.Error("die Kennzahl gilt nicht als gemessen")
}
}
// TestFileNameIsSafe prueft die Bereinigung des Dateinamens.
func TestFileNameIsSafe(testInstance *testing.T) {
unsafeName := SanitizeFileName("bericht\"\r\nX-Injected: 1.pdf")
for _, forbiddenRune := range []string{"\"", "\r", "\n", " ", ":"} {
if strings.Contains(unsafeName, forbiddenRune) {
testInstance.Errorf("der Name enthält %q: %q", forbiddenRune, unsafeName)
}
}
}