1087 lines
37 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|