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, "

"+heading+"

") if !found { t.Fatalf("the home page has no card %q", heading) } card, _, _ = strings.Cut(card, "") 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, "2") { 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), "2", "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) } }