Files
Provisioning/internal/api/smoke_test.go
T
josh 2a0fbf6923
build-and-push / test (push) Successful in 35s
build-and-push / build-and-push (push) Successful in 56s
Remove unused hostname_prefix from server types and add duplicate checking
The HostnamePrefix field on ServerType was loaded from YAML but never used —
hostnames are user-provided. This removes the field and adds explicit
duplicate checks (hostname + MAC) with clear per-field error messages in
both the JSON API and web UI, backed by a new GetByHostname store method
with case-insensitive matching.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-14 10:50:16 -04:00

418 lines
12 KiB
Go

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/image"
"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}
activity := &store.Activity{DB: database}
hub := events.NewHub()
t.Cleanup(func() { hub.Shutdown(context.Background()) })
imageDir := filepath.Join(tmp, "images")
os.MkdirAll(imageDir, 0o755)
imageSvc := &image.Service{Store: images, ImageDir: imageDir}
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,
Activity: activity,
}
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,
}
imageAPI := &api.ImageAPI{Svc: imageSvc}
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,
Activity: activity,
ImageSvc: imageSvc,
Runner: runner,
Orchestrator: hostOrch,
Hub: hub,
PXE: pxeSupervisor,
Config: cfg,
ServerTypes: serverTypes,
}
router := httpserver.NewRouter(httpserver.Deps{
HostAPI: hostAPI,
BootAPI: bootAPI,
ImageAPI: imageAPI,
UI: ui,
Hub: hub,
ImageDir: imageDir,
})
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
`)
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 TestOperationDetailPage(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
body := `{"hostname":"pve-test-op","mac":"aa:bb:cc:dd:ee:10","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 to create an operation
resp, _ = http.Post(ts.URL+"/api/hosts/"+itoa(created.ID)+"/rebuild", "application/json", nil)
resp.Body.Close()
// Get operation detail page
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}}
resp, err := client.Get(ts.URL + "/ops/1")
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("op detail: got %d, want %d", resp.StatusCode, http.StatusOK)
}
if ct := resp.Header.Get("Content-Type"); ct != "text/html; charset=utf-8" {
t.Fatalf("content-type = %q", ct)
}
resp.Body.Close()
}
func TestCancelOperation(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
body := `{"hostname":"pve-test-cancel","mac":"aa:bb:cc:dd:ee:11","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()
resp, _ = http.Post(ts.URL+"/api/hosts/"+itoa(created.ID)+"/rebuild", "application/json", nil)
resp.Body.Close()
// Cancel the operation
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}}
resp, err := client.Post(ts.URL+"/ops/1/cancel", "", nil)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("cancel: got %d, want %d", resp.StatusCode, http.StatusSeeOther)
}
resp.Body.Close()
// Verify host is now failed
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.StateFailed {
t.Fatalf("state = %q, want %q", host.State, model.StateFailed)
}
}
func TestOperationNotFound(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
resp, err := http.Get(ts.URL + "/ops/99999")
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("op not found: got %d, want %d", resp.StatusCode, http.StatusNotFound)
}
resp.Body.Close()
}
func TestDuplicateHostnameRejected(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
body := `{"hostname":"pve-dup-01","mac":"aa:bb:cc:dd:ee:d1","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("first create: got %d, want %d", resp.StatusCode, http.StatusCreated)
}
resp.Body.Close()
body = `{"hostname":"pve-dup-01","mac":"aa:bb:cc:dd:ee:d2","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.StatusConflict {
t.Fatalf("dup hostname: got %d, want %d", resp.StatusCode, http.StatusConflict)
}
var errResp map[string]any
json.NewDecoder(resp.Body).Decode(&errResp)
resp.Body.Close()
if msg, _ := errResp["error"].(string); msg != "a host with this hostname already exists" {
t.Fatalf("dup hostname error = %q, want specific hostname message", msg)
}
}
func TestDuplicateMACRejected(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
body := `{"hostname":"pve-mac-01","mac":"aa:bb:cc:dd:ee:e1","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("first create: got %d, want %d", resp.StatusCode, http.StatusCreated)
}
resp.Body.Close()
body = `{"hostname":"pve-mac-02","mac":"aa:bb:cc:dd:ee:e1","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.StatusConflict {
t.Fatalf("dup MAC: got %d, want %d", resp.StatusCode, http.StatusConflict)
}
var errResp map[string]any
json.NewDecoder(resp.Body).Decode(&errResp)
resp.Body.Close()
if msg, _ := errResp["error"].(string); msg != "a host with this MAC address already exists" {
t.Fatalf("dup MAC error = %q, want specific MAC message", msg)
}
}
func TestDuplicateHostnameCaseInsensitive(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
body := `{"hostname":"pve-case-01","mac":"aa:bb:cc:dd:ee:c1","server_type":"test-type"}`
resp, _ := http.Post(ts.URL+"/api/hosts", "application/json", bytes.NewBufferString(body))
resp.Body.Close()
body = `{"hostname":"PVE-CASE-01","mac":"aa:bb:cc:dd:ee:c2","server_type":"test-type"}`
resp, _ = http.Post(ts.URL+"/api/hosts", "application/json", bytes.NewBufferString(body))
if resp.StatusCode != http.StatusConflict {
t.Fatalf("case-insensitive dup: got %d, want %d", resp.StatusCode, http.StatusConflict)
}
resp.Body.Close()
}
func itoa(i int64) string {
return fmt.Sprintf("%d", i)
}