package api_test import ( "context" "fmt" "net/http" "net/http/httptest" "path/filepath" "regexp" "strings" "testing" "time" "github.com/go-chi/chi/v5" "vetting/internal/api" "vetting/internal/db" "vetting/internal/events" "vetting/internal/logs" "vetting/internal/model" "vetting/internal/orchestrator" "vetting/internal/store" ) func setupDetail(t *testing.T) (*api.UI, *store.Hosts, *store.Runs) { t.Helper() tmp := t.TempDir() conn, err := db.Open(filepath.Join(tmp, "vetting.db")) if err != nil { t.Fatalf("open db: %v", err) } t.Cleanup(func() { _ = conn.Close() }) hosts := &store.Hosts{DB: conn} runs := &store.Runs{DB: conn} stages := &store.Stages{DB: conn} subSteps := &store.SubSteps{DB: conn} diffs := &store.SpecDiffs{DB: conn} arts := &store.Artifacts{DB: conn} hub := events.NewHub() logsHub, err := logs.NewHub(filepath.Join(tmp, "logs"), hub) if err != nil { t.Fatalf("logs hub: %v", err) } t.Cleanup(logsHub.Close) runner := &orchestrator.Runner{Runs: runs, Hosts: hosts, Stages: stages, EventHub: hub} tiles := &api.TileEnricher{Runs: runs, Artifacts: arts, SpecDiffs: diffs} ui := &api.UI{ Hosts: hosts, Runs: runs, Stages: stages, SubSteps: subSteps, SpecDiffs: diffs, Artifacts: arts, EventHub: hub, Logs: logsHub, Runner: runner, Tiles: tiles, } return ui, hosts, runs } func detailReq(id int64) *http.Request { req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/hosts/%d", id), nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", fmt.Sprintf("%d", id)) return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) } // detailReqWithQuery is detailReq with an optional ?run= query string. // Used by TestHostDetail_RunQueryParam so we can drive the selected-run // branch without routing through the real router. func detailReqWithQuery(id int64, rawQuery string) *http.Request { req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/hosts/%d?%s", id, rawQuery), nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", fmt.Sprintf("%d", id)) return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) } func TestHostDetail_OK(t *testing.T) { ui, hosts, runs := setupDetail(t) ctx := context.Background() id, err := hosts.Create(ctx, model.Host{ Name: "detail-host", MAC: "aa:bb:cc:dd:ee:30", WoLBroadcastIP: "10.0.0.255", WoLPort: 9, ExpectedSpecYAML: "memory:\n total_gib: 16\n", }) if err != nil { t.Fatalf("create host: %v", err) } runID, err := runs.Create(ctx, id, "deadbeef", false) if err != nil { t.Fatalf("create run: %v", err) } if err := ui.Stages.Seed(ctx, runID); err != nil { t.Fatalf("seed stages: %v", err) } rr := httptest.NewRecorder() ui.HostDetail(rr, detailReq(id)) if rr.Code != http.StatusOK { t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String()) } body := rr.Body.String() if !strings.Contains(body, "detail-host") { t.Fatalf("body missing host name: %s", body) } wantPipelineID := fmt.Sprintf(`id="pipeline-%d"`, runID) if !strings.Contains(body, wantPipelineID) { t.Fatalf("body missing %s", wantPipelineID) } // Each stage owns its own log pane; assert one of them is present. wantLogID := fmt.Sprintf(`id="log-%d-Inventory"`, runID) if !strings.Contains(body, wantLogID) { t.Fatalf("body missing %s", wantLogID) } } func TestHostDetail_NeverRun(t *testing.T) { ui, hosts, _ := setupDetail(t) id, err := hosts.Create(context.Background(), model.Host{ Name: "never-run", MAC: "aa:bb:cc:dd:ee:31", WoLBroadcastIP: "10.0.0.255", WoLPort: 9, ExpectedSpecYAML: "memory:\n total_gib: 8\n", }) if err != nil { t.Fatalf("create host: %v", err) } rr := httptest.NewRecorder() ui.HostDetail(rr, detailReq(id)) if rr.Code != http.StatusOK { t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String()) } body := rr.Body.String() if !strings.Contains(body, "Start vetting") { t.Fatalf("never-run page missing Start vetting: %s", body) } // Ghost pipeline: all nodes rendered but none are running/failed/passed. if strings.Contains(body, "stage-dot-running") || strings.Contains(body, "stage-dot-failed") { t.Fatalf("ghost pipeline should have no running/failed dots: %s", body) } if !strings.Contains(body, "stage-dot-pending") { t.Fatalf("expected pending stage dots in ghost pipeline: %s", body) } } // TestHostDetail_ActiveStepsRendered: every canonical stage gets its own //
panel with a matching log pane id, replacing // the old flat log-tab scaffold. Also confirms the sub-step SSE swap // target exists when sub-steps are seeded for a stage (so Phase 1's // substep-* event path has a DOM home). func TestHostDetail_ActiveStepsRendered(t *testing.T) { ui, hosts, runs := setupDetail(t) ctx := context.Background() id, err := hosts.Create(ctx, model.Host{ Name: "steps-host", MAC: "aa:bb:cc:dd:ee:40", WoLBroadcastIP: "10.0.0.255", WoLPort: 9, ExpectedSpecYAML: "memory:\n total_gib: 16\n", }) if err != nil { t.Fatalf("create host: %v", err) } runID, err := runs.Create(ctx, id, "cafef00d", false) if err != nil { t.Fatalf("create run: %v", err) } if err := ui.Stages.Seed(ctx, runID); err != nil { t.Fatalf("seed stages: %v", err) } // Seed one CPUStress sub-step so the SubStepRow swap target lands. if err := ui.SubSteps.Upsert(ctx, model.SubStep{ RunID: runID, StageName: "CPUStress", Ordinal: 0, Name: "CPU pass", State: model.StagePending, }); err != nil { t.Fatalf("upsert sub-step: %v", err) } rr := httptest.NewRecorder() ui.HostDetail(rr, detailReq(id)) if rr.Code != http.StatusOK { t.Fatalf("status = %d", rr.Code) } body := rr.Body.String() // Every stage in DefaultStageOrder owns a collapsible panel + log pane. for _, s := range store.DefaultStageOrder { wantPanel := fmt.Sprintf(`data-stage="%s"`, s) if !strings.Contains(body, wantPanel) { t.Fatalf("body missing active-step panel %s", wantPanel) } wantPane := fmt.Sprintf(`id="log-%d-%s"`, runID, s) if !strings.Contains(body, wantPane) { t.Fatalf("body missing stage log pane %s", wantPane) } } // Sub-step row for CPUStress/0 is present and SSE-bound. wantSub := fmt.Sprintf(`id="substep-%d-CPUStress-0"`, runID) if !strings.Contains(body, wantSub) { t.Fatalf("body missing sub-step row %s", wantSub) } wantSubSwap := fmt.Sprintf(`sse-swap="substep-%d-CPUStress-0"`, runID) if !strings.Contains(body, wantSubSwap) { t.Fatalf("body missing sub-step sse-swap %s", wantSubSwap) } } // defaultOpenStage returns the value of data-stage on the single // `
` emitted by ActiveStep. Returns // "" if no stage is currently open. The rendered attribute order is // fixed by active_step.templ (class, then open?, then data-stage), so // a tight substring match is safe. func defaultOpenStage(body string) string { re := regexp.MustCompile(`open data-stage="([^"]+)"`) m := re.FindStringSubmatch(body) if len(m) < 2 { return "" } return m[1] } func TestHostDetail_DefaultStep_Running(t *testing.T) { ui, hosts, runs := setupDetail(t) ctx := context.Background() id, err := hosts.Create(ctx, model.Host{ Name: "default-running", MAC: "aa:bb:cc:dd:ee:50", WoLBroadcastIP: "10.0.0.255", WoLPort: 9, ExpectedSpecYAML: "memory:\n total_gib: 16\n", }) if err != nil { t.Fatalf("create host: %v", err) } runID, err := runs.Create(ctx, id, "t-running", false) if err != nil { t.Fatalf("create run: %v", err) } if err := ui.Stages.Seed(ctx, runID); err != nil { t.Fatalf("seed stages: %v", err) } // Earlier stages passed; SMART is running. for _, name := range []string{"Inventory", "SpecValidate"} { if err := ui.Stages.StartByName(ctx, runID, name); err != nil { t.Fatalf("start %s: %v", name, err) } if err := ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, ""); err != nil { t.Fatalf("complete %s: %v", name, err) } } if err := ui.Stages.StartByName(ctx, runID, "SMART"); err != nil { t.Fatalf("start SMART: %v", err) } rr := httptest.NewRecorder() ui.HostDetail(rr, detailReq(id)) if rr.Code != http.StatusOK { t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String()) } if got := defaultOpenStage(rr.Body.String()); got != "SMART" { t.Fatalf("default step = %q, want SMART", got) } } func TestHostDetail_DefaultStep_Failed(t *testing.T) { ui, hosts, runs := setupDetail(t) ctx := context.Background() id, err := hosts.Create(ctx, model.Host{ Name: "default-failed", MAC: "aa:bb:cc:dd:ee:51", WoLBroadcastIP: "10.0.0.255", WoLPort: 9, ExpectedSpecYAML: "memory:\n total_gib: 16\n", }) if err != nil { t.Fatalf("create host: %v", err) } runID, err := runs.Create(ctx, id, "t-failed", false) if err != nil { t.Fatalf("create run: %v", err) } if err := ui.Stages.Seed(ctx, runID); err != nil { t.Fatalf("seed stages: %v", err) } // Inventory + SpecValidate + SMART passed; CPUStress failed; nothing // running. Default must land on CPUStress. for _, name := range []string{"Inventory", "SpecValidate", "SMART"} { if err := ui.Stages.StartByName(ctx, runID, name); err != nil { t.Fatalf("start %s: %v", name, err) } if err := ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, ""); err != nil { t.Fatalf("complete %s: %v", name, err) } } if err := ui.Stages.StartByName(ctx, runID, "CPUStress"); err != nil { t.Fatalf("start CPUStress: %v", err) } if err := ui.Stages.CompleteByName(ctx, runID, "CPUStress", model.StageFailed, `{"reason":"thermal"}`); err != nil { t.Fatalf("complete CPUStress: %v", err) } rr := httptest.NewRecorder() ui.HostDetail(rr, detailReq(id)) if rr.Code != http.StatusOK { t.Fatalf("status = %d", rr.Code) } if got := defaultOpenStage(rr.Body.String()); got != "CPUStress" { t.Fatalf("default step = %q, want CPUStress", got) } } func TestHostDetail_DefaultStep_Completed(t *testing.T) { ui, hosts, runs := setupDetail(t) ctx := context.Background() id, err := hosts.Create(ctx, model.Host{ Name: "default-done", MAC: "aa:bb:cc:dd:ee:52", WoLBroadcastIP: "10.0.0.255", WoLPort: 9, ExpectedSpecYAML: "memory:\n total_gib: 16\n", }) if err != nil { t.Fatalf("create host: %v", err) } runID, err := runs.Create(ctx, id, "t-done", false) if err != nil { t.Fatalf("create run: %v", err) } if err := ui.Stages.Seed(ctx, runID); err != nil { t.Fatalf("seed stages: %v", err) } // All stages passed → default lands on Reporting. for _, name := range store.DefaultStageOrder { if err := ui.Stages.StartByName(ctx, runID, name); err != nil { t.Fatalf("start %s: %v", name, err) } if err := ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, ""); err != nil { t.Fatalf("complete %s: %v", name, err) } } rr := httptest.NewRecorder() ui.HostDetail(rr, detailReq(id)) if rr.Code != http.StatusOK { t.Fatalf("status = %d", rr.Code) } if got := defaultOpenStage(rr.Body.String()); got != "Reporting" { t.Fatalf("default step = %q, want Reporting", got) } } // TestHostDetail_RunQueryParam: ?run=N selects a specific past run // instead of the latest. The history sidebar's links rely on this. func TestHostDetail_RunQueryParam(t *testing.T) { ui, hosts, runs := setupDetail(t) ctx := context.Background() id, err := hosts.Create(ctx, model.Host{ Name: "query-run", MAC: "aa:bb:cc:dd:ee:53", WoLBroadcastIP: "10.0.0.255", WoLPort: 9, ExpectedSpecYAML: "memory:\n total_gib: 16\n", }) if err != nil { t.Fatalf("create host: %v", err) } // Older run: failed at CPUStress. Newer run: fully passed. oldRun, err := runs.Create(ctx, id, "old", false) if err != nil { t.Fatalf("create old run: %v", err) } if err := ui.Stages.Seed(ctx, oldRun); err != nil { t.Fatalf("seed old: %v", err) } for _, name := range []string{"Inventory", "SpecValidate", "SMART"} { _ = ui.Stages.StartByName(ctx, oldRun, name) _ = ui.Stages.CompleteByName(ctx, oldRun, name, model.StagePassed, "") } _ = ui.Stages.StartByName(ctx, oldRun, "CPUStress") _ = ui.Stages.CompleteByName(ctx, oldRun, "CPUStress", model.StageFailed, "") // Newer run lands after a tiny gap so Runs.LatestForHost picks it. time.Sleep(10 * time.Millisecond) newRun, err := runs.Create(ctx, id, "new", false) if err != nil { t.Fatalf("create new run: %v", err) } if err := ui.Stages.Seed(ctx, newRun); err != nil { t.Fatalf("seed new: %v", err) } for _, name := range store.DefaultStageOrder { _ = ui.Stages.StartByName(ctx, newRun, name) _ = ui.Stages.CompleteByName(ctx, newRun, name, model.StagePassed, "") } // Sanity: with no ?run=, default is Reporting (latest run is green). rr := httptest.NewRecorder() ui.HostDetail(rr, detailReq(id)) if got := defaultOpenStage(rr.Body.String()); got != "Reporting" { t.Fatalf("latest default = %q, want Reporting", got) } // With ?run={old}, we view the failed run → default is CPUStress and // the pipeline section references the old run's ID. rr = httptest.NewRecorder() ui.HostDetail(rr, detailReqWithQuery(id, fmt.Sprintf("run=%d", oldRun))) body := rr.Body.String() if got := defaultOpenStage(body); got != "CPUStress" { t.Fatalf("?run=old default = %q, want CPUStress", got) } wantPipelineID := fmt.Sprintf(`id="pipeline-%d"`, oldRun) if !strings.Contains(body, wantPipelineID) { t.Fatalf("?run=old body missing %s", wantPipelineID) } // A ?run= value that belongs to no host at all falls back to latest // silently (stale bookmark should never 4xx). rr = httptest.NewRecorder() ui.HostDetail(rr, detailReqWithQuery(id, "run=9999")) if rr.Code != http.StatusOK { t.Fatalf("bogus run fallback status = %d", rr.Code) } if got := defaultOpenStage(rr.Body.String()); got != "Reporting" { t.Fatalf("bogus run default = %q, want Reporting (fall back to latest)", got) } } func TestHostDetail_UnknownID(t *testing.T) { ui, _, _ := setupDetail(t) rr := httptest.NewRecorder() ui.HostDetail(rr, detailReq(9999)) if rr.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", rr.Code) } } func TestHostDetail_BadID(t *testing.T) { ui, _, _ := setupDetail(t) req := httptest.NewRequest(http.MethodGet, "/hosts/not-a-number", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", "not-a-number") req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) rr := httptest.NewRecorder() ui.HostDetail(rr, req) if rr.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rr.Code) } }