Make the home page's AS name search work (closes #151)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
The AS Name Search card on the home page said the search was coming
soon. It now asks the new GET /api/v1/search/as?q=<text>, which
searches the AS list built into asinfo, and lists the matches as links
to their AS pages. An empty q answers 400. asinfo.Search now ignores
case and returns its matches in AS number order. asinfo puts each
handle and description in lower case once, when the list loads.
A match can be an AS that the database has no record of. Its AS page
and /api/v1/as/{asn} then take its handle and description from asinfo
and still list its prefixes. They answer 404 only for an AS with no
record, no asinfo entry and no prefix. Empty prefix lists in that JSON
answer are [] rather than null.
Model: opus-5-5
This commit is contained in:
@@ -26,6 +26,7 @@ import (
|
||||
// The prefix and peer addresses the prefix page tests store routes for.
|
||||
const (
|
||||
testPrefixV4 = "192.0.2.0/24"
|
||||
testPrefixV6 = "2001:db8::/32"
|
||||
testPeerV4 = "198.51.100.1"
|
||||
testPeerV6 = "2001:db8::1"
|
||||
)
|
||||
@@ -194,7 +195,7 @@ func TestStatsHandlersAnswerFromMemory(t *testing.T) {
|
||||
}
|
||||
|
||||
// newServerWithTestRoutes returns a server on a new database that holds a
|
||||
// route for testPrefixV4 and one for 2001:db8::/32.
|
||||
// route for testPrefixV4 and one for testPrefixV6.
|
||||
func newServerWithTestRoutes(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
|
||||
@@ -214,7 +215,7 @@ func newServerWithTestRoutes(t *testing.T) *Server {
|
||||
NextHop: testPeerV4, LastUpdated: ts,
|
||||
},
|
||||
{
|
||||
Prefix: "2001:db8::/32", MaskLength: 32, IPVersion: 6,
|
||||
Prefix: testPrefixV6, MaskLength: 32, IPVersion: 6,
|
||||
OriginASN: 64501, PeerIP: testPeerV6, ASPath: []int{64501},
|
||||
NextHop: testPeerV6, LastUpdated: ts,
|
||||
},
|
||||
@@ -1166,6 +1167,327 @@ func TestIPLookupHidesPrivateAddresses(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// asSearchPath is the path of the AS name search.
|
||||
const asSearchPath = "/api/v1/search/as"
|
||||
|
||||
// searchAS sends an AS name search with the URL query rawQuery through s's
|
||||
// router, expects a 200 answer, and returns its data.
|
||||
func searchAS(t *testing.T, s *Server, rawQuery string) ASSearchResponse {
|
||||
t.Helper()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, asSearchPath+"?"+rawQuery, nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s: status %d, want %d; body %s",
|
||||
rawQuery, rec.Code, http.StatusOK, rec.Body)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Data ASSearchResponse `json:"data"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: decoding the answer %q: %v", rawQuery, rec.Body, err)
|
||||
}
|
||||
|
||||
return body.Data
|
||||
}
|
||||
|
||||
// TestASSearchAnswersMatches searches for Cloudflare's handle in lower case,
|
||||
// with spaces around it, and expects AS13335 among the matches. Before the fix
|
||||
// there was no AS name search, and the home page's search card said it was
|
||||
// coming soon (https://git.eeqj.de/sneak/routewatch/issues/151).
|
||||
func TestASSearchAnswersMatches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const query = "cloudflarenet"
|
||||
|
||||
data := searchAS(t, newServerWithTestRoutes(t), "q=%20"+query+"%20")
|
||||
|
||||
if data.Query != query {
|
||||
t.Errorf("query %q, want %q", data.Query, query)
|
||||
}
|
||||
|
||||
if data.Total != len(data.Results) {
|
||||
t.Errorf("total %d, but %d results", data.Total, len(data.Results))
|
||||
}
|
||||
|
||||
cloudflare := asinfo.Info{
|
||||
ASN: 13335, Handle: "CLOUDFLARENET", Description: "Cloudflare, Inc.",
|
||||
}
|
||||
if !slices.Contains(data.Results, cloudflare) {
|
||||
t.Errorf("results %v do not hold %v", data.Results, cloudflare)
|
||||
}
|
||||
}
|
||||
|
||||
// TestASSearchAnswersEmptyListWhenNothingMatches expects a search that matches
|
||||
// nothing to answer 200 with a total of 0 and an empty list, not null.
|
||||
func TestASSearchAnswersEmptyListWhenNothingMatches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := searchAS(t, newServerWithTestRoutes(t), "q=XYZABC123456")
|
||||
|
||||
if data.Total != 0 || data.Results == nil || len(data.Results) != 0 {
|
||||
t.Errorf("total %d and results %#v, want 0 and an empty list",
|
||||
data.Total, data.Results)
|
||||
}
|
||||
}
|
||||
|
||||
// TestASSearchAnswers400ForEmptyQuery expects a search with no q, an empty q
|
||||
// or a q of only spaces to answer 400 with the JSON error body.
|
||||
func TestASSearchAnswers400ForEmptyQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newServerWithTestRoutes(t)
|
||||
|
||||
for _, path := range []string{
|
||||
asSearchPath, asSearchPath + "?q=", asSearchPath + "?q=%20%20",
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, path, nil))
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("%s: status %d, want %d; body %s",
|
||||
path, rec.Code, http.StatusBadRequest, rec.Body)
|
||||
}
|
||||
|
||||
checkJSONErrorCode(t, path, rec, http.StatusBadRequest)
|
||||
checkJSONErrorMessage(t, path, rec, searchQueryMissingMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestASSearchShowsFirstMatchesAndTotal searches for a word with more matches
|
||||
// than an answer holds, and expects the first maxASSearchResults of them, in
|
||||
// AS number order, and the number of all of them.
|
||||
func TestASSearchShowsFirstMatchesAndTotal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const query = "university"
|
||||
|
||||
all := asinfo.Search(query)
|
||||
if len(all) <= maxASSearchResults {
|
||||
t.Fatalf("Search(%q) has %d matches; the test needs more than %d",
|
||||
query, len(all), maxASSearchResults)
|
||||
}
|
||||
|
||||
data := searchAS(t, newServerWithTestRoutes(t), "q="+query)
|
||||
|
||||
if data.Total != len(all) {
|
||||
t.Errorf("total %d, want %d", data.Total, len(all))
|
||||
}
|
||||
|
||||
if !slices.Equal(data.Results, all[:maxASSearchResults]) {
|
||||
t.Errorf("results %v, want the first %d matches %v",
|
||||
data.Results, maxASSearchResults, all[:maxASSearchResults])
|
||||
}
|
||||
}
|
||||
|
||||
// The AS numbers the AS detail tests ask for. storedASN originates the route
|
||||
// for testPrefixV4 and is in the database. routeOnlyASN originates the route
|
||||
// for testPrefixV6, but the database has no record of the AS itself, as when
|
||||
// a route is stored before its AS. builtInOnlyASN is in the AS list built into
|
||||
// asinfo but not in the database. unknownASN is in neither.
|
||||
const (
|
||||
storedASN = 64500
|
||||
routeOnlyASN = 64501
|
||||
builtInOnlyASN = 15169
|
||||
unknownASN = 4200000000
|
||||
)
|
||||
|
||||
// zeroDate is how a page writes a time that was never set.
|
||||
const zeroDate = "0001-01-01"
|
||||
|
||||
// firstSeenCard is the label of the AS page's first-seen date.
|
||||
const firstSeenCard = `<div class="info-label">First Seen</div>`
|
||||
|
||||
// newServerWithStoredAS returns newServerWithTestRoutes's server with storedASN
|
||||
// added to the database. It fails the test when the AS list built into asinfo
|
||||
// lacks routeOnlyASN or builtInOnlyASN, or holds unknownASN.
|
||||
func newServerWithStoredAS(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
|
||||
for _, asn := range []int{routeOnlyASN, builtInOnlyASN} {
|
||||
if _, ok := asinfo.Get(asn); !ok {
|
||||
t.Fatalf("AS%d is not in the AS info data", asn)
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := asinfo.Get(unknownASN); ok {
|
||||
t.Fatalf("AS%d is in the AS info data", unknownASN)
|
||||
}
|
||||
|
||||
s := newServerWithTestRoutes(t)
|
||||
|
||||
_, err := s.db.GetOrCreateASN(storedASN, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrCreateASN: %v", err)
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// TestASDetailJSONAnswersBuiltInAS asks /api/v1/as/ for each of the ASes above.
|
||||
// All but unknownASN answer 200 with the handle and description from the
|
||||
// built-in AS list and their prefixes, as lists even when empty. Only storedASN
|
||||
// has a first_seen time. unknownASN answers 404. Before the fix builtInOnlyASN
|
||||
// answered 404, so a result of the AS name search could lead nowhere
|
||||
// (https://git.eeqj.de/sneak/routewatch/issues/151), and routeOnlyASN listed
|
||||
// no prefixes.
|
||||
func TestASDetailJSONAnswersBuiltInAS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newServerWithStoredAS(t)
|
||||
|
||||
tests := []struct {
|
||||
asn int
|
||||
wantCode int
|
||||
wantPrefixes []string
|
||||
wantFirstSeen bool
|
||||
}{
|
||||
{storedASN, http.StatusOK, []string{testPrefixV4}, true},
|
||||
{routeOnlyASN, http.StatusOK, []string{testPrefixV6}, false},
|
||||
{builtInOnlyASN, http.StatusOK, nil, false},
|
||||
{unknownASN, http.StatusNotFound, nil, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
path := "/api/v1/as/" + strconv.Itoa(tt.asn)
|
||||
rec := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, path, nil))
|
||||
|
||||
if rec.Code != tt.wantCode {
|
||||
t.Errorf("%s: status %d, want %d; body %s",
|
||||
path, rec.Code, tt.wantCode, rec.Body)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if tt.wantCode != http.StatusOK {
|
||||
checkJSONErrorCode(t, path, rec, tt.wantCode)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
//nolint:tagliatelle // field names are the existing JSON API
|
||||
var body struct {
|
||||
Data struct {
|
||||
ASN database.ASN `json:"asn"`
|
||||
IPv4Prefixes []database.LiveRoute `json:"ipv4_prefixes"`
|
||||
IPv6Prefixes []database.LiveRoute `json:"ipv6_prefixes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
if err != nil {
|
||||
t.Errorf("%s: decoding the answer %q: %v", path, rec.Body, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
info, _ := asinfo.Get(tt.asn)
|
||||
got := body.Data.ASN
|
||||
|
||||
if got.ASN != tt.asn || got.Handle != info.Handle ||
|
||||
got.Description != info.Description {
|
||||
t.Errorf("%s: AS %d %q %q, want %d %q %q", path, got.ASN,
|
||||
got.Handle, got.Description, tt.asn, info.Handle, info.Description)
|
||||
}
|
||||
|
||||
if body.Data.IPv4Prefixes == nil || body.Data.IPv6Prefixes == nil {
|
||||
t.Errorf("%s: a prefix list is null; body %s", path, rec.Body)
|
||||
}
|
||||
|
||||
routes := slices.Concat(body.Data.IPv4Prefixes, body.Data.IPv6Prefixes)
|
||||
|
||||
gotPrefixes := make([]string, 0, len(routes))
|
||||
for _, route := range routes {
|
||||
gotPrefixes = append(gotPrefixes, route.Prefix)
|
||||
}
|
||||
|
||||
if !slices.Equal(gotPrefixes, tt.wantPrefixes) {
|
||||
t.Errorf("%s: prefixes %v, want %v", path, gotPrefixes, tt.wantPrefixes)
|
||||
}
|
||||
|
||||
gotFirstSeen := strings.Contains(rec.Body.String(), `"first_seen"`)
|
||||
if gotFirstSeen != tt.wantFirstSeen {
|
||||
t.Errorf("%s: has first_seen %t, want %t; body %s",
|
||||
path, gotFirstSeen, tt.wantFirstSeen, rec.Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestASDetailPageShowsBuiltInAS asks /as/ for each of the ASes above. All but
|
||||
// unknownASN answer 200 with the handle and description from the built-in AS
|
||||
// list. storedASN and routeOnlyASN list their prefixes, and builtInOnlyASN says
|
||||
// the AS announces none. Only storedASN shows a first-seen date, and no page
|
||||
// shows a date that was never set. unknownASN answers 404. Before the fix
|
||||
// builtInOnlyASN answered 404, so a result of the AS name search led nowhere
|
||||
// (https://git.eeqj.de/sneak/routewatch/issues/151), and routeOnlyASN listed no
|
||||
// prefixes.
|
||||
func TestASDetailPageShowsBuiltInAS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newServerWithStoredAS(t)
|
||||
|
||||
tests := []struct {
|
||||
asn int
|
||||
wantCode int
|
||||
wantText string
|
||||
wantFirstSeen bool
|
||||
}{
|
||||
{storedASN, http.StatusOK, testPrefixV4, true},
|
||||
{routeOnlyASN, http.StatusOK, testPrefixV6, false},
|
||||
{builtInOnlyASN, http.StatusOK, "No prefixes announced by this AS", false},
|
||||
{unknownASN, http.StatusNotFound, "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
path := "/as/" + strconv.Itoa(tt.asn)
|
||||
rec := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, path, nil))
|
||||
|
||||
if rec.Code != tt.wantCode {
|
||||
t.Errorf("%s: status %d, want %d; body %s",
|
||||
path, rec.Code, tt.wantCode, rec.Body)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if tt.wantCode != http.StatusOK {
|
||||
continue
|
||||
}
|
||||
|
||||
info, _ := asinfo.Get(tt.asn)
|
||||
page := rec.Body.String()
|
||||
|
||||
for _, want := range []string{
|
||||
html.EscapeString(info.Handle),
|
||||
html.EscapeString(info.Description),
|
||||
tt.wantText,
|
||||
} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Errorf("%s: page does not show %q", path, want)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(page, zeroDate) {
|
||||
t.Errorf("%s: page shows the date %s", path, zeroDate)
|
||||
}
|
||||
|
||||
gotFirstSeen := strings.Contains(page, firstSeenCard)
|
||||
if gotFirstSeen != tt.wantFirstSeen {
|
||||
t.Errorf("%s: shows a first-seen date %t, want %t",
|
||||
path, gotFirstSeen, tt.wantFirstSeen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// settledGoroutineCount lets transient goroutines finish, then reports the
|
||||
// current count.
|
||||
func settledGoroutineCount() int {
|
||||
|
||||
Reference in New Issue
Block a user