From 33c35b5c0994db99e7823594fb38f9d679dda481 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Wed, 4 Mar 2026 11:06:22 -0800 Subject: [PATCH] s3api: add conditional header combination tests for GetObjectAttributes Test the RFC 7232 combined conditional header semantics: - If-Match=true + If-Unmodified-Since=false => 200 (If-Unmodified-Since ignored) - If-None-Match=false + If-Modified-Since=true => 304 (If-Modified-Since ignored) - If-None-Match=true + If-Modified-Since=false => 200 (If-Modified-Since ignored) - If-Match=true + If-Unmodified-Since=true => 200 - If-Match=false => 412 regardless Co-Authored-By: Claude Opus 4.6 --- test/s3/normal/get_object_attributes_test.go | 108 +++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/test/s3/normal/get_object_attributes_test.go b/test/s3/normal/get_object_attributes_test.go index f461dbbb8..4740f0854 100644 --- a/test/s3/normal/get_object_attributes_test.go +++ b/test/s3/normal/get_object_attributes_test.go @@ -59,6 +59,9 @@ func TestGetObjectAttributes(t *testing.T) { t.Run("VersionedObject", func(t *testing.T) { testGetObjectAttributesVersioned(t, cluster) }) + t.Run("ConditionalHeaders", func(t *testing.T) { + testGetObjectAttributesConditionalHeaders(t, cluster) + }) } func testGetObjectAttributesBasic(t *testing.T, cluster *TestCluster) { @@ -348,3 +351,108 @@ func testGetObjectAttributesVersioned(t *testing.T, cluster *TestCluster) { 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) + resp, err := http.DefaultClient.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") +}