syncova-backup/packages/platform/config/config_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

255 lines
9.3 KiB
Go

package config
import (
"os"
"strings"
"testing"
"time"
)
// isolateEnvironment entfernt alle SYNCOVA_-Variablen für die Dauer eines Tests.
//
// Ohne diese Isolierung würde eine lokale .env oder eine gesetzte CI-Variable in
// den Test hineinwirken: ein Test, der die sicheren Standardwerte prüft, ginge
// dann je nach Entwicklungsrechner unterschiedlich aus. t.Setenv stellt den
// ursprünglichen Zustand nach dem Test automatisch wieder her.
func isolateEnvironment(testInstance *testing.T) {
testInstance.Helper()
for _, environmentEntry := range os.Environ() {
variableName, _, isValidEntry := strings.Cut(environmentEntry, "=")
if !isValidEntry || !strings.HasPrefix(variableName, envPrefix) {
continue
}
// Ein leerer Wert gilt in diesem Paket als "nicht gesetzt".
testInstance.Setenv(variableName, "")
}
}
// setMinimalValidEnvironment isoliert die Umgebung und setzt genau die Variablen,
// ohne die Load() fehlschlägt. Jeder Test ergänzt von hier aus nur den Aspekt,
// den er prüft.
func setMinimalValidEnvironment(testInstance *testing.T) {
testInstance.Helper()
isolateEnvironment(testInstance)
testInstance.Setenv(envPrefix+"DB_USER", "syncova")
testInstance.Setenv(envPrefix+"DB_PASSWORD", "geheim-nur-im-test")
testInstance.Setenv(envPrefix+"DB_NAME", "syncova")
// Ein 32-Byte-Schlüssel in base64; er dient ausschließlich diesem Test.
testInstance.Setenv(envPrefix+"ENCRYPTION_KEYS", "v1:"+testEncryptionKey)
}
// testEncryptionKey ist ein fester 32-Byte-Schlüssel für Tests.
//
// Er schützt keine echten Daten und darf deshalb im Quelltext stehen; in der
// Produktion gibt es bewusst keinen Standardschlüssel.
const testEncryptionKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
func TestLoadFailsWithoutDatabasePassword(testInstance *testing.T) {
isolateEnvironment(testInstance)
testInstance.Setenv(envPrefix+"DB_USER", "syncova")
testInstance.Setenv(envPrefix+"DB_NAME", "syncova")
// Das Passwort fehlt bewusst: ein Default wäre eine stillschweigend unsichere Konfiguration.
_, loadError := Load("test")
if loadError == nil {
testInstance.Fatal("Load() muss ohne DB_PASSWORD fehlschlagen, hat aber eine Konfiguration geliefert")
}
if !strings.Contains(loadError.Error(), "DB_PASSWORD") {
testInstance.Fatalf("Fehlermeldung soll die fehlende Variable benennen, war: %v", loadError)
}
}
func TestLoadReportsAllValidationErrorsAtOnce(testInstance *testing.T) {
isolateEnvironment(testInstance)
// Weder Benutzer noch Passwort noch Datenbankname sind gesetzt.
_, loadError := Load("test")
if loadError == nil {
testInstance.Fatal("Load() muss bei leerer Umgebung fehlschlagen")
}
// Der Betreiber soll alle Probleme in einem Durchgang beheben können.
for _, expectedVariable := range []string{"DB_USER", "DB_PASSWORD", "DB_NAME"} {
if !strings.Contains(loadError.Error(), expectedVariable) {
testInstance.Errorf("Fehlermeldung soll %s enthalten, war: %v", expectedVariable, loadError)
}
}
}
func TestLoadAppliesSecureDefaults(testInstance *testing.T) {
setMinimalValidEnvironment(testInstance)
loadedConfig, loadError := Load("syncova-api")
if loadError != nil {
testInstance.Fatalf("Load() schlug unerwartet fehl: %v", loadError)
}
// Ohne Angabe wird nicht die Produktion angenommen.
if loadedConfig.Environment != EnvironmentDevelopment {
testInstance.Errorf("Standardumgebung soll development sein, war %q", loadedConfig.Environment)
}
// Ein Listener auf 0.0.0.0 wäre ein unbeabsichtigt exponierter Dienst.
if loadedConfig.HTTP.ListenAddress != "127.0.0.1:8080" {
testInstance.Errorf("Standard-Listener soll nur lokal binden, war %q", loadedConfig.HTTP.ListenAddress)
}
// Ohne Limit könnte ein einzelner Request den Speicher erschöpfen.
if loadedConfig.HTTP.MaxRequestBodyBytes != 1<<20 {
testInstance.Errorf("Standard-Body-Limit soll 1 MiB sein, war %d", loadedConfig.HTTP.MaxRequestBodyBytes)
}
// Ohne explizite Freigabe darf keine fremde Herkunft zugreifen.
if len(loadedConfig.HTTP.AllowedOrigins) != 0 {
testInstance.Errorf("CORS soll standardmäßig leer sein, war %v", loadedConfig.HTTP.AllowedOrigins)
}
}
func TestLoadRejectsWildcardCORSOrigin(testInstance *testing.T) {
setMinimalValidEnvironment(testInstance)
testInstance.Setenv(envPrefix+"HTTP_ALLOWED_ORIGINS", "*")
_, loadError := Load("test")
if loadError == nil {
testInstance.Fatal("Load() muss eine Wildcard-Herkunft ablehnen")
}
}
func TestLoadRejectsPlaintextOriginInProduction(testInstance *testing.T) {
setMinimalValidEnvironment(testInstance)
testInstance.Setenv(envPrefix+"ENV", "production")
testInstance.Setenv(envPrefix+"HTTP_ALLOWED_ORIGINS", "http://syncova.example.local")
_, loadError := Load("test")
if loadError == nil {
testInstance.Fatal("Load() muss eine unverschlüsselte Herkunft in der Produktion ablehnen")
}
}
func TestLoadAcceptsSeveralHTTPSOrigins(testInstance *testing.T) {
setMinimalValidEnvironment(testInstance)
testInstance.Setenv(envPrefix+"ENV", "production")
testInstance.Setenv(envPrefix+"HTTP_ALLOWED_ORIGINS", "https://a.example.local, https://b.example.local")
loadedConfig, loadError := Load("test")
if loadError != nil {
testInstance.Fatalf("Load() schlug unerwartet fehl: %v", loadError)
}
if len(loadedConfig.HTTP.AllowedOrigins) != 2 {
testInstance.Fatalf("es sollen 2 Herkünfte geladen werden, waren %v", loadedConfig.HTTP.AllowedOrigins)
}
}
func TestLoadRejectsNonPositiveDurations(testInstance *testing.T) {
setMinimalValidEnvironment(testInstance)
testInstance.Setenv(envPrefix+"HTTP_READ_TIMEOUT", "0s")
// Ein Timeout von 0 würde die Begrenzung faktisch abschalten.
_, loadError := Load("test")
if loadError == nil {
testInstance.Fatal("Load() muss ein Timeout von 0 ablehnen")
}
}
func TestLoadRejectsUnknownEnvironment(testInstance *testing.T) {
setMinimalValidEnvironment(testInstance)
testInstance.Setenv(envPrefix+"ENV", "staging")
_, loadError := Load("test")
if loadError == nil {
testInstance.Fatal("Load() muss eine unbekannte Umgebung ablehnen")
}
}
func TestRedactedConnectionStringHidesPassword(testInstance *testing.T) {
const secretPassword = "streng-geheimes-passwort" // secretscan:erlaubt: erfundener Testwert
databaseConfig := DatabaseConfig{
Host: "db.example.local",
Port: 5432,
Name: "syncova",
User: "syncova",
password: secretPassword,
SSLMode: "require",
ConnectTimeout: 10 * time.Second,
}
redactedString := databaseConfig.RedactedConnectionString()
if strings.Contains(redactedString, secretPassword) {
testInstance.Fatalf("die redigierte DSN enthält das Passwort: %q", redactedString)
}
// Die echte DSN muss das Passwort dagegen tatsächlich transportieren.
if !strings.Contains(databaseConfig.ConnectionString(), secretPassword) {
testInstance.Fatal("ConnectionString() muss das Passwort enthalten, sonst schlägt der Verbindungsaufbau fehl")
}
}
func TestAllowsDemoDataIsFalseInProduction(testInstance *testing.T) {
// PROMPT.md §139: Demo-Daten sind in der Produktion ausgeschlossen.
if EnvironmentProduction.AllowsDemoData() {
testInstance.Fatal("die Produktion darf keine Demo-Daten zulassen")
}
for _, permissiveEnvironment := range []Environment{EnvironmentDevelopment, EnvironmentTest, EnvironmentDemo} {
if !permissiveEnvironment.AllowsDemoData() {
testInstance.Errorf("%q soll Demo-Daten zulassen", permissiveEnvironment)
}
}
}
func TestLoadFailsWithoutEncryptionKey(testInstance *testing.T) {
isolateEnvironment(testInstance)
testInstance.Setenv(envPrefix+"DB_USER", "syncova")
testInstance.Setenv(envPrefix+"DB_PASSWORD", "geheim-nur-im-test")
testInstance.Setenv(envPrefix+"DB_NAME", "syncova")
// Ohne Schlüssel liessen sich MFA-Secrets nicht verschlüsselt ablegen.
_, loadError := Load("test")
if loadError == nil {
testInstance.Fatal("Load() muss ohne ENCRYPTION_KEYS fehlschlagen")
}
if !strings.Contains(loadError.Error(), "ENCRYPTION_KEYS") {
testInstance.Fatalf("die Fehlermeldung soll die Variable benennen, war: %v", loadError)
}
}
func TestLoadRejectsEncryptionKeyOfWrongLength(testInstance *testing.T) {
setMinimalValidEnvironment(testInstance)
// 16 Byte statt der geforderten 32.
testInstance.Setenv(envPrefix+"ENCRYPTION_KEYS", "v1:AAAAAAAAAAAAAAAAAAAAAA==")
if _, loadError := Load("test"); loadError == nil {
testInstance.Fatal("ein zu kurzer Schlüssel muss abgelehnt werden")
}
}
func TestLoadRequiresCurrentKeyWhenSeveralKeysExist(testInstance *testing.T) {
setMinimalValidEnvironment(testInstance)
testInstance.Setenv(envPrefix+"ENCRYPTION_KEYS", "v1:"+testEncryptionKey+",v2:"+testEncryptionKey)
// Bei mehreren Schlüsseln muss ausdrücklich benannt werden, welcher gilt:
// eine falsche Annahme führte zu Daten, die mit dem falschen Schlüssel liegen.
if _, loadError := Load("test"); loadError == nil {
testInstance.Fatal("bei mehreren Schlüsseln muss ENCRYPTION_CURRENT_KEY verlangt werden")
}
}
func TestLoadRejectsAccessTokenLongerThanRefreshToken(testInstance *testing.T) {
setMinimalValidEnvironment(testInstance)
testInstance.Setenv(envPrefix+"AUTH_ACCESS_TOKEN_TTL", "24h")
testInstance.Setenv(envPrefix+"AUTH_REFRESH_TOKEN_TTL", "1h")
// Ein Zugriffstoken, das sein Erneuerungstoken überdauert, ergibt keinen Sinn.
if _, loadError := Load("test"); loadError == nil {
testInstance.Fatal("ein zu langlebiges Zugriffstoken muss abgelehnt werden")
}
}