package checker import ( "context" "encoding/json" "fmt" "strings" "testing" "time" "github.com/miekg/dns" contract "git.happydns.org/checker-dangling/contract" sdk "git.happydns.org/checker-sdk-go/checker" ) // --- test helpers --------------------------------------------------------- // stubResolver lets a single test override the resolution outcome per // target without touching the real network. The outer test wires it // in/out via a t.Cleanup so the package-level variable stays clean. func stubResolver(t *testing.T, table map[string]struct{ verdict, detail string }) { t.Helper() prev := resolveHost resolveHost = func(_ context.Context, target string) (string, string) { target = strings.TrimSuffix(target, ".") if v, ok := table[target]; ok { return v.verdict, v.detail } // Default: target resolves cleanly. Tests pin behaviour they // care about; everything else should be a "boring OK". return "ok", "" } t.Cleanup(func() { resolveHost = prev }) } func cnameSvc(target string) rawService { body, _ := json.Marshal(map[string]any{ "cname": map[string]any{ "Hdr": map[string]any{"Name": ""}, "Target": target, }, }) return rawService{Type: "svcs.CNAME", Domain: "", Service: body} } func mxSvc(targets ...string) rawService { mxs := make([]map[string]any, 0, len(targets)) for _, t := range targets { mxs = append(mxs, map[string]any{ "Hdr": map[string]any{"Name": ""}, "Mx": t, "Preference": 10, }) } body, _ := json.Marshal(map[string]any{"mx": mxs}) return rawService{Type: "svcs.MXs", Domain: "", Service: body} } func srvSvc(target string) rawService { body, _ := json.Marshal(map[string]any{ "srv": []map[string]any{{ "Hdr": map[string]any{"Name": ""}, "Target": target, }}, }) return rawService{Type: "svcs.UnknownSRV", Domain: "", Service: body} } func nsOrphan(host string) rawService { body, _ := json.Marshal(map[string]any{ "record": map[string]any{ "Hdr": map[string]any{"Name": "", "Rrtype": dns.TypeNS}, "Ns": host, }, }) return rawService{Type: "svcs.Orphan", Domain: "", Service: body} } // modernNonPointer mimics a service that carries no pointer (e.g. an // abstract.Server with A/AAAA records). The collector should ignore it // silently, contributing only to ServicesScanned. func modernNonPointer() rawService { body, _ := json.Marshal(map[string]any{"A": map[string]any{}}) return rawService{Type: "abstract.Server", Domain: "", Service: body} } func runCollect(t *testing.T, zone *rawZone, opts sdk.CheckerOptions) *DanglingData { t.Helper() if opts == nil { opts = sdk.CheckerOptions{} } raw, err := json.Marshal(zone) if err != nil { t.Fatalf("marshal zone: %v", err) } var jsonZone map[string]any if err := json.Unmarshal(raw, &jsonZone); err != nil { t.Fatalf("unmarshal zone: %v", err) } opts["zone"] = jsonZone if _, ok := opts["domain_name"]; !ok && zone.DomainName != "" { opts["domain_name"] = zone.DomainName } out, err := (&danglingProvider{}).Collect(context.Background(), opts) if err != nil { t.Fatalf("Collect: %v", err) } d, ok := out.(*DanglingData) if !ok { t.Fatalf("Collect returned %T, want *DanglingData", out) } return d } func mustMarshal(t *testing.T, v any) []byte { t.Helper() b, err := json.Marshal(v) if err != nil { t.Fatalf("marshal: %v", err) } return b } // staticObs serves a single observation by key plus a fixed map of // related observations keyed by ObservationKey. Mirrors the helper // used by checker-legacy-records, extended to cover GetRelated. type staticObs struct { key sdk.ObservationKey payload []byte related map[sdk.ObservationKey][]sdk.RelatedObservation } func (s staticObs) Get(_ context.Context, key sdk.ObservationKey, dest any) error { if key != s.key { return fmt.Errorf("staticObs: unexpected observation key %q (have %q)", key, s.key) } return json.Unmarshal(s.payload, dest) } func (s staticObs) GetRelated(_ context.Context, key sdk.ObservationKey) ([]sdk.RelatedObservation, error) { return s.related[key], nil } // --- collect tests -------------------------------------------------------- func TestCollect_CleanZone_NoPointers(t *testing.T) { stubResolver(t, nil) z := &rawZone{ DomainName: "example.com", Services: map[string][]rawService{ "": {modernNonPointer()}, "www": {modernNonPointer()}, }, } data := runCollect(t, z, nil) if data.ServicesScanned != 2 { t.Errorf("ServicesScanned = %d, want 2", data.ServicesScanned) } if len(data.Pointers) != 0 { t.Errorf("Pointers = %+v, want empty", data.Pointers) } } func TestCollect_DetectsCNAMEMXSRV_NS(t *testing.T) { stubResolver(t, nil) z := &rawZone{ DomainName: "example.com", Services: map[string][]rawService{ "www": {cnameSvc("target.example.net.")}, "": {mxSvc("mail.example.org."), nsOrphan("ns1.someprovider.net.")}, "_sip._tcp": {srvSvc("sipserver.example.io.")}, }, } data := runCollect(t, z, nil) if got := len(data.Pointers); got != 4 { t.Fatalf("Pointers count = %d, want 4: %+v", got, data.Pointers) } want := map[string]bool{"CNAME": false, "MX": false, "NS": false, "SRV": false} for _, p := range data.Pointers { if !p.External { t.Errorf("expected pointer to external target to be flagged External: %+v", p) } if p.Registrable == "" { t.Errorf("expected non-empty Registrable for external target: %+v", p) } want[p.Rrtype] = true } for k, ok := range want { if !ok { t.Errorf("missing pointer of type %s", k) } } } func TestCollect_InZoneTargetIsNotExternal(t *testing.T) { stubResolver(t, nil) z := &rawZone{ DomainName: "example.com", Services: map[string][]rawService{ "www": {cnameSvc("aliased.example.com.")}, }, } data := runCollect(t, z, nil) if len(data.Pointers) != 1 { t.Fatalf("want 1 pointer, got %d", len(data.Pointers)) } if data.Pointers[0].External { t.Errorf("same-registrable target must not be External: %+v", data.Pointers[0]) } } func TestCollect_MissingZoneOptionFails(t *testing.T) { _, err := (&danglingProvider{}).Collect(context.Background(), sdk.CheckerOptions{}) if err == nil { t.Fatal("expected error when 'zone' option is missing, got nil") } } // --- DiscoverEntries ------------------------------------------------------ func TestDiscoverEntries_PublishesExternalAndInZone(t *testing.T) { stubResolver(t, nil) z := &rawZone{ DomainName: "example.com", Services: map[string][]rawService{ "alias-ext": {cnameSvc("provider.example.net.")}, "alias-in": {cnameSvc("internal.example.com.")}, }, } data := runCollect(t, z, nil) entries, err := (&danglingProvider{}).DiscoverEntries(data) if err != nil { t.Fatalf("DiscoverEntries: %v", err) } if len(entries) != 2 { t.Fatalf("want 2 entries, got %d: %+v", len(entries), entries) } var sawExternal, sawInZone bool for _, e := range entries { switch e.Type { case contract.ExternalTargetType: sawExternal = true case contract.InZoneTargetType: sawInZone = true default: t.Errorf("unexpected entry Type %q", e.Type) } } if !sawExternal || !sawInZone { t.Errorf("entry types missing: external=%v inzone=%v", sawExternal, sawInZone) } } // --- Evaluate matrix ------------------------------------------------------ func TestEvaluate_NXDOMAINIsCritical(t *testing.T) { stubResolver(t, map[string]struct{ verdict, detail string }{ "gone.example.net": {"nxdomain", "no such host"}, }) z := &rawZone{ DomainName: "example.com", Services: map[string][]rawService{ "old": {cnameSvc("gone.example.net.")}, }, } data := runCollect(t, z, nil) obs := staticObs{key: ObservationKeyDangling, payload: mustMarshal(t, data)} states := (&danglingRule{}).Evaluate(context.Background(), obs, sdk.CheckerOptions{}) if len(states) != 1 || states[0].Status != sdk.StatusCrit { t.Fatalf("want 1 critical state, got %+v", states) } if !strings.Contains(states[0].Message, "old.example.com") { t.Errorf("message should name the impacted owner: %q", states[0].Message) } } func TestEvaluate_ServfailIsWarning(t *testing.T) { stubResolver(t, map[string]struct{ verdict, detail string }{ "flaky.example.net": {"servfail", "lookup servfail"}, }) z := &rawZone{ DomainName: "example.com", Services: map[string][]rawService{ "www": {cnameSvc("flaky.example.net.")}, }, } data := runCollect(t, z, nil) states := (&danglingRule{}).Evaluate(context.Background(), staticObs{key: ObservationKeyDangling, payload: mustMarshal(t, data)}, sdk.CheckerOptions{}) if len(states) != 1 || states[0].Status != sdk.StatusWarn { t.Fatalf("want 1 warning state, got %+v", states) } } func TestEvaluate_WhoisExpiredIsCritical(t *testing.T) { stubResolver(t, nil) // target resolves OK on DNS — only WHOIS is bad. z := &rawZone{ DomainName: "example.com", Services: map[string][]rawService{ "promo": {cnameSvc("brand.attackertarget.net.")}, }, } data := runCollect(t, z, nil) expired := whoisFacts{ExpiryDate: time.Now().Add(-30 * 24 * time.Hour)} ref := contract.Ref("promo.example.com", "CNAME", "brand.attackertarget.net") related := map[sdk.ObservationKey][]sdk.RelatedObservation{ ExternalWhoisObservationKey: {{ CheckerID: "domain-expiry", Key: ExternalWhoisObservationKey, Data: mustMarshal(t, expired), Ref: ref, }}, } states := (&danglingRule{}).Evaluate(context.Background(), staticObs{key: ObservationKeyDangling, payload: mustMarshal(t, data), related: related}, sdk.CheckerOptions{}) if len(states) != 1 || states[0].Status != sdk.StatusCrit { t.Fatalf("want 1 critical state, got %+v", states) } if !strings.Contains(states[0].Message, "expired") { t.Errorf("message should mention expired registrable: %q", states[0].Message) } } func TestEvaluate_WhoisPendingDeleteIsCritical(t *testing.T) { stubResolver(t, nil) z := &rawZone{ DomainName: "example.com", Services: map[string][]rawService{ "shop": {cnameSvc("brand.dropping.net.")}, }, } data := runCollect(t, z, nil) facts := whoisFacts{ ExpiryDate: time.Now().Add(30 * 24 * time.Hour), Status: []string{"clientTransferProhibited", "pendingDelete"}, } related := map[sdk.ObservationKey][]sdk.RelatedObservation{ ExternalWhoisObservationKey: {{ CheckerID: "domain-expiry", Key: ExternalWhoisObservationKey, Data: mustMarshal(t, facts), Ref: contract.Ref("shop.example.com", "CNAME", "brand.dropping.net"), }}, } states := (&danglingRule{}).Evaluate(context.Background(), staticObs{key: ObservationKeyDangling, payload: mustMarshal(t, data), related: related}, sdk.CheckerOptions{}) if len(states) != 1 || states[0].Status != sdk.StatusCrit { t.Fatalf("want 1 critical state, got %+v", states) } } func TestEvaluate_RecentRegistrationIsWarning(t *testing.T) { stubResolver(t, nil) z := &rawZone{ DomainName: "example.com", Services: map[string][]rawService{ "legacy": {cnameSvc("brand.recently-grabbed.net.")}, }, } data := runCollect(t, z, nil) facts := whoisFacts{ ExpiryDate: time.Now().Add(365 * 24 * time.Hour), CreationDate: time.Now().Add(-15 * 24 * time.Hour), } related := map[sdk.ObservationKey][]sdk.RelatedObservation{ ExternalWhoisObservationKey: {{ CheckerID: "domain-expiry", Key: ExternalWhoisObservationKey, Data: mustMarshal(t, facts), Ref: contract.Ref("legacy.example.com", "CNAME", "brand.recently-grabbed.net"), }}, } states := (&danglingRule{}).Evaluate(context.Background(), staticObs{key: ObservationKeyDangling, payload: mustMarshal(t, data), related: related}, sdk.CheckerOptions{}) if len(states) != 1 || states[0].Status != sdk.StatusWarn { t.Fatalf("want 1 warning state, got %+v", states) } } func TestEvaluate_CleanZoneReturnsOK(t *testing.T) { stubResolver(t, nil) z := &rawZone{ DomainName: "example.com", Services: map[string][]rawService{ "www": {cnameSvc("aliased.example.com.")}, // in-zone, OK }, } data := runCollect(t, z, nil) states := (&danglingRule{}).Evaluate(context.Background(), staticObs{key: ObservationKeyDangling, payload: mustMarshal(t, data)}, sdk.CheckerOptions{}) if len(states) != 1 || states[0].Status != sdk.StatusOK { t.Fatalf("want single OK state, got %+v", states) } } func TestEvaluate_RanksCriticalAboveWarning(t *testing.T) { stubResolver(t, map[string]struct{ verdict, detail string }{ "flaky.example.net": {"servfail", ""}, "gone.example.net": {"nxdomain", ""}, }) z := &rawZone{ DomainName: "example.com", Services: map[string][]rawService{ "a": {cnameSvc("flaky.example.net.")}, "b": {cnameSvc("gone.example.net.")}, }, } data := runCollect(t, z, nil) states := (&danglingRule{}).Evaluate(context.Background(), staticObs{key: ObservationKeyDangling, payload: mustMarshal(t, data)}, sdk.CheckerOptions{}) if len(states) != 2 { t.Fatalf("want 2 states, got %d: %+v", len(states), states) } if states[0].Status != sdk.StatusCrit { t.Errorf("first state must be critical (NXDOMAIN), got %v", states[0].Status) } if states[1].Status != sdk.StatusWarn { t.Errorf("second state must be warning (SERVFAIL), got %v", states[1].Status) } } // --- Report --------------------------------------------------------------- type staticReportCtx struct { data []byte states []sdk.CheckState related map[sdk.ObservationKey][]sdk.RelatedObservation } func (s staticReportCtx) Data() json.RawMessage { return s.data } func (s staticReportCtx) Related(k sdk.ObservationKey) []sdk.RelatedObservation { return s.related[k] } func (s staticReportCtx) States() []sdk.CheckState { return s.states } func TestReport_OKBannerWhenNoFindings(t *testing.T) { stubResolver(t, nil) z := &rawZone{ DomainName: "example.com", Services: map[string][]rawService{ "www": {cnameSvc("aliased.example.com.")}, }, } data := runCollect(t, z, nil) html, err := (&danglingProvider{}).GetHTMLReport(staticReportCtx{ data: mustMarshal(t, data), states: []sdk.CheckState{{Status: sdk.StatusOK, Code: "dangling_clean"}}, }) if err != nil { t.Fatalf("GetHTMLReport: %v", err) } if !strings.Contains(html, "status-ok") { t.Errorf("report missing OK banner") } } func TestReport_TopCardReflectsCriticalOwner(t *testing.T) { stubResolver(t, map[string]struct{ verdict, detail string }{ "gone.example.net": {"nxdomain", ""}, }) z := &rawZone{ DomainName: "example.com", Services: map[string][]rawService{ "old": {cnameSvc("gone.example.net.")}, }, } data := runCollect(t, z, nil) rule := &danglingRule{} states := rule.Evaluate(context.Background(), staticObs{key: ObservationKeyDangling, payload: mustMarshal(t, data)}, sdk.CheckerOptions{}) html, err := (&danglingProvider{}).GetHTMLReport(staticReportCtx{ data: mustMarshal(t, data), states: states, }) if err != nil { t.Fatalf("GetHTMLReport: %v", err) } if !strings.Contains(html, "Fix this first") { t.Errorf("report missing 'Fix this first' card") } if !strings.Contains(html, "old.example.com") { t.Errorf("report does not name the impacted owner") } }