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>
363 lines
12 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|