| 1 | package autoconf |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "io" |
| 7 | "maps" |
| 8 | "net/http" |
| 9 | "net/http/httptest" |
| 10 | "strings" |
| 11 | "sync" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "github.com/ipfs/boxo/autoconf" |
| 16 | "github.com/ipfs/kubo/test/cli/harness" |
| 17 | "github.com/stretchr/testify/assert" |
| 18 | "github.com/stretchr/testify/require" |
| 19 | ) |
| 20 | |
| 21 | // TestAutoConfIPNS tests IPNS publishing with autoconf-resolved delegated publishers |
| 22 | func TestAutoConfIPNS(t *testing.T) { |
| 23 | t.Parallel() |
| 24 | |
| 25 | t.Run("PublishingWithWorkingEndpoint", func(t *testing.T) { |
| 26 | t.Parallel() |
| 27 | testIPNSPublishingWithWorkingEndpoint(t) |
| 28 | }) |
| 29 | |
| 30 | t.Run("PublishingResilience", func(t *testing.T) { |
| 31 | t.Parallel() |
| 32 | testIPNSPublishingResilience(t) |
| 33 | }) |
| 34 | } |
| 35 | |
| 36 | // testIPNSPublishingWithWorkingEndpoint verifies that IPNS delegated publishing works |
| 37 | // correctly when the HTTP endpoint is functioning normally and accepts requests. |
| 38 | // It also verifies that the PUT payload matches what can be retrieved via routing get. |
| 39 | func testIPNSPublishingWithWorkingEndpoint(t *testing.T) { |
| 40 | // Create mock IPNS publisher that accepts requests |
| 41 | publisher := newMockIPNSPublisher(t) |
| 42 | defer publisher.close() |
| 43 | |
| 44 | // Create node with delegated publisher |
| 45 | node := setupNodeWithAutoconf(t, publisher.server.URL, "auto") |
| 46 | defer node.StopDaemon() |
| 47 | |
| 48 | // Wait for daemon to be ready |
| 49 | time.Sleep(5 * time.Second) |
| 50 | |
| 51 | // Get node's peer ID |
| 52 | idResult := node.RunIPFS("id", "-f", "<id>") |
| 53 | require.Equal(t, 0, idResult.ExitCode()) |
| 54 | peerID := strings.TrimSpace(idResult.Stdout.String()) |
| 55 | |
| 56 | // Get peer ID in base36 format (used for IPNS keys) |
| 57 | idBase36Result := node.RunIPFS("id", "--peerid-base", "base36", "-f", "<id>") |
| 58 | require.Equal(t, 0, idBase36Result.ExitCode()) |
| 59 | peerIDBase36 := strings.TrimSpace(idBase36Result.Stdout.String()) |
| 60 | |
| 61 | // Verify autoconf resolved "auto" correctly |
| 62 | result := node.RunIPFS("config", "Ipns.DelegatedPublishers", "--expand-auto") |
| 63 | var resolvedPublishers []string |
| 64 | err := json.Unmarshal([]byte(result.Stdout.String()), &resolvedPublishers) |
| 65 | require.NoError(t, err) |
| 66 | expectedURL := publisher.server.URL + "/routing/v1/ipns" |
| 67 | assert.Contains(t, resolvedPublishers, expectedURL, "AutoConf should resolve 'auto' to mock publisher") |
| 68 | |
| 69 | // Test publishing with --allow-delegated |
| 70 | testCID := "bafkqablimvwgy3y" |
| 71 | result = node.RunIPFS("name", "publish", "--allow-delegated", "/ipfs/"+testCID) |
| 72 | require.Equal(t, 0, result.ExitCode(), "Publishing should succeed") |
| 73 | assert.Contains(t, result.Stdout.String(), "Published to") |
| 74 | |
| 75 | // Wait for async HTTP request to delegated publisher |
| 76 | time.Sleep(2 * time.Second) |
| 77 | |
| 78 | // Verify HTTP PUT was made to delegated publisher |
| 79 | publishedKeys := publisher.getPublishedKeys() |
| 80 | assert.NotEmpty(t, publishedKeys, "HTTP PUT request should have been made to delegated publisher") |
| 81 | |
| 82 | // Get the PUT payload that was sent to the delegated publisher |
| 83 | putPayload := publisher.getRecordPayload(peerIDBase36) |
| 84 | require.NotNil(t, putPayload, "Should have captured PUT payload") |
| 85 | require.Greater(t, len(putPayload), 0, "PUT payload should not be empty") |
| 86 | |
| 87 | // Retrieve the IPNS record using routing get |
| 88 | getResult := node.RunIPFS("routing", "get", "/ipns/"+peerID) |
| 89 | require.Equal(t, 0, getResult.ExitCode(), "Should be able to retrieve IPNS record") |
| 90 | getPayload := getResult.Stdout.Bytes() |
| 91 | |
| 92 | // Compare the payloads |
| 93 | assert.Equal(t, putPayload, getPayload, |
| 94 | "PUT payload sent to delegated publisher should match what routing get returns") |
| 95 | |
| 96 | // Also verify the record points to the expected content |
| 97 | assert.Contains(t, getResult.Stdout.String(), testCID, |
| 98 | "Retrieved IPNS record should reference the published CID") |
| 99 | |
| 100 | // Use ipfs name inspect to verify the IPNS record's value matches the published CID |
| 101 | // First write the routing get result to a file for inspection |
| 102 | node.WriteBytes("ipns-record", getPayload) |
| 103 | inspectResult := node.RunIPFS("name", "inspect", "ipns-record") |
| 104 | require.Equal(t, 0, inspectResult.ExitCode(), "Should be able to inspect IPNS record") |
| 105 | |
| 106 | // The inspect output should show the path we published |
| 107 | inspectOutput := inspectResult.Stdout.String() |
| 108 | assert.Contains(t, inspectOutput, "/ipfs/"+testCID, |
| 109 | "IPNS record value should match the published path") |
| 110 | |
| 111 | // Also verify it's a valid record with proper fields |
| 112 | assert.Contains(t, inspectOutput, "Value:", "Should have Value field") |
| 113 | assert.Contains(t, inspectOutput, "Validity:", "Should have Validity field") |
| 114 | assert.Contains(t, inspectOutput, "Sequence:", "Should have Sequence field") |
| 115 | |
| 116 | t.Log("Verified: PUT payload to delegated publisher matches routing get result and name inspect confirms correct path") |
| 117 | } |
| 118 | |
| 119 | // testIPNSPublishingResilience verifies that IPNS publishing is resilient by design. |
| 120 | // Publishing succeeds as long as local storage works, even when all delegated endpoints fail. |
| 121 | // This test documents the intentional resilient behavior, not bugs. |
| 122 | func testIPNSPublishingResilience(t *testing.T) { |
| 123 | testCases := []struct { |
| 124 | name string |
| 125 | routingType string // "auto" or "delegated" |
| 126 | description string |
| 127 | }{ |
| 128 | { |
| 129 | name: "AutoRouting", |
| 130 | routingType: "auto", |
| 131 | description: "auto mode uses DHT + HTTP, tolerates HTTP failures", |
| 132 | }, |
| 133 | { |
| 134 | name: "DelegatedRouting", |
| 135 | routingType: "delegated", |
| 136 | description: "delegated mode uses HTTP only, tolerates HTTP failures", |
| 137 | }, |
| 138 | } |
| 139 | |
| 140 | for _, tc := range testCases { |
| 141 | t.Run(tc.name, func(t *testing.T) { |
| 142 | // Create publisher that always fails |
| 143 | publisher := newMockIPNSPublisher(t) |
| 144 | defer publisher.close() |
| 145 | publisher.responseFunc = func(peerID string, record []byte) int { |
| 146 | return http.StatusInternalServerError |
| 147 | } |
| 148 | |
| 149 | // Create node with failing endpoint |
| 150 | node := setupNodeWithAutoconf(t, publisher.server.URL, tc.routingType) |
| 151 | defer node.StopDaemon() |
| 152 | |
| 153 | // Test different publishing modes - all should succeed due to resilient design |
| 154 | testCID := "/ipfs/bafkqablimvwgy3y" |
| 155 | |
| 156 | // Normal publishing (should succeed despite endpoint failures) |
| 157 | result := node.RunIPFS("name", "publish", testCID) |
| 158 | assert.Equal(t, 0, result.ExitCode(), |
| 159 | "%s: Normal publishing should succeed (local storage works)", tc.description) |
| 160 | |
| 161 | // Publishing with --allow-offline (local only, no network) |
| 162 | result = node.RunIPFS("name", "publish", "--allow-offline", testCID) |
| 163 | assert.Equal(t, 0, result.ExitCode(), |
| 164 | "--allow-offline should succeed (local only)") |
| 165 | |
| 166 | // Publishing with --allow-delegated (if using auto routing) |
| 167 | if tc.routingType == "auto" { |
| 168 | result = node.RunIPFS("name", "publish", "--allow-delegated", testCID) |
| 169 | assert.Equal(t, 0, result.ExitCode(), |
| 170 | "--allow-delegated should succeed (no DHT required)") |
| 171 | } |
| 172 | |
| 173 | t.Logf("%s: All publishing modes succeeded despite endpoint failures (resilient design)", tc.name) |
| 174 | }) |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | // ============================================================================ |
| 179 | // Helper Functions |
| 180 | // ============================================================================ |
| 181 | |
| 182 | // setupNodeWithAutoconf creates an IPFS node with autoconf-configured delegated publishers |
| 183 | func setupNodeWithAutoconf(t *testing.T, publisherURL string, routingType string) *harness.Node { |
| 184 | // Create autoconf server with the publisher endpoint |
| 185 | autoconfData := createAutoconfJSON(publisherURL) |
| 186 | autoconfServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 187 | w.Header().Set("Content-Type", "application/json") |
| 188 | fmt.Fprint(w, autoconfData) |
| 189 | })) |
| 190 | t.Cleanup(func() { autoconfServer.Close() }) |
| 191 | |
| 192 | // Create and configure node |
| 193 | h := harness.NewT(t) |
| 194 | node := h.NewNode().Init("--profile=test") |
| 195 | |
| 196 | // Configure autoconf |
| 197 | node.SetIPFSConfig("AutoConf.URL", autoconfServer.URL) |
| 198 | node.SetIPFSConfig("AutoConf.Enabled", true) |
| 199 | node.SetIPFSConfig("Ipns.DelegatedPublishers", []string{"auto"}) |
| 200 | node.SetIPFSConfig("Routing.Type", routingType) |
| 201 | |
| 202 | // Additional config for delegated routing mode |
| 203 | if routingType == "delegated" { |
| 204 | node.SetIPFSConfig("Provide.Enabled", false) |
| 205 | node.SetIPFSConfig("Provide.DHT.Interval", "0s") |
| 206 | } |
| 207 | |
| 208 | // Add bootstrap peers for connectivity |
| 209 | node.SetIPFSConfig("Bootstrap", autoconf.FallbackBootstrapPeers) |
| 210 | |
| 211 | // Start daemon |
| 212 | node.StartDaemon() |
| 213 | |
| 214 | return node |
| 215 | } |
| 216 | |
| 217 | // createAutoconfJSON generates autoconf configuration with a delegated IPNS publisher |
| 218 | func createAutoconfJSON(publisherURL string) string { |
| 219 | // Use bootstrap peers from autoconf fallbacks for consistency |
| 220 | bootstrapPeers, _ := json.Marshal(autoconf.FallbackBootstrapPeers) |
| 221 | |
| 222 | return fmt.Sprintf(`{ |
| 223 | "AutoConfVersion": 2025072302, |
| 224 | "AutoConfSchema": 1, |
| 225 | "AutoConfTTL": 86400, |
| 226 | "SystemRegistry": { |
| 227 | "TestSystem": { |
| 228 | "Description": "Test system for IPNS publishing", |
| 229 | "NativeConfig": { |
| 230 | "Bootstrap": %s |
| 231 | } |
| 232 | } |
| 233 | }, |
| 234 | "DNSResolvers": {}, |
| 235 | "DelegatedEndpoints": { |
| 236 | "%s": { |
| 237 | "Systems": ["TestSystem"], |
| 238 | "Read": ["/routing/v1/ipns"], |
| 239 | "Write": ["/routing/v1/ipns"] |
| 240 | } |
| 241 | } |
| 242 | }`, string(bootstrapPeers), publisherURL) |
| 243 | } |
| 244 | |
| 245 | // ============================================================================ |
| 246 | // Mock IPNS Publisher |
| 247 | // ============================================================================ |
| 248 | |
| 249 | // mockIPNSPublisher implements a simple IPNS publishing HTTP API server |
| 250 | type mockIPNSPublisher struct { |
| 251 | t *testing.T |
| 252 | server *httptest.Server |
| 253 | mu sync.Mutex |
| 254 | publishedKeys map[string]string // peerID -> published CID |
| 255 | recordPayloads map[string][]byte // peerID -> actual HTTP PUT record payload |
| 256 | responseFunc func(peerID string, record []byte) int // returns HTTP status code |
| 257 | } |
| 258 | |
| 259 | func newMockIPNSPublisher(t *testing.T) *mockIPNSPublisher { |
| 260 | m := &mockIPNSPublisher{ |
| 261 | t: t, |
| 262 | publishedKeys: make(map[string]string), |
| 263 | recordPayloads: make(map[string][]byte), |
| 264 | } |
| 265 | |
| 266 | // Default response function accepts all publishes |
| 267 | m.responseFunc = func(peerID string, record []byte) int { |
| 268 | return http.StatusOK |
| 269 | } |
| 270 | |
| 271 | mux := http.NewServeMux() |
| 272 | mux.HandleFunc("/routing/v1/ipns/", m.handleIPNS) |
| 273 | |
| 274 | m.server = httptest.NewServer(mux) |
| 275 | return m |
| 276 | } |
| 277 | |
| 278 | func (m *mockIPNSPublisher) handleIPNS(w http.ResponseWriter, r *http.Request) { |
| 279 | m.mu.Lock() |
| 280 | defer m.mu.Unlock() |
| 281 | |
| 282 | // Extract peer ID from path |
| 283 | parts := strings.Split(r.URL.Path, "/") |
| 284 | if len(parts) < 5 { |
| 285 | http.Error(w, "invalid path", http.StatusBadRequest) |
| 286 | return |
| 287 | } |
| 288 | |
| 289 | peerID := parts[4] |
| 290 | |
| 291 | if r.Method == "PUT" { |
| 292 | // Handle IPNS record publication |
| 293 | body, err := io.ReadAll(r.Body) |
| 294 | if err != nil { |
| 295 | http.Error(w, "failed to read body", http.StatusBadRequest) |
| 296 | return |
| 297 | } |
| 298 | |
| 299 | // Get response status from response function |
| 300 | status := m.responseFunc(peerID, body) |
| 301 | |
| 302 | if status == http.StatusOK { |
| 303 | if len(body) > 0 { |
| 304 | // Store the actual record payload |
| 305 | m.recordPayloads[peerID] = make([]byte, len(body)) |
| 306 | copy(m.recordPayloads[peerID], body) |
| 307 | } |
| 308 | |
| 309 | // Mark as published |
| 310 | m.publishedKeys[peerID] = fmt.Sprintf("published-%d", time.Now().Unix()) |
| 311 | } |
| 312 | |
| 313 | w.WriteHeader(status) |
| 314 | if status != http.StatusOK { |
| 315 | fmt.Fprint(w, `{"error": "publish failed"}`) |
| 316 | } |
| 317 | } else if r.Method == "GET" { |
| 318 | // Handle IPNS record retrieval |
| 319 | if record, exists := m.publishedKeys[peerID]; exists { |
| 320 | w.Header().Set("Content-Type", "application/vnd.ipfs.ipns-record") |
| 321 | fmt.Fprint(w, record) |
| 322 | } else { |
| 323 | http.Error(w, "record not found", http.StatusNotFound) |
| 324 | } |
| 325 | } else { |
| 326 | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) |
| 327 | } |
| 328 | } |
| 329 | |
| 330 | func (m *mockIPNSPublisher) getPublishedKeys() map[string]string { |
| 331 | m.mu.Lock() |
| 332 | defer m.mu.Unlock() |
| 333 | result := make(map[string]string) |
| 334 | maps.Copy(result, m.publishedKeys) |
| 335 | return result |
| 336 | } |
| 337 | |
| 338 | func (m *mockIPNSPublisher) getRecordPayload(peerID string) []byte { |
| 339 | m.mu.Lock() |
| 340 | defer m.mu.Unlock() |
| 341 | if payload, exists := m.recordPayloads[peerID]; exists { |
| 342 | result := make([]byte, len(payload)) |
| 343 | copy(result, payload) |
| 344 | return result |
| 345 | } |
| 346 | return nil |
| 347 | } |
| 348 | |
| 349 | func (m *mockIPNSPublisher) close() { |
| 350 | m.server.Close() |
| 351 | } |