feat(web): harden server for public exposure

- hard per-traversal deadline (EXPLOREDNS_JOB_TIMEOUT, default 5m) so
  every job reaches a terminal state; timed-out jobs report error with
  any partial results instead of masquerading as complete
- cap concurrent traversals (EXPLOREDNS_MAX_JOBS, default 8) returning
  429 when saturated
- CORS off by default (the embedded SPA is same-origin); opt in via
  EXPLOREDNS_CORS_ORIGIN

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Gary Hansen
2026-07-07 21:50:29 +10:00
co-authored by Claude Fable 5
parent d71c7fbef2
commit 111b8bf48e
4 changed files with 178 additions and 9 deletions
+72
View File
@@ -1,11 +1,83 @@
package api
import (
"context"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"time"
)
// TestStartTraversalJobCap verifies that new traversals are rejected with
// 429 once maxRunning jobs are in flight. The store is pre-filled with
// running jobs so no real traversal is spawned.
func TestStartTraversalJobCap(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
h := newHandler(ctx)
h.maxRunning = 2
for i := 0; i < 2; i++ {
h.st.set(&TraversalJob{ID: strconv.Itoa(i), Status: statusRunning, StartedAt: time.Now()})
}
req := httptest.NewRequest("POST", "/api/traverse",
strings.NewReader(`{"domain":"example.com"}`))
rec := httptest.NewRecorder()
h.mux.ServeHTTP(rec, req)
if rec.Code != 429 {
t.Fatalf("want 429 with %d running jobs, got %d: %s", h.st.runningCount(), rec.Code, rec.Body.String())
}
// A finished job frees a slot: runningCount must drop below the cap.
done := time.Now()
if j, ok := h.st.get("0"); ok {
j.mu.Lock()
j.Status = statusComplete
j.DoneAt = &done
j.mu.Unlock()
}
if got := h.st.runningCount(); got != 1 {
t.Fatalf("runningCount after completion = %d, want 1", got)
}
}
// TestJobTimeoutReachesTerminalState verifies that a traversal launched
// with an already-expired deadline still drives the job to a terminal
// error state (the TTL cleanup only ever purges finished jobs).
func TestJobTimeoutReachesTerminalState(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond)
defer cancel()
<-ctx.Done() // deadline already exceeded
h := &Handler{st: newStore(), jobTimeout: time.Nanosecond}
job := &TraversalJob{ID: "t", Status: statusRunning, StartedAt: time.Now(), cancel: cancel}
h.st.set(job)
doneCh := make(chan struct{})
go func() {
h.runTraversal(ctx, job, "example.com", 1, false)
close(doneCh)
}()
select {
case <-doneCh:
case <-time.After(30 * time.Second):
t.Fatal("runTraversal did not return with an expired context")
}
job.mu.RLock()
defer job.mu.RUnlock()
if job.DoneAt == nil {
t.Fatal("job never reached a terminal state")
}
if job.Status != statusError || !strings.Contains(job.Error, "timed out") {
t.Fatalf("want error status mentioning timeout, got status=%q error=%q", job.Status, job.Error)
}
}
// TestSubscribeSnapshot_NoDuplicates regresses the subscribe/snapshot race:
// subscribers arriving while events are being published must never see the
// same event twice (once from the snapshot replay and once from the channel).