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 := `` + peer + `` 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 = `` 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) } }