whoissdk/rdap_bootstrap_test.go

380 lines
12 KiB
Go
Raw Permalink Normal View History

2026-03-19 11:53:07 +08:00
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
}