package s3api import ( "strings" "testing" ) // lcXML builds a minimal lifecycle config XML with one Enabled // Expiration.Days rule. prefix may be "" for a whole-bucket rule. func lcXML(id, prefix string, days int) []byte { var b strings.Builder b.WriteString("") if id != "" { b.WriteString("" + id + "") } b.WriteString("Enabled") if prefix != "" { b.WriteString("" + prefix + "") } b.WriteString("") // days is small in tests; fmt-free int->string b.WriteString(itoa(days)) b.WriteString("") return []byte(b.String()) } func itoa(n int) string { if n == 0 { return "0" } var buf [12]byte i := len(buf) neg := n < 0 if neg { n = -n } for n > 0 { i-- buf[i] = byte('0' + n%10) n /= 10 } if neg { i-- buf[i] = '-' } return string(buf[i:]) } func TestFastpathWarn_Lengthened(t *testing.T) { old := lcXML("r1", "logs/", 7) new := lcXML("r1", "logs/", 30) got := fastpathConfigChangeLeavesStampedObjects(old, new) if got == "" { t.Fatalf("lengthen 7->30 must warn") } if !strings.Contains(got, "lengthened") || !strings.Contains(got, "7 -> 30") { t.Fatalf("unexpected reason: %q", got) } } func TestFastpathWarn_Shortened_NoWarn(t *testing.T) { old := lcXML("r1", "logs/", 30) new := lcXML("r1", "logs/", 7) if got := fastpathConfigChangeLeavesStampedObjects(old, new); got != "" { t.Fatalf("shorten 30->7 must not warn, got %q", got) } } func TestFastpathWarn_Removed(t *testing.T) { old := lcXML("r1", "logs/", 7) // new config has a different rule; r1 is gone new := lcXML("r2", "data/", 30) got := fastpathConfigChangeLeavesStampedObjects(old, new) if got == "" { t.Fatalf("removing an eligible rule must warn") } if !strings.Contains(got, "removed or disabled") || !strings.Contains(got, "r1") { t.Fatalf("unexpected reason: %q", got) } } func TestFastpathWarn_Delete(t *testing.T) { old := lcXML("r1", "logs/", 7) got := fastpathConfigChangeLeavesStampedObjects(old, nil) if got == "" { t.Fatalf("delete with an eligible rule must warn") } if !strings.Contains(got, "removed or disabled") { t.Fatalf("unexpected reason: %q", got) } } func TestFastpathWarn_Unchanged_NoWarn(t *testing.T) { old := lcXML("r1", "logs/", 7) new := lcXML("r1", "logs/", 7) if got := fastpathConfigChangeLeavesStampedObjects(old, new); got != "" { t.Fatalf("identical config must not warn, got %q", got) } } func TestFastpathWarn_NoOldRules_NoWarn(t *testing.T) { // fastpath off path: old had no eligible rules -> no warning even on delete if got := fastpathConfigChangeLeavesStampedObjects(nil, nil); got != "" { t.Fatalf("no old rules must not warn, got %q", got) } new := lcXML("r1", "logs/", 7) if got := fastpathConfigChangeLeavesStampedObjects(nil, new); got != "" { t.Fatalf("adding a rule (no old eligible) must not warn, got %q", got) } } func TestFastpathWarn_TagFilteredRule_NoWarn(t *testing.T) { // Tag-filtered rules are never on the fast path; removing one must not warn. old := []byte(`rtEnabled` + `kv` + `7`) if got := fastpathConfigChangeLeavesStampedObjects(old, nil); got != "" { t.Fatalf("tag-only rule removal must not warn, got %q", got) } } func TestFastpathWarn_DisabledInNew_Warns(t *testing.T) { old := lcXML("r1", "logs/", 7) new := []byte(`r1Disabled` + `logs/` + `7`) got := fastpathConfigChangeLeavesStampedObjects(old, new) if got == "" { t.Fatalf("disabling an eligible rule must warn") } } func TestFastpathWarn_PrefixChanged_Warns(t *testing.T) { // Same ID, narrower prefix: old objects under "logs/" keep the old TTL // but the new rule only covers "logs/2026/". Treated as removed for the // old coverage -> warn. old := lcXML("r1", "logs/", 7) new := lcXML("r1", "logs/2026/", 7) got := fastpathConfigChangeLeavesStampedObjects(old, new) if got == "" { t.Fatalf("prefix change must warn") } } func TestFastpathWarn_OverflowDays_NoWarn(t *testing.T) { // ~68 years overflows int32 seconds; such a rule is never on the fast // path, so changing it must not warn. big := []byte(`rbEnabled` + `logs/` + `30000`) if got := fastpathConfigChangeLeavesStampedObjects(big, nil); got != "" { t.Fatalf("overflow rule must not warn, got %q", got) } } func TestFastpathWarn_IDOnlyRename_NoWarn(t *testing.T) { // Renaming a rule while keeping the same prefix, size filters, and // days must not warn: the policy is unchanged, only the label moved. old := lcXML("r1", "logs/", 7) new := lcXML("r2", "logs/", 7) if got := fastpathConfigChangeLeavesStampedObjects(old, new); got != "" { t.Fatalf("ID-only rename must not warn, got %q", got) } } func TestFastpathWarn_IDRenameAndLengthen_Warns(t *testing.T) { // Renaming AND lengthening: the predicate match finds the successor // and the days increase triggers a lengthened warning. old := lcXML("r1", "logs/", 7) new := lcXML("r2", "logs/", 30) got := fastpathConfigChangeLeavesStampedObjects(old, new) if got == "" { t.Fatalf("rename + lengthen must warn") } if !strings.Contains(got, "lengthened") || !strings.Contains(got, "7 -> 30") { t.Fatalf("unexpected reason: %q", got) } } func TestFastpathWarn_IDRenameAndShorten_NoWarn(t *testing.T) { // Renaming AND shortening: not the data-loss direction. old := lcXML("r1", "logs/", 30) new := lcXML("r2", "logs/", 7) if got := fastpathConfigChangeLeavesStampedObjects(old, new); got != "" { t.Fatalf("rename + shorten must not warn, got %q", got) } }