Files
routewatch/internal/server/home_internal_test.go
T
clawbot 281d162e27
check / check (push) Canceled after 0s
Show figures of the routing table on the home page (closes #154)
The home page gets cards for the newest and oldest kept route, the newest
and oldest updated AS, the AS with the most prefixes, the ASes with a
single prefix and fifteen well-known networks. Two more cards count the
bogon announcements and the prefixes with several origin ASes. They link
to the new pages /bogons and /multiple-origins, which list up to 1,000
entries each.

The new package internal/bogon tells reserved prefixes and AS numbers,
from the IANA special-purpose and address space registries. Table.Overview
walks the table and lets the read lock go every 10,000 prefixes. The
server works the cards out once a minute and serves that copy. A new index
on asns(last_seen) finds the newest and oldest updated AS.

Model: opus-5-5
2026-10-27 18:27:01 +01:00

359 lines
10 KiB
Go

package server
import (
"context"
"net/netip"
"strconv"
"strings"
"testing"
"time"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/routetable"
)
// The ASes of the routing table that newCardsTable fills. Each card test
// finds one of them on its card.
const (
twoPrefixASN = 13335 // originates two prefixes, the most
newestRouteASN = 15169 // the newest route
oldestRouteASN = 3 // the oldest route
secondOrigin = 16509 // a second origin AS of twoPrefixASN's prefix
privateUseASN = 64512 // reserved, so its route is a bogon announcement
)
// Paths of the pages that list what two of the home page's cards count.
const (
bogonsPath = "/bogons"
multipleOriginsPath = "/multiple-origins"
)
// cardsNotReadyText is what the pages say before the cards are first worked
// out.
const cardsNotReadyText = "being worked out"
// newCardsTable returns a routing table in which:
// - twoPrefixASN originates 1.0.0.0/24 and 1.0.4.0/22;
// - secondOrigin originates 1.0.0.0/24 too, which so has two origin ASes;
// - newestRouteASN's 8.8.8.0/24 has the newest route;
// - oldestRouteASN's 18.0.0.0/8 has the oldest;
// - privateUseASN originates 10.0.0.0/8, carried by two peers: a bogon
// announcement for both its prefix and its AS.
//
// The last four ASes originate one prefix each.
func newCardsTable() *routetable.Table {
table := emptyTable()
announce(table, "1.0.0.0/24", twoPrefixASN, testPeer(1), testRouteTime(10))
announce(table, "1.0.4.0/22", twoPrefixASN, testPeer(1), testRouteTime(5))
announce(table, "1.0.0.0/24", secondOrigin, testPeer(3), testRouteTime(12))
announce(table, "8.8.8.0/24", newestRouteASN, testPeer(2), testRouteTime(20))
announce(table, "18.0.0.0/8", oldestRouteASN, testPeer(2), testRouteTime(1))
announce(table, "10.0.0.0/8", privateUseASN, testPeer(1), testRouteTime(15))
announce(table, "10.0.0.0/8", privateUseASN, testPeer(2), testRouteTime(15))
return table
}
// homeCard returns the HTML of the home page's card with heading, up to the
// end of the card.
func homeCard(t *testing.T, page, heading string) string {
t.Helper()
_, card, found := strings.Cut(page, "<h2>"+heading+"</h2>")
if !found {
t.Fatalf("the home page has no card %q", heading)
}
card, _, _ = strings.Cut(card, "</div>")
return card
}
// asLink returns the HTML attribute of a link to the AS page of asn.
func asLink(asn int) string {
return `href="/as/` + strconv.Itoa(asn) + `"`
}
// TestHomePageCardsShowTheTableFigures fills a routing table and the asns
// table, works out the cards, and checks that each card shows its figure.
func TestHomePageCardsShowTheTableFigures(t *testing.T) {
t.Parallel()
const (
newestSeenASN = 6939
oldestSeenASN = 174
)
s := newServerWithTable(t, newCardsTable())
err := s.db.GetOrCreateASNBatch(map[int]database.SeenTimes{
newestSeenASN: {First: testRouteTime(0), Last: testRouteTime(30)},
oldestSeenASN: {First: testRouteTime(-30), Last: testRouteTime(-30)},
})
if err != nil {
t.Fatalf("GetOrCreateASNBatch: %v", err)
}
s.refreshHomeCards(t.Context())
page := get(t, s, "/").Body.String()
tests := []struct {
heading string
want []string
}{
{"Newest Updated Route", []string{
"8.8.8.0/24", asLink(newestRouteASN),
testRouteTime(20).Format(routeTimeFormat),
}},
{"Oldest Updated Route", []string{
"18.0.0.0/8", asLink(oldestRouteASN),
testRouteTime(1).Format(routeTimeFormat),
}},
{"Newest Updated AS", []string{
asLink(newestSeenASN), testRouteTime(30).Format(routeTimeFormat),
}},
{"Oldest Updated AS", []string{
asLink(oldestSeenASN), testRouteTime(-30).Format(routeTimeFormat),
}},
{"AS with the Most Prefixes", []string{
asLink(twoPrefixASN), "Prefixes it originates: 2",
}},
{"ASes with the Fewest Prefixes", []string{
"ASes that originate a single prefix: 4", asLink(newestRouteASN),
"8.8.8.0/24",
}},
{"Bogon Announcements", []string{`href="/bogons">1<`}},
{"Prefixes with Several Origins", []string{`href="/multiple-origins">1<`}},
}
for _, tc := range tests {
card := homeCard(t, page, tc.heading)
for _, want := range tc.want {
if !strings.Contains(card, want) {
t.Errorf("card %q does not hold %s: %s", tc.heading, want, card)
}
}
}
// The well-known networks come newest update first, and those without
// a route after them.
networks := homeCard(t, page, "Well-Known Networks")
order := []int{newestRouteASN, secondOrigin, twoPrefixASN, oldestRouteASN, 714}
last := -1
for _, asn := range order {
i := strings.Index(networks, asLink(asn))
if i <= last {
t.Fatalf("well-known networks: AS%d is not after the ASes before "+
"it in %v: %s", asn, order, networks)
}
last = i
}
if !strings.Contains(networks, "<td>2</td>") {
t.Errorf("well-known networks: no network with 2 prefixes: %s", networks)
}
}
// TestListPagesListTheirEntries checks the rows of the bogon and multiple-origin
// pages for the table of newCardsTable.
func TestListPagesListTheirEntries(t *testing.T) {
t.Parallel()
s := newServerWithTable(t, newCardsTable())
s.refreshHomeCards(t.Context())
pages := map[string][]string{
bogonsPath: {
"Bogon announcements: 1.", "10.0.0.0/8", asLink(privateUseASN),
"<td>2</td>",
"Private-Use (10.0.0.0/8); Private-Use (AS64512-AS65534)",
},
multipleOriginsPath: {
"Prefixes announced by more than one origin AS: 1.", "1.0.0.0/24",
asLink(twoPrefixASN), asLink(secondOrigin), ">1 peer<",
},
}
for path, wants := range pages {
page := get(t, s, path).Body.String()
for _, want := range wants {
if !strings.Contains(page, want) {
t.Errorf("%s does not hold %s", path, want)
}
}
}
}
// TestListPagesListAtMostMaxListedEntries fills a table with more prefixes than
// the list pages list, each announced by two origin ASes in reserved address
// space. Each page lists maxListedEntries of them and gives how many there are
// in all.
func TestListPagesListAtMostMaxListedEntries(t *testing.T) {
t.Parallel()
const prefixCount = maxListedEntries + 1
table := emptyTable()
addr := netip.MustParseAddr("10.0.0.0")
for range prefixCount {
prefix := netip.PrefixFrom(addr, addr.BitLen()).String()
announce(table, prefix, twoPrefixASN, testPeer(1), testRouteTime(0))
announce(table, prefix, secondOrigin, testPeer(2), testRouteTime(0))
addr = addr.Next()
}
s := newServerWithTable(t, table)
s.refreshHomeCards(t.Context())
listed := "The first " + strconv.Itoa(maxListedEntries) + " are listed"
tests := []struct {
path string
total string
}{
{bogonsPath, "Bogon announcements: " + strconv.Itoa(2*prefixCount) + "."},
{multipleOriginsPath, "Prefixes announced by more than one origin AS: " +
strconv.Itoa(prefixCount) + "."},
}
for _, tc := range tests {
page := get(t, s, tc.path).Body.String()
rows := strings.Count(page, `class="prefix-link"`)
if rows != maxListedEntries {
t.Errorf("%s lists %d entries, want %d", tc.path, rows,
maxListedEntries)
}
if !strings.Contains(page, tc.total) || !strings.Contains(page, listed) {
t.Errorf("%s does not say %q and %q", tc.path, tc.total, listed)
}
}
}
// TestPagesServeTheCopyOfTheCards checks that the pages show the cards as they
// were last worked out, not as the routing table is now, and that they say so
// before the cards are first worked out.
func TestPagesServeTheCopyOfTheCards(t *testing.T) {
t.Parallel()
table := emptyTable()
announce(table, "1.0.0.0/24", twoPrefixASN, testPeer(1), testRouteTime(0))
s := newServerWithTable(t, table)
for _, path := range []string{"/", bogonsPath, multipleOriginsPath} {
if !strings.Contains(get(t, s, path).Body.String(), cardsNotReadyText) {
t.Errorf("%s does not say the cards are %s", path, cardsNotReadyText)
}
}
s.refreshHomeCards(t.Context())
announce(table, "8.8.8.0/24", newestRouteASN, testPeer(2), testRouteTime(1))
card := homeCard(t, get(t, s, "/").Body.String(), "Newest Updated Route")
if !strings.Contains(card, "1.0.0.0/24") {
t.Errorf("before the cards are worked out again, the newest route "+
"is not the one they were worked out with: %s", card)
}
s.refreshHomeCards(t.Context())
card = homeCard(t, get(t, s, "/").Body.String(), "Newest Updated Route")
if !strings.Contains(card, "8.8.8.0/24") {
t.Errorf("after the cards are worked out again, the newest route is "+
"not the table's: %s", card)
}
}
// TestKeepHomeCardsFreshWorksThemOutEveryInterval runs keepHomeCardsFresh with
// a short interval. It works the cards out at once, again after the table
// changes, and stops when its context ends.
func TestKeepHomeCardsFreshWorksThemOutEveryInterval(t *testing.T) {
t.Parallel()
const (
interval = 10 * time.Millisecond
maxWait = 5 * time.Second
)
table := emptyTable()
announce(table, "1.0.0.0/24", twoPrefixASN, testPeer(1), testRouteTime(0))
s := newServerWithTable(t, table)
ctx, cancel := context.WithCancel(t.Context())
stopped := make(chan struct{})
go func() {
s.keepHomeCardsFresh(ctx, interval)
close(stopped)
}()
newestRouteASNIs := func(asn int) bool {
cards := s.cards.Load()
return cards != nil && cards.NewestRoute != nil &&
cards.NewestRoute.OriginASN == asn
}
waitUntil(t, maxWait, func() bool { return newestRouteASNIs(twoPrefixASN) })
announce(table, "8.8.8.0/24", newestRouteASN, testPeer(2), testRouteTime(1))
waitUntil(t, maxWait, func() bool { return newestRouteASNIs(newestRouteASN) })
cancel()
select {
case <-stopped:
case <-time.After(maxWait):
t.Fatal("keepHomeCardsFresh did not stop when its context ended")
}
}
// TestStartedServerWorksOutTheCards starts a server and waits until its home
// page shows the cards.
func TestStartedServerWorksOutTheCards(t *testing.T) {
t.Parallel()
const maxWait = 5 * time.Second
s := newServerWithTable(t, newCardsTable())
err := s.Start(t.Context())
if err != nil {
t.Fatalf("Start: %v", err)
}
defer func() { _ = s.Stop(context.Background()) }()
waitUntil(t, maxWait, func() bool {
return !strings.Contains(get(t, s, "/").Body.String(), cardsNotReadyText)
})
card := homeCard(t, get(t, s, "/").Body.String(), "Newest Updated Route")
if !strings.Contains(card, "8.8.8.0/24") {
t.Errorf("the newest route is not the table's: %s", card)
}
}
// waitUntil fails the test unless done reports true within maxWait.
func waitUntil(t *testing.T, maxWait time.Duration, done func() bool) {
t.Helper()
const pollInterval = 5 * time.Millisecond
deadline := time.Now().Add(maxWait)
for !done() {
if time.Now().After(deadline) {
t.Fatalf("the cards did not change within %s", maxWait)
}
time.Sleep(pollInterval)
}
}