package api_test import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "provisioning/internal/api" "provisioning/internal/config" "provisioning/internal/db" "provisioning/internal/events" "provisioning/internal/httpserver" "provisioning/internal/model" "provisioning/internal/orchestrator" "provisioning/internal/pxe" "provisioning/internal/store" ) func newTestServer(t *testing.T) *httptest.Server { t.Helper() tmp := t.TempDir() database, err := db.Open(filepath.Join(tmp, "test.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { database.Close() }) hosts := &store.Hosts{DB: database} ops := &store.Operations{DB: database} locks := &store.Locks{DB: database, TTLMinutes: 60} images := &store.Images{DB: database} hub := events.NewHub() t.Cleanup(func() { hub.Shutdown(context.Background()) }) cfg := &config.Config{ Server: config.Server{ Bind: "127.0.0.1:0", PublicURL: "http://localhost:8080", }, Locks: config.Locks{TTLMinutes: 60}, } serverTypes := mustLoadServerTypes(t, tmp) runner := &orchestrator.Runner{ Hosts: hosts, Ops: ops, Locks: locks, Hub: hub, } pxeSupervisor := pxe.NewSupervisor(pxe.SupervisorConfig{Enabled: false}) hostOrch := &orchestrator.HostOrchestrator{ Runner: runner, Hosts: hosts, Ops: ops, Locks: locks, Cluster: &orchestrator.ClusterJoiner{}, Config: cfg, ServerTypes: serverTypes, } hostAPI := &api.HostAPI{ Hosts: hosts, Ops: ops, Locks: locks, Images: images, Runner: runner, Orchestrator: hostOrch, PXE: pxeSupervisor, Config: cfg, ServerTypes: serverTypes, } bootAPI := &api.BootAPI{ Hosts: hosts, Images: images, Runner: runner, Orchestrator: hostOrch, Config: cfg, ServerTypes: serverTypes, } ui := &api.UI{ Hosts: hosts, Ops: ops, Locks: locks, Images: images, Runner: runner, Orchestrator: hostOrch, Hub: hub, PXE: pxeSupervisor, Config: cfg, ServerTypes: serverTypes, } router := httpserver.NewRouter(httpserver.Deps{ HostAPI: hostAPI, BootAPI: bootAPI, UI: ui, Hub: hub, }) return httptest.NewServer(router) } func mustLoadServerTypes(t *testing.T, dir string) *config.ServerTypeRegistry { t.Helper() path := filepath.Join(dir, "server-types.yaml") content := []byte(`server_types: test-type: display_name: "Test Type" boot_disk: "/dev/sda" management_nic: "eth0" gpu: false hostname_prefix: "pve-test" `) if err := writeTestFile(path, content); err != nil { t.Fatal(err) } reg, err := config.LoadServerTypes(path) if err != nil { t.Fatal(err) } return reg } func writeTestFile(path string, data []byte) error { return os.WriteFile(path, data, 0o644) } func TestCreateAndListHosts(t *testing.T) { ts := newTestServer(t) defer ts.Close() // Create host via JSON API body := `{"hostname":"pve-test-01","mac":"aa:bb:cc:dd:ee:01","server_type":"test-type"}` resp, err := http.Post(ts.URL+"/api/hosts", "application/json", bytes.NewBufferString(body)) if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusCreated { t.Fatalf("create: got %d, want %d", resp.StatusCode, http.StatusCreated) } var created model.Host json.NewDecoder(resp.Body).Decode(&created) resp.Body.Close() if created.Hostname != "pve-test-01" { t.Fatalf("hostname = %q, want %q", created.Hostname, "pve-test-01") } if created.State != model.StateRegistered { t.Fatalf("state = %q, want %q", created.State, model.StateRegistered) } // List hosts resp, err = http.Get(ts.URL + "/api/hosts") if err != nil { t.Fatal(err) } var hosts []model.Host json.NewDecoder(resp.Body).Decode(&hosts) resp.Body.Close() if len(hosts) != 1 { t.Fatalf("list: got %d hosts, want 1", len(hosts)) } } func TestRebuildTransition(t *testing.T) { ts := newTestServer(t) defer ts.Close() // Create host body := `{"hostname":"pve-test-02","mac":"aa:bb:cc:dd:ee:02","server_type":"test-type"}` resp, _ := http.Post(ts.URL+"/api/hosts", "application/json", bytes.NewBufferString(body)) var created model.Host json.NewDecoder(resp.Body).Decode(&created) resp.Body.Close() // Trigger rebuild resp, err := http.Post(ts.URL+"/api/hosts/"+itoa(created.ID)+"/rebuild", "application/json", nil) if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusOK { t.Fatalf("rebuild: got %d, want %d", resp.StatusCode, http.StatusOK) } resp.Body.Close() // Verify state is pxe_ready resp, _ = http.Get(ts.URL + "/api/hosts/" + itoa(created.ID)) var host model.Host json.NewDecoder(resp.Body).Decode(&host) resp.Body.Close() if host.State != model.StatePXEReady { t.Fatalf("state = %q, want %q", host.State, model.StatePXEReady) } } func TestDuplicateRebuildConflict(t *testing.T) { ts := newTestServer(t) defer ts.Close() body := `{"hostname":"pve-test-03","mac":"aa:bb:cc:dd:ee:03","server_type":"test-type"}` resp, _ := http.Post(ts.URL+"/api/hosts", "application/json", bytes.NewBufferString(body)) var created model.Host json.NewDecoder(resp.Body).Decode(&created) resp.Body.Close() // First rebuild resp, _ = http.Post(ts.URL+"/api/hosts/"+itoa(created.ID)+"/rebuild", "application/json", nil) resp.Body.Close() // Second rebuild should 409 resp, _ = http.Post(ts.URL+"/api/hosts/"+itoa(created.ID)+"/rebuild", "application/json", nil) if resp.StatusCode != http.StatusConflict { t.Fatalf("second rebuild: got %d, want %d", resp.StatusCode, http.StatusConflict) } resp.Body.Close() } func TestDashboardHTML(t *testing.T) { ts := newTestServer(t) defer ts.Close() resp, err := http.Get(ts.URL + "/") if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusOK { t.Fatalf("dashboard: got %d", resp.StatusCode) } if ct := resp.Header.Get("Content-Type"); ct != "text/html; charset=utf-8" { t.Fatalf("content-type = %q", ct) } } func itoa(i int64) string { return fmt.Sprintf("%d", i) }