package handlers_test import ( "encoding/json" "net/http" "net/http/httptest" "slices" "testing" "time" "go.uber.org/fx/fxtest" "sneak.berlin/go/dnswatcher/internal/config" "sneak.berlin/go/dnswatcher/internal/globals" "sneak.berlin/go/dnswatcher/internal/handlers" "sneak.berlin/go/dnswatcher/internal/logger" "sneak.berlin/go/dnswatcher/internal/notify" "sneak.berlin/go/dnswatcher/internal/state" ) // The state the handler tests serve: www.example.com has one nameserver // that answered and one whose query failed, and its certificate check // failed. example.net is an apex domain, whose own records are saved // with the hostnames' records, as the watcher saves them. Both names // resolve to domainAddress, whose port 443 entry lists them. // missingDomain is an apex domain whose parent zone's servers answered // that it does not exist, saved with no nameservers and no records. const ( missingDomain = "does-not-exist.example" testHostname = "www.example.com" answeringNS = "ns1.example.com." failedNS = "ns2.example.com." nsFailureReason = "server returned a referral" certKey = "192.0.2.1:443:www.example.com" certFailedReason = "x509: certificate has expired or is not yet valid" testDomain = "example.net" domainNS = "a.iana-servers.net." domainAddress = "192.0.2.2" sharedPort = domainAddress + ":443" ) // newHandlersWithFailures builds real Handlers whose state holds the // entries described above. func newHandlersWithFailures(t *testing.T) *handlers.Handlers { t.Helper() glob, err := globals.New(nil) if err != nil { t.Fatalf("globals.New: %v", err) } log, err := logger.New(nil, logger.Params{Globals: glob}) if err != nil { t.Fatalf("logger.New: %v", err) } notifier, err := notify.New(fxtest.NewLifecycle(t), notify.Params{ Logger: log, Config: &config.Config{}, }) if err != nil { t.Fatalf("notify.New: %v", err) } st, err := state.New(fxtest.NewLifecycle(t), state.Params{ Logger: log, Config: &config.Config{DataDir: t.TempDir()}, }) if err != nil { t.Fatalf("state.New: %v", err) } setTestState(st) hnd, err := handlers.New(nil, handlers.Params{ Logger: log, Globals: glob, State: st, Notify: notifier, }) if err != nil { t.Fatalf("handlers.New: %v", err) } return hnd } // setTestState sets the entries described above in st. func setTestState(st *state.State) { now := time.Now() st.SetHostnameState(testHostname, &state.HostnameState{ RecordsByNameserver: map[string]*state.NameserverRecordState{ answeringNS: { Records: map[string][]string{ "A": {"192.0.2.1", domainAddress}, }, Status: "ok", LastChecked: now, }, failedNS: { Records: map[string][]string{}, Status: "error", Error: nsFailureReason, LastChecked: now, }, }, LastChecked: now, }) st.SetCertificateState(certKey, &state.CertificateState{ Status: "error", Error: certFailedReason, LastChecked: now, }) st.SetDomainState(testDomain, &state.DomainState{ Nameservers: []string{domainNS}, LastChecked: now, }) st.SetHostnameState(testDomain, &state.HostnameState{ RecordsByNameserver: map[string]*state.NameserverRecordState{ domainNS: { Records: map[string][]string{"A": {domainAddress}}, Status: "ok", LastChecked: now, }, }, LastChecked: now, }) st.SetPortState(sharedPort, &state.PortState{ Open: true, Hostnames: []string{testDomain, testHostname}, LastChecked: now, }) st.SetDomainState(missingDomain, &state.DomainState{ Nameservers: []string{}, NXDomain: true, LastChecked: now, }) } // get serves one GET request to handler and returns the response body. func get(t *testing.T, handler http.HandlerFunc) string { t.Helper() rec := httptest.NewRecorder() req := httptest.NewRequestWithContext( t.Context(), http.MethodGet, "/", nil, ) handler(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } return rec.Body.String() } // TestStatusGivesFailureReasons checks that /api/v1/status gives the // reason for a failed nameserver entry and a failed certificate entry, // and no error for a nameserver that answered. func TestStatusGivesFailureReasons(t *testing.T) { t.Parallel() body := get(t, newHandlersWithFailures(t).HandleStatus()) var resp struct { Hostnames map[string]struct { Nameservers map[string]map[string]any `json:"nameservers"` } `json:"hostnames"` Certificates map[string]map[string]any `json:"certificates"` } err := json.Unmarshal([]byte(body), &resp) if err != nil { t.Fatalf("decoding response: %v", err) } nameservers := resp.Hostnames[testHostname].Nameservers got := nameservers[failedNS]["error"] if got != nsFailureReason { t.Errorf("failed nameserver error = %v, want %q", got, nsFailureReason) } _, has := nameservers[answeringNS]["error"] if has { t.Errorf("answering nameserver has an error field: %v", nameservers[answeringNS]) } got = resp.Certificates[certKey]["error"] if got != certFailedReason { t.Errorf("failed certificate error = %v, want %q", got, certFailedReason) } } // TestStatusGivesDomainRecordsUnderTheDomain checks that /api/v1/status // gives an apex domain's own records in its domain entry, and neither // lists nor counts the domain as a hostname. func TestStatusGivesDomainRecordsUnderTheDomain(t *testing.T) { t.Parallel() body := get(t, newHandlersWithFailures(t).HandleStatus()) var resp struct { Counts struct { Hostnames int `json:"hostnames"` } `json:"counts"` Domains map[string]struct { RecordsByNameserver map[string]struct { Records map[string][]string `json:"records"` } `json:"recordsByNameserver"` } `json:"domains"` Hostnames map[string]any `json:"hostnames"` } err := json.Unmarshal([]byte(body), &resp) if err != nil { t.Fatalf("decoding response: %v", err) } if resp.Counts.Hostnames != 1 { t.Errorf("counts.hostnames = %d, want 1", resp.Counts.Hostnames) } if _, listed := resp.Hostnames[testDomain]; listed { t.Errorf("hostnames lists the domain %s", testDomain) } records := resp.Domains[testDomain].RecordsByNameserver[domainNS].Records if !slices.Equal(records["A"], []string{domainAddress}) { t.Errorf("domain %s records at %s = %v, want A %s", testDomain, domainNS, records, domainAddress) } } // TestStatusMarksDomainThatDoesNotExist checks that /api/v1/status sets // nxdomain for a domain that does not exist, with no nameservers or // records, and not for a domain that exists. func TestStatusMarksDomainThatDoesNotExist(t *testing.T) { t.Parallel() body := get(t, newHandlersWithFailures(t).HandleStatus()) var resp struct { Domains map[string]struct { Nameservers []string `json:"nameservers"` RecordsByNameserver map[string]any `json:"recordsByNameserver"` NXDomain bool `json:"nxdomain"` } `json:"domains"` } err := json.Unmarshal([]byte(body), &resp) if err != nil { t.Fatalf("decoding response: %v", err) } missing := resp.Domains[missingDomain] if !missing.NXDomain || len(missing.Nameservers) != 0 || len(missing.RecordsByNameserver) != 0 { t.Errorf("domain %s = %+v, want nxdomain and nothing else", missingDomain, missing) } if resp.Domains[testDomain].NXDomain { t.Errorf("domain %s has nxdomain set", testDomain) } } // TestStatusPortsTellDomainsFromHostnames checks that a port entry in // /api/v1/status lists an apex domain in domains and a hostname in // hostnames when both resolve to its address. func TestStatusPortsTellDomainsFromHostnames(t *testing.T) { t.Parallel() body := get(t, newHandlersWithFailures(t).HandleStatus()) var resp struct { Ports map[string]struct { Domains []string `json:"domains"` Hostnames []string `json:"hostnames"` } `json:"ports"` } err := json.Unmarshal([]byte(body), &resp) if err != nil { t.Fatalf("decoding response: %v", err) } port := resp.Ports[sharedPort] if !slices.Equal(port.Domains, []string{testDomain}) { t.Errorf("port %s domains = %v, want [%s]", sharedPort, port.Domains, testDomain) } if !slices.Equal(port.Hostnames, []string{testHostname}) { t.Errorf("port %s hostnames = %v, want [%s]", sharedPort, port.Hostnames, testHostname) } }