package proxmox import ( "context" "errors" "strings" "testing" "github.com/syncova/syncova/packages/providers" ) // TestRestoreToOriginalHostRefusesRunningGuest ist der wichtigste Schutztest. // // Eine Wiederherstellung an den Ursprungsort überschreibt die vorhandene // Maschine. Geschieht das im Lauf, verliert man beides: den alten Zustand und // den laufenden Betrieb. func TestRestoreToOriginalHostRefusesRunningGuest(testInstance *testing.T) { fakeServer := newFakeProxmoxServer(testInstance) testProvider := fakeServer.newConnectedProvider(testInstance) // Gast 100 läuft im Nachbau. _, restoreError := testProvider.RestoreVM(context.Background(), providers.RestoreRequest{ TargetKind: providers.RestoreToOriginalHost, SourceGuestID: "qemu/100", ArchiveReference: "local:backup/vzdump-qemu-100-2026_08_11-02_00_00.vma", OverwriteExisting: true, }) if !errors.Is(restoreError, providers.ErrGuestRunning) { testInstance.Fatalf("eine laufende Maschine darf nicht überschrieben werden: %v", restoreError) } // Nichts darf angelegt worden sein. fakeServer.mutex.Lock() createdCount := len(fakeServer.createdGuests) fakeServer.mutex.Unlock() if createdCount != 0 { testInstance.Error("trotz Ablehnung wurde eine Wiederherstellung angestoßen") } } // TestRestoreToOriginalHostRequiresExplicitOverwrite prüft die Zustimmung. // // Ein vorhandener Gast wird nie stillschweigend überschrieben — auch nicht, // wenn er steht (PROMPT.md §140: destruktive Aktionen niemals still). func TestRestoreToOriginalHostRequiresExplicitOverwrite(testInstance *testing.T) { fakeServer := newFakeProxmoxServer(testInstance) testProvider := fakeServer.newConnectedProvider(testInstance) // Gast 101 steht — technisch überschreibbar, aber ohne Zustimmung nicht. _, restoreError := testProvider.RestoreVM(context.Background(), providers.RestoreRequest{ TargetKind: providers.RestoreToOriginalHost, SourceGuestID: "qemu/101", ArchiveReference: "local:backup/vzdump-qemu-101-2026_08_11-02_00_00.vma", }) if !errors.Is(restoreError, ErrTargetGuestExists) { testInstance.Fatalf("ohne ausdrückliche Zustimmung darf nicht überschrieben werden: %v", restoreError) } } // TestRestoreToOriginalHostOverwritesStoppedGuest prüft den erlaubten Fall. func TestRestoreToOriginalHostOverwritesStoppedGuest(testInstance *testing.T) { fakeServer := newFakeProxmoxServer(testInstance) testProvider := fakeServer.newConnectedProvider(testInstance) restoreResult, restoreError := testProvider.RestoreVM(context.Background(), providers.RestoreRequest{ TargetKind: providers.RestoreToOriginalHost, SourceGuestID: "qemu/101", ArchiveReference: "local:backup/vzdump-qemu-101-2026_08_11-02_00_00.vma", OverwriteExisting: true, }) if restoreError != nil { testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError) } if restoreResult.GuestID != "qemu/101" { testInstance.Errorf("die Maschine wurde als %q wiederhergestellt", restoreResult.GuestID) } // Der Ursprungsknoten muss getroffen werden. if restoreResult.HostID != "pve-02" { testInstance.Errorf("die Wiederherstellung landete auf Knoten %q statt auf dem Ursprungsknoten pve-02", restoreResult.HostID) } // Das Überschreiben muss ausgewiesen werden. if len(restoreResult.Warnings) == 0 { testInstance.Error("das Überschreiben eines bestehenden Gasts wurde nicht als Warnung ausgewiesen") } fakeServer.mutex.Lock() submittedForm := fakeServer.createdGuests[101] fakeServer.mutex.Unlock() if submittedForm.Get("force") != "1" { testInstance.Error("Proxmox wurde nicht zum Überschreiben angewiesen") } // Der Gast darf niemals unaufgefordert starten. if submittedForm.Get("start") != "0" { testInstance.Error("die Maschine wurde zum Starten angewiesen, obwohl das nicht verlangt war") } } // TestRestoreAsNewGuestPicksFreeIdentifier prüft die Wiederherstellung als // neue Maschine. // // Das ist der Weg für einen Wiederherstellungstest: Das Original bleibt // unberührt, der Betrieb läuft weiter. func TestRestoreAsNewGuestPicksFreeIdentifier(testInstance *testing.T) { fakeServer := newFakeProxmoxServer(testInstance) testProvider := fakeServer.newConnectedProvider(testInstance) restoreResult, restoreError := testProvider.RestoreVM(context.Background(), providers.RestoreRequest{ TargetKind: providers.RestoreAsNewGuest, SourceGuestID: "qemu/100", ArchiveReference: "local:backup/vzdump-qemu-100-2026_08_11-02_00_00.vma", }) if restoreError != nil { testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError) } // 101 ist belegt, 102 ist frei. if restoreResult.GuestID != "qemu/102" { testInstance.Errorf("es wurde die Kennung %q gewählt, erwartet war qemu/102", restoreResult.GuestID) } // Das Original muss unberührt bleiben. fakeServer.mutex.Lock() _, originalTouched := fakeServer.createdGuests[100] originalStatus := fakeServer.guestStatuses[100] fakeServer.mutex.Unlock() if originalTouched { testInstance.Fatal("die Ursprungsmaschine wurde angefasst, obwohl als neuer Gast wiederhergestellt wurde") } if originalStatus != "running" { testInstance.Error("die Ursprungsmaschine wurde angehalten") } // Die MAC-Warnung ist Pflicht: zwei Maschinen mit derselben Adresse im // selben Netz stören einander. var mentionsNetwork bool for _, warningText := range restoreResult.Warnings { if strings.Contains(warningText, "MAC") { mentionsNetwork = true } } if !mentionsNetwork { testInstance.Errorf("es fehlt die Warnung vor doppelten Netzwerkadressen: %v", restoreResult.Warnings) } } // TestRestoreAsNewGuestRejectsOccupiedIdentifier prüft die belegte Zielkennung. func TestRestoreAsNewGuestRejectsOccupiedIdentifier(testInstance *testing.T) { fakeServer := newFakeProxmoxServer(testInstance) testProvider := fakeServer.newConnectedProvider(testInstance) _, restoreError := testProvider.RestoreVM(context.Background(), providers.RestoreRequest{ TargetKind: providers.RestoreAsNewGuest, SourceGuestID: "qemu/100", TargetGuestID: "qemu/101", ArchiveReference: "local:backup/vzdump-qemu-100.vma", }) if !errors.Is(restoreError, ErrTargetGuestExists) { testInstance.Fatalf("eine belegte Zielkennung muss abgelehnt werden: %v", restoreError) } } // TestRestoreToAlternateHostUsesRequestedNode prüft den anderen Wirt. func TestRestoreToAlternateHostUsesRequestedNode(testInstance *testing.T) { fakeServer := newFakeProxmoxServer(testInstance) testProvider := fakeServer.newConnectedProvider(testInstance) restoreResult, restoreError := testProvider.RestoreVM(context.Background(), providers.RestoreRequest{ TargetKind: providers.RestoreToAlternateHost, SourceGuestID: "qemu/300", TargetHostID: "pve-02", ArchiveReference: "local:backup/vzdump-qemu-300.vma", }) if restoreError != nil { testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError) } if restoreResult.HostID != "pve-02" { testInstance.Errorf("die Wiederherstellung landete auf %q statt auf pve-02", restoreResult.HostID) } } // TestRestoreRefusesOfflineNode prüft den nicht erreichbaren Zielknoten. // // Das jetzt zu erkennen erspart einen Abbruch nach Stunden. func TestRestoreRefusesOfflineNode(testInstance *testing.T) { fakeServer := newFakeProxmoxServer(testInstance) testProvider := fakeServer.newConnectedProvider(testInstance) _, restoreError := testProvider.RestoreVM(context.Background(), providers.RestoreRequest{ TargetKind: providers.RestoreToAlternateHost, SourceGuestID: "qemu/300", TargetHostID: "pve-03", ArchiveReference: "local:backup/vzdump-qemu-300.vma", }) if restoreError == nil { testInstance.Fatal("ein nicht erreichbarer Zielknoten muss abgelehnt werden") } if !strings.Contains(restoreError.Error(), "erreichbar") { testInstance.Errorf("die Fehlermeldung nennt die Ursache nicht: %v", restoreError) } } // TestRestoreRefusesUnknownNode prüft einen fremden Knoten. func TestRestoreRefusesUnknownNode(testInstance *testing.T) { fakeServer := newFakeProxmoxServer(testInstance) testProvider := fakeServer.newConnectedProvider(testInstance) _, restoreError := testProvider.RestoreVM(context.Background(), providers.RestoreRequest{ TargetKind: providers.RestoreToAlternateHost, SourceGuestID: "qemu/300", TargetHostID: "fremder-knoten", ArchiveReference: "local:backup/vzdump-qemu-300.vma", }) if restoreError == nil { testInstance.Fatal("ein unbekannter Zielknoten muss abgelehnt werden") } } // TestRestoreRequiresArchive prüft die Pflichtangabe. func TestRestoreRequiresArchive(testInstance *testing.T) { fakeServer := newFakeProxmoxServer(testInstance) testProvider := fakeServer.newConnectedProvider(testInstance) _, restoreError := testProvider.RestoreVM(context.Background(), providers.RestoreRequest{ TargetKind: providers.RestoreAsNewGuest, SourceGuestID: "qemu/100", }) if restoreError == nil { testInstance.Fatal("eine Wiederherstellung ohne Archiv muss abgelehnt werden") } } // TestRestoreRejectsReservedIdentifier prüft den reservierten Bereich. func TestRestoreRejectsReservedIdentifier(testInstance *testing.T) { fakeServer := newFakeProxmoxServer(testInstance) testProvider := fakeServer.newConnectedProvider(testInstance) _, restoreError := testProvider.RestoreVM(context.Background(), providers.RestoreRequest{ TargetKind: providers.RestoreAsNewGuest, SourceGuestID: "qemu/100", TargetGuestID: "qemu/50", ArchiveReference: "local:backup/vzdump-qemu-100.vma", }) if restoreError == nil { testInstance.Fatal("eine Kennung im reservierten Bereich muss abgelehnt werden") } } // TestRestoreAppliesMetadataButNotDiskLayout ist der zweitwichtigste Test. // // Die Konfiguration muss zurückkommen — ohne sie hätte die Maschine ihre Daten, // aber nicht ihre Gestalt. Die Plattenzuordnung darf dagegen NICHT gesetzt // werden: sie verwiese auf Datenträger am alten Ort, und die Maschine startete // nicht. func TestRestoreAppliesMetadataButNotDiskLayout(testInstance *testing.T) { fakeServer := newFakeProxmoxServer(testInstance) testProvider := fakeServer.newConnectedProvider(testInstance) restoreContext := context.Background() sourceMetadata, metadataError := testProvider.GetVMMetaData(restoreContext, "qemu/100") if metadataError != nil { testInstance.Fatalf("die Konfiguration ließ sich nicht lesen: %v", metadataError) } restoreResult, restoreError := testProvider.RestoreVM(restoreContext, providers.RestoreRequest{ TargetKind: providers.RestoreAsNewGuest, SourceGuestID: "qemu/100", ArchiveReference: "local:backup/vzdump-qemu-100.vma", Metadata: sourceMetadata, }) if restoreError != nil { testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError) } fakeServer.mutex.Lock() appliedConfiguration := fakeServer.appliedConfigurations[102] fakeServer.mutex.Unlock() if appliedConfiguration == nil { testInstance.Fatal("die Konfiguration wurde gar nicht gesetzt") } // Die Gestalt der Maschine muss zurückkommen. if appliedConfiguration.Get("bios") != "ovmf" { testInstance.Errorf("die Firmware wurde nicht gesetzt: %q", appliedConfiguration.Get("bios")) } if appliedConfiguration.Get("net0") != "virtio=AA:BB:CC:DD:EE:01,bridge=vmbr0,tag=42" { testInstance.Errorf("die Netzwerkkarte wurde nicht wiederhergestellt: %q", appliedConfiguration.Get("net0")) } if appliedConfiguration.Get("cores") != "4" { testInstance.Errorf("die Prozessorzahl wurde nicht gesetzt: %q", appliedConfiguration.Get("cores")) } // Die Plattenzuordnung darf nicht überschrieben werden. for _, diskProperty := range []string{"scsi0", "scsi1", "efidisk0", "unused0"} { if appliedConfiguration.Get(diskProperty) != "" { testInstance.Errorf("die Plattenzuordnung %s wurde gesetzt und würde auf den alten Ort verweisen", diskProperty) } } // Interne Felder gehören nicht in eine Wiederherstellung. if appliedConfiguration.Get("digest") != "" { testInstance.Error("das interne Feld digest wurde mitgesetzt") } // Der Anwender muss erfahren, dass die Plattenzuordnung aus dem Archiv kommt. var mentionsDiskLayout bool for _, warningText := range restoreResult.Warnings { if strings.Contains(warningText, "Plattenzuordnung") { mentionsDiskLayout = true } } if !mentionsDiskLayout { testInstance.Errorf("es fehlt der Hinweis zur Plattenzuordnung: %v", restoreResult.Warnings) } } // TestRestoreStartsGuestOnlyWhenRequested prüft den ausdrücklichen Start. func TestRestoreStartsGuestOnlyWhenRequested(testInstance *testing.T) { fakeServer := newFakeProxmoxServer(testInstance) testProvider := fakeServer.newConnectedProvider(testInstance) restoreResult, restoreError := testProvider.RestoreVM(context.Background(), providers.RestoreRequest{ TargetKind: providers.RestoreAsNewGuest, SourceGuestID: "qemu/100", ArchiveReference: "local:backup/vzdump-qemu-100.vma", StartAfterRestore: true, }) if restoreError != nil { testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError) } if !restoreResult.Started { testInstance.Fatal("der ausdrücklich verlangte Start wurde nicht ausgeführt") } fakeServer.mutex.Lock() startedStatus := fakeServer.guestStatuses[102] fakeServer.mutex.Unlock() if startedStatus != "running" { testInstance.Errorf("die Maschine läuft nicht: %q", startedStatus) } } // TestRestoreReportsProgress prüft die Fortschrittsmeldung. func TestRestoreReportsProgress(testInstance *testing.T) { fakeServer := newFakeProxmoxServer(testInstance) testProvider := fakeServer.newConnectedProvider(testInstance) observedStages := make([]string, 0, 8) if _, restoreError := testProvider.RestoreVM(context.Background(), providers.RestoreRequest{ TargetKind: providers.RestoreAsNewGuest, SourceGuestID: "qemu/100", ArchiveReference: "local:backup/vzdump-qemu-100.vma", ProgressCallback: func(currentProgress providers.RestoreProgress) { observedStages = append(observedStages, currentProgress.Stage) }, }); restoreError != nil { testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError) } if len(observedStages) < 3 { testInstance.Fatalf("es wurden nur %d Schritte gemeldet: %v", len(observedStages), observedStages) } if observedStages[len(observedStages)-1] != "abgeschlossen" { testInstance.Errorf("der letzte gemeldete Schritt war %q", observedStages[len(observedStages)-1]) } } // TestFindFreeGuestIdentifierSearchesAboveOriginal prüft die Kennungssuche. // // Gesucht wird oberhalb der Ursprungskennung: die wiederhergestellte Maschine // steht damit in der Übersicht neben ihrem Original. func TestFindFreeGuestIdentifierSearchesAboveOriginal(testInstance *testing.T) { occupiedIdentifiers := map[int]struct{}{100: {}, 101: {}, 102: {}} freeIdentifier, searchError := findFreeGuestIdentifier(occupiedIdentifiers, 100) if searchError != nil { testInstance.Fatalf("die Suche schlug fehl: %v", searchError) } if freeIdentifier != 103 { testInstance.Errorf("es wurde %d gewählt, erwartet war 103", freeIdentifier) } // Auch unterhalb des zulässigen Bereichs muss die Suche richtig einsetzen. lowStartIdentifier, lowError := findFreeGuestIdentifier(map[int]struct{}{}, 5) if lowError != nil { testInstance.Fatalf("die Suche schlug fehl: %v", lowError) } if lowStartIdentifier != minimumUserGuestID { testInstance.Errorf("unterhalb des zulässigen Bereichs wurde %d gewählt", lowStartIdentifier) } }