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>
255 lines
9.3 KiB
Go
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")
|
|
}
|
|
}
|