syncova-backup/apps/api/internal/httpapi/contract_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

262 lines
7.7 KiB
Go

package httpapi
import (
"go/ast"
"go/parser"
"go/token"
"os"
"sort"
"strconv"
"strings"
"testing"
)
// contractGoldenFile ist die eingefrorene Endpunktliste.
const contractGoldenFile = "contract_routes.txt"
// TestAPIContractIsFrozen haelt den API-Vertrag fest (Phase 22).
//
// Der Vertrag ist eingefroren: `/api/v1` ist ausgeliefert, und jede Aenderung
// daran bricht bestehende Aufrufer. Ein Endpunkt, der still verschwindet oder
// eine andere Berechtigung bekommt, faellt niemandem auf, bis ein Kunde anruft
// — genau deshalb steht er hier in einer Datei, die man **absichtlich** aendern
// muss.
//
// Geprueft wird der Quelltext des Routers, nicht der gebaute Multiplexer:
// http.ServeMux gibt seine Routen nicht heraus, und ein Test, der nur die
// bekannten Routen anfragt, bemerkt eine **hinzugefuegte** nicht. Eine neue
// Route ist aber genau die haeufigste Vertragsaenderung.
//
// Bricht dieser Test, ist das keine Panne, sondern die Frage: Ist die Aenderung
// abwaertskompatibel? Wenn nein, gehoert sie hinter `/api/v2`.
func TestAPIContractIsFrozen(testInstance *testing.T) {
registeredRoutes := extractRegisteredRoutes(testInstance, "router.go")
goldenContent, readError := os.ReadFile(contractGoldenFile)
if readError != nil {
testInstance.Fatalf("die eingefrorene Endpunktliste ließ sich nicht lesen: %v", readError)
}
expectedRoutes := parseGoldenRoutes(string(goldenContent))
reportRouteDifferences(testInstance, expectedRoutes, registeredRoutes)
}
// reportRouteDifferences nennt jede Abweichung einzeln.
//
// Eine reine "stimmt nicht ueberein"-Meldung zwaenge zum Vergleichen von Hand;
// bei knapp hundert Endpunkten sucht das niemand freiwillig.
func reportRouteDifferences(testInstance *testing.T, expectedRoutes []string, actualRoutes []string) {
testInstance.Helper()
expectedSet := make(map[string]bool, len(expectedRoutes))
for _, singleRoute := range expectedRoutes {
expectedSet[singleRoute] = true
}
actualSet := make(map[string]bool, len(actualRoutes))
for _, singleRoute := range actualRoutes {
actualSet[singleRoute] = true
}
for _, singleRoute := range actualRoutes {
if !expectedSet[singleRoute] {
testInstance.Errorf("neuer oder geänderter Endpunkt: %s\n"+
" Ist er abwärtskompatibel? Dann in %s eintragen. Wenn nicht, gehört er hinter /api/v2.",
singleRoute, contractGoldenFile)
}
}
for _, singleRoute := range expectedRoutes {
if !actualSet[singleRoute] {
testInstance.Errorf("entfallener Endpunkt: %s\n"+
" Ein ausgelieferter Endpunkt darf nicht verschwinden — bestehende Aufrufer brechen.",
singleRoute)
}
}
}
// extractRegisteredRoutes liest die Routen aus dem Quelltext des Routers.
//
// Jede Zeile hat die Form "<METHODE> <pfad> <berechtigung>". Die Berechtigung
// gehoert dazu, weil eine stillschweigend gelockerte Pruefung die gefaehrlichste
// Vertragsaenderung ueberhaupt waere: Der Endpunkt funktioniert weiter, nur
// duerfen ihn ploetzlich mehr Leute aufrufen.
func extractRegisteredRoutes(testInstance *testing.T, sourceFileName string) []string {
testInstance.Helper()
fileSet := token.NewFileSet()
parsedFile, parseError := parser.ParseFile(fileSet, sourceFileName, nil, parser.SkipObjectResolution)
if parseError != nil {
testInstance.Fatalf("der Router ließ sich nicht auswerten: %v", parseError)
}
collectedRoutes := make([]string, 0, 128)
ast.Inspect(parsedFile, func(currentNode ast.Node) bool {
callExpression, isCall := currentNode.(*ast.CallExpr)
if !isCall {
return true
}
methodName, isMultiplexerCall := multiplexerMethodName(callExpression)
if !isMultiplexerCall || len(callExpression.Args) == 0 {
return true
}
routePattern, patternResolved := resolveRoutePattern(callExpression.Args[0])
if !patternResolved {
return true
}
// Die Auffangroute "/" ist kein fachlicher Endpunkt, sondern die
// Fehlerhülle für alles Unbekannte.
if routePattern == "/" {
return true
}
requiredPermission := "-"
if methodName == "Handle" && len(callExpression.Args) > 1 {
requiredPermission = resolveRequiredPermission(callExpression.Args[1])
}
collectedRoutes = append(collectedRoutes, routePattern+" "+requiredPermission)
return true
})
sort.Strings(collectedRoutes)
return collectedRoutes
}
// multiplexerMethodName erkennt einen Aufruf auf dem Router.
func multiplexerMethodName(callExpression *ast.CallExpr) (string, bool) {
selectorExpression, isSelector := callExpression.Fun.(*ast.SelectorExpr)
if !isSelector {
return "", false
}
receiverIdentifier, isIdentifier := selectorExpression.X.(*ast.Ident)
if !isIdentifier || receiverIdentifier.Name != "requestMultiplexer" {
return "", false
}
if selectorExpression.Sel.Name != "Handle" && selectorExpression.Sel.Name != "HandleFunc" {
return "", false
}
return selectorExpression.Sel.Name, true
}
// resolveRoutePattern setzt das Routenmuster aus dem Ausdruck zusammen.
//
// Die Routen stehen als `"GET " + apiBasePath + "/agents"` im Quelltext; der
// Basispfad wird dabei aufgeloest, damit die eingefrorene Liste die
// tatsaechlichen Adressen enthaelt und nicht eine Konstante.
func resolveRoutePattern(patternExpression ast.Expr) (string, bool) {
switch typedExpression := patternExpression.(type) {
case *ast.BasicLit:
if typedExpression.Kind != token.STRING {
return "", false
}
unquotedValue, unquoteError := strconv.Unquote(typedExpression.Value)
if unquoteError != nil {
return "", false
}
return unquotedValue, true
case *ast.Ident:
if typedExpression.Name == "apiBasePath" {
return apiBasePath, true
}
return "", false
case *ast.BinaryExpr:
if typedExpression.Op != token.ADD {
return "", false
}
leftValue, leftResolved := resolveRoutePattern(typedExpression.X)
rightValue, rightResolved := resolveRoutePattern(typedExpression.Y)
if !leftResolved || !rightResolved {
return "", false
}
return leftValue + rightValue, true
default:
return "", false
}
}
// resolveRequiredPermission liest die geforderte Berechtigung aus dem Aufruf.
//
// Der übliche Fall ist `protected("agents.read", handler)`. Alles andere —
// etwa die Middleware der Agentenanmeldung — wird als solches benannt statt
// stillschweigend als "keine Berechtigung" geführt.
func resolveRequiredPermission(handlerExpression ast.Expr) string {
callExpression, isCall := handlerExpression.(*ast.CallExpr)
if !isCall || len(callExpression.Args) == 0 {
return "?"
}
functionIdentifier, isIdentifier := callExpression.Fun.(*ast.Ident)
if !isIdentifier {
return "?"
}
if functionIdentifier.Name == "agentAuthenticated" {
return "agent-token"
}
// Angemeldet, aber ohne besondere Berechtigung: die Endpunkte, die jeder
// über sich selbst aufruft. Sie als "?" zu führen wäre die schlechteste
// Auskunft — eine Unklarheit in einem eingefrorenen Vertrag verdeckt genau
// die Änderung, die er aufdecken soll.
if functionIdentifier.Name == "authenticated" {
return "sitzung"
}
if functionIdentifier.Name != "protected" {
return "?"
}
permissionLiteral, isLiteral := callExpression.Args[0].(*ast.BasicLit)
if !isLiteral || permissionLiteral.Kind != token.STRING {
return "?"
}
unquotedPermission, unquoteError := strconv.Unquote(permissionLiteral.Value)
if unquoteError != nil {
return "?"
}
return unquotedPermission
}
// parseGoldenRoutes liest die eingefrorene Liste.
func parseGoldenRoutes(fileContent string) []string {
parsedRoutes := make([]string, 0, 128)
for _, currentLine := range strings.Split(fileContent, "\n") {
trimmedLine := strings.TrimSpace(currentLine)
if trimmedLine == "" || strings.HasPrefix(trimmedLine, "#") {
continue
}
parsedRoutes = append(parsedRoutes, trimmedLine)
}
sort.Strings(parsedRoutes)
return parsedRoutes
}