package cli import ( "bufio" "context" "encoding/json" "fmt" "net/http" "os" "path/filepath" "regexp" "strconv" "strings" "testing" "time" "github.com/ipfs/kubo/config" "github.com/ipfs/kubo/test/cli/harness" "github.com/libp2p/go-libp2p/core/peer" "github.com/multiformats/go-multiaddr" manet "github.com/multiformats/go-multiaddr/net" "github.com/multiformats/go-multibase" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestGateway(t *testing.T) { t.Parallel() h := harness.NewT(t) node := h.NewNode().Init().StartDaemon("--offline") t.Cleanup(func() { node.StopDaemon() }) cid := node.IPFSAddStr("Hello Worlds!") peerID, err := peer.ToCid(node.PeerID()).StringOfBase(multibase.Base36) assert.NoError(t, err) client := node.GatewayClient() client.TemplateData = map[string]string{ "CID": cid, "PeerID": peerID, } t.Run("GET IPFS path succeeds", func(t *testing.T) { t.Parallel() resp := client.Get("/ipfs/{{.CID}}") assert.Equal(t, 200, resp.StatusCode) }) t.Run("GET IPFS path with explicit ?filename succeeds with proper header", func(t *testing.T) { t.Parallel() resp := client.Get("/ipfs/{{.CID}}?filename=testтест.pdf") assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, `inline; filename="test____.pdf"; filename*=UTF-8''test%D1%82%D0%B5%D1%81%D1%82.pdf`, resp.Headers.Get("Content-Disposition"), ) }) t.Run("GET IPFS path with explicit ?filename and &download=true succeeds with proper header", func(t *testing.T) { t.Parallel() resp := client.Get("/ipfs/{{.CID}}?filename=testтест.mp4&download=true") assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, `attachment; filename="test____.mp4"; filename*=UTF-8''test%D1%82%D0%B5%D1%81%D1%82.mp4`, resp.Headers.Get("Content-Disposition"), ) }) // https://github.com/ipfs/go-ipfs/issues/4025#issuecomment-342250616 t.Run("GET for Server Worker registration outside of an IPFS content root errors", func(t *testing.T) { t.Parallel() resp := client.Get("/ipfs/{{.CID}}?filename=sw.js", client.WithHeader("Service-Worker", "script")) assert.Equal(t, 400, resp.StatusCode) assert.Contains(t, resp.Body, "navigator.serviceWorker: registration is not allowed for this scope") }) t.Run("GET IPFS directory path succeeds", func(t *testing.T) { t.Parallel() client := node.GatewayClient().DisableRedirects() pageContents := "hello i am a webpage" fileContents := "12345" h.WriteFile("dir/test", fileContents) h.WriteFile("dir/dirwithindex/index.html", pageContents) cids := node.IPFS("add", "-r", "-q", filepath.Join(h.Dir, "dir")).Stdout.Lines() rootCID := cids[len(cids)-1] client.TemplateData = map[string]string{ "IndexFileCID": cids[0], "TestFileCID": cids[1], "RootCID": rootCID, } t.Run("GET IPFS the index file CID", func(t *testing.T) { t.Parallel() resp := client.Get("/ipfs/{{.IndexFileCID}}") assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, pageContents, resp.Body) }) t.Run("GET IPFS the test file CID", func(t *testing.T) { t.Parallel() resp := client.Get("/ipfs/{{.TestFileCID}}") assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, fileContents, resp.Body) }) t.Run("GET IPFS directory with index.html returns redirect to add trailing slash", func(t *testing.T) { t.Parallel() resp := client.Head("/ipfs/{{.RootCID}}/dirwithindex?query=to-remember") assert.Equal(t, 301, resp.StatusCode) assert.Equal(t, fmt.Sprintf("/ipfs/%s/dirwithindex/?query=to-remember", rootCID), resp.Headers.Get("Location"), ) }) // This enables go get to parse go-import meta tags from index.html files stored in IPFS // https://github.com/ipfs/kubo/pull/3963 t.Run("GET IPFS directory with index.html and no trailing slash returns expected output when go-get is passed", func(t *testing.T) { t.Parallel() resp := client.Get("/ipfs/{{.RootCID}}/dirwithindex?go-get=1") assert.Equal(t, pageContents, resp.Body) }) t.Run("GET IPFS directory with index.html and trailing slash returns expected output", func(t *testing.T) { t.Parallel() resp := client.Get("/ipfs/{{.RootCID}}/dirwithindex/?query=to-remember") assert.Equal(t, pageContents, resp.Body) }) t.Run("GET IPFS nonexistent file returns 404 (Not Found)", func(t *testing.T) { t.Parallel() resp := client.Get("/ipfs/{{.RootCID}}/pleaseDontAddMe") assert.Equal(t, 404, resp.StatusCode) }) t.Run("GET IPFS invalid CID returns 400 (Bad Request)", func(t *testing.T) { t.Parallel() resp := client.Get("/ipfs/QmInvalid/pleaseDontAddMe") assert.Equal(t, 400, resp.StatusCode) }) t.Run("GET IPFS inlined zero-length data object returns ok code (200)", func(t *testing.T) { t.Parallel() resp := client.Get("/ipfs/bafkqaaa") assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, "0", resp.Resp.Header.Get("Content-Length")) assert.Equal(t, "", resp.Body) }) t.Run("GET IPFS inlined zero-length data object with byte range returns ok code (200)", func(t *testing.T) { t.Parallel() resp := client.Get("/ipfs/bafkqaaa", client.WithHeader("Range", "bytes=0-1048575")) assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, "0", resp.Resp.Header.Get("Content-Length")) assert.Equal(t, "text/plain", resp.Resp.Header.Get("Content-Type")) }) t.Run("GET /ipfs/ipfs/{cid} returns redirect to the valid path", func(t *testing.T) { t.Parallel() resp := client.Get("/ipfs/ipfs/bafkqaaa?query=to-remember") assert.Equal(t, 301, resp.StatusCode) assert.Equal(t, "/ipfs/bafkqaaa?query=to-remember", resp.Resp.Header.Get("Location")) }) }) t.Run("IPNS", func(t *testing.T) { t.Parallel() node.IPFS("name", "publish", "--allow-offline", "--ttl", "42h", cid) t.Run("GET invalid IPNS root returns 500 (Internal Server Error)", func(t *testing.T) { t.Parallel() resp := client.Get("/ipns/QmInvalid/pleaseDontAddMe") assert.Equal(t, 500, resp.StatusCode) }) t.Run("GET IPNS path succeeds", func(t *testing.T) { t.Parallel() resp := client.Get("/ipns/{{.PeerID}}") assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, "Hello Worlds!", resp.Body) }) t.Run("GET IPNS path has correct Cache-Control", func(t *testing.T) { t.Parallel() resp := client.Get("/ipns/{{.PeerID}}") assert.Equal(t, 200, resp.StatusCode) cacheControl := resp.Headers.Get("Cache-Control") assert.True(t, strings.HasPrefix(cacheControl, "public, max-age=")) maxAge, err := strconv.Atoi(strings.TrimPrefix(cacheControl, "public, max-age=")) assert.NoError(t, err) assert.True(t, maxAge-151200 < 60) // MaxAge within 42h and 42h-1m }) t.Run("GET /ipfs/ipns/{peerid} returns redirect to the valid path", func(t *testing.T) { t.Parallel() resp := client.Get("/ipfs/ipns/{{.PeerID}}?query=to-remember") assert.Equal(t, 301, resp.StatusCode) assert.Equal(t, fmt.Sprintf("/ipns/%s?query=to-remember", peerID), resp.Resp.Header.Get("Location")) }) }) t.Run("GET invalid IPFS path errors", func(t *testing.T) { t.Parallel() assert.Equal(t, 400, client.Get("/ipfs/12345").StatusCode) }) t.Run("GET invalid path errors", func(t *testing.T) { t.Parallel() assert.Equal(t, 404, client.Get("/12345").StatusCode) }) // TODO: these tests that use the API URL shouldn't be part of gateway tests... t.Run("GET /webui returns 301 or 302", func(t *testing.T) { t.Parallel() resp := node.APIClient().DisableRedirects().Get("/webui") assert.Contains(t, []int{302, 301, 307, 308}, resp.StatusCode) }) t.Run("GET /webui/ returns 301 or 302", func(t *testing.T) { t.Parallel() resp := node.APIClient().DisableRedirects().Get("/webui/") assert.Contains(t, []int{302, 301, 307, 308}, resp.StatusCode) }) t.Run("GET /webui/ returns user-specified headers", func(t *testing.T) { t.Parallel() header := "Access-Control-Allow-Origin" values := []string{"http://localhost:3000", "https://webui.ipfs.io"} node := harness.NewT(t).NewNode().Init() node.UpdateConfig(func(cfg *config.Config) { cfg.API.HTTPHeaders = map[string][]string{header: values} }) node.StartDaemon() defer node.StopDaemon() resp := node.APIClient().DisableRedirects().Get("/webui/") assert.Equal(t, resp.Headers.Values(header), values) assert.Contains(t, []int{302, 301}, resp.StatusCode) }) t.Run("POST /api/v0/version succeeds", func(t *testing.T) { t.Parallel() resp := node.APIClient().Post("/api/v0/version", nil) assert.Equal(t, 200, resp.StatusCode) assert.Len(t, resp.Resp.TransferEncoding, 1) assert.Equal(t, "chunked", resp.Resp.TransferEncoding[0]) vers := struct{ Version string }{} err := json.Unmarshal([]byte(resp.Body), &vers) require.NoError(t, err) assert.NotEmpty(t, vers.Version) }) t.Run("pprof", func(t *testing.T) { t.Parallel() node := harness.NewT(t).NewNode().Init().StartDaemon() t.Cleanup(func() { node.StopDaemon() }) apiClient := node.APIClient() t.Run("mutex", func(t *testing.T) { t.Parallel() t.Run("setting the mutex fraction works (negative so it doesn't enable)", func(t *testing.T) { t.Parallel() resp := apiClient.Post("/debug/pprof-mutex/?fraction=-1", nil) assert.Equal(t, 200, resp.StatusCode) }) t.Run("mutex endpoint doesn't accept a string as an argument", func(t *testing.T) { t.Parallel() resp := apiClient.Post("/debug/pprof-mutex/?fraction=that_is_a_string", nil) assert.Equal(t, 400, resp.StatusCode) }) t.Run("mutex endpoint returns 405 on GET", func(t *testing.T) { t.Parallel() resp := apiClient.Get("/debug/pprof-mutex/?fraction=-1") assert.Equal(t, 405, resp.StatusCode) }) }) t.Run("block", func(t *testing.T) { t.Parallel() t.Run("setting the block profiler rate works (0 so it doesn't enable)", func(t *testing.T) { t.Parallel() resp := apiClient.Post("/debug/pprof-block/?rate=0", nil) assert.Equal(t, 200, resp.StatusCode) }) t.Run("block profiler endpoint doesn't accept a string as an argument", func(t *testing.T) { t.Parallel() resp := apiClient.Post("/debug/pprof-block/?rate=that_is_a_string", nil) assert.Equal(t, 400, resp.StatusCode) }) t.Run("block profiler endpoint returns 405 on GET", func(t *testing.T) { t.Parallel() resp := apiClient.Get("/debug/pprof-block/?rate=0") assert.Equal(t, 405, resp.StatusCode) }) }) }) t.Run("index content types", func(t *testing.T) { t.Parallel() h := harness.NewT(t) node := h.NewNode().Init().StartDaemon() t.Cleanup(func() { node.StopDaemon() }) h.WriteFile("index/index.html", "

