check / check (push) Canceled after 0s
A panic in a JSON route's handler answered 500 with an empty body, because the router's recoverer sits outside the middleware that adds `@meta`. `JSONValidationMiddleware` now wraps the handler in the same recoverer, and answers the panic with the JSON error body. `/status.json` and the health check answered chi's empty 405 to any method they do not take. They now answer with the same JSON 405 as the routes under `/api/v1` and `/ip`. The home page's IP lookup card showed only the answer's `data`. It now shows the whole answer, `@meta` included. A table test checks `@meta` on every JSON route, in its successes and its errors. Model: opus-5-5
2204 lines
66 KiB
Go
2204 lines
66 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"
|
|
asJSONPath = "/api/v1/as/64500"
|
|
prefixLengthPagePath = "/prefixlength/24"
|
|
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)
|
|
}
|
|
}
|
|
|
|
// fixedIntervalASNFetcher is a WHOIS fetcher that does nothing and reports
|
|
// interval as its wait between fetches.
|
|
type fixedIntervalASNFetcher struct {
|
|
idleASNFetcher
|
|
|
|
interval time.Duration
|
|
}
|
|
|
|
func (f fixedIntervalASNFetcher) GetStats() ASNFetcherStats {
|
|
return ASNFetcherStats{CurrentInterval: f.interval}
|
|
}
|
|
|
|
// unitStats holds the figures of a stats answer that carry a unit. Each is a
|
|
// pointer, so that a figure missing from the answer is nil.
|
|
//
|
|
//nolint:tagliatelle // field names are the existing JSON API
|
|
type unitStats struct {
|
|
UptimeSeconds *int64 `json:"uptime_seconds"`
|
|
MemoryUsageBytes *uint64 `json:"memory_usage_bytes"`
|
|
WHOISStats struct {
|
|
CurrentIntervalSeconds *float64 `json:"current_interval_seconds"`
|
|
} `json:"whois_stats"`
|
|
GC struct {
|
|
HeapAllocBytes uint64 `json:"heap_alloc_bytes"`
|
|
} `json:"gc"`
|
|
}
|
|
|
|
// TestStatsAnswersGiveUnitsInFieldNames checks that the figures of both stats
|
|
// answers that carry a unit are numbers, with the unit at the end of their
|
|
// names. They used to be strings with the unit in them, such as "1h18m34s" or
|
|
// "1.1 GB" (https://git.eeqj.de/sneak/routewatch/issues/199). The stream has
|
|
// never connected, so the uptime is 0. The memory use in /api/v1/stats is the
|
|
// same number of bytes as its gc.heap_alloc_bytes.
|
|
func TestStatsAnswersGiveUnitsInFieldNames(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const fetchInterval = 18200 * time.Millisecond
|
|
|
|
s := newServerWithTestRoutes(t)
|
|
s.SetASNFetcher(fixedIntervalASNFetcher{interval: fetchInterval})
|
|
|
|
for _, path := range []string{statusJSONPath, statsPath} {
|
|
answer := get(t, s, path).Body.Bytes()
|
|
|
|
var body struct {
|
|
Data unitStats `json:"data"`
|
|
}
|
|
|
|
err := json.Unmarshal(answer, &body)
|
|
if err != nil {
|
|
t.Errorf("%s: figures are not numbers: %v; answer %s", path, err, answer)
|
|
|
|
continue
|
|
}
|
|
|
|
got := body.Data
|
|
if got.UptimeSeconds == nil || *got.UptimeSeconds != 0 {
|
|
t.Errorf("%s: uptime_seconds is not 0: %s", path, answer)
|
|
}
|
|
|
|
if got.MemoryUsageBytes == nil || *got.MemoryUsageBytes == 0 {
|
|
t.Errorf("%s: memory_usage_bytes is not a number of bytes: %s",
|
|
path, answer)
|
|
}
|
|
|
|
interval := got.WHOISStats.CurrentIntervalSeconds
|
|
if interval == nil || *interval != fetchInterval.Seconds() {
|
|
t.Errorf("%s: whois_stats.current_interval_seconds is not %v: %s",
|
|
path, fetchInterval.Seconds(), answer)
|
|
}
|
|
|
|
if path == statsPath && got.MemoryUsageBytes != nil &&
|
|
*got.MemoryUsageBytes != got.GC.HeapAllocBytes {
|
|
t.Errorf("%s: memory_usage_bytes %d, want gc.heap_alloc_bytes %d",
|
|
path, *got.MemoryUsageBytes, got.GC.HeapAllocBytes)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestStatsAnswerGivesConnectionDurationInSeconds checks that the
|
|
// connection_duration_seconds of /api/v1/stats is null while the stream is not
|
|
// connected, and a whole number of seconds once it is. It used to be the
|
|
// string "disconnected", or a Go duration such as "1h18m34s"
|
|
// (https://git.eeqj.de/sneak/routewatch/issues/199).
|
|
func TestStatsAnswerGivesConnectionDurationInSeconds(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newServerWithTestRoutes(t)
|
|
|
|
connectionDuration := func() json.RawMessage {
|
|
answer := get(t, s, statsPath).Body.Bytes()
|
|
|
|
var body struct {
|
|
Data map[string]json.RawMessage `json:"data"`
|
|
}
|
|
|
|
err := json.Unmarshal(answer, &body)
|
|
if err != nil {
|
|
t.Fatalf("decoding the answer: %v; answer %s", err, answer)
|
|
}
|
|
|
|
duration, ok := body.Data["connection_duration_seconds"]
|
|
if !ok {
|
|
t.Fatalf("no connection_duration_seconds: %s", answer)
|
|
}
|
|
|
|
return duration
|
|
}
|
|
|
|
disconnected := connectionDuration()
|
|
if string(disconnected) != "null" {
|
|
t.Errorf("disconnected: connection_duration_seconds = %s, want null",
|
|
disconnected)
|
|
}
|
|
|
|
s.streamer.GetMetricsTracker().SetConnected(true)
|
|
|
|
var seconds int64
|
|
|
|
connected := connectionDuration()
|
|
|
|
err := json.Unmarshal(connected, &seconds)
|
|
if err != nil || seconds != 0 {
|
|
t.Errorf("just connected: connection_duration_seconds = %s, want 0",
|
|
connected)
|
|
}
|
|
}
|
|
|
|
// TestStreamDurationsCountSeconds checks that the uptime and the connection
|
|
// duration of a stream that connected 90 seconds ago are both 90. A stream
|
|
// that has never connected has an uptime of 0, and one that is not connected
|
|
// has no connection duration.
|
|
func TestStreamDurationsCountSeconds(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const connectedFor = 90 * time.Second
|
|
|
|
connectedSince := time.Now().Add(-connectedFor)
|
|
want := int64(connectedFor / time.Second)
|
|
|
|
if got := uptimeSeconds(connectedSince); got != want {
|
|
t.Errorf("uptimeSeconds = %d, want %d", got, want)
|
|
}
|
|
|
|
if got := uptimeSeconds(time.Time{}); got != 0 {
|
|
t.Errorf("uptimeSeconds before any connection = %d, want 0", got)
|
|
}
|
|
|
|
switch got := connectionDurationSeconds(true, connectedSince); {
|
|
case got == nil:
|
|
t.Errorf("connectionDurationSeconds = nil, want %d", want)
|
|
case *got != want:
|
|
t.Errorf("connectionDurationSeconds = %d, want %d", *got, want)
|
|
}
|
|
|
|
if got := connectionDurationSeconds(false, connectedSince); got != nil {
|
|
t.Errorf("connectionDurationSeconds while not connected = %d, want nil",
|
|
*got)
|
|
}
|
|
}
|
|
|
|
// 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: asJSONPath, 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
|
|
}{
|
|
{healthCheckPath, http.StatusServiceUnavailable},
|
|
{statusJSONPath, http.StatusInternalServerError},
|
|
{statsPath, http.StatusInternalServerError},
|
|
{asJSONPath, 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
|
|
}
|