check / check (push) Canceled after 0s
The built-in AS list had no letter outside ASCII, and make asupdate rejected today's as.csv, which added a country code column. The generator now checks only the first three header columns and reads past the rest; the parsing moved into parseCSV. The list is regenerated. The new source spells some names differently and no longer lists AS 0 or the documentation AS numbers, so the tests that relied on those entries changed. A WHOIS answer that is not valid UTF-8 is now decoded as Latin-1, which RIPE and APNIC send, before it is parsed and stored. LACNIC answers in UTF-8. Model: opus-5-5
1654 lines
50 KiB
Go
1654 lines
50 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"html"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"runtime"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"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/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"
|
|
)
|
|
|
|
// 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, 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())
|
|
}
|
|
}
|
|
|
|
// TestStatsHandlersAnswerFromMemory checks that both stats handlers answer 200
|
|
// with the live route counts and the prefix distribution while the database is
|
|
// closed, so that any query would fail: the request path reads them from
|
|
// memory. The prefix distribution query it 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 oldest and newest route times still come from one-row lookups at the ends
|
|
// of an index; with the database closed they are left out of the answer.
|
|
func TestStatsHandlersAnswerFromMemory(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)
|
|
}
|
|
|
|
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
|
|
err = db.UpsertLiveRouteBatch([]*database.LiveRoute{
|
|
{
|
|
Prefix: "198.51.100.0/24", MaskLength: 24, IPVersion: 4,
|
|
OriginASN: 64500, PeerIP: "192.0.2.1", ASPath: []int{64500},
|
|
NextHop: "192.0.2.1", LastUpdated: ts,
|
|
},
|
|
{
|
|
Prefix: "2001:db8::/32", MaskLength: 32, IPVersion: 6,
|
|
OriginASN: 64501, PeerIP: testPeerV6, ASPath: []int{64501},
|
|
NextHop: testPeerV6, LastUpdated: ts,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpsertLiveRouteBatch: %v", err)
|
|
}
|
|
|
|
err = db.Close()
|
|
if err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
|
|
s := New(db, 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 {
|
|
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
|
|
}
|
|
|
|
//nolint:tagliatelle // field names are the existing JSON API
|
|
var body struct {
|
|
Data struct {
|
|
IPv4Routes int `json:"ipv4_routes"`
|
|
IPv6Routes int `json:"ipv6_routes"`
|
|
IPv4Distribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"`
|
|
IPv6Distribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
err := json.Unmarshal(rec.Body.Bytes(), &body)
|
|
if err != nil {
|
|
t.Fatalf("%s: decoding the answer: %v", name, err)
|
|
}
|
|
|
|
if body.Data.IPv4Routes != 1 || body.Data.IPv6Routes != 1 {
|
|
t.Errorf("%s: routes = (v4 %d, v6 %d), want (1, 1)",
|
|
name, body.Data.IPv4Routes, body.Data.IPv6Routes)
|
|
}
|
|
|
|
wantV4 := []database.PrefixDistribution{{MaskLength: 24, Count: 1}}
|
|
if !slices.Equal(body.Data.IPv4Distribution, wantV4) {
|
|
t.Errorf("%s: IPv4 distribution = %v, want %v",
|
|
name, body.Data.IPv4Distribution, wantV4)
|
|
}
|
|
|
|
wantV6 := []database.PrefixDistribution{{MaskLength: 32, Count: 1}}
|
|
if !slices.Equal(body.Data.IPv6Distribution, wantV6) {
|
|
t.Errorf("%s: IPv6 distribution = %v, want %v",
|
|
name, body.Data.IPv6Distribution, wantV6)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// newServerWithTestRoutes returns a server on a new database that holds a
|
|
// route for testPrefixV4 and one for testPrefixV6.
|
|
func newServerWithTestRoutes(t *testing.T) *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() })
|
|
|
|
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
|
|
err = db.UpsertLiveRouteBatch([]*database.LiveRoute{
|
|
{
|
|
Prefix: testPrefixV4, MaskLength: 24, IPVersion: 4,
|
|
OriginASN: 64500, PeerIP: testPeerV4, ASPath: []int{64500},
|
|
NextHop: testPeerV4, LastUpdated: ts,
|
|
},
|
|
{
|
|
Prefix: testPrefixV6, MaskLength: 32, IPVersion: 6,
|
|
OriginASN: 64501, PeerIP: testPeerV6, ASPath: []int{64501},
|
|
NextHop: testPeerV6, LastUpdated: ts,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpsertLiveRouteBatch: %v", err)
|
|
}
|
|
|
|
return New(db, 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// errASDetailsLookupFailed is what failingASDetailsDB answers every AS details
|
|
// lookup with.
|
|
var errASDetailsLookupFailed = errors.New("AS details lookup failed")
|
|
|
|
// failingASDetailsDB wraps a real store and fails every AS details lookup.
|
|
type failingASDetailsDB struct {
|
|
database.Store
|
|
}
|
|
|
|
func (failingASDetailsDB) GetASDetailsContext(
|
|
_ context.Context, _ int,
|
|
) (*database.ASN, []database.LiveRoute, error) {
|
|
return nil, nil, errASDetailsLookupFailed
|
|
}
|
|
|
|
// TestPrefixDetailShowsOriginNames checks that the prefix page shows each
|
|
// origin AS's handle and description without looking up the AS's details,
|
|
// which also read every route the AS originates. Before the fix the page did
|
|
// that lookup once per origin, so with it failing the names were missing
|
|
// (https://git.eeqj.de/sneak/routewatch/issues/67). 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() })
|
|
|
|
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
origins := []int{15169, 13335}
|
|
|
|
for _, asn := range origins {
|
|
_, err := db.GetOrCreateASN(asn, ts)
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreateASN(%d): %v", asn, err)
|
|
}
|
|
}
|
|
|
|
err = db.UpsertLiveRouteBatch([]*database.LiveRoute{
|
|
{
|
|
Prefix: testPrefixV4, MaskLength: 24, IPVersion: 4,
|
|
OriginASN: origins[0], PeerIP: testPeerV4,
|
|
ASPath: []int{origins[0]}, NextHop: testPeerV4, LastUpdated: ts,
|
|
},
|
|
{
|
|
Prefix: testPrefixV4, MaskLength: 24, IPVersion: 4,
|
|
OriginASN: origins[1], PeerIP: "198.51.100.2",
|
|
ASPath: []int{origins[1]}, NextHop: "198.51.100.2", LastUpdated: ts,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpsertLiveRouteBatch: %v", err)
|
|
}
|
|
|
|
s := New(failingASDetailsDB{db}, 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()
|
|
|
|
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() })
|
|
|
|
err = db.UpsertLiveRouteBatch([]*database.LiveRoute{{
|
|
Prefix: testPrefixV4, MaskLength: 24, IPVersion: 4,
|
|
OriginASN: 0, PeerIP: testPeerV4, ASPath: []int{64500, 64501, 64502},
|
|
NextHop: testPeerV4, LastUpdated: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
|
|
}})
|
|
if err != nil {
|
|
t.Fatalf("UpsertLiveRouteBatch: %v", err)
|
|
}
|
|
|
|
s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
|
|
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() })
|
|
|
|
// 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()
|
|
routes := make([]*database.LiveRoute, 0, len(ages))
|
|
|
|
for _, a := range ages {
|
|
routes = append(routes, &database.LiveRoute{
|
|
Prefix: a.prefix, MaskLength: 24, IPVersion: 4,
|
|
OriginASN: 64500, PeerIP: testPeerV4, ASPath: []int{64500},
|
|
NextHop: testPeerV4, LastUpdated: now.Add(-a.age),
|
|
})
|
|
}
|
|
|
|
err = db.UpsertLiveRouteBatch(routes)
|
|
if err != nil {
|
|
t.Fatalf("UpsertLiveRouteBatch: %v", err)
|
|
}
|
|
|
|
s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
|
|
|
|
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, 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, 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 IP read with the context's error, the prefix read
|
|
// that there is no route, and the others with what they had read, here
|
|
// nothing, and no 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) GetIPInfoContext(
|
|
ctx context.Context, _ string,
|
|
) (*database.IPInfo, error) {
|
|
<-ctx.Done()
|
|
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
func (slowDB) GetASDetailsContext(
|
|
ctx context.Context, asn int,
|
|
) (*database.ASN, []database.LiveRoute, error) {
|
|
<-ctx.Done()
|
|
|
|
return &database.ASN{ASN: asn}, nil, nil
|
|
}
|
|
|
|
func (slowDB) GetPrefixDetailsContext(
|
|
ctx context.Context, _ string,
|
|
) ([]database.LiveRoute, map[int]database.ASN, error) {
|
|
<-ctx.Done()
|
|
|
|
return nil, nil, database.ErrNoRoute
|
|
}
|
|
|
|
func (slowDB) GetRandomPrefixesByLengthContext(
|
|
ctx context.Context, _, _, _ int,
|
|
) ([]database.LiveRoute, error) {
|
|
<-ctx.Done()
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// 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, 404, 200, or, on a page
|
|
// that stops before rendering once the request has ended, 504 with an empty
|
|
// body. The health check is left out: it answers 503 when its own 2-second
|
|
// ping runs out.
|
|
func TestRoutesAnswer504WhenDatabaseReadIsCutOff(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := New(slowDB{}, streamer.New(logger.New(), metrics.New()), logger.New(),
|
|
&config.Config{})
|
|
|
|
tests := []struct {
|
|
path string
|
|
wantJSONBody bool
|
|
}{
|
|
{path: asPagePath},
|
|
{path: "/prefix/192.0.2.0/24"},
|
|
{path: prefixLengthPagePath},
|
|
{path: "/prefixlength6/48"},
|
|
{path: statusJSONPath, wantJSONBody: true},
|
|
{path: "/ip/192.0.2.1", wantJSONBody: true},
|
|
{path: statsPath, wantJSONBody: true},
|
|
{path: "/api/v1/ip/192.0.2.1", wantJSONBody: true},
|
|
{path: "/api/v1/as/64500", wantJSONBody: true},
|
|
{path: "/api/v1/prefix/192.0.2.0/24", 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) GetASDetailsContext(
|
|
_ context.Context, asn int,
|
|
) (*database.ASN, []database.LiveRoute, error) {
|
|
return &database.ASN{ASN: asn}, nil, 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{}, 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) GetIPInfoContext(
|
|
_ context.Context, _ string,
|
|
) (*database.IPInfo, error) {
|
|
return nil, errDiskIO
|
|
}
|
|
|
|
func (failingDB) GetASDetailsContext(
|
|
_ context.Context, _ int,
|
|
) (*database.ASN, []database.LiveRoute, error) {
|
|
return nil, nil, errDiskIO
|
|
}
|
|
|
|
func (failingDB) GetPrefixDetailsContext(
|
|
_ context.Context, _ string,
|
|
) ([]database.LiveRoute, map[int]database.ASN, error) {
|
|
return nil, nil, errDiskIO
|
|
}
|
|
|
|
// TestJSONRoutesDoNotSendErrorDetails sends a request to each JSON route and
|
|
// the health check with a store whose reads fail with the driver's error text,
|
|
// and a prefix that does not parse, and checks that no answer carries 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{}, 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/prefix/192.0.2.0/24", http.StatusInternalServerError},
|
|
{"/api/v1/ip/192.0.2.1", 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 addresses failingIPLookupDB fails the read of and finds a route for.
|
|
const (
|
|
failingLookupIP = "192.0.2.1"
|
|
routedLookupIP = "192.0.2.2"
|
|
)
|
|
|
|
// failingIPLookupDB embeds database.Store (left nil) and answers the IP lookup
|
|
// with errDiskIO for failingLookupIP, a route for routedLookupIP, and no route
|
|
// for any other address. Every other Store method is unused and would panic if
|
|
// called.
|
|
type failingIPLookupDB struct {
|
|
database.Store
|
|
}
|
|
|
|
func (failingIPLookupDB) GetIPInfoContext(
|
|
_ context.Context, ip string,
|
|
) (*database.IPInfo, error) {
|
|
switch ip {
|
|
case failingLookupIP:
|
|
return nil, errDiskIO
|
|
case routedLookupIP:
|
|
return &database.IPInfo{IP: ip}, nil
|
|
default:
|
|
return nil, database.ErrNoRoute
|
|
}
|
|
}
|
|
|
|
// TestIPLookupAnswers500WhenDatabaseReadFails sends IP lookups through the
|
|
// server's router with a store whose read of 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 and prefix JSON routes do; an address with no
|
|
// route still answers 404. 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{}, streamer.New(logger.New(), metrics.New()),
|
|
logger.New(), &config.Config{})
|
|
|
|
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.StatusNotFound,
|
|
},
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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 counts its IP
|
|
// lookups, answering each that the address has no route. Every other Store
|
|
// method is unused and would panic if called.
|
|
type countingIPLookupDB struct {
|
|
database.Store
|
|
|
|
lookups atomic.Int64
|
|
}
|
|
|
|
func (d *countingIPLookupDB) GetIPInfoContext(
|
|
_ context.Context, _ string,
|
|
) (*database.IPInfo, error) {
|
|
d.lookups.Add(1)
|
|
|
|
return nil, database.ErrNoRoute
|
|
}
|
|
|
|
// TestIPLookupRefusesTooManyAddresses sends IP lookups of maxLookupTargets
|
|
// addresses 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 once per 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.StatusNotFound, maxLookupTargets, 0,
|
|
},
|
|
{strings.Join(addrs, ","), http.StatusBadRequest, 0, 0},
|
|
{"most.test", http.StatusNotFound, 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, 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)
|
|
}
|
|
|
|
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 != testPrefixV4 {
|
|
t.Errorf("answer %s, want one result in %s", rec.Body, testPrefixV4)
|
|
}
|
|
}
|
|
|
|
// 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 []database.LiveRoute `json:"ipv4_prefixes"`
|
|
IPv6Prefixes []database.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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
|
|
err := s.db.UpsertLiveRouteBatch([]*database.LiveRoute{{
|
|
Prefix: noOriginPrefix, MaskLength: 24, IPVersion: 4,
|
|
OriginASN: ristypes.NoOrigin, PeerIP: testPeerV4,
|
|
ASPath: []int{64500, 64501, 64502}, NextHop: testPeerV4,
|
|
LastUpdated: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
|
|
}})
|
|
if err != nil {
|
|
t.Fatalf("UpsertLiveRouteBatch: %v", err)
|
|
}
|
|
|
|
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
|
|
}
|