") cid := node.IPFS("add", "-Q", "-r", filepath.Join(h.Dir, "index")).Stderr.Trimmed() apiClient := node.APIClient() apiClient.TemplateData = map[string]string{"CID": cid} t.Run("GET index.html has correct content type", func(t *testing.T) { t.Parallel() res := apiClient.Get("/ipfs/{{.CID}}/") assert.Equal(t, "text/html; charset=utf-8", res.Resp.Header.Get("Content-Type")) }) t.Run("HEAD index.html has no content", func(t *testing.T) { t.Parallel() res := apiClient.Head("/ipfs/{{.CID}}/") assert.Equal(t, "", res.Body) assert.Equal(t, "", res.Resp.Header.Get("Content-Length")) }) }) t.Run("raw leaves node", func(t *testing.T) { t.Parallel() contents := "This is RAW!" cid := node.IPFSAddStr(contents, "--raw-leaves") assert.Equal(t, contents, client.Get("/ipfs/"+cid).Body) }) t.Run("compact blocks", func(t *testing.T) { t.Parallel() block1 := "\x0a\x09\x08\x02\x12\x03\x66\x6f\x6f\x18\x03" block2 := "\x0a\x04\x08\x02\x18\x06\x12\x24\x0a\x22\x12\x20\xcf\x92\xfd\xef\xcd\xc3\x4c\xac\x00\x9c" + "\x8b\x05\xeb\x66\x2b\xe0\x61\x8d\xb9\xde\x55\xec\xd4\x27\x85\xe9\xec\x67\x12\xf8\xdf\x65" + "\x12\x24\x0a\x22\x12\x20\xcf\x92\xfd\xef\xcd\xc3\x4c\xac\x00\x9c\x8b\x05\xeb\x66\x2b\xe0" + "\x61\x8d\xb9\xde\x55\xec\xd4\x27\x85\xe9\xec\x67\x12\xf8\xdf\x65" node.PipeStrToIPFS(block1, "block", "put") block2CID := node.PipeStrToIPFS(block2, "block", "put", "--cid-codec=dag-pb").Stdout.Trimmed() resp := client.Get("/ipfs/" + block2CID) assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, "foofoo", resp.Body) }) t.Run("verify gateway file", func(t *testing.T) { t.Parallel() r := regexp.MustCompile(`Gateway server listening on (?P.+)\s`) matches := r.FindStringSubmatch(node.Daemon.Stdout.String()) ma, err := multiaddr.NewMultiaddr(matches[1]) require.NoError(t, err) netAddr, err := manet.ToNetAddr(ma) require.NoError(t, err) expURL := "http://" + netAddr.String() b, err := os.ReadFile(filepath.Join(node.Dir, "gateway")) require.NoError(t, err) assert.Equal(t, expURL, string(b)) }) t.Run("verify gateway file diallable while on unspecified", func(t *testing.T) { t.Parallel() node := harness.NewT(t).NewNode().Init() node.UpdateConfig(func(cfg *config.Config) { cfg.Addresses.Gateway = config.Strings{"/ip4/127.0.0.1/tcp/32563"} }) node.StartDaemon() defer node.StopDaemon() b, err := os.ReadFile(filepath.Join(node.Dir, "gateway")) require.NoError(t, err) assert.Equal(t, "http://127.0.0.1:32563", string(b)) }) t.Run("NoFetch", func(t *testing.T) { t.Parallel() nodes := harness.NewT(t).NewNodes(2).Init() node1 := nodes[0] node2 := nodes[1] node1.UpdateConfig(func(cfg *config.Config) { cfg.Gateway.NoFetch = true }) node2PeerID, err := peer.ToCid(node2.PeerID()).StringOfBase(multibase.Base36) assert.NoError(t, err) nodes.StartDaemons().Connect() t.Cleanup(func() { nodes.StopDaemons() }) t.Run("not present", func(t *testing.T) { cidFoo := node2.IPFSAddStr("foo") t.Run("not present CID from node 1", func(t *testing.T) { t.Parallel() assert.Equal(t, 404, node1.GatewayClient().Get("/ipfs/"+cidFoo).StatusCode) }) t.Run("not present IPNS Record from node 1", func(t *testing.T) { t.Parallel() assert.Equal(t, 500, node1.GatewayClient().Get("/ipns/"+node2PeerID).StatusCode) }) }) t.Run("present", func(t *testing.T) { cidBar := node1.IPFSAddStr("bar") t.Run("present CID from node 1", func(t *testing.T) { t.Parallel() assert.Equal(t, 200, node1.GatewayClient().Get("/ipfs/"+cidBar).StatusCode) }) t.Run("present IPNS Record from node 1", func(t *testing.T) { t.Parallel() node2.IPFS("name", "publish", "/ipfs/"+cidBar) assert.Equal(t, 200, node1.GatewayClient().Get("/ipns/"+node2PeerID).StatusCode) }) }) }) t.Run("DeserializedResponses", func(t *testing.T) { type testCase struct { globalValue config.Flag gatewayValue config.Flag deserializedGlobalStatusCode int deserializedGatewayStaticCode int message string } setHost := func(r *http.Request) { r.Host = "example.com" } withAccept := func(accept string) func(r *http.Request) { return func(r *http.Request) { r.Header.Set("Accept", accept) } } withHostAndAccept := func(accept string) func(r *http.Request) { return func(r *http.Request) { setHost(r) withAccept(accept)(r) } } makeTest := func(test *testCase) func(t *testing.T) { return func(t *testing.T) { t.Parallel() node := harness.NewT(t).NewNode().Init() node.UpdateConfig(func(cfg *config.Config) { cfg.Gateway.DeserializedResponses = test.globalValue cfg.Gateway.PublicGateways = map[string]*config.GatewaySpec{ "example.com": { Paths: []string{"/ipfs", "/ipns"}, DeserializedResponses: test.gatewayValue, }, } }) node.StartDaemon() defer node.StopDaemon() cidFoo := node.IPFSAddStr("foo") client := node.GatewayClient() deserializedPath := "/ipfs/" + cidFoo blockPath := deserializedPath + "?format=raw" carPath := deserializedPath + "?format=car" // Global Check (Gateway.DeserializedResponses) assert.Equal(t, http.StatusOK, client.Get(blockPath).StatusCode) assert.Equal(t, http.StatusOK, client.Get(deserializedPath, withAccept("application/vnd.ipld.raw")).StatusCode) assert.Equal(t, http.StatusOK, client.Get(carPath).StatusCode) assert.Equal(t, http.StatusOK, client.Get(deserializedPath, withAccept("application/vnd.ipld.car")).StatusCode) assert.Equal(t, test.deserializedGlobalStatusCode, client.Get(deserializedPath).StatusCode) assert.Equal(t, test.deserializedGlobalStatusCode, client.Get(deserializedPath, withAccept("application/json")).StatusCode) // Public Gateway (example.com) Check (Gateway.PublicGateways[example.com].DeserializedResponses) assert.Equal(t, http.StatusOK, client.Get(blockPath, setHost).StatusCode) assert.Equal(t, http.StatusOK, client.Get(deserializedPath, withHostAndAccept("application/vnd.ipld.raw")).StatusCode) assert.Equal(t, http.StatusOK, client.Get(carPath, setHost).StatusCode) assert.Equal(t, http.StatusOK, client.Get(deserializedPath, withHostAndAccept("application/vnd.ipld.car")).StatusCode) assert.Equal(t, test.deserializedGatewayStaticCode, client.Get(deserializedPath, setHost).StatusCode) assert.Equal(t, test.deserializedGatewayStaticCode, client.Get(deserializedPath, withHostAndAccept("application/json")).StatusCode) } } for _, test := range []*testCase{ {config.True, config.Default, http.StatusOK, http.StatusOK, "when Gateway.DeserializedResponses is globally enabled, leaving implicit default for Gateway.PublicGateways[example.com] should inherit the global setting (enabled)"}, {config.False, config.Default, http.StatusNotAcceptable, http.StatusNotAcceptable, "when Gateway.DeserializedResponses is globally disabled, leaving implicit default on Gateway.PublicGateways[example.com] should inherit the global setting (disabled)"}, {config.False, config.True, http.StatusNotAcceptable, http.StatusOK, "when Gateway.DeserializedResponses is globally disabled, explicitly enabling on Gateway.PublicGateways[example.com] should override global (enabled)"}, {config.True, config.False, http.StatusOK, http.StatusNotAcceptable, "when Gateway.DeserializedResponses is globally enabled, explicitly disabling on Gateway.PublicGateways[example.com] should override global (disabled)"}, } { t.Run(test.message, makeTest(test)) } }) t.Run("DisableHTMLErrors", func(t *testing.T) { t.Parallel() t.Run("Returns HTML error without DisableHTMLErrors, Accept contains text/html", func(t *testing.T) { t.Parallel() node := harness.NewT(t).NewNode().Init() node.StartDaemon() defer node.StopDaemon() client := node.GatewayClient() res := client.Get("/ipfs/invalid-thing", func(r *http.Request) { r.Header.Set("Accept", "text/html") }) assert.NotEqual(t, http.StatusOK, res.StatusCode) assert.Contains(t, res.Resp.Header.Get("Content-Type"), "text/html") }) t.Run("Does not return HTML error with DisableHTMLErrors enabled, and Accept contains text/html", func(t *testing.T) { t.Parallel() node := harness.NewT(t).NewNode().Init() node.UpdateConfig(func(cfg *config.Config) { cfg.Gateway.DisableHTMLErrors = config.True }) node.StartDaemon() defer node.StopDaemon() client := node.GatewayClient() res := client.Get("/ipfs/invalid-thing", func(r *http.Request) { r.Header.Set("Accept", "text/html") }) assert.NotEqual(t, http.StatusOK, res.StatusCode) assert.NotContains(t, res.Resp.Header.Get("Content-Type"), "text/html") }) }) } // TestLogs tests that GET /logs returns log messages. This test is separate // because it requires setting the server's log level to "info" which may // change the output expected by other tests. func TestLogs(t *testing.T) { h := harness.NewT(t) t.Setenv("GOLOG_LOG_LEVEL", "info") node := h.NewNode().Init().StartDaemon("--offline") defer node.StopDaemon() cid := node.IPFSAddStr("Hello Worlds!") peerID, err := peer.ToCid(node.PeerID()).StringOfBase(multibase.Base36) assert.NoError(t, err) client := node.GatewayClient() client.TemplateData = map[string]string{ "CID": cid, "PeerID": peerID, } apiClient := node.APIClient() reqURL := apiClient.BuildURL("/logs") ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) require.NoError(t, err) resp, err := apiClient.Client.Do(req) require.NoError(t, err) defer resp.Body.Close() var found bool scanner := bufio.NewScanner(resp.Body) for scanner.Scan() { if strings.Contains(scanner.Text(), "log API client connected") { found = true break } } assert.True(t, found) }