Dual-dialect store (SQLite via modernc.org, MySQL via go-sql-driver, both pure Go) with order-tolerant start/complete upserts, filtered and paginated listing, and aggregate queries (per-day, top domains, query types, statuses, duration percentiles, top clients). Sender gains optional EXPLOREDNS_WEBHOOK_TOKEN bearer auth; a round-trip test pins receiver structs byte-compatible with the sender payloads. Note: go directive moves to 1.25.0, required by modernc.org/sqlite. CI reads the version from go.mod so GOTOOLCHAIN=local stays satisfied. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
127 lines
3.7 KiB
Go
127 lines
3.7 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
rstore "gitea.hansenits.com.au/hits/ExploreDNS/internal/receiver/store"
|
|
)
|
|
|
|
// These tests pin the webhook payload contract between this package (the
|
|
// sender) and internal/receiver/store (the receiver): every field a sender
|
|
// struct marshals must decode into the receiver struct and vice versa.
|
|
// DisallowUnknownFields turns any renamed or missing field into a failure.
|
|
|
|
// decodeStrict unmarshals data into v, failing on unknown fields.
|
|
func decodeStrict(t *testing.T, data []byte, v any) {
|
|
t.Helper()
|
|
dec := json.NewDecoder(bytes.NewReader(data))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(v); err != nil {
|
|
t.Fatalf("decode %s into %T: %v", data, v, err)
|
|
}
|
|
}
|
|
|
|
func TestWebhookStartEventReceiverCompat(t *testing.T) {
|
|
sent := webhookStartEvent{
|
|
Event: webhookEventStart,
|
|
ID: "job-1",
|
|
Domain: "example.com",
|
|
QueryType: "AAAA",
|
|
AllRoots: true,
|
|
ClientIP: "203.0.113.9",
|
|
StartedAt: time.Date(2026, 7, 1, 10, 0, 0, 123456789, time.UTC),
|
|
}
|
|
raw, err := json.Marshal(sent)
|
|
if err != nil {
|
|
t.Fatalf("marshal sender: %v", err)
|
|
}
|
|
|
|
var got rstore.StartEvent
|
|
decodeStrict(t, raw, &got)
|
|
want := rstore.StartEvent{
|
|
Event: sent.Event,
|
|
ID: sent.ID,
|
|
Domain: sent.Domain,
|
|
QueryType: sent.QueryType,
|
|
AllRoots: sent.AllRoots,
|
|
ClientIP: sent.ClientIP,
|
|
StartedAt: sent.StartedAt,
|
|
}
|
|
if got != want {
|
|
t.Errorf("receiver decoded %+v, want %+v", got, want)
|
|
}
|
|
|
|
// Round-trip back into the sender struct so receiver-only fields
|
|
// would also fail.
|
|
back, err := json.Marshal(got)
|
|
if err != nil {
|
|
t.Fatalf("marshal receiver: %v", err)
|
|
}
|
|
var sent2 webhookStartEvent
|
|
decodeStrict(t, back, &sent2)
|
|
if sent2 != sent {
|
|
t.Errorf("sender round-trip %+v, want %+v", sent2, sent)
|
|
}
|
|
}
|
|
|
|
func TestWebhookCompleteEventReceiverCompat(t *testing.T) {
|
|
sent := webhookCompleteEvent{
|
|
Event: webhookEventComplete,
|
|
ID: "job-1",
|
|
Domain: "example.com",
|
|
QueryType: "A",
|
|
ClientIP: "203.0.113.9",
|
|
StartedAt: time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC),
|
|
DoneAt: time.Date(2026, 7, 1, 10, 0, 3, 500000000, time.UTC),
|
|
DurationMS: 3500,
|
|
Status: statusError,
|
|
Error: "traversal timed out after 5m0s",
|
|
ResultCount: 7,
|
|
Summary: &Summary{
|
|
Answers: []SummaryAnswer{{Probability: 0.75, Records: []string{"example.com 300 IN A 192.0.2.1"}}},
|
|
ByStatus: []SummaryStatus{{Status: "servfail", Probability: 0.25}},
|
|
},
|
|
}
|
|
raw, err := json.Marshal(sent)
|
|
if err != nil {
|
|
t.Fatalf("marshal sender: %v", err)
|
|
}
|
|
|
|
var got rstore.CompleteEvent
|
|
decodeStrict(t, raw, &got)
|
|
if got.Event != sent.Event || got.ID != sent.ID || got.Domain != sent.Domain ||
|
|
got.QueryType != sent.QueryType || got.ClientIP != sent.ClientIP ||
|
|
!got.StartedAt.Equal(sent.StartedAt) || !got.DoneAt.Equal(sent.DoneAt) ||
|
|
got.DurationMS != sent.DurationMS || got.Status != sent.Status ||
|
|
got.Error != sent.Error || got.ResultCount != sent.ResultCount {
|
|
t.Errorf("receiver decoded %+v, want %+v", got, sent)
|
|
}
|
|
|
|
// The receiver keeps Summary as raw JSON; it must match the sender's
|
|
// marshalled Summary byte for byte.
|
|
wantSummary, err := json.Marshal(sent.Summary)
|
|
if err != nil {
|
|
t.Fatalf("marshal summary: %v", err)
|
|
}
|
|
if !bytes.Equal(got.Summary, wantSummary) {
|
|
t.Errorf("receiver Summary = %s, want %s", got.Summary, wantSummary)
|
|
}
|
|
|
|
back, err := json.Marshal(got)
|
|
if err != nil {
|
|
t.Fatalf("marshal receiver: %v", err)
|
|
}
|
|
var sent2 webhookCompleteEvent
|
|
decodeStrict(t, back, &sent2)
|
|
back2, err := json.Marshal(sent2)
|
|
if err != nil {
|
|
t.Fatalf("re-marshal sender: %v", err)
|
|
}
|
|
if !bytes.Equal(back2, raw) {
|
|
t.Errorf("sender round-trip = %s, want %s", back2, raw)
|
|
}
|
|
}
|