Keep accented letters in AS names (closes #165)
check / check (push) Canceled after 0s

The built-in AS list had no letter outside ASCII, and make asupdate
rejected today's as.csv, which added a country code column.

The generator now checks only the first three header columns and reads
past the rest; the parsing moved into parseCSV. The list is regenerated.
The new source spells some names differently and no longer lists AS 0
or the documentation AS numbers, so the tests that relied on those
entries changed.

A WHOIS answer that is not valid UTF-8 is now decoded as Latin-1, which
RIPE and APNIC send, before it is parsed and stored. LACNIC answers in
UTF-8.

Model: opus-5-5
This commit was merged in pull request #172.
This commit is contained in:
2026-10-27 01:50:34 +01:00
parent 86e1f20e11
commit 6d55d39737
11 changed files with 224 additions and 109 deletions
+26 -67
View File
@@ -458,8 +458,8 @@ func TestPrefixDetailShowsOriginNames(t *testing.T) {
// TestPagesSayOriginZeroIsNoKnownOrigin stores a route with origin 0, as the
// prefix handler stores a route whose AS path ends in an AS set of several
// members, and checks that the prefix page and the prefix length page say it
// has no known origin, without a link to AS0 or the description of the
// reserved AS 0 (https://git.eeqj.de/sneak/routewatch/issues/75).
// has no known origin, without a link to AS0
// (https://git.eeqj.de/sneak/routewatch/issues/75).
func TestPagesSayOriginZeroIsNoKnownOrigin(t *testing.T) {
t.Parallel()
@@ -479,11 +479,6 @@ func TestPagesSayOriginZeroIsNoKnownOrigin(t *testing.T) {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
reserved, ok := asinfo.Get(0)
if !ok {
t.Fatal("AS0 is not in the AS info data")
}
s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
for _, path := range []string{"/prefix/" + testPrefixV4, prefixLengthPagePath} {
@@ -505,10 +500,6 @@ func TestPagesSayOriginZeroIsNoKnownOrigin(t *testing.T) {
if strings.Contains(page, `href="/as/0"`) {
t.Errorf("%s links to AS0", path)
}
if strings.Contains(page, html.EscapeString(reserved.Description)) {
t.Errorf("%s shows AS0's description %q", path, reserved.Description)
}
}
}
@@ -1327,7 +1318,7 @@ func TestASSearchAnswersMatches(t *testing.T) {
}
cloudflare := asinfo.Info{
ASN: 13335, Handle: "CLOUDFLARENET", Description: "Cloudflare, Inc.",
ASN: 13335, Handle: "CLOUDFLARENET", Description: "Cloudflare Inc.",
}
if !slices.Contains(data.Results, cloudflare) {
t.Errorf("results %v do not hold %v", data.Results, cloudflare)
@@ -1400,8 +1391,9 @@ func TestASSearchShowsFirstMatchesAndTotal(t *testing.T) {
// 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.
// a route is stored before its AS. Both are documentation AS numbers, which
// the AS list built into asinfo does not hold. builtInOnlyASN is in that list
// but not in the database. unknownASN is in neither.
const (
storedASN = 64500
routeOnlyASN = 64501
@@ -1417,14 +1409,12 @@ 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.
// lacks 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(builtInOnlyASN); !ok {
t.Fatalf("AS%d is not in the AS info data", builtInOnlyASN)
}
if _, ok := asinfo.Get(unknownASN); ok {
@@ -1442,10 +1432,10 @@ func newServerWithStoredAS(t *testing.T) *Server {
}
// 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
// and routeOnlyASN answered 404 too
// All but unknownASN answer 200 with the handle and description the built-in
// AS list has for them, if any, and their prefixes, as lists even when empty.
// Only storedASN has a first_seen time. unknownASN answers 404. Before the fix
// builtInOnlyASN and routeOnlyASN answered 404 too
// (https://git.eeqj.de/sneak/routewatch/issues/151).
func TestASDetailJSONAnswersBuiltInAS(t *testing.T) {
t.Parallel()
@@ -1499,13 +1489,13 @@ func TestASDetailJSONAnswersBuiltInAS(t *testing.T) {
continue
}
info, _ := asinfo.Get(tt.asn)
handle, description := asinfo.GetHandle(tt.asn), asinfo.GetDescription(tt.asn)
got := body.Data.ASN
if got.ASN != tt.asn || got.Handle != info.Handle ||
got.Description != info.Description {
if got.ASN != tt.asn || got.Handle != handle ||
got.Description != 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)
got.Handle, got.Description, tt.asn, handle, description)
}
if body.Data.IPv4Prefixes == nil || body.Data.IPv6Prefixes == nil {
@@ -1532,11 +1522,11 @@ func TestASDetailJSONAnswersBuiltInAS(t *testing.T) {
}
// 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 and routeOnlyASN answered 404 too
// unknownASN answer 200 with the handle and description the built-in AS list
// has for them, if any. 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 and routeOnlyASN answered 404 too
// (https://git.eeqj.de/sneak/routewatch/issues/151).
func TestASDetailPageShowsBuiltInAS(t *testing.T) {
t.Parallel()
@@ -1572,12 +1562,11 @@ func TestASDetailPageShowsBuiltInAS(t *testing.T) {
continue
}
info, _ := asinfo.Get(tt.asn)
page := rec.Body.String()
for _, want := range []string{
html.EscapeString(info.Handle),
html.EscapeString(info.Description),
html.EscapeString(asinfo.GetHandle(tt.asn)),
html.EscapeString(asinfo.GetDescription(tt.asn)),
tt.wantText,
} {
if !strings.Contains(page, want) {
@@ -1599,17 +1588,11 @@ func TestASDetailPageShowsBuiltInAS(t *testing.T) {
// TestNoOriginHasNoASAnswers stores a route with no known origin, which has
// origin AS 0 (https://git.eeqj.de/sneak/routewatch/issues/75). It expects
// /as/0 and /api/v1/as/0 to answer 404. It expects a search for "iana" to leave
// AS 0 out of its results and its total, although the AS list built into
// asinfo holds AS 0. That search has more matches than an answer holds, so it
// still lists maxASSearchResults of them.
// /as/0 and /api/v1/as/0 to answer 404.
func TestNoOriginHasNoASAnswers(t *testing.T) {
t.Parallel()
const (
noOriginPrefix = "203.0.113.0/24"
query = "iana"
)
const noOriginPrefix = "203.0.113.0/24"
s := newServerWithTestRoutes(t)
@@ -1635,30 +1618,6 @@ func TestNoOriginHasNoASAnswers(t *testing.T) {
path, rec.Code, http.StatusNotFound, rec.Body)
}
}
first, total := asinfo.Search(query, 1)
if len(first) != 1 || first[0].ASN != ristypes.NoOrigin {
t.Fatalf("AS%d is not the first match for %q in the AS info data",
ristypes.NoOrigin, query)
}
data := searchAS(t, s, "q="+query)
for _, info := range data.Results {
if info.ASN == ristypes.NoOrigin {
t.Errorf("the search for %q lists AS%d", query, info.ASN)
}
}
if data.Total != total-1 {
t.Errorf("the search for %q has a total of %d, want %d",
query, data.Total, total-1)
}
if len(data.Results) != maxASSearchResults {
t.Errorf("the search for %q lists %d matches, want %d",
query, len(data.Results), maxASSearchResults)
}
}
// settledGoroutineCount lets transient goroutines finish, then reports the