package investigation import ( "context" "log" "net" "net/http" "net/http/httptest" "net/netip" "strings" "sync" "testing" "time" "git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/config" ) func TestInvestigateRecognizesBotViaPublishedRanges(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/ranges.json" { http.NotFound(w, r) return } _, _ = w.Write([]byte(`{"prefixes":[{"ipv4Prefix":"203.0.113.0/24"}]}`)) })) defer server.Close() svc := newService( config.InvestigationConfig{Enabled: true, Timeout: config.Duration{Duration: time.Second}, UserAgent: "test-agent", SpamhausEnabled: true}, server.Client(), &fakeResolver{}, log.New(testWriter{t}, "", 0), []botProvider{{ ID: "google_official", Name: "Googlebot", Icon: "🤖", SourceFormat: "json_prefixes", CacheTTL: time.Hour, IPRangeURLs: []string{server.URL + "/ranges.json"}, }}, map[string]string{"ipv4": server.URL + "/bootstrap-v4.json", "ipv6": server.URL + "/bootstrap-v6.json"}, ) investigation, err := svc.Investigate(context.Background(), "203.0.113.10", []string{"Mozilla/5.0"}) if err != nil { t.Fatalf("investigate ip: %v", err) } if investigation.Bot == nil || investigation.Bot.Name != "Googlebot" { t.Fatalf("expected Googlebot match, got %+v", investigation) } if investigation.Registration != nil || investigation.Reputation != nil { t.Fatalf("expected bot investigation to stop before deep lookups, got %+v", investigation) } } func TestInvestigateRecognizesBotViaReverseDNS(t *testing.T) { t.Parallel() resolver := &fakeResolver{ reverse: map[string][]string{"198.51.100.20": {"crawl.search.example.test."}}, forward: map[string][]net.IPAddr{"crawl.search.example.test": {{IP: net.ParseIP("198.51.100.20")}}}, } svc := newService( config.InvestigationConfig{Enabled: true, Timeout: config.Duration{Duration: time.Second}, UserAgent: "test-agent", SpamhausEnabled: true}, http.DefaultClient, resolver, log.New(testWriter{t}, "", 0), []botProvider{{ ID: "bing_official", Name: "Bingbot", Icon: "🤖", CacheTTL: time.Hour, ReverseDNSSuffixes: []string{".search.example.test"}, }}, map[string]string{}, ) investigation, err := svc.Investigate(context.Background(), "198.51.100.20", nil) if err != nil { t.Fatalf("investigate ip: %v", err) } if investigation.Bot == nil || investigation.Bot.Name != "Bingbot" || investigation.ReverseDNS == nil || !investigation.ReverseDNS.ForwardConfirmed { t.Fatalf("expected reverse DNS bot match, got %+v", investigation) } } func TestInvestigateLoadsRegistrationAndSpamhausForNonBot(t *testing.T) { t.Parallel() serverURL := "" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/bootstrap-v4.json": _, _ = w.Write([]byte(`{"services":[[["198.51.100.0/24"],["` + serverURL + `/rdap/"]]]}`)) case "/rdap/ip/198.51.100.30": _, _ = w.Write([]byte(`{ "handle":"NET-198-51-100-0-1", "name":"Example Network", "country":"FR", "cidr0_cidrs":[{"v4prefix":"198.51.100.0","length":24}], "entities":[{"roles":["abuse"],"vcardArray":["vcard",[["email",{},"text","abuse@example.test"],["fn",{},"text","Example ISP"]]]}] }`)) default: http.NotFound(w, r) } })) serverURL = server.URL defer server.Close() resolver := &fakeResolver{ hosts: map[string][]string{spamhausQuery(t, "198.51.100.30"): {"127.0.0.2"}}, } svc := newService( config.InvestigationConfig{Enabled: true, Timeout: config.Duration{Duration: 2 * time.Second}, UserAgent: "test-agent", SpamhausEnabled: true}, server.Client(), resolver, log.New(testWriter{t}, "", 0), nil, map[string]string{"ipv4": server.URL + "/bootstrap-v4.json", "ipv6": server.URL + "/bootstrap-v6.json"}, ) investigation, err := svc.Investigate(context.Background(), "198.51.100.30", []string{"curl/8.0"}) if err != nil { t.Fatalf("investigate ip: %v", err) } if investigation.Bot != nil { t.Fatalf("expected no bot match, got %+v", investigation.Bot) } if investigation.Registration == nil || investigation.Registration.Organization != "Example ISP" || investigation.Registration.Prefix != "198.51.100.0/24" { t.Fatalf("unexpected registration info: %+v", investigation.Registration) } if investigation.Reputation == nil || !investigation.Reputation.SpamhausListed { t.Fatalf("expected spamhaus listing, got %+v", investigation.Reputation) } } func TestPublishedNetworksAreCached(t *testing.T) { t.Parallel() var mu sync.Mutex requestCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() requestCount++ mu.Unlock() _, _ = w.Write([]byte(`{"prefixes":[{"ipv4Prefix":"203.0.113.0/24"}]}`)) })) defer server.Close() svc := newService( config.InvestigationConfig{Enabled: true, Timeout: config.Duration{Duration: time.Second}, UserAgent: "test-agent", SpamhausEnabled: true}, server.Client(), &fakeResolver{}, log.New(testWriter{t}, "", 0), []botProvider{{ ID: "provider", Name: "Provider bot", Icon: "🤖", SourceFormat: "json_prefixes", CacheTTL: time.Hour, IPRangeURLs: []string{server.URL}, }}, map[string]string{}, ) for index := 0; index < 2; index++ { if _, err := svc.Investigate(context.Background(), "203.0.113.10", nil); err != nil { t.Fatalf("investigate ip #%d: %v", index, err) } } mu.Lock() defer mu.Unlock() if requestCount != 1 { t.Fatalf("expected exactly one published-range request, got %d", requestCount) } } type fakeResolver struct { reverse map[string][]string forward map[string][]net.IPAddr hosts map[string][]string } func (r *fakeResolver) LookupAddr(_ context.Context, addr string) ([]string, error) { if values, ok := r.reverse[addr]; ok { return values, nil } return nil, &net.DNSError{IsNotFound: true} } func (r *fakeResolver) LookupIPAddr(_ context.Context, host string) ([]net.IPAddr, error) { if values, ok := r.forward[host]; ok { return values, nil } return nil, &net.DNSError{IsNotFound: true} } func (r *fakeResolver) LookupHost(_ context.Context, host string) ([]string, error) { if values, ok := r.hosts[host]; ok { return values, nil } return nil, &net.DNSError{IsNotFound: true} } type testWriter struct{ t *testing.T } func (w testWriter) Write(payload []byte) (int, error) { w.t.Helper() w.t.Log(strings.TrimSpace(string(payload))) return len(payload), nil } func spamhausQuery(t *testing.T, ip string) string { t.Helper() addr, err := netip.ParseAddr(ip) if err != nil { t.Fatalf("parse ip: %v", err) } query, err := spamhausLookupName(addr) if err != nil { t.Fatalf("build spamhaus query: %v", err) } return query }