check / check (push) Canceled after 0s
An IP lookup took any number of addresses and host names, gave each PTR lookup until the request's 30-second limit, and looked up and named every address a host name resolved to. It now answers 400 for more than 10 entries, or more than 10 addresses once the names are resolved, before reading the database. Each PTR lookup gets 2 seconds; one that runs out leaves the address's result without PTR names. Of a host name's addresses only public ones are kept: global unicast and not private. A name with none is reported as having no public address. The tests give the server a fake resolver. Model: opus-5-5
1200 lines
36 KiB
Go
1200 lines
36 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/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"
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// newServerWithTestRoutes returns a server on a new database that holds a
|
|
// route for testPrefixV4 and one for 2001:db8::/32.
|
|
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: "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)
|
|
}
|
|
|
|
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 or the description of the
|
|
// reserved AS 0 (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)
|
|
}
|
|
|
|
reserved, ok := asinfo.Get(0)
|
|
if !ok {
|
|
t.Fatal("AS0 is not in the AS info data")
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
if strings.Contains(page, html.EscapeString(reserved.Description)) {
|
|
t.Errorf("%s shows AS0's description %q", path, reserved.Description)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|