You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
495 lines
14 KiB
495 lines
14 KiB
package s3lifecycle
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
var now = time.Date(2026, 3, 27, 12, 0, 0, 0, time.UTC)
|
|
|
|
func TestEvaluate_ExpirationDays(t *testing.T) {
|
|
rules := []Rule{{
|
|
ID: "expire-30d", Status: "Enabled",
|
|
ExpirationDays: 30,
|
|
}}
|
|
|
|
t.Run("object_older_than_days_is_expired", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "data/file.txt", IsLatest: true,
|
|
ModTime: now.Add(-31 * 24 * time.Hour),
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionDeleteObject, result.Action)
|
|
assertEqual(t, "expire-30d", result.RuleID)
|
|
})
|
|
|
|
t.Run("object_younger_than_days_is_not_expired", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "data/file.txt", IsLatest: true,
|
|
ModTime: now.Add(-10 * 24 * time.Hour),
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionNone, result.Action)
|
|
})
|
|
|
|
t.Run("non_latest_version_not_affected_by_expiration_days", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "data/file.txt", IsLatest: false,
|
|
ModTime: now.Add(-60 * 24 * time.Hour),
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionNone, result.Action)
|
|
})
|
|
|
|
t.Run("delete_marker_not_affected_by_expiration_days", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "data/file.txt", IsLatest: true, IsDeleteMarker: true,
|
|
ModTime: now.Add(-60 * 24 * time.Hour), NumVersions: 3,
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionNone, result.Action)
|
|
})
|
|
}
|
|
|
|
func TestEvaluate_ExpirationDate(t *testing.T) {
|
|
expirationDate := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
|
|
rules := []Rule{{
|
|
ID: "expire-date", Status: "Enabled",
|
|
ExpirationDate: expirationDate,
|
|
}}
|
|
|
|
t.Run("object_expired_after_date", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "file.txt", IsLatest: true,
|
|
ModTime: now.Add(-60 * 24 * time.Hour),
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionDeleteObject, result.Action)
|
|
})
|
|
|
|
t.Run("object_not_expired_before_date", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "file.txt", IsLatest: true,
|
|
ModTime: now.Add(-1 * time.Hour),
|
|
}
|
|
beforeDate := time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC)
|
|
result := Evaluate(rules, obj, beforeDate)
|
|
assertAction(t, ActionNone, result.Action)
|
|
})
|
|
}
|
|
|
|
func TestEvaluate_ExpiredObjectDeleteMarker(t *testing.T) {
|
|
rules := []Rule{{
|
|
ID: "cleanup-markers", Status: "Enabled",
|
|
ExpiredObjectDeleteMarker: true,
|
|
}}
|
|
|
|
t.Run("sole_delete_marker_is_expired", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "file.txt", IsLatest: true, IsDeleteMarker: true,
|
|
NumVersions: 1,
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionExpireDeleteMarker, result.Action)
|
|
})
|
|
|
|
t.Run("delete_marker_with_other_versions_not_expired", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "file.txt", IsLatest: true, IsDeleteMarker: true,
|
|
NumVersions: 3,
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionNone, result.Action)
|
|
})
|
|
|
|
t.Run("non_latest_delete_marker_not_expired", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "file.txt", IsLatest: false, IsDeleteMarker: true,
|
|
NumVersions: 1,
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionNone, result.Action)
|
|
})
|
|
|
|
t.Run("non_delete_marker_not_affected", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "file.txt", IsLatest: true, IsDeleteMarker: false,
|
|
NumVersions: 1,
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionNone, result.Action)
|
|
})
|
|
}
|
|
|
|
func TestEvaluate_NoncurrentVersionExpiration(t *testing.T) {
|
|
rules := []Rule{{
|
|
ID: "expire-noncurrent", Status: "Enabled",
|
|
NoncurrentVersionExpirationDays: 30,
|
|
}}
|
|
|
|
t.Run("old_noncurrent_version_is_expired", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "file.txt", IsLatest: false,
|
|
SuccessorModTime: now.Add(-45 * 24 * time.Hour),
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionDeleteVersion, result.Action)
|
|
})
|
|
|
|
t.Run("recent_noncurrent_version_is_not_expired", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "file.txt", IsLatest: false,
|
|
SuccessorModTime: now.Add(-10 * 24 * time.Hour),
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionNone, result.Action)
|
|
})
|
|
|
|
t.Run("latest_version_not_affected", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "file.txt", IsLatest: true,
|
|
ModTime: now.Add(-60 * 24 * time.Hour),
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionNone, result.Action)
|
|
})
|
|
}
|
|
|
|
func TestShouldExpireNoncurrentVersion(t *testing.T) {
|
|
rule := Rule{
|
|
ID: "noncurrent-rule", Status: "Enabled",
|
|
NoncurrentVersionExpirationDays: 30,
|
|
NewerNoncurrentVersions: 2,
|
|
}
|
|
|
|
t.Run("old_version_beyond_count_is_expired", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "file.txt", IsLatest: false,
|
|
SuccessorModTime: now.Add(-45 * 24 * time.Hour),
|
|
}
|
|
// noncurrentIndex=2 means this is the 3rd noncurrent version (0-indexed)
|
|
// With NewerNoncurrentVersions=2, indices 0 and 1 are kept.
|
|
if !ShouldExpireNoncurrentVersion(rule, obj, 2, now) {
|
|
t.Error("expected version at index 2 to be expired")
|
|
}
|
|
})
|
|
|
|
t.Run("old_version_within_count_is_kept", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "file.txt", IsLatest: false,
|
|
SuccessorModTime: now.Add(-45 * 24 * time.Hour),
|
|
}
|
|
// noncurrentIndex=1 is within the keep threshold (NewerNoncurrentVersions=2).
|
|
if ShouldExpireNoncurrentVersion(rule, obj, 1, now) {
|
|
t.Error("expected version at index 1 to be kept")
|
|
}
|
|
})
|
|
|
|
t.Run("recent_version_beyond_count_is_kept", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "file.txt", IsLatest: false,
|
|
SuccessorModTime: now.Add(-5 * 24 * time.Hour),
|
|
}
|
|
// Even at index 5 (beyond count), if too young, it's kept.
|
|
if ShouldExpireNoncurrentVersion(rule, obj, 5, now) {
|
|
t.Error("expected recent version to be kept regardless of index")
|
|
}
|
|
})
|
|
|
|
t.Run("disabled_rule_never_expires", func(t *testing.T) {
|
|
disabled := Rule{
|
|
ID: "disabled", Status: "Disabled",
|
|
NoncurrentVersionExpirationDays: 1,
|
|
}
|
|
obj := ObjectInfo{
|
|
Key: "file.txt", IsLatest: false,
|
|
SuccessorModTime: now.Add(-365 * 24 * time.Hour),
|
|
}
|
|
if ShouldExpireNoncurrentVersion(disabled, obj, 10, now) {
|
|
t.Error("disabled rule should never expire")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestEvaluate_PrefixFilter(t *testing.T) {
|
|
rules := []Rule{{
|
|
ID: "logs-only", Status: "Enabled",
|
|
Prefix: "logs/",
|
|
ExpirationDays: 7,
|
|
}}
|
|
|
|
t.Run("matching_prefix", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "logs/app.log", IsLatest: true,
|
|
ModTime: now.Add(-10 * 24 * time.Hour),
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionDeleteObject, result.Action)
|
|
})
|
|
|
|
t.Run("non_matching_prefix", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "data/file.txt", IsLatest: true,
|
|
ModTime: now.Add(-10 * 24 * time.Hour),
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionNone, result.Action)
|
|
})
|
|
}
|
|
|
|
func TestEvaluate_TagFilter(t *testing.T) {
|
|
rules := []Rule{{
|
|
ID: "temp-only", Status: "Enabled",
|
|
ExpirationDays: 1,
|
|
FilterTags: map[string]string{"env": "temp"},
|
|
}}
|
|
|
|
t.Run("matching_tags", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "file.txt", IsLatest: true,
|
|
ModTime: now.Add(-5 * 24 * time.Hour),
|
|
Tags: map[string]string{"env": "temp", "project": "foo"},
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionDeleteObject, result.Action)
|
|
})
|
|
|
|
t.Run("missing_tag", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "file.txt", IsLatest: true,
|
|
ModTime: now.Add(-5 * 24 * time.Hour),
|
|
Tags: map[string]string{"project": "foo"},
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionNone, result.Action)
|
|
})
|
|
|
|
t.Run("wrong_tag_value", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "file.txt", IsLatest: true,
|
|
ModTime: now.Add(-5 * 24 * time.Hour),
|
|
Tags: map[string]string{"env": "prod"},
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionNone, result.Action)
|
|
})
|
|
|
|
t.Run("nil_object_tags", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "file.txt", IsLatest: true,
|
|
ModTime: now.Add(-5 * 24 * time.Hour),
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionNone, result.Action)
|
|
})
|
|
}
|
|
|
|
func TestEvaluate_SizeFilter(t *testing.T) {
|
|
rules := []Rule{{
|
|
ID: "large-files", Status: "Enabled",
|
|
ExpirationDays: 7,
|
|
FilterSizeGreaterThan: 1024 * 1024, // > 1 MB
|
|
FilterSizeLessThan: 100 * 1024 * 1024, // < 100 MB
|
|
}}
|
|
|
|
t.Run("matching_size", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "file.bin", IsLatest: true,
|
|
ModTime: now.Add(-10 * 24 * time.Hour),
|
|
Size: 10 * 1024 * 1024, // 10 MB
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionDeleteObject, result.Action)
|
|
})
|
|
|
|
t.Run("too_small", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "file.bin", IsLatest: true,
|
|
ModTime: now.Add(-10 * 24 * time.Hour),
|
|
Size: 512, // 512 bytes
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionNone, result.Action)
|
|
})
|
|
|
|
t.Run("too_large", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "file.bin", IsLatest: true,
|
|
ModTime: now.Add(-10 * 24 * time.Hour),
|
|
Size: 200 * 1024 * 1024, // 200 MB
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionNone, result.Action)
|
|
})
|
|
}
|
|
|
|
func TestEvaluate_CombinedFilters(t *testing.T) {
|
|
rules := []Rule{{
|
|
ID: "combined", Status: "Enabled",
|
|
Prefix: "logs/",
|
|
ExpirationDays: 7,
|
|
FilterTags: map[string]string{"env": "dev"},
|
|
FilterSizeGreaterThan: 100,
|
|
}}
|
|
|
|
t.Run("all_filters_match", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "logs/app.log", IsLatest: true,
|
|
ModTime: now.Add(-10 * 24 * time.Hour),
|
|
Size: 1024,
|
|
Tags: map[string]string{"env": "dev"},
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionDeleteObject, result.Action)
|
|
})
|
|
|
|
t.Run("prefix_doesnt_match", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "data/app.log", IsLatest: true,
|
|
ModTime: now.Add(-10 * 24 * time.Hour),
|
|
Size: 1024,
|
|
Tags: map[string]string{"env": "dev"},
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionNone, result.Action)
|
|
})
|
|
|
|
t.Run("tag_doesnt_match", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "logs/app.log", IsLatest: true,
|
|
ModTime: now.Add(-10 * 24 * time.Hour),
|
|
Size: 1024,
|
|
Tags: map[string]string{"env": "prod"},
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionNone, result.Action)
|
|
})
|
|
|
|
t.Run("size_doesnt_match", func(t *testing.T) {
|
|
obj := ObjectInfo{
|
|
Key: "logs/app.log", IsLatest: true,
|
|
ModTime: now.Add(-10 * 24 * time.Hour),
|
|
Size: 50, // too small
|
|
Tags: map[string]string{"env": "dev"},
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionNone, result.Action)
|
|
})
|
|
}
|
|
|
|
func TestEvaluate_DisabledRule(t *testing.T) {
|
|
rules := []Rule{{
|
|
ID: "disabled", Status: "Disabled",
|
|
ExpirationDays: 1,
|
|
}}
|
|
obj := ObjectInfo{
|
|
Key: "file.txt", IsLatest: true,
|
|
ModTime: now.Add(-365 * 24 * time.Hour),
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionNone, result.Action)
|
|
}
|
|
|
|
func TestEvaluate_MultipleRules_Priority(t *testing.T) {
|
|
t.Run("delete_marker_takes_priority_over_expiration", func(t *testing.T) {
|
|
rules := []Rule{
|
|
{ID: "expire", Status: "Enabled", ExpirationDays: 1},
|
|
{ID: "marker", Status: "Enabled", ExpiredObjectDeleteMarker: true},
|
|
}
|
|
obj := ObjectInfo{
|
|
Key: "file.txt", IsLatest: true, IsDeleteMarker: true,
|
|
NumVersions: 1, ModTime: now.Add(-10 * 24 * time.Hour),
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionExpireDeleteMarker, result.Action)
|
|
assertEqual(t, "marker", result.RuleID)
|
|
})
|
|
|
|
t.Run("first_matching_expiration_rule_wins", func(t *testing.T) {
|
|
rules := []Rule{
|
|
{ID: "rule1", Status: "Enabled", ExpirationDays: 30, Prefix: "logs/"},
|
|
{ID: "rule2", Status: "Enabled", ExpirationDays: 7},
|
|
}
|
|
obj := ObjectInfo{
|
|
Key: "logs/app.log", IsLatest: true,
|
|
ModTime: now.Add(-31 * 24 * time.Hour),
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionDeleteObject, result.Action)
|
|
assertEqual(t, "rule1", result.RuleID)
|
|
})
|
|
}
|
|
|
|
func TestEvaluate_EmptyPrefix(t *testing.T) {
|
|
rules := []Rule{{
|
|
ID: "all", Status: "Enabled",
|
|
ExpirationDays: 30,
|
|
}}
|
|
obj := ObjectInfo{
|
|
Key: "any/path/file.txt", IsLatest: true,
|
|
ModTime: now.Add(-31 * 24 * time.Hour),
|
|
}
|
|
result := Evaluate(rules, obj, now)
|
|
assertAction(t, ActionDeleteObject, result.Action)
|
|
}
|
|
|
|
func TestEvaluateMPUAbort(t *testing.T) {
|
|
rules := []Rule{{
|
|
ID: "abort-mpu", Status: "Enabled",
|
|
AbortMPUDaysAfterInitiation: 7,
|
|
}}
|
|
|
|
t.Run("old_upload_is_aborted", func(t *testing.T) {
|
|
result := EvaluateMPUAbort(rules, "uploads/file.bin", now.Add(-10*24*time.Hour), now)
|
|
assertAction(t, ActionAbortMultipartUpload, result.Action)
|
|
})
|
|
|
|
t.Run("recent_upload_is_not_aborted", func(t *testing.T) {
|
|
result := EvaluateMPUAbort(rules, "uploads/file.bin", now.Add(-3*24*time.Hour), now)
|
|
assertAction(t, ActionNone, result.Action)
|
|
})
|
|
|
|
t.Run("prefix_scoped_abort", func(t *testing.T) {
|
|
prefixRules := []Rule{{
|
|
ID: "abort-logs", Status: "Enabled",
|
|
Prefix: "logs/",
|
|
AbortMPUDaysAfterInitiation: 1,
|
|
}}
|
|
result := EvaluateMPUAbort(prefixRules, "data/file.bin", now.Add(-5*24*time.Hour), now)
|
|
assertAction(t, ActionNone, result.Action)
|
|
})
|
|
}
|
|
|
|
func TestExpectedExpiryTime(t *testing.T) {
|
|
ref := time.Date(2026, 3, 1, 15, 30, 0, 0, time.UTC)
|
|
|
|
t.Run("30_days", func(t *testing.T) {
|
|
// S3 spec: expires at midnight UTC of day 32 (ref + 31 days, truncated).
|
|
expiry := expectedExpiryTime(ref, 30)
|
|
expected := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
|
|
if !expiry.Equal(expected) {
|
|
t.Errorf("expected %v, got %v", expected, expiry)
|
|
}
|
|
})
|
|
|
|
t.Run("zero_days_returns_ref", func(t *testing.T) {
|
|
expiry := expectedExpiryTime(ref, 0)
|
|
if !expiry.Equal(ref) {
|
|
t.Errorf("expected %v, got %v", ref, expiry)
|
|
}
|
|
})
|
|
}
|
|
|
|
func assertAction(t *testing.T, expected, actual Action) {
|
|
t.Helper()
|
|
if expected != actual {
|
|
t.Errorf("expected action %d, got %d", expected, actual)
|
|
}
|
|
}
|
|
|
|
func assertEqual(t *testing.T, expected, actual string) {
|
|
t.Helper()
|
|
if expected != actual {
|
|
t.Errorf("expected %q, got %q", expected, actual)
|
|
}
|
|
}
|