380 lines
12 KiB
Go
380 lines
12 KiB
Go
|
|
package whois
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"sync/atomic"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
func TestParseRDAPBootstrap(t *testing.T) {
|
||
|
|
raw := `{
|
||
|
|
"version":"1.0",
|
||
|
|
"publication":"2026-03-12T20:00:01Z",
|
||
|
|
"services":[
|
||
|
|
[["com","net"],["https://rdap.example.com/"]],
|
||
|
|
[["org"],["https://rdap.example.org/","https://rdap.example.org/"]]
|
||
|
|
]
|
||
|
|
}`
|
||
|
|
boot, err := ParseRDAPBootstrap([]byte(raw))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("ParseRDAPBootstrap() error: %v", err)
|
||
|
|
}
|
||
|
|
if boot.Version != "1.0" {
|
||
|
|
t.Fatalf("unexpected version: %q", boot.Version)
|
||
|
|
}
|
||
|
|
if len(boot.Services) != 2 {
|
||
|
|
t.Fatalf("unexpected service count: %d", len(boot.Services))
|
||
|
|
}
|
||
|
|
m := boot.ServerMap()
|
||
|
|
if len(m["com"]) != 1 || m["com"][0] != "https://rdap.example.com/" {
|
||
|
|
t.Fatalf("unexpected com mapping: %#v", m["com"])
|
||
|
|
}
|
||
|
|
if len(m["org"]) != 1 {
|
||
|
|
t.Fatalf("unexpected org mapping dedupe: %#v", m["org"])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLoadEmbeddedRDAPBootstrap(t *testing.T) {
|
||
|
|
boot, err := LoadEmbeddedRDAPBootstrap()
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("LoadEmbeddedRDAPBootstrap() error: %v", err)
|
||
|
|
}
|
||
|
|
if len(boot.Services) == 0 {
|
||
|
|
t.Fatal("embedded bootstrap has zero services")
|
||
|
|
}
|
||
|
|
if urls := boot.URLsForTLD("com"); len(urls) == 0 {
|
||
|
|
t.Fatal("embedded bootstrap missing com rdap mapping")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestFetchRDAPBootstrapFromURL(t *testing.T) {
|
||
|
|
raw := `{"version":"1.0","services":[[["io"],["https://rdap.example.io/"]]]}`
|
||
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
_, _ = w.Write([]byte(raw))
|
||
|
|
}))
|
||
|
|
defer srv.Close()
|
||
|
|
|
||
|
|
gotRaw, boot, err := FetchRDAPBootstrapFromURL(context.Background(), srv.URL)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("FetchRDAPBootstrapFromURL() error: %v", err)
|
||
|
|
}
|
||
|
|
if !strings.Contains(string(gotRaw), `"version":"1.0"`) {
|
||
|
|
t.Fatalf("unexpected bootstrap raw: %s", string(gotRaw))
|
||
|
|
}
|
||
|
|
if urls := boot.URLsForTLD("io"); len(urls) != 1 || urls[0] != "https://rdap.example.io/" {
|
||
|
|
t.Fatalf("unexpected io mapping: %#v", urls)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestUpdateRDAPBootstrapFileFromURL(t *testing.T) {
|
||
|
|
raw := `{"version":"1.0","services":[[["dev"],["https://rdap.example.dev/"]]]}`
|
||
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
_, _ = w.Write([]byte(raw))
|
||
|
|
}))
|
||
|
|
defer srv.Close()
|
||
|
|
|
||
|
|
dst := filepath.Join(t.TempDir(), "rdap_dns.json")
|
||
|
|
boot, err := UpdateRDAPBootstrapFileFromURL(context.Background(), srv.URL, dst)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("UpdateRDAPBootstrapFileFromURL() error: %v", err)
|
||
|
|
}
|
||
|
|
b, err := os.ReadFile(dst)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("ReadFile() error: %v", err)
|
||
|
|
}
|
||
|
|
if !strings.Contains(string(b), `"version":"1.0"`) {
|
||
|
|
t.Fatalf("unexpected updated file content: %s", string(b))
|
||
|
|
}
|
||
|
|
if urls := boot.URLsForTLD("dev"); len(urls) == 0 {
|
||
|
|
t.Fatalf("unexpected dev mapping: %#v", urls)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMergeRDAPBootstrapsOverride(t *testing.T) {
|
||
|
|
base := &RDAPBootstrap{
|
||
|
|
Version: "1.0",
|
||
|
|
Services: []RDAPService{
|
||
|
|
{TLDs: []string{"com"}, URLs: []string{"https://base.example/rdap/"}},
|
||
|
|
{TLDs: []string{"org"}, URLs: []string{"https://base.example.org/rdap/"}},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
overlay := &RDAPBootstrap{
|
||
|
|
Version: "1.1",
|
||
|
|
Services: []RDAPService{
|
||
|
|
{TLDs: []string{"com"}, URLs: []string{"https://overlay.example/rdap/"}},
|
||
|
|
{TLDs: []string{"dev"}, URLs: []string{"https://overlay.example.dev/rdap/"}},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
merged := MergeRDAPBootstraps(base, overlay)
|
||
|
|
if merged == nil {
|
||
|
|
t.Fatal("merged bootstrap is nil")
|
||
|
|
}
|
||
|
|
if merged.Version != "1.1" {
|
||
|
|
t.Fatalf("unexpected merged version: %q", merged.Version)
|
||
|
|
}
|
||
|
|
if got := merged.URLsForTLD("com"); len(got) != 1 || got[0] != "https://overlay.example/rdap/" {
|
||
|
|
t.Fatalf("unexpected com mapping after override: %#v", got)
|
||
|
|
}
|
||
|
|
if got := merged.URLsForTLD("org"); len(got) != 1 || got[0] != "https://base.example.org/rdap/" {
|
||
|
|
t.Fatalf("unexpected org mapping after merge: %#v", got)
|
||
|
|
}
|
||
|
|
if got := merged.URLsForTLD("dev"); len(got) != 1 {
|
||
|
|
t.Fatalf("unexpected dev mapping after merge: %#v", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLoadRDAPBootstrapLayered(t *testing.T) {
|
||
|
|
localRaw := `{"version":"1.0","services":[[["dev"],["https://local.example.dev/rdap/"]],[["com"],["https://local.example.com/rdap/"]]]}`
|
||
|
|
localPath := filepath.Join(t.TempDir(), "rdap_local.json")
|
||
|
|
if err := os.WriteFile(localPath, []byte(localRaw), 0644); err != nil {
|
||
|
|
t.Fatalf("write local bootstrap failed: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
remoteRaw := `{"version":"2.0","services":[[["com"],["https://remote.example.com/rdap/"]],[["net"],["https://remote.example.net/rdap/"]]]}`
|
||
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
_, _ = w.Write([]byte(remoteRaw))
|
||
|
|
}))
|
||
|
|
defer srv.Close()
|
||
|
|
|
||
|
|
boot, err := LoadRDAPBootstrapLayered(context.Background(), RDAPBootstrapLoadOptions{
|
||
|
|
LocalFiles: []string{localPath},
|
||
|
|
RefreshRemote: true,
|
||
|
|
RemoteURL: srv.URL,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("LoadRDAPBootstrapLayered() error: %v", err)
|
||
|
|
}
|
||
|
|
if boot.Version != "2.0" {
|
||
|
|
t.Fatalf("unexpected layered version: %q", boot.Version)
|
||
|
|
}
|
||
|
|
if got := boot.URLsForTLD("dev"); len(got) != 1 || got[0] != "https://local.example.dev/rdap/" {
|
||
|
|
t.Fatalf("unexpected dev mapping from local layer: %#v", got)
|
||
|
|
}
|
||
|
|
if got := boot.URLsForTLD("com"); len(got) != 1 || got[0] != "https://remote.example.com/rdap/" {
|
||
|
|
t.Fatalf("unexpected com mapping from remote override: %#v", got)
|
||
|
|
}
|
||
|
|
if got := boot.URLsForTLD("net"); len(got) != 1 || got[0] != "https://remote.example.net/rdap/" {
|
||
|
|
t.Fatalf("unexpected net mapping from remote layer: %#v", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLoadRDAPBootstrapLayeredCachedTTL(t *testing.T) {
|
||
|
|
cacheKey := t.Name() + "-ttl"
|
||
|
|
ClearRDAPBootstrapLayeredCache(cacheKey)
|
||
|
|
|
||
|
|
var hit int32
|
||
|
|
remoteRaw := `{"version":"2.0","services":[[["com"],["https://remote.example.com/rdap/"]]]}`
|
||
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
atomic.AddInt32(&hit, 1)
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
_, _ = w.Write([]byte(remoteRaw))
|
||
|
|
}))
|
||
|
|
defer srv.Close()
|
||
|
|
|
||
|
|
opt := RDAPBootstrapLoadOptions{
|
||
|
|
RefreshRemote: true,
|
||
|
|
RemoteURL: srv.URL,
|
||
|
|
CacheTTL: 80 * time.Millisecond,
|
||
|
|
CacheKey: cacheKey,
|
||
|
|
}
|
||
|
|
if _, err := LoadRDAPBootstrapLayeredCached(context.Background(), opt); err != nil {
|
||
|
|
t.Fatalf("first cached load failed: %v", err)
|
||
|
|
}
|
||
|
|
if _, err := LoadRDAPBootstrapLayeredCached(context.Background(), opt); err != nil {
|
||
|
|
t.Fatalf("second cached load failed: %v", err)
|
||
|
|
}
|
||
|
|
if got := atomic.LoadInt32(&hit); got != 1 {
|
||
|
|
t.Fatalf("expected one remote fetch within ttl, got=%d", got)
|
||
|
|
}
|
||
|
|
|
||
|
|
time.Sleep(120 * time.Millisecond)
|
||
|
|
if _, err := LoadRDAPBootstrapLayeredCached(context.Background(), opt); err != nil {
|
||
|
|
t.Fatalf("third cached load after ttl failed: %v", err)
|
||
|
|
}
|
||
|
|
if got := atomic.LoadInt32(&hit); got != 2 {
|
||
|
|
t.Fatalf("expected cache refresh after ttl expiry, got=%d", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLoadRDAPBootstrapLayeredCachedSingleflight(t *testing.T) {
|
||
|
|
cacheKey := t.Name() + "-sf"
|
||
|
|
ClearRDAPBootstrapLayeredCache(cacheKey)
|
||
|
|
|
||
|
|
var hit int32
|
||
|
|
remoteRaw := `{"version":"2.0","services":[[["net"],["https://remote.example.net/rdap/"]]]}`
|
||
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
atomic.AddInt32(&hit, 1)
|
||
|
|
time.Sleep(100 * time.Millisecond)
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
_, _ = w.Write([]byte(remoteRaw))
|
||
|
|
}))
|
||
|
|
defer srv.Close()
|
||
|
|
|
||
|
|
opt := RDAPBootstrapLoadOptions{
|
||
|
|
RefreshRemote: true,
|
||
|
|
RemoteURL: srv.URL,
|
||
|
|
CacheTTL: time.Minute,
|
||
|
|
CacheKey: cacheKey,
|
||
|
|
}
|
||
|
|
|
||
|
|
const n = 8
|
||
|
|
var wg sync.WaitGroup
|
||
|
|
errCh := make(chan error, n)
|
||
|
|
for i := 0; i < n; i++ {
|
||
|
|
wg.Add(1)
|
||
|
|
go func() {
|
||
|
|
defer wg.Done()
|
||
|
|
boot, err := LoadRDAPBootstrapLayeredCached(context.Background(), opt)
|
||
|
|
if err != nil {
|
||
|
|
errCh <- err
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if got := boot.URLsForTLD("net"); len(got) != 1 || got[0] != "https://remote.example.net/rdap/" {
|
||
|
|
errCh <- &testErr{msg: "unexpected cached bootstrap content"}
|
||
|
|
}
|
||
|
|
}()
|
||
|
|
}
|
||
|
|
wg.Wait()
|
||
|
|
close(errCh)
|
||
|
|
for err := range errCh {
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("concurrent cached load failed: %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if got := atomic.LoadInt32(&hit); got != 1 {
|
||
|
|
t.Fatalf("expected one remote fetch with singleflight, got=%d", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLoadRDAPBootstrapLayeredIgnoreRemoteError(t *testing.T) {
|
||
|
|
localRaw := `{"version":"1.0","services":[[["dev"],["https://local.example.dev/rdap/"]]]}`
|
||
|
|
localPath := filepath.Join(t.TempDir(), "rdap_local.json")
|
||
|
|
if err := os.WriteFile(localPath, []byte(localRaw), 0644); err != nil {
|
||
|
|
t.Fatalf("write local bootstrap failed: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
http.Error(w, "boom", http.StatusInternalServerError)
|
||
|
|
}))
|
||
|
|
defer srv.Close()
|
||
|
|
|
||
|
|
_, err := LoadRDAPBootstrapLayered(context.Background(), RDAPBootstrapLoadOptions{
|
||
|
|
LocalFiles: []string{localPath},
|
||
|
|
RefreshRemote: true,
|
||
|
|
RemoteURL: srv.URL,
|
||
|
|
IgnoreRemoteError: false,
|
||
|
|
})
|
||
|
|
if err == nil {
|
||
|
|
t.Fatal("expected strict remote refresh to fail")
|
||
|
|
}
|
||
|
|
|
||
|
|
boot, err := LoadRDAPBootstrapLayered(context.Background(), RDAPBootstrapLoadOptions{
|
||
|
|
LocalFiles: []string{localPath},
|
||
|
|
RefreshRemote: true,
|
||
|
|
RemoteURL: srv.URL,
|
||
|
|
IgnoreRemoteError: true,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("ignore remote error load failed: %v", err)
|
||
|
|
}
|
||
|
|
if got := boot.URLsForTLD("dev"); len(got) != 1 || got[0] != "https://local.example.dev/rdap/" {
|
||
|
|
t.Fatalf("unexpected local fallback mapping: %#v", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLoadRDAPBootstrapLayeredCachedAllowStaleOnError(t *testing.T) {
|
||
|
|
cacheKey := t.Name() + "-stale"
|
||
|
|
ClearRDAPBootstrapLayeredCache(cacheKey)
|
||
|
|
|
||
|
|
var fail atomic.Bool
|
||
|
|
var hit int32
|
||
|
|
remoteRaw := `{"version":"2.0","services":[[["com"],["https://remote.example.com/rdap/"]]]}`
|
||
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
atomic.AddInt32(&hit, 1)
|
||
|
|
if fail.Load() {
|
||
|
|
http.Error(w, "refresh failed", http.StatusBadGateway)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
_, _ = w.Write([]byte(remoteRaw))
|
||
|
|
}))
|
||
|
|
defer srv.Close()
|
||
|
|
|
||
|
|
baseOpt := RDAPBootstrapLoadOptions{
|
||
|
|
RefreshRemote: true,
|
||
|
|
RemoteURL: srv.URL,
|
||
|
|
CacheTTL: 70 * time.Millisecond,
|
||
|
|
CacheKey: cacheKey,
|
||
|
|
}
|
||
|
|
if _, err := LoadRDAPBootstrapLayeredCached(context.Background(), baseOpt); err != nil {
|
||
|
|
t.Fatalf("initial cached load failed: %v", err)
|
||
|
|
}
|
||
|
|
fail.Store(true)
|
||
|
|
time.Sleep(110 * time.Millisecond)
|
||
|
|
|
||
|
|
_, err := LoadRDAPBootstrapLayeredCached(context.Background(), baseOpt)
|
||
|
|
if err == nil {
|
||
|
|
t.Fatal("expected refresh failure when stale fallback disabled")
|
||
|
|
}
|
||
|
|
|
||
|
|
staleOpt := baseOpt
|
||
|
|
staleOpt.AllowStaleOnError = true
|
||
|
|
boot, err := LoadRDAPBootstrapLayeredCached(context.Background(), staleOpt)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("stale fallback load failed: %v", err)
|
||
|
|
}
|
||
|
|
if got := boot.URLsForTLD("com"); len(got) != 1 || got[0] != "https://remote.example.com/rdap/" {
|
||
|
|
t.Fatalf("unexpected stale fallback mapping: %#v", got)
|
||
|
|
}
|
||
|
|
if got := atomic.LoadInt32(&hit); got < 2 {
|
||
|
|
t.Fatalf("expected at least one failed refresh attempt, hit=%d", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestUpdateRDAPBootstrapFileFromURLCreateParentDir(t *testing.T) {
|
||
|
|
raw := `{"version":"1.0","services":[[["dev"],["https://rdap.example.dev/"]]]}`
|
||
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
_, _ = w.Write([]byte(raw))
|
||
|
|
}))
|
||
|
|
defer srv.Close()
|
||
|
|
|
||
|
|
dst := filepath.Join(t.TempDir(), "nested", "rdap", "rdap_dns.json")
|
||
|
|
boot, err := UpdateRDAPBootstrapFileFromURL(context.Background(), srv.URL, dst)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("UpdateRDAPBootstrapFileFromURL() error: %v", err)
|
||
|
|
}
|
||
|
|
content, err := os.ReadFile(dst)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("ReadFile() error: %v", err)
|
||
|
|
}
|
||
|
|
if !strings.Contains(string(content), `"version":"1.0"`) {
|
||
|
|
t.Fatalf("unexpected updated file content: %s", string(content))
|
||
|
|
}
|
||
|
|
if got := boot.URLsForTLD("dev"); len(got) != 1 || got[0] != "https://rdap.example.dev/" {
|
||
|
|
t.Fatalf("unexpected updated bootstrap mapping: %#v", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
type testErr struct {
|
||
|
|
msg string
|
||
|
|
}
|
||
|
|
|
||
|
|
func (e *testErr) Error() string {
|
||
|
|
if e == nil {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
return e.msg
|
||
|
|
}
|