check / check (push) Canceled after 0s
Every result of /ip/, /ip/{addr} and /api/v1/ip/{ip} now has every
field. A field without a value is null instead of left out, so an
address with no live route has all its route and AS fields set to null.
ptr is an empty list when there are no PTR names.
Two new fields, num_origins and origin_asns, give how many origin ASes
peers carry the netblock with, and which. The routing table's prefix
summary now lists them. asn still names the origin that most peers
carry.
first_seen is null, no longer the zero time, when the database has not
stored the netblock yet.
Model: opus-5-5
2043 lines
61 KiB
Go
2043 lines
61 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"html"
|
|
"maps"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"reflect"
|
|
"runtime"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/routewatch/internal/blocklist"
|
|
"git.eeqj.de/sneak/routewatch/internal/config"
|
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
|
"git.eeqj.de/sneak/routewatch/internal/logger"
|
|
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
|
"git.eeqj.de/sneak/routewatch/internal/ristypes"
|
|
"git.eeqj.de/sneak/routewatch/internal/routetable"
|
|
"git.eeqj.de/sneak/routewatch/internal/streamer"
|
|
"git.eeqj.de/sneak/routewatch/pkg/asinfo"
|
|
)
|
|
|
|
// The prefix and peer addresses the prefix page tests store routes for.
|
|
const (
|
|
testPrefixV4 = "192.0.2.0/24"
|
|
testPrefixV6 = "2001:db8::/32"
|
|
testPeerV4 = "198.51.100.1"
|
|
testPeerV6 = "2001:db8::1"
|
|
)
|
|
|
|
// testPeer returns the address of the nth of the IPv4 peers that tests
|
|
// announce routes from: 198.51.100.n.
|
|
func testPeer(n int) string {
|
|
return "198.51.100." + strconv.Itoa(n)
|
|
}
|
|
|
|
// testRouteTime is the time the tests' routes are announced at, or a number of
|
|
// minutes after it.
|
|
func testRouteTime(minutes int) time.Time {
|
|
return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC).
|
|
Add(time.Duration(minutes) * time.Minute)
|
|
}
|
|
|
|
// announce has peer announce prefix in table at time at, with an AS path
|
|
// that holds only the origin AS asn.
|
|
func announce(
|
|
table *routetable.Table, prefix string, asn int, peer string, at time.Time,
|
|
) {
|
|
table.Announce(netip.MustParsePrefix(prefix), asn,
|
|
routetable.Route{Peer: peer, ASPath: []int{asn}, Time: at})
|
|
}
|
|
|
|
// withdraw has peer withdraw prefix in table.
|
|
func withdraw(table *routetable.Table, prefix, peer string) {
|
|
table.Withdraw(netip.MustParsePrefix(prefix), peer)
|
|
}
|
|
|
|
// emptyTable returns a routing table that holds no route.
|
|
func emptyTable() *routetable.Table {
|
|
return routetable.New(routetable.Params{})
|
|
}
|
|
|
|
// Paths several tests request.
|
|
const (
|
|
asPagePath = "/as/64500"
|
|
prefixLengthPagePath = "/prefixlength/24"
|
|
statusJSONPath = "/status.json"
|
|
statsPath = "/api/v1/stats"
|
|
)
|
|
|
|
// The names the stats handler tests report the two stats handlers by.
|
|
const (
|
|
statusJSONHandlerName = "status.json"
|
|
statsHandlerName = "stats"
|
|
)
|
|
|
|
// blockingStatsDB embeds database.Store (left nil) and overrides only
|
|
// GetStatsContext, which blocks until release is closed. The stats handlers
|
|
// call it in a goroutine; every other Store method is unused on the timeout
|
|
// path and would panic if called.
|
|
type blockingStatsDB struct {
|
|
database.Store
|
|
|
|
release chan struct{}
|
|
}
|
|
|
|
func (d blockingStatsDB) GetStatsContext(_ context.Context) (database.Stats, error) {
|
|
<-d.release
|
|
|
|
return database.Stats{}, nil
|
|
}
|
|
|
|
// TestStatsHandlersDoNotLeakOnTimeout drives each stats handler repeatedly with
|
|
// a request whose context times out before the database responds, then releases
|
|
// the blocked queries and asserts the goroutine count returns to its starting
|
|
// value. Before the fix the per-request goroutine sent on an unbuffered channel
|
|
// that nothing received once the timeout won, so it blocked forever and every
|
|
// poll leaked one goroutine.
|
|
//
|
|
//nolint:paralleltest // counts goroutines, so no other test may run alongside
|
|
func TestStatsHandlersDoNotLeakOnTimeout(t *testing.T) {
|
|
release := make(chan struct{})
|
|
db := blockingStatsDB{release: release}
|
|
s := New(db, emptyTable(), streamer.New(logger.New(), metrics.New()),
|
|
logger.New(), &config.Config{})
|
|
|
|
handlers := map[string]http.HandlerFunc{
|
|
statusJSONHandlerName: s.handleStatusJSON(),
|
|
statsHandlerName: s.handleStats(),
|
|
}
|
|
|
|
baseline := settledGoroutineCount()
|
|
|
|
const (
|
|
iterations = 20
|
|
requestTimeout = 50 * time.Millisecond
|
|
)
|
|
for _, handler := range handlers {
|
|
for range iterations {
|
|
ctx, cancel := context.WithTimeout(context.Background(), requestTimeout)
|
|
req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/", nil)
|
|
handler(httptest.NewRecorder(), req)
|
|
cancel()
|
|
}
|
|
}
|
|
|
|
// Let the blocked queries finish; with buffered channels each goroutine's
|
|
// send now succeeds and the goroutine exits.
|
|
close(release)
|
|
|
|
if !waitForGoroutines(baseline) {
|
|
t.Fatalf("goroutines did not return to baseline %d, got %d",
|
|
baseline, runtime.NumGoroutine())
|
|
}
|
|
}
|
|
|
|
// tableStats holds the figures of a stats answer that come from the routing
|
|
// table.
|
|
//
|
|
//nolint:tagliatelle // field names are the existing JSON API
|
|
type tableStats struct {
|
|
Prefixes int `json:"prefixes"`
|
|
IPv4Prefixes int `json:"ipv4_prefixes"`
|
|
IPv6Prefixes int `json:"ipv6_prefixes"`
|
|
LiveRoutes int `json:"live_routes"`
|
|
IPv4Routes int `json:"ipv4_routes"`
|
|
IPv6Routes int `json:"ipv6_routes"`
|
|
OriginsWithoutKeptRoute int `json:"origins_without_kept_route"`
|
|
OldestRoute time.Time `json:"oldest_route"`
|
|
NewestRoute time.Time `json:"newest_route"`
|
|
|
|
IPv4Distribution []PrefixDistribution `json:"ipv4_prefix_distribution"`
|
|
IPv6Distribution []PrefixDistribution `json:"ipv6_prefix_distribution"`
|
|
}
|
|
|
|
// TestStatsHandlersAnswerFromMemory checks that both stats handlers answer 200
|
|
// with the routing table's figures while the database is closed, so that any
|
|
// query would fail. The prefix distribution query they used to run read every
|
|
// live route and, on a large database, took the whole 4-second deadline, so
|
|
// /api/v1/stats answered 500 (https://git.eeqj.de/sneak/routewatch/issues/30).
|
|
//
|
|
// The table holds two IPv4 prefixes and an IPv6 one. Of the three peers that
|
|
// carried 203.0.113.0/24, the two with kept routes withdrew it, so its origin
|
|
// keeps no route. The oldest and newest routes are those of the other two
|
|
// prefixes.
|
|
func TestStatsHandlersAnswerFromMemory(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const withdrawnPrefix = "203.0.113.0/24"
|
|
|
|
table := emptyTable()
|
|
announce(table, testPrefixV4, 64502, testPeer(1), testRouteTime(0))
|
|
|
|
for n := 1; n <= 3; n++ {
|
|
announce(table, withdrawnPrefix, 64500, testPeer(n), testRouteTime(n))
|
|
}
|
|
|
|
withdraw(table, withdrawnPrefix, testPeer(2))
|
|
withdraw(table, withdrawnPrefix, testPeer(3))
|
|
announce(table, testPrefixV6, 64501, testPeerV6, testRouteTime(4))
|
|
|
|
db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New())
|
|
if err != nil {
|
|
t.Fatalf("database.New: %v", err)
|
|
}
|
|
|
|
err = db.Close()
|
|
if err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
|
|
s := New(db, table, streamer.New(logger.New(), metrics.New()), logger.New(),
|
|
&config.Config{})
|
|
handlers := map[string]http.HandlerFunc{
|
|
statusJSONHandlerName: s.handleStatusJSON(),
|
|
statsHandlerName: s.handleStats(),
|
|
}
|
|
|
|
want := tableStats{
|
|
Prefixes: 3, IPv4Prefixes: 2, IPv6Prefixes: 1,
|
|
LiveRoutes: 3, IPv4Routes: 2, IPv6Routes: 1,
|
|
OriginsWithoutKeptRoute: 1,
|
|
OldestRoute: testRouteTime(0),
|
|
NewestRoute: testRouteTime(4),
|
|
IPv4Distribution: []PrefixDistribution{{MaskLength: 24, Count: 2}},
|
|
IPv6Distribution: []PrefixDistribution{{MaskLength: 32, Count: 1}},
|
|
}
|
|
|
|
for name, handler := range handlers {
|
|
rec := httptest.NewRecorder()
|
|
handler(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil))
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("%s: status %d, want %d; body %s",
|
|
name, rec.Code, http.StatusOK, rec.Body)
|
|
|
|
continue
|
|
}
|
|
|
|
var body struct {
|
|
Data tableStats `json:"data"`
|
|
}
|
|
|
|
err := json.Unmarshal(rec.Body.Bytes(), &body)
|
|
if err != nil {
|
|
t.Fatalf("%s: decoding the answer: %v", name, err)
|
|
}
|
|
|
|
got := body.Data
|
|
if !got.OldestRoute.Equal(want.OldestRoute) ||
|
|
!got.NewestRoute.Equal(want.NewestRoute) {
|
|
t.Errorf("%s: routes from %v to %v, want from %v to %v", name,
|
|
got.OldestRoute, got.NewestRoute, want.OldestRoute, want.NewestRoute)
|
|
}
|
|
|
|
got.OldestRoute, got.NewestRoute = want.OldestRoute, want.NewestRoute
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Errorf("%s: figures %+v, want %+v", name, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestStatsAnswerCarriesChurnPerMinute checks that the stream object of the
|
|
// /api/v1/stats answer holds route_churn_per_min as a whole number, and no
|
|
// longer route_churn_per_sec, the moving average it replaces
|
|
// (https://git.eeqj.de/sneak/routewatch/issues/160).
|
|
func TestStatsAnswerCarriesChurnPerMinute(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newServerWithTestRoutes(t)
|
|
|
|
rec := httptest.NewRecorder()
|
|
s.handleStats()(rec,
|
|
httptest.NewRequestWithContext(t.Context(), http.MethodGet, statsPath, nil))
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status %d, want %d; body %s", rec.Code, http.StatusOK, rec.Body)
|
|
}
|
|
|
|
var body struct {
|
|
Data struct {
|
|
Stream map[string]json.RawMessage `json:"stream"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
err := json.Unmarshal(rec.Body.Bytes(), &body)
|
|
if err != nil {
|
|
t.Fatalf("decoding the answer: %v", err)
|
|
}
|
|
|
|
churn, ok := body.Data.Stream["route_churn_per_min"]
|
|
if !ok {
|
|
t.Fatalf("stream has no route_churn_per_min: %s", rec.Body)
|
|
}
|
|
|
|
var count int
|
|
|
|
err = json.Unmarshal(churn, &count)
|
|
if err != nil {
|
|
t.Errorf("route_churn_per_min = %s, want a whole number: %v", churn, err)
|
|
}
|
|
|
|
if _, ok := body.Data.Stream["route_churn_per_sec"]; ok {
|
|
t.Errorf("stream still has route_churn_per_sec: %s", rec.Body)
|
|
}
|
|
}
|
|
|
|
// newTestTable returns a routing table that holds a route for testPrefixV4,
|
|
// with origin AS 64500, and one for testPrefixV6, with origin AS 64501.
|
|
func newTestTable() *routetable.Table {
|
|
table := emptyTable()
|
|
announce(table, testPrefixV4, 64500, testPeerV4, testRouteTime(0))
|
|
announce(table, testPrefixV6, 64501, testPeerV6, testRouteTime(0))
|
|
|
|
return table
|
|
}
|
|
|
|
// newServerWithTestRoutes returns a server on a new database that reads the
|
|
// routes of newTestTable.
|
|
func newServerWithTestRoutes(t *testing.T) *Server {
|
|
t.Helper()
|
|
|
|
return newServerWithTable(t, newTestTable())
|
|
}
|
|
|
|
// newServerWithTable returns a server on a new database that reads routes from
|
|
// table.
|
|
func newServerWithTable(t *testing.T, table *routetable.Table) *Server {
|
|
t.Helper()
|
|
|
|
db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New())
|
|
if err != nil {
|
|
t.Fatalf("database.New: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
return New(db, table, streamer.New(logger.New(), metrics.New()), logger.New(),
|
|
&config.Config{})
|
|
}
|
|
|
|
// TestPrefixDetailHandlersParsePrefix checks that both prefix pages find a
|
|
// stored route whatever valid text form the URL writes its prefix in, and
|
|
// answer 400 for a prefix that does not parse. The tables store the form
|
|
// net/netip prints. Before the fix the URL text was looked up as typed, so
|
|
// "2001:DB8:0:0::/32" was not found although "2001:db8::/32" was stored, and
|
|
// "foo/24" answered 500 (https://git.eeqj.de/sneak/routewatch/issues/71). A
|
|
// prefix with its colons percent-encoded, as JavaScript's encodeURIComponent
|
|
// writes it, answered 400 (https://git.eeqj.de/sneak/routewatch/issues/103).
|
|
func TestPrefixDetailHandlersParsePrefix(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newServerWithTestRoutes(t)
|
|
|
|
tests := []struct {
|
|
path string
|
|
want int
|
|
}{
|
|
{"/api/v1/prefix/foo/24", http.StatusBadRequest},
|
|
{"/prefix/foo/24", http.StatusBadRequest},
|
|
{"/api/v1/prefix/2001:DB8:0:0::/32", http.StatusOK},
|
|
{"/prefix/2001:DB8:0:0::/32", http.StatusOK},
|
|
{"/api/v1/prefix/192.0.2.1/24", http.StatusOK},
|
|
{"/prefix/192.0.2.1/24", http.StatusOK},
|
|
{"/api/v1/prefix/2001%3Adb8%3A%3A/32", http.StatusOK},
|
|
{"/prefix/2001%3Adb8%3A%3A/32", http.StatusOK},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
rec := httptest.NewRecorder()
|
|
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, tt.path, nil))
|
|
|
|
if rec.Code != tt.want {
|
|
t.Errorf("%s: status %d, want %d; body %s",
|
|
tt.path, rec.Code, tt.want, rec.Body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestIPLookupsReadPercentEncodedAddresses checks that the IP lookups find the
|
|
// addresses a URL asks for when it percent-encodes them, as the home page's
|
|
// lookup form does with encodeURIComponent. Before the fix the handler read
|
|
// "2001%3Adb8%3A%3A1" undecoded as a host name, which did not resolve, and
|
|
// answered 400, so the home page could look up no IPv6 address
|
|
// (https://git.eeqj.de/sneak/routewatch/issues/103).
|
|
func TestIPLookupsReadPercentEncodedAddresses(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newServerWithTestRoutes(t)
|
|
|
|
tests := []struct {
|
|
path string
|
|
wantIPs string // the addresses of the results, comma-separated
|
|
}{
|
|
{"/ip/2001%3Adb8%3A%3A1", "2001:db8::1"},
|
|
{"/api/v1/ip/192.0.2.1%2C192.0.2.2", "192.0.2.1,192.0.2.2"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
rec := httptest.NewRecorder()
|
|
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, tt.path, nil))
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("%s: status %d, want %d; body %s",
|
|
tt.path, rec.Code, http.StatusOK, rec.Body)
|
|
|
|
continue
|
|
}
|
|
|
|
var body struct {
|
|
Data IPLookupResponse `json:"data"`
|
|
}
|
|
|
|
err := json.Unmarshal(rec.Body.Bytes(), &body)
|
|
if err != nil {
|
|
t.Errorf("%s: decoding the answer %q: %v", tt.path, rec.Body, err)
|
|
|
|
continue
|
|
}
|
|
|
|
gotIPs := make([]string, 0, len(body.Data.Results))
|
|
|
|
for _, result := range body.Data.Results {
|
|
gotIPs = append(gotIPs, result.IP)
|
|
}
|
|
|
|
got := strings.Join(gotIPs, ",")
|
|
if got != tt.wantIPs {
|
|
t.Errorf("%s: results for %q, want %q", tt.path, got, tt.wantIPs)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPrefixDetailShowsOriginNames checks that the prefix page shows each
|
|
// origin AS's handle and description. The AS path column shows handles too, so
|
|
// the test looks for the origin list's "(handle)" form.
|
|
func TestPrefixDetailShowsOriginNames(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New())
|
|
if err != nil {
|
|
t.Fatalf("database.New: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
origins := []int{15169, 13335}
|
|
table := emptyTable()
|
|
|
|
for i, asn := range origins {
|
|
_, err := db.GetOrCreateASN(asn, testRouteTime(0))
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreateASN(%d): %v", asn, err)
|
|
}
|
|
|
|
announce(table, testPrefixV4, asn, testPeer(i+1), testRouteTime(0))
|
|
}
|
|
|
|
s := New(db, table,
|
|
streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
|
|
rec := httptest.NewRecorder()
|
|
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, "/prefix/"+testPrefixV4, nil))
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status %d, want %d; body %s", rec.Code, http.StatusOK, rec.Body)
|
|
}
|
|
|
|
page := rec.Body.String()
|
|
|
|
for _, asn := range origins {
|
|
info, ok := asinfo.Get(asn)
|
|
if !ok {
|
|
t.Fatalf("AS%d is not in the AS info data", asn)
|
|
}
|
|
|
|
want := "(" + html.EscapeString(info.Handle) + ")"
|
|
if !strings.Contains(page, want) {
|
|
t.Errorf("page does not show AS%d's handle %q", asn, want)
|
|
}
|
|
|
|
want = html.EscapeString(info.Description)
|
|
if !strings.Contains(page, want) {
|
|
t.Errorf("page does not show AS%d's description %q", asn, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPagesSayOriginZeroIsNoKnownOrigin stores a route with origin 0, as the
|
|
// prefix handler stores a route whose AS path ends in an AS set of several
|
|
// members, and checks that the prefix page and the prefix length page say it
|
|
// has no known origin, without a link to AS0
|
|
// (https://git.eeqj.de/sneak/routewatch/issues/75).
|
|
func TestPagesSayOriginZeroIsNoKnownOrigin(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
table := emptyTable()
|
|
table.Announce(netip.MustParsePrefix(testPrefixV4), ristypes.NoOrigin,
|
|
routetable.Route{
|
|
Peer: testPeerV4, ASPath: []int{64500, 64501, 64502},
|
|
Time: testRouteTime(0),
|
|
})
|
|
|
|
s := newServerWithTable(t, table)
|
|
|
|
for _, path := range []string{"/prefix/" + testPrefixV4, prefixLengthPagePath} {
|
|
rec := httptest.NewRecorder()
|
|
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, path, nil))
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("%s: status %d, want %d; body %s",
|
|
path, rec.Code, http.StatusOK, rec.Body)
|
|
}
|
|
|
|
page := rec.Body.String()
|
|
|
|
if !strings.Contains(page, "No known origin") {
|
|
t.Errorf("%s does not say the route has no known origin", path)
|
|
}
|
|
|
|
if strings.Contains(page, `href="/as/0"`) {
|
|
t.Errorf("%s links to AS0", path)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestIndexShowsTheClientAddress requests the home page and checks that it has
|
|
// the IP information API card, holding the address /ip/ would look up for the
|
|
// same request: the TCP peer, or, when the peer is a trusted proxy, the client
|
|
// the proxy names in X-Forwarded-For
|
|
// (https://git.eeqj.de/sneak/routewatch/issues/156).
|
|
func TestIndexShowsTheClientAddress(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const cardHeading = "<h2>IP Information API</h2>"
|
|
|
|
// Both requests carry an X-Forwarded-For naming forwardedClient; only a
|
|
// trusted proxy's is believed.
|
|
tests := []struct {
|
|
name string
|
|
trustedProxies []netip.Prefix
|
|
peer string
|
|
want string
|
|
}{
|
|
{"direct request", nil, untrustedPeer, untrustedPeerAddr},
|
|
{
|
|
"request through a trusted proxy",
|
|
[]netip.Prefix{netip.MustParsePrefix(trustedProxyRange)},
|
|
trustedPeer, forwardedClient,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
// The home page reads no route and nothing from the database, so the
|
|
// server has neither a routing table nor a database.
|
|
s := New(nil, nil, streamer.New(logger.New(), metrics.New()), logger.New(),
|
|
&config.Config{TrustedProxies: tt.trustedProxies})
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
|
req.RemoteAddr = tt.peer
|
|
req.Header.Set("X-Forwarded-For", forwardedClient)
|
|
|
|
rec := httptest.NewRecorder()
|
|
s.router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("%s: status %d, want %d; body %s",
|
|
tt.name, rec.Code, http.StatusOK, rec.Body)
|
|
|
|
continue
|
|
}
|
|
|
|
page := rec.Body.String()
|
|
|
|
if !strings.Contains(page, cardHeading) {
|
|
t.Errorf("%s: the page has no IP information API card", tt.name)
|
|
}
|
|
|
|
want := `data-client-ip="` + tt.want + `"`
|
|
if !strings.Contains(page, want) {
|
|
t.Errorf("%s: the card does not hold %s", tt.name, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPagesShowAgesInWords stores routes last updated about 5 minutes, 3 hours
|
|
// and 15 days ago, and checks that the AS page and the prefix pages show each
|
|
// age in words. Before the fix the ages read "5m0s ago", "3h0m0s ago" and
|
|
// "360h0m0s ago" (https://git.eeqj.de/sneak/routewatch/issues/163).
|
|
func TestPagesShowAgesInWords(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Each age is well past a whole number of its unit, so the time the test
|
|
// takes to run does not change the words.
|
|
ages := []struct {
|
|
prefix string
|
|
age time.Duration
|
|
want string
|
|
}{
|
|
{testPrefixV4, 5*time.Minute + 30*time.Second, "5 minutes ago"},
|
|
{"198.51.100.0/24", 3*time.Hour + 30*time.Minute, "3 hours ago"},
|
|
{"203.0.113.0/24", 15 * 24 * time.Hour, "2 weeks ago"},
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
table := emptyTable()
|
|
|
|
for _, a := range ages {
|
|
announce(table, a.prefix, 64500, testPeerV4, now.Add(-a.age))
|
|
}
|
|
|
|
s := newServerWithTable(t, table)
|
|
|
|
for _, a := range ages {
|
|
cell := `<td class="age">` + a.want + `</td>`
|
|
|
|
for _, path := range []string{asPagePath, "/prefix/" + a.prefix} {
|
|
rec := httptest.NewRecorder()
|
|
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, path, nil))
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("%s: status %d, want %d; body %s",
|
|
path, rec.Code, http.StatusOK, rec.Body)
|
|
}
|
|
|
|
if !strings.Contains(rec.Body.String(), cell) {
|
|
t.Errorf("%s does not show the age of the route for %s as %q",
|
|
path, a.prefix, a.want)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// shortRequestDeadline is the deadline the tests below give a request. The
|
|
// server's 30-second limit and the stats handlers' 4-second limit inherit it,
|
|
// so the tests do not wait for either.
|
|
const shortRequestDeadline = 50 * time.Millisecond
|
|
|
|
// TestRequestPastTimeLimitAnswers504 sends a request through the server's
|
|
// router to a page and to a JSON route whose handlers wait for the time limit
|
|
// to pass and return without answering. The page answers 504; the JSON route
|
|
// answers 504 with the JSON error body. Before the fix both answered 408,
|
|
// written by the time limit middleware from its own goroutine while the
|
|
// handler could still be writing.
|
|
func TestRequestPastTimeLimitAnswers504(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := New(nil, nil, streamer.New(logger.New(), metrics.New()), logger.New(),
|
|
&config.Config{})
|
|
|
|
waitForTimeLimit := func(_ http.ResponseWriter, r *http.Request) {
|
|
<-r.Context().Done()
|
|
}
|
|
// Registered the way setupRoutes registers pages and JSON routes.
|
|
s.router.Get("/slow-page", waitForTimeLimit)
|
|
s.router.Get("/slow.json",
|
|
JSONValidationMiddleware(http.HandlerFunc(waitForTimeLimit)).ServeHTTP)
|
|
|
|
tests := []struct {
|
|
path string
|
|
wantJSONBody bool
|
|
}{
|
|
{path: "/slow-page"},
|
|
{path: "/slow.json", wantJSONBody: true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
ctx, cancel := context.WithTimeout(context.Background(),
|
|
shortRequestDeadline)
|
|
rec := httptest.NewRecorder()
|
|
s.router.ServeHTTP(rec,
|
|
httptest.NewRequestWithContext(ctx, http.MethodGet, tt.path, nil))
|
|
cancel()
|
|
|
|
if rec.Code != http.StatusGatewayTimeout {
|
|
t.Errorf("%s: status %d, want %d; body %s",
|
|
tt.path, rec.Code, http.StatusGatewayTimeout, rec.Body)
|
|
}
|
|
|
|
if tt.wantJSONBody {
|
|
checkJSONErrorCode(t, tt.path, rec, http.StatusGatewayTimeout)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestStatsHandlersAnswer504WhenDatabaseReadRunsOut calls each stats handler
|
|
// with a store that does not answer before the handler's 4-second limit runs
|
|
// out, and checks that the handler itself answers 504 with the JSON error
|
|
// body. Before the fix status.json answered 408, and /api/v1/stats wrote
|
|
// nothing, which the JSON check on its route turned into 500.
|
|
func TestStatsHandlersAnswer504WhenDatabaseReadRunsOut(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
release := make(chan struct{})
|
|
defer close(release)
|
|
|
|
db := blockingStatsDB{release: release}
|
|
s := New(db, emptyTable(), streamer.New(logger.New(), metrics.New()),
|
|
logger.New(), &config.Config{})
|
|
|
|
handlers := map[string]http.HandlerFunc{
|
|
statusJSONHandlerName: s.handleStatusJSON(),
|
|
statsHandlerName: s.handleStats(),
|
|
}
|
|
|
|
for name, handler := range handlers {
|
|
ctx, cancel := context.WithTimeout(context.Background(),
|
|
shortRequestDeadline)
|
|
rec := httptest.NewRecorder()
|
|
handler(rec, httptest.NewRequestWithContext(ctx, http.MethodGet, "/", nil))
|
|
cancel()
|
|
|
|
if rec.Code != http.StatusGatewayTimeout {
|
|
t.Errorf("%s: status %d, want %d; body %s",
|
|
name, rec.Code, http.StatusGatewayTimeout, rec.Body)
|
|
}
|
|
|
|
checkJSONErrorCode(t, name, rec, http.StatusGatewayTimeout)
|
|
}
|
|
}
|
|
|
|
// slowDB embeds database.Store (left nil) and overrides the reads the routes
|
|
// in TestRoutesAnswer504WhenDatabaseReadIsCutOff make. Each read waits until
|
|
// its context ends and then answers as the real store does when the time
|
|
// limit cuts it off: the stats read with what it had read, here nothing, and
|
|
// no error, and the others with the context's error. Every other Store method
|
|
// is unused and would panic if called.
|
|
type slowDB struct {
|
|
database.Store
|
|
}
|
|
|
|
func (slowDB) GetStatsContext(ctx context.Context) (database.Stats, error) {
|
|
<-ctx.Done()
|
|
|
|
return database.Stats{}, nil
|
|
}
|
|
|
|
func (slowDB) GetAS(ctx context.Context, _ int) (*database.ASN, bool, error) {
|
|
<-ctx.Done()
|
|
|
|
return nil, false, ctx.Err()
|
|
}
|
|
|
|
func (slowDB) GetPrefixFirstSeen(
|
|
ctx context.Context, _ netip.Prefix,
|
|
) (time.Time, error) {
|
|
<-ctx.Done()
|
|
|
|
return time.Time{}, ctx.Err()
|
|
}
|
|
|
|
// TestRoutesAnswer504WhenDatabaseReadIsCutOff sends a request through the
|
|
// server's router for each route that reads the database, with a store whose
|
|
// reads are cut off when the request's time limit passes. Each route answers
|
|
// 504 with the time limit message. Without its check after the read, a route
|
|
// answers with what the cut-off read returned: 500 or 200. The health check is
|
|
// left out: it answers 503 when its own 2-second ping runs out. The prefix
|
|
// length pages and the prefix API read only the routing table.
|
|
func TestRoutesAnswer504WhenDatabaseReadIsCutOff(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := New(slowDB{}, newTestTable(), streamer.New(logger.New(), metrics.New()),
|
|
logger.New(), &config.Config{})
|
|
|
|
tests := []struct {
|
|
path string
|
|
wantJSONBody bool
|
|
}{
|
|
{path: asPagePath},
|
|
{path: "/prefix/" + testPrefixV4},
|
|
{path: statusJSONPath, wantJSONBody: true},
|
|
{path: "/ip/" + addrInTestPrefixV4, wantJSONBody: true},
|
|
{path: statsPath, wantJSONBody: true},
|
|
{path: "/api/v1/ip/" + addrInTestPrefixV4, wantJSONBody: true},
|
|
{path: "/api/v1/as/64500", wantJSONBody: true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
checkRouteAnswers504(t, s, tt.path, tt.wantJSONBody)
|
|
}
|
|
}
|
|
|
|
// slowSecondReadDB embeds database.Store (left nil) and overrides the reads
|
|
// of the routes that read the database twice. The first read answers at once;
|
|
// the second, the AS page's peers read or the stats routes' WHOIS read, waits
|
|
// until its context ends and then answers with the context's error, as the
|
|
// real store does.
|
|
type slowSecondReadDB struct {
|
|
database.Store
|
|
}
|
|
|
|
func (slowSecondReadDB) GetAS(_ context.Context, asn int) (*database.ASN, bool, error) {
|
|
return &database.ASN{ASN: asn}, true, nil
|
|
}
|
|
|
|
func (slowSecondReadDB) GetASPeersContext(
|
|
ctx context.Context, _ int,
|
|
) ([]database.ASPeer, error) {
|
|
<-ctx.Done()
|
|
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
func (slowSecondReadDB) GetStatsContext(_ context.Context) (database.Stats, error) {
|
|
return database.Stats{}, nil
|
|
}
|
|
|
|
func (slowSecondReadDB) GetWHOISStats(
|
|
ctx context.Context, _ time.Duration,
|
|
) (*database.WHOISStats, error) {
|
|
<-ctx.Done()
|
|
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
// idleASNFetcher is a WHOIS fetcher that does nothing. The stats handlers
|
|
// read the WHOIS stats only when the server has a fetcher.
|
|
type idleASNFetcher struct{}
|
|
|
|
func (idleASNFetcher) QueueImmediate(int) {}
|
|
|
|
func (idleASNFetcher) GetStats() ASNFetcherStats {
|
|
return ASNFetcherStats{}
|
|
}
|
|
|
|
// TestRoutesAnswer504WhenSecondDatabaseReadIsCutOff sends a request through
|
|
// the server's router for each route that reads the database twice, with a
|
|
// store that answers the first read and cuts off the second when the
|
|
// request's time limit passes. Each route answers 504 with the time limit
|
|
// message. Without the check after the second read, the AS page answers 504
|
|
// with an empty body and the stats routes answer 200 without the WHOIS stats.
|
|
func TestRoutesAnswer504WhenSecondDatabaseReadIsCutOff(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := New(slowSecondReadDB{}, emptyTable(),
|
|
streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
|
|
s.SetASNFetcher(idleASNFetcher{})
|
|
|
|
tests := []struct {
|
|
path string
|
|
wantJSONBody bool
|
|
}{
|
|
{path: asPagePath},
|
|
{path: statusJSONPath, wantJSONBody: true},
|
|
{path: statsPath, wantJSONBody: true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
checkRouteAnswers504(t, s, tt.path, tt.wantJSONBody)
|
|
}
|
|
}
|
|
|
|
// checkRouteAnswers504 sends a request for path through the server's router
|
|
// with a short deadline and checks that it answers 504 with the time limit
|
|
// message: the JSON error body on JSON routes, the plain message on pages.
|
|
func checkRouteAnswers504(
|
|
t *testing.T, s *Server, path string, wantJSONBody bool,
|
|
) {
|
|
t.Helper()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), shortRequestDeadline)
|
|
defer cancel()
|
|
|
|
rec := httptest.NewRecorder()
|
|
s.router.ServeHTTP(rec,
|
|
httptest.NewRequestWithContext(ctx, http.MethodGet, path, nil))
|
|
|
|
if rec.Code != http.StatusGatewayTimeout {
|
|
t.Errorf("%s: status %d, want %d; body %s",
|
|
path, rec.Code, http.StatusGatewayTimeout, rec.Body)
|
|
}
|
|
|
|
if wantJSONBody {
|
|
checkJSONErrorCode(t, path, rec, http.StatusGatewayTimeout)
|
|
|
|
return
|
|
}
|
|
|
|
body := strings.TrimSpace(rec.Body.String())
|
|
if body != requestTimeoutMessage {
|
|
t.Errorf("%s: body %q, want %q", path, body, requestTimeoutMessage)
|
|
}
|
|
}
|
|
|
|
// checkJSONErrorCode checks that rec holds the JSON error body with the given
|
|
// code.
|
|
func checkJSONErrorCode(
|
|
t *testing.T, name string, rec *httptest.ResponseRecorder, want int,
|
|
) {
|
|
t.Helper()
|
|
|
|
var body struct {
|
|
Status string `json:"status"`
|
|
Error struct {
|
|
Code int `json:"code"`
|
|
} `json:"error"`
|
|
}
|
|
|
|
err := json.Unmarshal(rec.Body.Bytes(), &body)
|
|
if err != nil {
|
|
t.Errorf("%s: decoding the answer %q: %v", name, rec.Body, err)
|
|
|
|
return
|
|
}
|
|
|
|
if body.Status != jsonStatusError || body.Error.Code != want {
|
|
t.Errorf("%s: answer %s, want status \"error\" and error code %d",
|
|
name, rec.Body, want)
|
|
}
|
|
}
|
|
|
|
// errDiskIO is the database driver's error failingDB answers with.
|
|
var errDiskIO = errors.New("disk I/O error")
|
|
|
|
// failingDB embeds database.Store (left nil) and fails the reads of the JSON
|
|
// routes and the health check's ping with errDiskIO. Every other Store method
|
|
// is unused and would panic if called.
|
|
type failingDB struct {
|
|
database.Store
|
|
}
|
|
|
|
func (failingDB) Ping(_ context.Context) error {
|
|
return errDiskIO
|
|
}
|
|
|
|
func (failingDB) GetStatsContext(_ context.Context) (database.Stats, error) {
|
|
return database.Stats{}, errDiskIO
|
|
}
|
|
|
|
func (failingDB) GetAS(_ context.Context, _ int) (*database.ASN, bool, error) {
|
|
return nil, false, errDiskIO
|
|
}
|
|
|
|
func (failingDB) GetPrefixFirstSeen(
|
|
_ context.Context, _ netip.Prefix,
|
|
) (time.Time, error) {
|
|
return time.Time{}, errDiskIO
|
|
}
|
|
|
|
// TestJSONRoutesDoNotSendErrorDetails sends a request to each JSON route that
|
|
// reads the database, and to the health check, with a store whose reads fail
|
|
// with the driver's error text. It also sends a prefix that does not parse.
|
|
// No answer may carry the driver's or the prefix parser's text. Before the fix
|
|
// the answers sent it to the client
|
|
// (https://git.eeqj.de/sneak/routewatch/issues/108).
|
|
func TestJSONRoutesDoNotSendErrorDetails(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := New(failingDB{}, newTestTable(), streamer.New(logger.New(), metrics.New()),
|
|
logger.New(), &config.Config{})
|
|
|
|
tests := []struct {
|
|
path string
|
|
wantCode int
|
|
}{
|
|
{"/.well-known/healthcheck.json", http.StatusServiceUnavailable},
|
|
{statusJSONPath, http.StatusInternalServerError},
|
|
{statsPath, http.StatusInternalServerError},
|
|
{"/api/v1/as/64500", http.StatusInternalServerError},
|
|
{"/api/v1/ip/" + addrInTestPrefixV4, http.StatusInternalServerError},
|
|
{"/api/v1/prefix/foo/24", http.StatusBadRequest},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
rec := httptest.NewRecorder()
|
|
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, tt.path, nil))
|
|
|
|
if rec.Code != tt.wantCode {
|
|
t.Errorf("%s: status %d, want %d; body %s",
|
|
tt.path, rec.Code, tt.wantCode, rec.Body)
|
|
}
|
|
|
|
for _, detail := range []string{errDiskIO.Error(), "netip"} {
|
|
if strings.Contains(rec.Body.String(), detail) {
|
|
t.Errorf("%s: body %s carries the error text %q",
|
|
tt.path, rec.Body, detail)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The address whose read failingIPLookupDB fails, in testPrefixV4, and one it
|
|
// reads, in testPrefixV6.
|
|
const (
|
|
failingLookupIP = addrInTestPrefixV4
|
|
routedLookupIP = addrInTestPrefixV6
|
|
)
|
|
|
|
// failingIPLookupDB embeds database.Store (left nil). It fails the read of
|
|
// testPrefixV4's first-seen time with errDiskIO, and answers the IP lookup's
|
|
// other reads with nothing stored. Every other Store method is unused and
|
|
// would panic if called.
|
|
type failingIPLookupDB struct {
|
|
database.Store
|
|
}
|
|
|
|
func (failingIPLookupDB) GetPrefixFirstSeen(
|
|
_ context.Context, prefix netip.Prefix,
|
|
) (time.Time, error) {
|
|
if prefix == netip.MustParsePrefix(testPrefixV4) {
|
|
return time.Time{}, errDiskIO
|
|
}
|
|
|
|
return time.Time{}, nil
|
|
}
|
|
|
|
func (failingIPLookupDB) GetASWHOIS(_ context.Context, asn int) (*database.ASN, error) {
|
|
return &database.ASN{ASN: asn}, nil
|
|
}
|
|
|
|
// TestIPLookupAnswers500WhenDatabaseReadFails sends IP lookups through the
|
|
// server's router with a store whose read for one address fails with the
|
|
// driver's error. A lookup that asks for that address answers 500 with the
|
|
// JSON error body, as the AS JSON route does; an address with no route answers
|
|
// 200. Before the fix the failed read was reported as if the address had no
|
|
// route: 404 for that address alone, and 200 beside a routed address
|
|
// (https://git.eeqj.de/sneak/routewatch/issues/109).
|
|
func TestIPLookupAnswers500WhenDatabaseReadFails(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := New(failingIPLookupDB{}, newTestTable(),
|
|
streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
|
|
s.resolver = &fakeResolver{}
|
|
|
|
tests := []struct {
|
|
path string
|
|
wantCode int
|
|
}{
|
|
{
|
|
path: "/api/v1/ip/" + failingLookupIP,
|
|
wantCode: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
path: "/api/v1/ip/" + failingLookupIP + "," + routedLookupIP,
|
|
wantCode: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
path: "/api/v1/ip/203.0.113.1",
|
|
wantCode: http.StatusOK,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
rec := httptest.NewRecorder()
|
|
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, tt.path, nil))
|
|
|
|
if rec.Code != tt.wantCode {
|
|
t.Errorf("%s: status %d, want %d; body %s",
|
|
tt.path, rec.Code, tt.wantCode, rec.Body)
|
|
}
|
|
|
|
if tt.wantCode != http.StatusOK {
|
|
checkJSONErrorCode(t, tt.path, rec, tt.wantCode)
|
|
}
|
|
}
|
|
}
|
|
|
|
// errNoSuchHost is what fakeResolver answers a lookup it has no answer for
|
|
// with.
|
|
var errNoSuchHost = errors.New("no such host")
|
|
|
|
// fakeResolver answers a host name lookup with the addresses hosts lists for
|
|
// the name, and a PTR lookup with no names or, when ptrNeverAnswers is set,
|
|
// not at all: it waits until the lookup's context ends. hostLookups counts the
|
|
// host name lookups.
|
|
type fakeResolver struct {
|
|
hosts map[string][]string
|
|
ptrNeverAnswers bool
|
|
|
|
hostLookups atomic.Int64
|
|
}
|
|
|
|
func (r *fakeResolver) LookupHost(_ context.Context, host string) ([]string, error) {
|
|
r.hostLookups.Add(1)
|
|
|
|
addrs, ok := r.hosts[host]
|
|
if !ok {
|
|
return nil, errNoSuchHost
|
|
}
|
|
|
|
return addrs, nil
|
|
}
|
|
|
|
func (r *fakeResolver) LookupAddr(ctx context.Context, _ string) ([]string, error) {
|
|
if r.ptrNeverAnswers {
|
|
<-ctx.Done()
|
|
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
return nil, errNoSuchHost
|
|
}
|
|
|
|
// countingIPLookupDB embeds database.Store (left nil) and answers the IP
|
|
// lookup's reads with nothing stored. It counts the lookups, as the reads of a
|
|
// prefix's first-seen time. Every other Store method is unused and would panic
|
|
// if called.
|
|
type countingIPLookupDB struct {
|
|
database.Store
|
|
|
|
lookups atomic.Int64
|
|
}
|
|
|
|
func (d *countingIPLookupDB) GetPrefixFirstSeen(
|
|
_ context.Context, _ netip.Prefix,
|
|
) (time.Time, error) {
|
|
d.lookups.Add(1)
|
|
|
|
return time.Time{}, nil
|
|
}
|
|
|
|
func (d *countingIPLookupDB) GetASWHOIS(
|
|
_ context.Context, asn int,
|
|
) (*database.ASN, error) {
|
|
return &database.ASN{ASN: asn}, nil
|
|
}
|
|
|
|
// TestIPLookupRefusesTooManyAddresses sends IP lookups of maxLookupTargets
|
|
// addresses in testPrefixV4 and of one more, listed or as a host name that
|
|
// resolves to them, and a lookup listing maxLookupTargets+1 host names. The
|
|
// longer lookups answer 400 without reading the database, and the list of host
|
|
// names without resolving any of them; the others read the database for each
|
|
// address. Before the fix every address was read and every name resolved,
|
|
// however many there were (https://git.eeqj.de/sneak/routewatch/issues/110).
|
|
func TestIPLookupRefusesTooManyAddresses(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
addrs := make([]string, maxLookupTargets+1)
|
|
names := make([]string, maxLookupTargets+1)
|
|
|
|
for i := range addrs {
|
|
addrs[i] = "192.0.2." + strconv.Itoa(i+1)
|
|
names[i] = "host" + strconv.Itoa(i+1) + ".test"
|
|
}
|
|
|
|
hosts := map[string][]string{
|
|
"most.test": addrs[:maxLookupTargets],
|
|
"too-many.test": addrs,
|
|
}
|
|
|
|
tests := []struct {
|
|
target string
|
|
wantCode int
|
|
wantLookups int64
|
|
wantHostLookups int64
|
|
}{
|
|
{
|
|
strings.Join(addrs[:maxLookupTargets], ","),
|
|
http.StatusOK, maxLookupTargets, 0,
|
|
},
|
|
{strings.Join(addrs, ","), http.StatusBadRequest, 0, 0},
|
|
{"most.test", http.StatusOK, maxLookupTargets, 1},
|
|
{"too-many.test", http.StatusBadRequest, 0, 1},
|
|
{strings.Join(names, ","), http.StatusBadRequest, 0, 0},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
db := &countingIPLookupDB{}
|
|
s := New(db, newTestTable(), streamer.New(logger.New(), metrics.New()),
|
|
logger.New(), &config.Config{})
|
|
resolver := &fakeResolver{hosts: hosts}
|
|
s.resolver = resolver
|
|
|
|
rec := httptest.NewRecorder()
|
|
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, "/api/v1/ip/"+tt.target, nil))
|
|
|
|
if rec.Code != tt.wantCode {
|
|
t.Errorf("%s: status %d, want %d; body %s",
|
|
tt.target, rec.Code, tt.wantCode, rec.Body)
|
|
}
|
|
|
|
if tt.wantCode != http.StatusOK {
|
|
checkJSONErrorCode(t, tt.target, rec, tt.wantCode)
|
|
}
|
|
|
|
got := db.lookups.Load()
|
|
if got != tt.wantLookups {
|
|
t.Errorf("%s: %d database reads, want %d",
|
|
tt.target, got, tt.wantLookups)
|
|
}
|
|
|
|
gotHostLookups := resolver.hostLookups.Load()
|
|
if gotHostLookups != tt.wantHostLookups {
|
|
t.Errorf("%s: %d host name lookups, want %d",
|
|
tt.target, gotHostLookups, tt.wantHostLookups)
|
|
}
|
|
}
|
|
}
|
|
|
|
// addrInTestPrefixV4 is an address in testPrefixV4, which
|
|
// newServerWithTestRoutes stores a route for.
|
|
const addrInTestPrefixV4 = "192.0.2.1"
|
|
|
|
// TestIPLookupKeepsRouteWhenPTRLookupDoesNotAnswer looks up a routed address
|
|
// with a resolver that never answers its PTR lookup. Once the PTR lookup's own
|
|
// limit passes, the lookup answers 200 with the route. Before the fix the PTR
|
|
// lookup ran until the request's time limit, and the lookup answered 504
|
|
// without the route (https://git.eeqj.de/sneak/routewatch/issues/110).
|
|
func TestIPLookupKeepsRouteWhenPTRLookupDoesNotAnswer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newServerWithTestRoutes(t)
|
|
s.resolver = &fakeResolver{ptrNeverAnswers: true}
|
|
|
|
// The request's time limit passes after the PTR lookup's.
|
|
ctx, cancel := context.WithTimeout(t.Context(), 2*ptrLookupTimeout)
|
|
defer cancel()
|
|
|
|
rec := httptest.NewRecorder()
|
|
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
|
ctx, http.MethodGet, "/api/v1/ip/"+addrInTestPrefixV4, nil))
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status %d, want %d; body %s", rec.Code, http.StatusOK, rec.Body)
|
|
}
|
|
|
|
var body struct {
|
|
Data IPLookupResponse `json:"data"`
|
|
}
|
|
|
|
err := json.Unmarshal(rec.Body.Bytes(), &body)
|
|
if err != nil {
|
|
t.Fatalf("decoding the answer %q: %v", rec.Body, err)
|
|
}
|
|
|
|
if len(body.Data.Results) != 1 || body.Data.Results[0].Netblock == nil ||
|
|
*body.Data.Results[0].Netblock != testPrefixV4 {
|
|
t.Errorf("answer %s, want one result in %s", rec.Body, testPrefixV4)
|
|
}
|
|
}
|
|
|
|
// addrInTestPrefixV6 is an address in testPrefixV6, which
|
|
// newServerWithTestRoutes stores a route for.
|
|
const addrInTestPrefixV6 = "2001:db8::1"
|
|
|
|
// testBlocklistInterval is fakeBlocklists' shortest interval.
|
|
const testBlocklistInterval = 15 * time.Minute
|
|
|
|
// ownAddressPath is the IP lookup of the caller's own address.
|
|
const ownAddressPath = "/ip/"
|
|
|
|
// listedUnroutedAddr is an address that fakeBlocklists lists, outside the
|
|
// routes newServerWithTestRoutes stores.
|
|
const listedUnroutedAddr = "203.0.113.1"
|
|
|
|
// fakeBlocklists has one list, which lists addrInTestPrefixV4 and
|
|
// listedUnroutedAddr and no other address.
|
|
type fakeBlocklists struct{}
|
|
|
|
func testListing() blocklist.Listing {
|
|
return blocklist.Listing{
|
|
Name: "test_list",
|
|
Description: "On the test list",
|
|
Updated: time.Date(2026, 10, 27, 4, 0, 0, 0, time.UTC),
|
|
}
|
|
}
|
|
|
|
func (fakeBlocklists) Lookup(addr netip.Addr) []blocklist.Listing {
|
|
if addr == netip.MustParseAddr(addrInTestPrefixV4) ||
|
|
addr == netip.MustParseAddr(listedUnroutedAddr) {
|
|
return []blocklist.Listing{testListing()}
|
|
}
|
|
|
|
return []blocklist.Listing{}
|
|
}
|
|
|
|
func (fakeBlocklists) ShortestInterval() time.Duration {
|
|
return testBlocklistInterval
|
|
}
|
|
|
|
// TestIPLookupReportsBlocklists looks up two routed addresses, one of them on
|
|
// a blocklist. The listed address's result names the list, says what it means
|
|
// and when it was updated. The other's result has an empty list.
|
|
func TestIPLookupReportsBlocklists(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newServerWithTestRoutes(t)
|
|
s.resolver = &fakeResolver{}
|
|
s.SetBlocklists(fakeBlocklists{})
|
|
|
|
rec := httptest.NewRecorder()
|
|
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(),
|
|
http.MethodGet,
|
|
"/api/v1/ip/"+addrInTestPrefixV4+","+addrInTestPrefixV6, nil))
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status %d, want %d; body %s", rec.Code, http.StatusOK, rec.Body)
|
|
}
|
|
|
|
var body struct {
|
|
Data IPLookupResponse `json:"data"`
|
|
}
|
|
|
|
err := json.Unmarshal(rec.Body.Bytes(), &body)
|
|
if err != nil {
|
|
t.Fatalf("decoding the answer %q: %v", rec.Body, err)
|
|
}
|
|
|
|
results := body.Data.Results
|
|
if len(results) != 2 {
|
|
t.Fatalf("answer %s, want two results", rec.Body)
|
|
}
|
|
|
|
want := testListing()
|
|
|
|
listed := results[0].Blocklists
|
|
if len(listed) != 1 || listed[0].Name != want.Name ||
|
|
listed[0].Description != want.Description ||
|
|
!listed[0].Updated.Equal(want.Updated) {
|
|
t.Errorf("%s: blocklists %+v, want [%+v]", results[0].IP, listed, want)
|
|
}
|
|
|
|
if !strings.Contains(rec.Body.String(), `"blocklists":[]`) {
|
|
t.Errorf("answer %s, want an empty blocklists list for %s",
|
|
rec.Body, addrInTestPrefixV6)
|
|
}
|
|
}
|
|
|
|
// jsonNull is how JSON writes a field without a value.
|
|
const jsonNull = "null"
|
|
|
|
// jsonEmptyList is how JSON writes an empty list.
|
|
const jsonEmptyList = "[]"
|
|
|
|
// resultFields decodes an IP lookup answer and returns each of its results as
|
|
// its JSON fields.
|
|
func resultFields(t *testing.T, answer []byte) []map[string]json.RawMessage {
|
|
t.Helper()
|
|
|
|
var body struct {
|
|
Data struct {
|
|
Results []map[string]json.RawMessage `json:"results"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
err := json.Unmarshal(answer, &body)
|
|
if err != nil {
|
|
t.Fatalf("decoding the answer %s: %v", answer, err)
|
|
}
|
|
|
|
return body.Data.Results
|
|
}
|
|
|
|
// TestIPLookupReportsBlocklistsOfAnAddressWithNoRoute looks up a listed
|
|
// address that has no live route, alone and beside a routed address. Each
|
|
// answer is 200 and has a result for the address, with its blocklists and
|
|
// every route field set to null. Before the fix the lookup of that address
|
|
// alone answered 404, and beside a routed address it was only named in
|
|
// errors, so its blocklists were never reported. After that fix the result
|
|
// still left out the route fields
|
|
// (https://git.eeqj.de/sneak/routewatch/issues/200).
|
|
func TestIPLookupReportsBlocklistsOfAnAddressWithNoRoute(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newServerWithTestRoutes(t)
|
|
s.resolver = &fakeResolver{}
|
|
s.SetBlocklists(fakeBlocklists{})
|
|
|
|
// routeFields are the fields of a result that come from the address's
|
|
// route and that route's origin AS.
|
|
routeFields := []string{
|
|
"netblock", "mask_length", "ip_version", "num_peers", "asn",
|
|
"num_origins", "origin_asns", "as_name", "handle", "description",
|
|
"org_name", "org_id", "address", "country_code", "abuse_email", "rir",
|
|
"first_seen", "last_seen",
|
|
}
|
|
|
|
for _, target := range []string{
|
|
listedUnroutedAddr,
|
|
addrInTestPrefixV4 + "," + listedUnroutedAddr,
|
|
} {
|
|
rec := httptest.NewRecorder()
|
|
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(),
|
|
http.MethodGet, "/api/v1/ip/"+target, nil))
|
|
|
|
var body struct {
|
|
Data IPLookupResponse `json:"data"`
|
|
}
|
|
|
|
err := json.Unmarshal(rec.Body.Bytes(), &body)
|
|
if rec.Code != http.StatusOK || err != nil {
|
|
t.Errorf("%s: status %d (%v), want %d; body %s",
|
|
target, rec.Code, err, http.StatusOK, rec.Body)
|
|
|
|
continue
|
|
}
|
|
|
|
i := slices.IndexFunc(body.Data.Results, func(r IPLookupResult) bool {
|
|
return r.IP == listedUnroutedAddr
|
|
})
|
|
if i < 0 || len(body.Data.Errors) != 0 {
|
|
t.Errorf("%s: answer %s, want a result for %s and no errors",
|
|
target, rec.Body, listedUnroutedAddr)
|
|
|
|
continue
|
|
}
|
|
|
|
fields := resultFields(t, rec.Body.Bytes())[i]
|
|
for _, name := range routeFields {
|
|
value, present := fields[name]
|
|
|
|
switch {
|
|
case !present:
|
|
t.Errorf("%s: the result for %s has no %s",
|
|
target, listedUnroutedAddr, name)
|
|
case string(value) != jsonNull:
|
|
t.Errorf("%s: %s of %s is %s, want %s",
|
|
target, name, listedUnroutedAddr, value, jsonNull)
|
|
}
|
|
}
|
|
|
|
result := body.Data.Results[i]
|
|
if len(result.Blocklists) != 1 ||
|
|
result.Blocklists[0].Name != testListing().Name {
|
|
t.Errorf("%s: blocklists %+v, want [%+v]",
|
|
target, result.Blocklists, testListing())
|
|
}
|
|
}
|
|
}
|
|
|
|
// twoAddressHostName is a host name that resolves to addrInTestPrefixV4 and
|
|
// listedUnroutedAddr.
|
|
const twoAddressHostName = "two-addresses.test"
|
|
|
|
// TestIPLookupResultsHaveTheSameFields looks up a host name, addresses and
|
|
// the caller's own address, through each IP lookup route. Some of the
|
|
// addresses have a route and some do not, and none has PTR names. Every
|
|
// result has the same fields. Before the fix a result left out every field
|
|
// without a value (https://git.eeqj.de/sneak/routewatch/issues/200).
|
|
func TestIPLookupResultsHaveTheSameFields(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newServerWithTestRoutes(t)
|
|
s.resolver = &fakeResolver{hosts: map[string][]string{
|
|
twoAddressHostName: {addrInTestPrefixV4, listedUnroutedAddr},
|
|
}}
|
|
s.SetBlocklists(fakeBlocklists{})
|
|
|
|
// want is the fields of the first result.
|
|
var want []string
|
|
|
|
for _, path := range []string{
|
|
"/api/v1/ip/" + twoAddressHostName,
|
|
"/ip/" + addrInTestPrefixV6 + "," + listedUnroutedAddr,
|
|
ownAddressPath,
|
|
} {
|
|
for _, result := range resultFields(t, get(t, s, path).Body.Bytes()) {
|
|
got := slices.Sorted(maps.Keys(result))
|
|
if want == nil {
|
|
want = got
|
|
}
|
|
|
|
if !slices.Equal(got, want) {
|
|
t.Errorf("%s: a result has the fields %v, want %v", path, got, want)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestIPLookupOfAnAddressWithoutPTRNamesOrStoredNetblock looks up a routed
|
|
// address that has no PTR names, and whose netblock the database has not
|
|
// stored. Its ptr is an empty list and its first_seen is null. Before the fix
|
|
// ptr was left out, and first_seen was 0001-01-01T00:00:00Z
|
|
// (https://git.eeqj.de/sneak/routewatch/issues/200).
|
|
func TestIPLookupOfAnAddressWithoutPTRNamesOrStoredNetblock(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newServerWithTestRoutes(t)
|
|
s.resolver = &fakeResolver{}
|
|
|
|
path := "/api/v1/ip/" + addrInTestPrefixV4
|
|
|
|
results := resultFields(t, get(t, s, path).Body.Bytes())
|
|
if len(results) != 1 {
|
|
t.Fatalf("%s: %d results, want 1", path, len(results))
|
|
}
|
|
|
|
want := map[string]string{
|
|
"ptr": jsonEmptyList,
|
|
"first_seen": jsonNull,
|
|
}
|
|
|
|
for name, value := range want {
|
|
got := string(results[0][name])
|
|
if got != value {
|
|
t.Errorf("%s: %s is %s, want %s", path, name, got, value)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestIPLookupAnswersMayBeCached checks the Cache-Control header of the IP
|
|
// lookup answers. Caches may keep them for the blocklists' shortest interval,
|
|
// and only the caller's own browser may keep the answer about the caller's
|
|
// own address.
|
|
func TestIPLookupAnswersMayBeCached(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newServerWithTestRoutes(t)
|
|
s.resolver = &fakeResolver{}
|
|
s.SetBlocklists(fakeBlocklists{})
|
|
|
|
tests := []struct {
|
|
path string
|
|
want string
|
|
}{
|
|
{"/api/v1/ip/" + addrInTestPrefixV4, "public, max-age=900"},
|
|
{ownAddressPath + addrInTestPrefixV4, "public, max-age=900"},
|
|
{ownAddressPath, "private, max-age=900"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
|
tt.path, nil)
|
|
req.RemoteAddr = net.JoinHostPort(addrInTestPrefixV4, "1234")
|
|
|
|
rec := httptest.NewRecorder()
|
|
s.router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("%s: status %d, want %d; body %s",
|
|
tt.path, rec.Code, http.StatusOK, rec.Body)
|
|
|
|
continue
|
|
}
|
|
|
|
if got := rec.Header().Get(cacheControlHeader); got != tt.want {
|
|
t.Errorf("%s: Cache-Control %q, want %q", tt.path, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The host name TestIPLookupHidesPrivateAddresses resolves to a private
|
|
// address, and that address.
|
|
const (
|
|
internalHostName = "internal.test"
|
|
internalHostAddr = "10.0.0.5"
|
|
)
|
|
|
|
// TestIPLookupHidesPrivateAddresses looks up host names that resolve to a
|
|
// private address, alone or beside a routed address. No answer carries the
|
|
// private address, and a name with no other address is reported as having no
|
|
// public address, in the 400 when nothing else is left to look up. Before the
|
|
// fix the private address was looked up and named in the answer, "No routes
|
|
// found: 10.0.0.5: no route found for IP 10.0.0.5", so the API showed what the
|
|
// deployment's own DNS knows (https://git.eeqj.de/sneak/routewatch/issues/110).
|
|
func TestIPLookupHidesPrivateAddresses(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newServerWithTestRoutes(t)
|
|
s.resolver = &fakeResolver{hosts: map[string][]string{
|
|
internalHostName: {internalHostAddr},
|
|
"mixed.test": {internalHostAddr, addrInTestPrefixV4},
|
|
}}
|
|
|
|
notPublic := internalHostName + ": " + hostNotPublicMessage
|
|
|
|
tests := []struct {
|
|
target string
|
|
wantCode int
|
|
wantMessage string
|
|
wantErrors []string
|
|
}{
|
|
{
|
|
internalHostName, http.StatusBadRequest,
|
|
"No valid IPs or hostnames provided: " + notPublic, nil,
|
|
},
|
|
{
|
|
internalHostName + "," + addrInTestPrefixV4, http.StatusOK, "",
|
|
[]string{notPublic},
|
|
},
|
|
{"mixed.test", http.StatusOK, "", nil},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
rec := httptest.NewRecorder()
|
|
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, "/api/v1/ip/"+tt.target, nil))
|
|
|
|
if rec.Code != tt.wantCode {
|
|
t.Errorf("%s: status %d, want %d; body %s",
|
|
tt.target, rec.Code, tt.wantCode, rec.Body)
|
|
}
|
|
|
|
if strings.Contains(rec.Body.String(), internalHostAddr) {
|
|
t.Errorf("%s: body %s carries %s", tt.target, rec.Body, internalHostAddr)
|
|
}
|
|
|
|
var body struct {
|
|
Data IPLookupResponse `json:"data"`
|
|
Error struct {
|
|
Msg string `json:"msg"`
|
|
} `json:"error"`
|
|
}
|
|
|
|
err := json.Unmarshal(rec.Body.Bytes(), &body)
|
|
if err != nil {
|
|
t.Errorf("%s: decoding the answer %q: %v", tt.target, rec.Body, err)
|
|
|
|
continue
|
|
}
|
|
|
|
if body.Error.Msg != tt.wantMessage {
|
|
t.Errorf("%s: error message %q, want %q",
|
|
tt.target, body.Error.Msg, tt.wantMessage)
|
|
}
|
|
|
|
if !slices.Equal(body.Data.Errors, tt.wantErrors) {
|
|
t.Errorf("%s: errors %q, want %q",
|
|
tt.target, body.Data.Errors, tt.wantErrors)
|
|
}
|
|
}
|
|
}
|
|
|
|
// asSearchPath is the path of the AS name search.
|
|
const asSearchPath = "/api/v1/search/as"
|
|
|
|
// searchAS sends an AS name search with the URL query rawQuery through s's
|
|
// router, expects a 200 answer, and returns its data.
|
|
func searchAS(t *testing.T, s *Server, rawQuery string) ASSearchResponse {
|
|
t.Helper()
|
|
|
|
rec := httptest.NewRecorder()
|
|
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, asSearchPath+"?"+rawQuery, nil))
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("%s: status %d, want %d; body %s",
|
|
rawQuery, rec.Code, http.StatusOK, rec.Body)
|
|
}
|
|
|
|
var body struct {
|
|
Data ASSearchResponse `json:"data"`
|
|
}
|
|
|
|
err := json.Unmarshal(rec.Body.Bytes(), &body)
|
|
if err != nil {
|
|
t.Fatalf("%s: decoding the answer %q: %v", rawQuery, rec.Body, err)
|
|
}
|
|
|
|
return body.Data
|
|
}
|
|
|
|
// TestASSearchAnswersMatches searches for Cloudflare's handle in lower case,
|
|
// with spaces around it, and expects AS13335 among the matches. Before the fix
|
|
// there was no AS name search, and the home page's search card said it was
|
|
// coming soon (https://git.eeqj.de/sneak/routewatch/issues/151).
|
|
func TestASSearchAnswersMatches(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const query = "cloudflarenet"
|
|
|
|
data := searchAS(t, newServerWithTestRoutes(t), "q=%20"+query+"%20")
|
|
|
|
if data.Query != query {
|
|
t.Errorf("query %q, want %q", data.Query, query)
|
|
}
|
|
|
|
if data.Total != len(data.Results) {
|
|
t.Errorf("total %d, but %d results", data.Total, len(data.Results))
|
|
}
|
|
|
|
cloudflare := asinfo.Info{
|
|
ASN: 13335, Handle: "CLOUDFLARENET", Description: "Cloudflare Inc.",
|
|
}
|
|
if !slices.Contains(data.Results, cloudflare) {
|
|
t.Errorf("results %v do not hold %v", data.Results, cloudflare)
|
|
}
|
|
}
|
|
|
|
// TestASSearchAnswersEmptyListWhenNothingMatches expects a search that matches
|
|
// nothing to answer 200 with a total of 0 and an empty list, not null.
|
|
func TestASSearchAnswersEmptyListWhenNothingMatches(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
data := searchAS(t, newServerWithTestRoutes(t), "q=XYZABC123456")
|
|
|
|
if data.Total != 0 || data.Results == nil || len(data.Results) != 0 {
|
|
t.Errorf("total %d and results %#v, want 0 and an empty list",
|
|
data.Total, data.Results)
|
|
}
|
|
}
|
|
|
|
// TestASSearchAnswers400ForEmptyQuery expects a search with no q, an empty q
|
|
// or a q of only spaces to answer 400 with the JSON error body.
|
|
func TestASSearchAnswers400ForEmptyQuery(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newServerWithTestRoutes(t)
|
|
|
|
for _, path := range []string{
|
|
asSearchPath, asSearchPath + "?q=", asSearchPath + "?q=%20%20",
|
|
} {
|
|
rec := httptest.NewRecorder()
|
|
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, path, nil))
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("%s: status %d, want %d; body %s",
|
|
path, rec.Code, http.StatusBadRequest, rec.Body)
|
|
}
|
|
|
|
checkJSONErrorCode(t, path, rec, http.StatusBadRequest)
|
|
checkJSONErrorMessage(t, path, rec, searchQueryMissingMessage)
|
|
}
|
|
}
|
|
|
|
// TestASSearchShowsFirstMatchesAndTotal searches for a word with more matches
|
|
// than an answer holds, and expects the first maxASSearchResults of them, in
|
|
// AS number order, and the number of all of them.
|
|
func TestASSearchShowsFirstMatchesAndTotal(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const query = "university"
|
|
|
|
all, _ := asinfo.Search(query, asinfo.Total())
|
|
if len(all) <= maxASSearchResults {
|
|
t.Fatalf("Search(%q) has %d matches; the test needs more than %d",
|
|
query, len(all), maxASSearchResults)
|
|
}
|
|
|
|
data := searchAS(t, newServerWithTestRoutes(t), "q="+query)
|
|
|
|
if data.Total != len(all) {
|
|
t.Errorf("total %d, want %d", data.Total, len(all))
|
|
}
|
|
|
|
if !slices.Equal(data.Results, all[:maxASSearchResults]) {
|
|
t.Errorf("results %v, want the first %d matches %v",
|
|
data.Results, maxASSearchResults, all[:maxASSearchResults])
|
|
}
|
|
}
|
|
|
|
// The AS numbers the AS detail tests ask for. storedASN originates the route
|
|
// for testPrefixV4 and is in the database. routeOnlyASN originates the route
|
|
// for testPrefixV6, but the database has no record of the AS itself, as when
|
|
// a route is stored before its AS. Both are documentation AS numbers, which
|
|
// the AS list built into asinfo does not hold. builtInOnlyASN is in that list
|
|
// but not in the database. unknownASN is in neither.
|
|
const (
|
|
storedASN = 64500
|
|
routeOnlyASN = 64501
|
|
builtInOnlyASN = 15169
|
|
unknownASN = 4200000000
|
|
)
|
|
|
|
// zeroDate is how a page writes a time that was never set.
|
|
const zeroDate = "0001-01-01"
|
|
|
|
// firstSeenCard is the label of the AS page's first-seen date.
|
|
const firstSeenCard = `<div class="info-label">First Seen</div>`
|
|
|
|
// newServerWithStoredAS returns newServerWithTestRoutes's server with storedASN
|
|
// added to the database. It fails the test when the AS list built into asinfo
|
|
// lacks builtInOnlyASN or holds unknownASN.
|
|
func newServerWithStoredAS(t *testing.T) *Server {
|
|
t.Helper()
|
|
|
|
if _, ok := asinfo.Get(builtInOnlyASN); !ok {
|
|
t.Fatalf("AS%d is not in the AS info data", builtInOnlyASN)
|
|
}
|
|
|
|
if _, ok := asinfo.Get(unknownASN); ok {
|
|
t.Fatalf("AS%d is in the AS info data", unknownASN)
|
|
}
|
|
|
|
s := newServerWithTestRoutes(t)
|
|
|
|
_, err := s.db.GetOrCreateASN(storedASN, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreateASN: %v", err)
|
|
}
|
|
|
|
return s
|
|
}
|
|
|
|
// TestASDetailJSONAnswersBuiltInAS asks /api/v1/as/ for each of the ASes above.
|
|
// All but unknownASN answer 200 with the handle and description the built-in
|
|
// AS list has for them, if any, and their prefixes, as lists even when empty.
|
|
// Only storedASN has a first_seen time. unknownASN answers 404. Before the fix
|
|
// builtInOnlyASN and routeOnlyASN answered 404 too
|
|
// (https://git.eeqj.de/sneak/routewatch/issues/151).
|
|
func TestASDetailJSONAnswersBuiltInAS(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newServerWithStoredAS(t)
|
|
|
|
tests := []struct {
|
|
asn int
|
|
wantCode int
|
|
wantPrefixes []string
|
|
wantFirstSeen bool
|
|
}{
|
|
{storedASN, http.StatusOK, []string{testPrefixV4}, true},
|
|
{routeOnlyASN, http.StatusOK, []string{testPrefixV6}, false},
|
|
{builtInOnlyASN, http.StatusOK, nil, false},
|
|
{unknownASN, http.StatusNotFound, nil, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
path := "/api/v1/as/" + strconv.Itoa(tt.asn)
|
|
rec := httptest.NewRecorder()
|
|
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, path, nil))
|
|
|
|
if rec.Code != tt.wantCode {
|
|
t.Errorf("%s: status %d, want %d; body %s",
|
|
path, rec.Code, tt.wantCode, rec.Body)
|
|
|
|
continue
|
|
}
|
|
|
|
if tt.wantCode != http.StatusOK {
|
|
checkJSONErrorCode(t, path, rec, tt.wantCode)
|
|
|
|
continue
|
|
}
|
|
|
|
//nolint:tagliatelle // field names are the existing JSON API
|
|
var body struct {
|
|
Data struct {
|
|
ASN database.ASN `json:"asn"`
|
|
IPv4Prefixes []LiveRoute `json:"ipv4_prefixes"`
|
|
IPv6Prefixes []LiveRoute `json:"ipv6_prefixes"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
err := json.Unmarshal(rec.Body.Bytes(), &body)
|
|
if err != nil {
|
|
t.Errorf("%s: decoding the answer %q: %v", path, rec.Body, err)
|
|
|
|
continue
|
|
}
|
|
|
|
handle, description := asinfo.GetHandle(tt.asn), asinfo.GetDescription(tt.asn)
|
|
got := body.Data.ASN
|
|
|
|
if got.ASN != tt.asn || got.Handle != handle ||
|
|
got.Description != description {
|
|
t.Errorf("%s: AS %d %q %q, want %d %q %q", path, got.ASN,
|
|
got.Handle, got.Description, tt.asn, handle, description)
|
|
}
|
|
|
|
if body.Data.IPv4Prefixes == nil || body.Data.IPv6Prefixes == nil {
|
|
t.Errorf("%s: a prefix list is null; body %s", path, rec.Body)
|
|
}
|
|
|
|
routes := slices.Concat(body.Data.IPv4Prefixes, body.Data.IPv6Prefixes)
|
|
|
|
gotPrefixes := make([]string, 0, len(routes))
|
|
for _, route := range routes {
|
|
gotPrefixes = append(gotPrefixes, route.Prefix)
|
|
}
|
|
|
|
if !slices.Equal(gotPrefixes, tt.wantPrefixes) {
|
|
t.Errorf("%s: prefixes %v, want %v", path, gotPrefixes, tt.wantPrefixes)
|
|
}
|
|
|
|
gotFirstSeen := strings.Contains(rec.Body.String(), `"first_seen"`)
|
|
if gotFirstSeen != tt.wantFirstSeen {
|
|
t.Errorf("%s: has first_seen %t, want %t; body %s",
|
|
path, gotFirstSeen, tt.wantFirstSeen, rec.Body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestASDetailPageShowsBuiltInAS asks /as/ for each of the ASes above. All but
|
|
// unknownASN answer 200 with the handle and description the built-in AS list
|
|
// has for them, if any. storedASN and routeOnlyASN list their prefixes, and
|
|
// builtInOnlyASN says the AS announces none. Only storedASN shows a first-seen
|
|
// date, and no page shows a date that was never set. unknownASN answers 404.
|
|
// Before the fix builtInOnlyASN and routeOnlyASN answered 404 too
|
|
// (https://git.eeqj.de/sneak/routewatch/issues/151).
|
|
func TestASDetailPageShowsBuiltInAS(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newServerWithStoredAS(t)
|
|
|
|
tests := []struct {
|
|
asn int
|
|
wantCode int
|
|
wantText string
|
|
wantFirstSeen bool
|
|
}{
|
|
{storedASN, http.StatusOK, testPrefixV4, true},
|
|
{routeOnlyASN, http.StatusOK, testPrefixV6, false},
|
|
{builtInOnlyASN, http.StatusOK, "No prefixes announced by this AS", false},
|
|
{unknownASN, http.StatusNotFound, "", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
path := "/as/" + strconv.Itoa(tt.asn)
|
|
rec := httptest.NewRecorder()
|
|
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, path, nil))
|
|
|
|
if rec.Code != tt.wantCode {
|
|
t.Errorf("%s: status %d, want %d; body %s",
|
|
path, rec.Code, tt.wantCode, rec.Body)
|
|
|
|
continue
|
|
}
|
|
|
|
if tt.wantCode != http.StatusOK {
|
|
continue
|
|
}
|
|
|
|
page := rec.Body.String()
|
|
|
|
for _, want := range []string{
|
|
html.EscapeString(asinfo.GetHandle(tt.asn)),
|
|
html.EscapeString(asinfo.GetDescription(tt.asn)),
|
|
tt.wantText,
|
|
} {
|
|
if !strings.Contains(page, want) {
|
|
t.Errorf("%s: page does not show %q", path, want)
|
|
}
|
|
}
|
|
|
|
if strings.Contains(page, zeroDate) {
|
|
t.Errorf("%s: page shows the date %s", path, zeroDate)
|
|
}
|
|
|
|
gotFirstSeen := strings.Contains(page, firstSeenCard)
|
|
if gotFirstSeen != tt.wantFirstSeen {
|
|
t.Errorf("%s: shows a first-seen date %t, want %t",
|
|
path, gotFirstSeen, tt.wantFirstSeen)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSortRoutesByMaskLength expects the AS page's prefix lists in mask length
|
|
// order, shortest first, and in address order within one mask length. Before
|
|
// the fix they were in address order, so 10.1.0.0/16 came before 11.0.0.0/8
|
|
// (https://git.eeqj.de/sneak/routewatch/issues/153).
|
|
func TestSortRoutesByMaskLength(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
prefixes []string
|
|
want []string
|
|
}{
|
|
{
|
|
name: "IPv4",
|
|
prefixes: []string{
|
|
"100.64.1.0/24", "11.0.0.0/8", "10.1.0.0/16",
|
|
"20.0.1.0/24", "172.16.0.0/12", "9.0.0.0/8",
|
|
},
|
|
want: []string{
|
|
"9.0.0.0/8", "11.0.0.0/8", "172.16.0.0/12",
|
|
"10.1.0.0/16", "20.0.1.0/24", "100.64.1.0/24",
|
|
},
|
|
},
|
|
{
|
|
name: "IPv6",
|
|
prefixes: []string{
|
|
"2001:db8:1::/48", "2001:db8:8000::/33", "2001:db8:0:1::/64",
|
|
"2001:db8::/48", "2001:db8::/33",
|
|
},
|
|
want: []string{
|
|
"2001:db8::/33", "2001:db8:8000::/33", "2001:db8::/48",
|
|
"2001:db8:1::/48", "2001:db8:0:1::/64",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
routes := make([]LiveRoute, 0, len(tt.prefixes))
|
|
for _, prefix := range tt.prefixes {
|
|
routes = append(routes, LiveRoute{Prefix: prefix})
|
|
}
|
|
|
|
sortRoutesByMaskLength(routes)
|
|
|
|
got := make([]string, 0, len(routes))
|
|
for _, route := range routes {
|
|
got = append(got, route.Prefix)
|
|
}
|
|
|
|
if !slices.Equal(got, tt.want) {
|
|
t.Errorf("%s: order %v, want %v", tt.name, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestNoOriginHasNoASAnswers stores a route with no known origin, which has
|
|
// origin AS 0 (https://git.eeqj.de/sneak/routewatch/issues/75). It expects
|
|
// /as/0 and /api/v1/as/0 to answer 404.
|
|
func TestNoOriginHasNoASAnswers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const noOriginPrefix = "203.0.113.0/24"
|
|
|
|
s := newServerWithTestRoutes(t)
|
|
s.table.Announce(netip.MustParsePrefix(noOriginPrefix), ristypes.NoOrigin,
|
|
routetable.Route{
|
|
Peer: testPeerV4, ASPath: []int{64500, 64501, 64502},
|
|
Time: testRouteTime(0),
|
|
})
|
|
|
|
asn := strconv.Itoa(ristypes.NoOrigin)
|
|
|
|
for _, path := range []string{"/as/" + asn, "/api/v1/as/" + asn} {
|
|
rec := httptest.NewRecorder()
|
|
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, path, nil))
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("%s: status %d, want %d; body %s",
|
|
path, rec.Code, http.StatusNotFound, rec.Body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// settledGoroutineCount lets transient goroutines finish, then reports the
|
|
// current count.
|
|
func settledGoroutineCount() int {
|
|
prev := runtime.NumGoroutine()
|
|
|
|
for range 20 {
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
cur := runtime.NumGoroutine()
|
|
if cur == prev {
|
|
return cur
|
|
}
|
|
|
|
prev = cur
|
|
}
|
|
|
|
return prev
|
|
}
|
|
|
|
// waitForGoroutines waits until the goroutine count drops to target or below.
|
|
func waitForGoroutines(target int) bool {
|
|
for range 100 {
|
|
if runtime.NumGoroutine() <= target {
|
|
return true
|
|
}
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
|
|
return false
|
|
}
|