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> and lists the matches
as links to their AS pages. asinfo.Search ignores case, returns the first
matches up to a limit in AS number order, and counts all matches. An
empty q answers 400.
When the database has no record of an AS, its page and /api/v1/as/{asn}
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. AS 0 means no known origin: it always answers 404, and the search
never lists it.
Model: opus-5-5
314 lines
6.5 KiB
Go
314 lines
6.5 KiB
Go
package asinfo_test
|
|
|
|
import (
|
|
"cmp"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.eeqj.de/sneak/routewatch/pkg/asinfo"
|
|
)
|
|
|
|
func TestGet(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
asn int
|
|
wantOK bool
|
|
wantDesc string
|
|
}{
|
|
{
|
|
name: "AS1 Level 3",
|
|
asn: 1,
|
|
wantOK: true,
|
|
wantDesc: "Level 3 Parent, LLC",
|
|
},
|
|
{
|
|
name: "AS15169 Google",
|
|
asn: 15169,
|
|
wantOK: true,
|
|
wantDesc: "Google LLC",
|
|
},
|
|
{
|
|
name: "Non-existent AS",
|
|
asn: 999999999,
|
|
wantOK: false,
|
|
},
|
|
{
|
|
name: "AS0 IANA Reserved",
|
|
asn: 0,
|
|
wantOK: true,
|
|
wantDesc: "Internet Assigned Numbers Authority",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
info, ok := asinfo.Get(tt.asn)
|
|
if ok != tt.wantOK {
|
|
t.Errorf("Get(%d) ok = %v, want %v", tt.asn, ok, tt.wantOK)
|
|
|
|
return
|
|
}
|
|
|
|
if ok && info.Description != tt.wantDesc {
|
|
t.Errorf("Get(%d) description = %q, want %q",
|
|
tt.asn, info.Description, tt.wantDesc)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetDescription(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
asn int
|
|
want string
|
|
}{
|
|
{1, "Level 3 Parent, LLC"},
|
|
{999999999, ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run("", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := asinfo.GetDescription(tt.asn)
|
|
if got != tt.want {
|
|
t.Errorf("GetDescription(%d) = %q, want %q", tt.asn, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetHandle(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
asn int
|
|
want string
|
|
}{
|
|
{1, "LVLT"},
|
|
{0, "IANA-RSVD"},
|
|
{999999999, ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run("", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := asinfo.GetHandle(tt.asn)
|
|
if got != tt.want {
|
|
t.Errorf("GetHandle(%d) = %q, want %q", tt.asn, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTotal(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
total := asinfo.Total()
|
|
if total < 100000 {
|
|
t.Errorf("Total() = %d, expected > 100000", total)
|
|
}
|
|
|
|
// Verify it's consistent
|
|
total2 := asinfo.Total()
|
|
if total2 != total {
|
|
t.Errorf("Total() returned different values: %d vs %d", total, total2)
|
|
}
|
|
}
|
|
|
|
func TestSearch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
query string
|
|
wantMin int
|
|
}{
|
|
{
|
|
name: "Search for Google",
|
|
query: "Google",
|
|
wantMin: 1,
|
|
},
|
|
{
|
|
name: "Search for University",
|
|
query: "University",
|
|
wantMin: 10,
|
|
},
|
|
{
|
|
name: "Non-existent org",
|
|
query: "XYZABC123456",
|
|
wantMin: 0,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
results, _ := asinfo.Search(tt.query, asinfo.Total())
|
|
if len(results) < tt.wantMin {
|
|
t.Errorf("Search(%q) returned %d results, want at least %d",
|
|
tt.query, len(results), tt.wantMin)
|
|
}
|
|
|
|
// Verify all results contain the query, ignoring case
|
|
query := strings.ToLower(tt.query)
|
|
for _, r := range results {
|
|
if !strings.Contains(strings.ToLower(r.Handle), query) &&
|
|
!strings.Contains(strings.ToLower(r.Description), query) {
|
|
t.Errorf("Search result ASN %d doesn't contain query %q",
|
|
r.ASN, tt.query)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSearchEmptyQueryMatchesNothing expects an empty query to match no AS,
|
|
// although every handle contains the empty string.
|
|
func TestSearchEmptyQueryMatchesNothing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
results, total := asinfo.Search("", asinfo.Total())
|
|
if len(results) != 0 || total != 0 {
|
|
t.Errorf(`Search("") returned %d results and a total of %d, want none`,
|
|
len(results), total)
|
|
}
|
|
}
|
|
|
|
// TestSearchIgnoresCase searches for Cloudflare's handle in lower case and its
|
|
// description in upper case, and expects both searches to find AS13335.
|
|
func TestSearchIgnoresCase(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const cloudflareASN = 13335 // handle CLOUDFLARENET, "Cloudflare, Inc."
|
|
|
|
isCloudflare := func(info asinfo.Info) bool {
|
|
return info.ASN == cloudflareASN
|
|
}
|
|
|
|
for _, query := range []string{"cloudflarenet", "CLOUDFLARE, INC."} {
|
|
results, _ := asinfo.Search(query, asinfo.Total())
|
|
if !slices.ContainsFunc(results, isCloudflare) {
|
|
t.Errorf("Search(%q) did not find AS%d", query, cloudflareASN)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSearchOrdersByASN expects the matches of a search with many of them to
|
|
// come in AS number order.
|
|
func TestSearchOrdersByASN(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const query = "University"
|
|
|
|
results, _ := asinfo.Search(query, asinfo.Total())
|
|
|
|
for i := 1; i < len(results); i++ {
|
|
if results[i-1].ASN >= results[i].ASN {
|
|
t.Fatalf("Search(%q): AS%d comes after AS%d",
|
|
query, results[i].ASN, results[i-1].ASN)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSearchStopsAtLimit searches for a word that matches more entries than
|
|
// the limit. It expects exactly limit entries, the first matches in AS number
|
|
// order, and the number of all matches, counted here from All.
|
|
func TestSearchStopsAtLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
query = "university"
|
|
limit = 10
|
|
)
|
|
|
|
var matches []asinfo.Info
|
|
|
|
for _, info := range asinfo.All() {
|
|
if strings.Contains(strings.ToLower(info.Handle), query) ||
|
|
strings.Contains(strings.ToLower(info.Description), query) {
|
|
matches = append(matches, info)
|
|
}
|
|
}
|
|
|
|
if len(matches) <= limit {
|
|
t.Fatalf("%q matches %d entries; the test needs more than %d",
|
|
query, len(matches), limit)
|
|
}
|
|
|
|
slices.SortFunc(matches, func(a, b asinfo.Info) int {
|
|
return cmp.Compare(a.ASN, b.ASN)
|
|
})
|
|
|
|
results, total := asinfo.Search(query, limit)
|
|
|
|
if total != len(matches) {
|
|
t.Errorf("Search(%q, %d): total %d, want %d",
|
|
query, limit, total, len(matches))
|
|
}
|
|
|
|
if !slices.Equal(results, matches[:limit]) {
|
|
t.Errorf("Search(%q, %d) = %v, want %v",
|
|
query, limit, results, matches[:limit])
|
|
}
|
|
}
|
|
|
|
func TestDataIntegrity(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Verify no duplicate ASNs
|
|
all := asinfo.All()
|
|
seen := make(map[int]bool)
|
|
|
|
for _, info := range all {
|
|
if seen[info.ASN] {
|
|
t.Errorf("Duplicate ASN found: %d", info.ASN)
|
|
}
|
|
|
|
seen[info.ASN] = true
|
|
}
|
|
|
|
// Verify all entries have required fields
|
|
for _, info := range all {
|
|
if info.Handle == "" && info.ASN != 0 {
|
|
t.Errorf("ASN %d has empty handle", info.ASN)
|
|
}
|
|
|
|
if info.Description == "" {
|
|
t.Errorf("ASN %d has empty description", info.ASN)
|
|
}
|
|
}
|
|
}
|
|
|
|
func BenchmarkGet(b *testing.B) {
|
|
// Common ASNs to lookup
|
|
asns := []int{1, 15169, 13335, 32934, 8075, 16509}
|
|
|
|
b.ResetTimer()
|
|
|
|
for i := range b.N {
|
|
asinfo.Get(asns[i%len(asns)])
|
|
}
|
|
}
|
|
|
|
func BenchmarkSearch(b *testing.B) {
|
|
const limit = 50
|
|
|
|
queries := []string{"Google", "Amazon", "Microsoft", "University"}
|
|
|
|
b.ResetTimer()
|
|
|
|
for i := range b.N {
|
|
asinfo.Search(queries[i%len(queries)], limit)
|
|
}
|
|
}
|