| 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 | } |