/ core / node / p2pforge_resolver_test.go
p2pforge_resolver_test.go
  1  package node
  2  
  3  import (
  4  	"context"
  5  	"errors"
  6  	"net"
  7  	"testing"
  8  
  9  	"github.com/ipfs/kubo/config"
 10  	"github.com/stretchr/testify/assert"
 11  	"github.com/stretchr/testify/require"
 12  )
 13  
 14  // Test constants matching p2p-forge production format
 15  const (
 16  	// testPeerID is a valid peerID in CIDv1 base36 format as used by p2p-forge.
 17  	// Base36 is lowercase-only, making it safe for case-insensitive DNS.
 18  	// Corresponds to 12D3KooWDpJ7As7BWAwRMfu1VU2WCqNjvq387JEYKDBj4kx6nXTN in base58btc.
 19  	testPeerID = "k51qzi5uqu5dhnwe629wdlncpql6frppdpwnz4wtlcw816aysd5wwlk63g4wmh"
 20  
 21  	// domainSuffix is the default p2p-forge domain used in tests.
 22  	domainSuffix = config.DefaultDomainSuffix
 23  )
 24  
 25  // mockResolver implements madns.BasicResolver for testing
 26  type mockResolver struct {
 27  	txtRecords map[string][]string
 28  	ipRecords  map[string][]net.IPAddr
 29  	ipErr      error
 30  }
 31  
 32  func (m *mockResolver) LookupIPAddr(_ context.Context, hostname string) ([]net.IPAddr, error) {
 33  	if m.ipErr != nil {
 34  		return nil, m.ipErr
 35  	}
 36  	if m.ipRecords != nil {
 37  		return m.ipRecords[hostname], nil
 38  	}
 39  	return nil, nil
 40  }
 41  
 42  func (m *mockResolver) LookupTXT(_ context.Context, name string) ([]string, error) {
 43  	if m.txtRecords != nil {
 44  		return m.txtRecords[name], nil
 45  	}
 46  	return nil, nil
 47  }
 48  
 49  // newTestResolver creates a p2pForgeResolver with default suffix.
 50  func newTestResolver(t *testing.T) *p2pForgeResolver {
 51  	t.Helper()
 52  	return NewP2PForgeResolver([]string{domainSuffix}, &mockResolver{})
 53  }
 54  
 55  // assertLookupIP verifies that hostname resolves to wantIP.
 56  func assertLookupIP(t *testing.T, r *p2pForgeResolver, hostname, wantIP string) {
 57  	t.Helper()
 58  	addrs, err := r.LookupIPAddr(t.Context(), hostname)
 59  	require.NoError(t, err)
 60  	require.Len(t, addrs, 1)
 61  	assert.Equal(t, wantIP, addrs[0].IP.String())
 62  }
 63  
 64  func TestP2PForgeResolver_LookupIPAddr(t *testing.T) {
 65  	r := newTestResolver(t)
 66  
 67  	tests := []struct {
 68  		name     string
 69  		hostname string
 70  		wantIP   string
 71  	}{
 72  		// IPv4
 73  		{"ipv4/basic", "192-168-1-1." + testPeerID + "." + domainSuffix, "192.168.1.1"},
 74  		{"ipv4/zeros", "0-0-0-0." + testPeerID + "." + domainSuffix, "0.0.0.0"},
 75  		{"ipv4/max", "255-255-255-255." + testPeerID + "." + domainSuffix, "255.255.255.255"},
 76  		{"ipv4/trailing dot", "10-0-0-1." + testPeerID + "." + domainSuffix + ".", "10.0.0.1"},
 77  		{"ipv4/uppercase suffix", "192-168-1-1." + testPeerID + ".LIBP2P.DIRECT", "192.168.1.1"},
 78  		// IPv6
 79  		{"ipv6/full", "2001-db8-0-0-0-0-0-1." + testPeerID + "." + domainSuffix, "2001:db8::1"},
 80  		{"ipv6/compressed", "2001-db8--1." + testPeerID + "." + domainSuffix, "2001:db8::1"},
 81  		{"ipv6/loopback", "0--1." + testPeerID + "." + domainSuffix, "::1"},
 82  		{"ipv6/all zeros", "0--0." + testPeerID + "." + domainSuffix, "::"},
 83  	}
 84  
 85  	for _, tt := range tests {
 86  		t.Run(tt.name, func(t *testing.T) {
 87  			assertLookupIP(t, r, tt.hostname, tt.wantIP)
 88  		})
 89  	}
 90  }
 91  
 92  func TestP2PForgeResolver_LookupIPAddr_MultipleSuffixes(t *testing.T) {
 93  	r := NewP2PForgeResolver([]string{domainSuffix, "custom.example.com"}, &mockResolver{})
 94  
 95  	tests := []struct {
 96  		hostname string
 97  		wantIP   string
 98  	}{
 99  		{"192-168-1-1." + testPeerID + "." + domainSuffix, "192.168.1.1"},
100  		{"10-0-0-1." + testPeerID + ".custom.example.com", "10.0.0.1"},
101  	}
102  
103  	for _, tt := range tests {
104  		t.Run(tt.hostname, func(t *testing.T) {
105  			assertLookupIP(t, r, tt.hostname, tt.wantIP)
106  		})
107  	}
108  }
109  
110  func TestP2PForgeResolver_LookupIPAddr_FallbackToNetwork(t *testing.T) {
111  	fallbackIP := []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}
112  
113  	tests := []struct {
114  		name     string
115  		hostname string
116  	}{
117  		{"peerID only", testPeerID + "." + domainSuffix},
118  		{"invalid peerID", "192-168-1-1.invalid-peer-id." + domainSuffix},
119  		{"invalid IP encoding", "not-an-ip." + testPeerID + "." + domainSuffix},
120  		{"leading hyphen", "-192-168-1-1." + testPeerID + "." + domainSuffix},
121  		{"too many parts", "extra.192-168-1-1." + testPeerID + "." + domainSuffix},
122  		{"wrong suffix", "192-168-1-1." + testPeerID + ".example.com"},
123  	}
124  
125  	// Build fallback records from test cases
126  	ipRecords := make(map[string][]net.IPAddr, len(tests))
127  	for _, tt := range tests {
128  		ipRecords[tt.hostname] = fallbackIP
129  	}
130  	fallback := &mockResolver{ipRecords: ipRecords}
131  	r := NewP2PForgeResolver([]string{domainSuffix}, fallback)
132  
133  	for _, tt := range tests {
134  		t.Run(tt.name, func(t *testing.T) {
135  			addrs, err := r.LookupIPAddr(t.Context(), tt.hostname)
136  			require.NoError(t, err)
137  			require.Len(t, addrs, 1, "should fallback to network")
138  			assert.Equal(t, "93.184.216.34", addrs[0].IP.String())
139  		})
140  	}
141  }
142  
143  func TestP2PForgeResolver_LookupIPAddr_FallbackError(t *testing.T) {
144  	expectedErr := errors.New("network error")
145  	r := NewP2PForgeResolver([]string{domainSuffix}, &mockResolver{ipErr: expectedErr})
146  
147  	// peerID-only triggers fallback, which returns error
148  	_, err := r.LookupIPAddr(t.Context(), testPeerID+"."+domainSuffix)
149  	require.ErrorIs(t, err, expectedErr)
150  }
151  
152  func TestP2PForgeResolver_LookupTXT(t *testing.T) {
153  	t.Run("delegates to fallback for ACME DNS-01", func(t *testing.T) {
154  		acmeHost := "_acme-challenge." + testPeerID + "." + domainSuffix
155  		fallback := &mockResolver{
156  			txtRecords: map[string][]string{acmeHost: {"acme-token-value"}},
157  		}
158  		r := NewP2PForgeResolver([]string{domainSuffix}, fallback)
159  
160  		records, err := r.LookupTXT(t.Context(), acmeHost)
161  		require.NoError(t, err)
162  		assert.Equal(t, []string{"acme-token-value"}, records)
163  	})
164  
165  	t.Run("returns empty when fallback has no records", func(t *testing.T) {
166  		r := NewP2PForgeResolver([]string{domainSuffix}, &mockResolver{})
167  
168  		records, err := r.LookupTXT(t.Context(), "anything."+domainSuffix)
169  		require.NoError(t, err)
170  		assert.Empty(t, records)
171  	})
172  }