Files
minio/cmd/site-replication-object-lock_test.go
T
Feng Ruohang 21646eebd2 fix: derive the Object Lock versioning rule from the parsed configuration
The load-time normalization compared the stored lock document with the
canonical enabled document byte for byte, so a lock configuration that also
carries a default retention rule kept a suspended or prefix-excluded
versioning document. Decide from the parsed configuration instead, after it
is parsed, so every writer that goes through Save, including the site
replication versioning and heal paths, ends with plain Enabled versioning on
a locked bucket. Receiving a lock configuration on a bucket created without
lock now enables versioning as well; the test that asserted the opposite is
updated, and a new test covers a rule-bearing lock document with suspended
and prefix-excluded versioning through Update, Get, and reload.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PvgysXDmhPBBimCReYtA8q
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-09-02 18:49:16 +08:00

269 lines
10 KiB
Go

// Copyright (c) 2015-2026 MinIO, Inc.
//
// This file is part of MinIO Object Storage stack
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package cmd
import (
"bytes"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/minio/madmin-go/v3"
"github.com/minio/minio/internal/auth"
"github.com/minio/mux"
)
func TestSRBucketObjectLockMetadata(t *testing.T) {
updatedAt := time.Date(2026, time.August, 29, 8, 0, 0, 0, time.UTC)
current := "current"
legacy := "legacy"
event := newSRBucketObjectLockMeta("bucket", &current, updatedAt)
if event.Type != madmin.SRBucketMetaTypeObjectLockConfig || event.Bucket != "bucket" ||
event.ObjectLockConfig == nil || *event.ObjectLockConfig != current || event.Tags != nil || !event.UpdatedAt.Equal(updatedAt) {
t.Fatalf("unexpected Object Lock event: %#v", event)
}
encoded, err := json.Marshal(event)
if err != nil {
t.Fatal(err)
}
var roundTrip madmin.SRBucketMeta
if err := json.Unmarshal(encoded, &roundTrip); err != nil {
t.Fatal(err)
}
if roundTrip.ObjectLockConfig == nil || *roundTrip.ObjectLockConfig != current || roundTrip.Tags != nil {
t.Fatalf("unexpected JSON round trip: %#v", roundTrip)
}
for _, test := range []struct {
name string
item madmin.SRBucketMeta
want *string
}{
{name: "current", item: madmin.SRBucketMeta{ObjectLockConfig: &current}, want: &current},
{name: "legacy", item: madmin.SRBucketMeta{Tags: &legacy}, want: &legacy},
{name: "current wins", item: madmin.SRBucketMeta{ObjectLockConfig: &current, Tags: &legacy}, want: &current},
{name: "missing", item: madmin.SRBucketMeta{}},
} {
t.Run(test.name, func(t *testing.T) {
got := srObjectLockPayload(test.item)
if test.want == nil {
if got != nil {
t.Fatalf("payload = %q, want nil", *got)
}
return
}
if got == nil || *got != *test.want {
t.Fatalf("payload = %v, want %q", got, *test.want)
}
})
}
}
func TestPeerBucketObjectLockMetadataCurrentAndLegacyPayloads(t *testing.T) {
defer DetectTestLeak(t)()
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
t: t,
objAPITest: testPeerBucketObjectLockMetadataCurrentAndLegacyPayloads,
makeBucketOptions: MakeBucketOptions{LockEnabled: true},
})
}
func applySRBucketMetaViaAdmin(t *testing.T, credentials auth.Credentials, item madmin.SRBucketMeta) *httptest.ResponseRecorder {
t.Helper()
body, err := json.Marshal(item)
if err != nil {
t.Fatal(err)
}
adminRouter := mux.NewRouter()
registerAdminRouter(adminRouter, true)
path := adminPathPrefix + adminAPIVersionPrefix + "/site-replication/peer/bucket-meta"
req, err := newTestSignedRequestV4(http.MethodPut, path, int64(len(body)), bytes.NewReader(body),
credentials.AccessKey, credentials.SecretKey, nil)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
adminRouter.ServeHTTP(rec, req)
return rec
}
func testPeerBucketObjectLockMetadataCurrentAndLegacyPayloads(_ ObjectLayer, instanceType, bucketName string,
_ http.Handler, credentials auth.Credentials, t *testing.T,
) {
apply := func(item madmin.SRBucketMeta, wantDays uint64) {
t.Helper()
rec := applySRBucketMetaViaAdmin(t, credentials, item)
if rec.Code != http.StatusOK {
t.Fatalf("%s: admin Object Lock apply returned %d: %s", instanceType, rec.Code, rec.Body.String())
}
config, _, err := globalBucketMetadataSys.GetObjectLockConfig(bucketName)
if err != nil {
t.Fatal(err)
}
if config.Rule == nil || config.Rule.DefaultRetention.Mode != "GOVERNANCE" ||
config.Rule.DefaultRetention.Days == nil || *config.Rule.DefaultRetention.Days != wantDays {
t.Fatalf("%s: persisted Object Lock config = %s, want GOVERNANCE/%d days", instanceType, config, wantDays)
}
}
config30 := base64.StdEncoding.EncodeToString([]byte(`<ObjectLockConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><ObjectLockEnabled>Enabled</ObjectLockEnabled><Rule><DefaultRetention><Mode>GOVERNANCE</Mode><Days>30</Days></DefaultRetention></Rule></ObjectLockConfiguration>`))
apply(newSRBucketObjectLockMeta(bucketName, &config30, UTCNow().Add(time.Hour)), 30)
config45 := base64.StdEncoding.EncodeToString([]byte(`<ObjectLockConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><ObjectLockEnabled>Enabled</ObjectLockEnabled><Rule><DefaultRetention><Mode>GOVERNANCE</Mode><Days>45</Days></DefaultRetention></Rule></ObjectLockConfiguration>`))
apply(madmin.SRBucketMeta{
Type: madmin.SRBucketMetaTypeObjectLockConfig,
Bucket: bucketName,
Tags: &config45,
UpdatedAt: UTCNow().Add(2 * time.Hour),
}, 45)
}
func TestPeerBucketObjectLockMetadataWithoutLockEnabled(t *testing.T) {
defer DetectTestLeak(t)()
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
t: t,
objAPITest: testPeerBucketObjectLockMetadataWithoutLockEnabled,
})
}
func testPeerBucketObjectLockMetadataWithoutLockEnabled(_ ObjectLayer, instanceType, bucketName string,
_ http.Handler, credentials auth.Credentials, t *testing.T,
) {
config := base64.StdEncoding.EncodeToString([]byte(`<ObjectLockConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><ObjectLockEnabled>Enabled</ObjectLockEnabled><Rule><DefaultRetention><Mode>GOVERNANCE</Mode><Days>30</Days></DefaultRetention></Rule></ObjectLockConfiguration>`))
item := newSRBucketObjectLockMeta(bucketName, &config, UTCNow().Add(time.Hour))
rec := applySRBucketMetaViaAdmin(t, credentials, item)
if rec.Code != http.StatusOK {
t.Fatalf("%s: admin Object Lock apply returned %d: %s", instanceType, rec.Code, rec.Body.String())
}
meta, err := globalBucketMetadataSys.Get(bucketName)
if err != nil {
t.Fatal(err)
}
// A lock configuration implies versioning: the bucket was created without
// lock, so receiving the configuration turns plain Enabled versioning on.
if meta.objectLockConfig == nil || !bytes.Equal(meta.VersioningConfigXML, enabledBucketVersioningConfig) {
t.Fatalf("%s: bucket metadata = objectLock:%v versioning:%q", instanceType, meta.objectLockConfig, meta.VersioningConfigXML)
}
}
func TestHealObjectLockMetadataUsesObjectLockField(t *testing.T) {
defer DetectTestLeak(t)()
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
t: t,
objAPITest: testHealObjectLockMetadataUsesObjectLockField,
})
}
func testHealObjectLockMetadataUsesObjectLockField(obj ObjectLayer, instanceType, bucketName string,
_ http.Handler, credentials auth.Credentials, t *testing.T,
) {
ctx := t.Context()
localID := globalDeploymentID()
remoteID := "remote-object-lock-heal"
updatedAt := UTCNow().Add(time.Hour)
createdAt := updatedAt.Add(-time.Hour)
config := base64.StdEncoding.EncodeToString([]byte(`<ObjectLockConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><ObjectLockEnabled>Enabled</ObjectLockEnabled><Rule><DefaultRetention><Mode>GOVERNANCE</Mode><Days>30</Days></DefaultRetention></Rule></ObjectLockConfiguration>`))
remoteApplies := make(chan madmin.SRBucketMeta, 1)
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var applied madmin.SRBucketMeta
if err := json.NewDecoder(r.Body).Decode(&applied); err != nil {
t.Errorf("%s: decode remote apply: %v", instanceType, err)
w.WriteHeader(http.StatusBadRequest)
return
}
remoteApplies <- applied
w.WriteHeader(http.StatusOK)
}))
defer remote.Close()
serviceCred, err := auth.CreateCredentials("object-lock-heal-svc", "object-lock-heal-service-secret")
if err != nil {
t.Fatal(err)
}
serviceCred.ParentUser = credentials.AccessKey
if _, err = globalIAMSys.store.AddServiceAccount(ctx, serviceCred); err != nil {
t.Fatal(err)
}
defer globalIAMSys.DeleteServiceAccount(ctx, serviceCred.AccessKey, false)
globalSiteReplicationSys.Lock()
oldEnabled := globalSiteReplicationSys.enabled
oldState := globalSiteReplicationSys.state
globalSiteReplicationSys.enabled = true
globalSiteReplicationSys.state = srState{
Name: "object-lock-heal-test",
ServiceAccountAccessKey: serviceCred.AccessKey,
Peers: map[string]madmin.PeerInfo{
localID: {Name: "local", DeploymentID: localID},
remoteID: {Name: "remote", DeploymentID: remoteID, Endpoint: remote.URL},
},
}
globalSiteReplicationSys.Unlock()
defer func() {
globalSiteReplicationSys.Lock()
globalSiteReplicationSys.enabled = oldEnabled
globalSiteReplicationSys.state = oldState
globalSiteReplicationSys.Unlock()
}()
status := srStatusInfo{
Sites: map[string]madmin.PeerInfo{
localID: {Name: "local", DeploymentID: localID},
remoteID: {Name: "remote", DeploymentID: remoteID, Endpoint: remote.URL},
},
BucketStats: map[string]map[string]srBucketStatsSummary{
bucketName: {
localID: {
SRBucketStatsSummary: madmin.SRBucketStatsSummary{OLockConfigMismatch: true},
meta: srBucketMetaInfo{SRBucketInfo: madmin.SRBucketInfo{
Bucket: bucketName,
CreatedAt: createdAt,
ObjectLockConfig: &config,
ObjectLockConfigUpdatedAt: updatedAt,
}, DeploymentID: localID},
},
remoteID: {
SRBucketStatsSummary: madmin.SRBucketStatsSummary{OLockConfigMismatch: true},
meta: srBucketMetaInfo{SRBucketInfo: madmin.SRBucketInfo{
Bucket: bucketName,
CreatedAt: createdAt,
}, DeploymentID: remoteID},
},
},
},
}
if err := globalSiteReplicationSys.healOLockConfigMetadata(ctx, obj, bucketName, status); err != nil {
t.Fatal(err)
}
select {
case applied := <-remoteApplies:
if applied.Type != madmin.SRBucketMetaTypeObjectLockConfig || applied.Bucket != bucketName ||
applied.ObjectLockConfig == nil || *applied.ObjectLockConfig != config || applied.Tags != nil || !applied.UpdatedAt.Equal(updatedAt) {
t.Fatalf("%s: remote heal apply = %#v", instanceType, applied)
}
case <-time.After(5 * time.Second):
t.Fatalf("%s: remote heal did not dispatch Object Lock metadata", instanceType)
}
}