snapshot of https://github.com/seaweedfs/seaweedfs.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled
Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled
This commit is contained in:
573
test/s3/normal/get_object_attributes_test.go
Normal file
573
test/s3/normal/get_object_attributes_test.go
Normal file
@@ -0,0 +1,573 @@
|
||||
package example
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
v2aws "github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
v2s3 "github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
v1credentials "github.com/aws/aws-sdk-go/aws/credentials"
|
||||
v1signer "github.com/aws/aws-sdk-go/aws/signer/v4"
|
||||
v1s3 "github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newS3V2Client creates an AWS SDK v2 S3 client from the test cluster.
|
||||
func newS3V2Client(cluster *TestCluster) *v2s3.Client {
|
||||
return v2s3.New(v2s3.Options{
|
||||
Region: testRegion,
|
||||
BaseEndpoint: v2aws.String(cluster.s3Endpoint),
|
||||
Credentials: v2aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(testAccessKey, testSecretKey, "")),
|
||||
UsePathStyle: true,
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetObjectAttributes(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
cluster, err := startMiniCluster(t)
|
||||
require.NoError(t, err)
|
||||
defer cluster.Stop()
|
||||
|
||||
t.Run("Basic", func(t *testing.T) {
|
||||
testGetObjectAttributesBasic(t, cluster)
|
||||
})
|
||||
t.Run("MultipartObject", func(t *testing.T) {
|
||||
testGetObjectAttributesMultipart(t, cluster)
|
||||
})
|
||||
t.Run("SelectiveAttributes", func(t *testing.T) {
|
||||
testGetObjectAttributesSelective(t, cluster)
|
||||
})
|
||||
t.Run("InvalidAttribute", func(t *testing.T) {
|
||||
testGetObjectAttributesInvalid(t, cluster)
|
||||
})
|
||||
t.Run("NonExistentObject", func(t *testing.T) {
|
||||
testGetObjectAttributesNotFound(t, cluster)
|
||||
})
|
||||
t.Run("VersionedObject", func(t *testing.T) {
|
||||
testGetObjectAttributesVersioned(t, cluster)
|
||||
})
|
||||
t.Run("ConditionalHeaders", func(t *testing.T) {
|
||||
testGetObjectAttributesConditionalHeaders(t, cluster)
|
||||
})
|
||||
t.Run("VersionedConditionalHeaders", func(t *testing.T) {
|
||||
testGetObjectAttributesVersionedConditionalHeaders(t, cluster)
|
||||
})
|
||||
}
|
||||
|
||||
func testGetObjectAttributesBasic(t *testing.T, cluster *TestCluster) {
|
||||
bucketName := createTestBucket(t, cluster, "test-goa-basic-")
|
||||
objectKey := "test-object.txt"
|
||||
objectData := "Hello, GetObjectAttributes!"
|
||||
|
||||
_, err := cluster.s3Client.PutObject(&v1s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: bytes.NewReader([]byte(objectData)),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
client := newS3V2Client(cluster)
|
||||
resp, err := client.GetObjectAttributes(context.Background(), &v2s3.GetObjectAttributesInput{
|
||||
Bucket: v2aws.String(bucketName),
|
||||
Key: v2aws.String(objectKey),
|
||||
ObjectAttributes: []types.ObjectAttributes{
|
||||
types.ObjectAttributesEtag,
|
||||
types.ObjectAttributesStorageClass,
|
||||
types.ObjectAttributesObjectSize,
|
||||
types.ObjectAttributesObjectParts,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// ETag should be present and non-empty
|
||||
require.NotNil(t, resp.ETag)
|
||||
assert.NotEmpty(t, *resp.ETag)
|
||||
assert.False(t, strings.Contains(*resp.ETag, `"`), "ETag in XML body should not have quotes")
|
||||
|
||||
// ObjectSize should match
|
||||
require.NotNil(t, resp.ObjectSize)
|
||||
assert.Equal(t, int64(len(objectData)), *resp.ObjectSize)
|
||||
|
||||
// StorageClass should be STANDARD (default)
|
||||
assert.Equal(t, "STANDARD", string(resp.StorageClass))
|
||||
|
||||
// ObjectParts should be nil for non-multipart objects
|
||||
assert.Nil(t, resp.ObjectParts)
|
||||
|
||||
// LastModified header should be present
|
||||
assert.NotNil(t, resp.LastModified)
|
||||
|
||||
t.Logf("Basic GetObjectAttributes passed: ETag=%s, Size=%d, StorageClass=%s",
|
||||
*resp.ETag, *resp.ObjectSize, resp.StorageClass)
|
||||
}
|
||||
|
||||
func testGetObjectAttributesMultipart(t *testing.T, cluster *TestCluster) {
|
||||
bucketName := createTestBucket(t, cluster, "test-goa-mp-")
|
||||
objectKey := "test-multipart.bin"
|
||||
|
||||
// Create a 2-part multipart upload
|
||||
part1Data := bytes.Repeat([]byte("A"), 5*1024*1024) // 5MB (minimum part size)
|
||||
part2Data := bytes.Repeat([]byte("B"), 3*1024*1024) // 3MB
|
||||
|
||||
initResp, err := cluster.s3Client.CreateMultipartUpload(&v1s3.CreateMultipartUploadInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
uploadID := initResp.UploadId
|
||||
|
||||
part1Resp, err := cluster.s3Client.UploadPart(&v1s3.UploadPartInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
PartNumber: aws.Int64(1),
|
||||
UploadId: uploadID,
|
||||
Body: bytes.NewReader(part1Data),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
part2Resp, err := cluster.s3Client.UploadPart(&v1s3.UploadPartInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
PartNumber: aws.Int64(2),
|
||||
UploadId: uploadID,
|
||||
Body: bytes.NewReader(part2Data),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = cluster.s3Client.CompleteMultipartUpload(&v1s3.CompleteMultipartUploadInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
UploadId: uploadID,
|
||||
MultipartUpload: &v1s3.CompletedMultipartUpload{
|
||||
Parts: []*v1s3.CompletedPart{
|
||||
{ETag: part1Resp.ETag, PartNumber: aws.Int64(1)},
|
||||
{ETag: part2Resp.ETag, PartNumber: aws.Int64(2)},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait briefly for metadata to settle
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
client := newS3V2Client(cluster)
|
||||
resp, err := client.GetObjectAttributes(context.Background(), &v2s3.GetObjectAttributesInput{
|
||||
Bucket: v2aws.String(bucketName),
|
||||
Key: v2aws.String(objectKey),
|
||||
ObjectAttributes: []types.ObjectAttributes{
|
||||
types.ObjectAttributesObjectParts,
|
||||
types.ObjectAttributesObjectSize,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, resp.ObjectSize)
|
||||
assert.Equal(t, int64(len(part1Data)+len(part2Data)), *resp.ObjectSize)
|
||||
|
||||
require.NotNil(t, resp.ObjectParts, "ObjectParts should be present for multipart objects")
|
||||
assert.Equal(t, int32(2), *resp.ObjectParts.TotalPartsCount)
|
||||
require.Len(t, resp.ObjectParts.Parts, 2)
|
||||
assert.Equal(t, int32(1), *resp.ObjectParts.Parts[0].PartNumber)
|
||||
assert.Equal(t, int64(len(part1Data)), *resp.ObjectParts.Parts[0].Size)
|
||||
assert.Equal(t, int32(2), *resp.ObjectParts.Parts[1].PartNumber)
|
||||
assert.Equal(t, int64(len(part2Data)), *resp.ObjectParts.Parts[1].Size)
|
||||
|
||||
// Test pagination: MaxParts=1
|
||||
resp2, err := client.GetObjectAttributes(context.Background(), &v2s3.GetObjectAttributesInput{
|
||||
Bucket: v2aws.String(bucketName),
|
||||
Key: v2aws.String(objectKey),
|
||||
MaxParts: v2aws.Int32(1),
|
||||
ObjectAttributes: []types.ObjectAttributes{
|
||||
types.ObjectAttributesObjectParts,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp2.ObjectParts)
|
||||
assert.Len(t, resp2.ObjectParts.Parts, 1)
|
||||
assert.True(t, *resp2.ObjectParts.IsTruncated)
|
||||
assert.Equal(t, int32(2), *resp2.ObjectParts.TotalPartsCount)
|
||||
|
||||
t.Logf("Multipart GetObjectAttributes passed: %d parts, total size %d",
|
||||
*resp.ObjectParts.TotalPartsCount, *resp.ObjectSize)
|
||||
}
|
||||
|
||||
func testGetObjectAttributesSelective(t *testing.T, cluster *TestCluster) {
|
||||
bucketName := createTestBucket(t, cluster, "test-goa-sel-")
|
||||
objectKey := "test-selective.txt"
|
||||
objectData := "Selective attributes test"
|
||||
|
||||
_, err := cluster.s3Client.PutObject(&v1s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: bytes.NewReader([]byte(objectData)),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
client := newS3V2Client(cluster)
|
||||
|
||||
// Request only ETag
|
||||
resp, err := client.GetObjectAttributes(context.Background(), &v2s3.GetObjectAttributesInput{
|
||||
Bucket: v2aws.String(bucketName),
|
||||
Key: v2aws.String(objectKey),
|
||||
ObjectAttributes: []types.ObjectAttributes{
|
||||
types.ObjectAttributesEtag,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.ETag)
|
||||
assert.NotEmpty(t, *resp.ETag)
|
||||
assert.Nil(t, resp.ObjectSize, "ObjectSize should not be present when not requested")
|
||||
assert.Empty(t, string(resp.StorageClass), "StorageClass should not be present when not requested")
|
||||
assert.Nil(t, resp.ObjectParts, "ObjectParts should not be present when not requested")
|
||||
|
||||
t.Logf("Selective GetObjectAttributes passed: ETag=%s", *resp.ETag)
|
||||
}
|
||||
|
||||
func testGetObjectAttributesInvalid(t *testing.T, cluster *TestCluster) {
|
||||
bucketName := createTestBucket(t, cluster, "test-goa-inv-")
|
||||
objectKey := "test-object.txt"
|
||||
|
||||
_, err := cluster.s3Client.PutObject(&v1s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: bytes.NewReader([]byte("test")),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Use raw HTTP to send an invalid attribute name since the SDK validates
|
||||
reqURL := fmt.Sprintf("%s/%s/%s?attributes", cluster.s3Endpoint, bucketName, objectKey)
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("X-Amz-Object-Attributes", "InvalidAttr")
|
||||
|
||||
signer := v1signer.NewSigner(v1credentials.NewStaticCredentials(testAccessKey, testSecretKey, ""))
|
||||
_, err = signer.Sign(req, nil, "s3", testRegion, time.Now())
|
||||
require.NoError(t, err)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
|
||||
assert.Equal(t, 400, resp.StatusCode)
|
||||
t.Logf("Invalid attribute test passed: got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
func testGetObjectAttributesNotFound(t *testing.T, cluster *TestCluster) {
|
||||
bucketName := createTestBucket(t, cluster, "test-goa-nf-")
|
||||
|
||||
client := newS3V2Client(cluster)
|
||||
_, err := client.GetObjectAttributes(context.Background(), &v2s3.GetObjectAttributesInput{
|
||||
Bucket: v2aws.String(bucketName),
|
||||
Key: v2aws.String("nonexistent-key"),
|
||||
ObjectAttributes: []types.ObjectAttributes{
|
||||
types.ObjectAttributesEtag,
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "NoSuchKey")
|
||||
|
||||
t.Logf("NotFound GetObjectAttributes passed")
|
||||
}
|
||||
|
||||
func testGetObjectAttributesVersioned(t *testing.T, cluster *TestCluster) {
|
||||
client := newS3V2Client(cluster)
|
||||
bucketName := createTestBucket(t, cluster, "test-goa-ver-")
|
||||
|
||||
// Enable versioning
|
||||
_, err := client.PutBucketVersioning(context.Background(), &v2s3.PutBucketVersioningInput{
|
||||
Bucket: v2aws.String(bucketName),
|
||||
VersioningConfiguration: &types.VersioningConfiguration{
|
||||
Status: types.BucketVersioningStatusEnabled,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Put two versions of the same object
|
||||
v1Data := "version 1 content"
|
||||
putResp1, err := client.PutObject(context.Background(), &v2s3.PutObjectInput{
|
||||
Bucket: v2aws.String(bucketName),
|
||||
Key: v2aws.String("versioned-key"),
|
||||
Body: strings.NewReader(v1Data),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, putResp1.VersionId)
|
||||
versionId1 := *putResp1.VersionId
|
||||
|
||||
v2Data := "version 2 content - longer"
|
||||
putResp2, err := client.PutObject(context.Background(), &v2s3.PutObjectInput{
|
||||
Bucket: v2aws.String(bucketName),
|
||||
Key: v2aws.String("versioned-key"),
|
||||
Body: strings.NewReader(v2Data),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, putResp2.VersionId)
|
||||
versionId2 := *putResp2.VersionId
|
||||
|
||||
assert.NotEqual(t, versionId1, versionId2, "versions should differ")
|
||||
|
||||
// GetObjectAttributes for latest version (v2)
|
||||
resp, err := client.GetObjectAttributes(context.Background(), &v2s3.GetObjectAttributesInput{
|
||||
Bucket: v2aws.String(bucketName),
|
||||
Key: v2aws.String("versioned-key"),
|
||||
ObjectAttributes: []types.ObjectAttributes{
|
||||
types.ObjectAttributesObjectSize,
|
||||
types.ObjectAttributesEtag,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.ObjectSize)
|
||||
assert.Equal(t, int64(len(v2Data)), *resp.ObjectSize)
|
||||
require.NotNil(t, resp.VersionId)
|
||||
assert.Equal(t, versionId2, *resp.VersionId)
|
||||
|
||||
// GetObjectAttributes for specific older version (v1)
|
||||
resp1, err := client.GetObjectAttributes(context.Background(), &v2s3.GetObjectAttributesInput{
|
||||
Bucket: v2aws.String(bucketName),
|
||||
Key: v2aws.String("versioned-key"),
|
||||
VersionId: v2aws.String(versionId1),
|
||||
ObjectAttributes: []types.ObjectAttributes{
|
||||
types.ObjectAttributesObjectSize,
|
||||
types.ObjectAttributesEtag,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp1.ObjectSize)
|
||||
assert.Equal(t, int64(len(v1Data)), *resp1.ObjectSize)
|
||||
require.NotNil(t, resp1.VersionId)
|
||||
assert.Equal(t, versionId1, *resp1.VersionId)
|
||||
|
||||
t.Logf("Versioned GetObjectAttributes passed: v1 size=%d (id=%s), v2 size=%d (id=%s)",
|
||||
*resp1.ObjectSize, versionId1, *resp.ObjectSize, versionId2)
|
||||
}
|
||||
|
||||
// signedGetObjectAttributes creates a signed GET request for ?attributes with custom headers.
|
||||
func signedGetObjectAttributes(t *testing.T, cluster *TestCluster, bucketName, objectKey string, extraHeaders map[string]string) *http.Response {
|
||||
reqURL := fmt.Sprintf("%s/%s/%s?attributes", cluster.s3Endpoint, bucketName, objectKey)
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("X-Amz-Object-Attributes", "ETag,ObjectSize")
|
||||
for k, v := range extraHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
signer := v1signer.NewSigner(v1credentials.NewStaticCredentials(testAccessKey, testSecretKey, ""))
|
||||
_, err = signer.Sign(req, nil, "s3", testRegion, time.Now())
|
||||
require.NoError(t, err)
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
return resp
|
||||
}
|
||||
|
||||
func testGetObjectAttributesConditionalHeaders(t *testing.T, cluster *TestCluster) {
|
||||
bucketName := createTestBucket(t, cluster, "test-goa-cond-")
|
||||
objectKey := "cond-test.txt"
|
||||
|
||||
_, err := cluster.s3Client.PutObject(&v1s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: bytes.NewReader([]byte("conditional headers test")),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get the ETag and Last-Modified for the object
|
||||
headResp, err := cluster.s3Client.HeadObject(&v1s3.HeadObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
etag := aws.StringValue(headResp.ETag)
|
||||
lastModified := headResp.LastModified
|
||||
require.NotNil(t, lastModified)
|
||||
|
||||
pastDate := lastModified.Add(-1 * time.Hour).UTC().Format(http.TimeFormat)
|
||||
futureDate := lastModified.Add(1 * time.Hour).UTC().Format(http.TimeFormat)
|
||||
|
||||
// RFC 7232: If-Match true + If-Unmodified-Since false => 200 OK
|
||||
// If-Unmodified-Since is ignored when If-Match is present
|
||||
t.Run("IfMatch_true_IfUnmodifiedSince_false", func(t *testing.T) {
|
||||
resp := signedGetObjectAttributes(t, cluster, bucketName, objectKey, map[string]string{
|
||||
"If-Match": etag,
|
||||
"If-Unmodified-Since": pastDate, // object was modified after this => false
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
assert.Equal(t, 200, resp.StatusCode,
|
||||
"If-Match=true should return 200 even when If-Unmodified-Since=false (RFC 7232 Section 3.4)")
|
||||
})
|
||||
|
||||
// RFC 7232: If-None-Match false + If-Modified-Since true => 304 Not Modified
|
||||
// If-Modified-Since is ignored when If-None-Match is present
|
||||
t.Run("IfNoneMatch_false_IfModifiedSince_true", func(t *testing.T) {
|
||||
resp := signedGetObjectAttributes(t, cluster, bucketName, objectKey, map[string]string{
|
||||
"If-None-Match": etag,
|
||||
"If-Modified-Since": pastDate, // object was modified after this => true
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
assert.Equal(t, 304, resp.StatusCode,
|
||||
"If-None-Match=false (ETag match) should return 304 even when If-Modified-Since=true (RFC 7232 Section 3.3)")
|
||||
})
|
||||
|
||||
// If-Match succeeds, If-Unmodified-Since also succeeds => 200
|
||||
t.Run("IfMatch_true_IfUnmodifiedSince_true", func(t *testing.T) {
|
||||
resp := signedGetObjectAttributes(t, cluster, bucketName, objectKey, map[string]string{
|
||||
"If-Match": etag,
|
||||
"If-Unmodified-Since": futureDate,
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
})
|
||||
|
||||
// If-None-Match passes (ETag differs), If-Modified-Since ignored => 200
|
||||
// Per RFC 7232, If-Modified-Since is ignored when If-None-Match is present
|
||||
t.Run("IfNoneMatch_true_IfModifiedSince_ignored", func(t *testing.T) {
|
||||
resp := signedGetObjectAttributes(t, cluster, bucketName, objectKey, map[string]string{
|
||||
"If-None-Match": `"nonexistent-etag"`,
|
||||
"If-Modified-Since": futureDate, // would fail alone, but is ignored
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
assert.Equal(t, 200, resp.StatusCode,
|
||||
"If-None-Match=true means If-Modified-Since is ignored, should return 200 (RFC 7232 Section 3.3)")
|
||||
})
|
||||
|
||||
// If-Match fails => 412 regardless of If-Unmodified-Since
|
||||
t.Run("IfMatch_false", func(t *testing.T) {
|
||||
resp := signedGetObjectAttributes(t, cluster, bucketName, objectKey, map[string]string{
|
||||
"If-Match": `"wrong-etag"`,
|
||||
"If-Unmodified-Since": futureDate,
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
assert.Equal(t, 412, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Logf("Conditional headers tests passed")
|
||||
}
|
||||
|
||||
// signedGetObjectAttributesVersioned creates a signed GET request for ?attributes&versionId=... with custom headers.
|
||||
func signedGetObjectAttributesVersioned(t *testing.T, cluster *TestCluster, bucketName, objectKey, versionId string, extraHeaders map[string]string) *http.Response {
|
||||
reqURL := fmt.Sprintf("%s/%s/%s?attributes&versionId=%s", cluster.s3Endpoint, bucketName, objectKey, versionId)
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("X-Amz-Object-Attributes", "ETag,ObjectSize")
|
||||
for k, v := range extraHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
signer := v1signer.NewSigner(v1credentials.NewStaticCredentials(testAccessKey, testSecretKey, ""))
|
||||
_, err = signer.Sign(req, nil, "s3", testRegion, time.Now())
|
||||
require.NoError(t, err)
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
return resp
|
||||
}
|
||||
|
||||
func testGetObjectAttributesVersionedConditionalHeaders(t *testing.T, cluster *TestCluster) {
|
||||
client := newS3V2Client(cluster)
|
||||
bucketName := createTestBucket(t, cluster, "test-goa-vcond-")
|
||||
|
||||
// Enable versioning
|
||||
_, err := client.PutBucketVersioning(context.Background(), &v2s3.PutBucketVersioningInput{
|
||||
Bucket: v2aws.String(bucketName),
|
||||
VersioningConfiguration: &types.VersioningConfiguration{
|
||||
Status: types.BucketVersioningStatusEnabled,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Put two versions with different content (different ETags)
|
||||
v1Data := "version 1 - original"
|
||||
putResp1, err := client.PutObject(context.Background(), &v2s3.PutObjectInput{
|
||||
Bucket: v2aws.String(bucketName),
|
||||
Key: v2aws.String("vcond-key"),
|
||||
Body: strings.NewReader(v1Data),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, putResp1.VersionId)
|
||||
vid1 := *putResp1.VersionId
|
||||
|
||||
v2Data := "version 2 - updated content"
|
||||
putResp2, err := client.PutObject(context.Background(), &v2s3.PutObjectInput{
|
||||
Bucket: v2aws.String(bucketName),
|
||||
Key: v2aws.String("vcond-key"),
|
||||
Body: strings.NewReader(v2Data),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, putResp2.VersionId)
|
||||
vid2 := *putResp2.VersionId
|
||||
|
||||
// Get ETags for each version
|
||||
headV1, err := client.HeadObject(context.Background(), &v2s3.HeadObjectInput{
|
||||
Bucket: v2aws.String(bucketName),
|
||||
Key: v2aws.String("vcond-key"),
|
||||
VersionId: v2aws.String(vid1),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
etagV1 := *headV1.ETag
|
||||
|
||||
headV2, err := client.HeadObject(context.Background(), &v2s3.HeadObjectInput{
|
||||
Bucket: v2aws.String(bucketName),
|
||||
Key: v2aws.String("vcond-key"),
|
||||
VersionId: v2aws.String(vid2),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
etagV2 := *headV2.ETag
|
||||
require.NotEqual(t, etagV1, etagV2, "versions should have different ETags")
|
||||
|
||||
// If-Match with v1's ETag + versionId=v1 => 200
|
||||
// Before the fix, this would fail with 412 because conditional headers
|
||||
// were evaluated against the latest version (v2) whose ETag differs
|
||||
t.Run("IfMatch_v1_etag_versionId_v1", func(t *testing.T) {
|
||||
resp := signedGetObjectAttributesVersioned(t, cluster, bucketName, "vcond-key", vid1, map[string]string{
|
||||
"If-Match": etagV1,
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
assert.Equal(t, 200, resp.StatusCode,
|
||||
"If-Match with v1 ETag targeting versionId=v1 should return 200")
|
||||
})
|
||||
|
||||
// If-Match with v2's ETag + versionId=v1 => 412
|
||||
// The ETag doesn't match v1, so this should fail
|
||||
t.Run("IfMatch_v2_etag_versionId_v1", func(t *testing.T) {
|
||||
resp := signedGetObjectAttributesVersioned(t, cluster, bucketName, "vcond-key", vid1, map[string]string{
|
||||
"If-Match": etagV2,
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
assert.Equal(t, 412, resp.StatusCode,
|
||||
"If-Match with v2 ETag targeting versionId=v1 should return 412")
|
||||
})
|
||||
|
||||
// If-None-Match with v1's ETag + versionId=v1 => 304
|
||||
t.Run("IfNoneMatch_v1_etag_versionId_v1", func(t *testing.T) {
|
||||
resp := signedGetObjectAttributesVersioned(t, cluster, bucketName, "vcond-key", vid1, map[string]string{
|
||||
"If-None-Match": etagV1,
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
assert.Equal(t, 304, resp.StatusCode,
|
||||
"If-None-Match with v1 ETag targeting versionId=v1 should return 304")
|
||||
})
|
||||
|
||||
t.Logf("Versioned conditional headers tests passed: vid1=%s, vid2=%s", vid1, vid2)
|
||||
}
|
||||
155
test/s3/normal/iam_test.go
Normal file
155
test/s3/normal/iam_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package example
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestIAMOperations tests authenticated IAM operations with AWS Signature V4
|
||||
// All IAM operations require proper authentication.
|
||||
func TestIAMOperations(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
// Set credentials before starting cluster
|
||||
accessKey := "testkey123"
|
||||
secretKey := "testsecret456"
|
||||
t.Setenv("AWS_ACCESS_KEY_ID", accessKey)
|
||||
t.Setenv("AWS_SECRET_ACCESS_KEY", secretKey)
|
||||
|
||||
// Create and start test cluster
|
||||
cluster, err := startMiniCluster(t)
|
||||
require.NoError(t, err)
|
||||
defer cluster.Stop()
|
||||
|
||||
// Wait for services to be fully ready
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Create IAM client with credentials
|
||||
sess, err := session.NewSession(&aws.Config{
|
||||
Region: aws.String("us-west-2"),
|
||||
Endpoint: aws.String(cluster.s3Endpoint),
|
||||
DisableSSL: aws.Bool(true),
|
||||
Credentials: credentials.NewStaticCredentials(accessKey, secretKey, ""),
|
||||
S3ForcePathStyle: aws.Bool(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
iamClient := iam.New(sess)
|
||||
|
||||
// Run all IAM tests with authentication
|
||||
t.Run("CreateUser", func(t *testing.T) {
|
||||
testCreateUserAuthenticated(t, iamClient)
|
||||
})
|
||||
|
||||
t.Run("ListUsers", func(t *testing.T) {
|
||||
testListUsersAuthenticated(t, iamClient)
|
||||
})
|
||||
|
||||
t.Run("GetUser", func(t *testing.T) {
|
||||
testGetUserAuthenticated(t, iamClient)
|
||||
})
|
||||
|
||||
t.Run("DeleteUser", func(t *testing.T) {
|
||||
testDeleteUserAuthenticated(t, iamClient)
|
||||
})
|
||||
}
|
||||
|
||||
// testCreateUserAuthenticated tests CreateUser with AWS Signature V4 authentication
|
||||
func testCreateUserAuthenticated(t *testing.T, iamClient *iam.IAM) {
|
||||
userName := "alice-" + randomString(8)
|
||||
|
||||
input := &iam.CreateUserInput{
|
||||
UserName: aws.String(userName),
|
||||
}
|
||||
|
||||
result, err := iamClient.CreateUser(input)
|
||||
require.NoError(t, err, "Authenticated CreateUser should succeed")
|
||||
require.NotNil(t, result.User)
|
||||
require.Equal(t, userName, *result.User.UserName)
|
||||
|
||||
t.Logf("✓ Created user with authentication: %s", userName)
|
||||
}
|
||||
|
||||
// testListUsersAuthenticated tests ListUsers with authentication
|
||||
func testListUsersAuthenticated(t *testing.T, iamClient *iam.IAM) {
|
||||
// First create a user
|
||||
userName := "listauth-" + randomString(8)
|
||||
_, err := iamClient.CreateUser(&iam.CreateUserInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for user to be persisted
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// List users
|
||||
result, err := iamClient.ListUsers(&iam.ListUsersInput{})
|
||||
require.NoError(t, err, "Authenticated ListUsers should succeed")
|
||||
require.NotNil(t, result.Users)
|
||||
|
||||
// Verify our user is in the list
|
||||
found := false
|
||||
for _, user := range result.Users {
|
||||
if *user.UserName == userName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "Created user should be in the list")
|
||||
|
||||
t.Logf("✓ Listed %d users with authentication", len(result.Users))
|
||||
}
|
||||
|
||||
// testGetUserAuthenticated tests GetUser with authentication
|
||||
func testGetUserAuthenticated(t *testing.T, iamClient *iam.IAM) {
|
||||
userName := "getauth-" + randomString(8)
|
||||
|
||||
// Create user
|
||||
_, err := iamClient.CreateUser(&iam.CreateUserInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for user to be persisted
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Get user
|
||||
result, err := iamClient.GetUser(&iam.GetUserInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err, "Authenticated GetUser should succeed")
|
||||
require.NotNil(t, result.User)
|
||||
require.Equal(t, userName, *result.User.UserName)
|
||||
|
||||
t.Logf("✓ Got user with authentication: %s", userName)
|
||||
}
|
||||
|
||||
// testDeleteUserAuthenticated tests DeleteUser with authentication
|
||||
func testDeleteUserAuthenticated(t *testing.T, iamClient *iam.IAM) {
|
||||
userName := "delauth-" + randomString(8)
|
||||
|
||||
// Create user
|
||||
_, err := iamClient.CreateUser(&iam.CreateUserInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for user to be persisted
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Delete user
|
||||
_, err = iamClient.DeleteUser(&iam.DeleteUserInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err, "Authenticated DeleteUser should succeed")
|
||||
|
||||
t.Logf("✓ Deleted user with authentication: %s", userName)
|
||||
}
|
||||
922
test/s3/normal/s3_integration_test.go
Normal file
922
test/s3/normal/s3_integration_test.go
Normal file
@@ -0,0 +1,922 @@
|
||||
package example
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/testutil"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/weed/command"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
flag "github.com/seaweedfs/seaweedfs/weed/util/fla9"
|
||||
)
|
||||
|
||||
const (
|
||||
testRegion = "us-west-2"
|
||||
testAccessKey = "admin"
|
||||
testSecretKey = "admin"
|
||||
)
|
||||
|
||||
// TestCluster manages the weed mini instance for integration testing
|
||||
type TestCluster struct {
|
||||
dataDir string
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
s3Client *s3.S3
|
||||
isRunning bool
|
||||
startOnce sync.Once
|
||||
wg sync.WaitGroup
|
||||
masterPort int
|
||||
volumePort int
|
||||
filerPort int
|
||||
s3Port int
|
||||
s3Endpoint string
|
||||
rustVolumeCmd *exec.Cmd
|
||||
}
|
||||
|
||||
// TestS3Integration demonstrates basic S3 operations against a running weed mini instance
|
||||
func TestS3Integration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
// Create and start test cluster
|
||||
cluster, err := startMiniCluster(t)
|
||||
require.NoError(t, err)
|
||||
defer cluster.Stop()
|
||||
|
||||
// Run test suite
|
||||
t.Run("CreateBucket", func(t *testing.T) {
|
||||
testCreateBucket(t, cluster)
|
||||
})
|
||||
|
||||
t.Run("PutObject", func(t *testing.T) {
|
||||
testPutObject(t, cluster)
|
||||
})
|
||||
|
||||
t.Run("UploadPart", func(t *testing.T) {
|
||||
testPutPartWithChecksum(t, cluster)
|
||||
})
|
||||
|
||||
t.Run("PutObjectWithChecksum", func(t *testing.T) {
|
||||
testPutObjectWithChecksum(t, cluster)
|
||||
})
|
||||
|
||||
t.Run("UploadPartWithChecksum", func(t *testing.T) {
|
||||
testUploadPartWithChecksum(t, cluster)
|
||||
})
|
||||
|
||||
t.Run("PutObjectWithChecksumAndSSEC", func(t *testing.T) {
|
||||
testPutObjectWithChecksumAndSSEC(t, cluster)
|
||||
})
|
||||
|
||||
t.Run("GetObject", func(t *testing.T) {
|
||||
testGetObject(t, cluster)
|
||||
})
|
||||
|
||||
t.Run("ListObjects", func(t *testing.T) {
|
||||
testListObjects(t, cluster)
|
||||
})
|
||||
|
||||
t.Run("DeleteObject", func(t *testing.T) {
|
||||
testDeleteObject(t, cluster)
|
||||
})
|
||||
|
||||
t.Run("DeleteDirectoryMarkerWithChildren", func(t *testing.T) {
|
||||
testDeleteDirectoryMarkerWithChildren(t, cluster)
|
||||
})
|
||||
|
||||
t.Run("DeleteBucket", func(t *testing.T) {
|
||||
testDeleteBucket(t, cluster)
|
||||
})
|
||||
}
|
||||
|
||||
// findAvailablePort finds an available port by binding to port 0
|
||||
|
||||
// startMiniCluster starts a weed mini instance directly without exec.
|
||||
// Extra flags (e.g. "-s3.allowDeleteBucketNotEmpty=false") can be appended via extraArgs.
|
||||
func startMiniCluster(t *testing.T, extraArgs ...string) (*TestCluster, error) {
|
||||
// Allocate non-colliding ports (including gRPC offsets) for weed mini.
|
||||
ports := testutil.MustFreeMiniPorts(t, []string{"Master", "Volume", "Filer", "S3"})
|
||||
masterPort := ports[0]
|
||||
volumePort := ports[1]
|
||||
filerPort := ports[2]
|
||||
s3Port := ports[3]
|
||||
// Create temporary directory for test data
|
||||
testDir := t.TempDir()
|
||||
|
||||
// Ensure no configuration file from previous runs
|
||||
configFile := filepath.Join(testDir, "mini.options")
|
||||
_ = os.Remove(configFile)
|
||||
|
||||
// Create context with timeout
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
s3Endpoint := fmt.Sprintf("http://127.0.0.1:%d", s3Port)
|
||||
cluster := &TestCluster{
|
||||
dataDir: testDir,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
masterPort: masterPort,
|
||||
volumePort: volumePort,
|
||||
filerPort: filerPort,
|
||||
s3Port: s3Port,
|
||||
s3Endpoint: s3Endpoint,
|
||||
}
|
||||
|
||||
// Create empty security.toml to disable JWT authentication in tests
|
||||
securityToml := filepath.Join(testDir, "security.toml")
|
||||
err := os.WriteFile(securityToml, []byte("# Empty security config for testing\n"), 0644)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("failed to create security.toml: %v", err)
|
||||
}
|
||||
|
||||
// Set environment variables for admin credentials safely for this test
|
||||
if os.Getenv("AWS_ACCESS_KEY_ID") == "" {
|
||||
t.Setenv("AWS_ACCESS_KEY_ID", "admin")
|
||||
}
|
||||
if os.Getenv("AWS_SECRET_ACCESS_KEY") == "" {
|
||||
t.Setenv("AWS_SECRET_ACCESS_KEY", "admin")
|
||||
}
|
||||
|
||||
// Start weed mini in a goroutine by calling the command directly
|
||||
cluster.wg.Add(1)
|
||||
go func() {
|
||||
defer cluster.wg.Done()
|
||||
|
||||
// Save current directory and args
|
||||
oldDir, _ := os.Getwd()
|
||||
oldArgs := os.Args
|
||||
defer func() {
|
||||
os.Chdir(oldDir)
|
||||
os.Args = oldArgs
|
||||
}()
|
||||
|
||||
// Change to test directory so mini picks up security.toml
|
||||
os.Chdir(testDir)
|
||||
|
||||
// Configure args for mini command
|
||||
// Note: When running via 'go test', os.Args[0] is the test binary
|
||||
// We need to make it look like we're running 'weed mini'
|
||||
os.Args = append([]string{
|
||||
"weed",
|
||||
"-dir=" + testDir,
|
||||
"-master.port=" + strconv.Itoa(masterPort),
|
||||
"-volume.port=" + strconv.Itoa(volumePort),
|
||||
"-filer.port=" + strconv.Itoa(filerPort),
|
||||
"-s3.port=" + strconv.Itoa(s3Port),
|
||||
"-webdav.port=0", // Disable WebDAV
|
||||
"-admin.ui=false", // Disable admin UI
|
||||
"-master.volumeSizeLimitMB=32", // Small volumes for testing
|
||||
"-ip=127.0.0.1",
|
||||
"-master.peers=none", // Faster startup
|
||||
"-s3.iam.readOnly=false", // Enable IAM write operations for tests
|
||||
}, extraArgs...)
|
||||
|
||||
// Suppress most logging during tests
|
||||
glog.MaxSize = 1024 * 1024
|
||||
|
||||
// Find and run the mini command
|
||||
// We simulate how main.go executes commands
|
||||
for _, cmd := range command.Commands {
|
||||
if cmd.Name() == "mini" && cmd.Run != nil {
|
||||
// Parse the flags for the mini command
|
||||
// Don't include "weed" in the args
|
||||
cmd.Flag.Parse(os.Args[1:])
|
||||
args := cmd.Flag.Args()
|
||||
cmd.Run(cmd, args)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for S3 service to be ready
|
||||
if !testutil.WaitForService(cluster.s3Endpoint, 30*time.Second) {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("S3 service failed to start at %s", cluster.s3Endpoint)
|
||||
}
|
||||
|
||||
// If VOLUME_SERVER_IMPL=rust, start a Rust volume server alongside weed mini
|
||||
if os.Getenv("VOLUME_SERVER_IMPL") == "rust" {
|
||||
if err := cluster.startRustVolumeServer(t); err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("failed to start Rust volume server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
cluster.isRunning = true
|
||||
|
||||
// Create S3 client
|
||||
sess, err := session.NewSession(&aws.Config{
|
||||
Region: aws.String(testRegion),
|
||||
Endpoint: aws.String(cluster.s3Endpoint),
|
||||
DisableSSL: aws.Bool(true),
|
||||
S3ForcePathStyle: aws.Bool(true),
|
||||
Credentials: credentials.NewStaticCredentials(testAccessKey, testSecretKey, ""),
|
||||
})
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("failed to create AWS session: %v", err)
|
||||
}
|
||||
|
||||
cluster.s3Client = s3.New(sess)
|
||||
|
||||
t.Logf("Test cluster started successfully at %s", cluster.s3Endpoint)
|
||||
return cluster, nil
|
||||
}
|
||||
|
||||
// startRustVolumeServer starts a Rust volume server that registers with the same master.
|
||||
func (c *TestCluster) startRustVolumeServer(t *testing.T) error {
|
||||
t.Helper()
|
||||
|
||||
rustBinary, err := framework.FindOrBuildRustBinary()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve rust volume binary: %v", err)
|
||||
}
|
||||
|
||||
rustPorts, err := testutil.AllocatePorts(2)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find rust volume ports: %v", err)
|
||||
}
|
||||
rustVolumePort := rustPorts[0]
|
||||
rustVolumeGrpcPort := rustPorts[1]
|
||||
|
||||
rustVolumeDir := filepath.Join(c.dataDir, "rust-volume")
|
||||
if err := os.MkdirAll(rustVolumeDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create rust volume dir: %v", err)
|
||||
}
|
||||
|
||||
securityToml := filepath.Join(c.dataDir, "security.toml")
|
||||
|
||||
args := []string{
|
||||
"--port", strconv.Itoa(rustVolumePort),
|
||||
"--port.grpc", strconv.Itoa(rustVolumeGrpcPort),
|
||||
"--port.public", strconv.Itoa(rustVolumePort),
|
||||
"--ip", "127.0.0.1",
|
||||
"--ip.bind", "127.0.0.1",
|
||||
"--dir", rustVolumeDir,
|
||||
"--max", "16",
|
||||
"--master", "127.0.0.1:" + strconv.Itoa(c.masterPort),
|
||||
"--securityFile", securityToml,
|
||||
"--preStopSeconds", "0",
|
||||
}
|
||||
|
||||
logFile, err := os.Create(filepath.Join(c.dataDir, "rust-volume.log"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create rust volume log: %v", err)
|
||||
}
|
||||
|
||||
c.rustVolumeCmd = exec.Command(rustBinary, args...)
|
||||
c.rustVolumeCmd.Dir = c.dataDir
|
||||
c.rustVolumeCmd.Stdout = logFile
|
||||
c.rustVolumeCmd.Stderr = logFile
|
||||
if err := c.rustVolumeCmd.Start(); err != nil {
|
||||
logFile.Close()
|
||||
return fmt.Errorf("start rust volume: %v", err)
|
||||
}
|
||||
|
||||
// Wait for the Rust volume server to be ready
|
||||
rustEndpoint := fmt.Sprintf("http://127.0.0.1:%d/healthz", rustVolumePort)
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
client := &http.Client{Timeout: 1 * time.Second}
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := client.Get(rustEndpoint)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
t.Logf("Rust volume server ready on port %d (grpc %d)", rustVolumePort, rustVolumeGrpcPort)
|
||||
return nil
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("rust volume server not ready after 15s (port %d)", rustVolumePort)
|
||||
}
|
||||
|
||||
// Stop stops the test cluster
|
||||
func (c *TestCluster) Stop() {
|
||||
// Stop Rust volume server first
|
||||
if c.rustVolumeCmd != nil && c.rustVolumeCmd.Process != nil {
|
||||
c.rustVolumeCmd.Process.Kill()
|
||||
c.rustVolumeCmd.Wait()
|
||||
}
|
||||
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
// Give services time to shut down gracefully
|
||||
if c.isRunning {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
// Wait for the mini goroutine to finish
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
c.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
// Goroutine finished
|
||||
case <-time.After(2 * time.Second):
|
||||
// Timeout - goroutine doesn't respond to context cancel
|
||||
}
|
||||
|
||||
// Reset the global cmdMini flags to prevent state leakage to other tests
|
||||
for _, cmd := range command.Commands {
|
||||
if cmd.Name() == "mini" {
|
||||
// Reset flags to defaults
|
||||
cmd.Flag.VisitAll(func(f *flag.Flag) {
|
||||
// Reset to default value
|
||||
f.Value.Set(f.DefValue)
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test functions
|
||||
|
||||
func testCreateBucket(t *testing.T, cluster *TestCluster) {
|
||||
bucketName := "test-bucket-" + randomString(8)
|
||||
|
||||
_, err := cluster.s3Client.CreateBucket(&s3.CreateBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err, "Failed to create bucket")
|
||||
|
||||
// Wait a bit for bucket to be fully created
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Verify bucket exists by trying to head it
|
||||
// Note: ListBuckets may not immediately show new buckets in SeaweedFS
|
||||
_, err = cluster.s3Client.HeadBucket(&s3.HeadBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err, "Bucket should be accessible via HeadBucket")
|
||||
|
||||
t.Logf("✓ Created bucket: %s", bucketName)
|
||||
}
|
||||
|
||||
func testPutObject(t *testing.T, cluster *TestCluster) {
|
||||
bucketName := "test-put-" + randomString(8)
|
||||
objectKey := "test-object.txt"
|
||||
objectData := "Hello, SeaweedFS S3!"
|
||||
|
||||
// Create bucket
|
||||
_, err := cluster.s3Client.CreateBucket(&s3.CreateBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait a bit for bucket to be fully created
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Put object
|
||||
_, err = cluster.s3Client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: bytes.NewReader([]byte(objectData)),
|
||||
})
|
||||
require.NoError(t, err, "Failed to put object")
|
||||
|
||||
// Verify object exists
|
||||
headResp, err := cluster.s3Client.HeadObject(&s3.HeadObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, headResp.ContentLength)
|
||||
assert.Equal(t, int64(len(objectData)), aws.Int64Value(headResp.ContentLength))
|
||||
|
||||
t.Logf("✓ Put object: %s/%s (%d bytes)", bucketName, objectKey, len(objectData))
|
||||
}
|
||||
|
||||
func createTestBucket(t *testing.T, cluster *TestCluster, prefix string) string {
|
||||
bucketName := prefix + randomString(8)
|
||||
_, err := cluster.s3Client.CreateBucket(&s3.CreateBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
return bucketName
|
||||
}
|
||||
|
||||
// generateSSECKey returns a 32-byte key as a raw string (what the SDK expects
|
||||
// for SSECustomerKey) and its base64-encoded MD5 (for SSECustomerKeyMD5).
|
||||
func generateSSECKey() (keyRaw, keyMD5B64 string) {
|
||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = byte(rng.Intn(256))
|
||||
}
|
||||
keyRaw = string(key)
|
||||
keyHash := md5.Sum(key)
|
||||
keyMD5B64 = base64.StdEncoding.EncodeToString(keyHash[:])
|
||||
return
|
||||
}
|
||||
|
||||
func testPutObjectWithChecksum(t *testing.T, cluster *TestCluster) {
|
||||
bucketName := createTestBucket(t, cluster, "test-put-checksum-")
|
||||
objectKey := "test-checksummed-object.txt"
|
||||
objectData := "Hello, SeaweedFS S3!"
|
||||
|
||||
correctMD5 := calculateMd5(objectData)
|
||||
incorrectMD5 := calculateMd5(objectData + "incorrect")
|
||||
|
||||
// Put object with incorrect MD5 should be rejected
|
||||
_, err := cluster.s3Client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: bytes.NewReader([]byte(objectData)),
|
||||
ContentMD5: aws.String(incorrectMD5),
|
||||
})
|
||||
assertBadDigestError(t, err, "PutObject should fail with incorrect MD5")
|
||||
|
||||
t.Logf("✓ Put object with incorrect MD5 rejected: %s/%s", bucketName, objectKey)
|
||||
|
||||
// Put object with correct MD5 should succeed
|
||||
_, err = cluster.s3Client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: bytes.NewReader([]byte(objectData)),
|
||||
ContentMD5: aws.String(correctMD5),
|
||||
})
|
||||
require.NoError(t, err, "Failed to put object")
|
||||
|
||||
// Verify object exists
|
||||
headResp, err := cluster.s3Client.HeadObject(&s3.HeadObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, headResp.ContentLength)
|
||||
assert.Equal(t, int64(len(objectData)), aws.Int64Value(headResp.ContentLength))
|
||||
|
||||
t.Logf("✓ Put object with correct MD5: %s/%s (%d bytes)", bucketName, objectKey, len(objectData))
|
||||
}
|
||||
|
||||
// putObjectSSEC sends a PutObject request with SSE-C headers over HTTP.
|
||||
// The AWS SDK v1 refuses to send SSE-C keys over plain HTTP, so we use the
|
||||
// low-level Request API and clear the Validate handlers to bypass that check.
|
||||
// We use Clear() because the specific validator is internal and not easily removable by name.
|
||||
func putObjectSSEC(client *s3.S3, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||
req, output := client.PutObjectRequest(input)
|
||||
req.Handlers.Validate.Clear()
|
||||
err := req.Send()
|
||||
return output, err
|
||||
}
|
||||
|
||||
func headObjectSSEC(client *s3.S3, input *s3.HeadObjectInput) (*s3.HeadObjectOutput, error) {
|
||||
req, output := client.HeadObjectRequest(input)
|
||||
req.Handlers.Validate.Clear()
|
||||
err := req.Send()
|
||||
return output, err
|
||||
}
|
||||
|
||||
func testPutObjectWithChecksumAndSSEC(t *testing.T, cluster *TestCluster) {
|
||||
bucketName := createTestBucket(t, cluster, "test-put-checksum-ssec-")
|
||||
objectKey := "test-checksummed-ssec-object.txt"
|
||||
objectData := "Hello, SeaweedFS S3 with SSE-C!"
|
||||
|
||||
correctMD5 := calculateMd5(objectData)
|
||||
incorrectMD5 := calculateMd5(objectData + "incorrect")
|
||||
keyRaw, keyMD5B64 := generateSSECKey()
|
||||
|
||||
// Put object with SSE-C and incorrect MD5 should be rejected
|
||||
_, err := putObjectSSEC(cluster.s3Client, &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: bytes.NewReader([]byte(objectData)),
|
||||
ContentMD5: aws.String(incorrectMD5),
|
||||
SSECustomerAlgorithm: aws.String("AES256"),
|
||||
SSECustomerKey: aws.String(keyRaw),
|
||||
SSECustomerKeyMD5: aws.String(keyMD5B64),
|
||||
})
|
||||
assertBadDigestError(t, err, "PutObject with SSE-C should fail with incorrect MD5")
|
||||
|
||||
t.Logf("Put object with SSE-C and incorrect MD5 rejected: %s/%s", bucketName, objectKey)
|
||||
|
||||
// Put object with SSE-C and correct MD5 should succeed
|
||||
_, err = putObjectSSEC(cluster.s3Client, &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: bytes.NewReader([]byte(objectData)),
|
||||
ContentMD5: aws.String(correctMD5),
|
||||
SSECustomerAlgorithm: aws.String("AES256"),
|
||||
SSECustomerKey: aws.String(keyRaw),
|
||||
SSECustomerKeyMD5: aws.String(keyMD5B64),
|
||||
})
|
||||
require.NoError(t, err, "Failed to put object with SSE-C and correct MD5")
|
||||
|
||||
// Verify object exists (SSE-C requires the key for HeadObject too)
|
||||
headResp, err := headObjectSSEC(cluster.s3Client, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
SSECustomerAlgorithm: aws.String("AES256"),
|
||||
SSECustomerKey: aws.String(keyRaw),
|
||||
SSECustomerKeyMD5: aws.String(keyMD5B64),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, headResp.ContentLength)
|
||||
assert.Equal(t, int64(len(objectData)), aws.Int64Value(headResp.ContentLength))
|
||||
|
||||
t.Logf("Put object with SSE-C and correct MD5: %s/%s (%d bytes)", bucketName, objectKey, len(objectData))
|
||||
}
|
||||
|
||||
func testUploadPartWithChecksum(t *testing.T, cluster *TestCluster) {
|
||||
bucketName := createTestBucket(t, cluster, "test-upload-part-checksum-")
|
||||
objectKey := "test-multipart-checksum.txt"
|
||||
objectData := "Hello, SeaweedFS S3 Multipart!"
|
||||
|
||||
// Initiate multipart upload
|
||||
initResp, err := cluster.s3Client.CreateMultipartUpload(&s3.CreateMultipartUploadInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
uploadID := initResp.UploadId
|
||||
|
||||
correctMD5 := calculateMd5(objectData)
|
||||
incorrectMD5 := calculateMd5(objectData + "incorrect")
|
||||
|
||||
// Upload part with incorrect MD5
|
||||
_, err = cluster.s3Client.UploadPart(&s3.UploadPartInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
PartNumber: aws.Int64(1),
|
||||
UploadId: uploadID,
|
||||
Body: bytes.NewReader([]byte(objectData)),
|
||||
ContentMD5: aws.String(incorrectMD5),
|
||||
})
|
||||
assertBadDigestError(t, err, "UploadPart should fail with incorrect MD5")
|
||||
|
||||
// Upload part with correct MD5
|
||||
partResp, err := cluster.s3Client.UploadPart(&s3.UploadPartInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
PartNumber: aws.Int64(1),
|
||||
UploadId: uploadID,
|
||||
Body: bytes.NewReader([]byte(objectData)),
|
||||
ContentMD5: aws.String(correctMD5),
|
||||
})
|
||||
require.NoError(t, err, "Failed to upload part with correct MD5")
|
||||
|
||||
// Complete multipart upload
|
||||
_, err = cluster.s3Client.CompleteMultipartUpload(&s3.CompleteMultipartUploadInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
UploadId: uploadID,
|
||||
MultipartUpload: &s3.CompletedMultipartUpload{
|
||||
Parts: []*s3.CompletedPart{
|
||||
{
|
||||
ETag: partResp.ETag,
|
||||
PartNumber: aws.Int64(1),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "Failed to complete multipart upload")
|
||||
|
||||
// Verify object exists
|
||||
headResp, err := cluster.s3Client.HeadObject(&s3.HeadObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(len(objectData)), aws.Int64Value(headResp.ContentLength))
|
||||
|
||||
t.Logf("✓ Multipart upload with checksum successful: %s/%s", bucketName, objectKey)
|
||||
}
|
||||
|
||||
func testPutPartWithChecksum(t *testing.T, cluster *TestCluster) {
|
||||
bucketName := createTestBucket(t, cluster, "test-put-checksum-")
|
||||
objectKey := "test-checksummed-part.txt"
|
||||
|
||||
partData := "Hello, SeaweedFS S3!"
|
||||
|
||||
correctMD5 := calculateMd5(partData)
|
||||
incorrectMD5 := calculateMd5(partData + "incorrect")
|
||||
|
||||
createResp, err := cluster.s3Client.CreateMultipartUpload(&s3.CreateMultipartUploadInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
uploadID := createResp.UploadId
|
||||
|
||||
partBody := []byte(partData)
|
||||
|
||||
_, err = cluster.s3Client.UploadPart(&s3.UploadPartInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
UploadId: uploadID,
|
||||
PartNumber: aws.Int64(1),
|
||||
Body: bytes.NewReader(partBody),
|
||||
ContentMD5: aws.String(incorrectMD5),
|
||||
})
|
||||
assertBadDigestError(t, err, "UploadPart should fail with incorrect MD5")
|
||||
|
||||
uploadResp, err := cluster.s3Client.UploadPart(&s3.UploadPartInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
UploadId: uploadID,
|
||||
PartNumber: aws.Int64(1),
|
||||
Body: bytes.NewReader(partBody),
|
||||
ContentMD5: aws.String(correctMD5),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = cluster.s3Client.CompleteMultipartUpload(&s3.CompleteMultipartUploadInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
UploadId: uploadID,
|
||||
MultipartUpload: &s3.CompletedMultipartUpload{
|
||||
Parts: []*s3.CompletedPart{
|
||||
{
|
||||
ETag: uploadResp.ETag,
|
||||
PartNumber: aws.Int64(1),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "Failed to complete multipart upload")
|
||||
|
||||
// Verify object exists
|
||||
headResp, err := cluster.s3Client.HeadObject(&s3.HeadObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(len(partData)), aws.Int64Value(headResp.ContentLength))
|
||||
|
||||
t.Logf("✓ UploadPart with MD5 validation: %s/%s", bucketName, objectKey)
|
||||
}
|
||||
|
||||
func calculateMd5(objectData string) string {
|
||||
dataBytes := []byte(objectData)
|
||||
hash := md5.Sum(dataBytes)
|
||||
return base64.StdEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func assertBadDigestError(t *testing.T, err error, description string) {
|
||||
require.Error(t, err, description)
|
||||
|
||||
var awsErr awserr.Error
|
||||
require.ErrorAs(t, err, &awsErr)
|
||||
assert.Equal(t, "BadDigest", awsErr.Code())
|
||||
}
|
||||
|
||||
func testGetObject(t *testing.T, cluster *TestCluster) {
|
||||
bucketName := "test-get-" + randomString(8)
|
||||
objectKey := "test-data.txt"
|
||||
objectData := "This is test data for GET operation"
|
||||
|
||||
// Create bucket and put object
|
||||
_, err := cluster.s3Client.CreateBucket(&s3.CreateBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait a bit for bucket to be fully created
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
_, err = cluster.s3Client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: bytes.NewReader([]byte(objectData)),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait a bit for object to be fully written
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
// Verify object metadata via HeadObject (more reliable than GetObject in mini mode)
|
||||
headResp, err := cluster.s3Client.HeadObject(&s3.HeadObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Failed to head object")
|
||||
assert.NotNil(t, headResp.ContentLength)
|
||||
assert.Equal(t, int64(len(objectData)), aws.Int64Value(headResp.ContentLength))
|
||||
|
||||
t.Logf("✓ Got object metadata: %s/%s (verified %d bytes via HEAD)", bucketName, objectKey, len(objectData))
|
||||
|
||||
// Note: GetObject can sometimes have volume location issues in mini mode during tests
|
||||
// The object is correctly stored (as verified by HEAD), which demonstrates S3 functionality
|
||||
}
|
||||
|
||||
func testListObjects(t *testing.T, cluster *TestCluster) {
|
||||
bucketName := "test-list-" + randomString(8)
|
||||
|
||||
// Create bucket
|
||||
_, err := cluster.s3Client.CreateBucket(&s3.CreateBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Put multiple objects
|
||||
objectKeys := []string{"file1.txt", "file2.txt", "file3.txt"}
|
||||
for _, key := range objectKeys {
|
||||
_, err = cluster.s3Client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(key),
|
||||
Body: bytes.NewReader([]byte("test data for " + key)),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// List objects
|
||||
listResp, err := cluster.s3Client.ListObjectsV2(&s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err, "Failed to list objects")
|
||||
|
||||
// Verify all objects are listed
|
||||
assert.Equal(t, len(objectKeys), len(listResp.Contents), "Should list all objects")
|
||||
|
||||
foundKeys := make(map[string]bool)
|
||||
for _, obj := range listResp.Contents {
|
||||
foundKeys[aws.StringValue(obj.Key)] = true
|
||||
}
|
||||
|
||||
for _, key := range objectKeys {
|
||||
assert.True(t, foundKeys[key], "Object %s should be in list", key)
|
||||
}
|
||||
|
||||
t.Logf("✓ Listed %d objects in bucket: %s", len(listResp.Contents), bucketName)
|
||||
}
|
||||
|
||||
func testDeleteObject(t *testing.T, cluster *TestCluster) {
|
||||
bucketName := "test-delete-" + randomString(8)
|
||||
objectKey := "to-be-deleted.txt"
|
||||
|
||||
// Create bucket and put object
|
||||
_, err := cluster.s3Client.CreateBucket(&s3.CreateBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = cluster.s3Client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: bytes.NewReader([]byte("This will be deleted")),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Delete object
|
||||
_, err = cluster.s3Client.DeleteObject(&s3.DeleteObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Failed to delete object")
|
||||
|
||||
// Verify object is gone
|
||||
_, err = cluster.s3Client.HeadObject(&s3.HeadObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
assert.Error(t, err, "Object should not exist after deletion")
|
||||
|
||||
t.Logf("✓ Deleted object: %s/%s", bucketName, objectKey)
|
||||
}
|
||||
|
||||
func testDeleteDirectoryMarkerWithChildren(t *testing.T, cluster *TestCluster) {
|
||||
bucketName := createTestBucket(t, cluster, "test-delete-dir-marker-")
|
||||
childKey := "test-content/file1.txt"
|
||||
directoryMarkerKey := "test-content/"
|
||||
|
||||
_, err := cluster.s3Client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(childKey),
|
||||
Body: bytes.NewReader([]byte("child")),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = cluster.s3Client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(directoryMarkerKey),
|
||||
Body: bytes.NewReader(nil),
|
||||
ContentType: aws.String("application/octet-stream"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = cluster.s3Client.DeleteObject(&s3.DeleteObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(directoryMarkerKey),
|
||||
})
|
||||
require.NoError(t, err, "Deleting a directory marker should succeed even when children exist")
|
||||
|
||||
listResp, err := cluster.s3Client.ListObjectsV2(&s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(bucketName),
|
||||
Prefix: aws.String("test-content/"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
foundKeys := make(map[string]bool)
|
||||
for _, obj := range listResp.Contents {
|
||||
foundKeys[aws.StringValue(obj.Key)] = true
|
||||
}
|
||||
|
||||
assert.True(t, foundKeys[childKey], "Child object should remain after deleting the directory marker")
|
||||
assert.False(t, foundKeys[directoryMarkerKey], "Directory marker should no longer be listed after deletion")
|
||||
}
|
||||
|
||||
func testDeleteBucket(t *testing.T, cluster *TestCluster) {
|
||||
bucketName := "test-delete-bucket-" + randomString(8)
|
||||
|
||||
// Create bucket
|
||||
_, err := cluster.s3Client.CreateBucket(&s3.CreateBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Delete bucket
|
||||
_, err = cluster.s3Client.DeleteBucket(&s3.DeleteBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err, "Failed to delete bucket")
|
||||
|
||||
// Verify bucket is gone
|
||||
resp, err := cluster.s3Client.ListBuckets(&s3.ListBucketsInput{})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, bucket := range resp.Buckets {
|
||||
assert.NotEqual(t, bucketName, aws.StringValue(bucket.Name), "Bucket should not exist after deletion")
|
||||
}
|
||||
|
||||
t.Logf("✓ Deleted bucket: %s", bucketName)
|
||||
}
|
||||
|
||||
func TestS3DeleteBucketNotEmpty(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
cluster, err := startMiniCluster(t, "-s3.allowDeleteBucketNotEmpty=false")
|
||||
require.NoError(t, err)
|
||||
defer cluster.Stop()
|
||||
|
||||
t.Run("DeleteNonEmptyBucketFails", func(t *testing.T) {
|
||||
bucketName := createTestBucket(t, cluster, "test-notempty-")
|
||||
objectKey := "keep-me.txt"
|
||||
|
||||
// Put an object so the bucket is non-empty
|
||||
_, err := cluster.s3Client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: bytes.NewReader([]byte("data")),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Attempt to delete the non-empty bucket — must fail with BucketNotEmpty (409)
|
||||
_, err = cluster.s3Client.DeleteBucket(&s3.DeleteBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.Error(t, err, "deleting a non-empty bucket should fail")
|
||||
var awsErr awserr.Error
|
||||
require.ErrorAs(t, err, &awsErr)
|
||||
assert.Equal(t, "BucketNotEmpty", awsErr.Code(),
|
||||
"expected BucketNotEmpty error code, got %s: %s", awsErr.Code(), awsErr.Message())
|
||||
})
|
||||
|
||||
t.Run("DeleteEmptyBucketSucceeds", func(t *testing.T) {
|
||||
bucketName := createTestBucket(t, cluster, "test-empty-")
|
||||
|
||||
// Delete the empty bucket — should succeed even with the flag
|
||||
_, err := cluster.s3Client.DeleteBucket(&s3.DeleteBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err, "deleting an empty bucket should succeed")
|
||||
})
|
||||
}
|
||||
|
||||
// randomString generates a random string for unique naming
|
||||
func randomString(length int) string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
b := make([]byte, length)
|
||||
for i := range b {
|
||||
b[i] = charset[rng.Intn(len(charset))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
220
test/s3/normal/s3_list_buckets_test.go
Normal file
220
test/s3/normal/s3_list_buckets_test.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package example
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
v2config "github.com/aws/aws-sdk-go-v2/config"
|
||||
v2creds "github.com/aws/aws-sdk-go-v2/credentials"
|
||||
s3v2 "github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
s3v2types "github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestListBucketsPaginationAndOwnerIndex exercises ListBuckets pagination
|
||||
// (max-buckets, continuation-token, prefix) and the owner-index path that
|
||||
// serves non-admin identities without scanning all buckets.
|
||||
func TestListBucketsPaginationAndOwnerIndex(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
s3Config := `{
|
||||
"identities": [
|
||||
{"name": "admin", "credentials": [{"accessKey": "admin", "secretKey": "admin"}], "actions": ["Admin"]},
|
||||
{"name": "alice", "credentials": [{"accessKey": "alice", "secretKey": "alice_secret"}],
|
||||
"actions": ["Admin:alice-b1", "Admin:alice-b2", "Admin:alice-b3"]},
|
||||
{"name": "carol", "credentials": [{"accessKey": "carol", "secretKey": "carol_secret"}],
|
||||
"actions": ["List:alice-b1"]},
|
||||
{"name": "bob", "credentials": [{"accessKey": "bob", "secretKey": "bob_secret"}],
|
||||
"actions": ["Read:alice-b1"]},
|
||||
{"name": "dana", "credentials": [{"accessKey": "dana", "secretKey": "dana_secret"}],
|
||||
"policyNames": ["ReadAliceB1"]},
|
||||
{"name": "erin", "credentials": [{"accessKey": "erin", "secretKey": "erin_secret"}],
|
||||
"policyNames": ["ListEveryBucket"]}
|
||||
],
|
||||
"policies": [
|
||||
{"name": "ReadAliceB1", "content": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"s3:GetBucketLocation\",\"s3:ListBucket\"],\"Resource\":[\"arn:aws:s3:::alice-b1\"]},{\"Effect\":\"Allow\",\"Action\":[\"s3:GetObject\"],\"Resource\":[\"arn:aws:s3:::alice-b1/*\"]},{\"Effect\":\"Allow\",\"Action\":[\"s3:ListAllMyBuckets\"],\"Resource\":\"*\"}]}"},
|
||||
{"name": "ListEveryBucket", "content": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"s3:ListBucket\"],\"Resource\":[\"arn:aws:s3:::*\"]}]}"}
|
||||
]
|
||||
}`
|
||||
configPath := filepath.Join(t.TempDir(), "s3.json")
|
||||
require.NoError(t, os.WriteFile(configPath, []byte(s3Config), 0644))
|
||||
|
||||
cluster, err := startMiniCluster(t, "-s3.config="+configPath)
|
||||
require.NoError(t, err)
|
||||
defer cluster.Stop()
|
||||
|
||||
ctx := context.Background()
|
||||
newClient := func(key, secret string) *s3v2.Client {
|
||||
cfg, err := v2config.LoadDefaultConfig(ctx,
|
||||
v2config.WithRegion("us-east-1"),
|
||||
v2config.WithCredentialsProvider(v2creds.NewStaticCredentialsProvider(key, secret, "")),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
return s3v2.NewFromConfig(cfg, func(o *s3v2.Options) {
|
||||
o.BaseEndpoint = aws.String(cluster.s3Endpoint)
|
||||
o.UsePathStyle = true
|
||||
})
|
||||
}
|
||||
admin := newClient("admin", "admin")
|
||||
alice := newClient("alice", "alice_secret")
|
||||
carol := newClient("carol", "carol_secret")
|
||||
bob := newClient("bob", "bob_secret")
|
||||
dana := newClient("dana", "dana_secret")
|
||||
erin := newClient("erin", "erin_secret")
|
||||
|
||||
// Non-admin listings use the owner index once the backfill marker exists.
|
||||
markerURL := fmt.Sprintf("http://127.0.0.1:%d/buckets/.system/owners/.complete", cluster.filerPort)
|
||||
require.Eventually(t, func() bool {
|
||||
resp, err := http.Get(markerURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}, 15*time.Second, 200*time.Millisecond, "owner index backfill marker")
|
||||
|
||||
aliceBuckets := []string{"alice-b1", "alice-b2", "alice-b3"}
|
||||
for _, b := range aliceBuckets {
|
||||
_, err := alice.CreateBucket(ctx, &s3v2.CreateBucketInput{Bucket: aws.String(b)})
|
||||
require.NoError(t, err, "alice create %s", b)
|
||||
}
|
||||
var adminBuckets []string
|
||||
for i := 0; i < 5; i++ {
|
||||
name := fmt.Sprintf("admin-b%d", i)
|
||||
adminBuckets = append(adminBuckets, name)
|
||||
_, err := admin.CreateBucket(ctx, &s3v2.CreateBucketInput{Bucket: aws.String(name)})
|
||||
require.NoError(t, err, "admin create %s", name)
|
||||
}
|
||||
|
||||
t.Run("OwnerSeesOwnBuckets", func(t *testing.T) {
|
||||
out, err := alice.ListBuckets(ctx, &s3v2.ListBucketsInput{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, aliceBuckets, bucketNames(out.Buckets))
|
||||
assert.Nil(t, out.ContinuationToken)
|
||||
})
|
||||
|
||||
t.Run("OwnerPagination", func(t *testing.T) {
|
||||
page1, err := alice.ListBuckets(ctx, &s3v2.ListBucketsInput{MaxBuckets: aws.Int32(2)})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, aliceBuckets[:2], bucketNames(page1.Buckets))
|
||||
require.NotNil(t, page1.ContinuationToken)
|
||||
|
||||
page2, err := alice.ListBuckets(ctx, &s3v2.ListBucketsInput{
|
||||
MaxBuckets: aws.Int32(2),
|
||||
ContinuationToken: page1.ContinuationToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, aliceBuckets[2:], bucketNames(page2.Buckets))
|
||||
assert.Nil(t, page2.ContinuationToken)
|
||||
})
|
||||
|
||||
t.Run("OwnerPrefix", func(t *testing.T) {
|
||||
out, err := alice.ListBuckets(ctx, &s3v2.ListBucketsInput{Prefix: aws.String("alice-b2")})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"alice-b2"}, bucketNames(out.Buckets))
|
||||
})
|
||||
|
||||
t.Run("GrantedListVisible", func(t *testing.T) {
|
||||
out, err := carol.ListBuckets(ctx, &s3v2.ListBucketsInput{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"alice-b1"}, bucketNames(out.Buckets))
|
||||
})
|
||||
|
||||
t.Run("ReadGrantNotVisible", func(t *testing.T) {
|
||||
out, err := bob.ListBuckets(ctx, &s3v2.ListBucketsInput{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, bucketNames(out.Buckets))
|
||||
})
|
||||
|
||||
// An attached IAM policy grants listing on a bucket the identity never
|
||||
// created, so ListBuckets has to report it.
|
||||
t.Run("PolicyGrantVisible", func(t *testing.T) {
|
||||
out, err := dana.ListBuckets(ctx, &s3v2.ListBucketsInput{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"alice-b1"}, bucketNames(out.Buckets))
|
||||
|
||||
_, err = dana.ListObjectsV2(ctx, &s3v2.ListObjectsV2Input{Bucket: aws.String("alice-b1")})
|
||||
assert.NoError(t, err, "the same policy authorizes reads")
|
||||
_, err = dana.ListObjectsV2(ctx, &s3v2.ListObjectsV2Input{Bucket: aws.String("alice-b3")})
|
||||
assert.Error(t, err, "and grants nothing beyond the bucket it names")
|
||||
})
|
||||
|
||||
t.Run("PolicyWildcardResourceVisible", func(t *testing.T) {
|
||||
out, err := erin.ListBuckets(ctx, &s3v2.ListBucketsInput{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, append(append([]string{}, adminBuckets...), aliceBuckets...), bucketNames(out.Buckets))
|
||||
})
|
||||
|
||||
t.Run("AdminPaginatesAll", func(t *testing.T) {
|
||||
var all []string
|
||||
var token *string
|
||||
for pages := 0; ; pages++ {
|
||||
require.Less(t, pages, 10, "runaway pagination")
|
||||
out, err := admin.ListBuckets(ctx, &s3v2.ListBucketsInput{MaxBuckets: aws.Int32(3), ContinuationToken: token})
|
||||
require.NoError(t, err)
|
||||
require.LessOrEqual(t, len(out.Buckets), 3)
|
||||
all = append(all, bucketNames(out.Buckets)...)
|
||||
if out.ContinuationToken == nil {
|
||||
break
|
||||
}
|
||||
token = out.ContinuationToken
|
||||
}
|
||||
assert.Equal(t, append(append([]string{}, adminBuckets...), aliceBuckets...), all)
|
||||
})
|
||||
|
||||
t.Run("AdminPrefix", func(t *testing.T) {
|
||||
out, err := admin.ListBuckets(ctx, &s3v2.ListBucketsInput{Prefix: aws.String("admin-")})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, adminBuckets, bucketNames(out.Buckets))
|
||||
})
|
||||
|
||||
t.Run("InvalidMaxBuckets", func(t *testing.T) {
|
||||
_, err := admin.ListBuckets(ctx, &s3v2.ListBucketsInput{MaxBuckets: aws.Int32(0)})
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("IndexEntriesOnFiler", func(t *testing.T) {
|
||||
indexURL := fmt.Sprintf("http://127.0.0.1:%d/buckets/.system/owners/alice/?limit=100", cluster.filerPort)
|
||||
req, err := http.NewRequest(http.MethodGet, indexURL, nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
for _, b := range aliceBuckets {
|
||||
assert.Contains(t, string(body), b)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DeleteRemovesFromListing", func(t *testing.T) {
|
||||
_, err := alice.DeleteBucket(ctx, &s3v2.DeleteBucketInput{Bucket: aws.String("alice-b2")})
|
||||
require.NoError(t, err)
|
||||
out, err := alice.ListBuckets(ctx, &s3v2.ListBucketsInput{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"alice-b1", "alice-b3"}, bucketNames(out.Buckets))
|
||||
})
|
||||
|
||||
t.Run("HiddenSystemBucket", func(t *testing.T) {
|
||||
_, err := admin.HeadBucket(ctx, &s3v2.HeadBucketInput{Bucket: aws.String(".system")})
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func bucketNames(buckets []s3v2types.Bucket) (names []string) {
|
||||
for _, b := range buckets {
|
||||
names = append(names, aws.ToString(b.Name))
|
||||
}
|
||||
return names
|
||||
}
|
||||
280
test/s3/normal/s3_list_empty_directory_test.go
Normal file
280
test/s3/normal/s3_list_empty_directory_test.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package example
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestS3ListObjectsEmptyDirectoryMarkers reproduces GitHub issue #8698:
|
||||
// S3 API ListObjects does not include empty directory markers (zero-byte
|
||||
// objects with keys ending in "/") created via PutObject.
|
||||
//
|
||||
// AWS S3 includes these markers as regular keys in Contents. SeaweedFS
|
||||
// was filtering them out during recursive directory listing.
|
||||
func TestS3ListObjectsEmptyDirectoryMarkers(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
cluster, err := startMiniCluster(t)
|
||||
require.NoError(t, err)
|
||||
defer cluster.Stop()
|
||||
|
||||
bucketName := createTestBucket(t, cluster, "test-empty-dirs-")
|
||||
|
||||
// Create a regular file at Empty/manifest.yaml
|
||||
_, err = cluster.s3Client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("Empty/manifest.yaml"),
|
||||
Body: bytes.NewReader([]byte("name: test\nversion: 1.0\n")),
|
||||
})
|
||||
require.NoError(t, err, "failed to create regular file")
|
||||
|
||||
// Create an empty directory marker via PutObject (key ending in "/", zero bytes).
|
||||
// This is how `aws s3api put-object --key "Empty/empty/"` works.
|
||||
_, err = cluster.s3Client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("Empty/empty/"),
|
||||
Body: bytes.NewReader([]byte{}),
|
||||
})
|
||||
require.NoError(t, err, "failed to create empty directory marker")
|
||||
|
||||
// Verify the directory marker exists via HeadObject
|
||||
_, err = cluster.s3Client.HeadObject(&s3.HeadObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("Empty/empty/"),
|
||||
})
|
||||
require.NoError(t, err, "directory marker should exist via HeadObject")
|
||||
|
||||
// Test 1: ListObjectsV2 with prefix (no delimiter) — the exact scenario from the issue.
|
||||
// AWS S3 returns both "Empty/empty/" and "Empty/manifest.yaml" in Contents.
|
||||
t.Run("ListV2_WithPrefix_NoDelimiter", func(t *testing.T) {
|
||||
resp, err := cluster.s3Client.ListObjectsV2(&s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(bucketName),
|
||||
Prefix: aws.String("Empty"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
keys := collectKeys(resp.Contents)
|
||||
sort.Strings(keys)
|
||||
|
||||
assert.Equal(t, []string{"Empty/empty/", "Empty/manifest.yaml"}, keys,
|
||||
"both the directory marker and regular file should be listed")
|
||||
})
|
||||
|
||||
// Test 2: ListObjectsV1 with prefix (no delimiter).
|
||||
t.Run("ListV1_WithPrefix_NoDelimiter", func(t *testing.T) {
|
||||
resp, err := cluster.s3Client.ListObjects(&s3.ListObjectsInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Prefix: aws.String("Empty"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
keys := collectKeysV1(resp.Contents)
|
||||
sort.Strings(keys)
|
||||
|
||||
assert.Equal(t, []string{"Empty/empty/", "Empty/manifest.yaml"}, keys,
|
||||
"both the directory marker and regular file should be listed")
|
||||
})
|
||||
|
||||
// Test 3: ListObjectsV2 without prefix and without delimiter.
|
||||
// All objects in the bucket should appear, including directory markers.
|
||||
t.Run("ListV2_NoPrefix_NoDelimiter", func(t *testing.T) {
|
||||
resp, err := cluster.s3Client.ListObjectsV2(&s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
keys := collectKeys(resp.Contents)
|
||||
|
||||
assert.Contains(t, keys, "Empty/empty/", "directory marker should appear in listing")
|
||||
assert.Contains(t, keys, "Empty/manifest.yaml", "regular file should appear in listing")
|
||||
})
|
||||
|
||||
// Test 4: ListObjectsV2 with prefix "Empty/" and delimiter "/".
|
||||
// The directory marker "Empty/empty/" should appear as a CommonPrefix,
|
||||
// and "Empty/manifest.yaml" should appear in Contents.
|
||||
t.Run("ListV2_WithPrefix_WithDelimiter", func(t *testing.T) {
|
||||
resp, err := cluster.s3Client.ListObjectsV2(&s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(bucketName),
|
||||
Prefix: aws.String("Empty/"),
|
||||
Delimiter: aws.String("/"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
keys := collectKeys(resp.Contents)
|
||||
prefixes := collectPrefixes(resp.CommonPrefixes)
|
||||
|
||||
assert.Contains(t, keys, "Empty/manifest.yaml", "regular file should be in Contents")
|
||||
assert.Contains(t, prefixes, "Empty/empty/", "directory marker should appear as CommonPrefix")
|
||||
})
|
||||
|
||||
// Test 5: Multiple empty directory markers at different nesting levels.
|
||||
t.Run("ListV2_NestedEmptyDirs", func(t *testing.T) {
|
||||
nestedBucket := createTestBucket(t, cluster, "test-nested-dirs-")
|
||||
|
||||
// Create nested structure:
|
||||
// dir/sub1/ (empty dir marker)
|
||||
// dir/sub2/deep/ (empty dir marker at deeper level)
|
||||
// dir/file.txt (regular file)
|
||||
_, err := cluster.s3Client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(nestedBucket),
|
||||
Key: aws.String("dir/sub1/"),
|
||||
Body: bytes.NewReader([]byte{}),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = cluster.s3Client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(nestedBucket),
|
||||
Key: aws.String("dir/sub2/deep/"),
|
||||
Body: bytes.NewReader([]byte{}),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = cluster.s3Client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(nestedBucket),
|
||||
Key: aws.String("dir/file.txt"),
|
||||
Body: bytes.NewReader([]byte("content")),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := cluster.s3Client.ListObjectsV2(&s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(nestedBucket),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
keys := collectKeys(resp.Contents)
|
||||
sort.Strings(keys)
|
||||
|
||||
assert.Equal(t, []string{"dir/file.txt", "dir/sub1/", "dir/sub2/deep/"}, keys,
|
||||
"all objects including nested empty directory markers should be listed")
|
||||
})
|
||||
|
||||
// Test 6: Directory marker with a child object, paginated with MaxKeys=1.
|
||||
// This exercises the emit-marker-then-recurse path under truncation:
|
||||
// the marker is emitted, maxKeys drops to 0, and the child must be
|
||||
// picked up on a subsequent page.
|
||||
t.Run("ListV2_MarkerWithChild_Paginated", func(t *testing.T) {
|
||||
siblingBucket := createTestBucket(t, cluster, "test-sibling-dirs-")
|
||||
|
||||
// Create:
|
||||
// docs/ (directory key object)
|
||||
// docs/readme.txt (child file inside the marker)
|
||||
// readme.txt (sibling file at top level)
|
||||
_, err := cluster.s3Client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(siblingBucket),
|
||||
Key: aws.String("docs/"),
|
||||
Body: bytes.NewReader([]byte{}),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = cluster.s3Client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(siblingBucket),
|
||||
Key: aws.String("docs/readme.txt"),
|
||||
Body: bytes.NewReader([]byte("inside docs")),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = cluster.s3Client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(siblingBucket),
|
||||
Key: aws.String("readme.txt"),
|
||||
Body: bytes.NewReader([]byte("hello")),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unpaginated: all three keys should appear.
|
||||
resp, err := cluster.s3Client.ListObjectsV2(&s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(siblingBucket),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
keys := collectKeys(resp.Contents)
|
||||
sort.Strings(keys)
|
||||
|
||||
assert.Equal(t, []string{"docs/", "docs/readme.txt", "readme.txt"}, keys,
|
||||
"directory marker, its child, and sibling file should all be listed")
|
||||
|
||||
// Paginated with MaxKeys=1: collect all keys across pages.
|
||||
var allKeys []string
|
||||
var token *string
|
||||
for {
|
||||
pageResp, pageErr := cluster.s3Client.ListObjectsV2(&s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(siblingBucket),
|
||||
MaxKeys: aws.Int64(1),
|
||||
ContinuationToken: token,
|
||||
})
|
||||
require.NoError(t, pageErr)
|
||||
allKeys = append(allKeys, collectKeys(pageResp.Contents)...)
|
||||
if !aws.BoolValue(pageResp.IsTruncated) {
|
||||
break
|
||||
}
|
||||
token = pageResp.NextContinuationToken
|
||||
}
|
||||
sort.Strings(allKeys)
|
||||
|
||||
assert.Equal(t, []string{"docs/", "docs/readme.txt", "readme.txt"}, allKeys,
|
||||
"paginated listing should return all keys including marker and its child")
|
||||
})
|
||||
|
||||
t.Run("ListExplicitDirectoryMarkerWithOctetStreamContentType", func(t *testing.T) {
|
||||
bucketWithContentType := createTestBucket(t, cluster, "test-explicit-dir-")
|
||||
objectKey := "test-content/empty/"
|
||||
|
||||
_, err := cluster.s3Client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketWithContentType),
|
||||
Key: aws.String(objectKey),
|
||||
Body: bytes.NewReader([]byte{}),
|
||||
ContentType: aws.String("application/octet-stream"),
|
||||
})
|
||||
require.NoError(t, err, "failed to create explicit directory marker with octet-stream content type")
|
||||
|
||||
headResp, err := cluster.s3Client.HeadObject(&s3.HeadObjectInput{
|
||||
Bucket: aws.String(bucketWithContentType),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "directory marker should exist via HeadObject")
|
||||
assert.Equal(t, "application/octet-stream", aws.StringValue(headResp.ContentType))
|
||||
|
||||
respV1, err := cluster.s3Client.ListObjects(&s3.ListObjectsInput{
|
||||
Bucket: aws.String(bucketWithContentType),
|
||||
Prefix: aws.String("test-content"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{objectKey}, collectKeysV1(respV1.Contents),
|
||||
"explicit empty directory markers with octet-stream content type should be listed")
|
||||
|
||||
respV2, err := cluster.s3Client.ListObjectsV2(&s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(bucketWithContentType),
|
||||
Prefix: aws.String("test-content"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{objectKey}, collectKeys(respV2.Contents),
|
||||
"explicit empty directory markers with octet-stream content type should be listed")
|
||||
})
|
||||
}
|
||||
|
||||
func collectKeys(contents []*s3.Object) []string {
|
||||
keys := make([]string, 0, len(contents))
|
||||
for _, obj := range contents {
|
||||
keys = append(keys, aws.StringValue(obj.Key))
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func collectKeysV1(contents []*s3.Object) []string {
|
||||
return collectKeys(contents)
|
||||
}
|
||||
|
||||
func collectPrefixes(prefixes []*s3.CommonPrefix) []string {
|
||||
result := make([]string, 0, len(prefixes))
|
||||
for _, p := range prefixes {
|
||||
result = append(result, aws.StringValue(p.Prefix))
|
||||
}
|
||||
return result
|
||||
}
|
||||
66
test/s3/normal/s3_list_multipart_uploads_test.go
Normal file
66
test/s3/normal/s3_list_multipart_uploads_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package example
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestListMultipartUploadsInitiated verifies that ListMultipartUploads
|
||||
// returns the Initiated timestamp for each in-progress upload, and that
|
||||
// the timestamp is preserved across repeated listings rather than
|
||||
// reflecting the time of the listing.
|
||||
func TestListMultipartUploadsInitiated(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
cluster, err := startMiniCluster(t)
|
||||
require.NoError(t, err)
|
||||
defer cluster.Stop()
|
||||
|
||||
bucket := createTestBucket(t, cluster, "test-list-mpu-initiated-")
|
||||
|
||||
beforeInit := time.Now().UTC()
|
||||
createOut, err := cluster.s3Client.CreateMultipartUpload(&s3.CreateMultipartUploadInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String("unfinished.bin"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
afterInit := time.Now().UTC()
|
||||
|
||||
listOut, err := cluster.s3Client.ListMultipartUploads(&s3.ListMultipartUploadsInput{
|
||||
Bucket: aws.String(bucket),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, listOut.Uploads, 1)
|
||||
|
||||
upload := listOut.Uploads[0]
|
||||
assert.Equal(t, "unfinished.bin", aws.StringValue(upload.Key))
|
||||
assert.Equal(t, aws.StringValue(createOut.UploadId), aws.StringValue(upload.UploadId))
|
||||
require.NotNil(t, upload.Initiated, "Initiated timestamp must be populated")
|
||||
|
||||
initiated := upload.Initiated.UTC()
|
||||
assert.False(t, initiated.Before(beforeInit.Add(-time.Second)), "Initiated %v is before upload creation %v", initiated, beforeInit)
|
||||
assert.False(t, initiated.After(afterInit.Add(time.Second)), "Initiated %v is after upload creation %v", initiated, afterInit)
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
listOut2, err := cluster.s3Client.ListMultipartUploads(&s3.ListMultipartUploadsInput{
|
||||
Bucket: aws.String(bucket),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, listOut2.Uploads, 1)
|
||||
require.NotNil(t, listOut2.Uploads[0].Initiated, "Initiated timestamp must be populated on repeated listing")
|
||||
assert.True(t, listOut2.Uploads[0].Initiated.Equal(*upload.Initiated), "Initiated must be preserved across listings, got %v then %v", *upload.Initiated, *listOut2.Uploads[0].Initiated)
|
||||
|
||||
_, err = cluster.s3Client.AbortMultipartUpload(&s3.AbortMultipartUploadInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String("unfinished.bin"),
|
||||
UploadId: createOut.UploadId,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
546
test/s3/normal/s3_prefix_object_test.go
Normal file
546
test/s3/normal/s3_prefix_object_test.go
Normal file
@@ -0,0 +1,546 @@
|
||||
package example
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
v1credentials "github.com/aws/aws-sdk-go/aws/credentials"
|
||||
v1signer "github.com/aws/aws-sdk-go/aws/signer/v4"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestS3PrefixObjectKeys covers keys that are a strict prefix of other keys: S3's
|
||||
// namespace is flat, so "collision/foo" and "collision/foo/bar" are independent
|
||||
// objects that coexist in either write order.
|
||||
func TestS3PrefixObjectKeys(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
cluster, err := startMiniCluster(t)
|
||||
require.NoError(t, err)
|
||||
defer cluster.Stop()
|
||||
|
||||
put := func(t *testing.T, bucket, key string, body []byte) {
|
||||
t.Helper()
|
||||
_, err := cluster.s3Client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
Body: bytes.NewReader(body),
|
||||
})
|
||||
require.NoError(t, err, "put %s", key)
|
||||
}
|
||||
// read checks both paths a client reaches an object by, since a directory entry
|
||||
// carrying an object is served by neither the directory nor the plain object path
|
||||
// alone.
|
||||
read := func(t *testing.T, bucket, key string, want []byte) {
|
||||
t.Helper()
|
||||
head, err := cluster.s3Client.HeadObject(&s3.HeadObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
require.NoError(t, err, "head %s", key)
|
||||
assert.Equal(t, int64(len(want)), aws.Int64Value(head.ContentLength), "head %s", key)
|
||||
|
||||
get, err := cluster.s3Client.GetObject(&s3.GetObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
require.NoError(t, err, "get %s", key)
|
||||
defer get.Body.Close()
|
||||
got, err := io.ReadAll(get.Body)
|
||||
require.NoError(t, err, "read %s", key)
|
||||
assert.Equal(t, want, got, "get %s", key)
|
||||
}
|
||||
// gone checks the key answers as absent rather than lingering on a directory
|
||||
// entry that outlived the object.
|
||||
gone := func(t *testing.T, bucket, key string) {
|
||||
t.Helper()
|
||||
_, err := cluster.s3Client.GetObject(&s3.GetObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
var missing awserr.RequestFailure
|
||||
require.ErrorAs(t, err, &missing, "get %s", key)
|
||||
assert.Equal(t, http.StatusNotFound, missing.StatusCode(), "get %s", key)
|
||||
|
||||
_, err = cluster.s3Client.HeadObject(&s3.HeadObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
require.ErrorAs(t, err, &missing, "head %s", key)
|
||||
assert.Equal(t, http.StatusNotFound, missing.StatusCode(), "head %s", key)
|
||||
}
|
||||
listKeys := func(t *testing.T, bucket string) []string {
|
||||
t.Helper()
|
||||
resp, err := cluster.s3Client.ListObjectsV2(&s3.ListObjectsV2Input{Bucket: aws.String(bucket)})
|
||||
require.NoError(t, err)
|
||||
keys := collectKeys(resp.Contents)
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
body := []byte("prefix object")
|
||||
// Distinct bodies, so a read that resolves to the wrong entry cannot pass.
|
||||
nested := []byte("nested under the prefix object")
|
||||
|
||||
// The reported order: the nested key is written first, so the prefix key has to
|
||||
// land on a path the filer already holds a directory at.
|
||||
t.Run("ChildFirst", func(t *testing.T) {
|
||||
bucket := createTestBucket(t, cluster, "test-prefix-child-first-")
|
||||
put(t, bucket, "collision/foo/bar", nested)
|
||||
put(t, bucket, "collision/foo", body)
|
||||
|
||||
assert.Equal(t, []string{"collision/foo", "collision/foo/bar"}, listKeys(t, bucket))
|
||||
read(t, bucket, "collision/foo", body)
|
||||
read(t, bucket, "collision/foo/bar", nested)
|
||||
})
|
||||
|
||||
// The opposite order used to keep the prefix key's data but hide the key.
|
||||
t.Run("PrefixFirst", func(t *testing.T) {
|
||||
bucket := createTestBucket(t, cluster, "test-prefix-first-")
|
||||
put(t, bucket, "collision/foo", body)
|
||||
put(t, bucket, "collision/foo/bar", nested)
|
||||
|
||||
assert.Equal(t, []string{"collision/foo", "collision/foo/bar"}, listKeys(t, bucket))
|
||||
read(t, bucket, "collision/foo", body)
|
||||
read(t, bucket, "collision/foo/bar", nested)
|
||||
})
|
||||
|
||||
// An empty object leaves no chunks, content or mime behind, so it is the case a
|
||||
// promoted directory carries no other trace of.
|
||||
t.Run("EmptyObject", func(t *testing.T) {
|
||||
bucket := createTestBucket(t, cluster, "test-prefix-empty-")
|
||||
put(t, bucket, "a/foo/bar", nil)
|
||||
put(t, bucket, "a/foo", nil)
|
||||
put(t, bucket, "b/foo", nil)
|
||||
put(t, bucket, "b/foo/bar", nil)
|
||||
|
||||
assert.Equal(t, []string{"a/foo", "a/foo/bar", "b/foo", "b/foo/bar"}, listKeys(t, bucket))
|
||||
read(t, bucket, "a/foo", []byte{})
|
||||
read(t, bucket, "b/foo", []byte{})
|
||||
})
|
||||
|
||||
// The key has no trailing slash, and the keys nested under it still roll up into
|
||||
// their own CommonPrefix.
|
||||
t.Run("Delimiter", func(t *testing.T) {
|
||||
bucket := createTestBucket(t, cluster, "test-prefix-delimiter-")
|
||||
put(t, bucket, "collision/foo/bar", nested)
|
||||
put(t, bucket, "collision/foo", body)
|
||||
put(t, bucket, "collision/other", body)
|
||||
|
||||
resp, err := cluster.s3Client.ListObjectsV2(&s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(bucket),
|
||||
Prefix: aws.String("collision/"),
|
||||
Delimiter: aws.String("/"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
keys := collectKeys(resp.Contents)
|
||||
sort.Strings(keys)
|
||||
assert.Equal(t, []string{"collision/foo", "collision/other"}, keys)
|
||||
assert.Equal(t, []string{"collision/foo/"}, collectPrefixes(resp.CommonPrefixes))
|
||||
|
||||
// Listing the prefix itself names only what is under it.
|
||||
resp, err = cluster.s3Client.ListObjectsV2(&s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(bucket),
|
||||
Prefix: aws.String("collision/foo/"),
|
||||
Delimiter: aws.String("/"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"collision/foo/bar"}, collectKeys(resp.Contents))
|
||||
assert.Empty(t, collectPrefixes(resp.CommonPrefixes))
|
||||
})
|
||||
|
||||
// The key and the CommonPrefix its nested keys fold into come off one filer
|
||||
// entry, so a page boundary must not drop either of them.
|
||||
t.Run("Paged", func(t *testing.T) {
|
||||
bucket := createTestBucket(t, cluster, "test-prefix-paged-")
|
||||
for _, key := range []string{"foo", "foo/bar", "foobar", "other", "zed", "zed/a"} {
|
||||
put(t, bucket, key, body)
|
||||
}
|
||||
|
||||
for _, maxKeys := range []int64{1, 2, 3, 4, 5} {
|
||||
var keys, prefixes []string
|
||||
var token *string
|
||||
for page := 0; page < 12; page++ {
|
||||
resp, err := cluster.s3Client.ListObjectsV2(&s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(bucket),
|
||||
Delimiter: aws.String("/"),
|
||||
MaxKeys: aws.Int64(maxKeys),
|
||||
ContinuationToken: token,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// One entry over the budget is the documented same-entry exception;
|
||||
// anything more means the unsigned budget wrapped.
|
||||
assert.LessOrEqual(t, int64(len(resp.Contents)+len(resp.CommonPrefixes)), maxKeys+1,
|
||||
"maxKeys=%d page %d", maxKeys, page)
|
||||
keys = append(keys, collectKeys(resp.Contents)...)
|
||||
prefixes = append(prefixes, collectPrefixes(resp.CommonPrefixes)...)
|
||||
if !aws.BoolValue(resp.IsTruncated) {
|
||||
token = nil
|
||||
break
|
||||
}
|
||||
token = resp.NextContinuationToken
|
||||
require.NotNil(t, token, "a truncated page must name where to resume")
|
||||
}
|
||||
require.Nil(t, token, "maxKeys=%d did not finish", maxKeys)
|
||||
assert.Equal(t, []string{"foo", "foobar", "other", "zed"}, keys, "maxKeys=%d", maxKeys)
|
||||
assert.Equal(t, []string{"foo/", "zed/"}, prefixes, "maxKeys=%d", maxKeys)
|
||||
}
|
||||
})
|
||||
|
||||
// Versioning reaches a prefix object from two directions: a suspended bucket
|
||||
// writes the null version at the key's own path, and a bucket versioned later
|
||||
// finds one already sitting there. Both leave a key that is a directory with
|
||||
// version history beside it.
|
||||
t.Run("Versioned", func(t *testing.T) {
|
||||
setVersioning := func(t *testing.T, bucket, status string) {
|
||||
t.Helper()
|
||||
_, err := cluster.s3Client.PutBucketVersioning(&s3.PutBucketVersioningInput{
|
||||
Bucket: aws.String(bucket),
|
||||
VersioningConfiguration: &s3.VersioningConfiguration{Status: aws.String(status)},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Both write orders, in a bucket that is versioned and in one where versioning
|
||||
// was suspended - the suspended one is the case that writes at the key's path.
|
||||
for _, state := range []string{"Enabled", "Suspended"} {
|
||||
bucket := createTestBucket(t, cluster, "test-prefix-"+strings.ToLower(state)+"-")
|
||||
setVersioning(t, bucket, "Enabled")
|
||||
if state == "Suspended" {
|
||||
setVersioning(t, bucket, "Suspended")
|
||||
}
|
||||
put(t, bucket, "child/foo/bar", nested)
|
||||
put(t, bucket, "child/foo", body)
|
||||
put(t, bucket, "prefix/foo", body)
|
||||
put(t, bucket, "prefix/foo/bar", nested)
|
||||
|
||||
assert.Equal(t, []string{"child/foo", "child/foo/bar", "prefix/foo", "prefix/foo/bar"},
|
||||
listKeys(t, bucket), state)
|
||||
for _, key := range []string{"child/foo", "prefix/foo"} {
|
||||
read(t, bucket, key, body)
|
||||
}
|
||||
for _, key := range []string{"child/foo/bar", "prefix/foo/bar"} {
|
||||
read(t, bucket, key, nested)
|
||||
}
|
||||
}
|
||||
|
||||
// A prefix object written before versioning is the key's null version. Removing
|
||||
// that version by id must not take the keys nested under it with it.
|
||||
bucket := createTestBucket(t, cluster, "test-prefix-nullversion-")
|
||||
put(t, bucket, "collision/foo/bar", nested)
|
||||
put(t, bucket, "collision/foo", body)
|
||||
setVersioning(t, bucket, "Enabled")
|
||||
newer := []byte("written after versioning was enabled")
|
||||
versioned, err := cluster.s3Client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String("collision/foo"),
|
||||
Body: bytes.NewReader(newer),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, v := range []struct {
|
||||
id string
|
||||
want []byte
|
||||
}{{"null", body}, {aws.StringValue(versioned.VersionId), newer}} {
|
||||
got, err := cluster.s3Client.GetObject(&s3.GetObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String("collision/foo"),
|
||||
VersionId: aws.String(v.id),
|
||||
})
|
||||
require.NoError(t, err, "get version %s", v.id)
|
||||
body, err := io.ReadAll(got.Body)
|
||||
require.NoError(t, err)
|
||||
got.Body.Close()
|
||||
assert.Equal(t, v.want, body, "get version %s", v.id)
|
||||
}
|
||||
|
||||
_, err = cluster.s3Client.DeleteObject(&s3.DeleteObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String("collision/foo"),
|
||||
VersionId: aws.String("null"),
|
||||
})
|
||||
require.NoError(t, err, "the null version sits on a directory other keys live in")
|
||||
|
||||
read(t, bucket, "collision/foo", newer)
|
||||
read(t, bucket, "collision/foo/bar", nested)
|
||||
remaining, err := cluster.s3Client.ListObjectVersions(&s3.ListObjectVersionsInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Prefix: aws.String("collision/foo"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
for _, v := range remaining.Versions {
|
||||
if aws.StringValue(v.Key) != "collision/foo" {
|
||||
// collision/foo/bar predates versioning too, and keeps its null version.
|
||||
continue
|
||||
}
|
||||
assert.NotEqual(t, "null", aws.StringValue(v.VersionId), "the null version was deleted")
|
||||
}
|
||||
})
|
||||
|
||||
// The two listings walk the tree differently, and a prefix object is the entry
|
||||
// they disagree about: it is a directory the version listing descends through and
|
||||
// a key at the same time. They have to name the same keys and the same prefixes.
|
||||
t.Run("VersionListingMatchesObjectListing", func(t *testing.T) {
|
||||
bucket := createTestBucket(t, cluster, "test-prefix-parity-")
|
||||
for _, key := range []string{"foo", "foo/bar", "other", "a/foo", "a/foo/bar", "a/z"} {
|
||||
put(t, bucket, key, body)
|
||||
}
|
||||
|
||||
for _, q := range []struct{ prefix, delimiter string }{
|
||||
{"", ""},
|
||||
{"foo/", ""},
|
||||
{"a/", ""},
|
||||
{"a/foo/", ""},
|
||||
{"", "/"},
|
||||
{"a/", "/"},
|
||||
} {
|
||||
name := "prefix=" + q.prefix + " delimiter=" + q.delimiter
|
||||
|
||||
objects, err := cluster.s3Client.ListObjectsV2(&s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(bucket),
|
||||
Prefix: aws.String(q.prefix),
|
||||
Delimiter: aws.String(q.delimiter),
|
||||
})
|
||||
require.NoError(t, err, name)
|
||||
|
||||
versions, err := cluster.s3Client.ListObjectVersions(&s3.ListObjectVersionsInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Prefix: aws.String(q.prefix),
|
||||
Delimiter: aws.String(q.delimiter),
|
||||
})
|
||||
require.NoError(t, err, name)
|
||||
|
||||
versionKeys := make([]string, 0, len(versions.Versions))
|
||||
for _, v := range versions.Versions {
|
||||
versionKeys = append(versionKeys, aws.StringValue(v.Key))
|
||||
}
|
||||
versionPrefixes := make([]string, 0, len(versions.CommonPrefixes))
|
||||
for _, p := range versions.CommonPrefixes {
|
||||
versionPrefixes = append(versionPrefixes, aws.StringValue(p.Prefix))
|
||||
}
|
||||
sort.Strings(versionKeys)
|
||||
sort.Strings(versionPrefixes)
|
||||
|
||||
objectKeys := collectKeys(objects.Contents)
|
||||
objectPrefixes := collectPrefixes(objects.CommonPrefixes)
|
||||
sort.Strings(objectKeys)
|
||||
sort.Strings(objectPrefixes)
|
||||
|
||||
assert.Equal(t, objectKeys, versionKeys, "keys, %s", name)
|
||||
assert.Equal(t, objectPrefixes, versionPrefixes, "prefixes, %s", name)
|
||||
}
|
||||
})
|
||||
|
||||
// A prefix object written before versioning is the key's null version, so the
|
||||
// version written after it has to take the latest flag off it.
|
||||
t.Run("VersionedAfterPrefixObject", func(t *testing.T) {
|
||||
bucket := createTestBucket(t, cluster, "test-prefix-versioned-")
|
||||
put(t, bucket, "collision/foo/bar", nested)
|
||||
put(t, bucket, "collision/foo", body)
|
||||
_, err := cluster.s3Client.PutBucketVersioning(&s3.PutBucketVersioningInput{
|
||||
Bucket: aws.String(bucket),
|
||||
VersioningConfiguration: &s3.VersioningConfiguration{Status: aws.String("Enabled")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
newer := []byte("written after versioning was enabled")
|
||||
put(t, bucket, "collision/foo", newer)
|
||||
|
||||
resp, err := cluster.s3Client.ListObjectVersions(&s3.ListObjectVersionsInput{Bucket: aws.String(bucket)})
|
||||
require.NoError(t, err)
|
||||
|
||||
latest := map[string]int{}
|
||||
var nullSize int64 = -1
|
||||
for _, v := range resp.Versions {
|
||||
if aws.BoolValue(v.IsLatest) {
|
||||
latest[aws.StringValue(v.Key)]++
|
||||
}
|
||||
if aws.StringValue(v.Key) == "collision/foo" && aws.StringValue(v.VersionId) == "null" {
|
||||
nullSize = aws.Int64Value(v.Size)
|
||||
assert.False(t, aws.BoolValue(v.IsLatest), "the newer version is the latest one")
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, latest["collision/foo"], "exactly one version of a key is the latest")
|
||||
assert.Equal(t, int64(len(body)), nullSize, "the null version keeps the prefix object's size")
|
||||
read(t, bucket, "collision/foo", newer)
|
||||
read(t, bucket, "collision/foo/bar", nested)
|
||||
})
|
||||
|
||||
// A key that other keys are nested under is a copy source and a copy destination
|
||||
// like any other. The keys nested under either end are not part of the copy.
|
||||
t.Run("Copy", func(t *testing.T) {
|
||||
bucket := createTestBucket(t, cluster, "test-prefix-copy-")
|
||||
copyObject := func(t *testing.T, src, dst string) {
|
||||
t.Helper()
|
||||
_, err := cluster.s3Client.CopyObject(&s3.CopyObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(dst),
|
||||
CopySource: aws.String(bucket + "/" + src),
|
||||
})
|
||||
require.NoError(t, err, "copy %s to %s", src, dst)
|
||||
}
|
||||
|
||||
put(t, bucket, "collision/foo", body)
|
||||
put(t, bucket, "collision/foo/bar", nested)
|
||||
|
||||
// Out of a prefix object, into a key of its own.
|
||||
copyObject(t, "collision/foo", "plain")
|
||||
read(t, bucket, "plain", body)
|
||||
read(t, bucket, "collision/foo", body)
|
||||
read(t, bucket, "collision/foo/bar", nested)
|
||||
|
||||
// Into a key that other keys are nested under.
|
||||
put(t, bucket, "target/child", nested)
|
||||
copyObject(t, "plain", "target")
|
||||
read(t, bucket, "target", body)
|
||||
read(t, bucket, "target/child", nested)
|
||||
|
||||
// And between two of them.
|
||||
put(t, bucket, "other", []byte("copied between prefix keys"))
|
||||
copyObject(t, "other", "collision/foo")
|
||||
read(t, bucket, "collision/foo", []byte("copied between prefix keys"))
|
||||
read(t, bucket, "collision/foo/bar", nested)
|
||||
|
||||
assert.Equal(t, []string{"collision/foo", "collision/foo/bar", "other", "plain", "target", "target/child"},
|
||||
listKeys(t, bucket))
|
||||
})
|
||||
|
||||
// Rename moves the object off the key without moving the keys nested under it,
|
||||
// which is not what the filer's atomic rename of a directory would do.
|
||||
t.Run("Rename", func(t *testing.T) {
|
||||
bucket := createTestBucket(t, cluster, "test-prefix-rename-")
|
||||
renameObject := func(t *testing.T, src, dst string) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest(http.MethodPut, cluster.s3Endpoint+"/"+bucket+"/"+dst+"?renameObject=", nil)
|
||||
req.Header.Set("x-amz-rename-source", "/"+bucket+"/"+src)
|
||||
signer := v1signer.NewSigner(v1credentials.NewStaticCredentials(testAccessKey, testSecretKey, ""))
|
||||
_, err := signer.Sign(req, nil, "s3", testRegion, time.Now())
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
|
||||
require.NoError(t, err, "rename %s to %s", src, dst)
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "rename %s to %s", src, dst)
|
||||
}
|
||||
|
||||
put(t, bucket, "collision/foo", body)
|
||||
put(t, bucket, "collision/foo/bar", nested)
|
||||
|
||||
// Off a prefix object: the key goes, the keys under it stay.
|
||||
renameObject(t, "collision/foo", "moved")
|
||||
read(t, bucket, "moved", body)
|
||||
read(t, bucket, "collision/foo/bar", nested)
|
||||
gone(t, bucket, "collision/foo")
|
||||
|
||||
// Onto a key other keys are nested under.
|
||||
put(t, bucket, "target/child", nested)
|
||||
renameObject(t, "moved", "target")
|
||||
read(t, bucket, "target", body)
|
||||
read(t, bucket, "target/child", nested)
|
||||
gone(t, bucket, "moved")
|
||||
|
||||
assert.Equal(t, []string{"collision/foo/bar", "target", "target/child"}, listKeys(t, bucket))
|
||||
})
|
||||
|
||||
// A directory SeaweedFS keeps its own state in is not a prefix a key can be
|
||||
// stored on: the object would replace that state with its own.
|
||||
t.Run("ReservedDirectory", func(t *testing.T) {
|
||||
bucket := createTestBucket(t, cluster, "test-prefix-reserved-")
|
||||
_, err := cluster.s3Client.PutBucketVersioning(&s3.PutBucketVersioningInput{
|
||||
Bucket: aws.String(bucket),
|
||||
VersioningConfiguration: &s3.VersioningConfiguration{Status: aws.String("Enabled")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
put(t, bucket, "foo", body)
|
||||
_, err = cluster.s3Client.PutBucketVersioning(&s3.PutBucketVersioningInput{
|
||||
Bucket: aws.String(bucket),
|
||||
VersioningConfiguration: &s3.VersioningConfiguration{Status: aws.String("Suspended")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = cluster.s3Client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String("foo.versions"),
|
||||
Body: bytes.NewReader(body),
|
||||
})
|
||||
// Not just any error: a permanently impossible write must not come back as a
|
||||
// 500 the SDK retries.
|
||||
var refused awserr.RequestFailure
|
||||
require.ErrorAs(t, err, &refused, "the version history of foo is not a prefix of foo.versions")
|
||||
assert.Equal(t, http.StatusConflict, refused.StatusCode())
|
||||
assert.Equal(t, "ExistingObjectIsDirectory", refused.Code())
|
||||
|
||||
versions, err := cluster.s3Client.ListObjectVersions(&s3.ListObjectVersionsInput{Bucket: aws.String(bucket)})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions.Versions, 1)
|
||||
assert.Equal(t, "foo", aws.StringValue(versions.Versions[0].Key))
|
||||
read(t, bucket, "foo", body)
|
||||
|
||||
// The multipart staging folder is the other one, and an in-flight upload has
|
||||
// to survive the attempt.
|
||||
staging := createTestBucket(t, cluster, "test-prefix-uploads-")
|
||||
created, err := cluster.s3Client.CreateMultipartUpload(&s3.CreateMultipartUploadInput{
|
||||
Bucket: aws.String(staging),
|
||||
Key: aws.String("mp.bin"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = cluster.s3Client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(staging),
|
||||
Key: aws.String(".uploads"),
|
||||
Body: bytes.NewReader(body),
|
||||
})
|
||||
require.ErrorAs(t, err, &refused, "the multipart staging folder is not a prefix of .uploads")
|
||||
assert.Equal(t, http.StatusConflict, refused.StatusCode())
|
||||
assert.Equal(t, "ExistingObjectIsDirectory", refused.Code())
|
||||
|
||||
uploads, err := cluster.s3Client.ListMultipartUploads(&s3.ListMultipartUploadsInput{Bucket: aws.String(staging)})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, uploads.Uploads, 1)
|
||||
assert.Equal(t, "mp.bin", aws.StringValue(uploads.Uploads[0].Key))
|
||||
_, err = cluster.s3Client.AbortMultipartUpload(&s3.AbortMultipartUploadInput{
|
||||
Bucket: aws.String(staging),
|
||||
Key: aws.String("mp.bin"),
|
||||
UploadId: created.UploadId,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
// Either key can be deleted without touching the other.
|
||||
t.Run("Delete", func(t *testing.T) {
|
||||
bucket := createTestBucket(t, cluster, "test-prefix-delete-")
|
||||
put(t, bucket, "collision/foo/bar", nested)
|
||||
put(t, bucket, "collision/foo", body)
|
||||
|
||||
_, err := cluster.s3Client.DeleteObject(&s3.DeleteObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String("collision/foo"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"collision/foo/bar"}, listKeys(t, bucket))
|
||||
read(t, bucket, "collision/foo/bar", nested)
|
||||
gone(t, bucket, "collision/foo")
|
||||
|
||||
put(t, bucket, "collision/foo", body)
|
||||
_, err = cluster.s3Client.DeleteObject(&s3.DeleteObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String("collision/foo/bar"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"collision/foo"}, listKeys(t, bucket))
|
||||
read(t, bucket, "collision/foo", body)
|
||||
gone(t, bucket, "collision/foo/bar")
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user