From 65fcf2d740aae0fdc9b576baa20c449fdfe2d44a Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sat, 9 May 2026 20:51:52 -0700 Subject: [PATCH] test(s3/lifecycle): direct router + engine helper coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles direct tests for several pure helpers in router.go and accessors on engine.Snapshot — all previously exercised only indirectly through the larger Route* / Compile tests, where a helper-level regression would surface as a downstream failure that's harder to diagnose. Router helpers: - successorModTimeFromContainer: missing ext, empty value, non-numeric garbage, non-positive seconds, positive seconds round-trip - logicalKeyFromVersionPath: extracts logical key, rejects path with non-.versions parent, root-level path with no real parent, no slashes at all, empty after trim - isVersionsContainerKey: bucket-root .versions rejected, trailing .versions accepted, files inside the container rejected - isVersionFolderPath: child of .versions accepted, container itself rejected, no-slash rejected; documents the trailing-slash edge - isDeleteMarkerEntry: nil/empty extended false, only literal "true" matches (case-insensitive variants and "1" rejected) - extractTags: nil/empty input returns nil, only AmzObjectTagging- prefixed keys surface, no-tag input returns nil (not empty map) - hasActiveEventDrivenAction: matches only active+event-driven kinds, scan-only kind rejected, unknown action skipped Engine snapshot accessors: - BucketVersioned reflects the compiled flag, unknown bucket false - BucketActionKeys returns all compiled keys for the bucket, unknown bucket nil - Action returns nil for unknown key - AllActions enumerates every compiled kind regardless of active state - SnapshotID is strictly monotonic across recompiles (the dispatcher's stale-snapshot check depends on this) 24 tests total, all passing under -race. --- weed/s3api/s3lifecycle/router/helpers_test.go | 335 ++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 weed/s3api/s3lifecycle/router/helpers_test.go 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") +}