package dashboard import ( "encoding/json" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "os" "strings" "sync" "testing" "time" ) // fakeControlAPI is a minimal in-memory stand-in for control-api's // /api/v1/admin/* surface, serving the exact JSON shapes the dashboard's // client.go expects (see dto.go). It's intentionally simple — enough to // exercise the dashboard's rendering logic and mutation flows, not a // re-implementation of control-api's own business rules (that's already // covered by internal/httpapi's own tests). type fakeControlAPI struct { mu sync.Mutex ips []ipQueueItem validators []validatorDTO sites map[int]string siteState map[int]string siteHostname map[int]string groups map[string][]string checkTypes map[string]checkTypeDTO fipSettleSeconds int inboundPorts []int inboundICMP bool } func newFakeControlAPI(t *testing.T) (*fakeControlAPI, string) { t.Helper() f := &fakeControlAPI{ sites: map[int]string{}, siteState: map[int]string{}, siteHostname: map[int]string{}, groups: map[string][]string{}, checkTypes: map[string]checkTypeDTO{}, } ts := httptest.NewServer(f.handler()) t.Cleanup(ts.Close) return f, ts.URL } func writeJSON(w http.ResponseWriter, status int, v interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func writeAPIErr(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, errorResponse{Error: msg}) } func (f *fakeControlAPI) handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /api/v1/admin/status", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() byState := map[string]int{} for _, ip := range f.ips { byState[ip.State]++ } writeJSON(w, http.StatusOK, statusResponse{TotalIPs: len(f.ips), IPsByState: byState, TotalValidators: len(f.validators)}) }) mux.HandleFunc("GET /api/v1/admin/ips", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() writeJSON(w, http.StatusOK, f.ips) }) mux.HandleFunc("GET /api/v1/admin/ips/{ip}", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() addr := r.PathValue("ip") for _, ip := range f.ips { if ip.IPAddress == addr { writeJSON(w, http.StatusOK, ipDetailResponse{IP: ip, Checks: []check{}, Events: []event{}}) return } } writeAPIErr(w, http.StatusNotFound, "unknown ip: "+addr) }) mux.HandleFunc("POST /api/v1/admin/ips", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() var req struct { Addresses []string `json:"addresses"` } _ = json.NewDecoder(r.Body).Decode(&req) if len(req.Addresses) == 0 { writeAPIErr(w, http.StatusBadRequest, "addresses must not be empty") return } resp := submitIPsResponse{} for _, addr := range req.Addresses { idx := f.findIP(addr) if idx < 0 { now := time.Now() f.ips = append(f.ips, ipQueueItem{IPAddress: addr, State: "queued", CreatedAt: now, UpdatedAt: now}) resp.Added = append(resp.Added, addr) continue } switch f.ips[idx].State { case "done", "failed": f.ips[idx].State = "queued" f.ips[idx].AttemptNumber++ f.ips[idx].OverallResult = "" resp.Requeued = append(resp.Requeued, addr) case "queued": resp.Reordered = append(resp.Reordered, addr) default: resp.SkippedInProgress = append(resp.SkippedInProgress, addr) } } writeJSON(w, http.StatusOK, resp) }) mux.HandleFunc("POST /api/v1/admin/ips/{ip}/cancel", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() addr := r.PathValue("ip") idx := f.findIP(addr) if idx < 0 { writeAPIErr(w, http.StatusNotFound, "unknown ip: "+addr) return } if f.ips[idx].State == "done" || f.ips[idx].State == "failed" { writeAPIErr(w, http.StatusConflict, "already finished") return } f.ips[idx].State = "failed" f.ips[idx].OverallResult = "cancelled" now := time.Now() f.ips[idx].AggregatedAt = &now writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) }) mux.HandleFunc("DELETE /api/v1/admin/ips/{ip}", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() addr := r.PathValue("ip") idx := f.findIP(addr) if idx < 0 { writeAPIErr(w, http.StatusNotFound, "unknown ip: "+addr) return } f.ips = append(f.ips[:idx], f.ips[idx+1:]...) writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) }) mux.HandleFunc("POST /api/v1/admin/ips/delete", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() var req struct { Addresses []string `json:"addresses"` } _ = json.NewDecoder(r.Body).Decode(&req) if len(req.Addresses) == 0 { writeAPIErr(w, http.StatusBadRequest, "addresses must not be empty") return } resp := deleteIPsResponse{} for _, addr := range req.Addresses { idx := f.findIP(addr) if idx < 0 { resp.NotFound = append(resp.NotFound, addr) continue } f.ips = append(f.ips[:idx], f.ips[idx+1:]...) resp.Deleted = append(resp.Deleted, addr) } writeJSON(w, http.StatusOK, resp) }) mux.HandleFunc("POST /api/v1/admin/ips/clear", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() resp := clearQueueResponse{} for _, ip := range f.ips { resp.Deleted = append(resp.Deleted, ip.IPAddress) } f.ips = nil writeJSON(w, http.StatusOK, resp) }) mux.HandleFunc("GET /api/v1/admin/config/orchestrator", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() writeJSON(w, http.StatusOK, orchestratorSettingsDTO{FIPSettleSeconds: f.fipSettleSeconds}) }) mux.HandleFunc("PUT /api/v1/admin/config/orchestrator", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() var req orchestratorSettingsDTO _ = json.NewDecoder(r.Body).Decode(&req) if req.FIPSettleSeconds < 0 { writeAPIErr(w, http.StatusBadRequest, "fip_settle_seconds must be >= 0") return } f.fipSettleSeconds = req.FIPSettleSeconds writeJSON(w, http.StatusOK, orchestratorSettingsDTO{FIPSettleSeconds: f.fipSettleSeconds}) }) mux.HandleFunc("GET /api/v1/admin/config/inbound-checks", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() writeJSON(w, http.StatusOK, inboundChecksDTO{Ports: f.inboundPorts, ICMP: f.inboundICMP}) }) mux.HandleFunc("PUT /api/v1/admin/config/inbound-checks", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() var req inboundChecksDTO _ = json.NewDecoder(r.Body).Decode(&req) for _, p := range req.Ports { if p < 1 || p > 65535 { writeAPIErr(w, http.StatusBadRequest, "port out of range") return } } f.inboundPorts = req.Ports f.inboundICMP = req.ICMP writeJSON(w, http.StatusOK, inboundChecksDTO{Ports: f.inboundPorts, ICMP: f.inboundICMP}) }) mux.HandleFunc("GET /api/v1/admin/config/validators", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() writeJSON(w, http.StatusOK, f.validators) }) mux.HandleFunc("POST /api/v1/admin/config/validators", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() var req validatorDTO _ = json.NewDecoder(r.Body).Decode(&req) for _, v := range f.validators { if v.ValidatorID == req.ValidatorID { writeAPIErr(w, http.StatusConflict, "already exists") return } } req.State = "idle" f.validators = append(f.validators, req) writeJSON(w, http.StatusCreated, req) }) mux.HandleFunc("PUT /api/v1/admin/config/validators/{id}", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() id := r.PathValue("id") var req struct { OSPortID string `json:"os_port_id"` } _ = json.NewDecoder(r.Body).Decode(&req) for i, v := range f.validators { if v.ValidatorID == id { f.validators[i].OSPortID = req.OSPortID writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) return } } writeAPIErr(w, http.StatusNotFound, "unknown validator") }) mux.HandleFunc("DELETE /api/v1/admin/config/validators/{id}", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() id := r.PathValue("id") for i, v := range f.validators { if v.ValidatorID == id { f.validators = append(f.validators[:i], f.validators[i+1:]...) writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) return } } writeAPIErr(w, http.StatusNotFound, "unknown validator") }) mux.HandleFunc("GET /api/v1/admin/config/sites", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() var out []siteDTO for idx, id := range f.sites { out = append(out, siteDTO{Index: idx, SiteID: id, Hostname: f.siteHostname[idx], State: f.siteState[idx]}) } writeJSON(w, http.StatusOK, out) }) mux.HandleFunc("PUT /api/v1/admin/config/sites/{index}", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() var idx int fmt.Sscanf(r.PathValue("index"), "%d", &idx) var req struct { SiteID string `json:"site_id"` } _ = json.NewDecoder(r.Body).Decode(&req) for existingIdx, id := range f.sites { if id == req.SiteID && existingIdx != idx { writeAPIErr(w, http.StatusConflict, "site_id already assigned to another slot") return } } f.sites[idx] = req.SiteID // UpsertSite always resets connection status on write (see its // control-api doc comment) — mirror that here. f.siteState[idx] = "unregistered" f.siteHostname[idx] = "" writeJSON(w, http.StatusOK, siteDTO{Index: idx, SiteID: req.SiteID, State: "unregistered"}) }) mux.HandleFunc("DELETE /api/v1/admin/config/sites/{index}", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() var idx int fmt.Sscanf(r.PathValue("index"), "%d", &idx) delete(f.sites, idx) delete(f.siteState, idx) delete(f.siteHostname, idx) writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) }) mux.HandleFunc("GET /api/v1/admin/config/targets", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() var out []targetGroupDTO for name, targets := range f.groups { out = append(out, targetGroupDTO{Name: name, Targets: targets}) } writeJSON(w, http.StatusOK, out) }) mux.HandleFunc("PUT /api/v1/admin/config/targets/{group}", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() name := r.PathValue("group") var req struct { Targets []string `json:"targets"` } _ = json.NewDecoder(r.Body).Decode(&req) if len(req.Targets) == 0 { writeAPIErr(w, http.StatusBadRequest, "targets must not be empty") return } f.groups[name] = req.Targets writeJSON(w, http.StatusOK, targetGroupDTO{Name: name, Targets: req.Targets}) }) mux.HandleFunc("DELETE /api/v1/admin/config/targets/{group}", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() name := r.PathValue("group") for _, ct := range f.checkTypes { for _, g := range ct.Targets { if g == name { writeAPIErr(w, http.StatusConflict, "in use by check type "+ct.Name) return } } } delete(f.groups, name) writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) }) mux.HandleFunc("GET /api/v1/admin/config/check-types", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() var out []checkTypeDTO for _, ct := range f.checkTypes { out = append(out, ct) } writeJSON(w, http.StatusOK, out) }) mux.HandleFunc("PUT /api/v1/admin/config/check-types/{name}", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() name := r.PathValue("name") var req struct { Enabled bool `json:"enabled"` Targets []string `json:"targets"` } _ = json.NewDecoder(r.Body).Decode(&req) for _, g := range req.Targets { if _, ok := f.groups[g]; !ok { writeAPIErr(w, http.StatusBadRequest, "unknown target group "+g) return } } ct := checkTypeDTO{Name: name, Enabled: req.Enabled, Targets: req.Targets} f.checkTypes[name] = ct writeJSON(w, http.StatusOK, ct) }) mux.HandleFunc("DELETE /api/v1/admin/config/check-types/{name}", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() delete(f.checkTypes, r.PathValue("name")) writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) }) return mux } func (f *fakeControlAPI) findIP(addr string) int { for i, ip := range f.ips { if ip.IPAddress == addr { return i } } return -1 } func newTestServer(t *testing.T, caURL string) *httptest.Server { t.Helper() log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) srv, err := New(Config{ ControlAPIBaseURL: caURL, ControlAPITimeout: 5 * time.Second, LastCompletedCount: 20, OverviewPollIntervalS: 5, }, log) if err != nil { t.Fatalf("new dashboard server: %v", err) } ts := httptest.NewServer(srv.Handler()) t.Cleanup(ts.Close) return ts } func get(t *testing.T, ts *httptest.Server, path string) string { t.Helper() resp, err := http.Get(ts.URL + path) if err != nil { t.Fatalf("GET %s: %v", path, err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) return string(body) } func postForm(t *testing.T, ts *httptest.Server, method, path string, form url.Values) string { t.Helper() req, err := http.NewRequest(method, ts.URL+path, strings.NewReader(form.Encode())) if err != nil { t.Fatalf("build request: %v", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := ts.Client().Do(req) if err != nil { t.Fatalf("%s %s: %v", method, path, err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) return string(body) }