package whois_test import ( "bufio" "bytes" "context" "encoding/json" "errors" "io" "log/slog" "maps" "net" "slices" "strings" "sync" "syscall" "testing" "time" "unicode/utf8" "git.eeqj.de/sneak/routewatch/internal/whois" ) // The servers LookupASN asks: IANA first, then the RIR IANA refers it to. const ( ianaServer = "whois.iana.org:43" ripeServer = "whois.ripe.net:43" ) // testASN is the AS the tests look up, and asQuery the query for it. const ( testASN = 3333 asQuery = "AS3333" ) // The fake servers' answers are keyed by a server's address, a space and a // query. These are the keys of the queries LookupASN sends for testASN: // IANA's, then RIPE's, which asks RIPE to leave out person and role // objects. const ( ianaQuery = ianaServer + " " + asQuery ripeQuery = ripeServer + " --no-personal " + asQuery ) // ianaReferral is IANA's answer for an AS in a block it gave to RIPE. const ianaReferral = `% IANA WHOIS server refer: whois.ripe.net as-block: 3154-3353 organisation: Assigned by RIPE NCC whois: whois.ripe.net changed: 2002-09-01 source: IANA ` // lacnicReferral is IANA's answer for an AS in a block it gave to LACNIC, // shortened to the referral. const lacnicReferral = "refer: whois.lacnic.net\n" // ripeRecord is RIPE's answer for the AS when asked to leave out person and // role objects. It starts with the as-block object of the block the AS is // in. Then come the abuse address as a comment, the AS's object and the // organisation object it refers to. const ripeRecord = `% This is the RIPE Database query service. % The objects are in RPSL format. % Note: --no-personal means ALL personal data has been filtered from this output. % Information related to 'AS3209 - AS3353' as-block: AS3209 - AS3353 descr: RIPE NCC ASN block remarks: These AS Numbers are assigned to network operators remarks: in the RIPE NCC service region. mnt-by: RIPE-NCC-HM-MNT created: 2018-11-22T15:27:19Z last-modified: 2018-11-22T15:27:19Z source: RIPE % Information related to 'AS3333' % Abuse contact for 'AS3333' is 'abuse@ripe.net' aut-num: AS3333 as-name: RIPE-NCC-AS org: ORG-RIEN1-RIPE admin-c: BRD-RIPE tech-c: OPS4-RIPE abuse-c: OPS4-RIPE mnt-by: RIPE-NCC-MNT created: 2002-09-17T13:08:32Z last-modified: 2024-01-09T08:55:41Z source: RIPE organisation: ORG-RIEN1-RIPE org-name: Reseaux IP Europeens Network Coordination Centre (RIPE NCC) country: NL org-type: RIR address: P.O. Box 10096 address: 1001EB Amsterdam abuse-c: OPS4-RIPE created: 2012-03-09T13:08:29Z last-modified: 2024-03-26T10:55:38Z source: RIPE ` // The error lines with which RIPE refuses a query: access denied to the // host, and the host's limit on returned objects reached. const ( ripeAccessDenied = "%ERROR:201: access denied for 192.0.2.1" ripeLimitReached = "%ERROR:202: access control limit reached" ) // ripeRefusal is RIPE's answer to a host it no longer serves. const ripeRefusal = `% This is the RIPE Database query service. % The objects are in RPSL format. % ` + ripeAccessDenied + ` % % Sorry, access from your host has been permanently % denied because of a repeated excessive querying. ` // apnicAfrinicRecord is the answer of APNIC or AFRINIC for the AS when asked // to leave out person and role objects. Like RIPE's, it holds the abuse // address as a comment, the AS's object and the organisation object. const apnicAfrinicRecord = `% Abuse contact for 'AS3333' is 'abuse@example.net' aut-num: AS3333 as-name: EXAMPLE-AS org: ORG-EXAMPLE1 admin-c: EX1-TEST last-modified: 2026-08-26T00:40:00Z organisation: ORG-EXAMPLE1 org-name: Example Networks country: ZA address: 1 Example Road ` // apnicServer is APNIC's WHOIS server, and apnicASN an AS that APNIC holds. const ( apnicServer = "whois.apnic.net:43" apnicASN = 4608 ) // The keys of the queries LookupASN sends for apnicASN: IANA's, then // APNIC's, which asks APNIC to leave out person and role objects. const ( apnicIANAQuery = ianaServer + " AS4608" apnicQuery = apnicServer + " --no-personal AS4608" ) // apnicRecord is APNIC's answer for apnicASN when asked to leave out person // and role objects, shortened. It starts with the as-block object of the // block the AS is in. Then come the abuse address as a comment, the AS's // object, and the irt and organisation objects it refers to. const apnicRecord = `% [whois.apnic.net] % Whois data copyright terms http://www.apnic.net/db/dbcopyright.html % Note: --no-personal means ALL personal data has been filtered from this output. % Information related to 'AS4608 - AS4865' as-block: AS4608 - AS4865 descr: APNIC ASN block remarks: These AS numbers are further assigned by APNIC remarks: to APNIC members and end-users in the APNIC region mnt-by: APNIC-HM mnt-lower: APNIC-HM last-modified: 2019-05-20T03:52:26Z source: APNIC % Information related to 'AS4608' % Abuse contact for 'AS4608' is 'abuse@apnic.net' aut-num: AS4608 as-name: APNIC-SERVICES descr: Asia Pacific Network Information Centre country: AU org: ORG-APNI1-AP admin-c: AIC1-AP abuse-c: AA1589-AP mnt-by: APNIC-HM mnt-irt: IRT-APNIC-IS-AP last-modified: 2026-08-26T00:40:00Z source: APNIC irt: IRT-APNIC-IS-AP address: South Brisbane, Australia e-mail: helpdesk@apnic.net abuse-mailbox: abuse@apnic.net mnt-by: MAINT-APNIC-IS-AP last-modified: 2025-11-18T00:27:08Z source: APNIC organisation: ORG-APNI1-AP org-name: Asia Pacific Network Information Centre org-type: LIR country: AU address: PO Box 3646 address: South Brisbane, QLD 4101, address: Australia e-mail: noc@apnic.net mnt-by: APNIC-HM last-modified: 2026-08-24T12:58:43Z source: APNIC % This query was served by the APNIC Whois Service version 1.88.48 (WHOIS-UK2) ` // ianaPrivateUse is IANA's answer for a private-use AS: its block and no // referral. const ianaPrivateUse = `% IANA WHOIS server as-block: 64512-65534 organisation: Reserved for Private Use descr: Reserved for Private Use source: IANA ` // ianaNoObject is IANA's answer when it has nothing for the query. const ianaNoObject = `% IANA WHOIS server % This query returned 0 objects. ` // fillerLine is one of the lines a server that does not stop keeps sending. const fillerLine = "remarks: and more\n" // endlessAnswerSize is how many bytes of filler lines that server sends: // four times the client's cap on an answer. const endlessAnswerSize = 4 << 20 // fakeServers returns a DialContext for a whois.Client that connects to a // fake server at each address answers names. Each key of answers is a // server's address, a space and a query, and its value is that server's // answer to that query. A server reads one query and sends its answer, or // nothing for a query it has no answer for. Connecting to any other address // is refused. func fakeServers( t *testing.T, answers map[string]string, ) func(context.Context, string, string) (net.Conn, error) { t.Helper() addresses := make(map[string]bool) for key := range answers { address, _, _ := strings.Cut(key, " ") addresses[address] = true } var servers sync.WaitGroup t.Cleanup(servers.Wait) return func(_ context.Context, _, address string) (net.Conn, error) { if !addresses[address] { return nil, syscall.ECONNREFUSED } client, server := net.Pipe() servers.Go(func() { defer func() { _ = server.Close() }() query, _ := bufio.NewReader(server).ReadString('\n') answer := answers[address+" "+strings.TrimSpace(query)] _, _ = io.WriteString(server, answer) }) return client, nil } } // lookUp looks testASN up with fake servers sending answers. func lookUp( t *testing.T, answers map[string]string, ) (*whois.ASNInfo, error) { t.Helper() client := whois.NewClient(slog.New(slog.DiscardHandler)) client.DialContext = fakeServers(t, answers) return client.LookupASN(t.Context(), testASN) } // TestLookupASNReadsTheRIRAnswer looks up an AS IANA refers to RIPE. RIPE // answers only a query that leaves out person and role objects, and its // answer to that still fills the AS's fields: the abuse address from the // comment line, and the organisation's name and country from its object. // The dates are the AS's. IANA's "changed:" line dates the AS's block, and // so do the dates of the as-block object at the start of RIPE's answer. func TestLookupASNReadsTheRIRAnswer(t *testing.T) { t.Parallel() info, err := lookUp(t, map[string]string{ ianaQuery: ianaReferral, ripeQuery: ripeRecord, }) if err != nil { t.Fatalf("LookupASN: %v", err) } got := []string{ info.ASName, info.OrgID, info.OrgName, info.CountryCode, info.AbuseEmail, info.RIR, } want := []string{ "RIPE-NCC-AS", "ORG-RIEN1-RIPE", "Reseaux IP Europeens Network Coordination Centre (RIPE NCC)", "NL", "abuse@ripe.net", whois.RIRRIPE, } if !slices.Equal(got, want) { t.Errorf("LookupASN = AS name, org ID, org name, country, abuse "+ "address, RIR %q; want %q", got, want) } wantRegDate := time.Date(2002, 9, 17, 13, 8, 32, 0, time.UTC) if info.RegDate == nil || !info.RegDate.Equal(wantRegDate) { t.Errorf("LookupASN registered = %v, want %v", info.RegDate, wantRegDate) } wantLastMod := time.Date(2024, 1, 9, 8, 55, 41, 0, time.UTC) if info.LastMod == nil || !info.LastMod.Equal(wantLastMod) { t.Errorf("LookupASN last modified = %v, want %v", info.LastMod, wantLastMod) } } // TestLookupASNReadsOnlyTheASAndItsOrganisation looks up an AS at APNIC, // whose answer starts with the as-block object of the block the AS is in. // The last-modified date is the aut-num object's, not the block's. The // address is the organisation object's alone, without the irt object's. func TestLookupASNReadsOnlyTheASAndItsOrganisation(t *testing.T) { t.Parallel() client := whois.NewClient(slog.New(slog.DiscardHandler)) client.DialContext = fakeServers(t, map[string]string{ apnicIANAQuery: "refer: whois.apnic.net\n", apnicQuery: apnicRecord, }) info, err := client.LookupASN(t.Context(), apnicASN) if err != nil { t.Fatalf("LookupASN: %v", err) } wantLastMod := time.Date(2026, 8, 26, 0, 40, 0, 0, time.UTC) if info.LastMod == nil || !info.LastMod.Equal(wantLastMod) { t.Errorf("LookupASN last modified = %v, want %v", info.LastMod, wantLastMod) } got := []string{ info.ASName, info.OrgID, info.OrgName, info.CountryCode, info.Address, info.AbuseEmail, } want := []string{ "APNIC-SERVICES", "ORG-APNI1-AP", "Asia Pacific Network Information Centre", "AU", "PO Box 3646\nSouth Brisbane, QLD 4101,\nAustralia", "abuse@apnic.net", } if !slices.Equal(got, want) { t.Errorf("LookupASN = AS name, org ID, org name, country, address, "+ "abuse address %q; want %q", got, want) } } // TestLookupASNAsksAPNICAndAFRINICToLeaveOutPersonalData looks up an AS at // APNIC and at AFRINIC. Each answers only a query that leaves out person // and role objects, as RIPE does. That one answer still holds the // organisation object, so the organisation's name and country are filled. func TestLookupASNAsksAPNICAndAFRINICToLeaveOutPersonalData(t *testing.T) { t.Parallel() for _, server := range []string{"whois.apnic.net", "whois.afrinic.net"} { t.Run(server, func(t *testing.T) { t.Parallel() address := server + ":43" info, err := lookUp(t, map[string]string{ ianaQuery: "refer: " + server + "\n", address + " --no-personal " + asQuery: apnicAfrinicRecord, }) if err != nil { t.Fatalf("LookupASN: %v", err) } got := []string{ info.ASName, info.OrgName, info.CountryCode, info.AbuseEmail, } want := []string{ "EXAMPLE-AS", "Example Networks", "ZA", "abuse@example.net", } if !slices.Equal(got, want) { t.Errorf("LookupASN = AS name, org name, country, abuse "+ "address %q; want %q", got, want) } }) } } // The name of an organisation with accented letters, in Latin-1, one byte for // each letter, and in UTF-8. const ( latin1OrgName = "Fundaci\xf3n Ra\xedces de Espa\xf1a" utf8OrgName = "Fundación Raíces de España" ) // TestLookupASNStoresAnswersInUTF8 looks up an AS at RIPE, which answers in // Latin-1, and at LACNIC, which answers in UTF-8. Both answers give the // organisation's name with its accented letters in UTF-8, in the parsed field // and in the stored answer. Before the fix the Latin-1 answer was kept as // received, which is not valid UTF-8. func TestLookupASNStoresAnswersInUTF8(t *testing.T) { t.Parallel() for name, answers := range map[string]map[string]string{ "RIPE in Latin-1": { ianaQuery: ianaReferral, ripeQuery: "aut-num: AS3333\n" + "org-name: " + latin1OrgName + "\n", }, "LACNIC in UTF-8": { ianaQuery: lacnicReferral, "whois.lacnic.net:43 " + asQuery: "aut-num: AS3333\n" + "owner: " + utf8OrgName + "\n", }, } { t.Run(name, func(t *testing.T) { t.Parallel() info, err := lookUp(t, answers) if err != nil { t.Fatalf("LookupASN: %v", err) } if info.OrgName != utf8OrgName { t.Errorf("LookupASN org name = %q, want %q", info.OrgName, utf8OrgName) } if !utf8.ValidString(info.RawResponse) || !strings.Contains(info.RawResponse, utf8OrgName) { t.Errorf("LookupASN answer = %q, want UTF-8 holding %q", info.RawResponse, utf8OrgName) } }) } } // lacnicServer is LACNIC's WHOIS server and nicbrServer NIC.br's, which // registers Brazil's ASes under LACNIC. brazilASN is one of those ASes. const ( lacnicServer = "whois.lacnic.net:43" nicbrServer = "whois.registro.br:43" brazilASN = 52751 ) // The keys of the queries LookupASN sends for brazilASN: IANA's, LACNIC's, // then NIC.br's. const ( brazilIANAQuery = ianaServer + " AS52751" brazilLACNICQuery = lacnicServer + " AS52751" brazilNICbrQuery = nicbrServer + " AS52751" ) // The name of brazilASN's organisation in three forms: as LACNIC sends it, // with each accented letter replaced by U+FFFD; as NIC.br sends it, in // Latin-1; and in UTF-8. const ( lacnicBrazilOrgName = "Brasrede Telecomunica\U0000FFFD\U0000FFFDes LTDA" nicbrBrazilOrgName = "Brasrede Telecomunica\xe7\xf5es LTDA" brazilOrgName = "Brasrede Telecomunicações LTDA" ) // nicbrCopyright is the copyright notice at the top of NIC.br's answers. const nicbrCopyright = "% Copyright (c) Nic.br - " + "Use of this data is governed by the Use and\n" + "% Privacy Policy at https://registro.br/upp . Distribution,\n" + "% commercialization, reproduction, and use for advertising or similar\n" + "% purposes are expressly prohibited.\n" // lacnicBrazilRecord is LACNIC's answer for brazilASN, recorded on // 2026-10-26. It is NIC.br's answer, which LACNIC passes on after a line // of its own and a space. It is shortened. The client's address is a // documentation address. The contact's name, the contact's object and the // footer are left out. const lacnicBrazilRecord = "% IP Client: 192.0.2.1\n" + " " + nicbrCopyright + `% 2026-10-26T23:12:22-03:00 - 192.0.2.1 aut-num: AS52751 owner: ` + lacnicBrazilOrgName + ` owner-c: AVL217 routing-c: AVL217 abuse-c: AVL217 created: 20120829 changed: 20130306 inetnum: 177.38.152.0/21 inetnum: 2804:b34::/32 inetnum: 191.243.4.0/22 inetnum: 131.72.248.0/22 inetnum: 138.94.228.0/22 inetnum: 170.233.8.0/22 inetnum: 170.246.4.0/22 ` // nicbrRecord is NIC.br's answer for brazilASN, in Latin-1, recorded on // 2026-10-26 and shortened as lacnicBrazilRecord is. const nicbrRecord = nicbrCopyright + `% 2026-10-26T23:14:41-03:00 - 192.0.2.1 aut-num: AS52751 owner: ` + nicbrBrazilOrgName + ` owner-c: AVL217 routing-c: AVL217 abuse-c: AVL217 created: 20120829 changed: 20130306 inetnum: 177.38.152.0/21 inetnum: 2804:b34::/32 inetnum: 191.243.4.0/22 inetnum: 131.72.248.0/22 inetnum: 138.94.228.0/22 inetnum: 170.233.8.0/22 inetnum: 170.246.4.0/22 ` // nicbrOverLimit stands for NIC.br's answer for brazilASN to a client over // its query limit, and is not recorded. Such an answer leaves information // out, here the owner, and says so in its first line. const nicbrOverLimit = `% Query rate limit exceeded. Reduced information. aut-num: AS52751 created: 20120829 changed: 20130306 ` // newNICbrClient returns a client whose fake servers send answers and // whose logger writes JSON lines to logs. It adds one to *nicbrAsks each // time it connects to NIC.br's server. func newNICbrClient( t *testing.T, answers map[string]string, logs io.Writer, nicbrAsks *int, ) *whois.Client { t.Helper() dial := fakeServers(t, answers) client := whois.NewClient(slog.New(slog.NewJSONHandler(logs, nil))) client.DialContext = func( ctx context.Context, network, address string, ) (net.Conn, error) { if address == nicbrServer { *nicbrAsks++ } return dial(ctx, network, address) } return client } // lookUpBrazilianAS looks brazilASN up with fake servers sending answers, // and reports whether the client connected to NIC.br's server. func lookUpBrazilianAS( t *testing.T, answers map[string]string, ) (*whois.ASNInfo, bool, error) { t.Helper() nicbrAsks := 0 client := newNICbrClient(t, answers, io.Discard, &nicbrAsks) info, err := client.LookupASN(t.Context(), brazilASN) return info, nicbrAsks > 0, err } // TestLookupASNTakesABrazilianASFromNICbr looks up an AS that NIC.br // registers. LACNIC's answer is NIC.br's with each accented letter replaced // by U+FFFD, so the client also asks NIC.br's server, which answers in // Latin-1. The organisation's name has its accented letters, in the parsed // field and in the stored answer. func TestLookupASNTakesABrazilianASFromNICbr(t *testing.T) { t.Parallel() info, _, err := lookUpBrazilianAS(t, map[string]string{ brazilIANAQuery: lacnicReferral, brazilLACNICQuery: lacnicBrazilRecord, brazilNICbrQuery: nicbrRecord, }) if err != nil { t.Fatalf("LookupASN: %v", err) } if info.OrgName != brazilOrgName || info.RIR != whois.RIRLACNIC { t.Errorf("LookupASN org name, RIR = %q, %q; want %q, %q", info.OrgName, info.RIR, brazilOrgName, whois.RIRLACNIC) } if !strings.Contains(info.RawResponse, brazilOrgName) { t.Errorf("LookupASN answer = %q, want one holding %q", info.RawResponse, brazilOrgName) } wantRegDate := time.Date(2012, 8, 29, 0, 0, 0, 0, time.UTC) if info.RegDate == nil || !info.RegDate.Equal(wantRegDate) { t.Errorf("LookupASN registered = %v, want %v", info.RegDate, wantRegDate) } } // TestLookupASNAsksNICbrOnlyForItsASes looks up an AS at LACNIC whose // answer does not carry NIC.br's copyright notice, as for an AS that LACNIC // registers itself. The client does not connect to NIC.br's server, which // would answer with a record, and keeps LACNIC's fields. func TestLookupASNAsksNICbrOnlyForItsASes(t *testing.T) { t.Parallel() info, askedNICbr, err := lookUpBrazilianAS(t, map[string]string{ brazilIANAQuery: lacnicReferral, brazilLACNICQuery: "aut-num: AS52751\n" + "owner: " + utf8OrgName + "\n", brazilNICbrQuery: nicbrRecord, }) if err != nil { t.Fatalf("LookupASN: %v", err) } if askedNICbr { t.Error("LookupASN asked NIC.br's server about an AS whose " + "answer from LACNIC is not NIC.br's") } if info.OrgName != utf8OrgName { t.Errorf("LookupASN org name = %q, want LACNIC's %q", info.OrgName, utf8OrgName) } } // TestLookupASNKeepsLACNICsAnswerWhenNICbrFails looks up an AS that NIC.br // registers when NIC.br's server refuses the connection, sends nothing, or // sends the reduced answer for a client over its query limit. The client // asks NIC.br, and the lookup still succeeds with LACNIC's fields. The // client logs one warning, which names the AS and the reason. func TestLookupASNKeepsLACNICsAnswerWhenNICbrFails(t *testing.T) { t.Parallel() for name, test := range map[string]struct { nicbrAnswer map[string]string reason string }{ "connection refused": { nicbrAnswer: map[string]string{}, reason: syscall.ECONNREFUSED.Error(), }, "no answer": { nicbrAnswer: map[string]string{brazilNICbrQuery: ""}, reason: "no WHOIS fields", }, "over the query limit": { nicbrAnswer: map[string]string{brazilNICbrQuery: nicbrOverLimit}, reason: "over its query limit", }, } { t.Run(name, func(t *testing.T) { t.Parallel() answers := map[string]string{ brazilIANAQuery: lacnicReferral, brazilLACNICQuery: lacnicBrazilRecord, } maps.Copy(answers, test.nicbrAnswer) var logs bytes.Buffer nicbrAsks := 0 client := newNICbrClient(t, answers, &logs, &nicbrAsks) info, err := client.LookupASN(t.Context(), brazilASN) if err != nil { t.Fatalf("LookupASN: %v", err) } if nicbrAsks == 0 { t.Error("LookupASN did not ask NIC.br's server") } if info.OrgName != lacnicBrazilOrgName { t.Errorf("LookupASN org name = %q, want LACNIC's %q", info.OrgName, lacnicBrazilOrgName) } var logged struct { Level string `json:"level"` ASN int `json:"asn"` } err = json.Unmarshal(logs.Bytes(), &logged) if err != nil { t.Fatalf("want one log line, got %q: %v", logs.String(), err) } if logged.Level != slog.LevelWarn.String() || logged.ASN != brazilASN || !strings.Contains(logs.String(), test.reason) { t.Errorf("logged %q, want a warning for AS%d holding %q", logs.String(), brazilASN, test.reason) } }) } } // TestLookupASNSkipsNICbrAfterItsLimit looks up an AS that NIC.br // registers three times with one client, while NIC.br answers that the // client is over its query limit. The first lookup asks NIC.br. The // second, a second before NICbrSkipTime has passed, does not. The third, // once NICbrSkipTime has passed, asks again. Each keeps LACNIC's fields. func TestLookupASNSkipsNICbrAfterItsLimit(t *testing.T) { t.Parallel() nicbrAsks := 0 client := newNICbrClient(t, map[string]string{ brazilIANAQuery: lacnicReferral, brazilLACNICQuery: lacnicBrazilRecord, brazilNICbrQuery: nicbrOverLimit, }, io.Discard, &nicbrAsks) start := time.Date(2026, 10, 27, 0, 0, 0, 0, time.UTC) now := start client.Now = func() time.Time { return now } for _, step := range []struct { after time.Duration wantAsks int }{ {after: 0, wantAsks: 1}, {after: whois.NICbrSkipTime - time.Second, wantAsks: 1}, {after: whois.NICbrSkipTime, wantAsks: 2}, } { now = start.Add(step.after) info, err := client.LookupASN(t.Context(), brazilASN) if err != nil { t.Fatalf("LookupASN after %v: %v", step.after, err) } if nicbrAsks != step.wantAsks { t.Errorf("after %v NIC.br's server was asked %d times, want %d", step.after, nicbrAsks, step.wantAsks) } if info.OrgName != lacnicBrazilOrgName { t.Errorf("LookupASN after %v org name = %q, want LACNIC's %q", step.after, info.OrgName, lacnicBrazilOrgName) } } } // TestLookupASNReportsARefusal looks up an AS IANA refers to RIPE when RIPE // refuses the query, with each of the error lines RIPE refuses with. The // error holds RIPE's line, so the log says why the lookup failed. func TestLookupASNReportsARefusal(t *testing.T) { t.Parallel() for _, line := range []string{ripeAccessDenied, ripeLimitReached} { t.Run(line, func(t *testing.T) { t.Parallel() _, err := lookUp(t, map[string]string{ ianaQuery: ianaReferral, ripeQuery: "% This is the RIPE Database query service.\n" + line + "\n", }) if err == nil || !strings.Contains(err.Error(), line) { t.Errorf("LookupASN error = %v, want one holding %q", err, line) } }) } } // TestLookupASNFailsWhenTheRIRGivesNoAnswer looks up an AS IANA refers to // RIPE when RIPE's server refuses the connection, and when it answers only // with an error. Neither is a lookup that found the AS. func TestLookupASNFailsWhenTheRIRGivesNoAnswer(t *testing.T) { t.Parallel() for name, answers := range map[string]map[string]string{ "connection refused": {ianaQuery: ianaReferral}, "only an error": {ianaQuery: ianaReferral, ripeQuery: ripeRefusal}, } { t.Run(name, func(t *testing.T) { t.Parallel() _, err := lookUp(t, answers) if err == nil || errors.Is(err, whois.ErrNoReferral) { t.Errorf("LookupASN error = %v, want one that is not "+ "ErrNoReferral", err) } }) } } // TestLookupASNReportsNoReferral looks up a private-use AS, which IANA // refers to no RIR. func TestLookupASNReportsNoReferral(t *testing.T) { t.Parallel() _, err := lookUp(t, map[string]string{ianaQuery: ianaPrivateUse}) if !errors.Is(err, whois.ErrNoReferral) { t.Errorf("LookupASN error = %v, want ErrNoReferral", err) } } // TestLookupASNFailsWhenIANAAnswersNoBlock looks up an AS when IANA answers // with neither a referral nor the AS's block. That is not IANA saying no // RIR holds the AS. func TestLookupASNFailsWhenIANAAnswersNoBlock(t *testing.T) { t.Parallel() _, err := lookUp(t, map[string]string{ianaQuery: ianaNoObject}) if err == nil || errors.Is(err, whois.ErrNoReferral) { t.Errorf("LookupASN error = %v, want one that is not ErrNoReferral", err) } } // TestLookupASNFailsWhenTheRIRAnswerDoesNotEnd looks up an AS whose RIR // server follows the record with filler lines far past any real answer. // The client stops reading at its cap and fails instead of keeping them. func TestLookupASNFailsWhenTheRIRAnswerDoesNotEnd(t *testing.T) { t.Parallel() endless := ripeRecord + strings.Repeat(fillerLine, endlessAnswerSize/len(fillerLine)) _, err := lookUp(t, map[string]string{ ianaQuery: ianaReferral, ripeQuery: endless, }) if err == nil || errors.Is(err, whois.ErrNoReferral) { t.Errorf("LookupASN error = %v, want one that is not ErrNoReferral", err) } } // TestLookupASNFailsOnAReferralToAnotherServer looks up an AS when IANA's // answer refers it to a server none of the five RIRs run, with a port and // without. The client follows a referral only to an RIR's server, so it // fails instead of asking that server, which would answer with a record. func TestLookupASNFailsOnAReferralToAnotherServer(t *testing.T) { t.Parallel() // Each case maps the server on the "refer:" line to the address the // client would dial to follow it. IANA's answer also names the AS's // block, so a client that dropped the referral would report no RIR. for refer, address := range map[string]string{ "whois.example.net:8080": "whois.example.net:8080", "whois.example.net": "whois.example.net:43", } { t.Run(refer, func(t *testing.T) { t.Parallel() _, err := lookUp(t, map[string]string{ ianaQuery: "refer: " + refer + "\n\n" + "as-block: 3154-3353\n", address + " " + asQuery: ripeRecord, }) if err == nil || errors.Is(err, whois.ErrNoReferral) { t.Errorf("LookupASN error = %v, want one that is not "+ "ErrNoReferral", err) } }) } }