Skip to content

Commit 172d5b6

Browse files
committed
test(rpz): the coverage-gap round — real paths, not line chasing
A gap analysis over the arc's per-package coverage, keeping only the gaps that name genuine behavior: - The AXFR feed's schedule loop had never actually run — every test hand-drove cycles. It now runs live: the first transfer lands without help and a context cancel ends the loop. - The neutral global gate — what un-wrapped, exempt queries reach — had no contract pin: a matching sidecar still serves them the truth, a stale one still restamps, and its counters never move. - The response wrap's DROP, TCP-Only, NODATA, and Local Data arms were never driven end-to-end; only NXDOMAIN and PASSTHRU were. All four now serve over real resolutions, miss and hit, including TCP-Only passing the truth on a non-UDP transport and Local Data answering on the client's qname — and the DROP swallow and the transport check are mutation-verified. - The decoded (Msg-born) request path had never been exercised in this middleware's tests; wire-born and decoded requests now provably reach the same verdicts. - Engine: the response trigger's shared filing semantics (Local Data merge, conflict, bare marker, unknown action), multi-zone fold ordering, and the trigger rank ladder get direct pins. The engine functions still showing 0% in per-package numbers — the loaders, ParseTSIGKey, the store predicates — are covered from the middleware and config packages' tests; in-package smoke for them would be line chasing. internal/rpz 76.8% → 79.7%, middleware/rpz 81.0% → 88.1%.
1 parent 3e171d2 commit 172d5b6

2 files changed

Lines changed: 258 additions & 0 deletions

File tree

