package dns import ( "context" "encoding/json" "errors" "io" "net" "net/http" "net/http/httptest" "strconv" "strings" "sync/atomic" "testing" "time" dnsprotocol "github.com/miekg/dns" ) func exchangeWithRetry(t *testing.T, client dnsprotocol.Client, request *dnsprotocol.Msg, address string) *dnsprotocol.Msg { t.Helper() for attempt := 0; attempt < 80; attempt++ { response, _, err := client.Exchange(request, address) if err == nil { return response } if !strings.Contains(err.Error(), "connection refused") { t.Fatalf("dns query failed: %v", err) } time.Sleep(25 * time.Millisecond) } t.Fatalf("dns query failed after retrying due to startup timing") return nil } func pickFreeTCPPort(t *testing.T) int { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("expected free TCP port: %v", err) } defer func() { _ = listener.Close() }() tcpAddress, ok := listener.Addr().(*net.TCPAddr) if !ok { t.Fatalf("expected TCP listener address, got %T", listener.Addr()) } return tcpAddress.Port } func TestServiceResolveUsesExactNameBeforeWildcard(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "*.charon.lthn": { A: []string{"10.69.69.165"}, }, "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, }, }) result, ok := service.Resolve("gateway.charon.lthn") if !ok { t.Fatal("expected exact record to resolve") } if len(result.A) != 1 || result.A[0] != "10.10.10.10" { t.Fatalf("unexpected resolve result: %#v", result.A) } } func TestServiceResolveWithMatchIndicatesExactMatch(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "*.charon.lthn": { A: []string{"10.69.69.165"}, }, "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, }, }) result, ok, usedWildcard := service.ResolveWithMatch("gateway.charon.lthn") if !ok { t.Fatal("expected exact record to resolve") } if usedWildcard { t.Fatalf("expected exact match to report usedWildcard=false, got %#v", result.A) } } func TestServiceResolveWithMatchIndicatesWildcardMatch(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "*.charon.lthn": { A: []string{"10.69.69.165"}, }, }, }) result, ok, usedWildcard := service.ResolveWithMatch("gateway.charon.lthn") if !ok { t.Fatal("expected wildcard record to resolve") } if !usedWildcard { t.Fatalf("expected wildcard match to report usedWildcard=true, got %#v", result.A) } } func TestServiceOptionsAliasBuildsService(t *testing.T) { service := NewService(Options{ Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, }, }) result, ok := service.ResolveAddress("gateway.charon.lthn") if !ok { t.Fatal("expected service constructed from Options alias to resolve") } if len(result.Addresses) != 1 || result.Addresses[0] != "10.10.10.10" { t.Fatalf("unexpected resolve result from Options alias: %#v", result.Addresses) } } func TestServiceResolveUsesMostSpecificWildcard(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "*.lthn": { A: []string{"10.0.0.1"}, }, "*.charon.lthn": { A: []string{"10.0.0.2"}, }, }, }) result, ok := service.Resolve("gateway.charon.lthn") if !ok { t.Fatal("expected wildcard record to resolve") } if len(result.A) != 1 || result.A[0] != "10.0.0.2" { t.Fatalf("unexpected wildcard match: %#v", result.A) } } func TestServiceResolveWildcardMatchesOnlyOneLabel(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "*.charon.lthn": { A: []string{"10.0.0.2"}, }, "*.bar.charon.lthn": { A: []string{"10.0.0.3"}, }, }, }) if _, ok := service.Resolve("foo.bar.charon.lthn"); !ok { t.Fatal("expected deeper wildcard match to resolve against the matching depth") } result, ok := service.Resolve("foo.charon.lthn") if !ok { t.Fatal("expected single-label wildcard to resolve") } if len(result.A) != 1 || result.A[0] != "10.0.0.2" { t.Fatalf("unexpected wildcard result for single-label match: %#v", result.A) } if _, ok := service.Resolve("foo.baz.charon.lthn"); ok { t.Fatal("expected wildcard to require an exact one-label match") } } func TestServiceResolveTXTUsesWildcard(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "*.gateway.charon.lthn": { TXT: []string{"v=lthn1 type=gateway"}, }, }, }) result, ok := service.ResolveTXT("node1.gateway.charon.lthn.") if !ok { t.Fatal("expected wildcard TXT record") } if len(result) != 1 || result[0] != "v=lthn1 type=gateway" { t.Fatalf("unexpected TXT record: %#v", result) } } func TestServiceResolveTXTRecordsReturnsNamedField(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { TXT: []string{"v=lthn1 type=gateway"}, }, }, }) result, ok := service.ResolveTXTRecords("gateway.charon.lthn") if !ok { t.Fatal("expected named TXT result to resolve") } if len(result.TXT) != 1 || result.TXT[0] != "v=lthn1 type=gateway" { t.Fatalf("unexpected ResolveTXTRecords output: %#v", result.TXT) } } func TestServiceResolveAddressReturnsMergedRecords(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10", "10.10.10.10"}, AAAA: []string{"2600:1f1c:7f0:4f01:0000:0000:0000:0001"}, }, }, }) result, ok := service.ResolveAddress("gateway.charon.lthn") if !ok { t.Fatal("expected address record to resolve") } if len(result.Addresses) != 2 { t.Fatalf("expected merged unique addresses, got %#v", result.Addresses) } if result.Addresses[0] != "10.10.10.10" || result.Addresses[1] != "2600:1f1c:7f0:4f01:0000:0000:0000:0001" { t.Fatalf("unexpected address order or value: %#v", result.Addresses) } } func TestServiceResolveAddressFallsBackToFalseWhenMissing(t *testing.T) { service := NewService(ServiceOptions{}) if _, ok := service.ResolveAddress("missing.charon.lthn"); ok { t.Fatal("expected missing record to return false") } } func TestServiceResolveReverseUsesARecords(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, }, }) result, ok := service.ResolveReverse("10.10.10.10") if !ok { t.Fatal("expected reverse record to resolve") } if len(result) != 1 || result[0] != "gateway.charon.lthn" { t.Fatalf("unexpected reverse result: %#v", result) } } func TestServiceResolveReverseNamesReturnsNamedField(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, }, }) result, ok := service.ResolveReverseNames("10.10.10.10") if !ok { t.Fatal("expected named reverse result") } if len(result.Names) != 1 || result.Names[0] != "gateway.charon.lthn" { t.Fatalf("unexpected reverse names result: %#v", result) } } func TestServiceResolveReverseFallsBackToFalseWhenUnknown(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, }, }) if _, ok := service.ResolveReverse("10.10.10.11"); ok { t.Fatal("expected no reverse match for unknown IP") } } func TestServiceResolveReverseUsesSetAndRemove(t *testing.T) { service := NewService(ServiceOptions{}) service.SetRecord("gateway.charon.lthn", NameRecords{ AAAA: []string{"2600:1f1c:7f0:4f01:0000:0000:0000:0001"}, }) result, ok := service.ResolveReverse("2600:1f1c:7f0:4f01::1") if !ok || len(result) != 1 || result[0] != "gateway.charon.lthn" { t.Fatalf("expected newly set reverse record, got %#v", result) } service.RemoveRecord("gateway.charon.lthn") if _, ok := service.ResolveReverse("2600:1f1c:7f0:4f01::1"); ok { t.Fatal("expected removed reverse record to disappear") } } func TestServiceRecordTTLExpiresForwardAndReverseLookups(t *testing.T) { service := NewService(ServiceOptions{ RecordTTL: 25 * time.Millisecond, }) service.SetRecord("gateway.charon.lthn", NameRecords{ A: []string{"10.10.10.10"}, }) if _, ok := service.Resolve("gateway.charon.lthn"); !ok { t.Fatal("expected record to resolve before expiry") } if names, ok := service.ResolveReverse("10.10.10.10"); !ok || len(names) != 1 || names[0] != "gateway.charon.lthn" { t.Fatalf("expected reverse record before expiry, got %#v (ok=%t)", names, ok) } time.Sleep(100 * time.Millisecond) if _, ok := service.Resolve("gateway.charon.lthn"); ok { t.Fatal("expected forward record to expire") } if _, ok := service.ResolveReverse("10.10.10.10"); ok { t.Fatal("expected reverse record to expire with the forward record") } if health := service.Health(); health.NamesCached != 0 { t.Fatalf("expected expired record to be pruned from health, got %#v", health) } } func TestServiceHealthUsesDeterministicTreeRootAndUpdatesOnMutations(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10", "10.0.0.1"}, NS: []string{"ns1.example.com"}, }, }, }) health := service.Health() root := health.TreeRoot if root == "" || root == "stubbed" { t.Fatalf("expected computed tree root, got %#v", health.TreeRoot) } healthRepeating := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { NS: []string{"ns1.example.com"}, A: []string{"10.0.0.1", "10.10.10.10"}, }, }, }).Health().TreeRoot if healthRepeating != root { t.Fatalf("expected deterministic tree root, got %s and %s", healthRepeating, root) } service.SetRecord("gateway.charon.lthn", NameRecords{ A: []string{"10.10.10.11"}, }) updatedRoot := service.Health().TreeRoot if updatedRoot == root { t.Fatalf("expected updated tree root after SetRecord, got %s", updatedRoot) } service.RemoveRecord("gateway.charon.lthn") removedRoot := service.Health().TreeRoot if removedRoot == updatedRoot { t.Fatalf("expected updated tree root after RemoveRecord, got %s", removedRoot) } } func TestServiceHealthUsesChainTreeRootAfterDiscovery(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { var payload struct { Method string `json:"method"` Params []any `json:"params"` } if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { t.Fatalf("unexpected request payload: %v", err) } switch payload.Method { case "getblockchaininfo": responseWriter.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "tree_root": "chain-root-1", }, }) case "getnameresource": responseWriter.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "a": []string{"10.10.10.10"}, }, }) default: t.Fatalf("unexpected method: %s", payload.Method) } })) defer server.Close() service := NewService(ServiceOptions{ ChainAliasDiscoverer: func(_ context.Context) ([]string, error) { return []string{"gateway.charon.lthn"}, nil }, HSDClient: NewHSDClient(HSDClientOptions{URL: server.URL}), }) if err := service.DiscoverAliases(context.Background()); err != nil { t.Fatalf("expected discover to run for health chain-root assertions: %v", err) } health := service.Health() root := health.TreeRoot if root != "chain-root-1" { t.Fatalf("expected health to expose chain tree_root, got %#v", health.TreeRoot) } } func TestServiceLocalMutationClearsChainTreeRoot(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { var payload struct { Method string `json:"method"` Params []any `json:"params"` } if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { t.Fatalf("unexpected request payload: %v", err) } switch payload.Method { case "getblockchaininfo": responseWriter.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "tree_root": "chain-root-1", }, }) case "getnameresource": responseWriter.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "a": []string{"10.10.10.10"}, }, }) default: t.Fatalf("unexpected method: %s", payload.Method) } })) defer server.Close() service := NewService(ServiceOptions{ ChainAliasDiscoverer: func(_ context.Context) ([]string, error) { return []string{"gateway.charon.lthn"}, nil }, HSDClient: NewHSDClient(HSDClientOptions{URL: server.URL}), }) if err := service.DiscoverAliases(context.Background()); err != nil { t.Fatalf("expected discovery to populate chain tree root: %v", err) } if health := service.Health(); health.TreeRoot != "chain-root-1" { t.Fatalf("expected chain tree root before local mutation, got %#v", health.TreeRoot) } service.SetRecord("gateway.charon.lthn", NameRecords{ A: []string{"10.10.10.11"}, }) health := service.Health() if health.TreeRoot == "chain-root-1" { t.Fatalf("expected local mutation to clear stale chain tree root, got %#v", health.TreeRoot) } if health.TreeRoot == "" { t.Fatal("expected health to fall back to computed tree root after local mutation") } } func TestServiceServeHTTPHealthReturnsJSON(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, }, }) httpServer, err := service.ServeHTTPHealth("127.0.0.1", 0) if err != nil { t.Fatalf("expected health HTTP server to start: %v", err) } defer func() { _ = httpServer.Close() }() if httpServer.HealthAddress() == "" { t.Fatal("expected health address from health server") } if httpServer.Address() != httpServer.HealthAddress() { t.Fatalf("expected Address and HealthAddress to match, got %q and %q", httpServer.Address(), httpServer.HealthAddress()) } response, err := http.Get("http://" + httpServer.HealthAddress() + "/health") if err != nil { t.Fatalf("expected health endpoint to respond: %v", err) } defer func() { _ = response.Body.Close() }() if response.StatusCode != http.StatusOK { t.Fatalf("unexpected health status: %d", response.StatusCode) } var payload map[string]any body, err := io.ReadAll(response.Body) if err != nil { t.Fatalf("expected health payload: %v", err) } if err := json.Unmarshal(body, &payload); err != nil { t.Fatalf("expected health JSON: %v", err) } if payload["status"] != "ready" { t.Fatalf("expected ready health status, got %#v", payload["status"]) } if payload["names_cached"] != float64(1) { t.Fatalf("expected one cached name, got %#v", payload["names_cached"]) } } func TestServiceServeAllStartsDNSAndHTTPTogether(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, }, }) runtime, err := service.ServeAll("127.0.0.1", 0, 0) if err != nil { t.Fatalf("expected combined runtime to start: %v", err) } defer func() { _ = runtime.Close() }() if runtime.DNSAddress() == "" { t.Fatal("expected DNS address from combined runtime") } if runtime.HealthAddress() == "" { t.Fatal("expected health address from combined runtime") } if runtime.DNS.Address() != runtime.DNSAddress() { t.Fatalf("expected DNSAddress and Address to match, got %q and %q", runtime.DNS.DNSAddress(), runtime.DNS.Address()) } if runtime.HTTPAddress() != runtime.HealthAddress() { t.Fatalf("expected HTTPAddress and HealthAddress to match, got %q and %q", runtime.HTTPAddress(), runtime.HealthAddress()) } response, err := http.Get("http://" + runtime.HealthAddress() + "/health") if err != nil { t.Fatalf("expected combined HTTP health endpoint to respond: %v", err) } defer func() { _ = response.Body.Close() }() if response.StatusCode != http.StatusOK { t.Fatalf("unexpected combined health status: %d", response.StatusCode) } client := dnsprotocol.Client{} request := new(dnsprotocol.Msg) request.SetQuestion("gateway.charon.lthn.", dnsprotocol.TypeA) dnsResponse := exchangeWithRetry(t, client, request, runtime.DNSAddress()) if dnsResponse.Rcode != dnsprotocol.RcodeSuccess { t.Fatalf("unexpected combined DNS rcode: %d", dnsResponse.Rcode) } if len(dnsResponse.Answer) != 1 { t.Fatalf("expected one DNS answer from combined runtime, got %d", len(dnsResponse.Answer)) } } func TestServiceServeConfiguredUsesPortsFromServiceOptions(t *testing.T) { dnsPort := pickFreeTCPPort(t) httpPort := pickFreeTCPPort(t) service := NewService(ServiceOptions{ DNSPort: dnsPort, HTTPPort: httpPort, Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, }, }) runtime, err := service.ServeConfigured("127.0.0.1") if err != nil { t.Fatalf("expected configured runtime to start: %v", err) } defer func() { _ = runtime.Close() }() _, dnsActualPort, err := net.SplitHostPort(runtime.DNSAddress()) if err != nil { t.Fatalf("expected DNS address to parse: %v", err) } if dnsActualPort != strconv.Itoa(dnsPort) { t.Fatalf("expected configured DNS port %d, got %s", dnsPort, dnsActualPort) } _, httpActualPort, err := net.SplitHostPort(runtime.HealthAddress()) if err != nil { t.Fatalf("expected HTTP address to parse: %v", err) } if httpActualPort != strconv.Itoa(httpPort) { t.Fatalf("expected configured HTTP port %d, got %s", httpPort, httpActualPort) } } func TestServiceDiscoverReplacesRecordsFromDiscoverer(t *testing.T) { records := []map[string]NameRecords{ { "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, "*.lthn": { A: []string{"10.0.0.1"}, }, }, } index := 0 service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "legacy.charon.lthn": { A: []string{"10.11.11.11"}, }, }, RecordDiscoverer: func() (map[string]NameRecords, error) { next := records[index%len(records)] index++ return next, nil }, }) if _, ok := service.Resolve("legacy.charon.lthn"); !ok { t.Fatal("expected baseline record before discovery") } if err := service.Discover(); err != nil { t.Fatalf("unexpected discover error: %v", err) } result, ok := service.Resolve("gateway.charon.lthn") if !ok { t.Fatal("expected discovered exact record") } if len(result.A) != 1 || result.A[0] != "10.10.10.10" { t.Fatalf("unexpected discovered resolve result: %#v", result.A) } if _, ok := service.Resolve("legacy.unknown"); ok { t.Fatal("expected replaced cache not to resolve via old record") } } func TestServiceDiscoverAliasesUsesConfiguredAliasDiscovery(t *testing.T) { var treeRootCalls int32 var nameResourceCalls int32 server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { var payload struct { Method string `json:"method"` Params []any `json:"params"` } if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { t.Fatalf("unexpected request payload: %v", err) } switch payload.Method { case "getblockchaininfo": atomic.AddInt32(&treeRootCalls, 1) responseWriter.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "tree_root": "alias-root-1", }, }) case "getnameresource": atomic.AddInt32(&nameResourceCalls, 1) if len(payload.Params) != 1 || payload.Params[0] != "gateway.charon.lthn" { t.Fatalf("unexpected alias lookup: %#v", payload.Params) } responseWriter.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "a": []string{"10.10.10.10"}, }, }) default: t.Fatalf("unexpected method: %s", payload.Method) } })) defer server.Close() service := NewService(ServiceOptions{ ChainAliasDiscoverer: func(_ context.Context) ([]string, error) { return []string{"gateway.charon.lthn"}, nil }, HSDClient: NewHSDClient(HSDClientOptions{URL: server.URL}), }) if err := service.DiscoverAliases(context.Background()); err != nil { t.Fatalf("expected DiscoverAliases action to run: %v", err) } record, ok := service.Resolve("gateway.charon.lthn") if !ok || len(record.A) != 1 || record.A[0] != "10.10.10.10" { t.Fatalf("expected discovered gateway record, got %#v (ok=%t)", record, ok) } if atomic.LoadInt32(&treeRootCalls) != 1 || atomic.LoadInt32(&nameResourceCalls) != 1 { t.Fatalf("expected discovery to perform chain and name-resource calls, got treeRoot=%d nameResource=%d", atomic.LoadInt32(&treeRootCalls), atomic.LoadInt32(&nameResourceCalls)) } } func TestServiceDiscoverAliasesUsesConfiguredActionCaller(t *testing.T) { var treeRootCalls int32 var nameResourceCalls int32 actionCalled := false discovererCalled := false server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { var payload struct { Method string `json:"method"` Params []any `json:"params"` } if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { t.Fatalf("unexpected request payload: %v", err) } switch payload.Method { case "getblockchaininfo": atomic.AddInt32(&treeRootCalls, 1) responseWriter.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "tree_root": "action-caller-root", }, }) case "getnameresource": atomic.AddInt32(&nameResourceCalls, 1) switch payload.Params[0] { case "gateway.charon.lthn": _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "a": []string{"10.10.10.10"}, }, }) case "node.charon.lthn": _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "aaaa": []string{"2600:1f1c:7f0:4f01::2"}, }, }) default: t.Fatalf("unexpected alias lookup: %#v", payload.Params) } default: t.Fatalf("unexpected method: %s", payload.Method) } })) defer server.Close() service := NewService(ServiceOptions{ ChainAliasActionCaller: actionCallerFunc(func(ctx context.Context, name string, values map[string]any) (any, bool, error) { actionCalled = true if name != "blockchain.chain.aliases" { t.Fatalf("unexpected action name: %s", name) } return map[string]any{ "aliases": []any{"gateway.charon.lthn", "node.charon.lthn"}, }, true, nil }), ChainAliasDiscoverer: func(_ context.Context) ([]string, error) { discovererCalled = true return nil, errors.New("discoverer should not run when action caller succeeds") }, HSDClient: NewHSDClient(HSDClientOptions{URL: server.URL}), }) if err := service.DiscoverAliases(context.Background()); err != nil { t.Fatalf("expected DiscoverAliases to complete through action caller: %v", err) } if !actionCalled { t.Fatal("expected action caller to be invoked") } if discovererCalled { t.Fatal("expected chain alias discoverer to be skipped after action caller success") } gateway, ok := service.Resolve("gateway.charon.lthn") if !ok || len(gateway.A) != 1 || gateway.A[0] != "10.10.10.10" { t.Fatalf("expected gateway A record, got %#v (ok=%t)", gateway, ok) } node, ok := service.Resolve("node.charon.lthn") if !ok || len(node.AAAA) != 1 || node.AAAA[0] != "2600:1f1c:7f0:4f01::2" { t.Fatalf("expected node AAAA record, got %#v (ok=%t)", node, ok) } if atomic.LoadInt32(&treeRootCalls) != 1 || atomic.LoadInt32(&nameResourceCalls) != 2 { t.Fatalf("expected discovery to perform chain and name-resource calls, got treeRoot=%d nameResource=%d", atomic.LoadInt32(&treeRootCalls), atomic.LoadInt32(&nameResourceCalls)) } } func TestServiceDiscoverAliasesUsesConfiguredChainAliasAction(t *testing.T) { var treeRootCalls int32 var nameResourceCalls int32 actionCalled := false discovererCalled := false server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { var payload struct { Method string `json:"method"` Params []any `json:"params"` } if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { t.Fatalf("unexpected request payload: %v", err) } switch payload.Method { case "getblockchaininfo": atomic.AddInt32(&treeRootCalls, 1) responseWriter.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "tree_root": "action-root-1", }, }) case "getnameresource": atomic.AddInt32(&nameResourceCalls, 1) if len(payload.Params) != 1 || payload.Params[0] != "gateway.charon.lthn" { t.Fatalf("unexpected alias lookup: %#v", payload.Params) } responseWriter.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "a": []string{"10.10.10.10"}, }, }) default: t.Fatalf("unexpected method: %s", payload.Method) } })) defer server.Close() service := NewService(ServiceOptions{ ChainAliasAction: func(_ context.Context) ([]string, error) { actionCalled = true return []string{"gateway.charon.lthn"}, nil }, ChainAliasDiscoverer: func(_ context.Context) ([]string, error) { discovererCalled = true return nil, errors.New("discoverer should not be used when action succeeds") }, HSDClient: NewHSDClient(HSDClientOptions{URL: server.URL}), }) if err := service.DiscoverAliases(context.Background()); err != nil { t.Fatalf("expected DiscoverAliases to complete through chain alias action: %v", err) } if !actionCalled { t.Fatal("expected chain alias action to be called") } if discovererCalled { t.Fatal("expected chain alias discoverer to be skipped after action success") } record, ok := service.Resolve("gateway.charon.lthn") if !ok || len(record.A) != 1 || record.A[0] != "10.10.10.10" { t.Fatalf("expected discovered gateway record, got %#v (ok=%t)", record, ok) } if atomic.LoadInt32(&treeRootCalls) != 1 || atomic.LoadInt32(&nameResourceCalls) != 1 { t.Fatalf("expected one tree-root and one name-resource RPC call, got treeRoot=%d nameResource=%d", atomic.LoadInt32(&treeRootCalls), atomic.LoadInt32(&nameResourceCalls)) } } func TestNewServiceBuildsRPCClientsFromOptions(t *testing.T) { var chainCalls int32 var treeRootCalls int32 var nameResourceCalls int32 expectedAuth := "Basic dXNlcjphcGkta2V5" server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { var payload struct { Method string `json:"method"` Params []any `json:"params"` } if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { t.Fatalf("unexpected request payload: %v", err) } switch payload.Method { case "get_all_alias_details": atomic.AddInt32(&chainCalls, 1) if got := request.Header.Get("Authorization"); got != "" { t.Fatalf("expected no auth header for mainchain request, got %q", got) } responseWriter.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": []any{ map[string]any{ "hns": "gateway.charon.lthn", }, }, }) case "getblockchaininfo": atomic.AddInt32(&treeRootCalls, 1) if got := request.Header.Get("Authorization"); got != expectedAuth { t.Fatalf("expected hsd auth header %q, got %q", expectedAuth, got) } responseWriter.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "tree_root": "options-root-1", }, }) case "getnameresource": atomic.AddInt32(&nameResourceCalls, 1) if got := request.Header.Get("Authorization"); got != expectedAuth { t.Fatalf("expected hsd auth header %q, got %q", expectedAuth, got) } if len(payload.Params) != 1 || payload.Params[0] != "gateway.charon.lthn" { t.Fatalf("unexpected alias lookup: %#v", payload.Params) } responseWriter.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "a": []string{"10.10.10.10"}, }, }) default: t.Fatalf("unexpected method: %s", payload.Method) } })) defer server.Close() service := NewService(ServiceOptions{ MainchainURL: server.URL, HSDURL: server.URL, HSDUsername: "user", HSDApiKey: "api-key", }) if err := service.DiscoverAliases(context.Background()); err != nil { t.Fatalf("expected configured RPC clients to drive discovery: %v", err) } record, ok := service.Resolve("gateway.charon.lthn") if !ok || len(record.A) != 1 || record.A[0] != "10.10.10.10" { t.Fatalf("expected discovered record from configured clients, got %#v (ok=%t)", record, ok) } health := service.Health() if health.TreeRoot != "options-root-1" { t.Fatalf("expected health to reflect configured HSD client tree root, got %#v", health.TreeRoot) } if atomic.LoadInt32(&chainCalls) != 1 || atomic.LoadInt32(&treeRootCalls) != 1 || atomic.LoadInt32(&nameResourceCalls) != 1 { t.Fatalf( "expected chain=1 tree-root=1 name-resource=1, got %d %d %d", atomic.LoadInt32(&chainCalls), atomic.LoadInt32(&treeRootCalls), atomic.LoadInt32(&nameResourceCalls), ) } } func TestServiceDiscoverAliasesClearsCacheWhenAliasListBecomesEmpty(t *testing.T) { var hsdCalls int32 server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { atomic.AddInt32(&hsdCalls, 1) t.Fatalf("unexpected HSD request while clearing an empty alias list") })) defer server.Close() service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "legacy.charon.lthn": { A: []string{"10.11.11.11"}, }, }, ChainAliasDiscoverer: func(_ context.Context) ([]string, error) { return []string{}, nil }, HSDClient: NewHSDClient(HSDClientOptions{URL: server.URL}), }) if err := service.DiscoverAliases(context.Background()); err != nil { t.Fatalf("expected empty alias discovery to succeed: %v", err) } if _, ok := service.Resolve("legacy.charon.lthn"); ok { t.Fatal("expected stale records to be cleared when the alias list is empty") } health := service.Health() if health.NamesCached != 0 { t.Fatalf("expected empty cache after clearing aliases, got %d", health.NamesCached) } if atomic.LoadInt32(&hsdCalls) != 0 { t.Fatalf("expected no HSD requests when alias discovery returns empty, got %d", atomic.LoadInt32(&hsdCalls)) } } func TestServiceDiscoverAliasesParsesAliasDetailRecordsFromActionCaller(t *testing.T) { var treeRootCalls int32 var nameResourceCalls int32 actionCalled := false server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { var payload struct { Method string `json:"method"` Params []any `json:"params"` } if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { t.Fatalf("unexpected request payload: %v", err) } switch payload.Method { case "getblockchaininfo": atomic.AddInt32(&treeRootCalls, 1) _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "tree_root": "record-root-1", }, }) case "getnameresource": atomic.AddInt32(&nameResourceCalls, 1) switch payload.Params[0] { case "gateway.charon.lthn": _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "a": []string{"10.10.10.10"}, }, }) case "node.charon.lthn": _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "aaaa": []string{"2600:1f1c:7f0:4f01::2"}, }, }) default: t.Fatalf("unexpected alias lookup: %#v", payload.Params) } default: t.Fatalf("unexpected method: %s", payload.Method) } })) defer server.Close() service := NewService(ServiceOptions{ ChainAliasActionCaller: actionCallerFunc(func(ctx context.Context, name string, values map[string]any) (any, bool, error) { actionCalled = true if name != "blockchain.chain.aliases" { t.Fatalf("unexpected action name: %s", name) } return map[string]any{ "aliases": []any{ map[string]any{ "name": "gateway", "comment": "gateway alias hns=gateway.charon.lthn", }, map[string]any{ "name": "node", "hns": "node.charon.lthn", }, }, }, true, nil }), HSDClient: NewHSDClient(HSDClientOptions{URL: server.URL}), }) if err := service.DiscoverAliases(context.Background()); err != nil { t.Fatalf("expected DiscoverAliases to parse alias detail records: %v", err) } if !actionCalled { t.Fatal("expected action caller to be invoked") } if atomic.LoadInt32(&treeRootCalls) != 1 || atomic.LoadInt32(&nameResourceCalls) != 2 { t.Fatalf("expected one tree-root and two name-resource RPC calls, got treeRoot=%d nameResource=%d", atomic.LoadInt32(&treeRootCalls), atomic.LoadInt32(&nameResourceCalls)) } gateway, ok := service.Resolve("gateway.charon.lthn") if !ok || len(gateway.A) != 1 || gateway.A[0] != "10.10.10.10" { t.Fatalf("expected gateway A record, got %#v (ok=%t)", gateway, ok) } node, ok := service.Resolve("node.charon.lthn") if !ok || len(node.AAAA) != 1 || node.AAAA[0] != "2600:1f1c:7f0:4f01::2" { t.Fatalf("expected node AAAA record, got %#v (ok=%t)", node, ok) } } func TestServiceDiscoverAliasesRefreshesWhenAliasListChangesBeforeTreeRootIntervalExpires(t *testing.T) { var treeRootCalls int32 var nameResourceCalls int32 aliasListIndex := 0 server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { var payload struct { Method string `json:"method"` Params []any `json:"params"` } if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { t.Fatalf("unexpected request payload: %v", err) } switch payload.Method { case "getblockchaininfo": atomic.AddInt32(&treeRootCalls, 1) responseWriter.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "tree_root": "shared-tree-root", }, }) case "getnameresource": atomic.AddInt32(&nameResourceCalls, 1) responseWriter.Header().Set("Content-Type", "application/json") switch payload.Params[0] { case "gateway.charon.lthn": _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "a": []string{"10.10.10.10"}, }, }) case "node.charon.lthn": _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "aaaa": []string{"2600:1f1c:7f0:4f01::2"}, }, }) default: t.Fatalf("unexpected alias lookup: %#v", payload.Params) } default: t.Fatalf("unexpected method: %s", payload.Method) } })) defer server.Close() service := NewService(ServiceOptions{ TreeRootCheckInterval: time.Hour, ChainAliasDiscoverer: func(_ context.Context) ([]string, error) { defer func() { aliasListIndex++ }() if aliasListIndex == 0 { return []string{"gateway.charon.lthn"}, nil } return []string{"gateway.charon.lthn", "node.charon.lthn"}, nil }, HSDClient: NewHSDClient(HSDClientOptions{URL: server.URL}), }) if err := service.DiscoverAliases(context.Background()); err != nil { t.Fatalf("expected first DiscoverAliases call to succeed: %v", err) } if atomic.LoadInt32(&treeRootCalls) != 1 || atomic.LoadInt32(&nameResourceCalls) != 1 { t.Fatalf("expected first discovery to query tree root and one alias, got treeRoot=%d nameResource=%d", atomic.LoadInt32(&treeRootCalls), atomic.LoadInt32(&nameResourceCalls)) } if err := service.DiscoverAliases(context.Background()); err != nil { t.Fatalf("expected second DiscoverAliases call to refresh changed aliases: %v", err) } if atomic.LoadInt32(&treeRootCalls) != 2 || atomic.LoadInt32(&nameResourceCalls) != 3 { t.Fatalf("expected alias change to force refresh, got treeRoot=%d nameResource=%d", atomic.LoadInt32(&treeRootCalls), atomic.LoadInt32(&nameResourceCalls)) } gateway, ok := service.Resolve("gateway.charon.lthn") if !ok || len(gateway.A) != 1 || gateway.A[0] != "10.10.10.10" { t.Fatalf("expected refreshed gateway record, got %#v (ok=%t)", gateway, ok) } node, ok := service.Resolve("node.charon.lthn") if !ok || len(node.AAAA) != 1 || node.AAAA[0] != "2600:1f1c:7f0:4f01::2" { t.Fatalf("expected refreshed node record, got %#v (ok=%t)", node, ok) } } func TestServiceDiscoverFallsBackWhenPrimaryDiscovererFails(t *testing.T) { primaryCalled := false fallbackCalled := false service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "legacy.charon.lthn": { A: []string{"10.11.11.11"}, }, }, RecordDiscoverer: func() (map[string]NameRecords, error) { primaryCalled = true return nil, errors.New("chain service unavailable") }, FallbackRecordDiscoverer: func() (map[string]NameRecords, error) { fallbackCalled = true return map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, }, nil }, }) if err := service.Discover(); err != nil { t.Fatalf("expected fallback discovery to succeed: %v", err) } if !primaryCalled { t.Fatal("expected primary discoverer to be attempted") } if !fallbackCalled { t.Fatal("expected fallback discoverer to run after primary failure") } result, ok := service.Resolve("gateway.charon.lthn") if !ok { t.Fatal("expected fallback record to resolve") } if len(result.A) != 1 || result.A[0] != "10.10.10.10" { t.Fatalf("unexpected fallback resolve result: %#v", result.A) } if _, ok := service.Resolve("legacy.charon.lthn"); ok { t.Fatal("expected legacy record to be replaced by fallback discovery") } } func TestServiceDiscoverReturnsNilAfterFallbackDiscoverySucceeds(t *testing.T) { service := NewService(ServiceOptions{ RecordDiscoverer: func() (map[string]NameRecords, error) { return nil, errors.New("primary discoverer failed") }, FallbackRecordDiscoverer: func() (map[string]NameRecords, error) { return map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, }, nil }, }) if err := service.Discover(); err != nil { t.Fatalf("expected fallback discovery success to return nil, got %v", err) } result, ok := service.Resolve("gateway.charon.lthn") if !ok { t.Fatal("expected fallback record to resolve after discovery") } if len(result.A) != 1 || result.A[0] != "10.10.10.10" { t.Fatalf("unexpected fallback resolve result: %#v", result.A) } } func TestServiceDiscoverUsesFallbackOnlyWhenPrimaryMissing(t *testing.T) { fallbackCalled := false service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "legacy.charon.lthn": { A: []string{"10.11.11.11"}, }, }, FallbackRecordDiscoverer: func() (map[string]NameRecords, error) { fallbackCalled = true return map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.20"}, }, }, nil }, }) if err := service.Discover(); err != nil { t.Fatalf("expected fallback discovery to run: %v", err) } if !fallbackCalled { t.Fatal("expected fallback discoverer to run when primary is missing") } if _, ok := service.Resolve("gateway.charon.lthn"); !ok { t.Fatal("expected fallback record to resolve") } } func TestServiceDiscoverFromChainAliasesUsesFallbackWhenPrimaryFails(t *testing.T) { primaryCalled := false fallbackCalled := false var treeRootCalls int32 var nameResourceCalls int32 server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { var payload struct { Method string `json:"method"` Params []any `json:"params"` } if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { t.Fatalf("unexpected request payload: %v", err) } switch payload.Method { case "getblockchaininfo": atomic.AddInt32(&treeRootCalls, 1) _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "tree_root": "root-1", }, }) case "getnameresource": atomic.AddInt32(&nameResourceCalls, 1) switch payload.Params[0] { case "gateway.charon.lthn": _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "a": []string{"10.10.10.10"}, }, }) case "node.charon.lthn": _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "aaaa": []string{"2600:1f1c:7f0:4f01::2"}, }, }) default: t.Fatalf("unexpected alias query: %#v", payload.Params) } default: t.Fatalf("unexpected method: %s", payload.Method) } })) defer server.Close() service := NewService(ServiceOptions{ ChainAliasDiscoverer: func(_ context.Context) ([]string, error) { primaryCalled = true return nil, errors.New("blockchain service unavailable") }, FallbackChainAliasDiscoverer: func(_ context.Context) ([]string, error) { fallbackCalled = true return []string{"gateway.charon.lthn", "node.charon.lthn"}, nil }, }) client := NewHSDClient(HSDClientOptions{ URL: server.URL, }) if err := service.DiscoverFromChainAliases(context.Background(), client); err != nil { t.Fatalf("expected chain alias discovery to complete: %v", err) } if !primaryCalled { t.Fatal("expected primary chain alias discoverer to be attempted") } if !fallbackCalled { t.Fatal("expected fallback chain alias discoverer to run") } gateway, ok := service.Resolve("gateway.charon.lthn") if !ok || len(gateway.A) != 1 || gateway.A[0] != "10.10.10.10" { t.Fatalf("expected gateway A record, got %#v (ok=%t)", gateway, ok) } node, ok := service.Resolve("node.charon.lthn") if !ok || len(node.AAAA) != 1 || node.AAAA[0] != "2600:1f1c:7f0:4f01::2" { t.Fatalf("expected node AAAA record, got %#v (ok=%t)", node, ok) } if atomic.LoadInt32(&treeRootCalls) != 1 || atomic.LoadInt32(&nameResourceCalls) != 2 { t.Fatalf("expected one tree-root and two name-resource RPC calls, got treeRoot=%d nameResource=%d", atomic.LoadInt32(&treeRootCalls), atomic.LoadInt32(&nameResourceCalls)) } } func TestServiceDiscoverFromChainAliasesUsesConfiguredHSDClient(t *testing.T) { var treeRootCalls int32 var nameResourceCalls int32 server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { var payload struct { Method string `json:"method"` Params []any `json:"params"` } if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { t.Fatalf("unexpected request payload: %v", err) } switch payload.Method { case "getblockchaininfo": atomic.AddInt32(&treeRootCalls, 1) _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "tree_root": "root-1", }, }) case "getnameresource": atomic.AddInt32(&nameResourceCalls, 1) switch payload.Params[0] { case "gateway.charon.lthn": _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "a": []string{"10.10.10.10"}, }, }) default: t.Fatalf("unexpected alias lookup: %#v", payload.Params) } default: t.Fatalf("unexpected method: %s", payload.Method) } })) defer server.Close() service := NewService(ServiceOptions{ ChainAliasDiscoverer: func(_ context.Context) ([]string, error) { return []string{"gateway.charon.lthn"}, nil }, HSDClient: NewHSDClient(HSDClientOptions{URL: server.URL}), }) if err := service.DiscoverFromChainAliases(context.Background(), nil); err != nil { t.Fatalf("expected chain alias discovery to complete: %v", err) } if atomic.LoadInt32(&treeRootCalls) != 1 { t.Fatalf("expected one tree-root call, got %d", atomic.LoadInt32(&treeRootCalls)) } if atomic.LoadInt32(&nameResourceCalls) != 1 { t.Fatalf("expected one name-resource call, got %d", atomic.LoadInt32(&nameResourceCalls)) } gateway, ok := service.Resolve("gateway.charon.lthn") if !ok || len(gateway.A) != 1 || gateway.A[0] != "10.10.10.10" { t.Fatalf("expected gateway A record, got %#v (ok=%t)", gateway, ok) } } func TestServiceDiscoverFromChainAliasesFallsBackToMainchainClientWhenDiscovererFails(t *testing.T) { var chainAliasCalls int32 var treeRootCalls int32 var nameResourceCalls int32 server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { var payload struct { Method string `json:"method"` Params []any `json:"params"` } if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { t.Fatalf("unexpected request payload: %v", err) } switch payload.Method { case "get_all_alias_details": atomic.AddInt32(&chainAliasCalls, 1) responseWriter.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": []any{ map[string]any{ "hns": "gateway.charon.lthn", }, map[string]any{ "hns": "node.charon.lthn", }, }, }) case "getblockchaininfo": atomic.AddInt32(&treeRootCalls, 1) responseWriter.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "tree_root": "chain-root-1", }, }) case "getnameresource": atomic.AddInt32(&nameResourceCalls, 1) switch payload.Params[0] { case "gateway.charon.lthn": _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "a": []string{"10.10.10.10"}, }, }) case "node.charon.lthn": _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "aaaa": []string{"2600:1f1c:7f0:4f01::2"}, }, }) default: t.Fatalf("unexpected alias query: %#v", payload.Params) } default: t.Fatalf("unexpected method: %s", payload.Method) } })) defer server.Close() service := NewService(ServiceOptions{ ChainAliasDiscoverer: func(_ context.Context) ([]string, error) { return nil, errors.New("blockchain service unavailable") }, MainchainAliasClient: NewMainchainAliasClient(MainchainClientOptions{ URL: server.URL, }), }) hsdClient := NewHSDClient(HSDClientOptions{ URL: server.URL, }) if err := service.DiscoverFromChainAliases(context.Background(), hsdClient); err != nil { t.Fatalf("expected chain alias discovery to complete: %v", err) } gateway, ok := service.Resolve("gateway.charon.lthn") if !ok || len(gateway.A) != 1 || gateway.A[0] != "10.10.10.10" { t.Fatalf("expected gateway A record, got %#v (ok=%t)", gateway, ok) } node, ok := service.Resolve("node.charon.lthn") if !ok || len(node.AAAA) != 1 || node.AAAA[0] != "2600:1f1c:7f0:4f01::2" { t.Fatalf("expected node AAAA record, got %#v (ok=%t)", node, ok) } if atomic.LoadInt32(&chainAliasCalls) != 1 { t.Fatalf("expected one chain alias call, got %d", atomic.LoadInt32(&chainAliasCalls)) } if atomic.LoadInt32(&treeRootCalls) != 1 || atomic.LoadInt32(&nameResourceCalls) != 2 { t.Fatalf("expected one tree-root and two name-resource calls, got treeRoot=%d nameResource=%d", atomic.LoadInt32(&treeRootCalls), atomic.LoadInt32(&nameResourceCalls)) } } func TestServiceDiscoverFromChainAliasesSkipsRefreshWhenTreeRootUnchanged(t *testing.T) { var treeRootCalls int32 var nameResourceCalls int32 server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { var payload struct { Method string `json:"method"` Params []any `json:"params"` } if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { t.Fatalf("unexpected request payload: %v", err) } switch payload.Method { case "getblockchaininfo": atomic.AddInt32(&treeRootCalls, 1) _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "tree_root": "same-root", }, }) case "getnameresource": atomic.AddInt32(&nameResourceCalls, 1) switch payload.Params[0] { case "gateway.charon.lthn": _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "a": []string{"10.10.10.10"}, }, }) default: t.Fatalf("unexpected alias query: %#v", payload.Params) } default: t.Fatalf("unexpected method: %s", payload.Method) } })) defer server.Close() service := NewService(ServiceOptions{ TreeRootCheckInterval: 5 * time.Second, ChainAliasDiscoverer: func(_ context.Context) ([]string, error) { return []string{"gateway.charon.lthn"}, nil }, }) client := NewHSDClient(HSDClientOptions{ URL: server.URL, }) if err := service.DiscoverFromChainAliases(context.Background(), client); err != nil { t.Fatalf("expected first chain alias discovery to run: %v", err) } if err := service.DiscoverFromChainAliases(context.Background(), client); err != nil { t.Fatalf("expected second chain alias discovery to skip refresh: %v", err) } if atomic.LoadInt32(&treeRootCalls) != 1 { t.Fatalf("expected one tree_root check in interval window, got %d", atomic.LoadInt32(&treeRootCalls)) } if atomic.LoadInt32(&nameResourceCalls) != 1 { t.Fatalf("expected one name-resource query while refreshing, got %d", atomic.LoadInt32(&nameResourceCalls)) } } func TestServiceDiscoverFromChainAliasesIgnoresMissingDiscoverers(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { t.Fatalf("expected no hsd requests when alias discoverers are missing") })) defer server.Close() service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, }, }) service.mainchainAliasClient = nil client := NewHSDClient(HSDClientOptions{ URL: server.URL, }) if err := service.DiscoverFromChainAliases(context.Background(), client); err != nil { t.Fatalf("expected no-op when no alias discoverer configured and no mainchain client: %v", err) } result, ok := service.Resolve("gateway.charon.lthn") if !ok || len(result.A) != 1 || result.A[0] != "10.10.10.10" { t.Fatalf("expected baseline record to stay in cache, got %#v (ok=%t)", result, ok) } } func TestServiceDiscoverReturnsNilWithoutDiscoverer(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, }, }) if err := service.Discover(); err != nil { t.Fatalf("expected no error when discoverer is missing: %v", err) } } func TestServiceDiscoverAliasesReturnsNilWithoutDiscovererOrHSDClient(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, }, }) service.mainchainAliasClient = nil if err := service.DiscoverAliases(context.Background()); err != nil { t.Fatalf("expected discover aliases to no-op without sources or HSD client when no explicit mainchain client: %v", err) } result, ok := service.Resolve("gateway.charon.lthn") if !ok || len(result.A) != 1 || result.A[0] != "10.10.10.10" { t.Fatalf("expected cached record to remain intact, got %#v (ok=%t)", result, ok) } } func TestServiceCreatesDefaultHSDClientWhenURLNotConfigured(t *testing.T) { service := NewService(ServiceOptions{ ChainAliasDiscoverer: func(_ context.Context) ([]string, error) { return []string{"gateway.charon.lthn"}, nil }, }) if service.hsdClient == nil { t.Fatalf("expected default HSD client to be created when HSDURL is not configured") } if service.hsdClient.baseURL != "http://127.0.0.1:14037" { t.Fatalf("expected default HSD base URL, got %q", service.hsdClient.baseURL) } } func TestServiceServeResolvesAAndAAAARecords(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10"}, AAAA: []string{"2600:1f1c:7f0:4f01::1"}, }, }, }) srv, err := service.Serve("127.0.0.1", 0) if err != nil { t.Fatalf("expected server to start: %v", err) } defer func() { _ = srv.Close() }() client := dnsprotocol.Client{} query := func(qtype uint16) *dnsprotocol.Msg { request := new(dnsprotocol.Msg) request.SetQuestion("gateway.charon.lthn.", qtype) response := exchangeWithRetry(t, client, request, srv.Address()) if response.Rcode != dnsprotocol.RcodeSuccess { t.Fatalf("unexpected rcode for qtype %d: %d", qtype, response.Rcode) } return response } aResponse := query(dnsprotocol.TypeA) if len(aResponse.Answer) != 1 { t.Fatalf("expected one A answer, got %d", len(aResponse.Answer)) } if got, ok := aResponse.Answer[0].(*dnsprotocol.A); !ok || got.A.String() != "10.10.10.10" { t.Fatalf("unexpected A answer: %#v", aResponse.Answer[0]) } aaaaResponse := query(dnsprotocol.TypeAAAA) if len(aaaaResponse.Answer) != 1 { t.Fatalf("expected one AAAA answer, got %d", len(aaaaResponse.Answer)) } if got, ok := aaaaResponse.Answer[0].(*dnsprotocol.AAAA); !ok || got.AAAA.String() != "2600:1f1c:7f0:4f01::1" { t.Fatalf("unexpected AAAA answer: %#v", aaaaResponse.Answer[0]) } } func TestServiceServeAnswersDSRecords(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { DS: []string{"60485 8 2 A1B2C3D4E5F60718293A4B5C6D7E8F9012345678"}, }, }, }) srv, err := service.Serve("127.0.0.1", 0) if err != nil { t.Fatalf("expected server to start: %v", err) } defer func() { _ = srv.Close() }() client := dnsprotocol.Client{} request := new(dnsprotocol.Msg) request.SetQuestion("gateway.charon.lthn.", dnsprotocol.TypeDS) response := exchangeWithRetry(t, client, request, srv.Address()) if response.Rcode != dnsprotocol.RcodeSuccess { t.Fatalf("unexpected DS rcode: %d", response.Rcode) } if len(response.Answer) != 1 { t.Fatalf("expected one DS answer, got %d", len(response.Answer)) } if _, ok := response.Answer[0].(*dnsprotocol.DS); !ok { t.Fatalf("expected DS answer, got %#v", response.Answer[0]) } } func TestServiceServeAnswersDNSKEYRecords(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { DNSKEY: []string{"257 3 13 AA=="}, }, }, }) srv, err := service.Serve("127.0.0.1", 0) if err != nil { t.Fatalf("expected server to start: %v", err) } defer func() { _ = srv.Close() }() client := dnsprotocol.Client{} request := new(dnsprotocol.Msg) request.SetQuestion("gateway.charon.lthn.", dnsprotocol.TypeDNSKEY) response := exchangeWithRetry(t, client, request, srv.Address()) if response.Rcode != dnsprotocol.RcodeSuccess { t.Fatalf("unexpected DNSKEY rcode: %d", response.Rcode) } if len(response.Answer) != 1 { t.Fatalf("expected one DNSKEY answer, got %d", len(response.Answer)) } if _, ok := response.Answer[0].(*dnsprotocol.DNSKEY); !ok { t.Fatalf("expected DNSKEY answer, got %#v", response.Answer[0]) } } func TestServiceServeAnswersRRSIGRecords(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { RRSIG: []string{"A 8 2 3600 20260101000000 20250101000000 12345 gateway.charon.lthn. AA=="}, }, }, }) srv, err := service.Serve("127.0.0.1", 0) if err != nil { t.Fatalf("expected server to start: %v", err) } defer func() { _ = srv.Close() }() client := dnsprotocol.Client{} request := new(dnsprotocol.Msg) request.SetQuestion("gateway.charon.lthn.", dnsprotocol.TypeRRSIG) response := exchangeWithRetry(t, client, request, srv.Address()) if response.Rcode != dnsprotocol.RcodeSuccess { t.Fatalf("unexpected RRSIG rcode: %d", response.Rcode) } if len(response.Answer) != 1 { t.Fatalf("expected one RRSIG answer, got %d", len(response.Answer)) } if _, ok := response.Answer[0].(*dnsprotocol.RRSIG); !ok { t.Fatalf("expected RRSIG answer, got %#v", response.Answer[0]) } } func TestServiceServeAnswersANYWithAllRecordTypes(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10"}, AAAA: []string{"2600:1f1c:7f0:4f01::1"}, TXT: []string{"v=lthn1 type=gateway"}, NS: []string{"ns.gateway.charon.lthn"}, DS: []string{"60485 8 2 A1B2C3D4E5F60718293A4B5C6D7E8F9012345678"}, }, "node.charon.lthn": { A: []string{"10.10.10.11"}, }, }, }) srv, err := service.Serve("127.0.0.1", 0) if err != nil { t.Fatalf("expected server to start: %v", err) } defer func() { _ = srv.Close() }() client := dnsprotocol.Client{} request := new(dnsprotocol.Msg) request.SetQuestion("gateway.charon.lthn.", dnsprotocol.TypeANY) response := exchangeWithRetry(t, client, request, srv.Address()) if response.Rcode != dnsprotocol.RcodeSuccess { t.Fatalf("unexpected ANY rcode: %d", response.Rcode) } var sawA, sawAAAA, sawTXT, sawNS, sawDS, sawSOA bool for _, answer := range response.Answer { switch rr := answer.(type) { case *dnsprotocol.A: sawA = rr.A.String() == "10.10.10.10" case *dnsprotocol.AAAA: sawAAAA = rr.AAAA.String() == "2600:1f1c:7f0:4f01::1" case *dnsprotocol.TXT: sawTXT = len(rr.Txt) == 1 && rr.Txt[0] == "v=lthn1 type=gateway" case *dnsprotocol.NS: sawNS = rr.Ns == "ns.gateway.charon.lthn." case *dnsprotocol.DS: sawDS = true case *dnsprotocol.SOA: sawSOA = true } } if !sawA || !sawAAAA || !sawTXT || !sawNS || !sawDS { t.Fatalf("expected ANY answer to include A, AAAA, TXT, NS, and DS records, got %#v", response.Answer) } if sawSOA { t.Fatalf("expected ANY answer for a non-apex name to omit SOA, got %#v", response.Answer) } } func TestServiceServeResolvesWildcardAndPTRRecords(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "*.charon.lthn": { A: []string{"10.0.0.1"}, }, "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, }, }) srv, err := service.Serve("127.0.0.1", 0) if err != nil { t.Fatalf("expected server to start: %v", err) } defer func() { _ = srv.Close() }() client := dnsprotocol.Client{} request := new(dnsprotocol.Msg) request.SetQuestion("node1.charon.lthn.", dnsprotocol.TypeA) response := exchangeWithRetry(t, client, request, srv.Address()) if response.Rcode != dnsprotocol.RcodeSuccess { t.Fatalf("unexpected rcode: %d", response.Rcode) } if got, ok := response.Answer[0].(*dnsprotocol.A); !ok || got.A.String() != "10.0.0.1" { t.Fatalf("unexpected wildcard A answer: %#v", response.Answer) } ptrName := "10.10.10.10.in-addr.arpa." ptrRequest := new(dnsprotocol.Msg) ptrRequest.SetQuestion(ptrName, dnsprotocol.TypePTR) ptrResponse := exchangeWithRetry(t, client, ptrRequest, srv.Address()) if len(ptrResponse.Answer) == 0 { t.Fatal("expected PTR answer") } if got, ok := ptrResponse.Answer[0].(*dnsprotocol.PTR); !ok || got.Ptr != "gateway.charon.lthn." { t.Fatalf("unexpected PTR answer: %#v", ptrResponse.Answer) } } func TestServiceServeAnswersSOAOnlyForZoneApex(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "charon.lthn": { NS: []string{"ns1.charon.lthn"}, }, "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, }, }) srv, err := service.Serve("127.0.0.1", 0) if err != nil { t.Fatalf("expected server to start: %v", err) } defer func() { _ = srv.Close() }() client := dnsprotocol.Client{} apexRequest := new(dnsprotocol.Msg) apexRequest.SetQuestion("charon.lthn.", dnsprotocol.TypeSOA) apexResponse := exchangeWithRetry(t, client, apexRequest, srv.Address()) if apexResponse.Rcode != dnsprotocol.RcodeSuccess { t.Fatalf("expected SOA query for apex to succeed, got %d", apexResponse.Rcode) } if len(apexResponse.Answer) != 1 { t.Fatalf("expected one SOA answer for apex, got %d", len(apexResponse.Answer)) } if _, ok := apexResponse.Answer[0].(*dnsprotocol.SOA); !ok { t.Fatalf("expected SOA answer for apex, got %#v", apexResponse.Answer[0]) } subdomainRequest := new(dnsprotocol.Msg) subdomainRequest.SetQuestion("gateway.charon.lthn.", dnsprotocol.TypeSOA) subdomainResponse := exchangeWithRetry(t, client, subdomainRequest, srv.Address()) if subdomainResponse.Rcode != dnsprotocol.RcodeSuccess { t.Fatalf("expected SOA query for non-apex existing name to succeed, got %d", subdomainResponse.Rcode) } if len(subdomainResponse.Answer) != 0 { t.Fatalf("expected no SOA answer for non-apex name, got %#v", subdomainResponse.Answer) } } func TestServiceServeAnswersSOAForDerivedZoneApexWithoutExactRecord(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, "node.charon.lthn": { A: []string{"10.10.10.11"}, }, }, }) srv, err := service.Serve("127.0.0.1", 0) if err != nil { t.Fatalf("expected server to start: %v", err) } defer func() { _ = srv.Close() }() client := dnsprotocol.Client{} request := new(dnsprotocol.Msg) request.SetQuestion("charon.lthn.", dnsprotocol.TypeSOA) response := exchangeWithRetry(t, client, request, srv.Address()) if response.Rcode != dnsprotocol.RcodeSuccess { t.Fatalf("expected SOA query for derived apex to succeed, got %d", response.Rcode) } if len(response.Answer) != 1 { t.Fatalf("expected one SOA answer for derived apex, got %d", len(response.Answer)) } if _, ok := response.Answer[0].(*dnsprotocol.SOA); !ok { t.Fatalf("expected SOA answer for derived apex, got %#v", response.Answer[0]) } } func TestServiceServeAnswersSOAForWildcardOnlyDerivedZoneApex(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "*.charon.lthn": { A: []string{"10.0.0.1"}, }, }, }) srv, err := service.Serve("127.0.0.1", 0) if err != nil { t.Fatalf("expected server to start: %v", err) } defer func() { _ = srv.Close() }() client := dnsprotocol.Client{} request := new(dnsprotocol.Msg) request.SetQuestion("charon.lthn.", dnsprotocol.TypeSOA) response := exchangeWithRetry(t, client, request, srv.Address()) if response.Rcode != dnsprotocol.RcodeSuccess { t.Fatalf("expected SOA query for wildcard-derived apex to succeed, got %d", response.Rcode) } if len(response.Answer) != 1 { t.Fatalf("expected one SOA answer for wildcard-derived apex, got %d", len(response.Answer)) } if _, ok := response.Answer[0].(*dnsprotocol.SOA); !ok { t.Fatalf("expected SOA answer for wildcard-derived apex, got %#v", response.Answer[0]) } } func TestServiceServeAnswersNSForDerivedZoneApexWithoutExactRecord(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, "node.charon.lthn": { A: []string{"10.10.10.11"}, }, }, }) srv, err := service.Serve("127.0.0.1", 0) if err != nil { t.Fatalf("expected server to start: %v", err) } defer func() { _ = srv.Close() }() client := dnsprotocol.Client{} request := new(dnsprotocol.Msg) request.SetQuestion("charon.lthn.", dnsprotocol.TypeNS) response := exchangeWithRetry(t, client, request, srv.Address()) if response.Rcode != dnsprotocol.RcodeSuccess { t.Fatalf("expected NS query for derived apex to succeed, got %d", response.Rcode) } if len(response.Answer) != 1 { t.Fatalf("expected one NS answer for derived apex, got %d", len(response.Answer)) } ns, ok := response.Answer[0].(*dnsprotocol.NS) if !ok { t.Fatalf("expected NS answer for derived apex, got %#v", response.Answer[0]) } if ns.Ns != "ns.charon.lthn." { t.Fatalf("expected synthesized apex NS, got %q", ns.Ns) } } func TestServiceServeAnswersNSForWildcardOnlyDerivedZoneApex(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "*.gateway.charon.lthn": { A: []string{"10.0.0.1"}, }, }, }) srv, err := service.Serve("127.0.0.1", 0) if err != nil { t.Fatalf("expected server to start: %v", err) } defer func() { _ = srv.Close() }() client := dnsprotocol.Client{} request := new(dnsprotocol.Msg) request.SetQuestion("gateway.charon.lthn.", dnsprotocol.TypeNS) response := exchangeWithRetry(t, client, request, srv.Address()) if response.Rcode != dnsprotocol.RcodeSuccess { t.Fatalf("expected NS query for wildcard-derived apex to succeed, got %d", response.Rcode) } if len(response.Answer) != 1 { t.Fatalf("expected one NS answer for wildcard-derived apex, got %d", len(response.Answer)) } ns, ok := response.Answer[0].(*dnsprotocol.NS) if !ok { t.Fatalf("expected NS answer for wildcard-derived apex, got %#v", response.Answer[0]) } if ns.Ns != "ns.gateway.charon.lthn." { t.Fatalf("expected synthesized wildcard-derived apex NS, got %q", ns.Ns) } } func TestServiceResolveAllSynthesizesNSForDerivedZoneApex(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, "node.charon.lthn": { AAAA: []string{"2600:1f1c:7f0:4f01::2"}, }, }, }) result, ok := service.ResolveAll("charon.lthn") if !ok { t.Fatal("expected derived zone apex to resolve") } if len(result.NS) != 1 || result.NS[0] != "ns.charon.lthn" { t.Fatalf("expected synthesized apex NS, got %#v", result.NS) } } func TestServiceResolveAllSynthesizesNSForWildcardOnlyDerivedZoneApex(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "*.charon.lthn": { A: []string{"10.0.0.1"}, }, }, }) result, ok := service.ResolveAll("charon.lthn") if !ok { t.Fatal("expected wildcard-derived zone apex to resolve") } if len(result.A) != 0 || len(result.AAAA) != 0 || len(result.TXT) != 0 { t.Fatalf("expected no A/AAAA/TXT values for derived wildcard apex, got %#v", result) } if len(result.NS) != 1 || result.NS[0] != "ns.charon.lthn" { t.Fatalf("expected synthesized NS from wildcard-derived apex, got %#v", result.NS) } } func TestServiceResolveAllReturnsStableShapeForDerivedZoneApex(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, "node.charon.lthn": { AAAA: []string{"2600:1f1c:7f0:4f01::2"}, }, }, }) result, ok := service.ResolveAll("charon.lthn") if !ok { t.Fatal("expected derived zone apex to resolve") } if result.A == nil || result.AAAA == nil || result.TXT == nil || result.NS == nil { t.Fatalf("expected stable slice fields for derived apex, got %#v", result) } if len(result.A) != 0 || len(result.AAAA) != 0 || len(result.TXT) != 0 { t.Fatalf("expected empty value arrays for derived apex, got %#v", result) } if len(result.NS) != 1 || result.NS[0] != "ns.charon.lthn" { t.Fatalf("expected synthesized apex NS, got %#v", result.NS) } raw, err := json.Marshal(result) if err != nil { t.Fatalf("expected derived apex payload to marshal: %v", err) } if string(raw) != `{"a":[],"aaaa":[],"txt":[],"ns":["ns.charon.lthn"]}` { t.Fatalf("expected stable JSON shape for derived apex, got %s", raw) } } func TestServiceResolveAllReturnsEmptyArraysForMissingRecordValues(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, "node.charon.lthn": { AAAA: []string{"2600:1f1c:7f0:4f01::2"}, }, }, }) result, ok := service.ResolveAll("gateway.charon.lthn") if !ok { t.Fatal("expected record to resolve") } if result.AAAA == nil || len(result.AAAA) != 0 { t.Fatalf("expected empty AAAA slice, got %#v", result.AAAA) } if result.TXT == nil || len(result.TXT) != 0 { t.Fatalf("expected empty TXT slice, got %#v", result.TXT) } if result.NS == nil || len(result.NS) != 0 { t.Fatalf("expected empty NS slice, got %#v", result.NS) } raw, err := json.Marshal(result) if err != nil { t.Fatalf("expected result to marshal: %v", err) } if string(raw) != `{"a":["10.10.10.10"],"aaaa":[],"txt":[],"ns":[]}` { t.Fatalf("expected empty arrays in JSON, got %s", raw) } } func TestServiceResolveAllReturnsEmptyArraysForMissingName(t *testing.T) { service := NewService(ServiceOptions{}) result, ok := service.ResolveAll("missing.charon.lthn") if !ok { t.Fatal("expected missing name to still return the array-shaped payload") } if len(result.A) != 0 || len(result.AAAA) != 0 || len(result.TXT) != 0 || len(result.NS) != 0 { t.Fatalf("expected empty arrays for missing name, got %#v", result) } raw, err := json.Marshal(result) if err != nil { t.Fatalf("expected result to marshal: %v", err) } if string(raw) != `{"a":[],"aaaa":[],"txt":[],"ns":[]}` { t.Fatalf("expected empty arrays in JSON, got %s", raw) } } func TestServiceResolveAllIncludesDNSSECRecords(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10"}, DS: []string{"60485 8 2 A1B2C3D4E5F60718293A4B5C6D7E8F9012345678"}, DNSKEY: []string{"257 3 13 AA=="}, RRSIG: []string{"A 8 2 3600 20260101000000 20250101000000 12345 gateway.charon.lthn. AA=="}, }, }, }) result, ok := service.ResolveAll("gateway.charon.lthn") if !ok { t.Fatal("expected dnssec record to resolve") } if len(result.A) != 1 || result.A[0] != "10.10.10.10" { t.Fatalf("unexpected A records in dns.resolve.all payload: %#v", result.A) } if len(result.DS) != 1 || result.DS[0] != "60485 8 2 A1B2C3D4E5F60718293A4B5C6D7E8F9012345678" { t.Fatalf("expected DS payload in resolve.all, got %#v", result.DS) } if len(result.DNSKEY) != 1 || result.DNSKEY[0] != "257 3 13 AA==" { t.Fatalf("expected DNSKEY payload in resolve.all, got %#v", result.DNSKEY) } if len(result.RRSIG) != 1 || result.RRSIG[0] != "A 8 2 3600 20260101000000 20250101000000 12345 gateway.charon.lthn. AA==" { t.Fatalf("expected RRSIG payload in resolve.all, got %#v", result.RRSIG) } } func TestServiceServeReturnsNXDOMAINWhenMissing(t *testing.T) { service := NewService(ServiceOptions{}) srv, err := service.Serve("127.0.0.1", 0) if err != nil { t.Fatalf("expected server to start: %v", err) } defer func() { _ = srv.Close() }() client := dnsprotocol.Client{} request := new(dnsprotocol.Msg) request.SetQuestion("missing.charon.lthn.", dnsprotocol.TypeA) response := exchangeWithRetry(t, client, request, srv.Address()) if response.Rcode != dnsprotocol.RcodeNameError { t.Fatalf("expected NXDOMAIN, got %d", response.Rcode) } } func TestServiceServeReturnsNoErrorWhenTypeIsMissing(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, }, }) srv, err := service.Serve("127.0.0.1", 0) if err != nil { t.Fatalf("expected server to start: %v", err) } defer func() { _ = srv.Close() }() client := dnsprotocol.Client{} request := new(dnsprotocol.Msg) request.SetQuestion("gateway.charon.lthn.", dnsprotocol.TypeTXT) response := exchangeWithRetry(t, client, request, srv.Address()) if response.Rcode != dnsprotocol.RcodeSuccess { t.Fatalf("expected NOERROR for existing name without TXT records, got %d", response.Rcode) } if len(response.Answer) != 0 { t.Fatalf("expected empty answer for missing TXT record, got %#v", response.Answer) } } func TestServiceHandleActionResolveAndTXTAndAll(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10"}, AAAA: []string{"2600:1f1c:7f0:4f01::1"}, TXT: []string{"v=lthn1 type=gateway"}, NS: []string{"ns.charon.lthn"}, }, }, }) addresses, ok, err := service.HandleAction(ActionResolve, map[string]any{ "name": "gateway.charon.lthn", }) if err != nil { t.Fatalf("unexpected resolve action error: %v", err) } if !ok { t.Fatal("expected resolve action to return a record") } payload, ok := addresses.(ResolveAddressResult) if !ok { t.Fatalf("expected ResolveAddressResult payload, got %T", addresses) } if len(payload.Addresses) != 2 || payload.Addresses[0] != "10.10.10.10" || payload.Addresses[1] != "2600:1f1c:7f0:4f01::1" { t.Fatalf("unexpected resolve result: %#v", payload.Addresses) } txtPayload, ok, err := service.HandleAction(ActionResolveTXT, map[string]any{ "name": "gateway.charon.lthn", }) if err != nil { t.Fatalf("unexpected txt action error: %v", err) } if !ok { t.Fatal("expected txt action to return a record") } txts, ok := txtPayload.(ResolveTXTResult) if !ok { t.Fatalf("expected ResolveTXTResult payload, got %T", txtPayload) } if len(txts.TXT) != 1 || txts.TXT[0] != "v=lthn1 type=gateway" { t.Fatalf("unexpected txt result: %#v", txts.TXT) } allPayload, ok, err := service.HandleAction(ActionResolveAll, map[string]any{ "name": "gateway.charon.lthn", }) if err != nil { t.Fatalf("unexpected resolve.all action error: %v", err) } if !ok { t.Fatal("expected resolve.all action to return a record") } all, ok := allPayload.(ResolveAllResult) if !ok { t.Fatalf("expected ResolveAllResult payload, got %T", allPayload) } if len(all.NS) != 1 || all.NS[0] != "ns.charon.lthn" { t.Fatalf("unexpected resolve.all result: %#v", all) } } func TestServiceHandleActionServeDefaultsPortFromServiceConfiguration(t *testing.T) { desiredPort := pickFreeTCPPort(t) service := NewService(ServiceOptions{ DNSPort: desiredPort, Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, }, }) payload, ok, err := service.HandleAction(ActionServe, map[string]any{ "bind": "127.0.0.1", }) if err != nil { t.Fatalf("expected serve action to default port when omitted: %v", err) } if !ok { t.Fatal("expected serve action to succeed with omitted port") } dnsServer, ok := payload.(*DNSServer) if !ok { t.Fatalf("expected DNSServer payload, got %T", payload) } if dnsServer == nil { t.Fatal("expected dns server from serve action") } _, port, err := net.SplitHostPort(dnsServer.DNSAddress()) if err != nil { t.Fatalf("expected service address to include port: %v", err) } if port != strconv.Itoa(desiredPort) { t.Fatalf("expected configured DNS port %d, got %q", desiredPort, port) } _ = dnsServer.Close() } func TestServiceResolveServePortDefaultsToStandardDNSPort(t *testing.T) { service := NewService(ServiceOptions{}) if service.ResolveDNSPort() != DefaultDNSPort { t.Fatalf("expected ResolveDNSPort to default to standard DNS port %d, got %d", DefaultDNSPort, service.ResolveDNSPort()) } if service.DNSPort() != DefaultDNSPort { t.Fatalf("expected DNSPort alias to return the standard DNS port %d, got %d", DefaultDNSPort, service.DNSPort()) } if service.resolveServePort() != DefaultDNSPort { t.Fatalf("expected internal resolveServePort helper to return the standard DNS port %d, got %d", DefaultDNSPort, service.resolveServePort()) } customPort := 1053 customService := NewService(ServiceOptions{ DNSPort: customPort, }) if customService.ResolveDNSPort() != customPort { t.Fatalf("expected ResolveDNSPort to honor configured DNSPort, got %d", customService.ResolveDNSPort()) } if customService.DNSPort() != customPort { t.Fatalf("expected DNSPort alias to honor configured DNSPort, got %d", customService.DNSPort()) } if customService.resolveServePort() != customPort { t.Fatalf("expected resolveServePort helper to honor configured DNSPort, got %d", customService.resolveServePort()) } } func TestServiceResolveHTTPPortDefaultsToStandardHTTPPort(t *testing.T) { service := NewService(ServiceOptions{}) if service.ResolveHTTPPort() != DefaultHTTPPort { t.Fatalf("expected ResolveHTTPPort to default to %d, got %d", DefaultHTTPPort, service.ResolveHTTPPort()) } if service.HTTPPort() != DefaultHTTPPort { t.Fatalf("expected HTTPPort alias to return default %d, got %d", DefaultHTTPPort, service.HTTPPort()) } if service.resolveHTTPPort() != DefaultHTTPPort { t.Fatalf("expected resolveHTTPPort helper to return default %d, got %d", DefaultHTTPPort, service.resolveHTTPPort()) } customPort := 5555 customService := NewService(ServiceOptions{ HTTPPort: customPort, }) if customService.ResolveHTTPPort() != customPort { t.Fatalf("expected ResolveHTTPPort to honor configured HTTPPort, got %d", customService.ResolveHTTPPort()) } if customService.HTTPPort() != customPort { t.Fatalf("expected HTTPPort alias to honor configured HTTPPort, got %d", customService.HTTPPort()) } if customService.resolveHTTPPort() != customPort { t.Fatalf("expected resolveHTTPPort helper to honor configured HTTPPort, got %d", customService.resolveHTTPPort()) } } func TestServiceActionNamesExposeAllRFCActions(t *testing.T) { service := NewService(ServiceOptions{}) names := service.ActionNames() expected := []string{ ActionResolve, ActionResolveTXT, ActionResolveAll, ActionReverse, ActionServe, ActionHealth, ActionDiscover, } if len(names) != len(expected) { t.Fatalf("expected %d action names, got %d: %#v", len(expected), len(names), names) } for i, name := range expected { if names[i] != name { t.Fatalf("unexpected action name at %d: got %q want %q", i, names[i], name) } } } func TestServiceRegisterActionsPublishesAllActionsInOrder(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, }, }) registrar := &actionRecorder{} service.RegisterActions(registrar) expected := service.ActionNames() if len(registrar.names) != len(expected) { t.Fatalf("expected %d registered actions, got %d: %#v", len(expected), len(registrar.names), registrar.names) } for index, name := range expected { if registrar.names[index] != name { t.Fatalf("unexpected registered action at %d: got %q want %q", index, registrar.names[index], name) } } payload, ok, err := registrar.handlers[ActionResolve](map[string]any{"name": "gateway.charon.lthn"}) if err != nil { t.Fatalf("unexpected registered handler error: %v", err) } if !ok { t.Fatal("expected registered handler to resolve") } result, ok := payload.(ResolveAddressResult) if !ok || len(result.Addresses) != 1 || result.Addresses[0] != "10.10.10.10" { t.Fatalf("unexpected registered handler payload: %#v", payload) } } func TestServiceRegisterActionsUsesContextAwareRegistrarWhenAvailable(t *testing.T) { type ctxKey string registrar := &actionContextRecorder{} service := NewService(ServiceOptions{ ChainAliasDiscoverer: func(ctx context.Context) ([]string, error) { value, ok := ctx.Value(ctxKey("discover-token")).(string) if !ok { t.Fatal("expected discover context to be preserved") } if value != "preserved" { t.Fatalf("unexpected discover context value: %q", value) } return []string{"gateway.charon.lthn"}, nil }, HSDClient: NewHSDClient(HSDClientOptions{ URL: "http://127.0.0.1:1", }), }) service.RegisterActions(registrar) invoke, ok := registrar.contextHandlers[ActionDiscover] if !ok { t.Fatal("expected context-aware registrar to receive discover action") } ctx := context.WithValue(context.Background(), ctxKey("discover-token"), "preserved") payload, succeeded, err := invoke(ctx, nil) if err == nil { t.Fatal("expected discover action to fail without an HSD endpoint") } if succeeded { t.Fatal("expected discover action to report failure") } if payload != nil { t.Fatalf("expected no payload on failure, got %#v", payload) } if !strings.Contains(err.Error(), "connection refused") && !strings.Contains(err.Error(), "hsd rpc request failed") { t.Fatalf("expected discover action to propagate the HSD client error, got %v", err) } } func TestNewServiceWithRegistrarBuildsAndRegistersInOneStep(t *testing.T) { registrar := &actionRecorder{} service := NewServiceWithRegistrar(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, }, }, registrar) if service == nil { t.Fatal("expected service to be built") } if len(registrar.names) != len(service.ActionNames()) { t.Fatalf("expected helper to register all actions, got %#v", registrar.names) } payload, ok, err := registrar.handlers[ActionResolve](map[string]any{"name": "gateway.charon.lthn"}) if err != nil { t.Fatalf("unexpected registered handler error: %v", err) } if !ok { t.Fatal("expected registered handler to resolve") } result, ok := payload.(ResolveAddressResult) if !ok || len(result.Addresses) != 1 || result.Addresses[0] != "10.10.10.10" { t.Fatalf("unexpected registered handler payload: %#v", payload) } } func TestNewServiceAutoRegistersActionsWhenRegistrarIsConfigured(t *testing.T) { registrar := &actionRecorder{} service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, }, ActionRegistrar: registrar, }) if service == nil { t.Fatal("expected service to be built") } expected := service.ActionNames() if len(registrar.names) != len(expected) { t.Fatalf("expected constructor to auto-register %d actions, got %d: %#v", len(expected), len(registrar.names), registrar.names) } for index, name := range expected { if registrar.names[index] != name { t.Fatalf("unexpected auto-registered action at %d: got %q want %q", index, registrar.names[index], name) } } } func TestServiceActionDefinitionsHaveInvokers(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.10"}, }, }, }) definitions := service.ActionDefinitions() if len(definitions) == 0 { t.Fatal("expected action definitions") } for _, definition := range definitions { if definition.Name == "" { t.Fatal("expected action definition name") } if definition.Invoke == nil { t.Fatalf("expected action invoke for %s", definition.Name) } } resolveDefinition := definitions[0] if resolveDefinition.Name != ActionResolve { t.Fatalf("expected first action definition to be %s, got %s", ActionResolve, resolveDefinition.Name) } payload, ok, err := resolveDefinition.Invoke(map[string]any{ "name": "gateway.charon.lthn", }) if err != nil { t.Fatalf("unexpected action invoke error: %v", err) } if !ok { t.Fatal("expected resolve action definition to return a record") } result, ok := payload.(ResolveAddressResult) if !ok || len(result.Addresses) != 1 || result.Addresses[0] != "10.10.10.10" { t.Fatalf("unexpected resolve payload: %#v", payload) } handlePayload, handleOK, handleErr := service.HandleAction(ActionResolve, map[string]any{ "name": "gateway.charon.lthn", }) if handleErr != nil || !handleOK { t.Fatalf("unexpected handle action result: ok=%t err=%v", handleOK, handleErr) } if handleResult, ok := handlePayload.(ResolveAddressResult); !ok || len(handleResult.Addresses) != 1 || handleResult.Addresses[0] != "10.10.10.10" { t.Fatalf("unexpected handle action payload: %#v", handlePayload) } } func TestServiceHandleActionReverseHealthServeAndDiscover(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { var payload struct { Method string `json:"method"` Params []any `json:"params"` } if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { t.Fatalf("unexpected request payload: %v", err) } switch payload.Method { case "getblockchaininfo": _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "tree_root": "discover-root", }, }) case "getnameresource": _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "a": []string{"10.10.10.10"}, }, }) default: t.Fatalf("unexpected method: %s", payload.Method) } })) defer server.Close() service := NewService(ServiceOptions{ ChainAliasDiscoverer: func(_ context.Context) ([]string, error) { return []string{"gateway.charon.lthn"}, nil }, HSDClient: NewHSDClient(HSDClientOptions{ URL: server.URL, }), Records: map[string]NameRecords{ "gateway.charon.lthn": { A: []string{"10.10.10.20"}, }, }, }) reversePayload, ok, err := service.HandleAction(ActionReverse, map[string]any{ "ip": "10.10.10.20", }) if err != nil { t.Fatalf("unexpected reverse action error: %v", err) } if !ok { t.Fatal("expected reverse action to return a record") } reverse, ok := reversePayload.(ReverseLookupResult) if !ok { t.Fatalf("expected ReverseLookupResult payload, got %T", reversePayload) } if len(reverse.Names) != 1 || reverse.Names[0] != "gateway.charon.lthn" { t.Fatalf("unexpected reverse result: %#v", reverse.Names) } healthPayload, ok, err := service.HandleAction(ActionHealth, nil) if err != nil { t.Fatalf("unexpected health action error: %v", err) } if !ok { t.Fatal("expected health action payload") } health, ok := healthPayload.(HealthResult) if !ok { t.Fatalf("expected HealthResult payload, got %T", healthPayload) } if health.Status != "ready" { t.Fatalf("unexpected health payload: %#v", health) } srvPayload, ok, err := service.HandleAction(ActionServe, map[string]any{ "bind": "127.0.0.1", "port": 0, }) if err != nil { t.Fatalf("unexpected serve action error: %v", err) } if !ok { t.Fatal("expected serve action to start server") } dnsServer, ok := srvPayload.(*DNSServer) if !ok { t.Fatalf("expected DNSServer payload, got %T", srvPayload) } if dnsServer.DNSAddress() == "" { t.Fatal("expected server address from serve action") } if dnsServer.Address() != dnsServer.DNSAddress() { t.Fatalf("expected Address and DNSAddress to match, got %q and %q", dnsServer.Address(), dnsServer.DNSAddress()) } _ = dnsServer.Close() discoverPayload, ok, err := service.HandleAction(ActionDiscover, nil) if err != nil { t.Fatalf("unexpected discover action error: %v", err) } if discoverPayload == nil || !ok { t.Fatal("expected discover action payload") } if !ok { t.Fatal("expected discover action to succeed") } discoverHealth, ok := discoverPayload.(HealthResult) if !ok { t.Fatalf("expected discover action payload HealthResult, got %T", discoverPayload) } if discoverHealth.TreeRoot != "discover-root" { t.Fatalf("expected discover to refresh tree root, got %#v", discoverHealth.TreeRoot) } } func TestServiceHandleActionContextPassesThroughToDiscover(t *testing.T) { service := NewService(ServiceOptions{ ChainAliasDiscoverer: func(ctx context.Context) ([]string, error) { <-ctx.Done() return nil, ctx.Err() }, HSDClient: NewHSDClient(HSDClientOptions{ URL: "http://127.0.0.1:1", }), }) ctx, cancel := context.WithCancel(context.Background()) cancel() payload, ok, err := service.HandleActionContext(ctx, ActionDiscover, nil) if err == nil { t.Fatal("expected discover action to fail for a canceled context") } if ok { t.Fatal("expected discover action to report failure") } if payload != nil { t.Fatalf("expected no payload on context cancellation, got %#v", payload) } if !errors.Is(err, context.Canceled) { t.Fatalf("expected context cancellation error, got %v", err) } } func TestServiceDiscoverAliasesFallsBackToMainchainAliasRPCUsingHSDURL(t *testing.T) { var chainAliasCalls int32 var treeRootCalls int32 var nameResourceCalls int32 server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { var payload struct { Method string `json:"method"` Params []any `json:"params"` } if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { t.Fatalf("unexpected request payload: %v", err) } switch payload.Method { case "get_all_alias_details": atomic.AddInt32(&chainAliasCalls, 1) responseWriter.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": []any{ map[string]any{ "hns": "gateway.charon.lthn", }, }, }) case "getblockchaininfo": atomic.AddInt32(&treeRootCalls, 1) responseWriter.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "tree_root": "inferred-mainchain-root-1", }, }) case "getnameresource": atomic.AddInt32(&nameResourceCalls, 1) responseWriter.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(responseWriter).Encode(map[string]any{ "result": map[string]any{ "a": []string{"10.10.10.10"}, }, }) default: t.Fatalf("unexpected method: %s", payload.Method) } })) defer server.Close() service := NewService(ServiceOptions{ HSDURL: server.URL, Records: map[string]NameRecords{}, }) if err := service.DiscoverAliases(context.Background()); err != nil { t.Fatalf("expected discover to use inferred mainchain URL: %v", err) } record, ok := service.Resolve("gateway.charon.lthn") if !ok || len(record.A) != 1 || record.A[0] != "10.10.10.10" { t.Fatalf("expected discovered gateway A record, got %#v (ok=%t)", record, ok) } if atomic.LoadInt32(&chainAliasCalls) != 1 { t.Fatalf("expected one mainchain alias call, got %d", atomic.LoadInt32(&chainAliasCalls)) } if atomic.LoadInt32(&treeRootCalls) != 1 { t.Fatalf("expected one tree-root call, got %d", atomic.LoadInt32(&treeRootCalls)) } if atomic.LoadInt32(&nameResourceCalls) != 1 { t.Fatalf("expected one name-resource call, got %d", atomic.LoadInt32(&nameResourceCalls)) } } func TestNewServiceBuildsMainchainAliasClientByDefault(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{}, }) if service.mainchainAliasClient == nil { t.Fatal("expected default mainchain alias client when none is provided") } if got := service.mainchainAliasClient.baseURL; got != "http://127.0.0.1:14037" { t.Fatalf("expected fallback mainchain alias client URL %q, got %q", "http://127.0.0.1:14037", got) } } func TestStringActionValueTrimsWhitespaceForRequiredArgument(t *testing.T) { value, err := stringActionValue(map[string]any{ actionArgName: " gateway.charon.lthn ", }, actionArgName) if err != nil { t.Fatalf("expected trimmed string value, got error: %v", err) } if value != "gateway.charon.lthn" { t.Fatalf("expected trimmed value, got %q", value) } } func TestStringActionValueRejectsWhitespaceOnlyArgument(t *testing.T) { _, err := stringActionValue(map[string]any{ actionArgName: " ", }, actionArgName) if err == nil { t.Fatal("expected whitespace-only argument to be rejected") } } func TestIntActionValueRejectsNonIntegerFloat(t *testing.T) { _, err := intActionValue(map[string]any{ actionArgPort: 53.9, }, actionArgPort) if err == nil { t.Fatal("expected non-integer float value to be rejected") } } func TestIntActionValueAcceptsWholeFloat(t *testing.T) { value, err := intActionValue(map[string]any{ actionArgPort: float64(53), }, actionArgPort) if err != nil { t.Fatalf("expected whole float to be accepted: %v", err) } if value != 53 { t.Fatalf("expected value 53, got %d", value) } } type actionRecorder struct { names []string handlers map[string]func(map[string]any) (any, bool, error) } func (recorder *actionRecorder) RegisterAction(name string, invoke func(map[string]any) (any, bool, error)) { if recorder.handlers == nil { recorder.handlers = map[string]func(map[string]any) (any, bool, error){} } recorder.names = append(recorder.names, name) recorder.handlers[name] = invoke } type actionContextRecorder struct { names []string contextHandlers map[string]func(context.Context, map[string]any) (any, bool, error) } func (recorder *actionContextRecorder) RegisterAction(name string, invoke func(map[string]any) (any, bool, error)) { if recorder.contextHandlers == nil { recorder.contextHandlers = map[string]func(context.Context, map[string]any) (any, bool, error){} } recorder.names = append(recorder.names, name) recorder.contextHandlers[name] = func(ctx context.Context, values map[string]any) (any, bool, error) { return invoke(values) } } func (recorder *actionContextRecorder) RegisterActionContext(name string, invoke func(context.Context, map[string]any) (any, bool, error)) { if recorder.contextHandlers == nil { recorder.contextHandlers = map[string]func(context.Context, map[string]any) (any, bool, error){} } recorder.names = append(recorder.names, name) recorder.contextHandlers[name] = invoke } type actionCallerFunc func(context.Context, string, map[string]any) (any, bool, error) func (caller actionCallerFunc) CallAction(ctx context.Context, name string, values map[string]any) (any, bool, error) { return caller(ctx, name, values) }