package hypervisor import ( "context" "errors" "os" "strings" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "github.com/syncova/syncova/packages/platform/crypto" ) // TestClusterInputValidation prueft die Eingangspruefung. // // Sie ist der einzige Punkt, an dem ein unbrauchbarer Verbund auffaellt, ohne // dass jemand darunter leidet: Ein Verbund ohne Zugriffsweg auf die Archive // laesst sich sonst einrichten, meldet „erreichbar" und liefert bei der ersten // Sicherung um zwei Uhr nachts kein einziges Byte. func TestClusterInputValidation(testInstance *testing.T) { validInput := ClusterInput{ Name: "pve-verbund", APIEndpoint: "https://pve.example:8006", APITokenID: "syncova@pve!backup", APITokenSecret: "geheim", // secretscan:erlaubt: erfundener Testwert BackupStorageID: "local", ArchiveTransport: TransportLocal, ArchiveMountRoots: map[string]string{"local": "/var/lib/vz"}, } if validationError := validInput.Validate(); validationError != nil { testInstance.Fatalf("eine gültige Eingabe wurde abgelehnt: %v", validationError) } testCases := []struct { name string modify func(*ClusterInput) expectedInMsg string }{ { name: "ohne Namen", modify: func(input *ClusterInput) { input.Name = "" }, expectedInMsg: "namen", }, { // http:// ist ein Tippfehler mit Folgen: Das API-Token ginge im // Klartext über das Netz. name: "unverschlüsselte Adresse", modify: func(input *ClusterInput) { input.APIEndpoint = "http://pve.example:8006" }, expectedInMsg: "klartext", }, { name: "ohne Token", modify: func(input *ClusterInput) { input.APITokenSecret = "" }, expectedInMsg: "api-token", }, { name: "ohne Sicherungsspeicher", modify: func(input *ClusterInput) { input.BackupStorageID = "" }, expectedInMsg: "speicher", }, { name: "lokaler Weg ohne Zuordnung", modify: func(input *ClusterInput) { input.ArchiveMountRoots = nil }, expectedInMsg: "zuordnung", }, { name: "lokaler Weg mit relativem Pfad", modify: func(input *ClusterInput) { input.ArchiveMountRoots = map[string]string{"local": "var/lib/vz"} }, expectedInMsg: "absolut", }, { name: "unbekannter Zugriffsweg", modify: func(input *ClusterInput) { input.ArchiveTransport = "ftp" }, expectedInMsg: "unbekannt", }, } for _, testCase := range testCases { testInstance.Run(testCase.name, func(subTest *testing.T) { modifiedInput := validInput testCase.modify(&modifiedInput) validationError := modifiedInput.Validate() if validationError == nil { subTest.Fatal("die fehlerhafte Eingabe wurde angenommen") } if !strings.Contains(validationError.Error(), testCase.expectedInMsg) { subTest.Errorf("die Meldung erklärt den Fehler nicht: %v", validationError) } }) } } // TestSSHClusterRequiresHostFingerprints ist der Sicherheitstest der Einrichtung. // // Ein SSH-Zugriffsweg ohne hinterlegte Wirtsschluessel nimmt jeden Server an, // der auf die Adresse antwortet. Der Angreifer lieferte dann das Archiv, das // Syncova fuer ein Backup haelt — und niemand saehe einen Fehler. func TestSSHClusterRequiresHostFingerprints(testInstance *testing.T) { sshInput := ClusterInput{ Name: "pve-ssh", APIEndpoint: "https://pve.example:8006", APITokenID: "syncova@pve!backup", APITokenSecret: "geheim", // secretscan:erlaubt: erfundener Testwert BackupStorageID: "local", ArchiveTransport: TransportSSH, SSHUsername: "syncova", SSHPrivateKeyPEM: "-----BEGIN OPENSSH PRIVATE KEY-----\n", // secretscan:erlaubt: Platzhalter ohne Schluesselmaterial } validationError := sshInput.Validate() if validationError == nil { testInstance.Fatal("ein SSH-Verbund ohne Wirtsschlüssel wurde angenommen") } if !strings.Contains(validationError.Error(), "zwischenangriff") { testInstance.Errorf("die Meldung nennt den Grund nicht: %v", validationError) } // Mit Fingerabdruck geht es durch. sshInput.SSHHostFingerprints = map[string]string{"pve-01": "SHA256:abc"} if acceptedError := sshInput.Validate(); acceptedError != nil { testInstance.Errorf("ein vollständiger SSH-Verbund wurde abgelehnt: %v", acceptedError) } } // TestClusterRoundTripKeepsSecretsEncrypted prueft Ablage und Rueckgabe. // // Der entscheidende Teil steht am Ende: Das API-Token darf in der Tabelle nicht // im Klartext auffindbar sein. Wer die Datenbank sichert, sicherte es sonst // gleich mit (PROMPT.md §140). func TestClusterRoundTripKeepsSecretsEncrypted(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) testStore := newTestStore(testInstance, connectionPool) const tokenSecret = "streng-geheimer-tokenwert-1234" // secretscan:erlaubt: erfundener Testwert createdCluster, createError := testStore.CreateCluster(context.Background(), ClusterInput{ Name: "verbund-" + uuid.NewString(), APIEndpoint: "https://pve.example:8006", APITokenID: "syncova@pve!backup", APITokenSecret: tokenSecret, TLSFingerprint: "aa:bb:cc", BackupStorageID: "local", ArchiveTransport: TransportLocal, ArchiveMountRoots: map[string]string{"local": "/var/lib/vz"}, }) if createError != nil { testInstance.Fatalf("der Verbund ließ sich nicht anlegen: %v", createError) } testInstance.Cleanup(func() { _ = testStore.DeleteCluster(context.Background(), createdCluster.ID) }) if createdCluster.Status != StatusUnknown { testInstance.Errorf("ein neuer Verbund gilt als %q statt als ungeprüft", createdCluster.Status) } readCluster, readError := testStore.GetCluster(context.Background(), createdCluster.ID) if readError != nil { testInstance.Fatalf("der Verbund ließ sich nicht lesen: %v", readError) } if readCluster.ArchiveMountRoots["local"] != "/var/lib/vz" { testInstance.Errorf("die Speicherzuordnung ging verloren: %v", readCluster.ArchiveMountRoots) } credentials, credentialError := testStore.LoadCredentials(context.Background(), createdCluster.ID) if credentialError != nil { testInstance.Fatalf("die Zugangsdaten ließen sich nicht lesen: %v", credentialError) } if credentials.APITokenSecret != tokenSecret { testInstance.Error("das entschlüsselte Token stimmt nicht mit dem abgelegten überein") } // Der Nachweis: Der Klartext steht nirgends in der Zeile. var storedRow string if scanError := connectionPool.QueryRow(context.Background(), `SELECT proxmox_clusters::text FROM proxmox_clusters WHERE id = $1`, createdCluster.ID).Scan(&storedRow); scanError != nil { testInstance.Fatalf("die Zeile ließ sich nicht lesen: %v", scanError) } if strings.Contains(storedRow, tokenSecret) { testInstance.Error("das API-Token steht im Klartext in der Datenbank") } } // TestRecordConnectionResultKeepsLastSeen ist der Regressionstest zum Zeitpunkt. // // last_seen_at wird nur bei Erfolg fortgeschrieben. Bei jedem Versuch zu setzen // machte aus „zuletzt erreicht" ein „zuletzt versucht" — und ein seit Wochen // toter Verbund saehe frisch aus. func TestRecordConnectionResultKeepsLastSeen(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) testStore := newTestStore(testInstance, connectionPool) createdCluster, createError := testStore.CreateCluster(context.Background(), ClusterInput{ Name: "verbund-" + uuid.NewString(), APIEndpoint: "https://pve.example:8006", APITokenID: "syncova@pve!backup", APITokenSecret: "geheim", // secretscan:erlaubt: erfundener Testwert BackupStorageID: "local", ArchiveTransport: TransportLocal, ArchiveMountRoots: map[string]string{"local": "/var/lib/vz"}, }) if createError != nil { testInstance.Fatalf("der Verbund ließ sich nicht anlegen: %v", createError) } testInstance.Cleanup(func() { _ = testStore.DeleteCluster(context.Background(), createdCluster.ID) }) if recordError := testStore.RecordConnectionResult(context.Background(), createdCluster.ID, StatusReachable, nil); recordError != nil { testInstance.Fatalf("das Ergebnis ließ sich nicht vermerken: %v", recordError) } reachableCluster, _ := testStore.GetCluster(context.Background(), createdCluster.ID) if reachableCluster.LastSeenAt == nil { testInstance.Fatal("nach einer erfolgreichen Verbindung fehlt der Zeitpunkt") } successTime := *reachableCluster.LastSeenAt if recordError := testStore.RecordConnectionResult(context.Background(), createdCluster.ID, StatusUnauthorized, errors.New("token abgelehnt")); recordError != nil { testInstance.Fatalf("das Ergebnis ließ sich nicht vermerken: %v", recordError) } failedCluster, _ := testStore.GetCluster(context.Background(), createdCluster.ID) if failedCluster.Status != StatusUnauthorized { testInstance.Errorf("der Zustand ist %q", failedCluster.Status) } if failedCluster.LastError == "" { testInstance.Error("der Grund des Fehlschlags wurde nicht vermerkt") } if failedCluster.LastSeenAt == nil || !failedCluster.LastSeenAt.Equal(successTime) { testInstance.Error("ein fehlgeschlagener Versuch hat den Zeitpunkt der letzten Verbindung verschoben") } } // TestMarkMissingGuestsHandlesEmptyList ist der Regressionstest zu `ANY(NULL)`. // // Ein Verbund ohne Gaeste — etwa weil alle geloescht wurden — muss **alle** // bekannten Gaeste als fehlend vermerken. Mit einer NULL statt eines leeren // Arrays waere die Bedingung niemals wahr, und es fiele niemandem auf. func TestMarkMissingGuestsHandlesEmptyList(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) testStore := newTestStore(testInstance, connectionPool) createdCluster, createError := testStore.CreateCluster(context.Background(), ClusterInput{ Name: "verbund-" + uuid.NewString(), APIEndpoint: "https://pve.example:8006", APITokenID: "syncova@pve!backup", APITokenSecret: "geheim", // secretscan:erlaubt: erfundener Testwert BackupStorageID: "local", ArchiveTransport: TransportLocal, ArchiveMountRoots: map[string]string{"local": "/var/lib/vz"}, }) if createError != nil { testInstance.Fatalf("der Verbund ließ sich nicht anlegen: %v", createError) } testInstance.Cleanup(func() { _ = testStore.DeleteCluster(context.Background(), createdCluster.ID) }) if _, insertError := connectionPool.Exec(context.Background(), `INSERT INTO virtual_machines (cluster_id, provider_vm_id, name, guest_kind) VALUES ($1, 'qemu/100', 'web-01', 'qemu'), ($1, 'qemu/101', 'db-01', 'qemu')`, createdCluster.ID); insertError != nil { testInstance.Fatalf("die Gäste ließen sich nicht anlegen: %v", insertError) } missingCount, markError := testStore.markMissingGuests(context.Background(), createdCluster.ID, nil) if markError != nil { testInstance.Fatalf("die fehlenden Gäste ließen sich nicht vermerken: %v", markError) } if missingCount != 2 { testInstance.Errorf("es wurden %d Gäste als fehlend vermerkt statt zwei", missingCount) } // Gelöscht wird nichts: Die Zuordnung zu vorhandenen Backups muss bleiben. machineList, listError := testStore.ListVirtualMachines(context.Background(), &createdCluster.ID) if listError != nil { testInstance.Fatalf("der Bestand ließ sich nicht lesen: %v", listError) } if len(machineList) != 2 { testInstance.Fatalf("es sind %d Gäste im Bestand statt zwei", len(machineList)) } for _, singleMachine := range machineList { if singleMachine.MissingSince == nil { testInstance.Errorf("der Gast %q gilt nicht als fehlend", singleMachine.Name) } } // Ein zweiter Lauf darf den Zeitpunkt nicht verschieben: Sonst stünde bei // einem seit Wochen fehlenden Gast immer „seit gerade eben". secondCount, secondError := testStore.markMissingGuests(context.Background(), createdCluster.ID, nil) if secondError != nil { testInstance.Fatalf("der zweite Lauf schlug fehl: %v", secondError) } if secondCount != 0 { testInstance.Errorf("der zweite Lauf vermerkte %d Gäste erneut", secondCount) } } // newTestStore baut den Speicher mit einem Testschlüssel. func newTestStore(testInstance *testing.T, connectionPool *pgxpool.Pool) *Store { testInstance.Helper() secretStore, storeError := crypto.NewLocalSecretStore(map[string][]byte{ "v1": []byte("0123456789abcdef0123456789abcdef"), // secretscan:erlaubt: erfundener Testwert }, "v1") if storeError != nil { testInstance.Fatalf("der Schlüsselspeicher ließ sich nicht bauen: %v", storeError) } builtStore, buildError := NewStore(connectionPool, secretStore) if buildError != nil { testInstance.Fatalf("der Speicher ließ sich nicht bauen: %v", buildError) } return builtStore } // connectTestDatabase öffnet die Testdatenbank. // // Ohne erreichbare Datenbank werden die Tests übersprungen statt zu scheitern: // Ein rot leuchtender Testlauf auf einem Rechner ohne Docker gewöhnt nur das // Wegschauen an. func connectTestDatabase(testInstance *testing.T) *pgxpool.Pool { testInstance.Helper() connectionString := os.Getenv("SYNCOVA_TEST_DATABASE_URL") if connectionString == "" { connectionString = os.Getenv("SYNCOVA_DATABASE_URL") } if connectionString == "" { testInstance.Skip("SYNCOVA_TEST_DATABASE_URL ist nicht gesetzt; die Datenbanktests werden übersprungen") } connectContext, cancelConnect := context.WithTimeout(context.Background(), 5*time.Second) defer cancelConnect() connectionPool, poolError := pgxpool.New(connectContext, connectionString) if poolError != nil { testInstance.Skipf("die Testdatenbank war nicht erreichbar: %v", poolError) } if pingError := connectionPool.Ping(connectContext); pingError != nil { connectionPool.Close() testInstance.Skipf("die Testdatenbank antwortete nicht: %v", pingError) } testInstance.Cleanup(connectionPool.Close) return connectionPool }