Files
proxdeploy/cli/internal/befehle/befehle_test.go
T
florian.wildeandClaude Opus 5.5 bd4bca5399 feat(cli): proxdeploy in Go
Befehle login, servers, distros, profiles, presets, vm, lxc, template list|build, batch, status, logs --follow, cancel. Renderer im Aufbau der Vorgabe (zeichengenau per Golden-File), 24-bit/256/ohne Farbe, --ascii, --quiet, --json, --dry-run; live im Terminal, zeilenweise ohne. SSE mit Wiederverbinden und Polling als Rückfall. Typen aus openapi.json per oapi-codegen; Release-Builds per cli/bauen.sh.

Refs #013

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:08:41 +02:00

326 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}