Files
fancy-dns/internal/app/integration_test.go
T
2026-09-28 10:37:15 +00:00

1087 lines
37 KiB
Go

package app
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/miekg/dns"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// TestZoneConsoleRoundTrip is the console's zone page, end to end: a record
// added through the admin API is answered by this resolver on the next query,
// with no reload and no restart, because a zone the console created is
// compiled into the same store the operator's master files go into.
func TestZoneConsoleRoundTrip(t *testing.T) {
managed := filepath.Join(t.TempDir(), "zones")
h := newHarness(t, func(c *config.Config) { c.Block.ManagedZones = managed })
do := func(method, path, body string) (int, string) {
t.Helper()
code, out := h.do(method, path, body)
return code, string(out)
}
mustWrite := func(method, path, body string) string {
t.Helper()
code, out := do(method, path, body)
if code != http.StatusOK {
t.Fatalf("%s %s = %d: %s", method, path, code, out)
}
return out
}
// Before anything is written the page is empty but honest: no zones, the
// managed directory named, and the types the API accepts so the console
// builds its picker from the server.
_, body := h.get("/api/zones")
var empty zonesPayload
if err := json.Unmarshal(body, &empty); err != nil {
t.Fatalf("zones: %v: %s", err, body)
}
// The operator's own zone is listed before the console has written
// anything, and it is not the console's to edit.
if len(empty.Zones) != 1 || empty.Zones[0].Origin != "local." {
t.Fatalf("zones = %+v, want just the operator's local. zone", empty.Zones)
}
if empty.Zones[0].Managed {
t.Error("the operator's zone is reported as managed")
}
if empty.ManagedDir != managed {
t.Errorf("managed_dir = %q, want %q", empty.ManagedDir, managed)
}
if !empty.Writable {
t.Error("writable = false, but the harness's temp dir is writable")
}
if len(empty.Types) == 0 {
t.Error("types is empty, so the console has no record types to offer")
}
// A zone starts with a server list: without one it cannot say NXDOMAIN,
// and the writer refuses to invent it.
mustWrite(http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"console.test.","type":"NS","value":"ns1.console.test."}`)
mustWrite(http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"www.console.test.","type":"A","ttl":60,"value":"10.9.9.9"}`)
m := h.query("www.console.test", dns.TypeA)
if len(m.Answer) != 1 {
t.Fatalf("query www.console.test = %v, want one answer", m)
}
a, ok := m.Answer[0].(*dns.A)
if !ok || a.A.String() != "10.9.9.9" {
t.Errorf("www.console.test A = %v, want the console's record, not the upstream's", m.Answer[0])
}
if m.Authoritative != true {
t.Error("the local answer is not authoritative")
}
// The file is on disk where the operator can read it, and it is a zone
// file rather than a JSON dump.
raw, err := os.ReadFile(filepath.Join(managed, "console.test.zone"))
if err != nil {
t.Fatalf("read the managed zone file: %v", err)
}
for _, want := range []string{"$ORIGIN console.test.", "IN SOA", "IN NS", "10.9.9.9"} {
if !strings.Contains(string(raw), want) {
t.Errorf("the zone file is missing %q:\n%s", want, raw)
}
}
// A delegation is a cut: the parent stops answering below it and the
// query goes upstream, which is the whole point of making one.
mustWrite(http.MethodPost, "/api/zones/delegations", `{"zone":"console.test.","name":"sub.console.test.","targets":["ns1.sub.console.test."]}`)
m = h.query("www.sub.console.test", dns.TypeA)
if len(m.Answer) != 1 {
t.Fatalf("query below the cut = %v, want the upstream answer", m)
}
if a, ok := m.Answer[0].(*dns.A); !ok || a.A.String() != "203.0.113.7" {
t.Errorf("below the cut = %v, want the upstream's 203.0.113.7", m.Answer[0])
}
// And the cut itself is answerable, or no resolver could ever learn it.
m = h.query("sub.console.test", dns.TypeNS)
if len(m.Answer) != 1 {
t.Errorf("NS at the cut = %v, want the delegation itself", m)
}
// A record below a cut is dead data, so it is refused rather than
// written and silently never answered.
if code, _ := do(http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"host.sub.console.test.","type":"A","value":"10.9.9.10"}`); code != http.StatusBadRequest {
t.Errorf("a record below a delegation = %d, want 400", code)
}
// Deleting the record leaves the zone authoritative, and a zone that
// exists says NXDOMAIN for the names it does not hold. That is what
// creating a zone means, and it is why deleting a zone is a separate
// action below rather than a side effect of deleting a record.
mustWrite(http.MethodDelete, "/api/zones/records?zone=console.test.&name=www.console.test.&type=A", "")
m = h.query("www.console.test", dns.TypeA)
if m.Rcode != dns.RcodeNameError || len(m.Answer) != 0 {
t.Errorf("query after the record was deleted = %v, want NXDOMAIN from the zone", m)
}
if m.Authoritative != true {
t.Error("the NXDOMAIN is not authoritative, so the zone is not serving")
}
// Deleting something that is not there is a 404, not a 500.
if code, _ := do(http.MethodDelete, "/api/zones/records?zone=console.test.&name=nope.console.test.&type=A", ""); code != http.StatusNotFound {
t.Errorf("deleting an absent record = %d, want 404", code)
}
// Removing the zone is what hands the name back to upstream, and the
// cached NXDOMAIN goes with it.
mustWrite(http.MethodDelete, "/api/zones?zone=console.test.", "")
if _, err := os.Stat(filepath.Join(managed, "console.test.zone")); !os.IsNotExist(err) {
t.Errorf("the managed zone file is still there (stat err = %v)", err)
}
m = h.query("www.console.test", dns.TypeA)
if len(m.Answer) != 1 {
t.Fatalf("query after the zone was deleted = %v, want the upstream answer", m)
}
if a, ok := m.Answer[0].(*dns.A); !ok || a.A.String() != "203.0.113.7" {
t.Errorf("after the zone was deleted = %v, want the upstream's 203.0.113.7", m.Answer[0])
}
// And the zone is gone from the page.
_, body = h.get("/api/zones")
var gone zonesPayload
if err := json.Unmarshal(body, &gone); err != nil {
t.Fatalf("zones: %v: %s", err, body)
}
for _, zone := range gone.Zones {
if zone.Origin == "console.test." {
t.Errorf("the deleted zone is still listed: %+v", zone)
}
}
// An operator's zone is not the console's to delete.
if code, _ := do(http.MethodDelete, "/api/zones?zone=local.", ""); code != http.StatusNotFound {
t.Errorf("deleting the operator's zone = %d, want 404", code)
}
// The operator's master file is still theirs: it is listed, and it is not
// reported as console-managed.
var sawLocal bool
for _, zone := range gone.Zones {
if zone.Origin != "local." {
continue
}
sawLocal = true
if zone.Managed {
t.Error("the operator's zone is reported as managed, so the console would offer to rewrite it")
}
}
if !sawLocal {
t.Errorf("the operator's zone is missing from the list: %+v", gone.Zones)
}
}
// zonesPayload is the slice of GET /api/zones this test reads. It is declared
// here rather than imported so the assertions name the fields the console
// depends on; the contract test in this package pins the whole key set.
type zonesPayload struct {
Zones []struct {
Origin string `json:"origin"`
Managed bool `json:"managed"`
Serial uint32 `json:"serial"`
Records []any `json:"records"`
RecordCount int `json:"record_count"`
Delegations []struct {
Name string `json:"name"`
Targets []string `json:"targets"`
Managed bool `json:"managed"`
} `json:"delegations"`
} `json:"zones"`
Files []string `json:"files"`
ManagedDir string `json:"managed_dir"`
Writable bool `json:"writable"`
Types []string `json:"types"`
Records int `json:"records"`
}
// TestZoneWritesNeedAManagedDirectory pins the read-only default: a
// deployment that never sets blocklist.managed_zones can browse its local zones
// and cannot have one rewritten behind its back.
func TestZoneWritesNeedAManagedDirectory(t *testing.T) {
h := newHarness(t, nil)
code, body := h.get("/api/zones")
if code != http.StatusOK {
t.Fatalf("GET /api/zones = %d: %s", code, body)
}
var zones zonesPayload
if err := json.Unmarshal(body, &zones); err != nil {
t.Fatalf("zones: %v: %s", err, body)
}
if zones.Writable {
t.Error("writable = true with no managed zone directory configured")
}
if zones.ManagedDir != "" {
t.Errorf("managed_dir = %q, want empty", zones.ManagedDir)
}
if len(zones.Zones) == 0 {
t.Error("the operator's local zone is missing, so the page would be blank rather than read-only")
}
for _, zone := range zones.Zones {
if zone.Managed {
t.Errorf("zone %s is reported as managed without a managed directory", zone.Origin)
}
}
// Every write route refuses, and says what to configure.
writes := []struct {
method string
path string
body string
}{
{http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"console.test.","type":"NS","value":"ns1.console.test."}`},
{http.MethodPut, "/api/zones/records", `{"zone":"console.test.","name":"www","type":"A","value":"10.0.0.1"}`},
{http.MethodDelete, "/api/zones/records?zone=console.test.&name=www&type=A", ""},
{http.MethodPost, "/api/zones/delegations", `{"zone":"console.test.","name":"sub","targets":["ns1.sub.console.test."]}`},
{http.MethodDelete, "/api/zones/delegations?zone=console.test.&name=sub.console.test.", ""},
{http.MethodDelete, "/api/zones?zone=console.test.", ""},
}
for _, w := range writes {
code, out := h.do(w.method, w.path, w.body)
if code != http.StatusBadRequest {
t.Errorf("%s %s = %d, want 400: %s", w.method, w.path, code, out)
continue
}
if !strings.Contains(string(out), "managed_zones") {
t.Errorf("%s %s did not say what to configure: %s", w.method, w.path, out)
}
}
}
// TestResolveUpstream covers the happy path: a name nobody blocks is forwarded
// to the upstream and the answer is cached.
func TestResolveUpstream(t *testing.T) {
h := newHarness(t, nil)
res := h.query("www.example.test", dns.TypeA)
if res.Rcode != dns.RcodeSuccess {
t.Fatalf("rcode = %s, want NOERROR", dns.RcodeToString[res.Rcode])
}
if len(res.Answer) == 0 {
t.Fatal("no answer records")
}
if got := res.Answer[0].(*dns.A).A.String(); got != "203.0.113.7" {
t.Errorf("answer = %s, want 203.0.113.7", got)
}
if h.up.hitCount("www.example.test.") == 0 {
t.Error("upstream was never asked")
}
// Second query must come from cache: the upstream counter must not move.
before := h.up.hitCount("www.example.test.")
h.query("www.example.test", dns.TypeA)
if after := h.up.hitCount("www.example.test."); after != before {
t.Errorf("upstream hit count moved from %d to %d: the cache did not serve the repeat query", before, after)
}
}
// TestBlockedNames covers every blocklist source shape: hosts-file entries
// (apex plus subdomains), RPZ wildcards and inline rules.
func TestBlockedNames(t *testing.T) {
h := newHarness(t, nil)
cases := []string{
"ads.example.com", // hosts file
"deep.ads.example.com", // hosts file, subdomain
"tracker.example.net",
"inline-blocked.example.com",
"anything.inline-sub.example.com",
}
for _, name := range cases {
res := h.query(name, dns.TypeA)
if res.Rcode != dns.RcodeNameError {
t.Errorf("%s: rcode = %s, want NXDOMAIN", name, dns.RcodeToString[res.Rcode])
}
}
// A blocked name must never reach the upstream.
if n := h.up.hitCount("ads.example.com."); n != 0 {
t.Errorf("blocked name reached the upstream %d times", n)
}
}
// TestAllowOverridesBlock checks that an inline allow entry beats a list rule.
func TestAllowOverridesBlock(t *testing.T) {
h := newHarness(t, nil)
res := h.query("allow.example.com", dns.TypeA)
// The fake upstream only knows *.test., so an allowed-but-unresolvable name
// must come back as NXDOMAIN *from upstream*, which means it got past the
// blocklist. Assert on the upstream counter instead of the rcode.
if h.up.hitCount("allow.example.com.") == 0 {
t.Error("allowed name did not reach the upstream, so the allow rule lost to a block rule")
}
_ = res
}
// TestLocalZone covers the local master file: exact, wildcard and NODATA.
func TestLocalZone(t *testing.T) {
h := newHarness(t, nil)
res := h.query("ns.local", dns.TypeA)
if len(res.Answer) != 1 {
t.Fatalf("want 1 answer for ns.local, got %d", len(res.Answer))
}
if got := res.Answer[0].(*dns.A).A.String(); got != "192.168.1.2" {
t.Errorf("ns.local = %s, want 192.168.1.2", got)
}
if !res.Authoritative {
t.Error("local zone answer should be authoritative")
}
// Wildcard covers a name one label below the wildcard's parent.
w := h.query("anything.wild.local", dns.TypeA)
if len(w.Answer) == 0 || w.Answer[0].(*dns.A).A.String() != "192.168.1.50" {
t.Errorf("wildcard answer wrong: %v", w.Answer)
}
// NODATA: the name exists but has no A record.
n := h.query("deep.svc.local", dns.TypeAAAA)
if n.Rcode != dns.RcodeSuccess {
t.Errorf("NODATA rcode = %s, want NOERROR", dns.RcodeToString[n.Rcode])
}
if len(n.Answer) != 0 {
t.Errorf("NODATA returned %d answers", len(n.Answer))
}
// NXDOMAIN inside a zone we are authoritative for.
x := h.query("nope.local", dns.TypeA)
if x.Rcode != dns.RcodeNameError {
t.Errorf("in-zone NXDOMAIN rcode = %s", dns.RcodeToString[x.Rcode])
}
}
// TestACLDenyGroup checks that a deny group refuses before anything else,
// including a name the local zone could answer.
func TestACLDenyGroup(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
c.ACL.Groups = []config.Group{{
Name: "deny-all",
Match: config.Match{CIDRs: []string{"127.0.0.0/8"}},
Policy: "deny",
Comment: "test",
}}
})
res := h.query("www.example.test", dns.TypeA)
if res.Rcode != dns.RcodeRefused {
t.Errorf("rcode = %s, want REFUSED", dns.RcodeToString[res.Rcode])
}
if h.up.hitCount("www.example.test.") != 0 {
t.Error("a denied client still reached the upstream")
}
}
// TestACLAllowDomains checks a group's allow_domains acts as a filter.
func TestACLAllowDomains(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
c.ACL.Groups = []config.Group{{
Name: "only-test",
Match: config.Match{CIDRs: []string{"127.0.0.0/8"}},
Policy: "allow",
AllowDomains: []string{"*.test"},
}}
})
// Inside the allow list: resolves.
if r := h.query("www.example.test", dns.TypeA); r.Rcode != dns.RcodeSuccess {
t.Errorf("allowed domain rcode = %s", dns.RcodeToString[r.Rcode])
}
// Outside it: blocked by the group.
if r := h.query("ads.example.com", dns.TypeA); r.Rcode != dns.RcodeNameError {
t.Errorf("domain outside allow_domains rcode = %s, want NXDOMAIN", dns.RcodeToString[r.Rcode])
}
}
// TestACLSkipBlocklist checks a group can opt out of blocklist filtering.
func TestACLSkipBlocklist(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
c.ACL.Groups = []config.Group{{
Name: "trusted",
Match: config.Match{CIDRs: []string{"127.0.0.0/8"}},
Policy: "allow",
SkipBlocklist: true,
}}
})
h.query("ads.example.com", dns.TypeA)
if h.up.hitCount("ads.example.com.") == 0 {
t.Error("skip_blocklist group still blocked the name")
}
}
// TestBlockActions covers the non-default blocklist responses.
func TestBlockActions(t *testing.T) {
t.Run("zero", func(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
c.Block.DefaultAction = "zero"
c.Block.ResponseIP = "0.0.0.0"
})
res := h.query("ads.example.com", dns.TypeA)
if res.Rcode != dns.RcodeSuccess {
t.Fatalf("rcode = %s, want NOERROR", dns.RcodeToString[res.Rcode])
}
if len(res.Answer) != 1 || res.Answer[0].(*dns.A).A.String() != "0.0.0.0" {
t.Errorf("sinkhole answer = %v", res.Answer)
}
// AAAA for the same name must get the v6 sinkhole, not the v4 one.
res6 := h.query("ads.example.com", dns.TypeAAAA)
if len(res6.Answer) != 1 || res6.Answer[0].(*dns.AAAA).AAAA.String() != "::" {
t.Errorf("v6 sinkhole answer = %v", res6.Answer)
}
})
t.Run("refused", func(t *testing.T) {
h := newHarness(t, func(c *config.Config) { c.Block.DefaultAction = "refused" })
if r := h.query("ads.example.com", dns.TypeA); r.Rcode != dns.RcodeRefused {
t.Errorf("rcode = %s, want REFUSED", dns.RcodeToString[r.Rcode])
}
})
t.Run("redirect", func(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
c.Block.DefaultAction = "redirect"
c.Block.RedirectName = "blockpage.local"
})
res := h.query("ads.example.com", dns.TypeA)
if len(res.Answer) == 0 {
t.Fatal("redirect produced no answer")
}
cname, ok := res.Answer[0].(*dns.CNAME)
if !ok {
t.Fatalf("first answer is %T, want CNAME", res.Answer[0])
}
if cname.Target != "blockpage.local." {
t.Errorf("cname target = %s", cname.Target)
}
})
}
// TestMalformedQueries covers the input validation path.
func TestMalformedQueries(t *testing.T) {
h := newHarness(t, nil)
// Zero questions: the server must answer FORMERR, not crash or hang.
m := new(dns.Msg)
m.Id = dns.Id()
m.Question = nil
c := &dns.Client{Timeout: 3 * time.Second}
res, _, err := c.Exchange(m, h.addr)
if err != nil {
t.Fatalf("exchange: %v", err)
}
if res.Rcode != dns.RcodeFormatError {
t.Errorf("rcode = %s, want FORMERR", dns.RcodeToString[res.Rcode])
}
}
// TestTCPSameAnswer checks TCP returns the same answer as UDP, and that a large
// UDP answer is truncated rather than dropped.
func TestTCPSameAnswer(t *testing.T) {
h := newHarness(t, nil)
m := new(dns.Msg)
m.SetQuestion("www.example.test.", dns.TypeA)
m.RecursionDesired = true
tcp := &dns.Client{Net: "tcp", Timeout: 5 * time.Second}
res, _, err := tcp.Exchange(m, h.addr)
if err != nil {
t.Fatalf("tcp exchange: %v", err)
}
if res.Rcode != dns.RcodeSuccess || len(res.Answer) == 0 {
t.Errorf("tcp answer wrong: rcode=%s answers=%d", dns.RcodeToString[res.Rcode], len(res.Answer))
}
}
// TestAPIAuth checks the admin API refuses unauthenticated reads and accepts
// the configured key.
func TestAPIAuth(t *testing.T) {
h := newHarness(t, nil)
// No key: 401.
res, err := httpGet("http://"+h.api+"/api/whoami", "")
if err != nil {
t.Fatal(err)
}
if res.status != 401 {
t.Errorf("unauthenticated /whoami = %d, want 401", res.status)
}
res, err = httpGet("http://"+h.api+"/api/whoami", "wrong-key")
if err != nil {
t.Fatal(err)
}
if res.status != 401 {
t.Errorf("bad key /whoami = %d, want 401", res.status)
}
res, err = httpGet("http://"+h.api+"/api/whoami", h.key)
if err != nil {
t.Fatal(err)
}
if res.status != 200 {
t.Errorf("good key /whoami = %d, want 200", res.status)
}
var who struct {
Name string `json:"name"`
CanWrite bool `json:"can_write"`
}
if err := json.Unmarshal(res.body, &who); err != nil {
t.Fatal(err)
}
if who.Name != "rw-1" || !who.CanWrite {
t.Errorf("whoami = %+v", who)
}
// Health is deliberately unauthenticated for probes.
res, err = httpGet("http://"+h.api+"/api/healthz", "")
if err != nil {
t.Fatal(err)
}
if res.status != 200 {
t.Errorf("/healthz = %d, want 200", res.status)
}
}
// TestReadOnlyKeyCannotWrite checks a read-only key is refused on mutations.
func TestReadOnlyKeyCannotWrite(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
c.API.Auth.ReadOnlyAPIKeys = []string{"ro-key"}
})
res, err := httpRequest("POST", "http://"+h.api+"/api/cache/flush", "ro-key", "")
if err != nil {
t.Fatal(err)
}
if res.status != 403 {
t.Errorf("read-only flush = %d, want 403", res.status)
}
res, err = httpRequest("POST", "http://"+h.api+"/api/cache/flush", h.key, "")
if err != nil {
t.Fatal(err)
}
if res.status != 200 {
t.Errorf("read-write flush = %d, want 200", res.status)
}
}
// TestDomainAddDeleteRoundTrip is the operator's core loop: add a rule, see it
// take effect on a live query, then remove it.
func TestDomainAddDeleteRoundTrip(t *testing.T) {
h := newHarness(t, nil)
// The name lives under .test. so the fake upstream resolves it: that way
// NOERROR unambiguously means "not blocked" and NXDOMAIN unambiguously
// means "the blocklist answered", with no third possibility to confuse the
// assertion.
const name = "newly-blocked.example.test"
before := h.up.hitCount(dns.Fqdn(name))
if r := h.query(name, dns.TypeA); r.Rcode != dns.RcodeSuccess {
t.Fatalf("precondition: rcode = %s, want NOERROR", dns.RcodeToString[r.Rcode])
}
if h.up.hitCount(dns.Fqdn(name)) == before {
t.Error("precondition: the upstream was not consulted")
}
code, body := h.do("POST", "/api/domains", `{"name":"`+name+`","action":"block"}`)
if code != 200 {
t.Fatalf("add domain = %d: %s", code, body)
}
if r := h.query(name, dns.TypeA); r.Rcode != dns.RcodeNameError {
t.Errorf("after add, rcode = %s, want NXDOMAIN", dns.RcodeToString[r.Rcode])
}
// The rule must survive a cache flush, i.e. it is really in the config and
// not just a transient matcher state.
if code, _ := h.do("POST", "/api/cache/flush", ""); code != 200 {
t.Fatalf("flush = %d", code)
}
if r := h.query(name, dns.TypeA); r.Rcode != dns.RcodeNameError {
t.Errorf("after flush, rcode = %s, want NXDOMAIN", dns.RcodeToString[r.Rcode])
}
code, body = h.do("DELETE", "/api/domains/"+name, "")
if code != 200 {
t.Fatalf("delete domain = %d: %s", code, body)
}
if r := h.query(name, dns.TypeA); r.Rcode != dns.RcodeSuccess {
t.Errorf("after delete, rcode = %s, want NOERROR", dns.RcodeToString[r.Rcode])
}
}
// TestDomainAddInvalid checks the API rejects a bad payload with 400 rather
// than writing a broken rule.
func TestDomainAddInvalid(t *testing.T) {
h := newHarness(t, nil)
code, _ := h.do("POST", "/api/domains", `{"name":"","action":"block"}`)
if code != 400 {
t.Errorf("empty name = %d, want 400", code)
}
code, _ = h.do("POST", "/api/domains", `{"name":"x.example.com","action":"nonsense"}`)
if code != 400 {
t.Errorf("bad action = %d, want 400", code)
}
}
// TestACLUpdateRoundTrip checks the clients editor's write path and that a
// saved group is enforced immediately.
// The console's "Test" button goes through the live pipeline, so this pins
// the whole path: a name the fake upstream answers must come back with the
// answer records, and one it does not must come back as a result (a 200 with
// an rcode) rather than as a failed request.
func TestDNSTestEndpoint(t *testing.T) {
h := newHarness(t, nil)
code, body := h.do(http.MethodPost, "/api/dns/test", `{"name":"www.example.test","type":"A"}`)
if code != http.StatusOK {
t.Fatalf("POST /api/dns/test = %d: %s", code, body)
}
var hit struct {
Name string `json:"name"`
Type string `json:"type"`
RCode string `json:"rcode"`
Action string `json:"action"`
Answers []string `json:"answers"`
}
if err := json.Unmarshal(body, &hit); err != nil {
t.Fatal(err)
}
if hit.Name != "www.example.test." || hit.Type != "A" {
t.Errorf("canonical name = %q type = %q", hit.Name, hit.Type)
}
if hit.RCode != "NOERROR" {
t.Fatalf("rcode = %q, want NOERROR: %s", hit.RCode, body)
}
if len(hit.Answers) != 1 || !strings.Contains(hit.Answers[0], "203.0.113.7") {
t.Errorf("answers = %v, want the upstream A record", hit.Answers)
}
// A name the upstream does not hold is still a successful test.
code, body = h.do(http.MethodPost, "/api/dns/test", `{"name":"nothing.example.com","type":"A"}`)
if code != http.StatusOK {
t.Fatalf("POST /api/dns/test (miss) = %d: %s", code, body)
}
if !strings.Contains(string(body), "NXDOMAIN") {
t.Errorf("a miss is not reported as a result: %s", body)
}
// An unknown record type is a bad request the caller can fix, so it comes
// back as an error in the payload rather than as a bogus lookup.
code, body = h.do(http.MethodPost, "/api/dns/test", `{"name":"www.example.test","type":"NOTATYPE"}`)
if code != http.StatusOK {
t.Fatalf("POST /api/dns/test (bad type) = %d: %s", code, body)
}
if !strings.Contains(string(body), "error") {
t.Errorf("a bad type is not reported: %s", body)
}
}
func TestACLUpdateRoundTrip(t *testing.T) {
h := newHarness(t, nil)
// Write a deny group through the API.
body := `{"groups":[{"name":"nobody","match":{"cidrs":["127.0.0.0/8"]},"policy":"deny","comment":"test"}],"default_policy":"allow"}`
code, out := h.do("PUT", "/api/clients", body)
if code != 200 {
t.Fatalf("put clients = %d: %s", code, out)
}
if r := h.query("www.example.test", dns.TypeA); r.Rcode != dns.RcodeRefused {
t.Errorf("after save, rcode = %s, want REFUSED", dns.RcodeToString[r.Rcode])
}
// Read it back.
code, out = h.get("/api/clients")
if code != 200 {
t.Fatalf("get clients = %d", code)
}
if !strings.Contains(string(out), "nobody") {
t.Error("saved group is missing from the read-back")
}
// An invalid group must be rejected with 400 and not applied.
bad := `{"groups":[{"name":"x","match":{"cidrs":["not-a-cidr"]},"policy":"deny"}],"default_policy":"allow"}`
code, _ = h.do("PUT", "/api/clients", bad)
if code != 400 {
t.Errorf("bad CIDR = %d, want 400", code)
}
}
// TestUpstreamUpdate checks the upstreams editor round-trips and rebinds the
// pool without a restart.
func TestUpstreamUpdate(t *testing.T) {
h := newHarness(t, nil)
body := fmt.Sprintf(`{"policy":"failover","servers":[{"addr":%q,"proto":"udp","weight":1}],"max_attempts":1,"breaker_threshold":5,"healthcheck_interval_s":0}`, h.up.addr)
code, out := h.do("PUT", "/api/upstreams", body)
if code != 200 {
t.Fatalf("put upstreams = %d: %s", code, out)
}
code, out = h.get("/api/upstreams")
if code != 200 {
t.Fatalf("get upstreams = %d", code)
}
if !strings.Contains(string(out), "failover") {
t.Error("policy did not persist")
}
// Still resolves after the rebind.
if r := h.query("www.example.test", dns.TypeA); r.Rcode != dns.RcodeSuccess {
t.Errorf("after upstream update, rcode = %s", dns.RcodeToString[r.Rcode])
}
// Invalid config rejected.
code, _ = h.do("PUT", "/api/upstreams", `{"policy":"nonsense","servers":[]}`)
if code != 400 {
t.Errorf("bad policy = %d, want 400", code)
}
}
// TestSettingsValidateAndSave covers the config editor: validate, save, and
// confirm the file on disk changed.
func TestSettingsValidateAndSave(t *testing.T) {
h := newHarness(t, nil)
code, out := h.get("/api/settings")
if code != 200 {
t.Fatalf("get settings = %d", code)
}
var s struct {
ConfigYAML string `json:"config_yaml"`
Path string `json:"path"`
Writable bool `json:"writable"`
}
if err := json.Unmarshal(out, &s); err != nil {
t.Fatal(err)
}
if !s.Writable {
t.Error("config file should be writable in the test harness")
}
// A valid edit round-trips.
edited := strings.Replace(s.ConfigYAML, "max_concurrent: 1024", "max_concurrent: 2048", 1)
if edited == s.ConfigYAML {
t.Fatal("test could not find the setting it wanted to edit")
}
payload, _ := json.Marshal(map[string]string{"config_yaml": edited})
code, out = h.do("POST", "/api/settings/validate", string(payload))
if code != 200 {
t.Fatalf("validate = %d: %s", code, out)
}
var v struct {
OK bool `json:"ok"`
Errors []struct {
Field string `json:"field"`
Message string `json:"message"`
} `json:"errors"`
}
if err := json.Unmarshal(out, &v); err != nil {
t.Fatal(err)
}
if !v.OK {
t.Errorf("valid config reported errors: %+v", v.Errors)
}
code, out = h.do("PUT", "/api/settings", string(payload))
if code != 200 {
t.Fatalf("save settings = %d: %s", code, out)
}
// The file on disk must now contain the edit.
onDisk := readFileString(t, s.Path)
if !strings.Contains(onDisk, "max_concurrent: 2048") {
t.Error("config file on disk was not updated")
}
// An invalid document must be refused and the file left alone.
badPayload, _ := json.Marshal(map[string]string{"config_yaml": "server:\n max_concurrent: -5\n"})
code, _ = h.do("PUT", "/api/settings", string(badPayload))
if code != 400 {
t.Errorf("invalid save = %d, want 400", code)
}
if readFileString(t, s.Path) != onDisk {
t.Error("a rejected config write still modified the file")
}
}
// TestBlocklistToggle checks a disabled list stops applying and its cached
// answers are dropped.
func TestBlocklistToggle(t *testing.T) {
h := newHarness(t, nil)
const name = "blocked.example.test"
if r := h.query(name, dns.TypeA); r.Rcode != dns.RcodeNameError {
t.Fatal("precondition: name should be blocked")
}
code, out := h.do("POST", "/api/blocklists/toggle", `{"name":"test-hosts","enabled":false}`)
if code != 200 {
t.Fatalf("toggle = %d: %s", code, out)
}
// The recompile runs in the background, so poll rather than assume.
waitFor(t, 5*time.Second, func() bool {
return h.query(name, dns.TypeA).Rcode == dns.RcodeSuccess
})
// Re-enabling must block it again: a one-way toggle would be a nasty
// surprise.
if code, _ := h.do("POST", "/api/blocklists/toggle", `{"name":"test-hosts","enabled":true}`); code != 200 {
t.Fatalf("re-enable = %d", code)
}
waitFor(t, 5*time.Second, func() bool {
return h.query(name, dns.TypeA).Rcode == dns.RcodeNameError
})
}
// TestDashboardAndStatus checks the aggregate endpoints return the fields the
// UI reads, so a frontend change cannot silently drift from the API.
func TestDashboardAndStatus(t *testing.T) {
h := newHarness(t, nil)
h.query("www.example.test", dns.TypeA)
h.query("ads.example.com", dns.TypeA)
code, out := h.get("/api/dashboard?range=1h")
if code != 200 {
t.Fatalf("dashboard = %d", code)
}
var d struct {
Health string `json:"health"`
Queries uint64 `json:"queries_total"`
Blocked uint64 `json:"blocked_total"`
HitRate any `json:"cache_hit_rate"`
Series []any `json:"series"`
TopBlocked []struct {
Domain string `json:"domain"`
Count uint64 `json:"count"`
} `json:"top_blocked"`
Upstreams []struct {
Addr string `json:"addr"`
Health string `json:"health"`
} `json:"upstreams"`
}
if err := json.Unmarshal(out, &d); err != nil {
t.Fatal(err)
}
if d.Health != "ok" {
t.Errorf("health = %q, want ok", d.Health)
}
if d.Queries == 0 {
t.Error("queries_total is zero after two queries")
}
if d.Blocked == 0 {
t.Error("blocked_total is zero after a blocked query")
}
if len(d.Upstreams) == 0 {
t.Error("upstreams empty in dashboard")
}
if len(d.Series) == 0 {
t.Error("series empty in dashboard")
}
_ = d.HitRate
_ = d.TopBlocked
code, out = h.get("/api/status")
if code != 200 {
t.Fatalf("status = %d", code)
}
if !strings.Contains(string(out), "listeners") {
t.Error("status has no listeners section")
}
}
// TestLogsAndClear covers the query log endpoint and the clear action.
func TestLogsAndClear(t *testing.T) {
h := newHarness(t, nil)
h.query("www.example.test", dns.TypeA)
h.query("ads.example.com", dns.TypeA)
waitFor(t, 3*time.Second, func() bool {
code, out := h.get("/api/logs?limit=50")
if code != 200 {
return false
}
return strings.Contains(string(out), "example.test")
})
code, out := h.get("/api/logs?q=ads.example.com&blocked=1")
if code != 200 {
t.Fatalf("logs = %d", code)
}
if !strings.Contains(string(out), "ads.example.com") {
t.Error("filtered log did not include the blocked query")
}
if code, _ := h.do("POST", "/api/logs/clear", ""); code != 200 {
t.Errorf("clear logs = %d, want 200", code)
}
}
// TestCacheEndpoints covers the cache stats/top/flush trio.
func TestCacheEndpoints(t *testing.T) {
h := newHarness(t, nil)
h.query("www.example.test", dns.TypeA)
code, out := h.get("/api/cache")
if code != 200 {
t.Fatalf("cache = %d", code)
}
var c struct {
Entries int `json:"entries"`
Shards int `json:"shards"`
HitRate float64 `json:"hit_rate"`
}
if err := json.Unmarshal(out, &c); err != nil {
t.Fatal(err)
}
if c.Shards <= 0 {
t.Error("shards is zero")
}
code, out = h.get("/api/cache/top?limit=10")
if code != 200 {
t.Fatalf("cache top = %d", code)
}
if !strings.Contains(string(out), "example.test") {
t.Error("cache top does not list the queried name")
}
if code, _ := h.do("POST", "/api/cache/flush", ""); code != 200 {
t.Errorf("flush = %d, want 200", code)
}
}
// TestReloadFromDisk checks SIGHUP-style reload applies a hand-edited file and
// reports a broken one without dropping the running config.
func TestReloadFromDisk(t *testing.T) {
h := newHarness(t, nil)
// Break the file, then reload: must fail and keep serving.
broken := strings.Replace(h.OriginalConfig(), "max_concurrent: 1024", "max_concurrent: not-a-number", 1)
if broken == h.OriginalConfig() {
t.Fatal("test could not find the setting it wanted to break")
}
writeFileString(t, h.app.cfg.Path(), broken)
if err := h.app.Reload(); err == nil {
t.Fatal("reload of a broken config should fail")
}
if h.app.cfg.LastError() == "" {
t.Error("a failed reload should record the error for the health endpoint")
}
// The server keeps working with the previous config.
if r := h.query("www.example.test", dns.TypeA); r.Rcode != dns.RcodeSuccess {
t.Errorf("after a failed reload, rcode = %s", dns.RcodeToString[r.Rcode])
}
// Fix the file and reload: should succeed and clear the error. The whole
// document is rewritten from the harness's own config, changing only the
// broken value, so the reload is testing a realistic edit rather than a
// minimal stub.
fixed := strings.Replace(h.OriginalConfig(), "max_concurrent: 1024", "max_concurrent: 512", 1)
if fixed == h.OriginalConfig() {
t.Fatal("test could not find the setting it wanted to edit")
}
writeFileString(t, h.app.cfg.Path(), fixed)
if err := h.app.Reload(); err != nil {
t.Fatalf("reload of a fixed config failed: %v", err)
}
if h.app.cfg.LastError() != "" {
t.Error("a successful reload should clear the recorded error")
}
}
// TestLogStream checks the SSE endpoint emits a ready frame and at least one
// entry frame, and that it terminates when the client disconnects.
func TestLogStream(t *testing.T) {
h := newHarness(t, nil)
req, err := httpNewRequest("GET", "http://"+h.api+"/api/logs/stream", h.key)
if err != nil {
t.Fatal(err)
}
// A streaming client must not time out while idle, so give it a generous
// deadline; the test closes the body when it has seen what it needs.
streamClient := &http.Client{Timeout: 20 * time.Second}
resp, err := streamClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("stream = %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
t.Errorf("content-type = %q, want text/event-stream", ct)
}
if resp.Header.Get("X-Accel-Buffering") != "no" {
t.Error("missing X-Accel-Buffering: no, a proxy would buffer the stream")
}
// Generate a query and read frames until we see an entry.
go func() {
time.Sleep(150 * time.Millisecond)
h.query("www.example.test", dns.TypeA)
}()
buf := make([]byte, 4096)
seen := map[string]bool{}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
n, err := resp.Body.Read(buf)
if err != nil {
break
}
chunk := string(buf[:n])
for _, ev := range []string{"event: ready", "event: entry", "event: stats"} {
if strings.Contains(chunk, ev) {
seen[ev] = true
}
}
if seen["event: ready"] && seen["event: entry"] {
break
}
}
if !seen["event: ready"] {
t.Error("no ready event")
}
if !seen["event: entry"] {
t.Error("no entry event for a query issued while streaming")
}
}
// TestMetricsEndpoint checks Prometheus output is served and protected.
func TestMetricsEndpoint(t *testing.T) {
h := newHarness(t, nil)
res, err := httpGet("http://"+h.api+"/api/metrics", "")
if err != nil {
t.Fatal(err)
}
if res.status != 200 {
t.Errorf("unprotected /metrics = %d, want 200", res.status)
}
if !strings.Contains(string(res.body), "fancydns_queries_total") {
t.Error("metrics output has no fancydns_queries_total")
}
// With protection on, an unauthenticated scrape must fail.
h2 := newHarness(t, func(c *config.Config) { c.Metrics.Protect = true })
res, err = httpGet("http://"+h2.api+"/api/metrics", "")
if err != nil {
t.Fatal(err)
}
if res.status != 401 {
t.Errorf("protected /metrics without a key = %d, want 401", res.status)
}
res, err = httpGet("http://"+h2.api+"/api/metrics", h2.key)
if err != nil {
t.Fatal(err)
}
if res.status != 200 {
t.Errorf("protected /metrics with a key = %d, want 200", res.status)
}
}
// TestConcurrentQueries hammers the server from many goroutines to catch races
// and to make sure the shared state is actually safe. Run with -race.
func TestConcurrentQueries(t *testing.T) {
h := newHarness(t, nil)
const workers, each = 16, 25
done := make(chan error, workers)
for w := 0; w < workers; w++ {
go func(w int) {
m := new(dns.Msg)
m.SetQuestion("www.example.test.", dns.TypeA)
m.RecursionDesired = true
c := &dns.Client{Net: "udp", Timeout: 5 * time.Second}
for i := 0; i < each; i++ {
if _, _, err := c.Exchange(m, h.addr); err != nil {
done <- err
return
}
}
done <- nil
}(w)
}
for w := 0; w < workers; w++ {
if err := <-done; err != nil {
t.Fatalf("concurrent query failed: %v", err)
}
}
// Also exercise the API concurrently, since it reads the same state.
apiDone := make(chan error, workers)
for w := 0; w < workers; w++ {
go func() {
res, err := httpGet("http://"+h.api+"/api/dashboard", h.key)
if err != nil {
apiDone <- err
return
}
if res.status != 200 {
apiDone <- fmt.Errorf("dashboard = %d", res.status)
return
}
apiDone <- nil
}()
}
for w := 0; w < workers; w++ {
if err := <-apiDone; err != nil {
t.Errorf("concurrent api call failed: %v", err)
}
}
}