diff --git a/weed/s3api/s3lifecycle/bootstrap/has_prefix_test.go b/weed/s3api/s3lifecycle/bootstrap/has_prefix_test.go new file mode 100644 index 000000000..643166387 --- /dev/null +++ b/weed/s3api/s3lifecycle/bootstrap/has_prefix_test.go @@ -0,0 +1,33 @@ +package bootstrap + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// HasPrefix is a thin wrapper around strings.HasPrefix exported by the +// bootstrap package so call sites can avoid pulling strings just to do +// a prefix check. Trivial but at 0% coverage — pin it. + +func TestHasPrefix(t *testing.T) { + cases := []struct { + name string + path string + prefix string + want bool + }{ + {"matching prefix", "logs/2026/01/01", "logs/", true}, + {"exact match", "logs/", "logs/", true}, + {"non-matching prefix", "metrics/x", "logs/", false}, + {"shorter than prefix", "logs", "logs/", false}, + {"empty prefix matches all", "anything", "", true}, + {"both empty", "", "", true}, + {"empty input non-empty prefix", "", "x", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + assert.Equal(t, c.want, HasPrefix(c.path, c.prefix)) + }) + } +} diff --git a/weed/s3api/s3lifecycle/final_cleanup_test.go b/weed/s3api/s3lifecycle/final_cleanup_test.go new file mode 100644 index 000000000..db6437dd7 --- /dev/null +++ b/weed/s3api/s3lifecycle/final_cleanup_test.go @@ -0,0 +1,48 @@ +package s3lifecycle + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// Final unit-test cleanup ahead of the integration suite. Pins the +// remaining 0%-and-default-branch helpers in the s3lifecycle package +// and the lifecycletest builder so a regression doesn't slip in +// during the integration work that follows. + +func TestActionKind_StringUnspecifiedDefault(t *testing.T) { + // String's default branch (covers ActionKindUnspecified and any + // future enum value not listed) must render "unspecified" rather + // than empty or panic. Operators read this via the metrics labels + // (see S3LifecycleDispatchCounter "kind"). + assert.Equal(t, "unspecified", ActionKindUnspecified.String()) + assert.Equal(t, "unspecified", ActionKind(99).String()) +} + +func TestHashExtended_DirectFromLifecyclePackage(t *testing.T) { + // HashExtended is exercised from the s3api package's identity + // tests; pin it here so the s3lifecycle package's own coverage + // reflects the call. A nil/empty map produces no bytes, so the + // CAS witness collapses to "no Extended" rather than a synthetic + // hash that would mismatch on the server. + assert.Empty(t, HashExtended(nil)) + assert.Empty(t, HashExtended(map[string][]byte{})) + got := HashExtended(map[string][]byte{"a": []byte("1")}) + assert.NotEmpty(t, got) + // Same content, different literal/insertion order across multiple + // keys: hash must be stable. A single-key check can't catch an + // iteration-order regression — multiple keys force the helper's + // sort path to actually do work. + first := HashExtended(map[string][]byte{ + "a": []byte("1"), + "b": []byte("2"), + "c": []byte("3"), + }) + second := HashExtended(map[string][]byte{ + "c": []byte("3"), + "a": []byte("1"), + "b": []byte("2"), + }) + assert.Equal(t, first, second, "hash must be insensitive to map iteration order") +} diff --git a/weed/s3api/s3lifecycle/lifecycletest/eventbuilder_old_entry_test.go b/weed/s3api/s3lifecycle/lifecycletest/eventbuilder_old_entry_test.go new file mode 100644 index 000000000..e12b15480 --- /dev/null +++ b/weed/s3api/s3lifecycle/lifecycletest/eventbuilder_old_entry_test.go @@ -0,0 +1,102 @@ +package lifecycletest + +import ( + "testing" + "time" + + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/reader" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Coverage gap-fillers for the With* options' OldEntry-only branches +// (NewEntry==nil, OldEntry populated — i.e. Delete events). The +// existing eventbuilder_test.go covers Create events; these tests +// exercise the alternate fall-through path on each option. + +func TestEventOption_WithModTime_AppliesToOldEntryOnDelete(t *testing.T) { + t0 := time.Unix(1700000000, 0) + override := time.Unix(1699000000, 250) + e := NewDelete("bk", "k", t0, WithModTime(override)) + require.Nil(t, e.NewEntry) + require.NotNil(t, e.OldEntry) + assert.Equal(t, override.Unix(), e.OldEntry.Attributes.Mtime) + assert.Equal(t, int32(250), e.OldEntry.Attributes.MtimeNs) +} + +func TestEventOption_WithTtlSec_AppliesToOldEntryOnDelete(t *testing.T) { + e := NewDelete("bk", "k", time.Unix(0, 0), WithTtlSec(7200)) + require.NotNil(t, e.OldEntry) + assert.Equal(t, int32(7200), e.OldEntry.Attributes.TtlSec) +} + +func TestEventOption_WithVersionID_AppliesToOldEntryOnDelete(t *testing.T) { + // VersionID-on-delete: the bootstrapper synthesizes deletes for + // noncurrent-version sweeps and needs the version-id stamp. + e := NewDelete("bk", "k", time.Unix(0, 0), WithVersionID("v_old")) + require.NotNil(t, e.OldEntry) + require.NotNil(t, e.OldEntry.Extended) + assert.Equal(t, []byte("v_old"), e.OldEntry.Extended[s3_constants.ExtVersionIdKey]) +} + +func TestEventOption_WithExtended_AppliesToOldEntryOnDelete(t *testing.T) { + e := NewDelete("bk", "k", time.Unix(0, 0), WithExtended("Custom-Tag", []byte("v"))) + require.NotNil(t, e.OldEntry) + require.NotNil(t, e.OldEntry.Extended) + assert.Equal(t, []byte("v"), e.OldEntry.Extended["Custom-Tag"]) +} + +func TestEventOption_WithChunks_AppliesToOldEntryOnDelete(t *testing.T) { + c := &filer_pb.FileChunk{FileId: "1,old"} + e := NewDelete("bk", "k", time.Unix(0, 0), WithChunks(c)) + require.NotNil(t, e.OldEntry) + require.Len(t, e.OldEntry.Chunks, 1) + assert.Equal(t, "1,old", e.OldEntry.Chunks[0].FileId) +} + +func TestEventOption_WithBootstrapVersion(t *testing.T) { + // WithBootstrapVersion attaches a BootstrapVersion to the event; + // the bootstrap walker uses this for per-version state the live + // meta-log doesn't carry. Pin both create and delete shapes. + bv := &reader.BootstrapVersion{ + LogicalKey: "obj.txt", + VersionID: "v_aaa", + IsLatest: true, + IsDeleteMarker: false, + NumVersions: 3, + NoncurrentIndex: 2, + } + + create := NewCreate("bk", "obj.txt", time.Unix(0, 0), WithBootstrapVersion(bv)) + require.Same(t, bv, create.BootstrapVersion) + + del := NewDelete("bk", "obj.txt", time.Unix(0, 0), WithBootstrapVersion(bv)) + require.Same(t, bv, del.BootstrapVersion) + + update := NewUpdate("bk", "obj.txt", time.Unix(0, 0), WithBootstrapVersion(bv)) + require.Same(t, bv, update.BootstrapVersion) +} + +func TestEventOption_NoPanicOnEmptyEvent(t *testing.T) { + // Defense: an event with neither NewEntry nor OldEntry must not + // panic on any With* option, even though no constructor produces + // this shape today. Entry-targeting options fall through silently + // (NewEntry/OldEntry stay nil); WithBootstrapVersion targets the + // event itself, not an entry, so it does set BootstrapVersion — + // included here for panic safety. + e := &reader.Event{} + WithModTime(time.Unix(1, 0))(e) + WithTtlSec(60)(e) + WithVersionID("v")(e) + WithExtended("k", []byte("v"))(e) + WithChunks(&filer_pb.FileChunk{FileId: "x"})(e) + WithSize(1024)(e) + bv := &reader.BootstrapVersion{VersionID: "v"} + WithBootstrapVersion(bv)(e) + + assert.Nil(t, e.NewEntry, "entry-targeting options must not allocate NewEntry") + assert.Nil(t, e.OldEntry, "entry-targeting options must not allocate OldEntry") + assert.Same(t, bv, e.BootstrapVersion, "WithBootstrapVersion sets the field regardless of entry state") +} diff --git a/weed/s3api/s3lifecycle/router/helpers_test.go b/weed/s3api/s3lifecycle/router/helpers_test.go new file mode 100644 index 000000000..1543c15d3 --- /dev/null +++ b/weed/s3api/s3lifecycle/router/helpers_test.go @@ -0,0 +1,335 @@ +package router + +import ( + "testing" + "time" + + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/engine" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Direct coverage for the pure helpers in router.go that the larger +// Route* tests exercise indirectly. Pinning each one separately catches +// regressions before they manifest as a broader Route failure that's +// harder to diagnose. + +// ---------- successorModTimeFromContainer ---------- + +func TestSuccessorModTimeFromContainer_MissingExtReturnsZero(t *testing.T) { + assert.True(t, successorModTimeFromContainer(&filer_pb.Entry{}).IsZero()) + assert.True(t, successorModTimeFromContainer(&filer_pb.Entry{ + Extended: map[string][]byte{}, + }).IsZero()) +} + +func TestSuccessorModTimeFromContainer_EmptyValueReturnsZero(t *testing.T) { + got := successorModTimeFromContainer(&filer_pb.Entry{ + Extended: map[string][]byte{s3_constants.ExtLatestVersionMtimeKey: nil}, + }) + assert.True(t, got.IsZero()) +} + +func TestSuccessorModTimeFromContainer_NonNumericReturnsZero(t *testing.T) { + // A malformed value is the writer's bug; the helper must not + // propagate a parse error or surface a nonsense time. + got := successorModTimeFromContainer(&filer_pb.Entry{ + Extended: map[string][]byte{s3_constants.ExtLatestVersionMtimeKey: []byte("not-a-number")}, + }) + assert.True(t, got.IsZero()) +} + +func TestSuccessorModTimeFromContainer_NonPositiveReturnsZero(t *testing.T) { + // The container ext stores Unix seconds; <=0 means "not set" so + // the helper falls back to zero rather than emitting 1970-01-01. + for _, raw := range []string{"0", "-1", "-1000"} { + t.Run(raw, func(t *testing.T) { + got := successorModTimeFromContainer(&filer_pb.Entry{ + Extended: map[string][]byte{s3_constants.ExtLatestVersionMtimeKey: []byte(raw)}, + }) + assert.True(t, got.IsZero(), "value %q must produce zero time", raw) + }) + } +} + +func TestSuccessorModTimeFromContainer_PositiveSecondsRoundTrip(t *testing.T) { + got := successorModTimeFromContainer(&filer_pb.Entry{ + Extended: map[string][]byte{s3_constants.ExtLatestVersionMtimeKey: []byte("1700000000")}, + }) + assert.Equal(t, time.Unix(1700000000, 0).UTC(), got.UTC()) +} + +// ---------- logicalKeyFromVersionPath ---------- + +func TestLogicalKeyFromVersionPath_ExtractsLogicalKey(t *testing.T) { + logical, ok := logicalKeyFromVersionPath("a/b/c.versions/v_abc") + require.True(t, ok) + assert.Equal(t, "a/b/c", logical) +} + +func TestLogicalKeyFromVersionPath_RejectsPathWithoutVersionsParent(t *testing.T) { + // The parent of the version file must end with .versions; otherwise + // it's not a version path. + _, ok := logicalKeyFromVersionPath("a/b/c/v_abc") + assert.False(t, ok) +} + +func TestLogicalKeyFromVersionPath_RejectsRootLevelPath(t *testing.T) { + // LastIndex returning 0 means the only "/" is at index 0, which + // would yield an empty parent — not a real version path. + _, ok := logicalKeyFromVersionPath("/v_abc") + assert.False(t, ok) +} + +func TestLogicalKeyFromVersionPath_RejectsNoSlashes(t *testing.T) { + _, ok := logicalKeyFromVersionPath("v_abc") + assert.False(t, ok) + _, ok = logicalKeyFromVersionPath("") + assert.False(t, ok) +} + +func TestLogicalKeyFromVersionPath_RejectsBareVersionsContainer(t *testing.T) { + // A path that's only the .versions container has no version-file + // child to extract a logical key from. + _, ok := logicalKeyFromVersionPath(s3_constants.VersionsFolder + "/v_x") + assert.False(t, ok, "logical key cannot be empty after trim") +} + +// ---------- isVersionsContainerKey ---------- + +func TestIsVersionsContainerKey(t *testing.T) { + cases := []struct { + key string + want bool + }{ + {"obj" + s3_constants.VersionsFolder, true}, + {"a/b/obj" + s3_constants.VersionsFolder, true}, + // Bucket-root .versions is rejected explicitly: it has no + // logical object key, so the router can't process it. + {s3_constants.VersionsFolder, false}, + {"obj.versions/v_x", false}, // the version file inside, not the container + {"obj", false}, + {"", false}, + } + for _, c := range cases { + t.Run(c.key, func(t *testing.T) { + assert.Equal(t, c.want, isVersionsContainerKey(c.key)) + }) + } +} + +// ---------- isVersionFolderPath ---------- + +func TestIsVersionFolderPath(t *testing.T) { + // Reports whether a key sits inside a .versions/ folder, looking at + // the parent segment specifically. The router uses this to skip + // version-file events that need sibling state to classify. + cases := []struct { + key string + want bool + }{ + {"obj.versions/v_aaa", true}, + {"a/b/obj.versions/v_aaa", true}, + {"obj/v_aaa", false}, // parent isn't .versions + {"obj.versions", false}, // the container itself, not a child + // "obj.versions/" reads as the trailing-slash form of the path + // "obj.versions" → leaf is "obj.versions" which ends with the + // suffix → true. The router never sees this shape from the + // reader (events carry concrete leaf names), but pin it here so + // the contract is documented. + {"obj.versions/", true}, + {"v_aaa", false}, // no slash at all + {"", false}, + } + for _, c := range cases { + t.Run(c.key, func(t *testing.T) { + assert.Equal(t, c.want, isVersionFolderPath(c.key)) + }) + } +} + +// ---------- isDeleteMarkerEntry ---------- + +func TestIsDeleteMarkerEntry_NilOrEmptyExtendedReturnsFalse(t *testing.T) { + assert.False(t, isDeleteMarkerEntry(nil)) + assert.False(t, isDeleteMarkerEntry(&filer_pb.Entry{})) + assert.False(t, isDeleteMarkerEntry(&filer_pb.Entry{Extended: map[string][]byte{}})) +} + +func TestIsDeleteMarkerEntry_TrueOnlyForLiteralTrue(t *testing.T) { + // The marker key must be exactly "true"; any other value is not a + // marker. Pinning catches a regression that does case-insensitive + // matching or treats "1" as truthy. + mk := func(val string) *filer_pb.Entry { + return &filer_pb.Entry{Extended: map[string][]byte{s3_constants.ExtDeleteMarkerKey: []byte(val)}} + } + assert.True(t, isDeleteMarkerEntry(mk("true"))) + assert.False(t, isDeleteMarkerEntry(mk("True"))) + assert.False(t, isDeleteMarkerEntry(mk("TRUE"))) + assert.False(t, isDeleteMarkerEntry(mk("1"))) + assert.False(t, isDeleteMarkerEntry(mk(""))) + assert.False(t, isDeleteMarkerEntry(mk("false"))) +} + +// ---------- extractTags ---------- + +func TestExtractTags_NilOrEmptyReturnsNil(t *testing.T) { + assert.Nil(t, extractTags(nil)) + assert.Nil(t, extractTags(map[string][]byte{})) +} + +func TestExtractTags_OnlyKeysWithObjectTaggingPrefix(t *testing.T) { + // extractTags returns ExtVersionIdKey, Mime, etc. → no. + // It only picks up keys with the AmzObjectTagging prefix and strips + // that prefix to produce the tag map. + prefix := s3_constants.AmzObjectTagging + "-" + ext := map[string][]byte{ + prefix + "env": []byte("prod"), + prefix + "team": []byte("data"), + "X-Amz-Other": []byte("ignored"), + "Seaweed-X-Internal": []byte("ignored"), + } + got := extractTags(ext) + require.Len(t, got, 2) + assert.Equal(t, "prod", got["env"]) + assert.Equal(t, "data", got["team"]) +} + +func TestExtractTags_ReturnsNilWhenNoTaggingPrefixedKeys(t *testing.T) { + // Keys with no tagging prefix produce a nil map (not an empty map); + // the router treats nil as "no tags" without an extra branch. + ext := map[string][]byte{ + "X-Amz-Other": []byte("ignored"), + "Seaweed-X-Internal": []byte("ignored"), + } + got := extractTags(ext) + assert.Nil(t, got) +} + +// ---------- hasActiveEventDrivenAction ---------- + +func TestHasActiveEventDrivenAction(t *testing.T) { + // Build a snapshot with two action kinds and one of them set to + // scan-only (inactive in event-driven view). hasActiveEventDrivenAction + // must answer true only for the active event-driven kind. + rule := &s3lifecycle.Rule{ + ID: "r", + Status: s3lifecycle.StatusEnabled, + ExpirationDays: 7, + AbortMPUDaysAfterInitiation: 3, + } + hash := s3lifecycle.RuleHash(rule) + expirationKey := s3lifecycle.ActionKey{Bucket: "bk", RuleHash: hash, ActionKind: s3lifecycle.ActionKindExpirationDays} + abortKey := s3lifecycle.ActionKey{Bucket: "bk", RuleHash: hash, ActionKind: s3lifecycle.ActionKindAbortMPU} + prior := map[s3lifecycle.ActionKey]engine.PriorState{ + expirationKey: {BootstrapComplete: true, Mode: engine.ModeEventDriven}, + abortKey: {BootstrapComplete: true, Mode: engine.ModeScanOnly}, + } + snap := engine.New().Compile( + []engine.CompileInput{{Bucket: "bk", Rules: []*s3lifecycle.Rule{rule}}}, + engine.CompileOptions{PriorStates: prior}, + ) + keys := []s3lifecycle.ActionKey{expirationKey, abortKey} + + // Active event-driven kind matches. + assert.True(t, hasActiveEventDrivenAction(snap, keys, s3lifecycle.ActionKindExpirationDays)) + // Scan-only kind does NOT match — operator promoted it to scan-only + // and the router must respect that by skipping the event-driven path. + assert.False(t, hasActiveEventDrivenAction(snap, keys, s3lifecycle.ActionKindAbortMPU)) + // A kind not in the keys list is false. + assert.False(t, hasActiveEventDrivenAction(snap, keys, s3lifecycle.ActionKindNoncurrentDays)) +} + +func TestHasActiveEventDrivenAction_NilActionSkipped(t *testing.T) { + // A key the snapshot doesn't know about returns nil from Action; + // the helper must skip rather than panic. + snap := engine.New().Compile(nil, engine.CompileOptions{}) + keys := []s3lifecycle.ActionKey{ + {Bucket: "ghost", ActionKind: s3lifecycle.ActionKindExpirationDays}, + } + assert.False(t, hasActiveEventDrivenAction(snap, keys, s3lifecycle.ActionKindExpirationDays)) +} + +// ---------- engine.Snapshot accessors ---------- + +func TestSnapshot_BucketVersionedReportsCompiledFlag(t *testing.T) { + rule := &s3lifecycle.Rule{ID: "r", Status: s3lifecycle.StatusEnabled, ExpirationDays: 7} + snap := engine.New().Compile( + []engine.CompileInput{ + {Bucket: "vbk", Rules: []*s3lifecycle.Rule{rule}, Versioned: true}, + {Bucket: "ubk", Rules: []*s3lifecycle.Rule{rule}, Versioned: false}, + }, + engine.CompileOptions{}, + ) + assert.True(t, snap.BucketVersioned("vbk")) + assert.False(t, snap.BucketVersioned("ubk")) + // Unknown bucket: not versioned, not a panic. + assert.False(t, snap.BucketVersioned("missing")) +} + +func TestSnapshot_BucketActionKeysReturnsCompiledList(t *testing.T) { + // Every CompileInput rule emits one action key per RuleActionKind; + // BucketActionKeys must surface them all (regardless of Mode) so + // MatchPath can iterate. + rule := &s3lifecycle.Rule{ID: "r", Status: s3lifecycle.StatusEnabled, ExpirationDays: 7} + snap := engine.New().Compile( + []engine.CompileInput{{Bucket: "bk", Rules: []*s3lifecycle.Rule{rule}}}, + engine.CompileOptions{}, + ) + keys := snap.BucketActionKeys("bk") + require.NotEmpty(t, keys) + for _, k := range keys { + assert.Equal(t, "bk", k.Bucket) + } +} + +func TestSnapshot_BucketActionKeysUnknownBucketReturnsNil(t *testing.T) { + snap := engine.New().Compile(nil, engine.CompileOptions{}) + assert.Nil(t, snap.BucketActionKeys("missing")) +} + +func TestSnapshot_ActionUnknownKeyReturnsNil(t *testing.T) { + snap := engine.New().Compile(nil, engine.CompileOptions{}) + got := snap.Action(s3lifecycle.ActionKey{Bucket: "ghost", ActionKind: s3lifecycle.ActionKindExpirationDays}) + assert.Nil(t, got) +} + +func TestSnapshot_AllActionsCoversEveryCompiledKind(t *testing.T) { + // AllActions must enumerate every compiled action regardless of + // active state, because the dispatcher uses it for full-snapshot + // reporting. + rule := &s3lifecycle.Rule{ + ID: "r", + Status: s3lifecycle.StatusEnabled, + ExpirationDays: 7, + AbortMPUDaysAfterInitiation: 3, + } + snap := engine.New().Compile( + []engine.CompileInput{{Bucket: "bk", Rules: []*s3lifecycle.Rule{rule}}}, + engine.CompileOptions{}, + ) + all := snap.AllActions() + wantKinds := s3lifecycle.RuleActionKinds(rule) + require.Len(t, all, len(wantKinds)) + seen := map[s3lifecycle.ActionKind]bool{} + for _, a := range all { + seen[a.Key.ActionKind] = true + } + for _, k := range wantKinds { + assert.True(t, seen[k], "missing kind %v in AllActions", k) + } +} + +func TestSnapshot_SnapshotIDIsMonotonicAcrossRecompiles(t *testing.T) { + // Every Compile call advances snapshotIDSeq; pin that successive + // snapshots from the same Engine carry strictly increasing IDs so + // the dispatcher's stale-snapshot check works. + e := engine.New() + first := e.Compile(nil, engine.CompileOptions{}) + second := e.Compile(nil, engine.CompileOptions{}) + assert.Greater(t, second.SnapshotID(), first.SnapshotID(), + "second compile must produce a strictly greater snapshot id") +}