mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-01 12:16:07 +00:00
* s3api: add Snowflake s3compat API integration tests Run the upstream snowflakedb/snowflake-s3compat-api-test-suite against a local SeaweedFS server in CI. test/s3/snowflake/run.sh starts weed server with S3 (-s3.autoCreateBucket=false so missing-bucket PUTs return NoSuchBucket), prepares the fixtures the suite needs (versioned bucket, deny-all-policy bucket, >1000-object prefix), clones the suite, patches it to path-style addressing, and runs mvn -Dtest=S3CompatApiTest. The suite also exposed that GetBucketLocation returned 404 NoSuchBucket for a malformed bucket name; validate the name first and return 400 InvalidBucketName like AWS. * test: harden snowflake s3compat runner per review - Pin the upstream suite to a tested commit (SUITE_REV) instead of the moving default branch - Bind the test server to loopback only - Require the AccessDenied error code when verifying the denied bucket - Fix README so go install runs in a subshell - checkout with persist-credentials: false - Make the concurrency group unique per PR, and widen path filters to the storage/operation/wdclient/cluster/pb packages the S3 stack uses * test: advertise loopback ip for snowflake test server -ip.bind 127.0.0.1 alone left the volume server advertising the host's primary address, so chunk uploads were refused. Also set -ip 127.0.0.1 and disable the Iceberg/Lance listeners so the harness is loopback-only and does not collide with other local services.
311 lines
10 KiB
Go
311 lines
10 KiB
Go
package s3api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go/service/s3"
|
|
"github.com/gorilla/mux"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
)
|
|
|
|
func newMiscTestServer(t *testing.T, bucket string) *S3ApiServer {
|
|
t.Helper()
|
|
s3a := &S3ApiServer{
|
|
iam: &IdentityAccessManagement{isAuthEnabled: true},
|
|
bucketConfigCache: NewBucketConfigCache(time.Minute),
|
|
}
|
|
s3a.bucketConfigCache.Set(bucket, &BucketConfig{Name: bucket})
|
|
return s3a
|
|
}
|
|
|
|
func newBucketRequest(method, bucket, query, body string) *http.Request {
|
|
req := httptest.NewRequest(method, "/"+bucket+"?"+query, strings.NewReader(body))
|
|
req = mux.SetURLVars(req, map[string]string{"bucket": bucket})
|
|
return req
|
|
}
|
|
|
|
func TestHasExplicitBucketACL(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
headers map[string]string
|
|
want bool
|
|
}{
|
|
{name: "none", headers: nil, want: false},
|
|
{name: "private is default", headers: map[string]string{s3_constants.AmzCannedAcl: "private"}, want: false},
|
|
{name: "canned public-read", headers: map[string]string{s3_constants.AmzCannedAcl: "public-read"}, want: true},
|
|
{name: "canned case-insensitive private", headers: map[string]string{s3_constants.AmzCannedAcl: "PRIVATE"}, want: false},
|
|
{name: "grant read", headers: map[string]string{s3_constants.AmzAclRead: `id="x"`}, want: true},
|
|
{name: "grant full control", headers: map[string]string{s3_constants.AmzAclFullControl: `id="x"`}, want: true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := newBucketRequest(http.MethodPut, "b", "", "")
|
|
for k, v := range tc.headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
if got := hasExplicitBucketACL(req); got != tc.want {
|
|
t.Fatalf("hasExplicitBucketACL = %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetBucketPolicyStatusIsPublic(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
raw string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "public allow star",
|
|
raw: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::b/*"}]}`,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "deny is not public",
|
|
raw: `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::b/*"}]}`,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "condition makes it non-public",
|
|
raw: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::b/*","Condition":{"IpAddress":{"aws:SourceIp":"10.0.0.0/8"}}}]}`,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "specific principal is not public",
|
|
raw: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"arn:aws:iam::1:user/a","Action":"s3:GetObject","Resource":"arn:aws:s3:::b/*"}]}`,
|
|
want: false,
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var doc policy_engine.PolicyDocument
|
|
if err := json.Unmarshal([]byte(tc.raw), &doc); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if got := isPolicyPublic(&doc); got != tc.want {
|
|
t.Fatalf("isPolicyPublic = %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPutBucketRequestPaymentBucketOwner(t *testing.T) {
|
|
s3a := newMiscTestServer(t, "b")
|
|
body := `<RequestPaymentConfiguration><Payer>BucketOwner</Payer></RequestPaymentConfiguration>`
|
|
req := newBucketRequest(http.MethodPut, "b", "requestPayment=", body)
|
|
rec := httptest.NewRecorder()
|
|
|
|
s3a.PutBucketRequestPaymentHandler(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestPutBucketRequestPaymentRequesterRejected(t *testing.T) {
|
|
s3a := newMiscTestServer(t, "b")
|
|
body := `<RequestPaymentConfiguration><Payer>Requester</Payer></RequestPaymentConfiguration>`
|
|
req := newBucketRequest(http.MethodPut, "b", "requestPayment=", body)
|
|
rec := httptest.NewRecorder()
|
|
|
|
s3a.PutBucketRequestPaymentHandler(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "MalformedXML") {
|
|
t.Fatalf("body missing MalformedXML: %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestPutBucketOwnershipControlsRejectsRuleWithoutObjectOwnership(t *testing.T) {
|
|
ownerID := AccountAdmin.Id
|
|
s3a := &S3ApiServer{
|
|
bucketRegistry: NewBucketRegistry(nil),
|
|
}
|
|
s3a.bucketRegistry.setMetadataCache(&BucketMetaData{
|
|
Name: "b",
|
|
Owner: &s3.Owner{
|
|
ID: &ownerID,
|
|
},
|
|
})
|
|
body := `<OwnershipControls><Rule></Rule></OwnershipControls>`
|
|
req := newBucketRequest(http.MethodPut, "b", "ownershipControls=", body)
|
|
req.Header.Set(s3_constants.AmzAccountId, AccountAdmin.Id)
|
|
rec := httptest.NewRecorder()
|
|
|
|
s3a.PutBucketOwnershipControls(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "InvalidRequest") {
|
|
t.Fatalf("body missing InvalidRequest: %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetBucketOwnershipControlsDefaultsToBucketOwnerEnforced(t *testing.T) {
|
|
ownerID := AccountAdmin.Id
|
|
s3a := newMiscTestServer(t, "b")
|
|
s3a.bucketRegistry = NewBucketRegistry(nil)
|
|
s3a.bucketRegistry.setMetadataCache(&BucketMetaData{
|
|
Name: "b",
|
|
Owner: &s3.Owner{ID: &ownerID},
|
|
})
|
|
req := newBucketRequest(http.MethodGet, "b", "ownershipControls=", "")
|
|
req.Header.Set(s3_constants.AmzAccountId, AccountAdmin.Id)
|
|
req = req.WithContext(s3_constants.SetIdentityInContext(req.Context(), &Identity{
|
|
Name: "admin",
|
|
Account: &AccountAdmin,
|
|
Actions: []Action{s3_constants.ACTION_ADMIN},
|
|
}))
|
|
rec := httptest.NewRecorder()
|
|
|
|
s3a.GetBucketOwnershipControls(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "<ObjectOwnership>"+s3_constants.OwnershipBucketOwnerEnforced+"</ObjectOwnership>") {
|
|
t.Fatalf("body missing default ownership: %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetBucketAccelerateConfiguration(t *testing.T) {
|
|
s3a := newMiscTestServer(t, "b")
|
|
req := newBucketRequest(http.MethodGet, "b", "accelerate=", "")
|
|
rec := httptest.NewRecorder()
|
|
|
|
s3a.GetBucketAccelerateConfigurationHandler(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
body, err := io.ReadAll(rec.Body)
|
|
if err != nil {
|
|
t.Fatalf("read body: %v", err)
|
|
}
|
|
got := string(body)
|
|
if !strings.Contains(got, "<AccelerateConfiguration") {
|
|
t.Fatalf("missing root element: %s", got)
|
|
}
|
|
if !strings.Contains(got, "<Status>Suspended</Status>") {
|
|
t.Fatalf("missing Suspended status: %s", got)
|
|
}
|
|
if !strings.Contains(got, `xmlns="http://s3.amazonaws.com/doc/2006-03-01/"`) {
|
|
t.Fatalf("missing xmlns: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestGetBucketLogging(t *testing.T) {
|
|
s3a := newMiscTestServer(t, "b")
|
|
req := newBucketRequest(http.MethodGet, "b", "logging=", "")
|
|
rec := httptest.NewRecorder()
|
|
|
|
s3a.GetBucketLoggingHandler(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
got := rec.Body.String()
|
|
if !strings.Contains(got, "<BucketLoggingStatus") {
|
|
t.Fatalf("missing root element: %s", got)
|
|
}
|
|
if strings.Contains(got, "<LoggingEnabled") {
|
|
t.Fatalf("unexpected LoggingEnabled element: %s", got)
|
|
}
|
|
if !strings.Contains(got, `xmlns="http://s3.amazonaws.com/doc/2006-03-01/"`) {
|
|
t.Fatalf("missing xmlns: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestGetBucketLocationInvalidBucketName(t *testing.T) {
|
|
// AWS answers 400 InvalidBucketName for a malformed bucket name rather than
|
|
// the 404 NoSuchBucket an unknown-but-valid name gets.
|
|
s3a := &S3ApiServer{}
|
|
req := httptest.NewRequest(http.MethodGet, "/invalid%20bucket%20name?location=", nil)
|
|
req = mux.SetURLVars(req, map[string]string{"bucket": "invalid bucket name"})
|
|
rec := httptest.NewRecorder()
|
|
|
|
s3a.GetBucketLocationHandler(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "InvalidBucketName") {
|
|
t.Fatalf("body = %s, want InvalidBucketName", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleAutoCreateBucketDisabled(t *testing.T) {
|
|
s3a := &S3ApiServer{option: &S3ApiServerOption{}}
|
|
req := newBucketRequest(http.MethodPut, "test-bucket", "", "")
|
|
rec := httptest.NewRecorder()
|
|
|
|
if s3a.handleAutoCreateBucket(rec, req, "test-bucket", "PutObjectHandler") {
|
|
t.Fatal("expected auto-create to be rejected")
|
|
}
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "NoSuchBucket") {
|
|
t.Fatalf("body = %s, want NoSuchBucket", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleAutoCreateBucketNonAdmin(t *testing.T) {
|
|
s3a := &S3ApiServer{option: &S3ApiServerOption{AutoCreateBucket: true}}
|
|
req := newBucketRequest(http.MethodPut, "test-bucket", "", "")
|
|
rec := httptest.NewRecorder()
|
|
|
|
if s3a.handleAutoCreateBucket(rec, req, "test-bucket", "PutObjectHandler") {
|
|
t.Fatal("expected auto-create to be rejected")
|
|
}
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
|
|
}
|
|
}
|
|
|
|
func TestUploadMissingBucketAutoCreateDisabled(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
method string
|
|
object string
|
|
handler func(*S3ApiServer, http.ResponseWriter, *http.Request)
|
|
}{
|
|
{"put object", http.MethodPut, "/key", (*S3ApiServer).PutObjectHandler},
|
|
{"put directory marker", http.MethodPut, "/dir/", (*S3ApiServer).PutObjectHandler},
|
|
{"new multipart upload", http.MethodPost, "/key", (*S3ApiServer).NewMultipartUploadHandler},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
s3a := &S3ApiServer{
|
|
option: &S3ApiServerOption{},
|
|
iam: &IdentityAccessManagement{},
|
|
bucketConfigCache: NewBucketConfigCache(time.Minute),
|
|
}
|
|
s3a.bucketConfigCache.SetNegativeCache("missing")
|
|
req := httptest.NewRequest(tc.method, "/missing"+tc.object, strings.NewReader(""))
|
|
req = mux.SetURLVars(req, map[string]string{"bucket": "missing", "object": tc.object})
|
|
rec := httptest.NewRecorder()
|
|
|
|
tc.handler(s3a, rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "NoSuchBucket") {
|
|
t.Fatalf("body = %s, want NoSuchBucket", rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|