mirror of
https://github.com/versity/versitygw.git
synced 2026-10-22 14:22:40 +00:00
Fixes #2499 An empty tag key returned `InvalidTag` without the `TagKey` element because the field was `omitempty`. S3 always sends `<TagKey>`, empty for an empty key, so it is now always written. `PutBucketTagging`, `PutObjectTagging`, the POST object `tagging` field and `CreateBucket` tags now share one validator, `backend.ParseTagSet`, which matches S3: a duplicate key error carries `TagKey`, a `Tag` missing its `Key` or `Value` element is `MalformedXML` and is checked across the whole document first, every tag is validated before the tag count limit, and the 128/256 length limits count UTF-16 code units instead of bytes. The duplicate tag regex in `s3api/utils` is removed. For the `x-amz-tagging` header, the length limits now apply to the URL-decoded key and value, the `InvalidArgument` error echoes the whole header as `ArgumentValue` instead of the unparsed remainder, and more than 10 tags is rejected with `BadRequest`.
1909 lines
49 KiB
Go
1909 lines
49 KiB
Go
// Copyright 2023 Versity Software
|
||
// This file is licensed under the Apache License, Version 2.0
|
||
// (the "License"); you may not use this file except in compliance
|
||
// with the License. You may obtain a copy of the License at
|
||
//
|
||
// http://www.apache.org/licenses/LICENSE-2.0
|
||
//
|
||
// Unless required by applicable law or agreed to in writing,
|
||
// software distributed under the License is distributed on an
|
||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||
// KIND, either express or implied. See the License for the
|
||
// specific language governing permissions and limitations
|
||
// under the License.
|
||
|
||
package utils
|
||
|
||
import (
|
||
"bufio"
|
||
"bytes"
|
||
"encoding/xml"
|
||
"errors"
|
||
"math/rand"
|
||
"net/http"
|
||
"net/url"
|
||
"reflect"
|
||
"strconv"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||
"github.com/gofiber/fiber/v3"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/valyala/fasthttp"
|
||
"github.com/versity/versitygw/backend"
|
||
"github.com/versity/versitygw/s3err"
|
||
"github.com/versity/versitygw/s3response"
|
||
)
|
||
|
||
// a helper method to construct a raw http request with the given http request headers
|
||
// to further parse with fasthttp.Request.Read and return fasthttp.RequestHeader
|
||
func createHeadersFromRawRequest(t *testing.T, hdrs [][2]string) *fasthttp.RequestHeader {
|
||
t.Helper()
|
||
|
||
var b strings.Builder
|
||
b.WriteString("PUT / HTTP/1.1\r\n")
|
||
b.WriteString("Host: example.com\r\n")
|
||
for _, kv := range hdrs {
|
||
b.WriteString(kv[0])
|
||
b.WriteString(": ")
|
||
b.WriteString(kv[1])
|
||
b.WriteString("\r\n")
|
||
}
|
||
b.WriteString("\r\n")
|
||
|
||
var req fasthttp.Request
|
||
if err := req.Read(bufio.NewReader(bytes.NewReader([]byte(b.String())))); err != nil {
|
||
t.Fatalf("failed to parse raw request: %v", err)
|
||
}
|
||
return &req.Header
|
||
}
|
||
|
||
func TestGetUserMetaData(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
hdrs [][2]string
|
||
want map[string]string
|
||
wantErr error
|
||
}{
|
||
{
|
||
name: "no metadata headers",
|
||
hdrs: [][2]string{
|
||
{"Content-Type", "application/json"},
|
||
},
|
||
want: map[string]string{},
|
||
},
|
||
{
|
||
name: "single metadata header",
|
||
hdrs: [][2]string{
|
||
{"x-amz-meta-foo", "bar"},
|
||
},
|
||
want: map[string]string{"foo": "bar"},
|
||
},
|
||
{
|
||
name: "multiple metadata headers",
|
||
hdrs: [][2]string{
|
||
{"x-amz-meta-foo", "bar"},
|
||
{"x-amz-meta-baz", "qux"},
|
||
},
|
||
want: map[string]string{"foo": "bar", "baz": "qux"},
|
||
},
|
||
{
|
||
name: "case-insensitive prefix and key lowercasing",
|
||
hdrs: [][2]string{
|
||
{"X-Amz-Meta-TestKey", "Value"},
|
||
},
|
||
want: map[string]string{"testkey": "Value"},
|
||
},
|
||
{
|
||
name: "ignores non-metadata headers",
|
||
hdrs: [][2]string{
|
||
{"authorization", "token"},
|
||
{"x-amz-meta-foo", "bar"},
|
||
},
|
||
want: map[string]string{"foo": "bar"},
|
||
},
|
||
{
|
||
name: "metadata size exceeds limit (single header)",
|
||
hdrs: [][2]string{
|
||
{"x-amz-meta-big", strings.Repeat("a", maxMetadataSize+1)},
|
||
},
|
||
wantErr: s3err.GetMetadataTooLargeErr(2052, maxMetadataSize),
|
||
},
|
||
{
|
||
name: "metadata cumulative size exceeds limit (multiple headers)",
|
||
hdrs: [][2]string{
|
||
{"x-amz-meta-a", strings.Repeat("a", maxMetadataSize/2)},
|
||
{"x-amz-meta-b", strings.Repeat("b", maxMetadataSize/2+10)},
|
||
},
|
||
wantErr: s3err.GetMetadataTooLargeErr(2060, maxMetadataSize),
|
||
},
|
||
{
|
||
name: "duplicate keys combined",
|
||
hdrs: [][2]string{
|
||
{"x-amz-meta-Foo", "first"},
|
||
{"x-amz-meta-foo", "second"},
|
||
},
|
||
want: map[string]string{"foo": "first,second"},
|
||
},
|
||
{
|
||
name: "duplicate same value keys combined",
|
||
hdrs: [][2]string{
|
||
{"x-amz-meta-Foo", "value"},
|
||
{"x-amz-meta-foo", "value"},
|
||
},
|
||
want: map[string]string{"foo": "value,value"},
|
||
},
|
||
{
|
||
name: "mixed keys",
|
||
hdrs: [][2]string{
|
||
{"x-amz-meta-Foo", "value2"},
|
||
{"x-amz-meta-fOo", "value1"},
|
||
{"x-amz-meta-foO", "value3"},
|
||
{"x-amz-meta-bar", "baz"},
|
||
{"x-amz-meta-quxx", "efg"},
|
||
{"x-amz-meta-abc", "value"},
|
||
{"x-amz-meta-Abc", "value"},
|
||
{"x-amz-meta-aBc", "value"},
|
||
{"x-amz-meta-abC", "value"},
|
||
{"x-amz-meta-ABC", "value"},
|
||
},
|
||
want: map[string]string{
|
||
"foo": "value2,value1,value3",
|
||
"bar": "baz",
|
||
"quxx": "efg",
|
||
"abc": "value,value,value,value,value",
|
||
},
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
h := createHeadersFromRawRequest(t, tt.hdrs)
|
||
|
||
got, err := GetUserMetaData(h)
|
||
assert.Equal(t, tt.wantErr, err)
|
||
assert.Equal(t, tt.want, got)
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestIsValidBucketName(t *testing.T) {
|
||
type args struct {
|
||
bucket string
|
||
}
|
||
tests := []struct {
|
||
name string
|
||
args args
|
||
want bool
|
||
}{
|
||
{
|
||
name: "IsValidBucketName-short-name",
|
||
args: args{
|
||
bucket: "a",
|
||
},
|
||
want: false,
|
||
},
|
||
{
|
||
name: "IsValidBucketName-start-with-hyphen",
|
||
args: args{
|
||
bucket: "-bucket",
|
||
},
|
||
want: false,
|
||
},
|
||
{
|
||
name: "IsValidBucketName-start-with-dot",
|
||
args: args{
|
||
bucket: ".bucket",
|
||
},
|
||
want: false,
|
||
},
|
||
{
|
||
name: "IsValidBucketName-contain-invalid-character",
|
||
args: args{
|
||
bucket: "my@bucket",
|
||
},
|
||
want: false,
|
||
},
|
||
{
|
||
name: "IsValidBucketName-end-with-hyphen",
|
||
args: args{
|
||
bucket: "bucket-",
|
||
},
|
||
want: false,
|
||
},
|
||
{
|
||
name: "IsValidBucketName-end-with-dot",
|
||
args: args{
|
||
bucket: "bucket.",
|
||
},
|
||
want: false,
|
||
},
|
||
{
|
||
name: "IsValidBucketName-valid-bucket-name",
|
||
args: args{
|
||
bucket: "my-bucket",
|
||
},
|
||
want: true,
|
||
},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
if got := IsValidBucketName(tt.args.bucket); got != tt.want {
|
||
t.Errorf("IsValidBucketName() = %v, want %v", got, tt.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestSetBucketNameValidationStrict(t *testing.T) {
|
||
SetBucketNameValidationStrict(true)
|
||
t.Cleanup(func() {
|
||
SetBucketNameValidationStrict(true)
|
||
})
|
||
|
||
invalidBucket := "Invalid_Bucket"
|
||
if IsValidBucketName(invalidBucket) {
|
||
t.Fatalf("expected %q to be invalid with strict validation", invalidBucket)
|
||
}
|
||
|
||
SetBucketNameValidationStrict(false)
|
||
if !IsValidBucketName(invalidBucket) {
|
||
t.Fatalf("expected %q to be accepted when strict validation disabled", invalidBucket)
|
||
}
|
||
|
||
SetBucketNameValidationStrict(true)
|
||
if IsValidBucketName(invalidBucket) {
|
||
t.Fatalf("expected %q to be invalid after re-enabling strict validation", invalidBucket)
|
||
}
|
||
}
|
||
|
||
func TestParseMaxLimiter(t *testing.T) {
|
||
type args struct {
|
||
str string
|
||
lt LimiterType
|
||
}
|
||
type expected struct {
|
||
err error
|
||
res int32
|
||
}
|
||
tests := []struct {
|
||
name string
|
||
args args
|
||
expected expected
|
||
}{
|
||
{
|
||
name: "empty_string",
|
||
args: args{
|
||
str: "",
|
||
lt: LimiterTypeMaxKeys,
|
||
},
|
||
expected: expected{
|
||
err: nil,
|
||
res: 1000,
|
||
},
|
||
},
|
||
{
|
||
name: "empty_max-buckets",
|
||
args: args{
|
||
str: "",
|
||
lt: LimiterTypeMaxBuckets,
|
||
},
|
||
expected: expected{
|
||
err: nil,
|
||
res: 10000,
|
||
},
|
||
},
|
||
{
|
||
name: "invalid_max-parts",
|
||
args: args{
|
||
str: "bla",
|
||
lt: LimiterTypeMaxParts,
|
||
},
|
||
expected: expected{
|
||
err: s3err.GetInvalidArgMaxLimiter(string(LimiterTypeMaxParts), "bla"),
|
||
res: 0,
|
||
},
|
||
},
|
||
{
|
||
name: "invalid_max-uploads",
|
||
args: args{
|
||
str: "invalid",
|
||
lt: LimiterTypeMaxUploads,
|
||
},
|
||
expected: expected{
|
||
err: s3err.GetInvalidArgMaxLimiter(string(LimiterTypeMaxUploads), "invalid"),
|
||
res: 0,
|
||
},
|
||
},
|
||
{
|
||
name: "invalid_max-buckets",
|
||
args: args{
|
||
str: "invalid",
|
||
lt: LimiterTypeMaxBuckets,
|
||
},
|
||
expected: expected{
|
||
err: s3err.GetInvalidArgMaxLimiter(string(LimiterTypeMaxBuckets), "invalid"),
|
||
res: 0,
|
||
},
|
||
},
|
||
{
|
||
name: "invalid_versions_max-keys",
|
||
args: args{
|
||
str: "invalid",
|
||
lt: LimiterTypeMaxKeys,
|
||
},
|
||
expected: expected{
|
||
err: s3err.GetInvalidArgMaxLimiter(string(LimiterTypeMaxKeys), "invalid"),
|
||
res: 0,
|
||
},
|
||
},
|
||
{
|
||
name: "negative_max-keys",
|
||
args: args{
|
||
str: "-5",
|
||
lt: LimiterTypeMaxKeys,
|
||
},
|
||
expected: expected{
|
||
err: s3err.GetInvalidArgNegativeMaxLimiter(string(LimiterTypeMaxKeys), "-5"),
|
||
res: 0,
|
||
},
|
||
},
|
||
{
|
||
name: "negative_part-number-marker",
|
||
args: args{
|
||
str: "-5",
|
||
lt: LimiterTypePartNumberMarker,
|
||
},
|
||
expected: expected{
|
||
err: s3err.GetInvalidArgNegativeMaxLimiter(string(LimiterTypePartNumberMarker), "-5"),
|
||
res: 0,
|
||
},
|
||
},
|
||
{
|
||
name: "negative_max-buckets",
|
||
args: args{
|
||
str: "-12",
|
||
lt: LimiterTypeMaxBuckets,
|
||
},
|
||
expected: expected{
|
||
err: s3err.GetInvalidArgumentErr(s3err.InvalidArgMaxBuckets, "-12"),
|
||
res: 0,
|
||
},
|
||
},
|
||
{
|
||
name: "negative_versions_max-keys",
|
||
args: args{
|
||
str: "-12",
|
||
lt: LimiterTypeVersionsMaxKeys,
|
||
},
|
||
expected: expected{
|
||
err: s3err.GetInvalidArgumentErr(s3err.InvalidArgNegativeMaxKeys, "-12"),
|
||
res: 0,
|
||
},
|
||
},
|
||
{
|
||
name: "greater_than_10000_max-buckets",
|
||
args: args{
|
||
str: "25000",
|
||
lt: LimiterTypeMaxBuckets,
|
||
},
|
||
expected: expected{
|
||
err: s3err.GetInvalidArgumentErr(s3err.InvalidArgMaxBuckets, "25000"),
|
||
res: 0,
|
||
},
|
||
},
|
||
{
|
||
name: "greater_than_1000_max-buckets",
|
||
args: args{
|
||
str: "1300",
|
||
lt: LimiterTypeMaxBuckets,
|
||
},
|
||
expected: expected{
|
||
err: nil,
|
||
res: 1300,
|
||
},
|
||
},
|
||
{
|
||
name: "greater_than_1000",
|
||
args: args{
|
||
str: "25000",
|
||
lt: LimiterTypeMaxParts,
|
||
},
|
||
expected: expected{
|
||
err: nil,
|
||
res: 1000,
|
||
},
|
||
},
|
||
{
|
||
name: "success",
|
||
args: args{
|
||
str: "23",
|
||
lt: LimiterTypeMaxUploads,
|
||
},
|
||
expected: expected{
|
||
err: nil,
|
||
res: 23,
|
||
},
|
||
},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
got, err := ParseMaxLimiter(tt.args.str, tt.args.lt)
|
||
assert.Equal(t, tt.expected.err, err)
|
||
assert.Equal(t, tt.expected.res, got)
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestFilterObjectAttributes(t *testing.T) {
|
||
type args struct {
|
||
attrs map[s3response.ObjectAttributes]struct{}
|
||
output s3response.GetObjectAttributesResponse
|
||
}
|
||
|
||
etag, objSize := "etag", int64(3222)
|
||
delMarker := true
|
||
|
||
tests := []struct {
|
||
name string
|
||
args args
|
||
want s3response.GetObjectAttributesResponse
|
||
}{
|
||
{
|
||
name: "keep only ETag",
|
||
args: args{
|
||
attrs: map[s3response.ObjectAttributes]struct{}{
|
||
s3response.ObjectAttributesEtag: {},
|
||
},
|
||
output: s3response.GetObjectAttributesResponse{
|
||
ObjectSize: &objSize,
|
||
ETag: &etag,
|
||
},
|
||
},
|
||
want: s3response.GetObjectAttributesResponse{ETag: &etag},
|
||
},
|
||
{
|
||
name: "keep multiple props",
|
||
args: args{
|
||
attrs: map[s3response.ObjectAttributes]struct{}{
|
||
s3response.ObjectAttributesEtag: {},
|
||
s3response.ObjectAttributesObjectSize: {},
|
||
s3response.ObjectAttributesStorageClass: {},
|
||
},
|
||
output: s3response.GetObjectAttributesResponse{
|
||
ObjectSize: &objSize,
|
||
ETag: &etag,
|
||
ObjectParts: &s3response.ObjectParts{},
|
||
VersionId: &etag,
|
||
},
|
||
},
|
||
want: s3response.GetObjectAttributesResponse{
|
||
ETag: &etag,
|
||
ObjectSize: &objSize,
|
||
},
|
||
},
|
||
{
|
||
name: "make sure LastModified, DeleteMarker and VersionId are removed",
|
||
args: args{
|
||
attrs: map[s3response.ObjectAttributes]struct{}{
|
||
s3response.ObjectAttributesEtag: {},
|
||
},
|
||
output: s3response.GetObjectAttributesResponse{
|
||
ObjectSize: &objSize,
|
||
ETag: &etag,
|
||
ObjectParts: &s3response.ObjectParts{},
|
||
VersionId: &etag,
|
||
LastModified: backend.GetTimePtr(time.Now()),
|
||
DeleteMarker: &delMarker,
|
||
},
|
||
},
|
||
want: s3response.GetObjectAttributesResponse{
|
||
ETag: &etag,
|
||
},
|
||
},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
if got := FilterObjectAttributes(tt.args.attrs, tt.args.output); !reflect.DeepEqual(got, tt.want) {
|
||
t.Errorf("FilterObjectAttributes() = %v, want %v", got, tt.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestIsValidOwnership(t *testing.T) {
|
||
type args struct {
|
||
val types.ObjectOwnership
|
||
}
|
||
tests := []struct {
|
||
name string
|
||
args args
|
||
want bool
|
||
}{
|
||
{
|
||
name: "valid-BucketOwnerEnforced",
|
||
args: args{
|
||
val: types.ObjectOwnershipBucketOwnerEnforced,
|
||
},
|
||
want: true,
|
||
},
|
||
{
|
||
name: "valid-BucketOwnerPreferred",
|
||
args: args{
|
||
val: types.ObjectOwnershipBucketOwnerPreferred,
|
||
},
|
||
want: true,
|
||
},
|
||
{
|
||
name: "valid-ObjectWriter",
|
||
args: args{
|
||
val: types.ObjectOwnershipObjectWriter,
|
||
},
|
||
want: true,
|
||
},
|
||
{
|
||
name: "invalid_value",
|
||
args: args{
|
||
val: types.ObjectOwnership("invalid_value"),
|
||
},
|
||
want: false,
|
||
},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
if got := IsValidOwnership(tt.args.val); got != tt.want {
|
||
t.Errorf("IsValidOwnership() = %v, want %v", got, tt.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestIsChecksumAlgorithmValid(t *testing.T) {
|
||
type args struct {
|
||
alg types.ChecksumAlgorithm
|
||
}
|
||
tests := []struct {
|
||
name string
|
||
args args
|
||
wantErr bool
|
||
}{
|
||
{
|
||
name: "empty",
|
||
args: args{
|
||
alg: "",
|
||
},
|
||
wantErr: false,
|
||
},
|
||
{
|
||
name: "crc32",
|
||
args: args{
|
||
alg: types.ChecksumAlgorithmCrc32,
|
||
},
|
||
wantErr: false,
|
||
},
|
||
{
|
||
name: "crc32c",
|
||
args: args{
|
||
alg: types.ChecksumAlgorithmCrc32c,
|
||
},
|
||
wantErr: false,
|
||
},
|
||
{
|
||
name: "sha1",
|
||
args: args{
|
||
alg: types.ChecksumAlgorithmSha1,
|
||
},
|
||
wantErr: false,
|
||
},
|
||
{
|
||
name: "sha256",
|
||
args: args{
|
||
alg: types.ChecksumAlgorithmSha256,
|
||
},
|
||
wantErr: false,
|
||
},
|
||
{
|
||
name: "crc64nvme",
|
||
args: args{
|
||
alg: types.ChecksumAlgorithmCrc64nvme,
|
||
},
|
||
wantErr: false,
|
||
},
|
||
{
|
||
name: "sha512",
|
||
args: args{
|
||
alg: types.ChecksumAlgorithmSha512,
|
||
},
|
||
wantErr: false,
|
||
},
|
||
{
|
||
name: "md5",
|
||
args: args{
|
||
alg: types.ChecksumAlgorithmMd5,
|
||
},
|
||
wantErr: false,
|
||
},
|
||
{
|
||
name: "xxhash64",
|
||
args: args{
|
||
alg: types.ChecksumAlgorithmXxhash64,
|
||
},
|
||
wantErr: false,
|
||
},
|
||
{
|
||
name: "xxhash3",
|
||
args: args{
|
||
alg: types.ChecksumAlgorithmXxhash3,
|
||
},
|
||
wantErr: false,
|
||
},
|
||
{
|
||
name: "xxhash128",
|
||
args: args{
|
||
alg: types.ChecksumAlgorithmXxhash128,
|
||
},
|
||
wantErr: false,
|
||
},
|
||
{
|
||
name: "invalid",
|
||
args: args{
|
||
alg: types.ChecksumAlgorithm("invalid_checksum_algorithm"),
|
||
},
|
||
wantErr: true,
|
||
},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
if err := IsChecksumAlgorithmValid(tt.args.alg); (err != nil) != tt.wantErr {
|
||
t.Errorf("IsChecksumAlgorithmValid() error = %v, wantErr %v", err, tt.wantErr)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestIsValidChecksum(t *testing.T) {
|
||
type args struct {
|
||
checksum string
|
||
algorithm types.ChecksumAlgorithm
|
||
}
|
||
tests := []struct {
|
||
name string
|
||
args args
|
||
want bool
|
||
}{
|
||
{
|
||
name: "invalid-base64",
|
||
args: args{
|
||
checksum: "invalid_base64_string",
|
||
algorithm: types.ChecksumAlgorithmCrc32,
|
||
},
|
||
want: false,
|
||
},
|
||
{
|
||
name: "invalid-crc32",
|
||
args: args{
|
||
checksum: "YXNkZmFzZGZhc2Rm",
|
||
algorithm: types.ChecksumAlgorithmCrc32,
|
||
},
|
||
want: false,
|
||
},
|
||
{
|
||
name: "valid-crc32",
|
||
args: args{
|
||
checksum: "ww2FVQ==",
|
||
algorithm: types.ChecksumAlgorithmCrc32,
|
||
},
|
||
want: true,
|
||
},
|
||
{
|
||
name: "invalid-crc32c",
|
||
args: args{
|
||
checksum: "Zmdoa2doZmtnZmhr",
|
||
algorithm: types.ChecksumAlgorithmCrc32c,
|
||
},
|
||
want: false,
|
||
},
|
||
{
|
||
name: "valid-crc32c",
|
||
args: args{
|
||
checksum: "DOsb4w==",
|
||
algorithm: types.ChecksumAlgorithmCrc32c,
|
||
},
|
||
want: true,
|
||
},
|
||
{
|
||
name: "invalid-sha1",
|
||
args: args{
|
||
checksum: "YXNkZmFzZGZhc2RmYXNkZnNhZGZzYWRm",
|
||
algorithm: types.ChecksumAlgorithmSha1,
|
||
},
|
||
want: false,
|
||
},
|
||
{
|
||
name: "valid-sha1",
|
||
args: args{
|
||
checksum: "L4q6V59Zcwn12wyLIytoE2c1ugk=",
|
||
algorithm: types.ChecksumAlgorithmSha1,
|
||
},
|
||
want: true,
|
||
},
|
||
{
|
||
name: "invalid-sha256",
|
||
args: args{
|
||
checksum: "Zmdoa2doZmtnZmhrYXNkZmFzZGZhc2RmZHNmYXNkZg==",
|
||
algorithm: types.ChecksumAlgorithmSha256,
|
||
},
|
||
want: false,
|
||
},
|
||
{
|
||
name: "valid-sha256",
|
||
args: args{
|
||
checksum: "d1SPCd/kZ2rAzbbLUC0n/bEaOSx70FNbXbIqoIxKuPY=",
|
||
algorithm: types.ChecksumAlgorithmSha256,
|
||
},
|
||
want: true,
|
||
},
|
||
{
|
||
name: "invalid-crc64nvme",
|
||
args: args{
|
||
checksum: "ww2FVQ==",
|
||
algorithm: types.ChecksumAlgorithmCrc64nvme,
|
||
},
|
||
want: false,
|
||
},
|
||
{
|
||
name: "valid-crc64nvme",
|
||
args: args{
|
||
checksum: "AAAAAAAAAAA=",
|
||
algorithm: types.ChecksumAlgorithmCrc64nvme,
|
||
},
|
||
want: true,
|
||
},
|
||
{
|
||
name: "invalid-sha512",
|
||
args: args{
|
||
checksum: "d1SPCd/kZ2rAzbbLUC0n/bEaOSx70FNbXbIqoIxKuPY=",
|
||
algorithm: types.ChecksumAlgorithmSha512,
|
||
},
|
||
want: false,
|
||
},
|
||
{
|
||
name: "valid-sha512",
|
||
args: args{
|
||
checksum: "z4PhNX7vuL3xVChQ1m2AB9Yg5AULVxXcg/SpIdNs6c5H0NE8XYXysP+DGNKHfuwvY7kxvUdBeoGlODJ6+SfaPg==",
|
||
algorithm: types.ChecksumAlgorithmSha512,
|
||
},
|
||
want: true,
|
||
},
|
||
{
|
||
name: "invalid-md5",
|
||
args: args{
|
||
checksum: "L4q6V59Zcwn12wyLIytoE2c1ugk=",
|
||
algorithm: types.ChecksumAlgorithmMd5,
|
||
},
|
||
want: false,
|
||
},
|
||
{
|
||
name: "valid-md5",
|
||
args: args{
|
||
checksum: "1B2M2Y8AsgTpgAmY7PhCfg==",
|
||
algorithm: types.ChecksumAlgorithmMd5,
|
||
},
|
||
want: true,
|
||
},
|
||
{
|
||
name: "invalid-xxhash64",
|
||
args: args{
|
||
checksum: "1B2M2Y8AsgTpgAmY7PhCfg==",
|
||
algorithm: types.ChecksumAlgorithmXxhash64,
|
||
},
|
||
want: false,
|
||
},
|
||
{
|
||
name: "valid-xxhash64",
|
||
args: args{
|
||
checksum: "70bbN1HY6Zk=",
|
||
algorithm: types.ChecksumAlgorithmXxhash64,
|
||
},
|
||
want: true,
|
||
},
|
||
{
|
||
name: "invalid-xxhash3",
|
||
args: args{
|
||
checksum: "L4q6V59Zcwn12wyLIytoE2c1ugk=",
|
||
algorithm: types.ChecksumAlgorithmXxhash3,
|
||
},
|
||
want: false,
|
||
},
|
||
{
|
||
name: "valid-xxhash3",
|
||
args: args{
|
||
checksum: "LQaABTjTlMI=",
|
||
algorithm: types.ChecksumAlgorithmXxhash3,
|
||
},
|
||
want: true,
|
||
},
|
||
{
|
||
name: "invalid-xxhash128",
|
||
args: args{
|
||
checksum: "70bbN1HY6Zk=",
|
||
algorithm: types.ChecksumAlgorithmXxhash128,
|
||
},
|
||
want: false,
|
||
},
|
||
{
|
||
name: "valid-xxhash128",
|
||
args: args{
|
||
checksum: "maoG0wFHmNhgAcMkRo1Jfw==",
|
||
algorithm: types.ChecksumAlgorithmXxhash128,
|
||
},
|
||
want: true,
|
||
},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
if got := IsValidChecksum(tt.args.checksum, tt.args.algorithm); got != tt.want {
|
||
t.Errorf("IsValidChecksum() = %v, want %v", got, tt.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestExtractMetadataFromFields(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
fields map[string]string
|
||
want map[string]string
|
||
wantErr error
|
||
}{
|
||
{
|
||
name: "extracts metadata only",
|
||
fields: map[string]string{
|
||
"x-amz-meta-owner": "alice",
|
||
"x-amz-meta-env": "prod",
|
||
"key": "uploads/report.pdf",
|
||
},
|
||
want: map[string]string{
|
||
"owner": "alice",
|
||
"env": "prod",
|
||
},
|
||
},
|
||
{
|
||
name: "returns empty map when no metadata fields exist",
|
||
fields: map[string]string{
|
||
"key": "uploads/report.pdf",
|
||
"acl": "private",
|
||
"file": "ignored",
|
||
},
|
||
want: map[string]string{},
|
||
},
|
||
{
|
||
name: "allows empty metadata value",
|
||
fields: map[string]string{
|
||
"x-amz-meta-owner": "",
|
||
},
|
||
want: map[string]string{
|
||
"owner": "",
|
||
},
|
||
},
|
||
{
|
||
name: "metadata too large",
|
||
fields: map[string]string{
|
||
"x-amz-meta-big": strings.Repeat("a", maxMetadataSize-len("big")+1),
|
||
},
|
||
wantErr: s3err.GetMetadataTooLargeErr(2049, maxMetadataSize),
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
got, err := ExtractMetadataFromFields(tt.fields)
|
||
if !errors.Is(err, tt.wantErr) {
|
||
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
|
||
}
|
||
if err != nil {
|
||
return
|
||
}
|
||
if !reflect.DeepEqual(got, tt.want) {
|
||
t.Fatalf("unexpected metadata: got %v want %v", got, tt.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestParseCalculatedChecksumFields(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
fields map[string]string
|
||
want ChecksumValues
|
||
wantErr error
|
||
}{
|
||
{
|
||
name: "empty fields",
|
||
fields: map[string]string{
|
||
"key": "uploads/file.txt",
|
||
},
|
||
want: ChecksumValues{},
|
||
},
|
||
{
|
||
name: "single valid checksum field",
|
||
fields: map[string]string{
|
||
"x-amz-checksum-crc32": "ww2FVQ==",
|
||
},
|
||
want: ChecksumValues{
|
||
types.ChecksumAlgorithmCrc32: "ww2FVQ==",
|
||
},
|
||
},
|
||
{
|
||
name: "ignores algorithm and type helper fields",
|
||
fields: map[string]string{
|
||
"x-amz-checksum-algorithm": "CRC32",
|
||
"x-amz-checksum-type": "FULL_OBJECT",
|
||
"x-amz-checksum-crc32": "ww2FVQ==",
|
||
},
|
||
want: ChecksumValues{
|
||
types.ChecksumAlgorithmCrc32: "ww2FVQ==",
|
||
},
|
||
},
|
||
{
|
||
name: "invalid checksum field name",
|
||
fields: map[string]string{
|
||
"x-amz-checksum-madeup": "abc",
|
||
},
|
||
wantErr: s3err.GetAPIError(s3err.ErrInvalidChecksumHeader),
|
||
},
|
||
{
|
||
name: "multiple checksum fields",
|
||
fields: map[string]string{
|
||
"x-amz-checksum-crc32": "ww2FVQ==",
|
||
"x-amz-checksum-sha256": "d1SPCd/kZ2rAzbbLUC0n/bEaOSx70FNbXbIqoIxKuPY=",
|
||
},
|
||
wantErr: s3err.GetAPIError(s3err.ErrMultipleChecksumHeaders),
|
||
},
|
||
{
|
||
name: "invalid checksum value",
|
||
fields: map[string]string{
|
||
"x-amz-checksum-crc32": "invalid_base64_string",
|
||
},
|
||
wantErr: s3err.GetInvalidChecksumHeaderErr("x-amz-checksum-crc32"),
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
got, err := ParseCalculatedChecksumFields(tt.fields)
|
||
if !errors.Is(err, tt.wantErr) {
|
||
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
|
||
}
|
||
if err != nil {
|
||
return
|
||
}
|
||
if !reflect.DeepEqual(got, tt.want) {
|
||
t.Fatalf("unexpected checksums: got %v want %v", got, tt.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestIsChecksumTypeValid(t *testing.T) {
|
||
type args struct {
|
||
t types.ChecksumType
|
||
}
|
||
tests := []struct {
|
||
name string
|
||
args args
|
||
wantErr bool
|
||
}{
|
||
{
|
||
name: "valid_FULL_OBJECT",
|
||
args: args{
|
||
t: types.ChecksumTypeFullObject,
|
||
},
|
||
wantErr: false,
|
||
},
|
||
{
|
||
name: "valid_COMPOSITE",
|
||
args: args{
|
||
t: types.ChecksumTypeComposite,
|
||
},
|
||
wantErr: false,
|
||
},
|
||
{
|
||
name: "invalid",
|
||
args: args{
|
||
t: types.ChecksumType("invalid_checksum_type"),
|
||
},
|
||
wantErr: true,
|
||
},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
if err := IsChecksumTypeValid(tt.args.t); (err != nil) != tt.wantErr {
|
||
t.Errorf("IsChecksumTypeValid() error = %v, wantErr %v", err, tt.wantErr)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func Test_checkChecksumTypeAndAlgo(t *testing.T) {
|
||
type args struct {
|
||
algo types.ChecksumAlgorithm
|
||
t types.ChecksumType
|
||
}
|
||
tests := []struct {
|
||
name string
|
||
args args
|
||
wantErr bool
|
||
}{
|
||
{
|
||
name: "full_object-crc32",
|
||
args: args{
|
||
algo: types.ChecksumAlgorithmCrc32,
|
||
t: types.ChecksumTypeFullObject,
|
||
},
|
||
wantErr: false,
|
||
},
|
||
{
|
||
name: "full_object-crc32c",
|
||
args: args{
|
||
algo: types.ChecksumAlgorithmCrc32c,
|
||
t: types.ChecksumTypeFullObject,
|
||
},
|
||
wantErr: false,
|
||
},
|
||
{
|
||
name: "full_object-sha1",
|
||
args: args{
|
||
algo: types.ChecksumAlgorithmSha1,
|
||
t: types.ChecksumTypeFullObject,
|
||
},
|
||
wantErr: true,
|
||
},
|
||
{
|
||
name: "full_object-sha256",
|
||
args: args{
|
||
algo: types.ChecksumAlgorithmSha1,
|
||
t: types.ChecksumTypeFullObject,
|
||
},
|
||
wantErr: true,
|
||
},
|
||
{
|
||
name: "full_object-crc64nvme",
|
||
args: args{
|
||
algo: types.ChecksumAlgorithmCrc64nvme,
|
||
t: types.ChecksumTypeFullObject,
|
||
},
|
||
wantErr: false,
|
||
},
|
||
{
|
||
name: "full_object-crc32",
|
||
args: args{
|
||
algo: types.ChecksumAlgorithmCrc32,
|
||
t: types.ChecksumTypeFullObject,
|
||
},
|
||
wantErr: false,
|
||
},
|
||
{
|
||
name: "composite-crc32",
|
||
args: args{
|
||
algo: types.ChecksumAlgorithmCrc32,
|
||
t: types.ChecksumTypeComposite,
|
||
},
|
||
wantErr: false,
|
||
},
|
||
{
|
||
name: "composite-crc32c",
|
||
args: args{
|
||
algo: types.ChecksumAlgorithmCrc32c,
|
||
t: types.ChecksumTypeComposite,
|
||
},
|
||
wantErr: false,
|
||
},
|
||
{
|
||
name: "composite-sha1",
|
||
args: args{
|
||
algo: types.ChecksumAlgorithmSha1,
|
||
t: types.ChecksumTypeComposite,
|
||
},
|
||
wantErr: false,
|
||
},
|
||
{
|
||
name: "composite-sha256",
|
||
args: args{
|
||
algo: types.ChecksumAlgorithmSha256,
|
||
t: types.ChecksumTypeComposite,
|
||
},
|
||
wantErr: false,
|
||
},
|
||
{
|
||
name: "composite-crc64nvme",
|
||
args: args{
|
||
algo: types.ChecksumAlgorithmCrc64nvme,
|
||
t: types.ChecksumTypeComposite,
|
||
},
|
||
wantErr: true,
|
||
},
|
||
{
|
||
name: "full_object-sha512",
|
||
args: args{
|
||
algo: types.ChecksumAlgorithmSha512,
|
||
t: types.ChecksumTypeFullObject,
|
||
},
|
||
wantErr: true,
|
||
},
|
||
{
|
||
name: "full_object-md5",
|
||
args: args{
|
||
algo: types.ChecksumAlgorithmMd5,
|
||
t: types.ChecksumTypeFullObject,
|
||
},
|
||
wantErr: true,
|
||
},
|
||
{
|
||
name: "full_object-xxhash64",
|
||
args: args{
|
||
algo: types.ChecksumAlgorithmXxhash64,
|
||
t: types.ChecksumTypeFullObject,
|
||
},
|
||
wantErr: true,
|
||
},
|
||
{
|
||
name: "full_object-xxhash3",
|
||
args: args{
|
||
algo: types.ChecksumAlgorithmXxhash3,
|
||
t: types.ChecksumTypeFullObject,
|
||
},
|
||
wantErr: true,
|
||
},
|
||
{
|
||
name: "full_object-xxhash128",
|
||
args: args{
|
||
algo: types.ChecksumAlgorithmXxhash128,
|
||
t: types.ChecksumTypeFullObject,
|
||
},
|
||
wantErr: true,
|
||
},
|
||
{
|
||
name: "composite-sha512",
|
||
args: args{
|
||
algo: types.ChecksumAlgorithmSha512,
|
||
t: types.ChecksumTypeComposite,
|
||
},
|
||
wantErr: false,
|
||
},
|
||
{
|
||
name: "composite-md5",
|
||
args: args{
|
||
algo: types.ChecksumAlgorithmMd5,
|
||
t: types.ChecksumTypeComposite,
|
||
},
|
||
wantErr: false,
|
||
},
|
||
{
|
||
name: "composite-xxhash64",
|
||
args: args{
|
||
algo: types.ChecksumAlgorithmXxhash64,
|
||
t: types.ChecksumTypeComposite,
|
||
},
|
||
wantErr: false,
|
||
},
|
||
{
|
||
name: "composite-xxhash3",
|
||
args: args{
|
||
algo: types.ChecksumAlgorithmXxhash3,
|
||
t: types.ChecksumTypeComposite,
|
||
},
|
||
wantErr: false,
|
||
},
|
||
{
|
||
name: "composite-xxhash128",
|
||
args: args{
|
||
algo: types.ChecksumAlgorithmXxhash128,
|
||
t: types.ChecksumTypeComposite,
|
||
},
|
||
wantErr: false,
|
||
},
|
||
{
|
||
name: "composite-empty",
|
||
args: args{
|
||
t: types.ChecksumTypeComposite,
|
||
},
|
||
wantErr: true,
|
||
},
|
||
{
|
||
name: "full_object-empty",
|
||
args: args{
|
||
t: types.ChecksumTypeFullObject,
|
||
},
|
||
wantErr: true,
|
||
},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
if err := checkChecksumTypeAndAlgo(tt.args.algo, tt.args.t); (err != nil) != tt.wantErr {
|
||
t.Errorf("checkChecksumTypeAndAlgo() error = %v, wantErr %v", err, tt.wantErr)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestParseTagging(t *testing.T) {
|
||
genRandStr := func(lgth int) string {
|
||
b := make([]byte, lgth)
|
||
for i := range b {
|
||
b[i] = byte(rand.Intn(95) + 32) // 126 - 32 + 1 = 95 printable characters
|
||
}
|
||
return string(b)
|
||
}
|
||
getTagSet := func(lgth int) s3response.Tagging {
|
||
res := s3response.Tagging{
|
||
TagSet: s3response.TagSet{
|
||
Tags: []s3response.Tag{},
|
||
},
|
||
}
|
||
|
||
for i := range lgth {
|
||
res.TagSet.Tags = append(res.TagSet.Tags, s3response.Tag{
|
||
Key: "key" + strconv.Itoa(i),
|
||
Value: "value",
|
||
})
|
||
}
|
||
|
||
return res
|
||
}
|
||
type args struct {
|
||
data s3response.Tagging
|
||
overrideXML []byte
|
||
limit TagLimit
|
||
}
|
||
tests := []struct {
|
||
name string
|
||
args args
|
||
want map[string]string
|
||
wantErr error
|
||
}{
|
||
{
|
||
name: "valid tags within limit",
|
||
args: args{
|
||
data: s3response.Tagging{
|
||
TagSet: s3response.TagSet{
|
||
Tags: []s3response.Tag{
|
||
{Key: "key1", Value: "value1"},
|
||
{Key: "key2", Value: "value2"},
|
||
},
|
||
},
|
||
},
|
||
limit: TagLimitObject,
|
||
},
|
||
want: map[string]string{"key1": "value1", "key2": "value2"},
|
||
wantErr: nil,
|
||
},
|
||
{
|
||
name: "malformed XML",
|
||
args: args{
|
||
overrideXML: []byte("invalid xml"),
|
||
limit: TagLimitObject,
|
||
},
|
||
want: nil,
|
||
wantErr: s3err.GetAPIError(s3err.ErrMalformedXML),
|
||
},
|
||
{
|
||
name: "valid tags without namespace",
|
||
args: args{
|
||
overrideXML: []byte(`<?xml version="1.0"?><Tagging><TagSet><Tag><Key>key1</Key><Value>value1</Value></Tag><Tag><Key>key2</Key><Value>value2</Value></Tag></TagSet></Tagging>`),
|
||
limit: TagLimitObject,
|
||
},
|
||
want: map[string]string{"key1": "value1", "key2": "value2"},
|
||
wantErr: nil,
|
||
},
|
||
{
|
||
name: "valid tags with namespace",
|
||
args: args{
|
||
overrideXML: []byte(`<?xml version="1.0"?><Tagging xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><TagSet><Tag><Key>key1</Key><Value>value1</Value></Tag><Tag><Key>key2</Key><Value>value2</Value></Tag></TagSet></Tagging>`),
|
||
limit: TagLimitObject,
|
||
},
|
||
want: map[string]string{"key1": "value1", "key2": "value2"},
|
||
wantErr: nil,
|
||
},
|
||
{
|
||
name: "exceeds bucket tag limit",
|
||
args: args{
|
||
data: getTagSet(51),
|
||
limit: TagLimitBucket,
|
||
},
|
||
want: nil,
|
||
wantErr: s3err.GetAPIError(s3err.ErrBucketTaggingLimited),
|
||
},
|
||
{
|
||
name: "exceeds object tag limit",
|
||
args: args{
|
||
data: getTagSet(11),
|
||
limit: TagLimitObject,
|
||
},
|
||
want: nil,
|
||
wantErr: s3err.GetAPIError(s3err.ErrObjectTaggingLimited),
|
||
},
|
||
{
|
||
name: "invalid tag over the tag limit",
|
||
args: args{
|
||
overrideXML: []byte(`<Tagging><TagSet>` +
|
||
strings.Repeat(`<Tag><Key>key</Key><Value>value</Value></Tag>`, 11) +
|
||
`</TagSet></Tagging>`),
|
||
limit: TagLimitObject,
|
||
},
|
||
want: nil,
|
||
wantErr: s3err.GetInvalidTagErr(s3err.ErrDuplicateTagKey, "key", ""),
|
||
},
|
||
{
|
||
name: "invalid 0 length tag key",
|
||
args: args{
|
||
data: s3response.Tagging{
|
||
TagSet: s3response.TagSet{
|
||
Tags: []s3response.Tag{{Key: "", Value: "value1"}},
|
||
},
|
||
},
|
||
limit: TagLimitObject,
|
||
},
|
||
want: nil,
|
||
wantErr: s3err.GetInvalidTagErr(s3err.ErrInvalidTagKey, "", ""),
|
||
},
|
||
{
|
||
name: "missing tag key",
|
||
args: args{
|
||
overrideXML: []byte(`<Tagging><TagSet><Tag><Value>value</Value></Tag></TagSet></Tagging>`),
|
||
limit: TagLimitObject,
|
||
},
|
||
want: nil,
|
||
wantErr: s3err.GetAPIError(s3err.ErrMalformedXML),
|
||
},
|
||
{
|
||
name: "missing tag value",
|
||
args: args{
|
||
overrideXML: []byte(`<Tagging><TagSet><Tag><Key>key</Key></Tag></TagSet></Tagging>`),
|
||
limit: TagLimitObject,
|
||
},
|
||
want: nil,
|
||
wantErr: s3err.GetAPIError(s3err.ErrMalformedXML),
|
||
},
|
||
{
|
||
name: "missing tag key after an invalid tag",
|
||
args: args{
|
||
overrideXML: []byte(`<Tagging><TagSet><Tag><Key>key!</Key><Value>value</Value></Tag><Tag><Value>value</Value></Tag></TagSet></Tagging>`),
|
||
limit: TagLimitObject,
|
||
},
|
||
want: nil,
|
||
wantErr: s3err.GetAPIError(s3err.ErrMalformedXML),
|
||
},
|
||
{
|
||
name: "empty tag value",
|
||
args: args{
|
||
overrideXML: []byte(`<Tagging><TagSet><Tag><Key>key</Key><Value/></Tag></TagSet></Tagging>`),
|
||
limit: TagLimitObject,
|
||
},
|
||
want: map[string]string{"key": ""},
|
||
wantErr: nil,
|
||
},
|
||
{
|
||
name: "multibyte tags at the length limits",
|
||
args: args{
|
||
data: s3response.Tagging{
|
||
TagSet: s3response.TagSet{
|
||
Tags: []s3response.Tag{
|
||
{Key: strings.Repeat("é", 128), Value: strings.Repeat("中", 256)},
|
||
{Key: strings.Repeat("𝐀", 64), Value: "value"},
|
||
},
|
||
},
|
||
},
|
||
limit: TagLimitObject,
|
||
},
|
||
want: map[string]string{
|
||
strings.Repeat("é", 128): strings.Repeat("中", 256),
|
||
strings.Repeat("𝐀", 64): "value",
|
||
},
|
||
wantErr: nil,
|
||
},
|
||
{
|
||
name: "tag key over 128 UTF-16 code units",
|
||
args: args{
|
||
data: s3response.Tagging{
|
||
TagSet: s3response.TagSet{
|
||
Tags: []s3response.Tag{{Key: strings.Repeat("𝐀", 65), Value: "value"}},
|
||
},
|
||
},
|
||
limit: TagLimitObject,
|
||
},
|
||
want: nil,
|
||
wantErr: s3err.GetInvalidTagErr(s3err.ErrInvalidTagKey, strings.Repeat("𝐀", 65), ""),
|
||
},
|
||
{
|
||
name: "invalid long tag key",
|
||
args: args{
|
||
data: s3response.Tagging{
|
||
TagSet: s3response.TagSet{
|
||
Tags: []s3response.Tag{{Key: genRandStr(130), Value: "value1"}},
|
||
},
|
||
},
|
||
limit: TagLimitObject,
|
||
},
|
||
want: nil,
|
||
wantErr: s3err.GetAPIError(s3err.ErrInvalidTagKey),
|
||
},
|
||
{
|
||
name: "invalid long tag value",
|
||
args: args{
|
||
data: s3response.Tagging{
|
||
TagSet: s3response.TagSet{
|
||
Tags: []s3response.Tag{{Key: "key", Value: genRandStr(257)}},
|
||
},
|
||
},
|
||
limit: TagLimitBucket,
|
||
},
|
||
want: nil,
|
||
wantErr: s3err.GetAPIError(s3err.ErrInvalidTagValue),
|
||
},
|
||
{
|
||
name: "duplicate tag key",
|
||
args: args{
|
||
data: s3response.Tagging{
|
||
TagSet: s3response.TagSet{
|
||
Tags: []s3response.Tag{
|
||
{Key: "key", Value: "value1"},
|
||
{Key: "key", Value: "value2"},
|
||
},
|
||
},
|
||
},
|
||
limit: TagLimitObject,
|
||
},
|
||
want: nil,
|
||
wantErr: s3err.GetInvalidTagErr(s3err.ErrDuplicateTagKey, "key", ""),
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
var data []byte
|
||
if tt.args.overrideXML != nil {
|
||
data = tt.args.overrideXML
|
||
} else {
|
||
var err error
|
||
data, err = xml.Marshal(tt.args.data)
|
||
if err != nil {
|
||
t.Fatalf("error marshalling input: %v", err)
|
||
}
|
||
}
|
||
got, err := ParseTagging(data, tt.args.limit)
|
||
|
||
if !errors.Is(err, tt.wantErr) {
|
||
t.Errorf("expected error %v, got %v", tt.wantErr, err)
|
||
}
|
||
if err == nil && !reflect.DeepEqual(got, tt.want) {
|
||
t.Errorf("expected result %v, got %v", tt.want, got)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestConvertTaggingXMLToQueryString(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
data s3response.Tagging
|
||
rawXML []byte
|
||
want string
|
||
wantErr error
|
||
}{
|
||
{
|
||
name: "success",
|
||
data: s3response.Tagging{
|
||
TagSet: s3response.TagSet{
|
||
Tags: []s3response.Tag{
|
||
{Key: "project", Value: "versity gw"},
|
||
{Key: "team", Value: "storage"},
|
||
},
|
||
},
|
||
},
|
||
want: "project=versity+gw&team=storage",
|
||
},
|
||
{
|
||
name: "parse error",
|
||
rawXML: []byte("not xml"),
|
||
wantErr: s3err.GetAPIError(s3err.ErrMalformedXML),
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
var data []byte
|
||
if tt.rawXML != nil {
|
||
data = tt.rawXML
|
||
} else {
|
||
var err error
|
||
data, err = xml.Marshal(tt.data)
|
||
if err != nil {
|
||
t.Fatalf("error marshalling input: %v", err)
|
||
}
|
||
}
|
||
|
||
got, err := ConvertTaggingXMLToQueryString(data)
|
||
if !errors.Is(err, tt.wantErr) {
|
||
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
|
||
}
|
||
if err != nil {
|
||
return
|
||
}
|
||
|
||
if got != tt.want {
|
||
t.Fatalf("unexpected tagging string: got %q want %q", got, tt.want)
|
||
}
|
||
|
||
values, err := url.ParseQuery(got)
|
||
if err != nil {
|
||
t.Fatalf("parse query: %v", err)
|
||
}
|
||
for _, tag := range tt.data.TagSet.Tags {
|
||
if values.Get(tag.Key) != tag.Value {
|
||
t.Fatalf("unexpected query value for %q: got %q want %q", tag.Key, values.Get(tag.Key), tag.Value)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestValidateCopySource(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
copysource string
|
||
err error
|
||
}{
|
||
// invalid encoding
|
||
{"invalid encoding 1", "%", s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceEncoding, "%")},
|
||
{"invalid encoding 2", "%2", s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceEncoding, "%2")},
|
||
{"invalid encoding 3", "%G1", s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceEncoding, "%G1")},
|
||
{"invalid encoding 4", "%1Z", s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceEncoding, "%1Z")},
|
||
{"invalid encoding 5", "%0H", s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceEncoding, "%0H")},
|
||
{"invalid encoding 6", "%XY", s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceEncoding, "%XY")},
|
||
{"invalid encoding 7", "%E", s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceEncoding, "%E")},
|
||
{"invalid encoding 8", "hello%", s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceEncoding, "hello%")},
|
||
{"invalid encoding 9", "%%", s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceEncoding, "%%")},
|
||
{"invalid encoding 10", "%2Gmore", s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceEncoding, "%2Gmore")},
|
||
{"invalid encoding 11", "100%%sure", s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceEncoding, "100%%sure")},
|
||
{"invalid encoding 12", "%#00", s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceEncoding, "%#00")},
|
||
{"invalid encoding 13", "%0%0", s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceEncoding, "%0%0")},
|
||
{"invalid encoding 14", "%?versionId=id", s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceEncoding, "%?versionId=id")},
|
||
// invalid bucket name
|
||
{"invalid bucket name 1", "168.200.1.255/obj/foo", s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceBucket, "168.200.1.255/obj/foo")},
|
||
{"invalid bucket name 2", "/0000:0db8:85a3:0000:0000:8a2e:0370:7224/smth", s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceBucket, "/0000:0db8:85a3:0000:0000:8a2e:0370:7224/smth")},
|
||
{"invalid bucket name 3", "", s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceBucket, "")},
|
||
{"invalid bucket name 4", "//obj/foo", s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceBucket, "//obj/foo")},
|
||
{"invalid bucket name 5", "//obj/foo?versionId=id", s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceBucket, "//obj/foo?versionId=id")},
|
||
// invalid object name
|
||
{"invalid object name 1", "bucket/../foo", s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceObject, "../foo")},
|
||
{"invalid object name 2", "bucket/", s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceObject, "")},
|
||
{"invalid object name 3", "bucket", s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceObject, "")},
|
||
{"invalid object name 4", "bucket/../foo/dir/../../../", s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceObject, "../foo/dir/../../../")},
|
||
{"invalid object name 5", "bucket/.?versionId=smth", s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceObject, ".")},
|
||
// success
|
||
{"no error 1", "bucket/object", nil},
|
||
{"no error 2", "bucket/object/key", nil},
|
||
{"no error 3", "bucket/4*&(*&(89765))", nil},
|
||
{"no error 4", "bucket/foo/../bar", nil},
|
||
{"no error 5", "bucket/foo/bar/baz?versionId=01BX5ZZKBKACTAV9WEVGEMMVRZ", nil},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
err := ValidateCopySource(tt.copysource)
|
||
assert.Equal(t, tt.err, err)
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestHasAwsChunkedEncoding(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
contentEncoding string
|
||
want bool
|
||
}{
|
||
{"empty", "", false},
|
||
{"only aws-chunked", "aws-chunked", true},
|
||
{"uppercase", "AWS-CHUNKED", true},
|
||
{"padded", " aws-chunked ", true},
|
||
{"alongside another coding", "aws-chunked, gzip", true},
|
||
{"other coding only", "gzip", false},
|
||
{"token is a prefix of another coding", "aws-chunked-custom", false},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
assert.Equal(t, tt.want, HasAwsChunkedEncoding(tt.contentEncoding))
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestValidateBucketRegion(t *testing.T) {
|
||
endpointErr := s3err.GetInvalidArgumentErr(s3err.InvalidArgBucketRegionEndpoint, "")
|
||
tests := []struct {
|
||
name string
|
||
bucketRegion string
|
||
region string
|
||
want error
|
||
}{
|
||
{"gateway region", "us-east-1", "us-east-1", nil},
|
||
{"gateway region in uppercase", "EU-CENTRAL-1", "eu-central-1", nil},
|
||
{"custom gateway region", "my-region", "my-region", nil},
|
||
{"another aws region", "eu-central-1", "us-east-1", endpointErr},
|
||
{"another aws region in uppercase", "US-EAST-1", "eu-central-1", endpointErr},
|
||
{"gov cloud region", "us-gov-west-1", "us-east-1", endpointErr},
|
||
{"iso partition region", "us-isob-east-1", "us-east-1", endpointErr},
|
||
{"sovereign cloud region", "eusc-de-east-1", "us-east-1", endpointErr},
|
||
{"gibberish", "abc", "us-east-1", s3err.GetInvalidArgBucketRegion("abc")},
|
||
{"empty", "", "us-east-1", s3err.GetInvalidArgBucketRegion("")},
|
||
{"missing number", "us-east", "us-east-1", s3err.GetInvalidArgBucketRegion("us-east")},
|
||
{"custom region of another gateway", "my-region", "us-east-1", s3err.GetInvalidArgBucketRegion("my-region")},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
assert.Equal(t, tt.want, ValidateBucketRegion(tt.bucketRegion, tt.region))
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestParseContentEncoding(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
contentSha256 string
|
||
contentEncoding string
|
||
want string
|
||
}{
|
||
// streaming: the token frames the request, so S3 drops it
|
||
{"streaming, only aws-chunked", "STREAMING-UNSIGNED-PAYLOAD-TRAILER", "aws-chunked", ""},
|
||
{"streaming, aws-chunked first", "STREAMING-UNSIGNED-PAYLOAD-TRAILER", "aws-chunked,gzip", "gzip"},
|
||
{"streaming, aws-chunked last", "STREAMING-AWS4-HMAC-SHA256-PAYLOAD", "gzip,aws-chunked", "gzip"},
|
||
{"streaming, aws-chunked in the middle", "STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER", "deflate,aws-chunked,gzip", "deflate,gzip"},
|
||
{"streaming, spaces around codings", "STREAMING-UNSIGNED-PAYLOAD-TRAILER", "aws-chunked, gzip", "gzip"},
|
||
{"streaming, uppercase", "STREAMING-UNSIGNED-PAYLOAD-TRAILER", "AWS-CHUNKED,gzip", "gzip"},
|
||
{"streaming, repeated", "STREAMING-UNSIGNED-PAYLOAD-TRAILER", "aws-chunked,aws-chunked", ""},
|
||
{"streaming, empty coding dropped", "STREAMING-UNSIGNED-PAYLOAD-TRAILER", "gzip,,aws-chunked", "gzip"},
|
||
{"streaming, token is a prefix of another coding", "STREAMING-UNSIGNED-PAYLOAD-TRAILER", "aws-chunked-custom", "aws-chunked-custom"},
|
||
{"streaming, no aws-chunked", "STREAMING-UNSIGNED-PAYLOAD-TRAILER", "gzip", "gzip"},
|
||
{"streaming, no content encoding", "STREAMING-UNSIGNED-PAYLOAD-TRAILER", "", ""},
|
||
// no payload type: a presigned request is signed as UNSIGNED-PAYLOAD and
|
||
// may still frame its body, so S3 drops the token
|
||
{"no payload type, only aws-chunked", "", "aws-chunked", ""},
|
||
{"no payload type, aws-chunked first", "", "aws-chunked,gzip", "gzip"},
|
||
{"no payload type, aws-chunked last", "", "gzip,aws-chunked", "gzip"},
|
||
{"no payload type, uppercase", "", "AWS-CHUNKED", ""},
|
||
{"no payload type, token is a prefix of another coding", "", "aws-chunked-custom", "aws-chunked-custom"},
|
||
{"no payload type, no aws-chunked", "", "gzip", "gzip"},
|
||
// a declared non streaming payload type: the token is a value the client
|
||
// chose, and S3 keeps it
|
||
{"hex payload keeps aws-chunked", "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "aws-chunked", "aws-chunked"},
|
||
{"hex payload keeps other codings", "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "aws-chunked,gzip", "aws-chunked,gzip"},
|
||
{"unsigned payload keeps aws-chunked", "UNSIGNED-PAYLOAD", "aws-chunked", "aws-chunked"},
|
||
{"ecdsa streaming is not chunk decoded, so the value is kept", "STREAMING-AWS4-ECDSA-P256-SHA256-PAYLOAD", "aws-chunked", "aws-chunked"},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
headers := http.Header{}
|
||
if tt.contentSha256 != "" {
|
||
headers.Set("X-Amz-Content-Sha256", tt.contentSha256)
|
||
}
|
||
if tt.contentEncoding != "" {
|
||
headers.Set("Content-Encoding", tt.contentEncoding)
|
||
}
|
||
|
||
ctx := fiberCtxFromURL(t, http.MethodPut, "http://localhost/bucket/object", headers)
|
||
assert.Equal(t, tt.want, ParseContentEncoding(ctx))
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestSetRegionMismatchHeader(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
err error
|
||
want string
|
||
}{
|
||
{
|
||
"signature v4 header region mismatch",
|
||
s3err.MalformedAuth.IncorrectRegion("eu-west-1", "us-east-1"),
|
||
"eu-west-1",
|
||
},
|
||
{
|
||
"presigned url region mismatch",
|
||
s3err.QueryAuthErrors.IncorrectRegion("eu-west-1", "us-east-1"),
|
||
"eu-west-1",
|
||
},
|
||
{
|
||
"post object region mismatch",
|
||
s3err.PostAuth.IncorrectRegion("creds", "eu-west-1", "us-east-1"),
|
||
"eu-west-1",
|
||
},
|
||
// only the region mismatch carries a region, the rest of the errors
|
||
// must not report one
|
||
{
|
||
"malformed auth error without a region",
|
||
s3err.MalformedAuth.MissingSignature(),
|
||
"",
|
||
},
|
||
{
|
||
"presigned url error without a region",
|
||
s3err.QueryAuthErrors.IncorrectService("", "iam"),
|
||
"",
|
||
},
|
||
{
|
||
"invalid argument error without a region",
|
||
s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceBucket, "bucket"),
|
||
"",
|
||
},
|
||
{
|
||
"unrelated s3 error",
|
||
s3err.GetAPIError(s3err.ErrNoSuchBucket),
|
||
"",
|
||
},
|
||
{
|
||
"non s3 error",
|
||
errors.New("internal"),
|
||
"",
|
||
},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
ctx := fiber.New().AcquireCtx(&fasthttp.RequestCtx{})
|
||
|
||
SetRegionMismatchHeader(ctx, tt.err)
|
||
|
||
assert.Equal(t, tt.want,
|
||
string(ctx.Response().Header.Peek("x-amz-bucket-region")))
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestParseObjectLockParams covers the Object Lock parameters of an object
|
||
// write, as headers and as POST form fields alike: the order S3 validates
|
||
// them in, a parameter sent with an empty value, the date formats accepted
|
||
// and the millisecond precision the date is kept to.
|
||
func TestParseObjectLockParams(t *testing.T) {
|
||
future := time.Now().UTC().Add(time.Hour).Truncate(time.Second)
|
||
date := future.Format("2006-01-02T15:04:05")
|
||
past := "2020-01-01T00:00:00Z"
|
||
|
||
tests := []struct {
|
||
name string
|
||
params map[string]string
|
||
wantErr error
|
||
wantMode types.ObjectLockMode
|
||
wantHold types.ObjectLockLegalHoldStatus
|
||
wantUntil time.Time
|
||
}{
|
||
{name: "none", params: map[string]string{}},
|
||
{
|
||
name: "all set",
|
||
params: map[string]string{"x-amz-object-lock-mode": "GOVERNANCE", "x-amz-object-lock-retain-until-date": date + "Z", "x-amz-object-lock-legal-hold": "ON"},
|
||
wantMode: types.ObjectLockModeGovernance,
|
||
wantHold: types.ObjectLockLegalHoldStatusOn,
|
||
wantUntil: future,
|
||
},
|
||
{
|
||
name: "date truncated to the millisecond",
|
||
params: map[string]string{"x-amz-object-lock-mode": "COMPLIANCE", "x-amz-object-lock-retain-until-date": date + ".123999Z"},
|
||
wantMode: types.ObjectLockModeCompliance,
|
||
wantUntil: future.Add(123 * time.Millisecond),
|
||
},
|
||
{
|
||
name: "lowercase separator and UTC designator",
|
||
params: map[string]string{"x-amz-object-lock-mode": "GOVERNANCE", "x-amz-object-lock-retain-until-date": strings.Replace(date, "T", "t", 1) + "z"},
|
||
wantMode: types.ObjectLockModeGovernance,
|
||
wantUntil: future,
|
||
},
|
||
{
|
||
name: "date with an offset",
|
||
params: map[string]string{"x-amz-object-lock-mode": "GOVERNANCE", "x-amz-object-lock-retain-until-date": future.In(time.FixedZone("", 2*60*60)).Format(time.RFC3339)},
|
||
wantMode: types.ObjectLockModeGovernance,
|
||
wantUntil: future,
|
||
},
|
||
{
|
||
name: "legal hold off",
|
||
params: map[string]string{"x-amz-object-lock-legal-hold": "OFF"},
|
||
wantHold: types.ObjectLockLegalHoldStatusOff,
|
||
},
|
||
{
|
||
name: "mode without a date",
|
||
params: map[string]string{"x-amz-object-lock-mode": "INVALID", "x-amz-object-lock-legal-hold": "INVALID"},
|
||
wantErr: s3err.GetInvalidArgumentErr(s3err.InvalidArgMissingObjectLockRetainDate, ""),
|
||
},
|
||
{
|
||
name: "date without a mode",
|
||
params: map[string]string{"x-amz-object-lock-retain-until-date": past, "x-amz-object-lock-legal-hold": "INVALID"},
|
||
wantErr: s3err.GetInvalidArgumentErr(s3err.InvalidArgMissingObjectLockMode, ""),
|
||
},
|
||
{
|
||
name: "invalid legal hold before the date",
|
||
params: map[string]string{"x-amz-object-lock-mode": "INVALID", "x-amz-object-lock-retain-until-date": past, "x-amz-object-lock-legal-hold": "on"},
|
||
wantErr: s3err.GetInvalidArgumentErr(s3err.InvalidArgLegalHoldStatus, "on"),
|
||
},
|
||
{
|
||
name: "empty legal hold",
|
||
params: map[string]string{"x-amz-object-lock-legal-hold": ""},
|
||
wantErr: s3err.GetInvalidArgumentErr(s3err.InvalidArgLegalHoldStatus, ""),
|
||
},
|
||
{
|
||
name: "invalid date before the mode",
|
||
params: map[string]string{"x-amz-object-lock-mode": "INVALID", "x-amz-object-lock-retain-until-date": "2026-10-04"},
|
||
wantErr: s3err.GetInvalidArgumentErr(s3err.InvalidArgRetainUntilDate, "2026-10-04"),
|
||
},
|
||
{
|
||
name: "empty date",
|
||
params: map[string]string{"x-amz-object-lock-mode": "", "x-amz-object-lock-retain-until-date": ""},
|
||
wantErr: s3err.GetInvalidArgumentErr(s3err.InvalidArgRetainUntilDate, ""),
|
||
},
|
||
{
|
||
name: "past date before the mode",
|
||
params: map[string]string{"x-amz-object-lock-mode": "INVALID", "x-amz-object-lock-retain-until-date": past},
|
||
wantErr: s3err.GetInvalidArgumentErr(s3err.InvalidArgPastObjectLockRetainDate, past),
|
||
},
|
||
{
|
||
name: "lowercase mode",
|
||
params: map[string]string{"x-amz-object-lock-mode": "governance", "x-amz-object-lock-retain-until-date": date + "Z"},
|
||
wantErr: s3err.GetInvalidArgumentErr(s3err.InvalidArgObjectLockMode, "governance"),
|
||
},
|
||
{
|
||
name: "empty mode",
|
||
params: map[string]string{"x-amz-object-lock-mode": "", "x-amz-object-lock-retain-until-date": date + "Z"},
|
||
wantErr: s3err.GetInvalidArgumentErr(s3err.InvalidArgObjectLockMode, ""),
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
ctx := fiber.New().AcquireCtx(&fasthttp.RequestCtx{})
|
||
for name, value := range tt.params {
|
||
ctx.Request().Header.Set(name, value)
|
||
}
|
||
|
||
for source, parse := range map[string]func() (*objLockCfg, error){
|
||
"headers": func() (*objLockCfg, error) { return ParsObjectLockHdrs(ctx) },
|
||
"fields": func() (*objLockCfg, error) { return ParseObjectLockFields(tt.params) },
|
||
} {
|
||
cfg, err := parse()
|
||
if tt.wantErr != nil {
|
||
assert.Equal(t, tt.wantErr, err, source)
|
||
continue
|
||
}
|
||
if assert.NoError(t, err, source) {
|
||
assert.Equal(t, tt.wantMode, cfg.ObjectLockMode, source)
|
||
assert.Equal(t, tt.wantHold, cfg.LegalHoldStatus, source)
|
||
assert.True(t, tt.wantUntil.Equal(cfg.RetainUntilDate), "%s: expected %v, got %v", source, tt.wantUntil, cfg.RetainUntilDate)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestFormatRetainUntilDate covers the retain until date in response
|
||
// headers: UTC, to the millisecond, the milliseconds left out when zero.
|
||
func TestFormatRetainUntilDate(t *testing.T) {
|
||
assert.Nil(t, FormatRetainUntilDate(nil))
|
||
assert.Nil(t, FormatRetainUntilDate(&time.Time{}))
|
||
|
||
zone := time.FixedZone("", 2*60*60)
|
||
tests := []struct {
|
||
name string
|
||
date time.Time
|
||
want string
|
||
}{
|
||
{name: "whole second", date: time.Date(2026, 10, 4, 13, 28, 21, 0, zone), want: "2026-10-04T11:28:21Z"},
|
||
{name: "milliseconds", date: time.Date(2026, 10, 4, 11, 28, 21, 500000000, time.UTC), want: "2026-10-04T11:28:21.500Z"},
|
||
{name: "below a millisecond", date: time.Date(2026, 10, 4, 11, 28, 21, 123999999, time.UTC), want: "2026-10-04T11:28:21.123Z"},
|
||
{name: "only below a millisecond", date: time.Date(2026, 10, 4, 11, 28, 21, 999999, time.UTC), want: "2026-10-04T11:28:21Z"},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
got := FormatRetainUntilDate(&tt.date)
|
||
if assert.NotNil(t, got) {
|
||
assert.Equal(t, tt.want, *got)
|
||
}
|
||
})
|
||
}
|
||
}
|