Files
routewatch/internal/server/handlers_table_internal_test.go
T
sneak a749d7abe6
check / check (push) Canceled after 0s
Resolve PTR names over DNS-over-HTTPS and cache them for the record TTL (closes #238)
`internal/ptr` asks Google, Cloudflare, AdGuard and Quad9 in turn, 2 seconds in all per address. It keeps answers in memory and writes them through to `cache.db`, opened by the new `internal/cachedb`. A reserved address is not sent to the resolvers.

Judgement calls:
- Each resolver gets an equal share of the time left, so a hung resolver does not use up the 2 seconds.
- The negative TTL is the smaller of the SOA minimum and the SOA's own TTL (RFC 2308).
- Names that are not host names are dropped, as `net.Resolver.LookupAddr` does.

Model: opus-5-5
2026-11-10 11:35:16 +00:00

414 lines
13 KiB
Go

package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/netip"
"reflect"
"slices"
"strconv"
"strings"
"testing"
"time"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
"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, lists both origin ASes,
// 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. A WHOIS
// detail the database does not hold is null.
func TestIPLookupGivesTheOriginMostPeersCarry(t *testing.T) {
t.Parallel()
s := newServerWithTable(t, newTwoOriginTable())
useFakeResolver(s, &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)
}
// The details the database does not hold, such as org_name, are null.
want := IPInfo{
Netblock: new(testPrefixV4),
MaskLength: new(netip.MustParsePrefix(testPrefixV4).Bits()),
IPVersion: new(ipVersionV4),
NumPeers: new(4),
ASN: new(mostPeersASN),
NumOrigins: new(2),
OriginASNs: []int{mostPeersASN, fewerPeersASN},
ASName: &whois.ASName,
CountryCode: &whois.CountryCode,
FirstSeen: &firstSeen,
LastSeen: new(testRouteTime(5)),
}
wantJSON, err := json.Marshal(want)
if err != nil {
t.Fatalf("encoding %+v: %v", want, 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"`
}
answer := get(t, s, path).Body.Bytes()
err := json.Unmarshal(answer, &body)
if err != nil || len(body.Data.Results) != 1 {
t.Fatalf("%s: answer %s (%v), want one result", path, answer, err)
}
if !reflect.DeepEqual(body.Data.Results[0].IPInfo, want) {
t.Errorf("%s: answer %s, want the fields %s", path, answer, wantJSON)
}
}
}
// TestIPLookupAsksForNoWHOISLookupOfOriginZero checks that the IP lookup asks
// for no WHOIS lookup of AS 0, which means no known origin. It asks for one of
// an AS whose WHOIS details were never fetched or are stale, and not of one
// whose details are fresh.
func TestIPLookupAsksForNoWHOISLookupOfOriginZero(t *testing.T) {
t.Parallel()
fresh := time.Now().Add(-time.Hour)
stale := time.Now().Add(-2 * whoisStaleThreshold)
tests := []struct {
name string
asInfo database.ASN
want bool
}{
{"no known origin", database.ASN{ASN: ristypes.NoOrigin}, false},
{"details never fetched", database.ASN{ASN: mostPeersASN}, true},
{"stale details", database.ASN{ASN: mostPeersASN, WHOISUpdatedAt: &stale}, true},
{"fresh details", database.ASN{ASN: mostPeersASN, WHOISUpdatedAt: &fresh}, false},
}
for _, tt := range tests {
if got := needsWHOISRefresh(&tt.asInfo); got != tt.want {
t.Errorf("%s: needsWHOISRefresh = %t, want %t", tt.name, got, tt.want)
}
}
}
// 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)
}
}