Files
tasks/tests/xdc/nexus_state_replication_test.go
Roey BermanandRoey Berman f29676e3e7 Enable Nexus by default (#7021)
## What changed?

- Change the `system.enableNexus` dynamic config default to `true`.
- Remove test overrides for that dynamic config.
- Return false for Nexus enabled in `GetSystemInfo` if the HTTP server
is disabled.
2024-12-20 06:27:13 -08:00

570 lines
21 KiB
Go

// The MIT License
//
// Copyright (c) 2024 Temporal Technologies Inc. All rights reserved.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
package xdc
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"slices"
"sync/atomic"
"testing"
"time"
"github.com/nexus-rpc/sdk-go/nexus"
"github.com/pborman/uuid"
"github.com/stretchr/testify/suite"
commandpb "go.temporal.io/api/command/v1"
commonpb "go.temporal.io/api/common/v1"
enumspb "go.temporal.io/api/enums/v1"
historypb "go.temporal.io/api/history/v1"
nexuspb "go.temporal.io/api/nexus/v1"
"go.temporal.io/api/operatorservice/v1"
taskqueuepb "go.temporal.io/api/taskqueue/v1"
"go.temporal.io/api/workflow/v1"
"go.temporal.io/api/workflowservice/v1"
sdkclient "go.temporal.io/sdk/client"
"go.temporal.io/server/common/dynamicconfig"
commonnexus "go.temporal.io/server/common/nexus"
"go.temporal.io/server/common/nexus/nexustest"
"go.temporal.io/server/common/testing/testvars"
"go.temporal.io/server/components/callbacks"
"go.temporal.io/server/components/nexusoperations"
"go.temporal.io/server/tests/testcore"
)
type NexusStateReplicationSuite struct {
xdcBaseSuite
}
func TestNexusStateReplicationTestSuite(t *testing.T) {
t.Parallel()
suite.Run(t, new(NexusStateReplicationSuite))
}
func (s *NexusStateReplicationSuite) SetupSuite() {
s.dynamicConfigOverrides = map[dynamicconfig.Key]any{
// Make sure we don't hit the rate limiter in tests
dynamicconfig.FrontendGlobalNamespaceNamespaceReplicationInducingAPIsRPS.Key(): 1000,
dynamicconfig.RefreshNexusEndpointsMinWait.Key(): 1 * time.Millisecond,
callbacks.AllowedAddresses.Key(): []any{map[string]any{"Pattern": "*", "AllowInsecure": true}},
}
s.setupSuite([]string{"nexus_state_replication_active", "nexus_state_replication_standby"})
}
func (s *NexusStateReplicationSuite) SetupTest() {
s.setupTest()
}
func (s *NexusStateReplicationSuite) TearDownSuite() {
s.tearDownSuite()
}
// TestNexusOperationEventsReplicated tests that nexus related operation events and state updates are replicated
// across clusters and that the operation machinary functions as expected when failover happens.
// General outline:
// 1. Start two clusters, cluster1 set to active, cluster2 set to standby.
// 2. Start a workflow on cluster1.
// 3. Schedule a nexus operation on cluster1. An error is injected to fail the operation on start.
// 4. Check the operation scheduled event and state changes are replicated to cluster2.
// 5. Failover to cluster2 and unblock the operation start by removing the injected error.
// 6. Wait for the operation to be started on cluster2.
// 7. Fail back to cluster1.
// 8. Complete the operation via callback on cluster1.
// 9. Check that the operation completion triggers a workflow task when we poll on cluster1.
// 10. Complete the workflow.
func (s *NexusStateReplicationSuite) TestNexusOperationEventsReplicated() {
var callbackToken string
var publicCallbackUrl string
failStartOperation := atomic.Bool{}
failStartOperation.Store(true)
h := nexustest.Handler{
OnStartOperation: func(ctx context.Context, service, operation string, input *nexus.LazyValue, options nexus.StartOperationOptions) (nexus.HandlerStartOperationResult[any], error) {
if failStartOperation.Load() {
return nil, errors.New("injected error for failing nexus start operation")
}
callbackToken = options.CallbackHeader.Get(commonnexus.CallbackTokenHeader)
publicCallbackUrl = options.CallbackURL
return &nexus.HandlerStartOperationResultAsync{OperationID: "test"}, nil
},
}
listenAddr := nexustest.AllocListenAddress(s.T())
nexustest.NewNexusServer(s.T(), listenAddr, h)
ctx := testcore.NewContext()
ns := s.createGlobalNamespace()
endpointName := testcore.RandomizedNexusEndpoint(s.T().Name())
// Set URL template after httpAPAddress is set, see commonnexus.RouteCompletionCallback.
for _, cluster := range []*testcore.TestCluster{s.cluster1, s.cluster2} {
cluster.OverrideDynamicConfig(
s.T(),
nexusoperations.CallbackURLTemplate,
// We'll send the callback to cluster1, when we fail back to it.
"http://"+s.cluster1.Host().FrontendHTTPAddress()+"/namespaces/{{.NamespaceName}}/nexus/callback")
}
// Nexus endpoints registry isn't replicated yet, manually create the same endpoint in both clusters.
for _, cl := range []operatorservice.OperatorServiceClient{s.cluster1.OperatorClient(), s.cluster2.OperatorClient()} {
_, err := cl.CreateNexusEndpoint(ctx, &operatorservice.CreateNexusEndpointRequest{
Spec: &nexuspb.EndpointSpec{
Name: endpointName,
Target: &nexuspb.EndpointTarget{
Variant: &nexuspb.EndpointTarget_External_{
External: &nexuspb.EndpointTarget_External{
Url: "http://" + listenAddr,
},
},
},
},
})
s.NoError(err)
}
sdkClient1, err := sdkclient.Dial(sdkclient.Options{
HostPort: s.cluster1.Host().FrontendGRPCAddress(),
Namespace: ns,
})
s.NoError(err)
sdkClient2, err := sdkclient.Dial(sdkclient.Options{
HostPort: s.cluster2.Host().FrontendGRPCAddress(),
Namespace: ns,
})
s.NoError(err)
run, err := sdkClient1.ExecuteWorkflow(ctx, sdkclient.StartWorkflowOptions{
TaskQueue: "tq",
ID: "test",
}, "workflow")
s.NoError(err)
pollRes := s.pollWorkflowTask(ctx, s.cluster1.FrontendClient(), ns)
_, err = s.cluster1.FrontendClient().RespondWorkflowTaskCompleted(ctx, &workflowservice.RespondWorkflowTaskCompletedRequest{
TaskToken: pollRes.TaskToken,
Commands: []*commandpb.Command{
{
CommandType: enumspb.COMMAND_TYPE_SCHEDULE_NEXUS_OPERATION,
Attributes: &commandpb.Command_ScheduleNexusOperationCommandAttributes{
ScheduleNexusOperationCommandAttributes: &commandpb.ScheduleNexusOperationCommandAttributes{
Endpoint: endpointName,
Service: "service",
Operation: "operation",
},
},
},
},
})
s.NoError(err)
// Ensure the scheduled event is replicated.
s.waitEvent(ctx, sdkClient2, run, enumspb.EVENT_TYPE_NEXUS_OPERATION_SCHEDULED)
// Check operation state changes are replicated to cluster2.
s.waitOperationRetry(ctx, sdkClient2, run)
// Now failover, and let cluster2 be the active.
s.failover(ns, s.clusterNames[1], 2, s.cluster1.FrontendClient())
s.NoError(sdkClient2.SignalWorkflow(ctx, run.GetID(), run.GetRunID(), "dont-care", nil))
pollRes = s.pollWorkflowTask(ctx, s.cluster2.FrontendClient(), ns)
// Unblock nexus operation start after failover.
failStartOperation.Store(false)
s.Eventually(func() bool {
describeRes, err := sdkClient2.DescribeWorkflowExecution(ctx, run.GetID(), run.GetRunID())
s.NoError(err)
s.Equal(1, len(describeRes.PendingNexusOperations))
op := describeRes.PendingNexusOperations[0]
return op.State == enumspb.PENDING_NEXUS_OPERATION_STATE_STARTED
}, time.Second*20, time.Millisecond*100)
_, err = s.cluster2.FrontendClient().RespondWorkflowTaskCompleted(ctx, &workflowservice.RespondWorkflowTaskCompletedRequest{
TaskToken: pollRes.TaskToken,
Commands: []*commandpb.Command{}, // No need to generate other commands, this "workflow" just waits for the operation to complete.
})
s.NoError(err)
// Poll in cluster2 (previously standby) and verify the operation was started.
pollRes = s.pollWorkflowTask(ctx, s.cluster2.FrontendClient(), ns)
_, err = s.cluster2.FrontendClient().RespondWorkflowTaskCompleted(ctx, &workflowservice.RespondWorkflowTaskCompletedRequest{
TaskToken: pollRes.TaskToken,
Commands: []*commandpb.Command{}, // No need to generate other commands, this "workflow" just waits for the operation to complete.
})
s.NoError(err)
idx := slices.IndexFunc(pollRes.History.Events, func(ev *historypb.HistoryEvent) bool {
return ev.EventType == enumspb.EVENT_TYPE_NEXUS_OPERATION_STARTED
})
s.Greater(idx, -1)
// Ensure the started event is replicated back to cluster1.
s.waitEvent(ctx, sdkClient1, run, enumspb.EVENT_TYPE_NEXUS_OPERATION_STARTED)
// Fail back to cluster1.
s.failover(ns, s.clusterNames[0], 11, s.cluster2.FrontendClient())
s.completeNexusOperation(ctx, "result", publicCallbackUrl, callbackToken)
// Verify completion triggers a new workflow task and that the workflow completes.
pollRes = s.pollWorkflowTask(ctx, s.cluster1.FrontendClient(), ns)
_, err = s.cluster1.FrontendClient().RespondWorkflowTaskCompleted(ctx, &workflowservice.RespondWorkflowTaskCompletedRequest{
TaskToken: pollRes.TaskToken,
Commands: []*commandpb.Command{
{
CommandType: enumspb.COMMAND_TYPE_COMPLETE_WORKFLOW_EXECUTION,
Attributes: &commandpb.Command_CompleteWorkflowExecutionCommandAttributes{
CompleteWorkflowExecutionCommandAttributes: &commandpb.CompleteWorkflowExecutionCommandAttributes{},
},
},
},
})
s.NoError(err)
idx = slices.IndexFunc(pollRes.History.Events, func(ev *historypb.HistoryEvent) bool {
return ev.EventType == enumspb.EVENT_TYPE_NEXUS_OPERATION_COMPLETED
})
s.Greater(idx, -1)
s.NoError(run.Get(ctx, nil))
}
func (s *NexusStateReplicationSuite) TestNexusOperationCancelationReplicated() {
var callbackToken string
var publicCallbackUrl string
h := nexustest.Handler{
OnStartOperation: func(ctx context.Context, service, operation string, input *nexus.LazyValue, options nexus.StartOperationOptions) (nexus.HandlerStartOperationResult[any], error) {
callbackToken = options.CallbackHeader.Get(commonnexus.CallbackTokenHeader)
publicCallbackUrl = options.CallbackURL
return &nexus.HandlerStartOperationResultAsync{OperationID: "test"}, nil
},
OnCancelOperation: func(ctx context.Context, service, operation, operationID string, options nexus.CancelOperationOptions) error {
return nil
},
}
listenAddr := nexustest.AllocListenAddress(s.T())
nexustest.NewNexusServer(s.T(), listenAddr, h)
ctx := testcore.NewContext()
ns := s.createGlobalNamespace()
endpointName := testcore.RandomizedNexusEndpoint(s.T().Name())
// Set URL template after httpAPAddress is set, see commonnexus.RouteCompletionCallback.
// We don't actually want to deliver callbacks in this test, the config just has to be set for nexus task execution.
for _, cluster := range []*testcore.TestCluster{s.cluster1, s.cluster2} {
cluster.OverrideDynamicConfig(
s.T(),
nexusoperations.CallbackURLTemplate,
"http://"+s.cluster1.Host().FrontendHTTPAddress()+"/namespaces/{{.NamespaceName}}/nexus/callback")
}
// Nexus endpoints registry isn't replicated yet, manually create the same endpoint in both clusters.
for _, cl := range []operatorservice.OperatorServiceClient{s.cluster1.OperatorClient(), s.cluster2.OperatorClient()} {
_, err := cl.CreateNexusEndpoint(ctx, &operatorservice.CreateNexusEndpointRequest{
Spec: &nexuspb.EndpointSpec{
Name: endpointName,
Target: &nexuspb.EndpointTarget{
Variant: &nexuspb.EndpointTarget_External_{
External: &nexuspb.EndpointTarget_External{
Url: "http://" + listenAddr,
},
},
},
},
})
s.NoError(err)
}
sdkClient1, err := sdkclient.Dial(sdkclient.Options{
HostPort: s.cluster1.Host().FrontendGRPCAddress(),
Namespace: ns,
})
s.NoError(err)
sdkClient2, err := sdkclient.Dial(sdkclient.Options{
HostPort: s.cluster2.Host().FrontendGRPCAddress(),
Namespace: ns,
})
s.NoError(err)
run, err := sdkClient1.ExecuteWorkflow(ctx, sdkclient.StartWorkflowOptions{
TaskQueue: "tq",
ID: "test",
}, "workflow")
s.NoError(err)
pollRes := s.pollWorkflowTask(ctx, s.cluster1.FrontendClient(), ns)
_, err = s.cluster1.FrontendClient().RespondWorkflowTaskCompleted(ctx, &workflowservice.RespondWorkflowTaskCompletedRequest{
TaskToken: pollRes.TaskToken,
Commands: []*commandpb.Command{
{
CommandType: enumspb.COMMAND_TYPE_SCHEDULE_NEXUS_OPERATION,
Attributes: &commandpb.Command_ScheduleNexusOperationCommandAttributes{
ScheduleNexusOperationCommandAttributes: &commandpb.ScheduleNexusOperationCommandAttributes{
Endpoint: endpointName,
Service: "service",
Operation: "operation",
},
},
},
},
})
s.NoError(err)
// Ensure the scheduled event is replicated.
scheduledEventID := s.waitEvent(ctx, sdkClient2, run, enumspb.EVENT_TYPE_NEXUS_OPERATION_SCHEDULED)
// Verify the operation started and it is replicated in the passive cluster
s.waitEvent(ctx, sdkClient2, run, enumspb.EVENT_TYPE_NEXUS_OPERATION_STARTED)
pollRes = s.pollWorkflowTask(ctx, s.cluster1.FrontendClient(), ns)
_, err = s.cluster1.FrontendClient().RespondWorkflowTaskCompleted(ctx, &workflowservice.RespondWorkflowTaskCompletedRequest{
TaskToken: pollRes.TaskToken,
Commands: []*commandpb.Command{
{
CommandType: enumspb.COMMAND_TYPE_REQUEST_CANCEL_NEXUS_OPERATION,
Attributes: &commandpb.Command_RequestCancelNexusOperationCommandAttributes{
RequestCancelNexusOperationCommandAttributes: &commandpb.RequestCancelNexusOperationCommandAttributes{
ScheduledEventId: scheduledEventID,
},
},
},
},
})
s.NoError(err)
s.Eventually(func() bool {
describeRes, err := sdkClient1.DescribeWorkflowExecution(ctx, run.GetID(), run.GetRunID())
s.NoError(err)
s.Equal(1, len(describeRes.PendingNexusOperations))
op := describeRes.PendingNexusOperations[0]
fmt.Println(op.CancellationInfo)
s.NotNil(op.CancellationInfo)
return op.CancellationInfo.State == enumspb.NEXUS_OPERATION_CANCELLATION_STATE_SUCCEEDED
}, time.Second*20, time.Millisecond*100)
s.cancelNexusOperation(ctx, publicCallbackUrl, callbackToken)
// Verify the canceled event is replicated and the passive cluster catches up.
s.waitEvent(ctx, sdkClient2, run, enumspb.EVENT_TYPE_NEXUS_OPERATION_CANCELED)
pollRes = s.pollWorkflowTask(ctx, s.cluster1.FrontendClient(), ns)
_, err = s.cluster1.FrontendClient().RespondWorkflowTaskCompleted(ctx, &workflowservice.RespondWorkflowTaskCompletedRequest{
TaskToken: pollRes.TaskToken,
Commands: []*commandpb.Command{
{
CommandType: enumspb.COMMAND_TYPE_COMPLETE_WORKFLOW_EXECUTION,
Attributes: &commandpb.Command_CompleteWorkflowExecutionCommandAttributes{
CompleteWorkflowExecutionCommandAttributes: &commandpb.CompleteWorkflowExecutionCommandAttributes{},
},
},
},
})
s.NoError(err)
s.NoError(run.Get(ctx, nil))
}
// TestNexusCallbackReplicated tests that nexus callback and state updates are replicated
// across clusters and callback can work as expected when failover happens.
// General outline:
// 1. Start two clusters, cluster1 set to active, cluster2 set to standby.
// 2. Start a workflow will callback on cluster1.
// 3. Terminate the workflow to trigger the callback. An error is injected to always fail the callback.
// 4. Check the callback state changes are replicated to cluster2.
// 5. Failover to cluster2 and unblock the callback by removing the injected error.
// 6. Wait for the callback to complete on both clusters.
func (s *NexusStateReplicationSuite) TestNexusCallbackReplicated() {
failCallback := atomic.Bool{}
failCallback.Store(true)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if failCallback.Load() {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintln(w, "Injected error to trigger callback retry")
return
}
fmt.Fprintln(w, "Callback succeeded")
}))
defer ts.Close()
ctx := testcore.NewContext()
ns := s.createGlobalNamespace()
sdkClient1, err := sdkclient.Dial(sdkclient.Options{
HostPort: s.cluster1.Host().FrontendGRPCAddress(),
Namespace: ns,
})
s.NoError(err)
sdkClient2, err := sdkclient.Dial(sdkclient.Options{
HostPort: s.cluster2.Host().FrontendGRPCAddress(),
Namespace: ns,
})
s.NoError(err)
tv := testvars.New(s.T())
startResp, err := sdkClient1.WorkflowService().StartWorkflowExecution(ctx, &workflowservice.StartWorkflowExecutionRequest{
Namespace: ns,
WorkflowId: tv.WorkflowID(),
WorkflowType: tv.WorkflowType(),
TaskQueue: tv.TaskQueue(),
RequestId: uuid.New(),
CompletionCallbacks: []*commonpb.Callback{
{
Variant: &commonpb.Callback_Nexus_{
Nexus: &commonpb.Callback_Nexus{
Url: ts.URL,
},
},
},
},
})
s.NoError(err)
// Terminate the workflow to trigger the callback.
err = sdkClient1.TerminateWorkflow(ctx, tv.WorkflowID(), startResp.RunId, "terminate workflow to trigger callback")
s.NoError(err)
// Check callback state changes are replicated to cluster2.
s.waitCallback(ctx, sdkClient2, &commonpb.WorkflowExecution{
WorkflowId: tv.WorkflowID(),
RunId: startResp.GetRunId(),
}, func(callback *workflow.CallbackInfo) bool {
return callback.Attempt > 2
})
// Failover to cluster2.
s.failover(ns, s.clusterNames[1], 2, s.cluster1.FrontendClient())
// Unblock callback after failover.
failCallback.Store(false)
// Check callback can complete on cluster2 after failover,
// and succeeded state will be replicated back to cluster1.
for _, sdkClient := range []sdkclient.Client{sdkClient1, sdkClient2} {
s.waitCallback(ctx, sdkClient, &commonpb.WorkflowExecution{
WorkflowId: tv.WorkflowID(),
RunId: startResp.GetRunId(),
}, func(callback *workflow.CallbackInfo) bool {
return callback.State == enumspb.CALLBACK_STATE_SUCCEEDED
})
}
}
func (s *NexusStateReplicationSuite) waitEvent(ctx context.Context, sdkClient sdkclient.Client, run sdkclient.WorkflowRun, eventType enumspb.EventType) (eventID int64) {
s.Eventually(func() bool {
history := sdkClient.GetWorkflowHistory(ctx, run.GetID(), run.GetRunID(), false, enumspb.HISTORY_EVENT_FILTER_TYPE_ALL_EVENT)
for history.HasNext() {
event, err := history.Next()
s.NoError(err)
if event.EventType == eventType {
eventID = event.EventId
return true
}
}
return false
}, time.Second*10, time.Millisecond*100)
return
}
func (s *NexusStateReplicationSuite) waitOperationRetry(
ctx context.Context,
sdkClient sdkclient.Client,
run sdkclient.WorkflowRun,
) {
s.Eventually(func() bool {
descResp, err := sdkClient.DescribeWorkflowExecution(ctx, run.GetID(), run.GetRunID())
s.NoError(err)
s.Len(descResp.GetPendingNexusOperations(), 1)
return descResp.GetPendingNexusOperations()[0].Attempt > 2
}, time.Second*10, time.Millisecond*100)
}
func (s *NexusStateReplicationSuite) pollWorkflowTask(ctx context.Context, client workflowservice.WorkflowServiceClient, ns string) *workflowservice.PollWorkflowTaskQueueResponse {
pollRes, err := client.PollWorkflowTaskQueue(ctx, &workflowservice.PollWorkflowTaskQueueRequest{
Namespace: ns,
TaskQueue: &taskqueuepb.TaskQueue{
Name: "tq",
Kind: enumspb.TASK_QUEUE_KIND_NORMAL,
},
Identity: "test",
})
s.NoError(err)
return pollRes
}
func (s *NexusStateReplicationSuite) waitCallback(
ctx context.Context,
sdkClient sdkclient.Client,
execution *commonpb.WorkflowExecution,
condition func(callback *workflow.CallbackInfo) bool,
) {
s.Eventually(func() bool {
descResp, err := sdkClient.DescribeWorkflowExecution(ctx, execution.WorkflowId, execution.RunId)
s.NoError(err)
s.Len(descResp.GetCallbacks(), 1)
return condition(descResp.GetCallbacks()[0])
}, time.Second*20, time.Millisecond*100)
}
func (s *NexusStateReplicationSuite) completeNexusOperation(ctx context.Context, result any, callbackUrl, callbackToken string) {
completion, err := nexus.NewOperationCompletionSuccessful(s.mustToPayload(result), nexus.OperationCompletionSuccessfulOptions{
Serializer: commonnexus.PayloadSerializer,
})
s.NoError(err)
req, err := nexus.NewCompletionHTTPRequest(ctx, callbackUrl, completion)
s.NoError(err)
if callbackToken != "" {
req.Header.Add(commonnexus.CallbackTokenHeader, callbackToken)
}
res, err := http.DefaultClient.Do(req)
s.NoError(err)
defer res.Body.Close()
_, err = io.ReadAll(res.Body)
s.NoError(err)
s.Equal(http.StatusOK, res.StatusCode)
}
func (s *NexusStateReplicationSuite) cancelNexusOperation(ctx context.Context, callbackUrl, callbackToken string) {
completion, err := nexus.NewOperationCompletionUnsuccessful(
nexus.NewCanceledOperationError(errors.New("operation canceled")),
nexus.OperationCompletionUnsuccessfulOptions{},
)
s.NoError(err)
req, err := nexus.NewCompletionHTTPRequest(ctx, callbackUrl, completion)
s.NoError(err)
if callbackToken != "" {
req.Header.Add(commonnexus.CallbackTokenHeader, callbackToken)
}
res, err := http.DefaultClient.Do(req)
s.NoError(err)
defer res.Body.Close()
_, err = io.ReadAll(res.Body)
s.NoError(err)
s.Equal(http.StatusOK, res.StatusCode)
}