kubo/test/cli/autoconf/ipns_test.go
Marcin Rataj 71e883440e
Some checks are pending
CodeQL / codeql (push) Waiting to run
Docker Check / lint (push) Waiting to run
Docker Check / build (push) Waiting to run
Gateway Conformance / gateway-conformance (push) Waiting to run
Gateway Conformance / gateway-conformance-libp2p-experiment (push) Waiting to run
Go Build / go-build (push) Waiting to run
Go Check / go-check (push) Waiting to run
Go Lint / go-lint (push) Waiting to run
Go Test / go-test (push) Waiting to run
Interop / interop-prep (push) Waiting to run
Interop / helia-interop (push) Blocked by required conditions
Interop / ipfs-webui (push) Blocked by required conditions
Sharness / sharness-test (push) Waiting to run
Spell Check / spellcheck (push) Waiting to run
refactor(config): migration 17-to-18 to unify Provider/Reprovider into Provide.DHT (#10951)
* refactor: consolidate Provider/Reprovider into unified Provide config

- merge Provider and Reprovider configs into single Provide section
- add fs-repo-17-to-18 migration for config consolidation
- improve migration ergonomics with common package utilities
- convert deprecated "flat" strategy to "all" during migration
- improve Provide docs

* docs: add total_provide_count metric guidance

- document how to monitor provide success rates via prometheus metrics
- add performance comparison section to changelog
- explain how to evaluate sweep vs legacy provider effectiveness

* fix: add OpenTelemetry meter provider for metrics

- set up meter provider with Prometheus exporter in daemon
- enables metrics from external libs like go-libp2p-kad-dht
- fixes missing total_provide_count_total when SweepEnabled=true
- update docs to reflect actual metric names

---------

Co-authored-by: gammazero <11790789+gammazero@users.noreply.github.com>
Co-authored-by: guillaumemichel <guillaume@michel.id>
Co-authored-by: Daniel Norman <1992255+2color@users.noreply.github.com>
Co-authored-by: Hector Sanjuan <code@hector.link>
2025-09-18 22:17:43 +02:00

353 lines
12 KiB
Go

package autoconf
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/ipfs/boxo/autoconf"
"github.com/ipfs/kubo/test/cli/harness"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestAutoConfIPNS tests IPNS publishing with autoconf-resolved delegated publishers
func TestAutoConfIPNS(t *testing.T) {
t.Parallel()
t.Run("PublishingWithWorkingEndpoint", func(t *testing.T) {
t.Parallel()
testIPNSPublishingWithWorkingEndpoint(t)
})
t.Run("PublishingResilience", func(t *testing.T) {
t.Parallel()
testIPNSPublishingResilience(t)
})
}
// testIPNSPublishingWithWorkingEndpoint verifies that IPNS delegated publishing works
// correctly when the HTTP endpoint is functioning normally and accepts requests.
// It also verifies that the PUT payload matches what can be retrieved via routing get.
func testIPNSPublishingWithWorkingEndpoint(t *testing.T) {
// Create mock IPNS publisher that accepts requests
publisher := newMockIPNSPublisher(t)
defer publisher.close()
// Create node with delegated publisher
node := setupNodeWithAutoconf(t, publisher.server.URL, "auto")
defer node.StopDaemon()
// Wait for daemon to be ready
time.Sleep(5 * time.Second)
// Get node's peer ID
idResult := node.RunIPFS("id", "-f", "<id>")
require.Equal(t, 0, idResult.ExitCode())
peerID := strings.TrimSpace(idResult.Stdout.String())
// Get peer ID in base36 format (used for IPNS keys)
idBase36Result := node.RunIPFS("id", "--peerid-base", "base36", "-f", "<id>")
require.Equal(t, 0, idBase36Result.ExitCode())
peerIDBase36 := strings.TrimSpace(idBase36Result.Stdout.String())
// Verify autoconf resolved "auto" correctly
result := node.RunIPFS("config", "Ipns.DelegatedPublishers", "--expand-auto")
var resolvedPublishers []string
err := json.Unmarshal([]byte(result.Stdout.String()), &resolvedPublishers)
require.NoError(t, err)
expectedURL := publisher.server.URL + "/routing/v1/ipns"
assert.Contains(t, resolvedPublishers, expectedURL, "AutoConf should resolve 'auto' to mock publisher")
// Test publishing with --allow-delegated
testCID := "bafkqablimvwgy3y"
result = node.RunIPFS("name", "publish", "--allow-delegated", "/ipfs/"+testCID)
require.Equal(t, 0, result.ExitCode(), "Publishing should succeed")
assert.Contains(t, result.Stdout.String(), "Published to")
// Wait for async HTTP request to delegated publisher
time.Sleep(2 * time.Second)
// Verify HTTP PUT was made to delegated publisher
publishedKeys := publisher.getPublishedKeys()
assert.NotEmpty(t, publishedKeys, "HTTP PUT request should have been made to delegated publisher")
// Get the PUT payload that was sent to the delegated publisher
putPayload := publisher.getRecordPayload(peerIDBase36)
require.NotNil(t, putPayload, "Should have captured PUT payload")
require.Greater(t, len(putPayload), 0, "PUT payload should not be empty")
// Retrieve the IPNS record using routing get
getResult := node.RunIPFS("routing", "get", "/ipns/"+peerID)
require.Equal(t, 0, getResult.ExitCode(), "Should be able to retrieve IPNS record")
getPayload := getResult.Stdout.Bytes()
// Compare the payloads
assert.Equal(t, putPayload, getPayload,
"PUT payload sent to delegated publisher should match what routing get returns")
// Also verify the record points to the expected content
assert.Contains(t, getResult.Stdout.String(), testCID,
"Retrieved IPNS record should reference the published CID")
// Use ipfs name inspect to verify the IPNS record's value matches the published CID
// First write the routing get result to a file for inspection
node.WriteBytes("ipns-record", getPayload)
inspectResult := node.RunIPFS("name", "inspect", "ipns-record")
require.Equal(t, 0, inspectResult.ExitCode(), "Should be able to inspect IPNS record")
// The inspect output should show the path we published
inspectOutput := inspectResult.Stdout.String()
assert.Contains(t, inspectOutput, "/ipfs/"+testCID,
"IPNS record value should match the published path")
// Also verify it's a valid record with proper fields
assert.Contains(t, inspectOutput, "Value:", "Should have Value field")
assert.Contains(t, inspectOutput, "Validity:", "Should have Validity field")
assert.Contains(t, inspectOutput, "Sequence:", "Should have Sequence field")
t.Log("Verified: PUT payload to delegated publisher matches routing get result and name inspect confirms correct path")
}
// testIPNSPublishingResilience verifies that IPNS publishing is resilient by design.
// Publishing succeeds as long as local storage works, even when all delegated endpoints fail.
// This test documents the intentional resilient behavior, not bugs.
func testIPNSPublishingResilience(t *testing.T) {
testCases := []struct {
name string
routingType string // "auto" or "delegated"
description string
}{
{
name: "AutoRouting",
routingType: "auto",
description: "auto mode uses DHT + HTTP, tolerates HTTP failures",
},
{
name: "DelegatedRouting",
routingType: "delegated",
description: "delegated mode uses HTTP only, tolerates HTTP failures",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create publisher that always fails
publisher := newMockIPNSPublisher(t)
defer publisher.close()
publisher.responseFunc = func(peerID string, record []byte) int {
return http.StatusInternalServerError
}
// Create node with failing endpoint
node := setupNodeWithAutoconf(t, publisher.server.URL, tc.routingType)
defer node.StopDaemon()
// Test different publishing modes - all should succeed due to resilient design
testCID := "/ipfs/bafkqablimvwgy3y"
// Normal publishing (should succeed despite endpoint failures)
result := node.RunIPFS("name", "publish", testCID)
assert.Equal(t, 0, result.ExitCode(),
"%s: Normal publishing should succeed (local storage works)", tc.description)
// Publishing with --allow-offline (local only, no network)
result = node.RunIPFS("name", "publish", "--allow-offline", testCID)
assert.Equal(t, 0, result.ExitCode(),
"--allow-offline should succeed (local only)")
// Publishing with --allow-delegated (if using auto routing)
if tc.routingType == "auto" {
result = node.RunIPFS("name", "publish", "--allow-delegated", testCID)
assert.Equal(t, 0, result.ExitCode(),
"--allow-delegated should succeed (no DHT required)")
}
t.Logf("%s: All publishing modes succeeded despite endpoint failures (resilient design)", tc.name)
})
}
}
// ============================================================================
// Helper Functions
// ============================================================================
// setupNodeWithAutoconf creates an IPFS node with autoconf-configured delegated publishers
func setupNodeWithAutoconf(t *testing.T, publisherURL string, routingType string) *harness.Node {
// Create autoconf server with the publisher endpoint
autoconfData := createAutoconfJSON(publisherURL)
autoconfServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, autoconfData)
}))
t.Cleanup(func() { autoconfServer.Close() })
// Create and configure node
h := harness.NewT(t)
node := h.NewNode().Init("--profile=test")
// Configure autoconf
node.SetIPFSConfig("AutoConf.URL", autoconfServer.URL)
node.SetIPFSConfig("AutoConf.Enabled", true)
node.SetIPFSConfig("Ipns.DelegatedPublishers", []string{"auto"})
node.SetIPFSConfig("Routing.Type", routingType)
// Additional config for delegated routing mode
if routingType == "delegated" {
node.SetIPFSConfig("Provide.Enabled", false)
node.SetIPFSConfig("Provide.DHT.Interval", "0s")
}
// Add bootstrap peers for connectivity
node.SetIPFSConfig("Bootstrap", autoconf.FallbackBootstrapPeers)
// Start daemon
node.StartDaemon()
return node
}
// createAutoconfJSON generates autoconf configuration with a delegated IPNS publisher
func createAutoconfJSON(publisherURL string) string {
// Use bootstrap peers from autoconf fallbacks for consistency
bootstrapPeers, _ := json.Marshal(autoconf.FallbackBootstrapPeers)
return fmt.Sprintf(`{
"AutoConfVersion": 2025072302,
"AutoConfSchema": 1,
"AutoConfTTL": 86400,
"SystemRegistry": {
"TestSystem": {
"Description": "Test system for IPNS publishing",
"NativeConfig": {
"Bootstrap": %s
}
}
},
"DNSResolvers": {},
"DelegatedEndpoints": {
"%s": {
"Systems": ["TestSystem"],
"Read": ["/routing/v1/ipns"],
"Write": ["/routing/v1/ipns"]
}
}
}`, string(bootstrapPeers), publisherURL)
}
// ============================================================================
// Mock IPNS Publisher
// ============================================================================
// mockIPNSPublisher implements a simple IPNS publishing HTTP API server
type mockIPNSPublisher struct {
t *testing.T
server *httptest.Server
mu sync.Mutex
publishedKeys map[string]string // peerID -> published CID
recordPayloads map[string][]byte // peerID -> actual HTTP PUT record payload
responseFunc func(peerID string, record []byte) int // returns HTTP status code
}
func newMockIPNSPublisher(t *testing.T) *mockIPNSPublisher {
m := &mockIPNSPublisher{
t: t,
publishedKeys: make(map[string]string),
recordPayloads: make(map[string][]byte),
}
// Default response function accepts all publishes
m.responseFunc = func(peerID string, record []byte) int {
return http.StatusOK
}
mux := http.NewServeMux()
mux.HandleFunc("/routing/v1/ipns/", m.handleIPNS)
m.server = httptest.NewServer(mux)
return m
}
func (m *mockIPNSPublisher) handleIPNS(w http.ResponseWriter, r *http.Request) {
m.mu.Lock()
defer m.mu.Unlock()
// Extract peer ID from path
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 5 {
http.Error(w, "invalid path", http.StatusBadRequest)
return
}
peerID := parts[4]
if r.Method == "PUT" {
// Handle IPNS record publication
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}
// Get response status from response function
status := m.responseFunc(peerID, body)
if status == http.StatusOK {
if len(body) > 0 {
// Store the actual record payload
m.recordPayloads[peerID] = make([]byte, len(body))
copy(m.recordPayloads[peerID], body)
}
// Mark as published
m.publishedKeys[peerID] = fmt.Sprintf("published-%d", time.Now().Unix())
}
w.WriteHeader(status)
if status != http.StatusOK {
fmt.Fprint(w, `{"error": "publish failed"}`)
}
} else if r.Method == "GET" {
// Handle IPNS record retrieval
if record, exists := m.publishedKeys[peerID]; exists {
w.Header().Set("Content-Type", "application/vnd.ipfs.ipns-record")
fmt.Fprint(w, record)
} else {
http.Error(w, "record not found", http.StatusNotFound)
}
} else {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (m *mockIPNSPublisher) getPublishedKeys() map[string]string {
m.mu.Lock()
defer m.mu.Unlock()
result := make(map[string]string)
for k, v := range m.publishedKeys {
result[k] = v
}
return result
}
func (m *mockIPNSPublisher) getRecordPayload(peerID string) []byte {
m.mu.Lock()
defer m.mu.Unlock()
if payload, exists := m.recordPayloads[peerID]; exists {
result := make([]byte, len(payload))
copy(result, payload)
return result
}
return nil
}
func (m *mockIPNSPublisher) close() {
m.server.Close()
}