- 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>
150 lines
3.9 KiB
Go
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)
|
|
}
|
|
}
|