Files
routewatch/internal/server/handlers_table_internal_test.go
T
clawbot 506e289a63
check / check (push) Canceled after 0s
Read every route from the routing table in memory (part of #122)
The IP lookups, the AS and prefix pages and their API answers, the prefix-length pages and the stats read routes only from the routing table the prefix handler keeps. fx reads the table at startup and gives it to both. AS names, WHOIS details and first-seen times still come from the database.

The IP lookup names the origin AS most peers carry, with the time of the newest kept route. The prefix answers list peers whose routes are not kept without a path or time. The stats gain origins_without_kept_route. They no longer read route times from the per-peer tables, so the two helpers that did are gone.

Model: opus-5-5
2026-10-27 16:21:59 +01:00

369 lines
12 KiB
Go

package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"strconv"
"strings"
"testing"
"time"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/routetable"
)
// The origin ASes in the routing table that newTwoOriginTable fills.
const (
mostPeersASN = 64500
fewerPeersASN = 64501
v6OriginASN = 64502
)
// noKeptRoutePrefix is the prefix of newTwoOriginTable whose origin keeps no
// route.
const noKeptRoutePrefix = "198.51.100.0/24"
// routeTimeFormat is how the pages write a route's time.
const routeTimeFormat = "2006-01-02 15:04:05"
// newTwoOriginTable returns a routing table that holds:
// - testPrefixV4 with two origin ASes. Peers 1, 2 and 3 carry it with
// mostPeersASN, which keeps the routes of peers 3 and 2. Peer 4 carries
// it with fewerPeersASN, in the prefix's newest route.
// - noKeptRoutePrefix with mostPeersASN. Only peer 7 carries it, and its
// route is not kept: peers 5 and 6, whose routes were, withdrew it.
// - testPrefixV6 with v6OriginASN.
func newTwoOriginTable() *routetable.Table {
table := emptyTable()
for n := 1; n <= 3; n++ {
announce(table, testPrefixV4, mostPeersASN, testPeer(n), testRouteTime(n))
}
announce(table, testPrefixV4, fewerPeersASN, testPeer(4), testRouteTime(5))
announce(table, noKeptRoutePrefix, mostPeersASN, testPeer(5), testRouteTime(4))
announce(table, noKeptRoutePrefix, mostPeersASN, testPeer(6), testRouteTime(6))
announce(table, noKeptRoutePrefix, mostPeersASN, testPeer(7), testRouteTime(0))
withdraw(table, noKeptRoutePrefix, testPeer(5))
withdraw(table, noKeptRoutePrefix, testPeer(6))
announce(table, testPrefixV6, v6OriginASN, testPeerV6, testRouteTime(0))
return table
}
// get sends a GET for path through s's router and fails the test unless it
// answers 200.
func get(t *testing.T, s *Server, path string) *httptest.ResponseRecorder {
t.Helper()
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)
}
return rec
}
// TestIPLookupGivesTheOriginMostPeersCarry looks up an address in a prefix
// with two origin ASes, through each IP lookup route. The answer names the
// origin AS that more peers carry, counts every peer, and gives the time of
// the prefix's newest kept route. The prefix's first-seen time and the AS's
// WHOIS details come from the database.
func TestIPLookupGivesTheOriginMostPeersCarry(t *testing.T) {
t.Parallel()
s := newServerWithTable(t, newTwoOriginTable())
s.resolver = &fakeResolver{}
firstSeen := testRouteTime(-1)
err := s.db.UpdatePrefixesBatch(map[string]time.Time{testPrefixV4: firstSeen})
if err != nil {
t.Fatalf("UpdatePrefixesBatch: %v", err)
}
_, err = s.db.GetOrCreateASN(mostPeersASN, firstSeen)
if err != nil {
t.Fatalf("GetOrCreateASN: %v", err)
}
whois := &database.ASNWHOISUpdate{
ASN: mostPeersASN, ASName: "TEST-AS", CountryCode: "DE",
}
err = s.db.UpdateASNWHOIS(t.Context(), whois)
if err != nil {
t.Fatalf("UpdateASNWHOIS: %v", err)
}
// ownAddressPath looks up the request's own address, which httptest
// gives as addrInTestPrefixV4.
for _, path := range []string{
"/api/v1/ip/" + addrInTestPrefixV4, "/ip/" + addrInTestPrefixV4,
ownAddressPath,
} {
var body struct {
Data IPLookupResponse `json:"data"`
}
err := json.Unmarshal(get(t, s, path).Body.Bytes(), &body)
if err != nil || len(body.Data.Results) != 1 ||
body.Data.Results[0].IPInfo == nil {
t.Fatalf("%s: answer %+v (%v), want one result with a route",
path, body.Data, err)
}
got := body.Data.Results[0].IPInfo
if got.Netblock != testPrefixV4 || got.ASN != mostPeersASN ||
got.NumPeers != 4 || !got.LastSeen.Equal(testRouteTime(5)) ||
!got.FirstSeen.Equal(firstSeen) || got.ASName != whois.ASName ||
got.CountryCode != whois.CountryCode {
t.Errorf("%s: route %+v, want %s from AS%d, 4 peers, last seen %v, "+
"first seen %v, %s in %s", path, *got, testPrefixV4,
mostPeersASN, testRouteTime(5), firstSeen, whois.ASName,
whois.CountryCode)
}
}
}
// prefixRoute is one route of the prefix API's answer. A field the answer
// leaves out stays nil.
//
//nolint:tagliatelle // field names are the existing JSON API
type prefixRoute struct {
OriginASN int `json:"origin_asn"`
PeerIP string `json:"peer_ip"`
ASPath []int `json:"as_path"`
NextHop *string `json:"next_hop"`
LastUpdated *time.Time `json:"last_updated"`
}
// TestPrefixAnswersListEveryPeer asks the prefix API and page for a prefix
// with two origin ASes, and for one whose origin keeps no route. Each answer
// lists every peer that carries the prefix, by origin AS. Only the kept routes
// have an AS path, a next hop and a time, newest first. The other peers follow
// without them.
func TestPrefixAnswersListEveryPeer(t *testing.T) {
t.Parallel()
s := newServerWithTable(t, newTwoOriginTable())
// keptAt[peer] is the time of the peer's kept route; a peer not in it is
// listed without a route.
keptAt := map[string]time.Time{
testPeer(3): testRouteTime(3),
testPeer(2): testRouteTime(2),
testPeer(4): testRouteTime(5),
}
tests := []struct {
prefix string
wantPeers []string
wantOrigins []int
}{
{
testPrefixV4,
[]string{testPeer(3), testPeer(2), testPeer(1), testPeer(4)},
[]int{mostPeersASN, mostPeersASN, mostPeersASN, fewerPeersASN},
},
{noKeptRoutePrefix, []string{testPeer(7)}, []int{mostPeersASN}},
}
for _, tt := range tests {
//nolint:tagliatelle // field names are the existing JSON API
var body struct {
Data struct {
Routes []prefixRoute `json:"routes"`
PeerCount int `json:"peer_count"`
OriginCount int `json:"origin_count"`
} `json:"data"`
}
path := "/api/v1/prefix/" + tt.prefix
err := json.Unmarshal(get(t, s, path).Body.Bytes(), &body)
if err != nil {
t.Fatalf("%s: decoding the answer: %v", path, err)
}
wantOriginCount := len(slices.Compact(slices.Clone(tt.wantOrigins)))
if body.Data.PeerCount != len(tt.wantPeers) ||
body.Data.OriginCount != wantOriginCount {
t.Errorf("%s: %d peers and %d origins, want %d and %d", path,
body.Data.PeerCount, body.Data.OriginCount, len(tt.wantPeers),
wantOriginCount)
}
checkPrefixRoutes(t, path, body.Data.Routes, tt.wantPeers, tt.wantOrigins,
keptAt)
checkPrefixPage(t, s, tt.prefix, tt.wantPeers, keptAt)
}
}
// checkPrefixRoutes checks that routes come from wantPeers, in that order,
// with wantOrigins as their origin ASes. The route of a peer in keptAt must
// have an AS path, the peer as next hop, and the time keptAt gives. Any other
// route must have none of the three.
func checkPrefixRoutes(
t *testing.T, path string, routes []prefixRoute, wantPeers []string,
wantOrigins []int, keptAt map[string]time.Time,
) {
t.Helper()
if len(routes) != len(wantPeers) {
t.Fatalf("%s: routes %+v, want one each from %v", path, routes, wantPeers)
}
for i, route := range routes {
if route.PeerIP != wantPeers[i] || route.OriginASN != wantOrigins[i] {
t.Errorf("%s: route %d from %s with AS%d, want from %s with AS%d",
path, i, route.PeerIP, route.OriginASN, wantPeers[i], wantOrigins[i])
}
at, kept := keptAt[route.PeerIP]
if !kept {
if route.ASPath != nil || route.NextHop != nil || route.LastUpdated != nil {
t.Errorf("%s: route from %s is not kept but has %+v",
path, route.PeerIP, route)
}
continue
}
if route.ASPath == nil || route.NextHop == nil ||
*route.NextHop != route.PeerIP || route.LastUpdated == nil ||
!route.LastUpdated.Equal(at) {
t.Errorf("%s: kept route from %s is %+v, want its AS path, the peer "+
"as next hop and the time %v", path, route.PeerIP, route, at)
}
}
}
// checkPrefixPage checks that the page of prefix shows each of wantPeers, the
// time of each kept route among them, and no time that was never set.
func checkPrefixPage(
t *testing.T, s *Server, prefix string, wantPeers []string,
keptAt map[string]time.Time,
) {
t.Helper()
path := "/prefix/" + prefix
page := get(t, s, path).Body.String()
for _, peer := range wantPeers {
want := `<td class="peer-ip">` + peer + `</td>`
if !strings.Contains(page, want) {
t.Errorf("%s does not list the peer %s", path, peer)
}
at, kept := keptAt[peer]
if kept && !strings.Contains(page, at.Format(routeTimeFormat)) {
t.Errorf("%s does not show the time %v of the route from %s",
path, at, peer)
}
}
if strings.Contains(page, zeroDate) {
t.Errorf("%s shows the date %s", path, zeroDate)
}
}
// TestASAnswersGiveEachPrefixItsNewestKeptRoute asks the AS API and page for
// mostPeersASN. Its prefixes are testPrefixV4, whose newest kept route with
// that origin AS is peer 3's, and noKeptRoutePrefix, which keeps no route. The
// first has that route's time. The second is listed without a time.
func TestASAnswersGiveEachPrefixItsNewestKeptRoute(t *testing.T) {
t.Parallel()
s := newServerWithTable(t, newTwoOriginTable())
path := "/api/v1/as/" + strconv.Itoa(mostPeersASN)
//nolint:tagliatelle // field names are the existing JSON API
var body struct {
Data struct {
IPv4Prefixes []struct {
Prefix string `json:"prefix"`
LastUpdated *time.Time `json:"last_updated"`
} `json:"ipv4_prefixes"`
} `json:"data"`
}
err := json.Unmarshal(get(t, s, path).Body.Bytes(), &body)
if err != nil {
t.Fatalf("%s: decoding the answer: %v", path, err)
}
prefixes := body.Data.IPv4Prefixes
if len(prefixes) != 2 || prefixes[0].Prefix != testPrefixV4 ||
prefixes[0].LastUpdated == nil ||
!prefixes[0].LastUpdated.Equal(testRouteTime(3)) ||
prefixes[1].Prefix != noKeptRoutePrefix || prefixes[1].LastUpdated != nil {
t.Errorf("%s: prefixes %+v, want %s updated %v and %s without a time",
path, prefixes, testPrefixV4, testRouteTime(3), noKeptRoutePrefix)
}
path = "/as/" + strconv.Itoa(mostPeersASN)
page := get(t, s, path).Body.String()
for _, want := range []string{
testPrefixV4, noKeptRoutePrefix, testRouteTime(3).Format(routeTimeFormat),
} {
if !strings.Contains(page, want) {
t.Errorf("%s does not show %s", path, want)
}
}
if strings.Contains(page, zeroDate) {
t.Errorf("%s shows the date %s", path, zeroDate)
}
}
// TestPrefixLengthPagesShowTheOriginMostPeersCarry asks for the IPv4 /24
// page and the IPv6 /32 page. The first lists both /24 prefixes with
// mostPeersASN, the origin AS more peers carry testPrefixV4 with, and gives
// noKeptRoutePrefix no age. The second lists testPrefixV6.
func TestPrefixLengthPagesShowTheOriginMostPeersCarry(t *testing.T) {
t.Parallel()
s := newServerWithTable(t, newTwoOriginTable())
page := get(t, s, prefixLengthPagePath).Body.String()
for _, want := range []string{
">" + testPrefixV4 + "<", ">" + noKeptRoutePrefix + "<",
`href="/as/` + strconv.Itoa(mostPeersASN) + `"`,
} {
if !strings.Contains(page, want) {
t.Errorf("%s does not show %s", prefixLengthPagePath, want)
}
}
if strings.Contains(page, `href="/as/`+strconv.Itoa(fewerPeersASN)+`"`) {
t.Errorf("%s links to AS%d, which fewer peers carry",
prefixLengthPagePath, fewerPeersASN)
}
const noAge = `<td class="age"></td>`
if strings.Count(page, noAge) != 1 {
t.Errorf("%s gives %d prefixes no age, want 1 (%s)",
prefixLengthPagePath, strings.Count(page, noAge), noKeptRoutePrefix)
}
const v6PagePath = "/prefixlength6/32"
page = get(t, s, v6PagePath).Body.String()
if !strings.Contains(page, ">"+testPrefixV6+"<") ||
!strings.Contains(page, `href="/as/`+strconv.Itoa(v6OriginASN)+`"`) {
t.Errorf("%s does not show %s with AS%d", v6PagePath, testPrefixV6,
v6OriginASN)
}
}