// 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 .
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", ¤t, 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: ¤t}, want: ¤t},
{name: "legacy", item: madmin.SRBucketMeta{Tags: &legacy}, want: &legacy},
{name: "current wins", item: madmin.SRBucketMeta{ObjectLockConfig: ¤t, Tags: &legacy}, want: ¤t},
{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(`EnabledGOVERNANCE30`))
apply(newSRBucketObjectLockMeta(bucketName, &config30, UTCNow().Add(time.Hour)), 30)
config45 := base64.StdEncoding.EncodeToString([]byte(`EnabledGOVERNANCE45`))
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(`EnabledGOVERNANCE30`))
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(`EnabledGOVERNANCE30`))
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)
}
}