internal/rpz/responseip_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,3 +315,66 @@ func TestFoldResponseLists(t *testing.T) {
315315
t.Fatalf("fold is order-sensitive: %+v", folded)
316316
}
317317
}
318+
319+
// TestInsertResponseIPSharedSemantics pins that the response trigger
320+
// inherited the address-rule filing discipline whole: Local Data merges
321+
// at one prefix, a conflicting action class is skipped, the bare marker
322+
// owns no address, and an unknown rpz-* action is skipped.
323+
func TestInsertResponseIPSharedSemantics(t *testing.T) {
324+
z, err := LoadZone("sem", strings.NewReader(`
325+
rpz.test. IN SOA ns.rpz.test. admin.rpz.test. 1 3600 900 604800 300
326+
24.0.2.0.192.rpz-ip.rpz.test. IN A 203.0.113.1
327+
24.0.2.0.192.rpz-ip.rpz.test. IN A 203.0.113.2
328+
24.0.2.0.192.rpz-ip.rpz.test. IN CNAME .
329+
rpz-ip.rpz.test. IN CNAME .
330+
32.9.9.9.9.rpz-ip.rpz.test. IN CNAME rpz-bogus.
331+
`), "sem.zone", OverrideGiven, "")
332+
if err != nil {
333+
t.Fatal(err)
334+
}
335+
if z.RulesResponseIP != 1 {
336+
t.Fatalf("RulesResponseIP = %d, want 1 (skips: %v)", z.RulesResponseIP, z.Skipped)
337+
}
338+
s := &Store{Zones: []*Zone{z}}
339+
rm := s.EvaluateResponse([]dns.RR{answerA("192.0.2.9")})
340+
if len(rm.List) != 1 || rm.List[0].Action != ActionLocalData || len(rm.List[0].Local) != 2 {
341+
t.Fatalf("local-data merge broken: %+v", rm.List)
342+
}
343+
want := map[string]int{SkipConflict: 1, SkipOwnerEncoding: 1, SkipUnknownAction: 1}
344+
for reason, n := range want {
345+
if z.Skipped[reason] != n {
346+
t.Fatalf("skip %s = %d, want %d (all: %v)", reason, z.Skipped[reason], n, z.Skipped)
347+
}
348+
}
349+
}
350+
351+
// TestFoldOrdersAcrossZones pins the fold's ordering with more than one
352+
// zone: whatever order the segment lists arrive in, the folded output is
353+
// ascending by zone index — the shape Merge's single walk requires.
354+
func TestFoldOrdersAcrossZones(t *testing.T) {
355+
z0 := loadResponseIPZone(t)
356+
z1 := loadResponseIPZone(t)
357+
s := &Store{Zones: []*Zone{z0, z1}}
358+
359+
both := s.EvaluateResponseList([]dns.RR{answerA("192.0.2.1")}) // matches zones 0 and 1
360+
onlyLater := []ResponseMatch{both[1]}
361+
onlyEarlier := []ResponseMatch{both[0]}
362+
363+
folded := FoldResponseLists(onlyLater, onlyEarlier)
364+
if len(folded) != 2 || folded[0].ZoneIdx != 0 || folded[1].ZoneIdx != 1 {
365+
t.Fatalf("fold output not ascending by zone: %+v", folded)
366+
}
367+
}
368+
369+
// TestTriggerRankOrdering pins precedence rule 2's ladder directly.
370+
func TestTriggerRankOrdering(t *testing.T) {
371+
if triggerRank(TriggerClientIP) <= triggerRank(TriggerQNAME) {
372+
t.Fatal("CLIENT-IP must outrank QNAME")
373+
}
374+
if triggerRank(TriggerQNAME) <= triggerRank(TriggerResponseIP) {
375+
t.Fatal("QNAME must outrank IP")
376+
}
377+
if triggerRank(TriggerResponseIP) <= triggerRank("nonsense") {
378+
t.Fatal("IP must outrank the unknown")
379+
}
380+
}
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
package rpz
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
8+
"github.com/miekg/dns"
9+
"github.com/prometheus/client_golang/prometheus/testutil"
10+
"github.com/semihalev/sdns/config"
11+
"github.com/semihalev/sdns/internal/mock"
12+
rpzengine "github.com/semihalev/sdns/internal/rpz"
13+
"github.com/semihalev/sdns/middleware"
14+
)
15+
16+
// The coverage-gap round: paths the arc's tests drove only indirectly
17+
// or not at all — the feed's real lifecycle loop, the neutral global
18+
// gate's exempt-query contract, the response wrap's remaining actions,
19+
// and the decoded request path.
20+
21+
// serveProto is serve with the transport under test.
22+
func (h *sidecarHarness) serveProto(t *testing.T, qname, proto string) *mock.Writer {
23+
t.Helper()
24+
q := new(dns.Msg)
25+
q.SetQuestion(qname, dns.TypeA)
26+
q.RecursionDesired = true
27+
raw, err := q.Pack()
28+
if err != nil {
29+
t.Fatal(err)
30+
}
31+
req := new(middleware.Request)
32+
if !req.ParseWire(raw, time.Now(), nil) {
33+
t.Fatal("refused")
34+
}
35+
w := mock.NewWriter(proto, "192.0.2.1:40000")
36+
ch := middleware.NewChain([]middleware.Handler{h.r, h.c, h.authority()})
37+
ch.ResetWire(w, req)
38+
ch.AllowDirectPack()
39+
ch.Next(context.Background())
40+
return w
41+
}
42+
43+
// TestResponseActionsEndToEnd drives the wrap's remaining actions over
44+
// real resolutions: DROP swallows the reply, TCP-Only truncates UDP and
45+
// passes other transports, NODATA empties the answer, and Local Data
46+
// answers with the rule's records on the client's qname.
47+
func TestResponseActionsEndToEnd(t *testing.T) {
48+
const actionZone = `
49+
rpz.test. IN SOA ns.rpz.test. admin.rpz.test. 1 3600 900 604800 300
50+
24.0.113.0.203.rpz-ip.rpz.test. IN CNAME rpz-drop.
51+
24.0.100.51.198.rpz-ip.rpz.test. IN CNAME rpz-tcp-only.
52+
24.0.2.0.192.rpz-ip.rpz.test. IN CNAME *.
53+
16.0.0.0.10.rpz-ip.rpz.test. IN A 203.0.113.53
54+
`
55+
h := newSidecarHarness(t, "enforce", config.RPZZone{Name: "act", File: writeZone(t, actionZone)})
56+
h.truth["dropme.test."] = "203.0.113.5"
57+
h.truth["tcpme.test."] = "198.51.100.5"
58+
h.truth["nodatame.test."] = "192.0.2.5"
59+
h.truth["localme.test."] = "10.0.1.2"
60+
61+
for _, pass := range []string{"miss", "hit"} {
62+
if w := h.serve(t, "dropme.test.", "192.0.2.1:40000", true); w.Written() {
63+
t.Fatalf("%s: DROP wrote a reply", pass)
64+
}
65+
w := h.serve(t, "tcpme.test.", "192.0.2.1:40000", true)
66+
if !w.Written() || !w.Msg().Truncated || len(w.Msg().Answer) != 0 {
67+
t.Fatalf("%s: TCP-Only over UDP must truncate emptily: tc=%v answers=%d", pass, w.Msg().Truncated, len(w.Msg().Answer))
68+
}
69+
w = h.serve(t, "nodatame.test.", "192.0.2.1:40000", true)
70+
if w.Rcode() != dns.RcodeSuccess || len(w.Msg().Answer) != 0 || w.Msg().AuthenticatedData {
71+
t.Fatalf("%s: NODATA broken: rcode=%d answers=%d", pass, w.Rcode(), len(w.Msg().Answer))
72+
}
73+
w = h.serve(t, "localme.test.", "192.0.2.1:40000", true)
74+
if w.Rcode() != dns.RcodeSuccess || len(w.Msg().Answer) != 1 {
75+
t.Fatalf("%s: Local Data broken: rcode=%d answers=%d", pass, w.Rcode(), len(w.Msg().Answer))
76+
}
77+
a := w.Msg().Answer[0].(*dns.A)
78+
if a.Hdr.Name != "localme.test." || a.A.String() != "203.0.113.53" {
79+
t.Fatalf("%s: Local Data owner/rdata: %v", pass, a)
80+
}
81+
}
82+
83+
// A non-UDP transport already satisfies TCP-Only: the truth flows.
84+
w := h.serveProto(t, "tcpme.test.", "tcp")
85+
if w.Rcode() != dns.RcodeSuccess || len(w.Msg().Answer) != 1 || w.Msg().Truncated {
86+
t.Fatalf("TCP-Only over TCP must pass the truth: rcode=%d tc=%v", w.Rcode(), w.Msg().Truncated)
87+
}
88+
}
89+
90+
// TestNeutralGateServesExemptQueriesUntouched pins the global gate's
91+
// contract — the gate un-wrapped (exempt) queries reach: entries stay
92+
// healthy (stale restamps) but even a matching sidecar serves the truth
93+
// and counts nothing, because policy does not apply to those queries.
94+
func TestNeutralGateServesExemptQueriesUntouched(t *testing.T) {
95+
h := newSidecarHarness(t, "enforce", config.RPZZone{Name: "resp", File: writeZone(t, respTestZone)})
96+
s := h.r.store.Load()
97+
98+
matching := &middleware.Sidecar{Value: &rpzengine.ResponseMatches{
99+
Gen: s.Gen,
100+
List: s.EvaluateResponseList([]dns.RR{testA("x.test.", "203.0.113.10")}),
101+
}}
102+
if v := h.r.JudgeWireHit(matching); v != middleware.WireHitServe {
103+
t.Fatalf("a matching entry must still serve an exempt query: %v", v)
104+
}
105+
if v := h.r.JudgeWireHit(nil); v != middleware.WireHitRestamp {
106+
t.Fatalf("an unevaluated entry must restamp: %v", v)
107+
}
108+
stale := &middleware.Sidecar{Value: &rpzengine.ResponseMatches{Gen: s.Gen - 1}}
109+
if v := h.r.JudgeWireHit(stale); v != middleware.WireHitRestamp {
110+
t.Fatalf("a stale entry must restamp: %v", v)
111+
}
112+
113+
var chain middleware.SidecarChain
114+
chain.Append(matching)
115+
if v := h.r.JudgeWireChase(chain); v != middleware.WireHitServe {
116+
t.Fatalf("chase with fresh segments must serve: %v", v)
117+
}
118+
var staleChain middleware.SidecarChain
119+
staleChain.Append(stale)
120+
if v := h.r.JudgeWireChase(staleChain); v != middleware.WireHitRestamp {
121+
t.Fatalf("chase with a stale segment must restamp: %v", v)
122+
}
123+
124+
// Counting nothing is the whole point.
125+
counter := actionTotal.WithLabelValues("resp", rpzengine.TriggerResponseIP, "nxdomain", "enforced")
126+
base := testutil.ToFloat64(counter)
127+
h.r.CountWireHit(matching)
128+
h.r.CountWireChase(chain)
129+
if d := testutil.ToFloat64(counter) - base; d != 0 {
130+
t.Fatalf("the neutral gate counted an exempt query: %v", d)
131+
}
132+
133+
// Without response rules everything serves, whatever the sidecar.
134+
bare := newRPZ(t, "enforce", config.RPZZone{Name: "plain", File: writeZone(t, testZone)})
135+
if v := bare.JudgeWireHit(nil); v != middleware.WireHitServe {
136+
t.Fatalf("no response rules must mean Serve: %v", v)
137+
}
138+
if v := bare.WireHitGate().JudgeWireChase(middleware.SidecarChain{}); v != middleware.WireHitServe {
139+
t.Fatalf("no response rules must mean Serve for chases too: %v", v)
140+
}
141+
}
142+
143+
// TestDecodedRequestsSeeTheSamePolicy drives the Msg-born request path
144+
// (ch.Reset, not ResetWire): the decoded key build must reach the same
145+
// verdicts the wire-born path does.
146+
func TestDecodedRequestsSeeTheSamePolicy(t *testing.T) {
147+
r := testRPZ(t, "enforce")
148+
149+
q := new(dns.Msg)
150+
q.SetQuestion("nx.example.com.", dns.TypeA)
151+
q.RecursionDesired = true
152+
passed := false
153+
next := middleware.HandlerFunc(func(_ context.Context, ch *middleware.Chain) {
154+
passed = true
155+
ch.Cancel()
156+
})
157+
w := mock.NewWriter("udp", "192.0.2.1:40000")
158+
ch := middleware.NewChain([]middleware.Handler{r, next})
159+
ch.Reset(w, q)
160+
ch.Next(context.Background())
161+
162+
if passed || !w.Written() || w.Rcode() != dns.RcodeNameError {
163+
t.Fatalf("decoded request escaped policy: passed=%v rcode=%d", passed, w.Rcode())
164+
}
165+
}
166+
167+
// TestAXFRFeedRunLoopLifecycle runs the feed's real schedule loop: the
168+
// first transfer lands without being hand-driven, and cancelling the
169+
// context ends the loop.
170+
func TestAXFRFeedRunLoopLifecycle(t *testing.T) {
171+
srv := startFeedServer(t, "", "")
172+
r, feed := feedUnderTest(t, srv, "")
173+
174+
ctx, cancel := context.WithCancel(context.Background())
175+
done := make(chan struct{})
176+
go func() { defer close(done); feed.run(ctx) }()
177+
178+
deadline := time.Now().Add(3 * time.Second)
179+
for {
180+
if _, passed := serve(t, r, "blocked.example.com.", dns.TypeA, "udp", true); !passed {
181+
break // the first transfer landed and the rule enforces
182+
}
183+
if time.Now().After(deadline) {
184+
t.Fatal("the run loop never completed its first transfer")
185+
}
186+
time.Sleep(10 * time.Millisecond)
187+
}
188+
189+
cancel()
190+
select {
191+
case <-done:
192+
case <-time.After(2 * time.Second):
193+
t.Fatal("cancelling the context did not end the run loop")
194+
}
195+
}

0 commit comments

Comments
 (0)