Files
Gary HansenandClaude Fable 5 beb595442f feat(receiver): ingest server and admin dashboard
POST /webhook (bearer-token auth, strict validation, 1MB cap) and
/healthz stay open; everything under /admin requires basic auth
(RECEIVER_ADMIN_USER/PASSWORD, refuses to start without a password,
constant-time compares). Admin JSON APIs for the traversal log
(filters, pagination) and stats, plus an embedded dashboard: stat
cards, four Chart.js charts (lazy CDN load with graceful degradation),
filterable log with expandable summaries, auto-refresh. Fetches
resolve against location.origin so credentialed bookmark URLs work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 02:43:51 +10:00

210 lines
6.3 KiB
Go

package server
import (
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"gitea.hansenits.com.au/hits/ExploreDNS/internal/receiver/store"
)
func newTestHandler(t *testing.T, token string) (http.Handler, *store.Store) {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "receiver.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
t.Cleanup(func() { st.Close() })
return newHandler(st, "test-version", token, testAdminUser, testAdminPass), st
}
func post(h http.Handler, body string, headers map[string]string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
return w
}
const startJSON = `{"event":"start","id":"job-1","domain":"example.com","query_type":"A",` +
`"all_roots":true,"client_ip":"203.0.113.9","started_at":"2026-07-01T10:00:00Z"}`
const completeJSON = `{"event":"complete","id":"job-1","domain":"example.com","query_type":"A",` +
`"client_ip":"203.0.113.9","started_at":"2026-07-01T10:00:00Z","done_at":"2026-07-01T10:00:03Z",` +
`"duration_ms":3000,"status":"complete","result_count":2,` +
`"summary":{"answers":[{"probability":1,"records":["example.com 300 IN A 192.0.2.1"]}]}}`
func TestIngestAuthMatrix(t *testing.T) {
tests := []struct {
name string
token string
authHeader string
want int
}{
{"no token configured, no header", "", "", http.StatusNoContent},
{"no token configured, stray header", "", "Bearer whatever", http.StatusNoContent},
{"token configured, missing header", "s3cret", "", http.StatusUnauthorized},
{"token configured, wrong scheme", "s3cret", "Basic s3cret", http.StatusUnauthorized},
{"token configured, wrong token", "s3cret", "Bearer nope", http.StatusUnauthorized},
{"token configured, correct token", "s3cret", "Bearer s3cret", http.StatusNoContent},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
h, _ := newTestHandler(t, tc.token)
headers := map[string]string{}
if tc.authHeader != "" {
headers["Authorization"] = tc.authHeader
}
if got := post(h, startJSON, headers).Code; got != tc.want {
t.Errorf("status = %d, want %d", got, tc.want)
}
})
}
}
func TestIngestStartEvent(t *testing.T) {
h, st := newTestHandler(t, "")
w := post(h, startJSON, nil)
if w.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204 (%s)", w.Code, w.Body)
}
rows, total, err := st.ListTraversals(context.Background(), store.ListFilter{}, 10, 0)
if err != nil {
t.Fatalf("ListTraversals: %v", err)
}
if total != 1 {
t.Fatalf("total = %d, want 1", total)
}
row := rows[0]
if row.ID != "job-1" || row.Domain != "example.com" || row.QueryType != "A" ||
!row.AllRoots || row.ClientIP != "203.0.113.9" || row.Status != store.StatusRunning {
t.Errorf("row = %+v", row)
}
want := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC)
if !row.StartedAt.Equal(want) {
t.Errorf("StartedAt = %v, want %v", row.StartedAt, want)
}
}
func TestIngestCompleteEvent(t *testing.T) {
h, st := newTestHandler(t, "")
if w := post(h, startJSON, nil); w.Code != http.StatusNoContent {
t.Fatalf("start status = %d", w.Code)
}
if w := post(h, completeJSON, nil); w.Code != http.StatusNoContent {
t.Fatalf("complete status = %d", w.Code)
}
rows, total, err := st.ListTraversals(context.Background(), store.ListFilter{}, 10, 0)
if err != nil {
t.Fatalf("ListTraversals: %v", err)
}
if total != 1 {
t.Fatalf("total = %d, want 1", total)
}
row := rows[0]
if row.Status != store.StatusComplete {
t.Errorf("Status = %q, want complete", row.Status)
}
if row.DurationMS == nil || *row.DurationMS != 3000 {
t.Errorf("DurationMS = %v, want 3000", row.DurationMS)
}
if row.ResultCount == nil || *row.ResultCount != 2 {
t.Errorf("ResultCount = %v, want 2", row.ResultCount)
}
if !strings.Contains(row.Summary, "192.0.2.1") {
t.Errorf("Summary = %q, want raw summary JSON", row.Summary)
}
if !row.AllRoots {
t.Error("AllRoots lost after complete")
}
}
func TestIngestRejectsBadInput(t *testing.T) {
tests := []struct {
name string
body string
}{
{"malformed JSON", `{"event":`},
{"unknown event", `{"event":"pause","id":"x"}`},
{"empty event", `{"id":"x"}`},
{"wrong type for field", `{"event":"start","id":42}`},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
h, st := newTestHandler(t, "")
if got := post(h, tc.body, nil).Code; got != http.StatusBadRequest {
t.Errorf("status = %d, want 400", got)
}
_, total, err := st.ListTraversals(context.Background(), store.ListFilter{}, 10, 0)
if err != nil {
t.Fatalf("ListTraversals: %v", err)
}
if total != 0 {
t.Errorf("stored %d rows from rejected input", total)
}
})
}
}
func TestIngestBodyCap(t *testing.T) {
h, _ := newTestHandler(t, "")
big := `{"event":"start","id":"x","domain":"` + strings.Repeat("a", maxBodyBytes) + `"}`
if got := post(h, big, nil).Code; got != http.StatusRequestEntityTooLarge {
t.Errorf("status = %d, want 413", got)
}
}
func TestHealthz(t *testing.T) {
h, _ := newTestHandler(t, "s3cret")
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
// healthz stays open even when an ingest token is configured.
if !strings.Contains(body, `"status":"ok"`) || !strings.Contains(body, `"version":"test-version"`) {
t.Errorf("body = %s", body)
}
}
func TestServerStartShutdown(t *testing.T) {
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "receiver.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer st.Close()
srv := New("127.0.0.1:0", st)
srv.SetVersion("v-test")
srv.SetAdminAuth(testAdminUser, testAdminPass)
if err := srv.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
defer srv.Shutdown(2 * time.Second)
resp, err := http.Get("http://" + srv.Addr() + "/healthz")
if err != nil {
t.Fatalf("GET /healthz: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("healthz status = %d, want 200", resp.StatusCode)
}
if err := srv.Shutdown(2 * time.Second); err != nil {
t.Errorf("Shutdown: %v", err)
}
}