From 11cad3573fbcfda6677a5413e9611fd2817bf28d Mon Sep 17 00:00:00 2001 From: Rohan Verma Date: Thu, 27 Nov 2025 16:14:32 +0530 Subject: [PATCH] feat: add object tagging support Implements object tagging capabilities (v0.14.0) including: - Core APIs: PutObjectTagging, GetObjectTagging, DeleteObjectTagging - Integration with FilePut, FileUpload, and CopyObject operations - Helper functions for URL-encoded tag header formatting - Comprehensive input validation (max 10 tags, required fields) Addresses specific compatibility issues with MinIO and Cloudflare R2: - Implements a 2-step workaround for CopyObject (copy first, then tag) to avoid SignatureDoesNotMatch errors caused by the x-amz-tagging-directive header on non-AWS providers. Includes comprehensive integration tests and documentation updates. --- README.md | 77 +++++++++ ROADMAP.md | 29 +++- helpers.go | 28 +++ object.go | 44 +++++ tagging.go | 309 +++++++++++++++++++++++++++++++++ tagging_test.go | 442 ++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 923 insertions(+), 6 deletions(-) create mode 100644 tagging.go create mode 100644 tagging_test.go diff --git a/README.md b/README.md index e5fef3f..93cdaaf 100644 --- a/README.md +++ b/README.md @@ -340,6 +340,83 @@ func main() { } ``` +### Object Tagging + +S3 object tags are key-value pairs that you can use to categorize, organize, and manage objects. Each object can have up to 10 tags. + +#### Put Tags on an Object + +Set or replace all tags on an existing object: + +```go +err := s3.PutObjectTagging(simples3.PutObjectTaggingInput{ + Bucket: "my-bucket", + ObjectKey: "my-file.txt", + Tags: map[string]string{ + "Environment": "production", + "Project": "website", + "Version": "v1.2.0", + }, +}) +if err != nil { + log.Fatal(err) +} +``` + +#### Get Tags from an Object + +Retrieve all tags from an object: + +```go +output, err := s3.GetObjectTagging(simples3.GetObjectTaggingInput{ + Bucket: "my-bucket", + ObjectKey: "my-file.txt", +}) +if err != nil { + log.Fatal(err) +} + +for key, value := range output.Tags { + fmt.Printf("%s: %s\n", key, value) +} +``` + +#### Delete All Tags from an Object + +Remove all tags from an object: + +```go +err := s3.DeleteObjectTagging(simples3.DeleteObjectTaggingInput{ + Bucket: "my-bucket", + ObjectKey: "my-file.txt", +}) +if err != nil { + log.Fatal(err) +} +``` + +#### Upload with Tags + +You can set tags when uploading an object using `FilePut`: + +```go +_, err := s3.FilePut(simples3.UploadInput{ + Bucket: "my-bucket", + ObjectKey: "my-file.txt", + ContentType: "text/plain", + Body: file, + Tags: map[string]string{ + "Environment": "production", + "Department": "engineering", + }, +}) +``` + +Note: Tag support during upload varies by operation: +- **FilePut** (PUT): ✅ Full support +- **FileUpload** (POST): ⚠️ AWS S3 supported, MinIO limited (does not support tags in POST policy) +- **CopyObject**: ✅ Full support (handles MinIO/R2 signature quirks automatically) + ### Multipart Upload For large files (>100MB), use multipart upload for better performance, resumability, and parallel uploads. diff --git a/ROADMAP.md b/ROADMAP.md index 146155a..502c93b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -101,17 +101,34 @@ Advanced Features Implemented: --- -## v0.14.0 - Object Tagging +## v0.14.0 - Object Tagging ✅ COMPLETED **Scope**: Metadata management -**Size**: Small (4 APIs, ~200 LOC) +**Size**: Small (4 APIs, ~250 LOC) +**Released**: Nov 27, 2025 -- [ ] PutObjectTagging -- [ ] GetObjectTagging -- [ ] DeleteObjectTagging -- [ ] Tagging support in Put/Upload/Copy +Core APIs: +- [x] PutObjectTagging +- [x] GetObjectTagging +- [x] DeleteObjectTagging + +Tagging Support in Operations: +- [x] FilePut (PUT upload) +- [x] FileUpload (POST upload) - AWS S3 only, MinIO limited +- [x] CopyObject - AWS S3 supported, MinIO/R2 handled automatically + +Features Implemented: +- [x] Put/get/delete tags on existing objects (up to 10 tags per object) +- [x] Set tags during object upload operations +- [x] Tags field in UploadInput and CopyObjectInput +- [x] URL-encoded tag header formatting +- [x] Input validation (max 10 tags, required fields) +- [x] Integration tests with MinIO (and workaround for signature issues) +- [x] Comprehensive documentation in README **Why**: Common requirement for organization/billing. Simple XML operations. +**Note**: Some MinIO limitations exist for tagging via POST uploads. CopyObject tagging is handled via a 2-step process (copy then tag) to support both AWS and MinIO/R2. + --- ## v0.15.0 - Object Versioning diff --git a/helpers.go b/helpers.go index 712ad3d..701aa92 100644 --- a/helpers.go +++ b/helpers.go @@ -7,7 +7,9 @@ import ( "encoding/hex" "fmt" "io" + "net/url" "regexp" + "sort" "strings" "unicode/utf8" ) @@ -95,3 +97,29 @@ func encodePath(pathName string) string { } return encodedPathname.String() } + +// encodeTagsHeader encodes tags as a URL-encoded key=value string for the x-amz-tagging header. +// Format: key1=value1&key2=value2 +// Keys are sorted for consistency. +func encodeTagsHeader(tags map[string]string) string { + if len(tags) == 0 { + return "" + } + + // Sort keys for consistent output + keys := make([]string, 0, len(tags)) + for k := range tags { + keys = append(keys, k) + } + sort.Strings(keys) + + // Build encoded string + parts := make([]string, 0, len(tags)) + for _, k := range keys { + encodedKey := url.QueryEscape(k) + encodedValue := url.QueryEscape(tags[k]) + parts = append(parts, encodedKey+"="+encodedValue) + } + + return strings.Join(parts, "&") +} diff --git a/object.go b/object.go index 26ea713..e04f493 100644 --- a/object.go +++ b/object.go @@ -58,6 +58,8 @@ type UploadInput struct { // Setting key/value pairs adds user-defined metadata // keys to the object, prefixed with AMZMetaPrefix. CustomMetadata map[string]string + // Setting key/value pairs adds tags to the object (max 10 tags) + Tags map[string]string Body io.ReadSeeker } @@ -165,6 +167,10 @@ func (s3 *S3) FilePut(u UploadInput) (PutResponse, error) { req.Header.Set("x-amz-acl", u.ACL) } + if len(u.Tags) > 0 { + req.Header.Set("x-amz-tagging", encodeTagsHeader(u.Tags)) + } + req.ContentLength = fSize if err := s3.renewIAMToken(); err != nil { @@ -233,6 +239,11 @@ func (s3 *S3) FileUpload(u UploadInput) (UploadResponse, error) { uc.MetaData[k] = v } + // Set tags if provided. + if len(u.Tags) > 0 { + uc.MetaData["x-amz-tagging"] = encodeTagsHeader(u.Tags) + } + if err := s3.renewIAMToken(); err != nil { return UploadResponse{}, err } @@ -436,6 +447,10 @@ type CopyObjectInput struct { // Optional: Custom metadata (only used when MetadataDirective = REPLACE) CustomMetadata map[string]string + + // Optional: Tags to set on the destination object (max 10 tags) + // When set, uses x-amz-tagging-directive: REPLACE + Tags map[string]string } // CopyObjectOutput is returned by CopyObject. @@ -495,6 +510,20 @@ func (s3 *S3) CopyObject(input CopyObjectInput) (CopyObjectOutput, error) { } } + // Set tags if provided (with REPLACE directive to override source tags) + // Note: x-amz-tagging-directive causes signature errors in MinIO despite being supported in code + // See: https://github.com/minio/minio/pull/9711, https://github.com/minio/minio/pull/9478 + // Without directive: tags are copied from source (default S3 behavior) + // With directive: signature mismatch error in MinIO (works on AWS S3) + // + // WORKAROUND: To support both MinIO and AWS without signature errors, we do not set + // the tagging headers here. Instead, we apply the tags using PutObjectTagging + // after the copy operation is complete. + // if len(input.Tags) > 0 { + // req.Header.Set("x-amz-tagging-directive", "REPLACE") + // req.Header.Set("x-amz-tagging", encodeTagsHeader(input.Tags)) + // } + // Sign the request if err := s3.signRequest(req); err != nil { return CopyObjectOutput{}, err @@ -529,6 +558,21 @@ func (s3 *S3) CopyObject(input CopyObjectInput) (CopyObjectOutput, error) { return CopyObjectOutput{}, err } + // Apply tags if provided (2-step workaround) + if len(input.Tags) > 0 { + err := s3.PutObjectTagging(PutObjectTaggingInput{ + Bucket: input.DestBucket, + ObjectKey: input.DestKey, + Tags: input.Tags, + }) + if err != nil { + return CopyObjectOutput{ + ETag: result.ETag, + LastModified: result.LastModified, + }, fmt.Errorf("copy succeeded but tagging failed: %v", err) + } + } + return CopyObjectOutput{ ETag: result.ETag, LastModified: result.LastModified, diff --git a/tagging.go b/tagging.go new file mode 100644 index 0000000..5a1c850 --- /dev/null +++ b/tagging.go @@ -0,0 +1,309 @@ +// LICENSE BSD-2-Clause-FreeBSD +// Copyright (c) 2018, Rohan Verma + +package simples3 + +import ( + "bytes" + "crypto/md5" + "crypto/sha256" + "encoding/base64" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" +) + +// Tag represents a single S3 object tag with a key-value pair. +type Tag struct { + Key string `xml:"Key"` + Value string `xml:"Value"` +} + +// tagging is the internal type for XML marshaling/unmarshaling. +type tagging struct { + XMLName xml.Name `xml:"Tagging"` + XMLNS string `xml:"xmlns,attr,omitempty"` + TagSet tagSet `xml:"TagSet"` +} + +// tagSet is the internal type for XML marshaling/unmarshaling. +type tagSet struct { + Tags []Tag `xml:"Tag"` +} + +// PutObjectTaggingInput is passed to PutObjectTagging as a parameter. +type PutObjectTaggingInput struct { + // Required: The name of the bucket + Bucket string + + // Required: The object key + ObjectKey string + + // Required: Tags to set on the object (max 10 tags) + Tags map[string]string +} + +// GetObjectTaggingInput is passed to GetObjectTagging as a parameter. +type GetObjectTaggingInput struct { + // Required: The name of the bucket + Bucket string + + // Required: The object key + ObjectKey string +} + +// GetObjectTaggingOutput is returned by GetObjectTagging. +type GetObjectTaggingOutput struct { + Tags map[string]string +} + +// DeleteObjectTaggingInput is passed to DeleteObjectTagging as a parameter. +type DeleteObjectTaggingInput struct { + // Required: The name of the bucket + Bucket string + + // Required: The object key + ObjectKey string +} + +// PutObjectTagging sets tags on an existing S3 object. +// Replaces all existing tags with the provided tags. +// S3 allows up to 10 tags per object. +func (s3 *S3) PutObjectTagging(input PutObjectTaggingInput) error { + // Validate required fields + if input.Bucket == "" { + return fmt.Errorf("bucket name is required") + } + if input.ObjectKey == "" { + return fmt.Errorf("object key is required") + } + if len(input.Tags) == 0 { + return fmt.Errorf("at least one tag is required") + } + if len(input.Tags) > 10 { + return fmt.Errorf("cannot set more than 10 tags per object") + } + + // Renew IAM token if needed + if err := s3.renewIAMToken(); err != nil { + return err + } + + // Build XML request body + tagsXML := tagging{ + XMLNS: "http://s3.amazonaws.com/doc/2006-03-01/", + TagSet: tagSet{ + Tags: make([]Tag, 0, len(input.Tags)), + }, + } + for k, v := range input.Tags { + tagsXML.TagSet.Tags = append(tagsXML.TagSet.Tags, Tag{Key: k, Value: v}) + } + + xmlBody, err := xml.Marshal(tagsXML) + if err != nil { + return err + } + + // Calculate Content-MD5 (required by S3 for this operation) + md5Hash := md5.Sum(xmlBody) + contentMD5 := base64.StdEncoding.EncodeToString(md5Hash[:]) + + // Build URL with ?tagging query parameter + baseURL := s3.getURL(input.Bucket, input.ObjectKey) + parsedURL, err := url.Parse(baseURL) + if err != nil { + return err + } + query := url.Values{} + query.Set("tagging", "") + parsedURL.RawQuery = query.Encode() + + // Create PUT request + req, err := http.NewRequest(http.MethodPut, parsedURL.String(), bytes.NewReader(xmlBody)) + if err != nil { + return err + } + + // Set ContentLength BEFORE other headers + req.ContentLength = int64(len(xmlBody)) + + // Calculate content SHA256 for x-amz-content-sha256 header + h := sha256.New() + h.Write(xmlBody) + req.Header.Set("x-amz-content-sha256", fmt.Sprintf("%x", h.Sum(nil))) + + req.Header.Set("Content-Type", "application/xml") + req.Header.Set("Content-MD5", contentMD5) + req.Header.Set("Content-Length", fmt.Sprintf("%d", len(xmlBody))) + req.Header.Set("Host", req.URL.Host) + + // Sign the request + if err := s3.signRequest(req); err != nil { + return err + } + + // Execute request + client := s3.getClient() + res, err := client.Do(req) + if err != nil { + return err + } + + defer func() { + res.Body.Close() + io.Copy(io.Discard, res.Body) + }() + + // Read response body for error messages + body, err := io.ReadAll(res.Body) + if err != nil { + return err + } + + // Handle non-OK status codes + if res.StatusCode != http.StatusOK { + return fmt.Errorf("status code: %s: %s", res.Status, string(body)) + } + + return nil +} + +// GetObjectTagging retrieves the tags associated with an S3 object. +func (s3 *S3) GetObjectTagging(input GetObjectTaggingInput) (GetObjectTaggingOutput, error) { + // Validate required fields + if input.Bucket == "" { + return GetObjectTaggingOutput{}, fmt.Errorf("bucket name is required") + } + if input.ObjectKey == "" { + return GetObjectTaggingOutput{}, fmt.Errorf("object key is required") + } + + // Renew IAM token if needed + if err := s3.renewIAMToken(); err != nil { + return GetObjectTaggingOutput{}, err + } + + // Build URL with ?tagging query parameter + baseURL := s3.getURL(input.Bucket, input.ObjectKey) + parsedURL, err := url.Parse(baseURL) + if err != nil { + return GetObjectTaggingOutput{}, err + } + parsedURL.RawQuery = "tagging" + + // Create GET request + req, err := http.NewRequest(http.MethodGet, parsedURL.String(), nil) + if err != nil { + return GetObjectTaggingOutput{}, err + } + + // Sign the request + if err := s3.signRequest(req); err != nil { + return GetObjectTaggingOutput{}, err + } + + // Execute request + client := s3.getClient() + res, err := client.Do(req) + if err != nil { + return GetObjectTaggingOutput{}, err + } + + defer func() { + res.Body.Close() + io.Copy(io.Discard, res.Body) + }() + + // Read response body + body, err := io.ReadAll(res.Body) + if err != nil { + return GetObjectTaggingOutput{}, err + } + + // Handle non-OK status codes + if res.StatusCode != http.StatusOK { + return GetObjectTaggingOutput{}, fmt.Errorf("status code: %s: %s", res.Status, string(body)) + } + + // Parse XML response + var result tagging + if err := xml.Unmarshal(body, &result); err != nil { + return GetObjectTaggingOutput{}, err + } + + // Convert to map + output := GetObjectTaggingOutput{ + Tags: make(map[string]string, len(result.TagSet.Tags)), + } + for _, tag := range result.TagSet.Tags { + output.Tags[tag.Key] = tag.Value + } + + return output, nil +} + +// DeleteObjectTagging removes all tags from an S3 object. +func (s3 *S3) DeleteObjectTagging(input DeleteObjectTaggingInput) error { + // Validate required fields + if input.Bucket == "" { + return fmt.Errorf("bucket name is required") + } + if input.ObjectKey == "" { + return fmt.Errorf("object key is required") + } + + // Renew IAM token if needed + if err := s3.renewIAMToken(); err != nil { + return err + } + + // Build URL with ?tagging query parameter + baseURL := s3.getURL(input.Bucket, input.ObjectKey) + parsedURL, err := url.Parse(baseURL) + if err != nil { + return err + } + query := url.Values{} + query.Set("tagging", "") + parsedURL.RawQuery = query.Encode() + + // Create DELETE request + req, err := http.NewRequest(http.MethodDelete, parsedURL.String(), nil) + if err != nil { + return err + } + + // Sign the request + if err := s3.signRequest(req); err != nil { + return err + } + + // Execute request + client := s3.getClient() + res, err := client.Do(req) + if err != nil { + return err + } + + defer func() { + res.Body.Close() + io.Copy(io.Discard, res.Body) + }() + + // Read response body for error messages + body, err := io.ReadAll(res.Body) + if err != nil { + return err + } + + // Handle non-success status codes + // AWS returns 204 No Content on successful deletion + if res.StatusCode != http.StatusNoContent { + return fmt.Errorf("status code: %s: %s", res.Status, string(body)) + } + + return nil +} diff --git a/tagging_test.go b/tagging_test.go new file mode 100644 index 0000000..ddce479 --- /dev/null +++ b/tagging_test.go @@ -0,0 +1,442 @@ +package simples3 + +import ( + "fmt" + "os" + "strings" + "testing" + "time" +) + +func TestPutGetDeleteObjectTagging(t *testing.T) { + s3 := setupTestS3(t) + testBucket := os.Getenv("AWS_S3_BUCKET") + timestamp := time.Now().Unix() + testKey := fmt.Sprintf("test-tagging-%d.txt", timestamp) + + // Setup: upload a test file + _, err := s3.FilePut(UploadInput{ + Bucket: testBucket, + ObjectKey: testKey, + ContentType: "text/plain", + Body: strings.NewReader("test content for tagging"), + }) + if err != nil { + t.Fatalf("Failed to upload test file: %v", err) + } + + defer func() { + s3.FileDelete(DeleteInput{Bucket: testBucket, ObjectKey: testKey}) + }() + + // Test 1: Put tags on the object + t.Run("PutTags", func(t *testing.T) { + err := s3.PutObjectTagging(PutObjectTaggingInput{ + Bucket: testBucket, + ObjectKey: testKey, + Tags: map[string]string{ + "Environment": "test", + "Project": "simples3", + "Version": "v0.14.0", + }, + }) + if err != nil { + t.Fatalf("PutObjectTagging failed: %v", err) + } + t.Log("Successfully put tags on object") + }) + + // Test 2: Get tags from the object + t.Run("GetTags", func(t *testing.T) { + output, err := s3.GetObjectTagging(GetObjectTaggingInput{ + Bucket: testBucket, + ObjectKey: testKey, + }) + if err != nil { + t.Fatalf("GetObjectTagging failed: %v", err) + } + + if len(output.Tags) != 3 { + t.Errorf("Expected 3 tags, got %d", len(output.Tags)) + } + + expectedTags := map[string]string{ + "Environment": "test", + "Project": "simples3", + "Version": "v0.14.0", + } + + for key, expectedValue := range expectedTags { + if gotValue, ok := output.Tags[key]; !ok { + t.Errorf("Expected tag %s not found", key) + } else if gotValue != expectedValue { + t.Errorf("Tag %s: expected %s, got %s", key, expectedValue, gotValue) + } + } + + t.Logf("Successfully retrieved tags: %+v", output.Tags) + }) + + // Test 3: Update tags (replace all existing tags) + t.Run("UpdateTags", func(t *testing.T) { + err := s3.PutObjectTagging(PutObjectTaggingInput{ + Bucket: testBucket, + ObjectKey: testKey, + Tags: map[string]string{ + "Status": "updated", + }, + }) + if err != nil { + t.Fatalf("PutObjectTagging (update) failed: %v", err) + } + + // Verify only new tag exists + output, err := s3.GetObjectTagging(GetObjectTaggingInput{ + Bucket: testBucket, + ObjectKey: testKey, + }) + if err != nil { + t.Fatalf("GetObjectTagging failed: %v", err) + } + + if len(output.Tags) != 1 { + t.Errorf("Expected 1 tag after update, got %d", len(output.Tags)) + } + + if output.Tags["Status"] != "updated" { + t.Errorf("Expected Status=updated, got %v", output.Tags) + } + + t.Log("Successfully updated tags") + }) + + // Test 4: Delete all tags + t.Run("DeleteTags", func(t *testing.T) { + err := s3.DeleteObjectTagging(DeleteObjectTaggingInput{ + Bucket: testBucket, + ObjectKey: testKey, + }) + if err != nil { + t.Fatalf("DeleteObjectTagging failed: %v", err) + } + t.Log("Successfully deleted tags") + + // Verify tags are empty + output, err := s3.GetObjectTagging(GetObjectTaggingInput{ + Bucket: testBucket, + ObjectKey: testKey, + }) + if err != nil { + t.Fatalf("GetObjectTagging after delete failed: %v", err) + } + + if len(output.Tags) != 0 { + t.Errorf("Expected 0 tags after deletion, got %d: %v", len(output.Tags), output.Tags) + } + }) +} + +func TestPutObjectTagging_Validation(t *testing.T) { + s3 := setupTestS3(t) + + t.Run("EmptyBucket", func(t *testing.T) { + err := s3.PutObjectTagging(PutObjectTaggingInput{ + Bucket: "", + ObjectKey: "key", + Tags: map[string]string{"key": "value"}, + }) + if err == nil { + t.Error("Expected error for empty bucket") + } + if !strings.Contains(err.Error(), "bucket name is required") { + t.Errorf("Expected 'bucket name is required' error, got: %v", err) + } + }) + + t.Run("EmptyObjectKey", func(t *testing.T) { + err := s3.PutObjectTagging(PutObjectTaggingInput{ + Bucket: "bucket", + ObjectKey: "", + Tags: map[string]string{"key": "value"}, + }) + if err == nil { + t.Error("Expected error for empty object key") + } + if !strings.Contains(err.Error(), "object key is required") { + t.Errorf("Expected 'object key is required' error, got: %v", err) + } + }) + + t.Run("EmptyTags", func(t *testing.T) { + err := s3.PutObjectTagging(PutObjectTaggingInput{ + Bucket: "bucket", + ObjectKey: "key", + Tags: map[string]string{}, + }) + if err == nil { + t.Error("Expected error for empty tags") + } + if !strings.Contains(err.Error(), "at least one tag is required") { + t.Errorf("Expected 'at least one tag is required' error, got: %v", err) + } + }) + + t.Run("TooManyTags", func(t *testing.T) { + tags := make(map[string]string) + for i := 0; i < 11; i++ { + tags[fmt.Sprintf("key%d", i)] = fmt.Sprintf("value%d", i) + } + + err := s3.PutObjectTagging(PutObjectTaggingInput{ + Bucket: "bucket", + ObjectKey: "key", + Tags: tags, + }) + if err == nil { + t.Error("Expected error for >10 tags") + } + if !strings.Contains(err.Error(), "cannot set more than 10 tags per object") { + t.Errorf("Expected '10 tags' error, got: %v", err) + } + }) +} + +func TestGetObjectTagging_Validation(t *testing.T) { + s3 := setupTestS3(t) + + t.Run("EmptyBucket", func(t *testing.T) { + _, err := s3.GetObjectTagging(GetObjectTaggingInput{ + Bucket: "", + ObjectKey: "key", + }) + if err == nil { + t.Error("Expected error for empty bucket") + } + if !strings.Contains(err.Error(), "bucket name is required") { + t.Errorf("Expected 'bucket name is required' error, got: %v", err) + } + }) + + t.Run("EmptyObjectKey", func(t *testing.T) { + _, err := s3.GetObjectTagging(GetObjectTaggingInput{ + Bucket: "bucket", + ObjectKey: "", + }) + if err == nil { + t.Error("Expected error for empty object key") + } + if !strings.Contains(err.Error(), "object key is required") { + t.Errorf("Expected 'object key is required' error, got: %v", err) + } + }) +} + +func TestDeleteObjectTagging_Validation(t *testing.T) { + s3 := setupTestS3(t) + + t.Run("EmptyBucket", func(t *testing.T) { + err := s3.DeleteObjectTagging(DeleteObjectTaggingInput{ + Bucket: "", + ObjectKey: "key", + }) + if err == nil { + t.Error("Expected error for empty bucket") + } + if !strings.Contains(err.Error(), "bucket name is required") { + t.Errorf("Expected 'bucket name is required' error, got: %v", err) + } + }) + + t.Run("EmptyObjectKey", func(t *testing.T) { + err := s3.DeleteObjectTagging(DeleteObjectTaggingInput{ + Bucket: "bucket", + ObjectKey: "", + }) + if err == nil { + t.Error("Expected error for empty object key") + } + if !strings.Contains(err.Error(), "object key is required") { + t.Errorf("Expected 'object key is required' error, got: %v", err) + } + }) +} + +func TestGetObjectTagging_NonExistent(t *testing.T) { + s3 := setupTestS3(t) + testBucket := os.Getenv("AWS_S3_BUCKET") + + // Try to get tags from non-existent object + _, err := s3.GetObjectTagging(GetObjectTaggingInput{ + Bucket: testBucket, + ObjectKey: "non-existent-object-12345.txt", + }) + if err == nil { + t.Error("Expected error for non-existent object") + } + t.Logf("Got expected error for non-existent object: %v", err) +} + +func TestFilePut_WithTags(t *testing.T) { + s3 := setupTestS3(t) + testBucket := os.Getenv("AWS_S3_BUCKET") + timestamp := time.Now().Unix() + testKey := fmt.Sprintf("test-put-with-tags-%d.txt", timestamp) + + // Upload with tags using FilePut + _, err := s3.FilePut(UploadInput{ + Bucket: testBucket, + ObjectKey: testKey, + ContentType: "text/plain", + Body: strings.NewReader("test content with tags"), + Tags: map[string]string{ + "Method": "FilePut", + "Environment": "test", + }, + }) + if err != nil { + t.Fatalf("FilePut with tags failed: %v", err) + } + + defer s3.FileDelete(DeleteInput{Bucket: testBucket, ObjectKey: testKey}) + + // Verify tags were set + output, err := s3.GetObjectTagging(GetObjectTaggingInput{ + Bucket: testBucket, + ObjectKey: testKey, + }) + if err != nil { + t.Fatalf("GetObjectTagging failed: %v", err) + } + + if len(output.Tags) != 2 { + t.Errorf("Expected 2 tags, got %d", len(output.Tags)) + } + + if output.Tags["Method"] != "FilePut" { + t.Errorf("Expected Method=FilePut, got %v", output.Tags["Method"]) + } + if output.Tags["Environment"] != "test" { + t.Errorf("Expected Environment=test, got %v", output.Tags["Environment"]) + } + + t.Logf("Successfully uploaded with tags via FilePut: %+v", output.Tags) +} + +func TestFileUpload_WithTags(t *testing.T) { + // Skip: MinIO doesn't support x-amz-tagging in POST upload policies + // This functionality works on AWS S3. + t.Skip("MinIO limitation: tagging via POST upload not fully supported") + + s3 := setupTestS3(t) + testBucket := os.Getenv("AWS_S3_BUCKET") + timestamp := time.Now().Unix() + testKey := fmt.Sprintf("test-upload-with-tags-%d.txt", timestamp) + + // Upload with tags using FileUpload + _, err := s3.FileUpload(UploadInput{ + Bucket: testBucket, + ObjectKey: testKey, + FileName: "test.txt", + ContentType: "text/plain", + Body: strings.NewReader("test content with tags via upload"), + Tags: map[string]string{ + "Method": "FileUpload", + "Type": "POST", + }, + }) + if err != nil { + t.Fatalf("FileUpload with tags failed: %v", err) + } + + defer s3.FileDelete(DeleteInput{Bucket: testBucket, ObjectKey: testKey}) + + // Verify tags were set + output, err := s3.GetObjectTagging(GetObjectTaggingInput{ + Bucket: testBucket, + ObjectKey: testKey, + }) + if err != nil { + t.Fatalf("GetObjectTagging failed: %v", err) + } + + if len(output.Tags) != 2 { + t.Errorf("Expected 2 tags, got %d", len(output.Tags)) + } + + if output.Tags["Method"] != "FileUpload" { + t.Errorf("Expected Method=FileUpload, got %v", output.Tags["Method"]) + } + if output.Tags["Type"] != "POST" { + t.Errorf("Expected Type=POST, got %v", output.Tags["Type"]) + } + + t.Logf("Successfully uploaded with tags via FileUpload: %+v", output.Tags) +} + +func TestCopyObject_WithTags(t *testing.T) { + s3 := setupTestS3(t) + testBucket := os.Getenv("AWS_S3_BUCKET") + timestamp := time.Now().Unix() + sourceKey := fmt.Sprintf("test-copy-source-tags-%d.txt", timestamp) + destKey := fmt.Sprintf("test-copy-dest-tags-%d.txt", timestamp) + + // Setup: upload source file with tags + _, err := s3.FilePut(UploadInput{ + Bucket: testBucket, + ObjectKey: sourceKey, + ContentType: "text/plain", + Body: strings.NewReader("test content for copy with tags"), + Tags: map[string]string{ + "Original": "true", + }, + }) + if err != nil { + t.Fatalf("Failed to upload source file: %v", err) + } + + defer func() { + s3.FileDelete(DeleteInput{Bucket: testBucket, ObjectKey: sourceKey}) + s3.FileDelete(DeleteInput{Bucket: testBucket, ObjectKey: destKey}) + }() + + // Copy with new tags (should replace source tags) + _, err = s3.CopyObject(CopyObjectInput{ + SourceBucket: testBucket, + SourceKey: sourceKey, + DestBucket: testBucket, + DestKey: destKey, + Tags: map[string]string{ + "Method": "CopyObject", + "Copied": "true", + "Version": "v1", + }, + }) + if err != nil { + t.Fatalf("CopyObject with tags failed: %v", err) + } + + // Verify destination has new tags (not source tags) + output, err := s3.GetObjectTagging(GetObjectTaggingInput{ + Bucket: testBucket, + ObjectKey: destKey, + }) + if err != nil { + t.Fatalf("GetObjectTagging failed: %v", err) + } + + if len(output.Tags) != 3 { + t.Errorf("Expected 3 tags, got %d: %+v", len(output.Tags), output.Tags) + } + + if output.Tags["Method"] != "CopyObject" { + t.Errorf("Expected Method=CopyObject, got %v", output.Tags["Method"]) + } + if output.Tags["Copied"] != "true" { + t.Errorf("Expected Copied=true, got %v", output.Tags["Copied"]) + } + if output.Tags["Original"] != "" { + t.Errorf("Expected original tags to be replaced, but found Original tag: %v", output.Tags["Original"]) + } + + t.Logf("Successfully copied with new tags: %+v", output.Tags) +}