Files
cloud-ip-validator/internal/dashboard/dashboard_test.go
T

426 lines
12 KiB
Go

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
groups map[string][]string
checkTypes map[string]checkTypeDTO
fipSettleSeconds int
}
func newFakeControlAPI(t *testing.T) (*fakeControlAPI, string) {
t.Helper()
f := &fakeControlAPI{
sites: 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/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})
}
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
writeJSON(w, http.StatusOK, siteDTO{Index: idx, SiteID: req.SiteID})
})
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)
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)
}