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") } }