check / check (push) Waiting to run
Server.Start ran the loop that works out the home page's cards on the context it was given. fx ends that context once start-up is done, so the cards were worked out once and never again. The loop now runs on a context that only Stop ends, as the other background parts do. Stop waits for the loop to return, until its own context ends, before it stops the HTTP server. A new test starts a server in a synctest bubble and ends its context at once. On the bubble's fake clock, the cards must be worked out again a minute later, and not after Stop. Model: opus-5-5
420 lines
12 KiB
Go
420 lines
12 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"net/netip"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"testing/synctest"
|
|
"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)
|
|
}
|
|
}
|
|
|
|
// TestStartedServerWorksOutTheCardsUntilStop starts a server with a context
|
|
// that ends as soon as Start returns. It runs in a synctest bubble, whose clock
|
|
// is fake: time.Sleep there moves the clock on and returns at once. The server
|
|
// must work the cards out again a minute later, and no more after Stop. Each
|
|
// time it works them out, it keeps a new copy. Before the fix the end of
|
|
// Start's context stopped the goroutine that works them out.
|
|
func TestStartedServerWorksOutTheCardsUntilStop(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// The server is made outside the bubble. The streamer's metrics start a
|
|
// goroutine that never returns, and a bubble waits for each of its
|
|
// goroutines to return.
|
|
s := newServerWithTable(t, newCardsTable())
|
|
|
|
synctest.Test(t, func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
|
|
err := s.Start(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
|
|
cancel()
|
|
|
|
// The bubble's clock moves only while each of its goroutines waits
|
|
// on the bubble, and the goroutine that serves HTTP waits on the
|
|
// network. The test sends no request, so it closes the HTTP server.
|
|
_ = s.srv.Close()
|
|
|
|
synctest.Wait()
|
|
|
|
started := s.cards.Load()
|
|
if started == nil {
|
|
t.Fatal("the server did not work out the cards when it started")
|
|
}
|
|
|
|
time.Sleep(homeCardsRefreshInterval)
|
|
synctest.Wait()
|
|
|
|
if s.cards.Load() == started {
|
|
t.Error("the server did not work out the cards again a minute " +
|
|
"after its start context ended")
|
|
}
|
|
|
|
err = s.Stop(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("Stop: %v", err)
|
|
}
|
|
|
|
stopped := s.cards.Load()
|
|
|
|
time.Sleep(homeCardsRefreshInterval)
|
|
synctest.Wait()
|
|
|
|
if s.cards.Load() != stopped {
|
|
t.Error("the server worked out the cards again after Stop")
|
|
}
|
|
})
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|