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:
co-authored by
Claude Fable 5
parent
d71c7fbef2
commit
111b8bf48e
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user