// Object Lock + lifecycle integration scenario. package lifecycle import ( "context" "strings" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/stretchr/testify/require" ) // TestLifecycleSkipsObjectLockedObjects: an object under Object Lock // retention must NOT be expired by the lifecycle worker. The handler's // enforceObjectLockProtections check (s3api_internal_lifecycle.go:47) // returns an error which the dispatcher classifies as // SKIPPED_OBJECT_LOCK. Pinning the integration end-to-end so a // regression in either the lock state lookup or the dispatcher's // outcome handling is caught. // // Companion positive control: a second object in the same bucket // without retention is expired by the same sweep — proves the rule // is firing AND that the lock check is selective, not a blanket skip. func TestLifecycleSkipsObjectLockedObjects(t *testing.T) { c := s3Client(t) fc, fcClose := filerClient(t) defer fcClose() bucket := uniqueBucket("objlock") // Object Lock requires the bucket to be created with the flag set; // it can't be enabled retroactively on an existing bucket. Use the // raw API rather than mustCreateBucket here so we can pass the // ObjectLockEnabledForBucket option. _, err := c.CreateBucket(context.Background(), &s3.CreateBucketInput{ Bucket: aws.String(bucket), ObjectLockEnabledForBucket: aws.Bool(true), }) require.NoError(t, err) t.Cleanup(func() { // Best-effort cleanup mirrors mustCreateBucket; locked objects // may resist deletion but the test process continues regardless. c.DeleteBucketLifecycle(context.Background(), &s3.DeleteBucketLifecycleInput{Bucket: aws.String(bucket)}) listOut, _ := c.ListObjectVersions(context.Background(), &s3.ListObjectVersionsInput{Bucket: aws.String(bucket)}) if listOut != nil { for _, v := range listOut.Versions { // Bypass governance retention to free the bucket for delete. c.DeleteObject(context.Background(), &s3.DeleteObjectInput{ Bucket: aws.String(bucket), Key: v.Key, VersionId: v.VersionId, BypassGovernanceRetention: aws.Bool(true), }) } for _, m := range listOut.DeleteMarkers { c.DeleteObject(context.Background(), &s3.DeleteObjectInput{ Bucket: aws.String(bucket), Key: m.Key, VersionId: m.VersionId, }) } } c.DeleteBucket(context.Background(), &s3.DeleteBucketInput{Bucket: aws.String(bucket)}) }) putExpirationLifecycle(t, c, bucket, "lock/", 1) const lockedKey = "lock/protected.txt" const freeKey = "lock/free.txt" // Locked object: PUT with GOVERNANCE retention until 1h from now. lockedPut, err := c.PutObject(context.Background(), &s3.PutObjectInput{ Bucket: aws.String(bucket), Key: aws.String(lockedKey), Body: strings.NewReader("locked"), ObjectLockMode: types.ObjectLockModeGovernance, ObjectLockRetainUntilDate: aws.Time(time.Now().Add(time.Hour)), }) require.NoError(t, err, "PUT with retention must succeed on a lock-enabled bucket") require.NotEmpty(t, aws.ToString(lockedPut.VersionId)) // Free object: PUT without retention. Object Lock requires // versioning, so the bucket is implicitly versioned and every PUT // produces a versionId. Capture both for the version-aware // backdate path. freePut, err := c.PutObject(context.Background(), &s3.PutObjectInput{ Bucket: aws.String(bucket), Key: aws.String(freeKey), Body: strings.NewReader("free"), }) require.NoError(t, err) freeVersionID := aws.ToString(freePut.VersionId) require.NotEmpty(t, freeVersionID) // Backdate both so they would otherwise both expire under the // 1-day rule. The lock check is what distinguishes them. // Versioning-enabled buckets store entries under .versions/v_, // not at the bare key path, so use backdateVersionedMtime. backdateVersionedMtime(t, fc, bucket, lockedKey, aws.ToString(lockedPut.VersionId), 30) backdateVersionedMtime(t, fc, bucket, freeKey, freeVersionID, 30) out := runLifecycleShard(t) t.Logf("shell output:\n%s", out) // Free object expires (positive control). require.Eventuallyf(t, func() bool { _, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{ Bucket: aws.String(bucket), Key: aws.String(freeKey), }) return isS3NotFound(err) }, 30*time.Second, 500*time.Millisecond, "unlocked object must expire (control)") // Locked object survives — Object Lock retention overrides the // lifecycle rule. HEAD without versionId returns the still-current // object data, not a 404. _, err = c.HeadObject(context.Background(), &s3.HeadObjectInput{ Bucket: aws.String(bucket), Key: aws.String(lockedKey), }) require.NoError(t, err, "object under GOVERNANCE retention must survive the sweep") }