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/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, 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) } } } // 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 := `