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.
A match can be an AS that the database does not hold yet. Its AS page
and /api/v1/as/{asn} now answer with its handle and description from
asinfo and no prefixes, instead of 404. Empty prefix lists in that JSON
answer are [] rather than null.
Model: opus-5-5
This commit is contained in:
@@ -1166,6 +1166,303 @@ 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. builtInOnlyASN is in the AS list
|
||||
// built into asinfo but not in the database. unknownASN is in neither.
|
||||
const (
|
||||
storedASN = 64500
|
||||
builtInOnlyASN = 15169
|
||||
unknownASN = 4200000000
|
||||
)
|
||||
|
||||
// zeroDate is how a page writes a time that was never set.
|
||||
const zeroDate = "0001-01-01"
|
||||
|
||||
// newServerWithStoredAS returns newServerWithTestRoutes's server with storedASN
|
||||
// added to the database. It fails the test when the AS list built into asinfo
|
||||
// lacks builtInOnlyASN or holds unknownASN.
|
||||
func newServerWithStoredAS(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
|
||||
if _, ok := asinfo.Get(builtInOnlyASN); !ok {
|
||||
t.Fatalf("AS%d is not in the AS info data", builtInOnlyASN)
|
||||
}
|
||||
|
||||
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 an AS in the database,
|
||||
// one only the built-in AS list knows, and one in neither. The first two answer
|
||||
// 200 with the handle and description from that list and their prefixes, as
|
||||
// lists even when empty. Only the AS in the database has a first_seen time. The
|
||||
// third answers 404. Before the fix the second answered 404, so a result of the
|
||||
// AS name search could lead nowhere
|
||||
// (https://git.eeqj.de/sneak/routewatch/issues/151).
|
||||
func TestASDetailJSONAnswersBuiltInAS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newServerWithStoredAS(t)
|
||||
|
||||
tests := []struct {
|
||||
asn int
|
||||
wantCode int
|
||||
wantPrefixes int
|
||||
wantFirstSeen bool
|
||||
}{
|
||||
{storedASN, http.StatusOK, 1, true},
|
||||
{builtInOnlyASN, http.StatusOK, 0, false},
|
||||
{unknownASN, http.StatusNotFound, 0, 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)
|
||||
}
|
||||
|
||||
gotPrefixes := len(body.Data.IPv4Prefixes) + len(body.Data.IPv6Prefixes)
|
||||
if gotPrefixes != tt.wantPrefixes {
|
||||
t.Errorf("%s: %d prefixes, want %d", 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 an AS in the database, one only
|
||||
// the built-in AS list knows, and one in neither. The first two answer 200 with
|
||||
// the handle and description from that list. The first lists its prefix, and
|
||||
// the second says the AS announces none. Neither shows a date that was never
|
||||
// set. The third answers 404. Before the fix the second answered 404, so a
|
||||
// result of the AS name search led nowhere
|
||||
// (https://git.eeqj.de/sneak/routewatch/issues/151).
|
||||
func TestASDetailPageShowsBuiltInAS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newServerWithStoredAS(t)
|
||||
|
||||
tests := []struct {
|
||||
asn int
|
||||
wantCode int
|
||||
wantText string
|
||||
}{
|
||||
{storedASN, http.StatusOK, testPrefixV4},
|
||||
{builtInOnlyASN, http.StatusOK, "No prefixes announced by this AS"},
|
||||
{unknownASN, http.StatusNotFound, ""},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// settledGoroutineCount lets transient goroutines finish, then reports the
|
||||
// current count.
|
||||
func settledGoroutineCount() int {
|
||||
|
||||
Reference in New Issue
Block a user