package befehle import ( "bytes" "context" "encoding/json" "flag" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" ) var aktualisieren = flag.Bool("update", false, "Golden-Dateien neu schreiben") func golden(t *testing.T, name, ist string) { t.Helper() pfad := filepath.Join("testdata", name) if *aktualisieren { if err := os.MkdirAll("testdata", 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(pfad, []byte(ist), 0o644); err != nil { t.Fatal(err) } } soll, err := os.ReadFile(pfad) if err != nil { t.Fatal(err) } if ist != string(soll) { t.Errorf("%s weicht ab.\n--- soll\n%s\n--- ist\n%s", name, soll, ist) } } // phase baut eine Phase der API. func phase(schluessel, titel, status, detail string) map[string]any { p := map[string]any{"schluessel": schluessel, "titel": titel, "status": status, "detail": nil, "ursache": nil, "hinweis": nil, "gestartetAm": nil, "beendetAm": nil} if detail != "" { p["detail"] = detail } if status == "LAEUFT" { p["gestartetAm"] = "2026-10-12T10:00:00.000Z" } return p } func auftrag(status string, phasen []map[string]any, abschluss any) map[string]any { return map[string]any{ "id": "c1", "kurzId": "a-7f3k", "art": "AUSROLLEN", "status": status, "kopf": "Server rz1 · Node pve01 · Debian 13 (Template 9013)", "server": map[string]any{"name": "rz1", "titel": "RZ 1"}, "knoten": "pve01", "gastTyp": "VM", "vmid": 120, "name": "web01", "distributionId": "debian-13", "profile": []string{"base", "docker"}, "ergebnis": map[string]any{"vmid": 120, "ip": "10.0.20.15", "ssh": "ssh florian@10.0.20.15"}, "fehler": nil, "erstelltVon": "florian", "erstelltAm": "2026-10-12T10:00:00.000Z", "gestartetAm": "2026-10-12T10:00:00.000Z", "beendetAm": nil, "abschluss": abschluss, "phasen": phasen, } } var alle = []map[string]any{ phase("pruefungen", "Prüfungen", "ERLEDIGT", "VMID 120 frei · local-zfs · vmbr0 VLAN 20"), phase("klonen", "Klonen", "ERLEDIGT", "full clone 9013 → 120 · Disk auf 40G"), phase("cloudinit", "cloud-init", "ERLEDIGT", "user florian · SSH-Key · Profile base, docker"), phase("start", "Start", "ERLEDIGT", "VM 120 läuft"), phase("agent", "Guest-Agent", "ERLEDIGT", "antwortet nach 18s"), phase("provisionierung", "Provisionierung", "ERLEDIGT", "base, docker"), } // fakeAPI spielt die REST-API nach; `anfragen` hält die Rümpfe fest. type fakeAPI struct { mu sync.Mutex anfragen map[string][]map[string]any staende []map[string]any plan map[string]any status int antwort any } func (f *fakeAPI) handler(t *testing.T) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "Bearer pd_test" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(401) fmt.Fprint(w, `{"fehler":{"code":"nicht_angemeldet","nachricht":"Nicht angemeldet."}}`) return } if r.Body != nil && r.Method == http.MethodPost { var rumpf map[string]any _ = json.NewDecoder(r.Body).Decode(&rumpf) f.mu.Lock() if f.anfragen == nil { f.anfragen = map[string][]map[string]any{} } f.anfragen[r.URL.Path] = append(f.anfragen[r.URL.Path], rumpf) f.mu.Unlock() } schreiben := func(status int, wert any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(wert) } if f.status != 0 { schreiben(f.status, f.antwort) return } switch { case r.URL.Path == "/api/v1/ich": schreiben(200, map[string]any{"benutzername": "florian", "anzeigename": "Florian", "rolle": "OPERATOR", "rechte": []string{}, "version": "0.4.0"}) case r.URL.Path == "/api/v1/auftraege" && r.Method == http.MethodPost: schreiben(202, map[string]any{"id": "c1", "kurzId": "a-7f3k"}) case r.URL.Path == "/api/v1/auftraege/plan": schreiben(200, f.plan) case strings.HasSuffix(r.URL.Path, "/ereignisse"): w.Header().Set("Content-Type", "text/event-stream") for nr, s := range f.staende { daten, _ := json.Marshal(s) fmt.Fprintf(w, "id: %d\nevent: stand\ndata: %s\n\n: ping\n\n", nr+1, daten) } fmt.Fprint(w, "event: ende\ndata: {}\n\n") case r.URL.Path == "/api/v1/distributionen": schreiben(200, []map[string]any{ {"id": "debian-13", "name": "Debian 13 „Trixie“", "familie": "debian", "aktiv": true, "vm": true, "lxc": true}, {"id": "ubuntu-26.04", "name": "Ubuntu 26.04 LTS", "familie": "debian", "aktiv": true, "vm": true, "lxc": false}, }) default: t.Errorf("unerwartete Anfrage %s %s", r.Method, r.URL.Path) schreiben(404, map[string]any{"fehler": map[string]any{"code": "nicht_gefunden", "nachricht": "?"}}) } }) } func lauf(t *testing.T, f *fakeAPI, argumente ...string) (string, string, int) { t.Helper() server := httptest.NewServer(f.handler(t)) t.Cleanup(server.Close) t.Setenv("PROXDEPLOY_CONFIG", filepath.Join(t.TempDir(), "config.toml")) t.Setenv("PROXDEPLOY_URL", server.URL) t.Setenv("PROXDEPLOY_TOKEN", "pd_test") var aus, fehler bytes.Buffer u := &umgebung{aus: &aus, fehler: &fehler, terminal: false, opt: &optionen{}} code := u.ausfuehren(context.Background(), argumente) return aus.String(), fehler.String(), code } func TestVmZeilenweiseBisZurBox(t *testing.T) { Version = "1.0" laeuft := append(append([]map[string]any{}, alle[:5]...), phase("provisionierung", "Provisionierung", "LAEUFT", "cloud-init läuft")) f := &fakeAPI{staende: []map[string]any{ auftrag("LAEUFT", []map[string]any{phase("pruefungen", "Prüfungen", "LAEUFT", ""), phase("klonen", "Klonen", "OFFEN", "")}, nil), auftrag("LAEUFT", laeuft, nil), auftrag("ERFOLGREICH", alle, map[string]any{"ok": true, "titel": "web01 bereit", "dauer": "1m 54s", "zeilen": [][]string{ {"VMID", "120"}, {"IP", "10.0.20.15"}, {"Profile", "base, docker"}, {"SSH", "ssh florian@10.0.20.15"}, }}), }} aus, fehler, code := lauf(t, f, "vm", "web01", "--server", "rz1", "--distro", "debian-13", "--profile", "base,docker", "--ip", "10.0.20.15/24", "--vlan", "20", "--disk", "40G", "--var", "zabbix-agent.server=zbx.local") if code != 0 { t.Fatalf("Exit %d, stderr: %s", code, fehler) } golden(t, "vm-zeilenweise.golden", aus) // Nur gesetzte Flags gehen hinaus – Storage, Bridge, Benutzer und Schlüssel füllt der Server. gesendet, _ := json.MarshalIndent(f.anfragen["/api/v1/auftraege"][0], "", " ") golden(t, "vm-anfrage.golden", string(gesendet)+"\n") } func TestVmFehlgeschlagenExit1UndQuiet(t *testing.T) { Version = "1.0" fehlerPhase := phase("klonen", "Klonen", "FEHLER", "") fehlerPhase["ursache"] = "Storage local-zfs ist voll." fehlerPhase["hinweis"] = "Anderen Storage wählen (--storage)." f := &fakeAPI{staende: []map[string]any{ auftrag("FEHLGESCHLAGEN", []map[string]any{alle[0], fehlerPhase, phase("cloudinit", "cloud-init", "OFFEN", "")}, map[string]any{"ok": false, "titel": "web01 fehlgeschlagen", "dauer": "12s", "zeilen": [][]string{ {"Phase", "Klonen"}, {"Ursache", "Storage local-zfs ist voll."}, {"Hinweis", "Anderen Storage wählen (--storage)."}, }}), }} aus, _, code := lauf(t, f, "vm", "web01", "--server", "rz1", "--distro", "debian-13", "--quiet", "--ascii") if code != ExitFehlschlag { t.Fatalf("Exit %d, erwartet 1", code) } golden(t, "vm-fehler-quiet-ascii.golden", aus) } func TestDryRun(t *testing.T) { Version = "1.0" f := &fakeAPI{plan: map[string]any{ "kopf": "Server rz1 · Node pve01 · Debian 13 (Template 9013)", "phasen": []map[string]any{alle[0], alle[1], phase("provisionierung", "Provisionierung", "ERLEDIGT", "base, docker")}, "aufrufe": []map[string]any{ {"methode": "POST", "pfad": "/nodes/pve01/qemu/9013/clone", "parameter": map[string]any{"newid": 120, "full": 1, "name": "web01"}}, {"methode": "POST", "pfad": "/nodes/pve01/qemu/120/status/start"}, }, "skript": nil, "cliBefehl": "proxdeploy vm web01 --server rz1 --distro debian-13", "automatisch": []string{"base"}, "ok": true, }} aus, fehler, code := lauf(t, f, "vm", "web01", "--server", "rz1", "--distro", "debian-13", "--dry-run") if code != 0 { t.Fatalf("Exit %d: %s", code, fehler) } golden(t, "vm-dryrun.golden", aus) if len(f.anfragen["/api/v1/auftraege"]) != 0 { t.Error("Dry-Run hat einen Auftrag angelegt") } } func TestFehlerUndExitCodes(t *testing.T) { f := &fakeAPI{status: 400, antwort: map[string]any{"fehler": map[string]any{ "code": "ungueltig", "nachricht": "Die Eingaben sind nicht vollständig oder ungültig.", "felder": map[string]string{"netz.ip": "Format: 10.0.20.15/24", "variablen.zabbix-agent.server": "Zabbix-Server fehlt."}, }}} _, fehler, code := lauf(t, f, "vm", "web01", "--server", "rz1", "--distro", "debian-13", "--ip", "x") if code != ExitBedienung { t.Errorf("Exit %d, erwartet 2", code) } golden(t, "fehler-felder.golden", fehler) f = &fakeAPI{status: 401, antwort: map[string]any{"fehler": map[string]any{"code": "nicht_angemeldet", "nachricht": "Nicht angemeldet."}}} if _, _, code := lauf(t, f, "servers"); code != ExitVerbindung { t.Errorf("401: Exit %d, erwartet 3", code) } t.Setenv("PROXDEPLOY_URL", "http://127.0.0.1:1") t.Setenv("PROXDEPLOY_TOKEN", "pd_test") var aus, fehlerAus bytes.Buffer u := &umgebung{aus: &aus, fehler: &fehlerAus, opt: &optionen{}} if code := u.ausfuehren(context.Background(), []string{"distros"}); code != ExitVerbindung { t.Errorf("nicht erreichbar: Exit %d, erwartet 3", code) } if code := u.ausfuehren(context.Background(), []string{"vm"}); code != ExitBedienung { t.Errorf("ohne Name: Exit %d, erwartet 2", code) } } func TestDistrosTabelleUndJson(t *testing.T) { aus, _, code := lauf(t, &fakeAPI{}, "distros") if code != 0 { t.Fatal(code) } golden(t, "distros.golden", aus) aus, _, _ = lauf(t, &fakeAPI{}, "distros", "--json") var liste []map[string]any if err := json.Unmarshal([]byte(aus), &liste); err != nil || len(liste) != 2 { t.Errorf("--json nicht lesbar: %v\n%s", err, aus) } } func TestBatch(t *testing.T) { Version = "1.0" datei := filepath.Join(t.TempDir(), "gaeste.yaml") if err := os.WriteFile(datei, []byte(`defaults: server: rz1 distro: debian-13 profile: [base] guests: - name: web01 type: vm profile: [base, docker] ip: 10.0.20.15/24 gw: 10.0.20.1 memory: 4096 - name: mon01 type: lxc profile: [base, zabbix-agent] vars: { zabbix-agent: { server: zbx.example } } nesting: true `), 0o644); err != nil { t.Fatal(err) } f := &fakeAPI{staende: []map[string]any{auftrag("ERFOLGREICH", alle, map[string]any{"ok": true, "titel": "web01 bereit", "dauer": "1m 54s", "zeilen": [][]string{}})}} _, fehler, code := lauf(t, f, "batch", datei, "--quiet") if code != 0 { t.Fatalf("Exit %d: %s", code, fehler) } gesendet, _ := json.MarshalIndent(f.anfragen["/api/v1/auftraege"], "", " ") golden(t, "batch-anfragen.golden", string(gesendet)+"\n") falsch := filepath.Join(t.TempDir(), "falsch.yaml") _ = os.WriteFile(falsch, []byte("guests:\n - name: a\n type: vm\n server: rz1\n ram: 4096\n"), 0o644) _, fehler, code = lauf(t, &fakeAPI{}, "batch", falsch) if code != ExitBedienung || !strings.Contains(fehler, `unbekannter Schlüssel "ram"`) { t.Errorf("Exit %d, %s", code, fehler) } } func TestLoginMitToken(t *testing.T) { server := httptest.NewServer((&fakeAPI{}).handler(t)) defer server.Close() pfad := filepath.Join(t.TempDir(), "proxdeploy", "config.toml") t.Setenv("PROXDEPLOY_CONFIG", pfad) t.Setenv("PROXDEPLOY_URL", "") t.Setenv("PROXDEPLOY_TOKEN", "") var aus, fehler bytes.Buffer u := &umgebung{aus: &aus, fehler: &fehler, opt: &optionen{}} if code := u.ausfuehren(context.Background(), []string{"login", server.URL, "--token", "pd_test", "--default-server", "rz1"}); code != 0 { t.Fatalf("Exit %d: %s", code, fehler.String()) } info, err := os.Stat(pfad) if err != nil { t.Fatal(err) } if info.Mode().Perm() != 0o600 { t.Errorf("Rechte %v, erwartet 0600", info.Mode().Perm()) } inhalt, _ := os.ReadFile(pfad) if !strings.Contains(string(inhalt), `token = "pd_test"`) || !strings.Contains(string(inhalt), `server = "rz1"`) { t.Errorf("Konfiguration:\n%s", inhalt) } if !strings.Contains(aus.String(), "Angemeldet als florian") { t.Errorf("Ausgabe: %s", aus.String()) } // Ein falsches Token wird nicht gespeichert. aus.Reset() code := u.ausfuehren(context.Background(), []string{"login", server.URL, "--token", "pd_falsch", "--instance", "labor"}) if code != ExitVerbindung { t.Errorf("falsches Token: Exit %d", code) } inhalt2, _ := os.ReadFile(pfad) if string(inhalt2) != string(inhalt) { t.Error("falsches Token hat die Konfiguration verändert") } }