package rfc2136 import ( "net" "sync" "testing" "time" "github.com/miekg/dns" ) // testNotifyListener spins up a UDP DNS-protocol listener on an // ephemeral port that captures any messages it receives. Returns the // host:port string for use as a NOTIFY target, plus a getter for the // last-captured message. func testNotifyListener(t *testing.T) (addr string, getLast func() *dns.Msg) { t.Helper() conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) if err != nil { t.Fatalf("ListenUDP: %v", err) } var mu sync.Mutex var last *dns.Msg done := make(chan struct{}) go func() { buf := make([]byte, 512) for { conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) n, _, err := conn.ReadFromUDP(buf) if err != nil { select { case <-done: return default: continue } } msg := new(dns.Msg) if unpackErr := msg.Unpack(buf[:n]); unpackErr == nil { mu.Lock() last = msg mu.Unlock() } } }() t.Cleanup(func() { close(done) conn.Close() }) return conn.LocalAddr().String(), func() *dns.Msg { mu.Lock() defer mu.Unlock() return last } } func TestSendNotify_DeliversToTarget(t *testing.T) { addr, getLast := testNotifyListener(t) sendNotify("auth.example.com", []string{addr}) // Wait up to 1s for the packet to arrive (test listener polls on // 500ms deadline). The send goroutine writes immediately; the // listener loop just needs one read cycle to pick it up. deadline := time.Now().Add(1 * time.Second) for time.Now().Before(deadline) { if msg := getLast(); msg != nil { if msg.Opcode != dns.OpcodeNotify { t.Errorf("Opcode = %d, want OpcodeNotify (%d)", msg.Opcode, dns.OpcodeNotify) } if len(msg.Question) != 1 || msg.Question[0].Name != "auth.example.com." { t.Errorf("Question = %+v, want one entry with name auth.example.com.", msg.Question) } if !msg.Authoritative { t.Errorf("AA flag not set on NOTIFY") } return } time.Sleep(20 * time.Millisecond) } t.Fatal("NOTIFY never arrived at target within 1s") } func TestSendNotify_NoTargets_NoCrash(t *testing.T) { // Empty target list must short-circuit without launching goroutines // or panicking. sendNotify("auth.example.com", nil) sendNotify("auth.example.com", []string{}) // No assertions — survival is the test. } func TestSendNotify_BadTarget_LogsButDoesNotBlock(t *testing.T) { // Target a port we know nothing listens on. The fire-and-forget // send must return immediately; the goroutine eventually times out. start := time.Now() sendNotify("auth.example.com", []string{"127.0.0.1:1"}) if elapsed := time.Since(start); elapsed > 100*time.Millisecond { t.Errorf("sendNotify blocked %v on unreachable target; expected fire-and-forget", elapsed) } } func TestNotifyOne_AppendsDefaultPort(t *testing.T) { // Spin up a listener on 127.0.0.1:, then call notifyOne // with both forms (bare host + host:port) and verify both deliver. addr, getLast := testNotifyListener(t) host, port, err := net.SplitHostPort(addr) if err != nil { t.Fatalf("split: %v", err) } _ = host // Form 1: host:port (the normal case). notifyOne("first.example.com", addr) time.Sleep(100 * time.Millisecond) if m := getLast(); m == nil || len(m.Question) == 0 || m.Question[0].Name != "first.example.com." { t.Errorf("host:port form did not deliver: %+v", m) } // We can't easily test the bare-host case because port 53 is the // default and we can't bind there without root. Verifying the // defaulting branch directly is sufficient. if port == "" { t.Fatal("unreachable: SplitHostPort returned empty port") } }