feat(receiver): storage layer and webhook payload compat
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d96f25b1d6
commit
e8f56a1ef4
@@ -0,0 +1,126 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user