Files
kubemirror/pkg/controller/source_reconciler_test.go
T
lukaszraczylo dfe08b35d1 fix(controller): stop self-triggered reconcile loops
C2: updateLastSyncStatus wrote the sync-status annotation on every
successful reconcile. Because the source's watch predicate is the
'enabled' label (server-side filter), that Update fires a watch event
that re-enters Reconcile. With reconciled/error counts varying across
cycles, the value differs each time, so the API server bumps RV and
the loop never quiesces. Now skips the Update when the value matches
the existing annotation.

C3: NamespaceReconciler's happy-path returned RequeueAfter=3s
unconditionally. Every namespace in the cluster re-reconciled every
3 seconds forever, generating constant List calls per source kind.
Now returns ctrl.Result{}; cache-staleness windows are handled by
the manager's resync period and source freshness verification.
2026-05-02 22:39:09 +01:00

1158 lines
35 KiB
Go

package controller
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/lukaszraczylo/kubemirror/pkg/config"
"github.com/lukaszraczylo/kubemirror/pkg/constants"
"github.com/lukaszraczylo/kubemirror/pkg/filter"
)
// MockClient is a mock implementation of client.Client for testing.
type MockClient struct {
mock.Mock
}
func (m *MockClient) Get(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) error {
args := m.Called(ctx, key, obj)
if args.Error(0) != nil {
return args.Error(0)
}
// Copy the mock object into obj
if mockObj := args.Get(1); mockObj != nil {
switch v := mockObj.(type) {
case *corev1.Secret:
*obj.(*corev1.Secret) = *v
case *corev1.ConfigMap:
*obj.(*corev1.ConfigMap) = *v
case *unstructured.Unstructured:
// Copy the unstructured object
*obj.(*unstructured.Unstructured) = *v
}
}
return nil
}
func (m *MockClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error {
args := m.Called(ctx, list, opts)
return args.Error(0)
}
func (m *MockClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error {
args := m.Called(ctx, obj, opts)
return args.Error(0)
}
func (m *MockClient) Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error {
args := m.Called(ctx, obj, opts)
return args.Error(0)
}
func (m *MockClient) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error {
args := m.Called(ctx, obj, opts)
return args.Error(0)
}
func (m *MockClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error {
args := m.Called(ctx, obj, patch, opts)
return args.Error(0)
}
func (m *MockClient) DeleteAllOf(ctx context.Context, obj client.Object, opts ...client.DeleteAllOfOption) error {
args := m.Called(ctx, obj, opts)
return args.Error(0)
}
func (m *MockClient) Status() client.StatusWriter {
args := m.Called()
return args.Get(0).(client.StatusWriter)
}
func (m *MockClient) Scheme() *runtime.Scheme {
args := m.Called()
return args.Get(0).(*runtime.Scheme)
}
func (m *MockClient) RESTMapper() meta.RESTMapper {
args := m.Called()
return args.Get(0).(meta.RESTMapper)
}
func (m *MockClient) GroupVersionKindFor(obj runtime.Object) (schema.GroupVersionKind, error) {
args := m.Called(obj)
return args.Get(0).(schema.GroupVersionKind), args.Error(1)
}
func (m *MockClient) IsObjectNamespaced(obj runtime.Object) (bool, error) {
args := m.Called(obj)
return args.Bool(0), args.Error(1)
}
func (m *MockClient) SubResource(subResource string) client.SubResourceClient {
args := m.Called(subResource)
return args.Get(0).(client.SubResourceClient)
}
func (m *MockClient) Apply(ctx context.Context, obj runtime.ApplyConfiguration, opts ...client.ApplyOption) error {
args := m.Called(ctx, obj, opts)
return args.Error(0)
}
// MockNamespaceLister is a mock implementation of NamespaceLister for testing.
type MockNamespaceLister struct {
mock.Mock
}
func (m *MockNamespaceLister) ListNamespaces(ctx context.Context) ([]string, error) {
args := m.Called(ctx)
return args.Get(0).([]string), args.Error(1)
}
func (m *MockNamespaceLister) ListAllowMirrorsNamespaces(ctx context.Context) ([]string, error) {
args := m.Called(ctx)
return args.Get(0).([]string), args.Error(1)
}
func (m *MockNamespaceLister) ListOptOutNamespaces(ctx context.Context) ([]string, error) {
args := m.Called(ctx)
return args.Get(0).([]string), args.Error(1)
}
func (m *MockNamespaceLister) ListNamespacesWithLabels(ctx context.Context) (*NamespaceInfo, error) {
args := m.Called(ctx)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*NamespaceInfo), args.Error(1)
}
func TestIsEnabledForMirroring(t *testing.T) {
tests := []struct {
obj metav1.Object
name string
want bool
}{
{
name: "enabled with both label and annotation",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
constants.LabelEnabled: "true",
},
Annotations: map[string]string{
constants.AnnotationSync: "true",
},
},
},
want: true,
},
{
name: "missing label",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
constants.AnnotationSync: "true",
},
},
},
want: false,
},
{
name: "missing annotation",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
constants.LabelEnabled: "true",
},
},
},
want: false,
},
{
name: "label set to false",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
constants.LabelEnabled: "false",
},
Annotations: map[string]string{
constants.AnnotationSync: "true",
},
},
},
want: false,
},
{
name: "no labels or annotations",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isEnabledForMirroring(tt.obj)
assert.Equal(t, tt.want, got)
})
}
}
func TestSourceReconciler_resolveTargetNamespaces(t *testing.T) {
tests := []struct {
name string
sourceAnnotations map[string]string
allNamespaces []string
allowMirrorsNamespaces []string
sourceNamespace string
wantContains []string
wantNotContains []string
wantError bool
expectListCalls bool
}{
{
name: "no target annotation",
sourceAnnotations: map[string]string{
constants.AnnotationSync: "true",
},
allNamespaces: []string{"app1", "app2"},
sourceNamespace: "default",
wantContains: nil,
expectListCalls: false,
},
{
name: "single target namespace",
sourceAnnotations: map[string]string{
constants.AnnotationTargetNamespaces: "app1",
},
allNamespaces: []string{"app1", "app2", "default"},
sourceNamespace: "default",
wantContains: []string{"app1"},
wantNotContains: []string{"app2", "default"},
expectListCalls: true,
},
{
name: "multiple target namespaces",
sourceAnnotations: map[string]string{
constants.AnnotationTargetNamespaces: "app1,app2",
},
allNamespaces: []string{"app1", "app2", "app3", "default"},
sourceNamespace: "default",
wantContains: []string{"app1", "app2"},
wantNotContains: []string{"app3", "default"},
expectListCalls: true,
},
{
name: "all keyword",
sourceAnnotations: map[string]string{
constants.AnnotationTargetNamespaces: "all",
},
allNamespaces: []string{"app1", "app2", "default"},
sourceNamespace: "default",
wantContains: []string{"app1", "app2"},
wantNotContains: []string{"default"}, // source excluded
expectListCalls: true,
},
{
name: "pattern matching",
sourceAnnotations: map[string]string{
constants.AnnotationTargetNamespaces: "app-*",
},
allNamespaces: []string{"app-frontend", "app-backend", "prod-api", "default"},
sourceNamespace: "default",
wantContains: []string{"app-frontend", "app-backend"},
wantNotContains: []string{"prod-api", "default"},
expectListCalls: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockLister := new(MockNamespaceLister)
if tt.expectListCalls {
nsInfo := &NamespaceInfo{
All: tt.allNamespaces,
AllowMirrors: tt.allowMirrorsNamespaces,
OptOut: []string{},
}
mockLister.On("ListNamespacesWithLabels", mock.Anything).Return(nsInfo, nil)
}
r := &SourceReconciler{
Config: &config.Config{},
Filter: filter.NewNamespaceFilter([]string{}, []string{}),
NamespaceLister: mockLister,
}
sourceObj := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "test-secret",
Namespace: tt.sourceNamespace,
Annotations: tt.sourceAnnotations,
},
}
got, err := r.resolveTargetNamespaces(context.Background(), sourceObj)
if tt.wantError {
require.Error(t, err)
return
}
require.NoError(t, err)
if tt.wantContains != nil {
for _, ns := range tt.wantContains {
assert.Contains(t, got, ns)
}
}
if tt.wantNotContains != nil {
for _, ns := range tt.wantNotContains {
assert.NotContains(t, got, ns)
}
}
if tt.expectListCalls {
mockLister.AssertExpectations(t)
}
})
}
}
func TestSourceReconciler_Reconcile_MirrorResource(t *testing.T) {
// Test that mirrors are not reconciled as sources
mockClient := new(MockClient)
mockLister := new(MockNamespaceLister)
r := &SourceReconciler{
Client: mockClient,
Scheme: runtime.NewScheme(),
Config: &config.Config{},
Filter: filter.NewNamespaceFilter([]string{}, []string{}),
NamespaceLister: mockLister,
GVK: schema.GroupVersionKind{
Group: "",
Version: "v1",
Kind: "Secret",
},
}
// Create a mirror resource (has the mirror label) as unstructured
mirrorSecret := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "test-secret",
"namespace": "app1",
"labels": map[string]interface{}{
constants.LabelManagedBy: constants.ControllerName,
constants.LabelMirror: "true",
},
},
},
}
mockClient.On("Get", mock.Anything, mock.Anything, mock.AnythingOfType("*unstructured.Unstructured")).
Return(nil, mirrorSecret)
req := ctrl.Request{
NamespacedName: types.NamespacedName{
Namespace: "app1",
Name: "test-secret",
},
}
result, err := r.Reconcile(context.Background(), req)
require.NoError(t, err)
assert.Equal(t, ctrl.Result{}, result)
mockClient.AssertExpectations(t)
}
func TestSourceReconciler_Reconcile_NotFound(t *testing.T) {
// Test that deleted resources are handled gracefully
mockClient := new(MockClient)
mockLister := new(MockNamespaceLister)
r := &SourceReconciler{
Client: mockClient,
Scheme: runtime.NewScheme(),
Config: &config.Config{},
Filter: filter.NewNamespaceFilter([]string{}, []string{}),
NamespaceLister: mockLister,
GVK: schema.GroupVersionKind{
Group: "",
Version: "v1",
Kind: "Secret",
},
}
notFoundErr := errors.NewNotFound(schema.GroupResource{
Group: "",
Resource: "secrets",
}, "test-secret")
mockClient.On("Get", mock.Anything, mock.Anything, mock.AnythingOfType("*unstructured.Unstructured")).
Return(notFoundErr, (*unstructured.Unstructured)(nil))
req := ctrl.Request{
NamespacedName: types.NamespacedName{
Namespace: "default",
Name: "test-secret",
},
}
result, err := r.Reconcile(context.Background(), req)
require.NoError(t, err)
assert.Equal(t, ctrl.Result{}, result)
mockClient.AssertExpectations(t)
}
// Benchmark tests for performance-critical paths
func BenchmarkIsEnabledForMirroring(b *testing.B) {
obj := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
constants.LabelEnabled: "true",
},
Annotations: map[string]string{
constants.AnnotationSync: "true",
},
},
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = isEnabledForMirroring(obj)
}
}
func BenchmarkResolveTargetNamespaces(b *testing.B) {
mockLister := new(MockNamespaceLister)
allNamespaces := make([]string, 100)
for i := range 100 {
allNamespaces[i] = fmt.Sprintf("namespace-%d", i)
}
nsInfo := &NamespaceInfo{
All: allNamespaces,
AllowMirrors: allNamespaces[:50],
OptOut: []string{},
}
mockLister.On("ListNamespacesWithLabels", mock.Anything).Return(nsInfo, nil)
r := &SourceReconciler{
Config: &config.Config{},
Filter: filter.NewNamespaceFilter([]string{}, []string{}),
NamespaceLister: mockLister,
}
sourceObj := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "test-secret",
Namespace: "default",
Annotations: map[string]string{
constants.AnnotationTargetNamespaces: "all",
},
},
}
ctx := context.Background()
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = r.resolveTargetNamespaces(ctx, sourceObj)
}
}
func TestSourceReconciler_cleanupOrphanedMirrors(t *testing.T) {
// Setup: Source in default namespace with mirrors in app-1, app-2, app-3
// Then target-namespaces changes to only app-1, app-2
// Expect: app-3 mirror is deleted (orphaned)
sourceObj := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "test-secret",
"namespace": "default",
"uid": "source-uid-123",
},
},
}
// Mock existing mirror in app-3 (will be orphaned)
orphanedMirror := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "test-secret",
"namespace": "app-3",
"labels": map[string]interface{}{
constants.LabelManagedBy: constants.ControllerName,
constants.LabelMirror: "true",
},
"annotations": map[string]interface{}{
constants.AnnotationSourceNamespace: "default",
constants.AnnotationSourceName: "test-secret",
constants.AnnotationSourceUID: "source-uid-123",
},
},
},
}
mockClient := new(MockClient)
mockLister := new(MockNamespaceLister)
// Setup: all namespaces in cluster
allNamespaces := []string{"default", "app-1", "app-2", "app-3", "prod-1"}
nsInfo := &NamespaceInfo{
All: allNamespaces,
AllowMirrors: []string{},
OptOut: []string{},
}
mockLister.On("ListNamespacesWithLabels", mock.Anything).Return(nsInfo, nil)
// Current target list (after annotation change): only app-1 and app-2
targetNamespaces := []string{"app-1", "app-2"}
// The function will iterate through all namespaces and:
// - Skip "default" (source namespace)
// - Skip "app-1" and "app-2" (in target list)
// - Check "app-3" (not in target list) → will find orphaned mirror
// - Check "prod-1" (not in target list) → no mirror exists
notFoundErr := errors.NewNotFound(schema.GroupResource{Group: "", Resource: "secrets"}, "test-secret")
// app-3: orphaned mirror exists
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "app-3", Name: "test-secret"}, mock.Anything).
Return(nil, orphanedMirror)
// prod-1: no mirror exists
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "prod-1", Name: "test-secret"}, mock.Anything).
Return(notFoundErr, nil)
// Expect delete call for app-3 mirror
mockClient.On("Delete", mock.Anything, mock.MatchedBy(func(obj client.Object) bool {
u, ok := obj.(*unstructured.Unstructured)
return ok && u.GetNamespace() == "app-3" && u.GetName() == "test-secret"
}), mock.Anything).Return(nil)
r := &SourceReconciler{
Client: mockClient,
NamespaceLister: mockLister,
}
ctx := context.Background()
deletedCount, err := r.cleanupOrphanedMirrors(ctx, sourceObj, targetNamespaces)
require.NoError(t, err)
assert.Equal(t, 1, deletedCount, "should have deleted 1 orphaned mirror")
mockClient.AssertExpectations(t)
mockLister.AssertExpectations(t)
}
func TestSourceReconciler_Reconcile_AnnotationChange_AllToAllLabeled(t *testing.T) {
// Scenario: annotation changes from "all" → "all-labeled"
// Before: mirrors in all 5 namespaces
// After: mirrors only in labeled namespaces (app-1, app-2)
// Expected: 3 orphaned mirrors deleted (app-3, prod-1, prod-2)
source := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "test-secret",
"namespace": "default",
"uid": "source-uid-123",
"labels": map[string]interface{}{
constants.LabelEnabled: "true",
},
"annotations": map[string]interface{}{
constants.AnnotationSync: "true",
constants.AnnotationTargetNamespaces: "all-labeled", // Changed from "all"
},
"finalizers": []interface{}{
constants.FinalizerName,
},
},
"data": map[string]interface{}{
"password": "c2VjcmV0",
},
},
}
// Setup: 5 total namespaces, only 2 have allow-mirrors label
allNamespaces := []string{"default", "app-1", "app-2", "app-3", "prod-1", "prod-2"}
allowMirrorsNamespaces := []string{"app-1", "app-2"}
// Mock existing orphaned mirrors in app-3, prod-1, prod-2
createOrphanedMirror := func(ns string) *unstructured.Unstructured {
return &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "test-secret",
"namespace": ns,
"labels": map[string]interface{}{
constants.LabelManagedBy: constants.ControllerName,
constants.LabelMirror: "true",
},
"annotations": map[string]interface{}{
constants.AnnotationSourceNamespace: "default",
constants.AnnotationSourceName: "test-secret",
constants.AnnotationSourceUID: "source-uid-123",
},
},
"data": map[string]interface{}{
"password": "c2VjcmV0",
},
},
}
}
mockClient := new(MockClient)
mockLister := new(MockNamespaceLister)
mockFilter := filter.NewNamespaceFilter(nil, nil)
nsInfo := &NamespaceInfo{
All: allNamespaces,
AllowMirrors: allowMirrorsNamespaces,
OptOut: []string{},
}
mockLister.On("ListNamespacesWithLabels", mock.Anything).Return(nsInfo, nil)
// Mock Get for source
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "default", Name: "test-secret"}, mock.Anything).
Return(nil, source)
// Helper to create a mock mirror for verification
createMirror := func(ns string) *unstructured.Unstructured {
return &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]any{
"name": "test-secret",
"namespace": ns,
"labels": map[string]any{
constants.LabelManagedBy: constants.ControllerName,
constants.LabelMirror: "true",
},
},
},
}
}
// Mock reconcileMirror calls for app-1 and app-2 (current targets)
notFoundErr := errors.NewNotFound(schema.GroupResource{Group: "", Resource: "secrets"}, "test-secret")
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "app-1", Name: "test-secret"}, mock.Anything).
Return(notFoundErr, nil).Once()
mockClient.On("Create", mock.Anything, mock.MatchedBy(func(obj client.Object) bool {
return obj.GetNamespace() == "app-1"
}), mock.Anything).Return(nil).Once()
// Verification Get after Create
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "app-1", Name: "test-secret"}, mock.Anything).
Return(nil, createMirror("app-1")).Once()
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "app-2", Name: "test-secret"}, mock.Anything).
Return(notFoundErr, nil).Once()
mockClient.On("Create", mock.Anything, mock.MatchedBy(func(obj client.Object) bool {
return obj.GetNamespace() == "app-2"
}), mock.Anything).Return(nil).Once()
// Verification Get after Create
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "app-2", Name: "test-secret"}, mock.Anything).
Return(nil, createMirror("app-2")).Once()
// Mock cleanup: check orphaned namespaces app-3, prod-1, prod-2
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "app-3", Name: "test-secret"}, mock.Anything).
Return(nil, createOrphanedMirror("app-3")).Once()
mockClient.On("Delete", mock.Anything, mock.MatchedBy(func(obj client.Object) bool {
return obj.GetNamespace() == "app-3"
}), mock.Anything).Return(nil).Once()
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "prod-1", Name: "test-secret"}, mock.Anything).
Return(nil, createOrphanedMirror("prod-1")).Once()
mockClient.On("Delete", mock.Anything, mock.MatchedBy(func(obj client.Object) bool {
return obj.GetNamespace() == "prod-1"
}), mock.Anything).Return(nil).Once()
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "prod-2", Name: "test-secret"}, mock.Anything).
Return(nil, createOrphanedMirror("prod-2")).Once()
mockClient.On("Delete", mock.Anything, mock.MatchedBy(func(obj client.Object) bool {
return obj.GetNamespace() == "prod-2"
}), mock.Anything).Return(nil).Once()
// Mock Update for status annotation
mockClient.On("Update", mock.Anything, mock.Anything, mock.Anything).Return(nil)
r := &SourceReconciler{
Client: mockClient,
Scheme: runtime.NewScheme(),
Config: &config.Config{},
Filter: mockFilter,
NamespaceLister: mockLister,
GVK: schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"},
}
ctx := context.Background()
req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "test-secret"}}
result, err := r.Reconcile(ctx, req)
require.NoError(t, err)
assert.Equal(t, ctrl.Result{}, result)
mockClient.AssertExpectations(t)
mockLister.AssertExpectations(t)
}
func TestSourceReconciler_Reconcile_AnnotationChange_PatternChange(t *testing.T) {
// Scenario: annotation changes from "app-*" → "prod-*"
// Before: mirrors in app-1, app-2, app-3
// After: mirrors in prod-1, prod-2
// Expected: app-1, app-2, app-3 deleted; prod-1, prod-2 created
source := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": map[string]interface{}{
"name": "app-config",
"namespace": "default",
"uid": "config-uid-456",
"labels": map[string]interface{}{
constants.LabelEnabled: "true",
},
"annotations": map[string]interface{}{
constants.AnnotationSync: "true",
constants.AnnotationTargetNamespaces: "prod-*", // Changed from "app-*"
},
"finalizers": []interface{}{
constants.FinalizerName,
},
},
"data": map[string]interface{}{
"key": "value",
},
},
}
allNamespaces := []string{"default", "app-1", "app-2", "app-3", "prod-1", "prod-2"}
createOrphanedMirror := func(ns string) *unstructured.Unstructured {
return &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": map[string]interface{}{
"name": "app-config",
"namespace": ns,
"labels": map[string]interface{}{
constants.LabelManagedBy: constants.ControllerName,
constants.LabelMirror: "true",
},
"annotations": map[string]interface{}{
constants.AnnotationSourceNamespace: "default",
constants.AnnotationSourceName: "app-config",
constants.AnnotationSourceUID: "config-uid-456",
},
},
"data": map[string]interface{}{
"key": "value",
},
},
}
}
mockClient := new(MockClient)
mockLister := new(MockNamespaceLister)
mockFilter := filter.NewNamespaceFilter(nil, nil)
nsInfo := &NamespaceInfo{
All: allNamespaces,
AllowMirrors: []string{},
OptOut: []string{},
}
mockLister.On("ListNamespacesWithLabels", mock.Anything).Return(nsInfo, nil)
// Mock Get for source
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "default", Name: "app-config"}, mock.Anything).
Return(nil, source)
notFoundErr := errors.NewNotFound(schema.GroupResource{Group: "", Resource: "configmaps"}, "app-config")
// Helper to create a mock mirror for verification
createMirror := func(ns string) *unstructured.Unstructured {
return &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": map[string]any{
"name": "app-config",
"namespace": ns,
"labels": map[string]any{
constants.LabelManagedBy: constants.ControllerName,
constants.LabelMirror: "true",
},
},
},
}
}
// Mock reconcileMirror for prod-1 and prod-2 (new targets)
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "prod-1", Name: "app-config"}, mock.Anything).
Return(notFoundErr, nil).Once()
mockClient.On("Create", mock.Anything, mock.MatchedBy(func(obj client.Object) bool {
return obj.GetNamespace() == "prod-1"
}), mock.Anything).Return(nil).Once()
// Verification Get after Create
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "prod-1", Name: "app-config"}, mock.Anything).
Return(nil, createMirror("prod-1")).Once()
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "prod-2", Name: "app-config"}, mock.Anything).
Return(notFoundErr, nil).Once()
mockClient.On("Create", mock.Anything, mock.MatchedBy(func(obj client.Object) bool {
return obj.GetNamespace() == "prod-2"
}), mock.Anything).Return(nil).Once()
// Verification Get after Create
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "prod-2", Name: "app-config"}, mock.Anything).
Return(nil, createMirror("prod-2")).Once()
// Mock cleanup: delete orphaned mirrors in app-1, app-2, app-3
for _, ns := range []string{"app-1", "app-2", "app-3"} {
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: ns, Name: "app-config"}, mock.Anything).
Return(nil, createOrphanedMirror(ns)).Once()
mockClient.On("Delete", mock.Anything, mock.MatchedBy(func(obj client.Object) bool {
return obj.GetNamespace() == ns
}), mock.Anything).Return(nil).Once()
}
// Mock Update for status
mockClient.On("Update", mock.Anything, mock.Anything, mock.Anything).Return(nil)
r := &SourceReconciler{
Client: mockClient,
Scheme: runtime.NewScheme(),
Config: &config.Config{},
Filter: mockFilter,
NamespaceLister: mockLister,
GVK: schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"},
}
ctx := context.Background()
req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "app-config"}}
result, err := r.Reconcile(ctx, req)
require.NoError(t, err)
assert.Equal(t, ctrl.Result{}, result)
mockClient.AssertExpectations(t)
mockLister.AssertExpectations(t)
}
func TestSourceReconciler_deleteAllMirrors_skipsUnmanagedResources(t *testing.T) {
// Regression test: deleteAllMirrors must NOT delete a resource it does not own,
// even if the name and GVK happen to match the source.
sourceObj := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "shared-name",
"namespace": "default",
"uid": "source-uid",
},
},
}
mockClient := new(MockClient)
mockLister := new(MockNamespaceLister)
mockLister.On("ListNamespaces", mock.Anything).Return([]string{"default", "ns-other"}, nil)
// In ns-other a resource with the same name/GVK exists but is NOT managed
// by kubemirror — pretend it's a regular Secret created by another operator.
otherSecret := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "shared-name",
"namespace": "ns-other",
},
},
}
mockClient.On("Get", mock.Anything,
types.NamespacedName{Namespace: "ns-other", Name: "shared-name"},
mock.AnythingOfType("*unstructured.Unstructured")).
Return(nil, otherSecret)
r := &SourceReconciler{Client: mockClient, NamespaceLister: mockLister}
err := r.deleteAllMirrors(context.Background(), sourceObj)
require.NoError(t, err)
// The critical assertion: Delete was NEVER called.
mockClient.AssertNotCalled(t, "Delete", mock.Anything, mock.Anything, mock.Anything)
mockLister.AssertExpectations(t)
}
func TestSourceReconciler_deleteAllMirrors_aggregatesErrors(t *testing.T) {
// Regression test: per-namespace deletion failures must be returned (joined),
// otherwise callers will remove the finalizer and orphan the failed mirrors.
sourceObj := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "test-secret",
"namespace": "default",
"uid": "source-uid",
},
},
}
managedMirror := func(ns string) *unstructured.Unstructured {
return &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "test-secret",
"namespace": ns,
"labels": map[string]interface{}{
constants.LabelManagedBy: constants.ControllerName,
constants.LabelMirror: "true",
},
"annotations": map[string]interface{}{
constants.AnnotationSourceNamespace: "default",
constants.AnnotationSourceName: "test-secret",
constants.AnnotationSourceUID: "source-uid",
},
},
},
}
}
mockClient := new(MockClient)
mockLister := new(MockNamespaceLister)
mockLister.On("ListNamespaces", mock.Anything).Return([]string{"default", "ns-ok", "ns-fail"}, nil)
mockClient.On("Get", mock.Anything,
types.NamespacedName{Namespace: "ns-ok", Name: "test-secret"},
mock.AnythingOfType("*unstructured.Unstructured")).
Return(nil, managedMirror("ns-ok"))
mockClient.On("Get", mock.Anything,
types.NamespacedName{Namespace: "ns-fail", Name: "test-secret"},
mock.AnythingOfType("*unstructured.Unstructured")).
Return(nil, managedMirror("ns-fail"))
mockClient.On("Delete", mock.Anything, mock.MatchedBy(func(obj client.Object) bool {
u, ok := obj.(*unstructured.Unstructured)
return ok && u.GetNamespace() == "ns-ok"
}), mock.Anything).Return(nil)
mockClient.On("Delete", mock.Anything, mock.MatchedBy(func(obj client.Object) bool {
u, ok := obj.(*unstructured.Unstructured)
return ok && u.GetNamespace() == "ns-fail"
}), mock.Anything).Return(fmt.Errorf("webhook denied"))
r := &SourceReconciler{Client: mockClient, NamespaceLister: mockLister}
err := r.deleteAllMirrors(context.Background(), sourceObj)
require.Error(t, err, "must surface deletion failure so finalizer is retained")
assert.Contains(t, err.Error(), "ns-fail")
}
func TestIsBlacklistedSecret(t *testing.T) {
cases := []struct {
obj *unstructured.Unstructured
name string
expected bool
}{
{
name: "service-account-token blacklisted",
obj: &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "v1", "kind": "Secret",
"type": "kubernetes.io/service-account-token",
}},
expected: true,
},
{
name: "bootstrap token blacklisted",
obj: &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "v1", "kind": "Secret",
"type": "bootstrap.kubernetes.io/token",
}},
expected: true,
},
{
name: "helm release blacklisted",
obj: &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "v1", "kind": "Secret",
"type": "helm.sh/release.v1",
}},
expected: true,
},
{
name: "opaque secret allowed",
obj: &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "v1", "kind": "Secret",
"type": "Opaque",
}},
expected: false,
},
{
name: "secret without type allowed",
obj: &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "v1", "kind": "Secret",
}},
expected: false,
},
{
name: "configmap with matching type ignored",
obj: &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "v1", "kind": "ConfigMap",
"type": "kubernetes.io/service-account-token",
}},
expected: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, isBlacklistedSecret(tc.obj))
})
}
}
func TestSourceReconciler_Reconcile_RefusesBlacklistedSecret(t *testing.T) {
// Regression test: enabling mirroring on a service-account-token Secret
// must NOT cause it to be mirrored anywhere.
mockClient := new(MockClient)
mockLister := new(MockNamespaceLister)
tokenSecret := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "sa-token",
"namespace": "default",
"labels": map[string]interface{}{
constants.LabelEnabled: "true",
},
"annotations": map[string]interface{}{
constants.AnnotationSync: "true",
constants.AnnotationTargetNamespaces: "all",
},
},
"type": "kubernetes.io/service-account-token",
},
}
mockClient.On("Get", mock.Anything,
types.NamespacedName{Namespace: "default", Name: "sa-token"},
mock.AnythingOfType("*unstructured.Unstructured")).
Return(nil, tokenSecret)
r := &SourceReconciler{
Client: mockClient,
Scheme: runtime.NewScheme(),
Config: &config.Config{},
Filter: filter.NewNamespaceFilter([]string{}, []string{}),
NamespaceLister: mockLister,
GVK: schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"},
}
result, err := r.Reconcile(context.Background(),
ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "sa-token"}})
require.NoError(t, err)
assert.Equal(t, ctrl.Result{}, result)
// Critical: no namespace listing, no Create, no Update — the Secret was
// rejected before anything mirroring-related happened.
mockLister.AssertNotCalled(t, "ListNamespacesWithLabels", mock.Anything)
mockClient.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything)
}
func TestSourceReconciler_updateLastSyncStatus_skipsWhenUnchanged(t *testing.T) {
// Regression test: re-running with the same reconciled/error counts must
// NOT issue an Update call — otherwise every successful reconcile bumps
// resourceVersion, fires a watch event, and re-enters Reconcile in a loop.
mockClient := new(MockClient)
source := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "test",
"namespace": "default",
"annotations": map[string]interface{}{
constants.AnnotationSyncStatus: "reconciled:3,errors:0",
},
},
},
}
r := &SourceReconciler{Client: mockClient}
err := r.updateLastSyncStatus(context.Background(), source, source, 3, 0)
require.NoError(t, err)
mockClient.AssertNotCalled(t, "Update", mock.Anything, mock.Anything, mock.Anything)
}
func TestSourceReconciler_updateLastSyncStatus_writesWhenChanged(t *testing.T) {
mockClient := new(MockClient)
mockClient.On("Update", mock.Anything, mock.AnythingOfType("*unstructured.Unstructured"), mock.Anything).Return(nil)
source := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "test",
"namespace": "default",
"annotations": map[string]interface{}{
constants.AnnotationSyncStatus: "reconciled:2,errors:0",
},
},
},
}
r := &SourceReconciler{Client: mockClient}
err := r.updateLastSyncStatus(context.Background(), source, source, 3, 0)
require.NoError(t, err)
assert.Equal(t, "reconciled:3,errors:0", source.GetAnnotations()[constants.AnnotationSyncStatus])
mockClient.AssertCalled(t, "Update", mock.Anything, mock.Anything, mock.Anything)
}