package storage import ( "bytes" "context" "io" "net/http" "net/http/httptest" "testing" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNormalizeS3Endpoint(t *testing.T) { tests := []struct { name string endpoint string bucket string want string wantErr bool }{ {name: "empty path", endpoint: "https://account.example.com", bucket: "images", want: "https://account.example.com"}, {name: "strip exact bucket path", endpoint: "https://account.example.com/images", bucket: "images", want: "https://account.example.com"}, {name: "httptest HTTP supported", endpoint: "http://127.0.0.1:12345/images", bucket: "images", want: "http://127.0.0.1:12345"}, {name: "root path rejected", endpoint: "https://account.example.com/", bucket: "images", wantErr: true}, {name: "different path rejected", endpoint: "https://account.example.com/other", bucket: "images", wantErr: true}, {name: "bucket trailing slash rejected", endpoint: "https://account.example.com/images/", bucket: "images", wantErr: true}, {name: "encoded bucket rejected", endpoint: "https://account.example.com/%69mages", bucket: "images", wantErr: true}, {name: "relative rejected", endpoint: "account.example.com", bucket: "images", wantErr: true}, {name: "empty hostname rejected", endpoint: "https://:443", bucket: "images", wantErr: true}, {name: "unsupported scheme rejected", endpoint: "ftp://account.example.com", bucket: "images", wantErr: true}, {name: "query rejected", endpoint: "https://account.example.com?x=1", bucket: "images", wantErr: true}, {name: "empty query rejected", endpoint: "https://account.example.com?", bucket: "images", wantErr: true}, {name: "fragment rejected", endpoint: "https://account.example.com#fragment", bucket: "images", wantErr: true}, {name: "userinfo rejected", endpoint: "https://user@account.example.com", bucket: "images", wantErr: true}, {name: "empty bucket rejected", endpoint: "https://account.example.com", bucket: "", wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := normalizeS3Endpoint(tt.endpoint, tt.bucket) if tt.wantErr { require.Error(t, err) return } require.NoError(t, err) assert.Equal(t, tt.want, got) }) } } func TestNewS3ClientConfiguration(t *testing.T) { client, err := NewS3Client(context.Background(), "https://account.example.com/images", "images", "test-id", "test-secret") require.NoError(t, err) options := client.Options() assert.Equal(t, "auto", options.Region) assert.True(t, options.UsePathStyle) credentials, err := options.Credentials.Retrieve(context.Background()) require.NoError(t, err) assert.Equal(t, "test-id", credentials.AccessKeyID) assert.Equal(t, "test-secret", credentials.SecretAccessKey) httpClient, ok := options.HTTPClient.(*http.Client) require.True(t, ok) assert.Zero(t, httpClient.Timeout, "全局 Client.Timeout 会截断大对象传输") transport, ok := httpClient.Transport.(*http.Transport) require.True(t, ok) assert.NotNil(t, transport.DialContext) assert.Positive(t, transport.TLSHandshakeTimeout) assert.Positive(t, transport.ResponseHeaderTimeout) assert.Positive(t, transport.ExpectContinueTimeout) } func TestS3PutObjectRequestShape(t *testing.T) { type capturedRequest struct { method string path string body string contentType string cacheControl string authorization string } captured := make(chan capturedRequest, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } captured <- capturedRequest{ method: r.Method, path: r.URL.Path, body: string(body), contentType: r.Header.Get("Content-Type"), cacheControl: r.Header.Get("Cache-Control"), authorization: r.Header.Get("Authorization"), } w.Header().Set("ETag", `"test-etag"`) w.WriteHeader(http.StatusOK) })) defer server.Close() client, err := NewS3Client(context.Background(), server.URL+"/images", "images", "test-id", "test-secret") require.NoError(t, err) err = PutObject(context.Background(), client, &s3.PutObjectInput{ Bucket: aws.String("images"), Key: aws.String("2026/11/picture.webp"), Body: bytes.NewReader([]byte("fake-image")), ContentLength: aws.Int64(10), ContentType: aws.String("image/webp"), CacheControl: aws.String("public, max-age=31536000, immutable"), }) require.NoError(t, err) request := <-captured assert.Equal(t, http.MethodPut, request.method) assert.Equal(t, "/images/2026/11/picture.webp", request.path, "bucket 必须且只能出现一次") assert.Equal(t, "fake-image", request.body) assert.Equal(t, "image/webp", request.contentType) assert.Equal(t, "public, max-age=31536000, immutable", request.cacheControl) assert.Contains(t, request.authorization, "Credential=test-id/") assert.Contains(t, request.authorization, "/auto/s3/aws4_request") }