Files
seaweedfs/test/s3/versioning/s3_conditional_reads_test.go
T
Chris Lu d8a189f07f s3: keep a missing object a 404 under If-Match and If-Unmodified-Since (#10985)
* s3: keep a missing object a 404 under If-Match and If-Unmodified-Since

GET and HEAD resolved the target before evaluating the conditional headers, and
a missing target failed If-Match and If-Unmodified-Since outright, so absence
surfaced as 412 PreconditionFailed. AWS reports the missing object instead:
404 for HeadObject, NoSuchKey for GetObject, and 412 only when a live object
fails the condition. Clients cannot tell absence from a stale precondition
without an extra racy HEAD, so OpenDAL disabled its four conditional
stat/read capabilities against SeaweedFS.

A precondition now only fails against an object that exists; a missing one --
including a latest version that is a delete marker -- returns NoSuchKey.

Claude-Session: https://claude.ai/code/session_01X4kEbuwxd9DFsTnSXjfjgv

* s3: evaluate a conditional read against the version the request names

GET and HEAD resolved the latest version before evaluating the conditional
headers, so a request carrying versionId had its If-Match compared against a
different version than the one it was asking for: a live version whose ETag the
client held failed once a newer version -- or a delete marker -- became the
latest. resolveObjectEntry now resolves the named version on a versioned bucket,
the way DELETE already does.

A named version that resolves to nothing is left to the handler, which alone
knows whether the bucket is versioned and so whether it owes NoSuchVersion.

Claude-Session: https://claude.ai/code/session_01X4kEbuwxd9DFsTnSXjfjgv
2026-08-27 11:56:33 -07:00

131 lines
4.4 KiB
Go

package s3api
import (
"context"
"errors"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/smithy-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func requireAPIErrorCode(t *testing.T, err error, expected string) {
t.Helper()
require.Error(t, err)
var apiErr smithy.APIError
require.True(t, errors.As(err, &apiErr), "expected a smithy.APIError, got %T: %v", err, err)
assert.Equal(t, expected, apiErr.ErrorCode())
}
// TestConditionalReadsOfMissingObject verifies that a missing key stays a missing key
// under If-Match and If-Unmodified-Since instead of surfacing as 412.
// reproduces issue #10984
func TestConditionalReadsOfMissingObject(t *testing.T) {
client := getS3Client(t)
bucketName := getNewBucketName()
createBucket(t, client, bucketName)
defer deleteBucket(t, client, bucketName)
existing := putObject(t, client, bucketName, "etag-source", "content")
require.NotNil(t, existing.ETag)
future := aws.Time(time.Now().Add(24 * time.Hour))
missing := aws.String("conditional-missing")
t.Run("HeadObject If-Match", func(t *testing.T) {
_, err := client.HeadObject(context.TODO(), &s3.HeadObjectInput{
Bucket: aws.String(bucketName), Key: missing, IfMatch: existing.ETag,
})
requireAPIErrorCode(t, err, "NotFound")
})
t.Run("HeadObject If-Unmodified-Since", func(t *testing.T) {
_, err := client.HeadObject(context.TODO(), &s3.HeadObjectInput{
Bucket: aws.String(bucketName), Key: missing, IfUnmodifiedSince: future,
})
requireAPIErrorCode(t, err, "NotFound")
})
t.Run("GetObject If-Match", func(t *testing.T) {
_, err := client.GetObject(context.TODO(), &s3.GetObjectInput{
Bucket: aws.String(bucketName), Key: missing, IfMatch: existing.ETag,
})
requireAPIErrorCode(t, err, "NoSuchKey")
})
t.Run("GetObject If-Unmodified-Since", func(t *testing.T) {
_, err := client.GetObject(context.TODO(), &s3.GetObjectInput{
Bucket: aws.String(bucketName), Key: missing, IfUnmodifiedSince: future,
})
requireAPIErrorCode(t, err, "NoSuchKey")
})
t.Run("GetObject stale If-Match on a live object stays 412", func(t *testing.T) {
_, err := client.GetObject(context.TODO(), &s3.GetObjectInput{
Bucket: aws.String(bucketName), Key: aws.String("etag-source"),
IfMatch: aws.String(`"0000000000000000000000000000dead"`),
})
requireAPIErrorCode(t, err, "PreconditionFailed")
})
}
// TestConditionalReadsOfNamedVersion verifies that a conditional GET or HEAD of an
// explicit versionId is evaluated against that version rather than the latest one,
// including when the latest version is a delete marker.
func TestConditionalReadsOfNamedVersion(t *testing.T) {
client := getS3Client(t)
bucketName := getNewBucketName()
createBucket(t, client, bucketName)
defer deleteBucket(t, client, bucketName)
enableVersioning(t, client, bucketName)
key := "conditional-read-version"
v1 := putObject(t, client, bucketName, key, "content-v1")
require.NotNil(t, v1.ETag)
require.NotNil(t, v1.VersionId)
v2 := putObject(t, client, bucketName, key, "content-v2")
require.NotNil(t, v2.ETag)
require.NotEqual(t, *v1.ETag, *v2.ETag)
t.Run("If-Match matches the named version, not the latest", func(t *testing.T) {
_, err := client.GetObject(context.TODO(), &s3.GetObjectInput{
Bucket: aws.String(bucketName), Key: aws.String(key),
VersionId: v1.VersionId, IfMatch: v1.ETag,
})
require.NoError(t, err)
})
t.Run("If-Match against the latest ETag fails on the named version", func(t *testing.T) {
_, err := client.GetObject(context.TODO(), &s3.GetObjectInput{
Bucket: aws.String(bucketName), Key: aws.String(key),
VersionId: v1.VersionId, IfMatch: v2.ETag,
})
requireAPIErrorCode(t, err, "PreconditionFailed")
})
_, err := client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
Bucket: aws.String(bucketName), Key: aws.String(key),
})
require.NoError(t, err)
t.Run("named version survives a delete marker on the latest", func(t *testing.T) {
_, err := client.HeadObject(context.TODO(), &s3.HeadObjectInput{
Bucket: aws.String(bucketName), Key: aws.String(key),
VersionId: v1.VersionId, IfMatch: v1.ETag,
})
require.NoError(t, err)
})
t.Run("delete marker latest is a missing object", func(t *testing.T) {
_, err := client.GetObject(context.TODO(), &s3.GetObjectInput{
Bucket: aws.String(bucketName), Key: aws.String(key), IfMatch: v1.ETag,
})
requireAPIErrorCode(t, err, "NoSuchKey")
})
}