diff --git a/web/api/handler.go b/web/api/handler.go index 57dfd3e..07f4b87 100644 --- a/web/api/handler.go +++ b/web/api/handler.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io/fs" + "net" "net/http" "os" "sort" @@ -16,6 +17,7 @@ import ( "gitea.hansenits.com.au/hits/ExploreDNS/internal/config" idns "gitea.hansenits.com.au/hits/ExploreDNS/internal/dns" + "gitea.hansenits.com.au/hits/ExploreDNS/internal/fingerprint" "gitea.hansenits.com.au/hits/ExploreDNS/internal/traverse" ) @@ -38,6 +40,14 @@ const ( defaultMaxRunningJobs = 8 ) +// Fingerprinting runs after a traversal reaches a terminal state: bounded +// concurrency across the unique server IPs, with its own overall deadline so +// a timed-out or cancelled job context never blocks the server list. +const ( + fingerprintConcurrency = 8 + fingerprintTimeout = 15 * time.Second +) + // TraverseRequest is the JSON body for POST /api/traverse. type TraverseRequest struct { Domain string `json:"domain"` @@ -106,6 +116,14 @@ type Summary struct { ByStatus []SummaryStatus `json:"by_status,omitempty"` } +// ServerInfo is one (server name, IP) pair queried during a traversal plus +// its version.bind fingerprint ("" when the server didn't answer the probe). +type ServerInfo struct { + Name string `json:"name"` + IP string `json:"ip"` + Version string `json:"version"` +} + // TraversalJob holds all state for a single asynchronous traversal. type TraversalJob struct { ID string `json:"id"` @@ -115,13 +133,18 @@ type TraversalJob struct { Results []ResultItem `json:"results,omitempty"` Summary *Summary `json:"summary,omitempty"` Progress []ProgressEvent `json:"progress,omitempty"` + Servers []ServerInfo `json:"servers,omitempty"` Error string `json:"error,omitempty"` StartedAt time.Time `json:"started_at"` DoneAt *time.Time `json:"done_at,omitempty"` - mu sync.RWMutex - subs []chan ProgressEvent - cancel context.CancelFunc + mu sync.RWMutex + subs []chan ProgressEvent + cancel context.CancelFunc + clientIP string + // serversDone flips once the post-traversal fingerprinting step has + // stored Servers (or was skipped); until then GET …/servers is pending. + serversDone bool } // subscribeSnapshot atomically registers a subscriber and snapshots the @@ -224,24 +247,42 @@ func (s *store) cleanup() { } } +// versionQuerier is the subset of fingerprint.Fingerprinter the handler +// uses; tests substitute a fake so no probes leave the process. +type versionQuerier interface { + Query(ctx context.Context, ip net.IP) string +} + // Handler wires together the HTTP routes and the job store. type Handler struct { st *store mux *http.ServeMux jobTimeout time.Duration maxRunning int + version string + limiter *rateLimiter + webhook *webhookReporter + // fp fingerprints server IPs after each traversal; shared across jobs + // so its per-IP cache is reused. + fp versionQuerier } func newHandler(ctx context.Context) *Handler { + limit, window := parseRateLimit(os.Getenv("EXPLOREDNS_RATE_LIMIT")) h := &Handler{ st: newStore(), mux: http.NewServeMux(), jobTimeout: envDuration("EXPLOREDNS_JOB_TIMEOUT", defaultJobTimeout), maxRunning: envInt("EXPLOREDNS_MAX_JOBS", defaultMaxRunningJobs), + version: "dev", + limiter: newRateLimiter(limit, window), + webhook: newWebhookReporter(os.Getenv("EXPLOREDNS_WEBHOOK_URL")), + fp: fingerprint.New(), } h.mux.HandleFunc("POST /api/traverse", h.startTraversal) h.mux.HandleFunc("GET /api/traverse/{id}/stream", h.streamTraversal) + h.mux.HandleFunc("GET /api/traverse/{id}/servers", h.getServers) h.mux.HandleFunc("GET /api/traverse/{id}", h.getTraversal) h.mux.HandleFunc("GET /api/health", h.health) @@ -255,6 +296,7 @@ func newHandler(ctx context.Context) *Handler { return case <-t.C: h.st.cleanup() + h.limiter.sweep() } } }() @@ -270,11 +312,24 @@ func (h *Handler) registerStatic(sub fs.FS) { // health handles GET /api/health. func (h *Handler) health(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + body := map[string]string{"status": "ok", "version": h.version} + // On Fly.io this identifies which machine served the request — + // useful for observing anycast routing and auto-start behaviour. + if region := os.Getenv("FLY_REGION"); region != "" { + body["region"] = region + } + writeJSON(w, http.StatusOK, body) } // startTraversal handles POST /api/traverse. func (h *Handler) startTraversal(w http.ResponseWriter, r *http.Request) { + ip := clientIP(r) + if h.limiter != nil && !rateLimitExempt(r) && !h.limiter.allow(ip) { + writeError(w, http.StatusTooManyRequests, + fmt.Sprintf("rate limit exceeded: %s per client IP", h.limiter)) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB limit var req TraverseRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -310,9 +365,20 @@ func (h *Handler) startTraversal(w http.ResponseWriter, r *http.Request) { QueryType: queryType, StartedAt: time.Now(), cancel: cancel, + clientIP: ip, } h.st.set(job) + h.webhook.send(webhookEventStart, webhookStartEvent{ + Event: webhookEventStart, + ID: job.ID, + Domain: job.Domain, + QueryType: job.QueryType, + AllRoots: req.AllRoots, + ClientIP: ip, + StartedAt: job.StartedAt, + }) + go h.runTraversal(ctx, job, req.Domain, qtype, req.AllRoots) writeJSON(w, http.StatusAccepted, TraverseStartResponse{ID: id, Status: statusRunning}) @@ -337,6 +403,7 @@ func (h *Handler) getTraversal(w http.ResponseWriter, r *http.Request) { Results []ResultItem `json:"results,omitempty"` Summary *Summary `json:"summary,omitempty"` Progress []ProgressEvent `json:"progress,omitempty"` + Servers []ServerInfo `json:"servers,omitempty"` Error string `json:"error,omitempty"` StartedAt time.Time `json:"started_at"` DoneAt *time.Time `json:"done_at,omitempty"` @@ -348,6 +415,7 @@ func (h *Handler) getTraversal(w http.ResponseWriter, r *http.Request) { Results: job.Results, Summary: job.Summary, Progress: job.Progress, + Servers: job.Servers, Error: job.Error, StartedAt: job.StartedAt, DoneAt: job.DoneAt, @@ -357,6 +425,35 @@ func (h *Handler) getTraversal(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, snapshot) } +// getServers handles GET /api/traverse/{id}/servers. It answers 202 with a +// pending body while the traversal or the post-traversal fingerprinting is +// still in flight, then the fingerprinted server list. +func (h *Handler) getServers(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + job, ok := h.st.get(id) + if !ok { + writeError(w, http.StatusNotFound, "traversal not found") + return + } + + job.mu.RLock() + done := job.serversDone + servers := job.Servers + job.mu.RUnlock() + + if !done { + writeJSON(w, http.StatusAccepted, map[string]string{"status": "pending"}) + return + } + if servers == nil { + servers = []ServerInfo{} + } + writeJSON(w, http.StatusOK, struct { + Status string `json:"status"` + Servers []ServerInfo `json:"servers"` + }{Status: "complete", Servers: servers}) +} + // streamTraversal handles GET /api/traverse/{id}/stream (SSE). func (h *Handler) streamTraversal(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") @@ -430,13 +527,21 @@ func (h *Handler) runTraversal(ctx context.Context, job *TraversalJob, domain st if r := recover(); r != nil { now := time.Now() job.mu.Lock() - job.Status = statusError - job.Error = fmt.Sprintf("panic: %v", r) - job.DoneAt = &now + if job.DoneAt == nil { + job.Status = statusError + job.Error = fmt.Sprintf("panic: %v", r) + job.DoneAt = &now + } job.mu.Unlock() } + // Never leave GET …/servers pending: fingerprinting is skipped on + // the panic path, so flip the flag here (idempotent otherwise). + job.mu.Lock() + job.serversDone = true + job.mu.Unlock() job.cancel() job.closeSubscribers() + h.reportCompletion(job) }() cfg := traverse.DefaultTraverserConfig() @@ -474,6 +579,25 @@ func (h *Handler) runTraversal(ctx context.Context, job *TraversalJob, domain st tr := traverse.NewTraverser(cfg) root, err := tr.Run(ctx, domain) + h.commitResult(ctx, job, root, err) + + // Tell streaming clients the traversal reached a terminal state so they + // can fetch results now; the stream stays open for the servers event + // published once fingerprinting (below) finishes. + job.mu.Lock() + ev := ProgressEvent{Stage: "complete", Status: job.Status} + job.Progress = append(job.Progress, ev) + job.publishLocked(ev) + job.mu.Unlock() + + // Fingerprint the servers queried during the traversal. This runs after + // the terminal status is committed, so results never wait on versions. + h.fingerprintServers(job, tr.ServersEncountered()) +} + +// commitResult stores the traversal outcome and moves the job to its +// terminal status. +func (h *Handler) commitResult(ctx context.Context, job *TraversalJob, root *traverse.Referral, err error) { now := time.Now() job.mu.Lock() defer job.mu.Unlock() @@ -507,6 +631,105 @@ func (h *Handler) runTraversal(ctx context.Context, job *TraversalJob, domain st job.Status = statusComplete } +// fingerprintServers turns the traversal's (server, ip) pairs into +// job.Servers, probing each unique IP's version.bind with bounded +// concurrency, then publishes a {"stage":"servers"} event so streaming +// clients know the list is ready without polling. Pseudo "key:" entries and +// non-address entries are skipped. +func (h *Handler) fingerprintServers(job *TraversalJob, seen map[string][]string) { + type pair struct{ name, ip string } + var pairs []pair + uniq := make(map[string]bool) + var ips []net.IP + for name, addrs := range seen { + for _, addr := range addrs { + if strings.HasPrefix(addr, "key:") { + continue + } + ip := net.ParseIP(addr) + if ip == nil { + continue + } + pairs = append(pairs, pair{name: name, ip: addr}) + if !uniq[addr] { + uniq[addr] = true + ips = append(ips, ip) + } + } + } + + versions := make(map[string]string, len(ips)) + if len(ips) > 0 && h.fp != nil { + ctx, cancel := context.WithTimeout(context.Background(), fingerprintTimeout) + defer cancel() + + var ( + wg sync.WaitGroup + mu sync.Mutex + sem = make(chan struct{}, fingerprintConcurrency) + ) + for _, ip := range ips { + wg.Add(1) + go func(ip net.IP) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + v := h.fp.Query(ctx, ip) + mu.Lock() + versions[ip.String()] = v + mu.Unlock() + }(ip) + } + wg.Wait() + } + + servers := make([]ServerInfo, 0, len(pairs)) + for _, p := range pairs { + servers = append(servers, ServerInfo{Name: p.name, IP: p.ip, Version: versions[p.ip]}) + } + sort.Slice(servers, func(i, j int) bool { + if servers[i].Name != servers[j].Name { + return servers[i].Name < servers[j].Name + } + return servers[i].IP < servers[j].IP + }) + + ev := ProgressEvent{Stage: "servers"} + job.mu.Lock() + job.Servers = servers + job.serversDone = true + job.Progress = append(job.Progress, ev) + job.publishLocked(ev) + job.mu.Unlock() +} + +// reportCompletion posts the webhook "complete" event for a job that has +// reached a terminal state. Fire-and-forget; never blocks the caller. +func (h *Handler) reportCompletion(job *TraversalJob) { + if h.webhook == nil { + return + } + job.mu.RLock() + ev := webhookCompleteEvent{ + Event: webhookEventComplete, + ID: job.ID, + Domain: job.Domain, + QueryType: job.QueryType, + ClientIP: job.clientIP, + StartedAt: job.StartedAt, + Status: job.Status, + Error: job.Error, + ResultCount: len(job.Results), + Summary: job.Summary, + } + if job.DoneAt != nil { + ev.DoneAt = *job.DoneAt + ev.DurationMS = job.DoneAt.Sub(job.StartedAt).Milliseconds() + } + job.mu.RUnlock() + h.webhook.send(webhookEventComplete, ev) +} + // envDuration reads a Go duration from the environment, falling back to // def when unset or unparsable. func envDuration(name string, def time.Duration) time.Duration { diff --git a/web/api/handler_internal_test.go b/web/api/handler_internal_test.go index c4430c6..34ac3b4 100644 --- a/web/api/handler_internal_test.go +++ b/web/api/handler_internal_test.go @@ -2,6 +2,8 @@ package api import ( "context" + "encoding/json" + "net" "net/http/httptest" "strconv" "strings" @@ -147,3 +149,171 @@ func TestSubscribeSnapshot_NoDuplicates(t *testing.T) { t.Error(msg) } } + +// fakeVersionQuerier returns canned version strings without touching the +// network. +type fakeVersionQuerier struct { + mu sync.Mutex + versions map[string]string + queried []string +} + +func (f *fakeVersionQuerier) Query(_ context.Context, ip net.IP) string { + f.mu.Lock() + defer f.mu.Unlock() + f.queried = append(f.queried, ip.String()) + return f.versions[ip.String()] +} + +// TestGetServers_PendingWhileRunning verifies the 202 pending shape while a +// job has not finished fingerprinting (running or just-completed). +func TestGetServers_PendingWhileRunning(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + h := newHandler(ctx) + + now := time.Now() + jobs := []*TraversalJob{ + {ID: "running", Status: statusRunning, StartedAt: now}, + {ID: "fingerprinting", Status: statusComplete, StartedAt: now, DoneAt: &now}, + } + for _, j := range jobs { + h.st.set(j) + } + + for _, id := range []string{"running", "fingerprinting"} { + req := httptest.NewRequest("GET", "/api/traverse/"+id+"/servers", nil) + rec := httptest.NewRecorder() + h.mux.ServeHTTP(rec, req) + + if rec.Code != 202 { + t.Fatalf("%s: want 202, got %d: %s", id, rec.Code, rec.Body.String()) + } + var body map[string]string + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("%s: decode: %v", id, err) + } + if body["status"] != "pending" { + t.Fatalf("%s: want status=pending, got %q", id, body["status"]) + } + } +} + +// TestFingerprintServers_StoresServersAndPublishes drives the fingerprint +// step with a fake querier: (server, ip) pairs become sorted job.Servers with +// versions, pseudo/invalid entries are skipped, a {"stage":"servers"} event +// is published, and the endpoint flips from pending to the final list. +func TestFingerprintServers_StoresServersAndPublishes(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + h := newHandler(ctx) + fake := &fakeVersionQuerier{versions: map[string]string{ + "192.0.2.1": "TestDNS 1.0", + "192.0.2.2": "", + }} + h.fp = fake + + now := time.Now() + job := &TraversalJob{ID: "j", Status: statusComplete, StartedAt: now, DoneAt: &now} + h.st.set(job) + sub, _, _ := job.subscribeSnapshot() + defer job.unsubscribe(sub) + + h.fingerprintServers(job, map[string][]string{ + "b.example.net": {"192.0.2.2"}, + "a.example.net": {"192.0.2.1", "key:pseudo:entry", "not-an-ip"}, + }) + + want := []ServerInfo{ + {Name: "a.example.net", IP: "192.0.2.1", Version: "TestDNS 1.0"}, + {Name: "b.example.net", IP: "192.0.2.2", Version: ""}, + } + job.mu.RLock() + got := append([]ServerInfo(nil), job.Servers...) + done := job.serversDone + job.mu.RUnlock() + if !done { + t.Fatal("serversDone not set") + } + if len(got) != len(want) { + t.Fatalf("servers = %+v, want %+v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("servers[%d] = %+v, want %+v", i, got[i], want[i]) + } + } + + select { + case ev := <-sub: + if ev.Stage != "servers" { + t.Fatalf("published stage = %q, want servers", ev.Stage) + } + default: + t.Fatal("no servers event published") + } + + // Endpoint now serves the final list. + req := httptest.NewRequest("GET", "/api/traverse/j/servers", nil) + rec := httptest.NewRecorder() + h.mux.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String()) + } + var body struct { + Status string `json:"status"` + Servers []ServerInfo `json:"servers"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Status != "complete" || len(body.Servers) != 2 { + t.Fatalf("body = %+v", body) + } + + // Snapshot includes servers too. + req = httptest.NewRequest("GET", "/api/traverse/j", nil) + rec = httptest.NewRecorder() + h.mux.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("snapshot: want 200, got %d", rec.Code) + } + var snap struct { + Servers []ServerInfo `json:"servers"` + } + if err := json.NewDecoder(rec.Body).Decode(&snap); err != nil { + t.Fatal(err) + } + if len(snap.Servers) != 2 { + t.Fatalf("snapshot servers = %+v, want 2 entries", snap.Servers) + } +} + +// TestFingerprintServers_EmptySeen still terminates the pending state and +// publishes the servers event for traversals that recorded no servers. +func TestFingerprintServers_EmptySeen(t *testing.T) { + h := &Handler{st: newStore()} + job := &TraversalJob{ID: "e", Status: statusError} + h.st.set(job) + sub, _, _ := job.subscribeSnapshot() + defer job.unsubscribe(sub) + + h.fingerprintServers(job, nil) + + job.mu.RLock() + defer job.mu.RUnlock() + if !job.serversDone { + t.Fatal("serversDone not set") + } + if len(job.Servers) != 0 { + t.Fatalf("servers = %+v, want empty", job.Servers) + } + select { + case ev := <-sub: + if ev.Stage != "servers" { + t.Fatalf("published stage = %q, want servers", ev.Stage) + } + default: + t.Fatal("no servers event published") + } +} diff --git a/web/api/handler_test.go b/web/api/handler_test.go index c30a06e..18a6361 100644 --- a/web/api/handler_test.go +++ b/web/api/handler_test.go @@ -29,6 +29,7 @@ func newTestServer(t *testing.T) *api.Server { } func TestHealth(t *testing.T) { + t.Setenv("FLY_REGION", "") // ensure region is absent regardless of host env srv := newTestServer(t) defer srv.Shutdown(5 * time.Second) //nolint:errcheck @@ -48,6 +49,55 @@ func TestHealth(t *testing.T) { if body["status"] != "ok" { t.Fatalf("want status=ok, got %q", body["status"]) } + if body["version"] != "dev" { + t.Fatalf("want version=dev, got %q", body["version"]) + } + if region, ok := body["region"]; ok { + t.Fatalf("region should be omitted outside Fly, got %q", region) + } +} + +func TestHealthReportsFlyRegion(t *testing.T) { + t.Setenv("FLY_REGION", "syd") + srv := newTestServer(t) + defer srv.Shutdown(5 * time.Second) //nolint:errcheck + + resp, err := http.Get("http://" + srv.Addr() + "/api/health") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + var body map[string]string + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["region"] != "syd" { + t.Fatalf("want region=syd, got %q", body["region"]) + } +} + +func TestHealthReportsStampedVersion(t *testing.T) { + srv := api.NewServer("127.0.0.1:0") + srv.SetVersion("v1.2.3") + if err := srv.Start(); err != nil { + t.Fatalf("start server: %v", err) + } + defer srv.Shutdown(5 * time.Second) //nolint:errcheck + + resp, err := http.Get("http://" + srv.Addr() + "/api/health") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + var body map[string]string + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["version"] != "v1.2.3" { + t.Fatalf("want version=v1.2.3, got %q", body["version"]) + } } func TestCORSDisabledByDefault(t *testing.T) { @@ -205,6 +255,95 @@ func TestGetTraversal_Found(t *testing.T) { } } +func TestGetServers_NotFound(t *testing.T) { + srv := newTestServer(t) + defer srv.Shutdown(5 * time.Second) //nolint:errcheck + + resp, err := http.Get("http://" + srv.Addr() + "/api/traverse/does-not-exist/servers") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("want 404, got %d", resp.StatusCode) + } +} + +// TestGetServers_AvailableAfterCompletion drives a full job through the API: +// the servers endpoint answers 202 pending while the traversal/fingerprinting +// is in flight and the fingerprinted list once everything finished. +func TestGetServers_AvailableAfterCompletion(t *testing.T) { + srv := newTestServer(t) + defer srv.Shutdown(5 * time.Second) //nolint:errcheck + + startBody := bytes.NewBufferString(`{"domain":"example.com","type":"A"}`) + startResp, err := http.Post("http://"+srv.Addr()+"/api/traverse", "application/json", startBody) + if err != nil { + t.Fatal(err) + } + defer startResp.Body.Close() + + var start struct { + ID string `json:"id"` + } + if err := json.NewDecoder(startResp.Body).Decode(&start); err != nil { + t.Fatal(err) + } + + deadline := time.Now().Add(90 * time.Second) + for { + resp, err := http.Get("http://" + srv.Addr() + "/api/traverse/" + start.ID + "/servers") + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + t.Fatal(err) + } + + switch resp.StatusCode { + case http.StatusAccepted: + var pending struct { + Status string `json:"status"` + } + if err := json.Unmarshal(body, &pending); err != nil { + t.Fatalf("pending body: %v (%s)", err, body) + } + if pending.Status != "pending" { + t.Fatalf("want status=pending, got %q", pending.Status) + } + case http.StatusOK: + var done struct { + Status string `json:"status"` + Servers []struct { + Name string `json:"name"` + IP string `json:"ip"` + Version string `json:"version"` + } `json:"servers"` + } + if err := json.Unmarshal(body, &done); err != nil { + t.Fatalf("servers body: %v (%s)", err, body) + } + if done.Status != "complete" { + t.Fatalf("want status=complete, got %q", done.Status) + } + if done.Servers == nil { + t.Fatalf("servers key missing or null: %s", body) + } + return + default: + t.Fatalf("unexpected status %d: %s", resp.StatusCode, body) + } + + if time.Now().After(deadline) { + t.Fatal("timed out waiting for servers to become available") + } + time.Sleep(250 * time.Millisecond) + } +} + func TestStreamTraversal_NotFound(t *testing.T) { srv := newTestServer(t) defer srv.Shutdown(5 * time.Second) //nolint:errcheck @@ -322,6 +461,71 @@ func TestStaticSPA_TypeOptions(t *testing.T) { } } +// TestStaticSPA_DetailTree asserts the SPA ships the live detail tree with +// its resolve-subtree toggle markup, plus the raw-log fallback feed so the +// old flat progress view is still reachable for debugging. +func TestStaticSPA_DetailTree(t *testing.T) { + srv := newTestServer(t) + defer srv.Shutdown(5 * time.Second) //nolint:errcheck + + resp, err := http.Get("http://" + srv.Addr() + "/") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + page := string(body) + + for _, want := range []string{ + `id="detailTree"`, // detail-tree container + `resolve-toggle`, // per-node show/hide resolve markup + `show resolve`, // toggle wording mirrors dns.squish.net + `id="progressFeed"`, // raw-log fallback feed still present + `id="rawToggle"`, // toggle that reveals it + } { + if !strings.Contains(page, want) { + t.Errorf("index.html missing %q", want) + } + } +} + +// TestStaticSPA_ServersSection asserts the SPA ships the server map/table +// section: Leaflet lazy-loaded from unpkg, geojs.io client-side geolocation, +// and the reference-style table headings. +func TestStaticSPA_ServersSection(t *testing.T) { + srv := newTestServer(t) + defer srv.Shutdown(5 * time.Second) //nolint:errcheck + + resp, err := http.Get("http://" + srv.Addr() + "/") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + page := string(body) + + for _, want := range []string{ + `unpkg.com/leaflet@1.9`, // map library CDN + `get.geojs.io`, // client-side geolocation service + `id="serversCard"`, + `id="serverMap"`, + `