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 }