kubo/test/cli/gateway_test.go
Andrew Gillis 20d9660a64
Some checks are pending
CodeQL / codeql (push) Waiting to run
Docker Build / docker-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
chore: use go-log/v2 (#10801)
* chore: update to go-log/v2

go-log v2 has been out for quite a while now and it is time to deprecate v1.

Replace all use of go-log with go-log/v2
Makes /api/v0/log/tail useful over HTTP
Updates dependencies that have moved to go-lov/v2
Removes support for ContextWithLoggable as this is not needed for tracing-like functionality
- Replaces: PR #8765
- Closes issue #8753
- Closes issue #9245
- Closes issue #10809

Other fixes:
* update go-ipfs-cmds
* update http logs test
* fix test
* Read/send one line of log data at a time
* Update -log-level docs
2025-05-19 13:04:05 -07:00

583 lines
20 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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")
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}, 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}, 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()
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()
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()
h.WriteFile("index/index.html", "<p></p>")
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<addr>.+)\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()
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.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()
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()
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()
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")
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)
}