package dashboard import ( "encoding/json" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "os" "sort" "strconv" "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 historyRetentionCycles int selfCheckMaxAttempts int // selfCheckFailedOn is served as self_check_failed_on of the address // detail and registry history endpoints. selfCheckFailedOn []string // Analytics: the run selector, the report JSON per run, the lists per // "run/kind[/class]" and the subnet list of /settings. runs []analyticsRun reports map[int64]string lists map[string]string // compares is the comparison JSON per "base-target", compareLists the lists // per "base-target/group[/indicator]". compares map[string]string compareLists map[string]string subnets subnetList analyticsReqs []string // scanFreeAddresses is what POST /ips/scan "discovers" — tests set it // directly rather than this fake reimplementing OpenStack floating-IP // filtering (already covered by internal/orchestrator's own tests). scanFreeAddresses []string registry map[string]registryItem registryChecks map[string][]check // Scan job state machine (see the scan handlers): POST starts a job that // stays "running" for scanRunPolls GET polls (0 = finishes at once), then // ends as done — or as error when scanFinalError is set. scan is the status // served by GET; tests may also set it directly (with scanPollsLeft == 0 it // stays as is). scanStartStatus != 0 makes POST fail with that HTTP status. scan scanStatusDTO scanRunPolls int scanPollsLeft int scanFinalError string scanStartStatus int // Requests seen on the list endpoints, for "never loads everything" checks: // the raw query of every GET /ips (ipsQueries) and the number of GET // /registry calls without `limit` (bare), plus the sizes of the DeleteIPs / // SubmitIPs bulk calls. ipsQueries []string registryQueries []string bareIPsCalls int deleteChunks []int submitChunks []int // autoCycle is the state served by /api/v1/admin/auto-cycle*; // autoCycleDown makes all four endpoints answer 500 (unavailable API). autoCycle autoCycleDTO autoCycleDown bool // authHeaders records the Authorization header of every request served. authMu sync.Mutex authHeaders []string } 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{}, registry: map[string]registryItem{}, registryChecks: map[string][]check{}, autoCycle: autoCycleDTO{IntervalSeconds: 3600, Phase: "idle"}, selfCheckMaxAttempts: 5, } 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{} results := map[string]int{"pass": 0, "partial": 0, "fail": 0, "cancelled": 0} for _, ip := range f.ips { byState[ip.State]++ if ip.OverallResult != "" { results[ip.OverallResult]++ } } writeJSON(w, http.StatusOK, statusResponse{TotalIPs: len(f.ips), IPsByState: byState, TotalValidators: len(f.validators), ResultsByOverall: results}) }) mux.HandleFunc("GET /api/v1/admin/ips", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() f.ipsQueries = append(f.ipsQueries, r.URL.RawQuery) qv := r.URL.Query() if qv.Get("limit") == "" { // Legacy shape: the whole queue as a bare array. f.bareIPsCalls++ writeJSON(w, http.StatusOK, f.ips) return } limit, err := strconv.Atoi(qv.Get("limit")) if err != nil || limit < 1 || limit > 1000 { writeAPIErr(w, http.StatusBadRequest, "limit must be 1..1000") return } offset, _ := strconv.Atoi(qv.Get("offset")) var states map[string]bool if st := qv.Get("state"); st != "" { states = map[string]bool{} for _, x := range strings.Split(st, ",") { states[x] = true } } var matched []ipQueueItem for _, ip := range f.ips { if states != nil && !states[ip.State] { continue } if q := qv.Get("q"); q != "" && !strings.Contains(strings.ToLower(ip.IPAddress), strings.ToLower(q)) { continue } if res := qv.Get("result"); res != "" && ip.OverallResult != res { continue } matched = append(matched, ip) } if qv.Get("order") == "aggregated_at_desc" { sort.SliceStable(matched, func(i, j int) bool { a, b := matched[i].AggregatedAt, matched[j].AggregatedAt if a == nil || b == nil { return a != nil && b == nil } return a.After(*b) }) } else { sort.SliceStable(matched, func(i, j int) bool { return matched[i].Sequence < matched[j].Sequence }) } page := []ipQueueItem{} if offset < len(matched) { page = matched[offset:] if len(page) > limit { page = page[:limit] } } writeJSON(w, http.StatusOK, ipsPage{Items: page, Total: len(matched), Limit: limit, Offset: offset}) }) 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{}, SelfCheckFailedOn: f.selfCheckFailedOn}) 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 } f.submitChunks = append(f.submitChunks, len(req.Addresses)) 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 } f.deleteChunks = append(f.deleteChunks, len(req.Addresses)) 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) } resp.Count = len(resp.Deleted) 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, HistoryRetentionCycles: f.historyRetentionCycles, SelfCheckMaxAttempts: f.selfCheckMaxAttempts, }) }) 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 } if req.HistoryRetentionCycles < 0 { writeAPIErr(w, http.StatusBadRequest, "history_retention_cycles must be >= 0") return } if req.SelfCheckMaxAttempts < 1 || req.SelfCheckMaxAttempts > 50 { writeAPIErr(w, http.StatusBadRequest, "self_check_max_attempts must be in 1..50") return } f.fipSettleSeconds = req.FIPSettleSeconds f.historyRetentionCycles = req.HistoryRetentionCycles f.selfCheckMaxAttempts = req.SelfCheckMaxAttempts writeJSON(w, http.StatusOK, orchestratorSettingsDTO{ FIPSettleSeconds: f.fipSettleSeconds, HistoryRetentionCycles: f.historyRetentionCycles, SelfCheckMaxAttempts: f.selfCheckMaxAttempts, }) }) mux.HandleFunc("POST /api/v1/admin/ips/scan", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() if f.scanStartStatus != 0 { writeAPIErr(w, f.scanStartStatus, "scan refused") return } if !f.scan.Running { now := time.Now() f.scan = scanStatusDTO{State: "listing", Running: true, DryRun: r.URL.Query().Get("dry_run") == "true", StartedAt: &now} f.scanPollsLeft = f.scanRunPolls if f.scanPollsLeft == 0 { f.finishScan() } } writeJSON(w, http.StatusAccepted, f.scan) }) mux.HandleFunc("GET /api/v1/admin/ips/scan", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() if f.scan.Running && f.scanPollsLeft > 0 { f.scanPollsLeft-- if f.scanPollsLeft == 0 { f.finishScan() } else { f.scan.Pages++ } } writeJSON(w, http.StatusOK, f.scan) }) mux.HandleFunc("GET /api/v1/admin/auto-cycle", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() if f.autoCycleDown { writeAPIErr(w, http.StatusInternalServerError, "auto-cycle unavailable") return } writeJSON(w, http.StatusOK, f.autoCycle) }) mux.HandleFunc("PUT /api/v1/admin/auto-cycle", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() if f.autoCycleDown { writeAPIErr(w, http.StatusInternalServerError, "auto-cycle unavailable") return } var req struct { IntervalSeconds *int `json:"interval_seconds"` MaxRunSeconds *int `json:"max_run_seconds"` } _ = json.NewDecoder(r.Body).Decode(&req) if req.IntervalSeconds != nil && *req.IntervalSeconds < 60 { writeAPIErr(w, http.StatusBadRequest, "interval_seconds must be >= 60: validation failed") return } if req.MaxRunSeconds != nil && *req.MaxRunSeconds < 0 { writeAPIErr(w, http.StatusBadRequest, "max_run_seconds must be >= 0: validation failed") return } if req.IntervalSeconds != nil { f.autoCycle.IntervalSeconds = *req.IntervalSeconds } if req.MaxRunSeconds != nil { f.autoCycle.MaxRunSeconds = *req.MaxRunSeconds } writeJSON(w, http.StatusOK, f.autoCycle) }) mux.HandleFunc("POST /api/v1/admin/auto-cycle/start", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() if f.autoCycleDown { writeAPIErr(w, http.StatusInternalServerError, "auto-cycle unavailable") return } if !f.autoCycle.Enabled { now := time.Now() f.autoCycle.Enabled = true f.autoCycle.Phase = "idle" f.autoCycle.NextRunAt = &now } writeJSON(w, http.StatusOK, f.autoCycle) }) mux.HandleFunc("POST /api/v1/admin/auto-cycle/stop", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() if f.autoCycleDown { writeAPIErr(w, http.StatusInternalServerError, "auto-cycle unavailable") return } f.autoCycle.Enabled = false f.autoCycle.Phase = "idle" f.autoCycle.NextRunAt = nil f.autoCycle.LastOutcome = "stopped" writeJSON(w, http.StatusOK, f.autoCycle) }) mux.HandleFunc("GET /api/v1/admin/registry", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() qv := r.URL.Query() f.registryQueries = append(f.registryQueries, r.URL.RawQuery) matched := make([]registryItem, 0, len(f.registry)) for _, item := range f.registry { if q := qv.Get("q"); q != "" && !strings.Contains(strings.ToLower(item.IPAddress), strings.ToLower(q)) { continue } if lr := qv.Get("last_result"); lr != "" && item.LastResult != lr { continue } matched = append(matched, item) } sort.Slice(matched, func(i, j int) bool { return matched[i].IPAddress < matched[j].IPAddress }) if qv.Get("limit") == "" { writeJSON(w, http.StatusOK, matched) return } limit, err := strconv.Atoi(qv.Get("limit")) if err != nil || limit < 1 || limit > 1000 { writeAPIErr(w, http.StatusBadRequest, "limit must be 1..1000") return } offset, _ := strconv.Atoi(qv.Get("offset")) page := []registryItem{} if offset < len(matched) { page = matched[offset:] if len(page) > limit { page = page[:limit] } } writeJSON(w, http.StatusOK, registryPage{Items: page, Total: len(matched), Limit: limit, Offset: offset}) }) mux.HandleFunc("GET /api/v1/admin/registry/{ip}", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() addr := r.PathValue("ip") item, ok := f.registry[addr] if !ok { writeAPIErr(w, http.StatusNotFound, "unknown ip: "+addr) return } writeJSON(w, http.StatusOK, registryHistoryResponse{Registry: item, Checks: f.registryChecks[addr], SelfCheckFailedOn: f.selfCheckFailedOn}) }) mux.HandleFunc("GET /api/v1/admin/config/subnets", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() out := f.subnets if out.Subnets == nil { out.Subnets = []subnetEntry{} } writeJSON(w, http.StatusOK, out) }) mux.HandleFunc("PUT /api/v1/admin/config/subnets", func(w http.ResponseWriter, r *http.Request) { var in subnetList if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeAPIErr(w, http.StatusBadRequest, err.Error()) return } for _, s := range in.Subnets { if !strings.Contains(s.CIDR, "/") { writeAPIErr(w, http.StatusBadRequest, "subnet "+s.CIDR+": not a CIDR") return } } f.mu.Lock() defer f.mu.Unlock() f.subnets = in writeJSON(w, http.StatusOK, in) }) mux.HandleFunc("GET /api/v1/admin/analytics/runs", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() out := f.runs if out == nil { out = []analyticsRun{} } writeJSON(w, http.StatusOK, out) }) mux.HandleFunc("GET /api/v1/admin/analytics/runs/{id}", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI()) rep, ok := f.reports[id] if !ok { writeAPIErr(w, http.StatusNotFound, "run not found") return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(rep)) }) mux.HandleFunc("GET /api/v1/admin/analytics/runs/{id}/lists/{kind}", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI()) key := r.PathValue("id") + "/" + r.PathValue("kind") if c := r.URL.Query().Get("class"); c != "" { key += "/" + c } l, ok := f.lists[key] if !ok { writeAPIErr(w, http.StatusNotFound, "unknown list") return } if r.URL.Query().Get("format") == "csv" { w.Header().Set("Content-Type", "text/csv; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="`+r.PathValue("kind")+`_run`+r.PathValue("id")+`.csv"`) _, _ = w.Write([]byte("Адрес\r\n1.2.3.4\r\n")) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(l)) }) mux.HandleFunc("GET /api/v1/admin/analytics/compare", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI()) rep, ok := f.compares[r.URL.Query().Get("base")+"-"+r.URL.Query().Get("target")] if !ok { writeAPIErr(w, http.StatusNotFound, "run not found") return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(rep)) }) mux.HandleFunc("GET /api/v1/admin/analytics/compare/lists/{group}", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI()) q := r.URL.Query() key := q.Get("base") + "-" + q.Get("target") + "/" + r.PathValue("group") if ind := q.Get("indicator"); ind != "" { key += "/" + ind } l, ok := f.compareLists[key] if !ok { writeAPIErr(w, http.StatusNotFound, "unknown list") return } if q.Get("format") == "csv" { w.Header().Set("Content-Type", "text/csv; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="compare_`+r.PathValue("group")+`_run`+q.Get("base")+"-"+q.Get("target")+`.csv"`) _, _ = w.Write([]byte("Адрес\r\n1.2.3.4\r\n")) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(l)) }) 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 http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { f.authMu.Lock() f.authHeaders = append(f.authHeaders, r.Header.Get("Authorization")) f.authMu.Unlock() mux.ServeHTTP(w, r) }) } // finishScan ends the running scan job (caller holds f.mu): the discovered // free addresses are queued unless it was a dry run, or the job fails with // scanFinalError. func (f *fakeControlAPI) finishScan() { now := time.Now() f.scan.Running = false f.scan.FinishedAt = &now f.scan.Discovered = len(f.scanFreeAddresses) f.scan.Free = len(f.scanFreeAddresses) if f.scanFinalError != "" { f.scan.State = "error" f.scan.Error = f.scanFinalError return } f.scan.State = "done" if f.scan.DryRun { return } for _, addr := range f.scanFreeAddresses { if f.findIP(addr) < 0 { f.ips = append(f.ips, ipQueueItem{IPAddress: addr, State: "queued", Sequence: len(f.ips) + 1, CreatedAt: now, UpdatedAt: now}) f.scan.Added++ } else { f.scan.Reordered++ } } } // seedIPs appends n queued addresses 10.x.y.z (distinct, in sequence order) // and returns them. Use it for tests that need pages' worth of rows. func (f *fakeControlAPI) seedIPs(n int) []string { f.mu.Lock() defer f.mu.Unlock() now := time.Now() out := make([]string, 0, n) base := len(f.ips) for i := 0; i < n; i++ { k := base + i + 1 addr := fmt.Sprintf("10.%d.%d.%d", k/65536, (k/256)%256, k%256) f.ips = append(f.ips, ipQueueItem{IPAddress: addr, State: "queued", Sequence: k, CreatedAt: now, UpdatedAt: now}) out = append(out, addr) } return out } 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) } // newSlowAPI serves an empty JSON object for every request after delay. func newSlowAPI(t *testing.T, delay time.Duration) string { t.Helper() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(delay) writeJSON(w, http.StatusOK, map[string]interface{}{}) })) t.Cleanup(ts.Close) return ts.URL }