mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-23 14:30:46 +02:00
* s3: keep '\' a literal object-key character when AllowBackslashInKey is set NormalizeObjectKey rewrites every S3 object key: '\' becomes '/' and duplicate slashes collapse. On AWS S3 both are ordinary key characters, so keys that are distinct there alias one object here — writing probe/back/slash.txt silently overwrites probe/back\slash.txt. The filer only treats '/' as a path separator, so a verbatim '\' stays inside one entry name. Gate the backslash rewrite and the matching '\' handling in IsValidObjectKey behind a process-wide mode the S3 server sets from the new S3ApiServerOption. Duplicate-slash collapsing stays: an entry name cannot contain an empty segment. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * command: add -s3.allowBackslashInKey flag for AWS-compatible key handling Registers the flag on all four S3 entry points (s3, server -s3, filer -s3, mini) and passes it through S3Options into S3ApiServerOption. Default off, so existing normalization is unchanged. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: correct IsValidObjectKey/NormalizeObjectKey docs under allowBackslashInKey Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: normalize DeleteObjects body keys the same as path keys DeleteObjects keys arrive in the XML body, so they bypassed the normalization applied to route-var keys. The lock, the authorization checks, the table-bucket layout check and the filer delete itself all addressed the raw key while the conditional check resolved the normalized one, so a key spelled with '\' or '//' could be reported deleted while the stored entry survived. Echo the client-sent key in the result while operating on the normalized form. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: report a normalized-empty DeleteObjects key as a delete no-op A multi-delete key of only slashes normalizes to the empty string. Left to reach the filer delete, the empty name resolves to the bucket entry itself, so an unauthorized caller could remove a bucket through the body-key path. Such a key can never name a stored entry, so like a missing key the delete is an idempotent no-op reported before any permission check or filer call. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: drop unused AllowBackslashInObjectKey getter * s3: answer conditional delete of a normalized-empty key with NoSuchKey * s3: normalize the probed key in authorizeAnonymousBucketList The masked-miss probe read the raw mux var, so a spelling like folder// or folder\ was authorized against a key that can never exist while the request itself targets the normalized folder/. Probe the normalized key so the answer matches what the read path authorizes. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: cover anonymous authz of a literal-backslash key under allowBackslashInKey A public grant on the slash key must not authorize an anonymous read of the distinct backslash object when the flag keeps '\' literal; grant the backslash key's own ACL and it reads back. Default mode still folds the spelling onto the covered slash key. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: publish the key mode only after the S3 server is constructed A failed NewS3ApiServerWithStore could previously flip the process-wide key mode out from under an already-running gateway. * s3: normalize the copy-source key before validation and authorization pathToBucketObjectAndVersion returned the raw header spelling, so an equivalent spelling (a//b, a\b) was authorized and compared against the normalized destination while resolveCopySourceEntry looked up the normalized key — a deny on the canonical key could be bypassed and self-copy detection missed. Normalize once at parse so the CopyObject and UploadPartCopy paths agree. --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
323 lines
16 KiB
Go
323 lines
16 KiB
Go
package s3api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/remote_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/remote_storage"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
// A cache fill may return newer metadata without committing downloaded chunks.
|
|
// Keep lookup snapshots separate so the handler must authorize the RPC response.
|
|
type anonymousReadCacheFiler struct {
|
|
*anonymousReadFiler
|
|
cached *filer_pb.Entry
|
|
}
|
|
|
|
func (f *anonymousReadCacheFiler) CacheRemoteObjectToLocalCluster(context.Context, *filer_pb.CacheRemoteObjectToLocalClusterRequest) (*filer_pb.CacheRemoteObjectToLocalClusterResponse, error) {
|
|
return &filer_pb.CacheRemoteObjectToLocalClusterResponse{Entry: proto.Clone(f.cached).(*filer_pb.Entry)}, nil
|
|
}
|
|
|
|
// anonymousReadDirectoryFiler provides the empty version history left after a marker delete.
|
|
type anonymousReadDirectoryFiler struct {
|
|
*anonymousReadFiler
|
|
}
|
|
|
|
// ListEntries reports an empty history so recovery cannot mistake the parent for a version.
|
|
func (f *anonymousReadDirectoryFiler) ListEntries(*filer_pb.ListEntriesRequest, filer_pb.SeaweedFiler_ListEntriesServer) error {
|
|
return nil
|
|
}
|
|
|
|
// DeleteEntry removes an exhausted version history without triggering missing-RPC retries.
|
|
func (f *anonymousReadDirectoryFiler) DeleteEntry(_ context.Context, req *filer_pb.DeleteEntryRequest) (*filer_pb.DeleteEntryResponse, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
delete(f.entries, path.Join(req.Directory, req.Name))
|
|
return &filer_pb.DeleteEntryResponse{}, nil
|
|
}
|
|
|
|
func TestAnonymousObjectACLCacheMetadata(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name, change string
|
|
chunks, versioned, ranged, part bool
|
|
want int
|
|
}{
|
|
{name: "uncached ACL revoked", change: "acl", want: 403},
|
|
{name: "uncached policy tag changed", change: "tag", want: 403},
|
|
{name: "uncached policy tag removed", change: "missing-tag", want: 403},
|
|
{name: "uncached delete marker", change: "delete", want: 403},
|
|
{name: "cached ACL revoked", change: "acl", chunks: true, want: 403},
|
|
{name: "cached policy tag changed", change: "tag", chunks: true, want: 403},
|
|
{name: "versioned cached denial clears metadata", change: "acl", chunks: true, versioned: true, want: 403},
|
|
{name: "versioned uncached denial clears metadata", change: "tag", versioned: true, want: 403},
|
|
{name: "part denial clears metadata", change: "acl", chunks: true, part: true, want: 403},
|
|
{name: "uncached public origin read", want: 200},
|
|
{name: "uncached public origin range", ranged: true, want: 206},
|
|
{name: "uncached public origin part", part: true, want: 206},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
client := &fakeStreamRemoteClient{data: []byte("hello world")}
|
|
oldMaker, existed := remote_storage.RemoteStorageClientMakers["anonymous-cache-test"]
|
|
remote_storage.RemoteStorageClientMakers["anonymous-cache-test"] = &fakeStreamRemoteMaker{client: client}
|
|
t.Cleanup(func() {
|
|
if existed {
|
|
remote_storage.RemoteStorageClientMakers["anonymous-cache-test"] = oldMaker
|
|
} else {
|
|
delete(remote_storage.RemoteStorageClientMakers, "anonymous-cache-test")
|
|
}
|
|
})
|
|
entry := anonymousReadEntry(anonymousReadGrant("READ"))
|
|
entry.Content = nil
|
|
entry.RemoteEntry = &filer_pb.RemoteEntry{RemoteSize: 11}
|
|
entry.Extended[s3_constants.AmzObjectTaggingPrefix+"classification"] = []byte("public")
|
|
entry.Extended[s3_constants.ExtObjectLockModeKey] = []byte("GOVERNANCE")
|
|
entry.Extended[s3_constants.ExtRetentionUntilDateKey] = []byte("1900000000")
|
|
entry.Extended[s3_constants.ExtLegalHoldKey] = []byte("ON")
|
|
if tc.part {
|
|
entry.Extended[s3_constants.SeaweedFSMultipartPartsCount] = []byte("1")
|
|
boundaries, err := json.Marshal([]PartBoundaryInfo{{PartNumber: 1, StartOffset: 0, EndOffset: 11}})
|
|
require.NoError(t, err)
|
|
entry.Extended[s3_constants.SeaweedFSMultipartPartBoundaries] = boundaries
|
|
}
|
|
cached := proto.Clone(entry).(*filer_pb.Entry)
|
|
if tc.chunks {
|
|
cached.Chunks = []*filer_pb.FileChunk{{FileId: "1,abc", Size: 11}}
|
|
}
|
|
switch tc.change {
|
|
case "acl":
|
|
cached.Extended[s3_constants.ExtAmzAclKey] = []byte(`[]`)
|
|
case "tag":
|
|
cached.Extended[s3_constants.AmzObjectTaggingPrefix+"classification"] = []byte("private")
|
|
case "missing-tag":
|
|
delete(cached.Extended, s3_constants.AmzObjectTaggingPrefix+"classification")
|
|
case "delete":
|
|
cached.Extended[s3_constants.ExtDeleteMarkerKey] = []byte("true")
|
|
}
|
|
f := &anonymousReadCacheFiler{anonymousReadFiler: &anonymousReadFiler{entries: map[string]*filer_pb.Entry{"/buckets/b/o": entry}}, cached: cached}
|
|
// Reuse one cache slot while differing configurations refresh the fake client,
|
|
// including when a single subtest is repeated. The fake maker ignores the endpoint.
|
|
remoteName := "anonymous-cache-origin"
|
|
mapping, err := proto.Marshal(&remote_pb.RemoteStorageMapping{Mappings: map[string]*remote_pb.RemoteStorageLocation{"/buckets/b": {Name: remoteName, Bucket: "origin", Path: "/"}}})
|
|
require.NoError(t, err)
|
|
conf, err := proto.Marshal(&remote_pb.RemoteConf{Name: remoteName, Type: "anonymous-cache-test", S3Endpoint: fmt.Sprintf("https://origin-%p.invalid", client)})
|
|
require.NoError(t, err)
|
|
f.entries["/etc/remote/mount.mapping"] = &filer_pb.Entry{Content: mapping}
|
|
f.entries["/etc/remote/"+remoteName+".conf"] = &filer_pb.Entry{Content: conf}
|
|
s3a := newPutTestServer(t, startFakeFiler(t, f))
|
|
s3a.iam = &IdentityAccessManagement{isAuthEnabled: true}
|
|
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
|
|
config := &BucketConfig{Name: "b", Ownership: s3_constants.OwnershipObjectWriter}
|
|
s3a.bucketConfigCache.Set("b", config)
|
|
s3a.policyEngine = NewBucketPolicyEngine()
|
|
require.NoError(t, s3a.policyEngine.engine.SetBucketPolicy("b", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":"*","Action":"s3:GetObject*","Resource":"arn:aws:s3:::b/*","Condition":{"StringNotEquals":{"s3:ExistingObjectTag/classification":"public"}}}]}`))
|
|
query := ""
|
|
if tc.part {
|
|
query = "partNumber=1"
|
|
}
|
|
if tc.versioned {
|
|
config.Versioning = "Enabled"
|
|
entry.Extended[s3_constants.ExtVersionIdKey] = []byte("v1")
|
|
cached.Extended[s3_constants.ExtVersionIdKey] = []byte("v1")
|
|
f.entries["/buckets/b/o.versions/v_v1"] = entry
|
|
query = "versionId=v1"
|
|
}
|
|
headers := map[string]string{}
|
|
if tc.ranged {
|
|
headers["Range"] = "bytes=2-5"
|
|
}
|
|
rr := serveAnonymousRead(s3a, http.MethodGet, "o", query, headers)
|
|
require.Equal(t, tc.want, rr.Code, rr.Body.String())
|
|
if tc.want == 403 {
|
|
require.Nil(t, client.gotLoc, "a denied cache snapshot must never reach the origin")
|
|
require.NotContains(t, rr.Body.String(), "hello world")
|
|
for _, name := range []string{"x-amz-version-id", s3_constants.AmzObjectLockMode, s3_constants.AmzObjectLockRetainUntilDate, s3_constants.AmzObjectLockLegalHold, s3_constants.AmzMpPartsCount} {
|
|
require.Empty(t, rr.Header().Get(name), name)
|
|
}
|
|
} else {
|
|
require.NotNil(t, client.gotLoc)
|
|
if tc.ranged {
|
|
require.Equal(t, "llo ", rr.Body.String())
|
|
require.Equal(t, "bytes 2-5/11", rr.Header().Get("Content-Range"))
|
|
} else {
|
|
require.Equal(t, "hello world", rr.Body.String())
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// An explicit zero-byte directory marker is a null-version object, unlike a
|
|
// request for the bare prefix. Both versioning states must keep that distinction.
|
|
func TestAnonymousObjectACLVersionedEmptyDirectoryMarker(t *testing.T) {
|
|
for _, versioning := range []string{"Enabled", "Suspended"} {
|
|
for _, method := range []string{http.MethodGet, http.MethodHead} {
|
|
t.Run(versioning+"/"+method, func(t *testing.T) {
|
|
s3a, f := newAnonymousReadTestServer(t)
|
|
config, _ := s3a.getBucketConfig("b")
|
|
config.Versioning = versioning
|
|
marker := &filer_pb.Entry{Name: "folder", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mime: "application/octet-stream"}, Extended: map[string][]byte{s3_constants.ExtAmzAclKey: anonymousReadGrant("READ")}}
|
|
f.entries["/buckets/b/folder"] = marker
|
|
for _, query := range []string{"", "versionId=null"} {
|
|
rr := serveAnonymousRead(s3a, method, "folder/", query, nil)
|
|
require.Equal(t, 200, rr.Code, query+": "+rr.Body.String())
|
|
require.Equal(t, "null", rr.Header().Get("x-amz-version-id"))
|
|
}
|
|
rr := serveAnonymousRead(s3a, method, "folder", "", nil)
|
|
require.Equal(t, 403, rr.Code, "the bare prefix is not an object, and the miss stays masked for a non-listable bucket")
|
|
marker.Extended[s3_constants.ExtAmzAclKey] = []byte(`[]`)
|
|
rr = serveAnonymousRead(s3a, method, "folder/", "", nil)
|
|
require.Equal(t, 403, rr.Code, rr.Body.String())
|
|
require.Empty(t, rr.Header().Get("x-amz-version-id"))
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAnonymousObjectACLVersionedDirectoryIdentity distinguishes slash keys from
|
|
// implicit parents, deleted markers, and objects stored under the bare prefix.
|
|
func TestAnonymousObjectACLVersionedDirectoryIdentity(t *testing.T) {
|
|
for _, versioning := range []string{"Enabled", "Suspended"} {
|
|
for _, method := range []string{http.MethodGet, http.MethodHead} {
|
|
for _, kind := range []string{"implicit parent", "deleted marker", "regular file", "prefix object", "empty prefix object"} {
|
|
for _, route := range []struct {
|
|
name, query string
|
|
conditional, history bool
|
|
}{{name: "latest"}, {name: "null", query: "versionId=null"}, {name: "conditional latest", conditional: true}, {name: "conditional null", query: "versionId=null", conditional: true}, {name: "empty history", history: true}} {
|
|
t.Run(versioning+"/"+method+"/"+kind+"/"+route.name, func(t *testing.T) {
|
|
f := &anonymousReadDirectoryFiler{anonymousReadFiler: &anonymousReadFiler{entries: make(map[string]*filer_pb.Entry)}}
|
|
s3a := newPutTestServer(t, startFakeFiler(t, f))
|
|
s3a.iam = &IdentityAccessManagement{isAuthEnabled: true}
|
|
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
|
|
s3a.bucketConfigCache.Set("b", &BucketConfig{Name: "b", Ownership: s3_constants.OwnershipObjectWriter, Versioning: versioning})
|
|
s3a.policyEngine = NewBucketPolicyEngine()
|
|
s3a.iam.policyEngine = s3a.policyEngine
|
|
require.NoError(t, s3a.policyEngine.engine.SetBucketPolicy("b", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject*","Resource":"arn:aws:s3:::b/folder/"}]}`))
|
|
entry := &filer_pb.Entry{Name: "folder", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mtime: 1700000000}}
|
|
if kind == "deleted marker" {
|
|
// Deletion clears the marker metadata while preserving a parent with children.
|
|
entry.Extended = make(map[string][]byte)
|
|
} else if kind == "regular file" || kind == "prefix object" || kind == "empty prefix object" {
|
|
entry = anonymousReadEntry([]byte(`[]`))
|
|
entry.Name = "folder"
|
|
if kind != "regular file" {
|
|
entry.MarkPrefixObject()
|
|
}
|
|
if kind == "empty prefix object" {
|
|
entry.Content = nil
|
|
entry.Attributes.FileSize = 0
|
|
entry.Attributes.Mime = ""
|
|
}
|
|
}
|
|
f.entries["/buckets/b/folder"] = entry
|
|
f.entries["/buckets/b/folder/child"] = anonymousReadEntry([]byte(`[]`))
|
|
assertMissing := func(rr *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
require.Equal(t, http.StatusNotFound, rr.Code, rr.Body.String())
|
|
for _, header := range []string{"x-amz-version-id", "Last-Modified", "ETag"} {
|
|
require.Empty(t, rr.Header().Get(header), header)
|
|
}
|
|
require.NotContains(t, rr.Body.String(), "hello world")
|
|
}
|
|
headers := map[string]string{}
|
|
if route.conditional {
|
|
headers["If-Modified-Since"] = "Fri, 01 Jan 2100 00:00:00 GMT"
|
|
}
|
|
if route.history {
|
|
// An empty history exercises recovery instead of the no-history fast path.
|
|
f.entries["/buckets/b/folder/.versions"] = &filer_pb.Entry{IsDirectory: true, Extended: map[string][]byte{s3_constants.ExtLatestVersionIdKey: []byte("")}}
|
|
}
|
|
// Equivalent filer-path spellings must not recover a different object's data.
|
|
for _, object := range []string{"folder/", "folder//", "folder\\"} {
|
|
assertMissing(serveAnonymousRead(s3a, method, object, route.query, headers))
|
|
}
|
|
// Spellings the policy's folder/ grant does not cover are
|
|
// masked: a miss must be indistinguishable from a denied read.
|
|
for _, object := range []string{"folder//x", "folder\\x"} {
|
|
rr := serveAnonymousRead(s3a, method, object, route.query, headers)
|
|
require.Equal(t, http.StatusForbidden, rr.Code, rr.Body.String())
|
|
}
|
|
if kind == "regular file" || kind == "prefix object" || kind == "empty prefix object" {
|
|
// A slash-only policy must not authorize data belonging to the bare key.
|
|
rr := serveAnonymousRead(s3a, method, "folder", route.query, nil)
|
|
require.Equal(t, http.StatusForbidden, rr.Code, rr.Body.String())
|
|
entry.Extended[s3_constants.ExtAmzAclKey] = anonymousReadGrant("READ")
|
|
rr = serveAnonymousRead(s3a, method, "folder", route.query, nil)
|
|
require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
|
|
if method == http.MethodGet {
|
|
require.Equal(t, string(entry.Content), rr.Body.String())
|
|
}
|
|
}
|
|
// The private child remains independently protected.
|
|
rr := serveAnonymousRead(s3a, method, "folder/child", "", nil)
|
|
require.Equal(t, http.StatusForbidden, rr.Code, rr.Body.String())
|
|
if method == http.MethodGet {
|
|
require.Contains(t, rr.Body.String(), "AccessDenied")
|
|
}
|
|
// A real historical slash-key version is a file, not a regular-path marker.
|
|
version := anonymousReadEntry([]byte(`[]`))
|
|
version.Extended[s3_constants.ExtVersionIdKey] = []byte("v1")
|
|
f.entries["/buckets/b/folder/.versions/v_v1"] = version
|
|
f.entries["/buckets/b/folder/.versions"] = &filer_pb.Entry{IsDirectory: true, Extended: map[string][]byte{s3_constants.ExtLatestVersionIdKey: []byte("v1"), s3_constants.ExtLatestVersionFileNameKey: []byte("v_v1")}}
|
|
for _, versionQuery := range []string{"versionId=v1", ""} {
|
|
rr = serveAnonymousRead(s3a, method, "folder/", versionQuery, nil)
|
|
require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
|
|
require.Equal(t, "v1", rr.Header().Get("x-amz-version-id"))
|
|
if method == http.MethodGet {
|
|
require.Equal(t, "hello world", rr.Body.String())
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// A literal-backslash key is a different object than its slash spelling only
|
|
// when allowBackslashInObjectKey is set. A public grant on b/folder/ must not
|
|
// authorize an anonymous read of the separate folder\ object under that mode.
|
|
func TestAnonymousObjectACLBackslashKeyIdentity(t *testing.T) {
|
|
for _, allowBackslash := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("allowBackslashInKey=%v", allowBackslash), func(t *testing.T) {
|
|
s3a, f := newAnonymousReadTestServer(t)
|
|
s3_constants.SetAllowBackslashInObjectKey(allowBackslash)
|
|
t.Cleanup(func() { s3_constants.SetAllowBackslashInObjectKey(false) })
|
|
require.NoError(t, s3a.policyEngine.engine.SetBucketPolicy("b", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject*","Resource":"arn:aws:s3:::b/folder/"}]}`))
|
|
slash := anonymousReadEntry([]byte(`[]`))
|
|
slash.Name = "folder"
|
|
slash.MarkPrefixObject()
|
|
f.entries["/buckets/b/folder"] = slash
|
|
backslash := anonymousReadEntry([]byte(`[]`))
|
|
backslash.Name = `folder\`
|
|
f.entries[`/buckets/b/folder\`] = backslash
|
|
|
|
rr := serveAnonymousRead(s3a, http.MethodGet, "folder/", "", nil)
|
|
require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
|
|
require.Equal(t, "hello world", rr.Body.String())
|
|
|
|
rr = serveAnonymousRead(s3a, http.MethodGet, `folder\`, "", nil)
|
|
if !allowBackslash {
|
|
// Default mode folds the spelling onto the slash key the policy covers.
|
|
require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
|
|
} else {
|
|
require.Equal(t, http.StatusForbidden, rr.Code, rr.Body.String())
|
|
backslash.Extended[s3_constants.ExtAmzAclKey] = anonymousReadGrant("READ")
|
|
rr = serveAnonymousRead(s3a, http.MethodGet, `folder\`, "", nil)
|
|
require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
|
|
require.Equal(t, "hello world", rr.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|