Files
ExploreDNS/web/api/handler_internal_test.go
T
Gary HansenandClaude Fable 5 111b8bf48e 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>
2026-07-07 21:50:29 +10:00

150 lines
3.9 KiB
Go

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).
// Events are numbered, so any duplicate breaks strict monotonicity. Slow
// subscribers may legitimately drop events (publishLocked is non-blocking),
// so gaps are not an error.
func TestSubscribeSnapshot_NoDuplicates(t *testing.T) {
job := &TraversalJob{Status: statusRunning}
const total = 2000
const subscribers = 8
var pub sync.WaitGroup
pub.Add(1)
go func() {
defer pub.Done()
for i := 0; i < total; i++ {
ev := ProgressEvent{RefID: strconv.Itoa(i)}
job.mu.Lock()
job.Progress = append(job.Progress, ev)
job.publishLocked(ev)
job.mu.Unlock()
}
job.mu.Lock()
job.Status = statusComplete
job.mu.Unlock()
job.closeSubscribers()
}()
var subs sync.WaitGroup
errs := make(chan string, subscribers)
for s := 0; s < subscribers; s++ {
subs.Add(1)
go func() {
defer subs.Done()
sub, past, done := job.subscribeSnapshot()
defer job.unsubscribe(sub)
last := -1
check := func(refid string) {
n, err := strconv.Atoi(refid)
if err != nil {
errs <- "bad refid " + refid
return
}
if n <= last {
errs <- "event " + refid + " out of order or duplicated after " + strconv.Itoa(last)
return
}
last = n
}
for _, ev := range past {
check(ev.RefID)
}
if !done {
for ev := range sub {
check(ev.RefID)
}
}
}()
}
pub.Wait()
subs.Wait()
close(errs)
for msg := range errs {
t.Error(msg)
}
}