Ctrl K
ComunidadGuías…

api/handler/resource_deletion_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package handler

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"testing"
	"time"

	"github.com/goodrain/rainbond/api/model"
	"github.com/goodrain/rainbond/db"
	"github.com/goodrain/rainbond/db/dao"
	dbmodel "github.com/goodrain/rainbond/db/model"
	apierrors "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"
	"k8s.io/client-go/dynamic"
	dynamicfake "k8s.io/client-go/dynamic/fake"
	ktesting "k8s.io/client-go/testing"
)

var (
	testCRDGVR        = schema.GroupVersionResource{Group: "apiextensions.k8s.io", Version: "v1", Resource: "customresourcedefinitions"}
	testWidgetGVR     = schema.GroupVersionResource{Group: "example.com", Version: "v1", Resource: "widgets"}
	testReportGVR     = schema.GroupVersionResource{Group: "aquasecurity.github.io", Version: "v1alpha1", Resource: "clusterconfigauditreports"}
	testConfigGVR     = schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}
	testDeploymentGVR = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}
)

// capability_id: rainbond.k8s-resource.response-array-contract
func TestK8sResourceDeletionResponseArrays(t *testing.T) {
	for _, test := range []struct {
		name     string
		kind     string
		instance *unstructured.Unstructured
	}{
		{name: "empty selection"},
		{name: "ordinary ConfigMap", kind: "ConfigMap"},
		{name: "CRD without instances", kind: "CustomResourceDefinition"},
		{name: "CRD with current application instance", kind: "CustomResourceDefinition", instance: newTestWidget("owned", "team-a", "app-a")},
		{name: "CRD with another application instance", kind: "CustomResourceDefinition", instance: newTestWidget("shared", "team-b", "app-b")},
	} {
		t.Run(test.name, func(t *testing.T) {
			var objects []*unstructured.Unstructured
			if test.instance != nil {
				objects = append(objects, test.instance)
			}
			orchestrator, client := newDeletionTestOrchestrator(t, objects...)
			req := &model.K8sResourceDeletionRequest{AppID: "app-a"}
			wantIDs := []string{}
			wantCRDs := []model.CRDDeletionImpact{}
			switch test.kind {
			case "ConfigMap":
				orchestrator.mapper.(*meta.DefaultRESTMapper).Add(schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"}, meta.RESTScopeNamespace)
				configMap := &unstructured.Unstructured{Object: map[string]interface{}{
					"apiVersion": "v1", "kind": "ConfigMap",
					"metadata": map[string]interface{}{"name": "settings", "namespace": "team-a"},
				}}
				if err := client.Tracker().Add(configMap); err != nil {
					t.Fatal(err)
				}
				req.K8sResources = []model.HandleResource{{
					ClientID: "config-row", AppID: "app-a", Namespace: "team-a", Name: "settings", Kind: "ConfigMap",
					ResourceYaml: "apiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: settings\n  namespace: team-a\n", State: model.CreateSuccess,
				}}
				wantIDs = []string{"config-row"}
			case "CustomResourceDefinition":
				req = newCRDDeletionRequest(true)
				wantIDs = []string{"crd-row"}
				wantCRDs = []model.CRDDeletionImpact{{
					Name: "widgets.example.com", Group: "example.com", Version: "v1", Kind: "Widget", Plural: "widgets", Scope: "Namespaced",
					AffectedRegionAppIDs: []string{},
				}}
				if test.instance != nil {
					if test.instance.GetLabels()[appIDLabel] == req.AppID {
						wantCRDs[0].CurrentAppCRCount = 1
					} else {
						wantCRDs[0].OtherAppCRCount = 1
						wantCRDs[0].AffectedRegionAppIDs = []string{"app-b"}
					}
				}
			}
			impact, err := orchestrator.Preview(context.Background(), req)
			if err != nil {
				t.Fatalf("Preview() error = %v", err)
			}
			assertResourceResponseJSONField(t, impact, "crds", wantCRDs)
			for _, action := range client.Actions() {
				if action.GetVerb() == "delete" {
					t.Fatalf("Preview() mutated Kubernetes: %#v", action)
				}
			}
			result, err := orchestrator.Delete(context.Background(), req)
			if err != nil {
				t.Fatalf("Delete() error = %v", err)
			}
			assertResourceResponseJSONField(t, result, "status", "completed")
			assertResourceResponseJSONField(t, result, "deleted_client_ids", wantIDs)
			assertResourceResponseJSONField(t, result, "cascaded_crds", wantCRDs)
		})
	}
}

func TestK8sResourceReconcileResponseArrays(t *testing.T) {
	lookupErr := apierrors.NewForbidden(testWidgetGVR.GroupResource(), "unknown", errors.New("denied"))
	for _, test := range []struct {
		name        string
		names       []string
		wantMissing []string
		wantUnknown []model.K8sResourceUnknown
	}{
		{name: "empty selection", wantMissing: []string{}, wantUnknown: []model.K8sResourceUnknown{}},
		{name: "all present", names: []string{"present"}, wantMissing: []string{}, wantUnknown: []model.K8sResourceUnknown{}},
		{name: "missing", names: []string{"missing"}, wantMissing: []string{"missing"}, wantUnknown: []model.K8sResourceUnknown{}},
		{name: "unknown", names: []string{"unknown"}, wantMissing: []string{}, wantUnknown: []model.K8sResourceUnknown{{ClientID: "unknown", Error: lookupErr.Error()}}},
		{name: "mixed", names: []string{"present", "missing", "unknown"}, wantMissing: []string{"missing"}, wantUnknown: []model.K8sResourceUnknown{{ClientID: "unknown", Error: lookupErr.Error()}}},
	} {
		t.Run(test.name, func(t *testing.T) {
			orchestrator, client := newDeletionTestOrchestrator(t, newTestWidget("present", "team-a", "app-a"))
			client.PrependReactor("get", "widgets", func(action ktesting.Action) (bool, runtime.Object, error) {
				if action.(ktesting.GetAction).GetName() == "unknown" {
					return true, nil, lookupErr
				}
				return false, nil, nil
			})
			req := &model.K8sResourceReconcileRequest{AppID: "app-a"}
			for _, name := range test.names {
				req.K8sResources = append(req.K8sResources, model.HandleResource{
					ClientID: name, Namespace: "team-a", Name: name, Kind: "Widget", ResourceYaml: testWidgetYAML(name, "team-a"), State: model.CreateSuccess,
				})
			}
			result := orchestrator.Reconcile(context.Background(), req)
			assertResourceResponseJSONField(t, result, "missing_client_ids", test.wantMissing)
			assertResourceResponseJSONField(t, result, "unknown", test.wantUnknown)
		})
	}
}

func assertResourceResponseJSONField(t *testing.T, response interface{}, field string, want interface{}) {
	t.Helper()
	data, err := json.Marshal(response)
	if err != nil {
		t.Fatalf("marshal response: %v", err)
	}
	var fields map[string]json.RawMessage
	if err := json.Unmarshal(data, &fields); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	expected, err := json.Marshal(want)
	if err != nil {
		t.Fatalf("marshal expected %s: %v", field, err)
	}
	if string(fields[field]) != string(expected) {
		t.Errorf("response %s = %s, want %s", field, fields[field], expected)
	}
}

func TestK8sResourceDeletionPreviewRequiresCascadeForOtherApplications(t *testing.T) {
	orchestrator, _ := newDeletionTestOrchestrator(t,
		newTestWidget("owned", "team-a", "app-a"),
		newTestWidget("shared", "team-b", "app-b"),
		newTestWidget("unowned", "team-c", ""),
	)

	impact, err := orchestrator.Preview(context.Background(), newCRDDeletionRequest(false))
	if err != nil {
		t.Fatalf("Preview() error = %v", err)
	}
	if !impact.HasCRD || !impact.RequiresCascade {
		t.Fatalf("Preview() impact = %#v, want CRD requiring cascade", impact)
	}
	if impact.CRDCount != 1 || impact.CRCount != 3 || impact.OtherAppCount != 1 || impact.UnownedCRCount != 1 {
		t.Fatalf("Preview() counts = %#v", impact)
	}
	crd := impact.CRDs[0]
	if crd.CurrentAppCRCount != 1 || crd.OtherAppCRCount != 1 || crd.UnownedCRCount != 1 {
		t.Fatalf("Preview() CRD counts = %#v", crd)
	}
	if len(crd.AffectedRegionAppIDs) != 1 || crd.AffectedRegionAppIDs[0] != "app-b" {
		t.Fatalf("Preview() affected apps = %v, want [app-b]", crd.AffectedRegionAppIDs)
	}
}

func TestK8sResourceDeletionBlocksCascadeBeforeMutation(t *testing.T) {
	orchestrator, client := newDeletionTestOrchestrator(t, newTestWidget("shared", "team-b", "app-b"))

	_, err := orchestrator.Delete(context.Background(), newCRDDeletionRequest(false))
	if !errors.Is(err, ErrCRDCascadeConfirmationRequired) {
		t.Fatalf("Delete() error = %v, want ErrCRDCascadeConfirmationRequired", err)
	}
	for _, action := range client.Actions() {
		if action.GetVerb() == "delete" {
			t.Fatalf("Delete() mutated Kubernetes before confirmation: %#v", action)
		}
	}
}

// capability_id: rainbond.k8s-resource.failed-delete-metadata-only
func TestK8sResourceDeletionSkipsFailedResourceKubernetesDeletion(t *testing.T) {
	orchestrator, client := newDeletionTestOrchestrator(t)
	req := newCRDDeletionRequest(false)
	req.K8sResources[0].State = model.CreateError

	result, err := orchestrator.Delete(context.Background(), req)
	if err != nil {
		t.Fatalf("Delete() error = %v", err)
	}
	if result.Status != "completed" || len(result.DeletedClientIDs) != 1 || result.DeletedClientIDs[0] != "crd-row" {
		t.Fatalf("Delete() result = %#v, want metadata-only completion", result)
	}
	for _, action := range client.Actions() {
		if action.GetVerb() == "delete" {
			t.Fatalf("Delete() mutated Kubernetes for failed resource: %#v", action)
		}
	}
}

// capability_id: rainbond.k8s-resource.crd-cascade-delete
func TestK8sResourceDeletionDeletesCustomResourcesBeforeDefinition(t *testing.T) {
	orchestrator, client := newDeletionTestOrchestrator(t,
		newTestWidget("owned", "team-a", "app-a"),
		newTestWidget("shared", "team-b", "app-b"),
	)

	result, err := orchestrator.Delete(context.Background(), newCRDDeletionRequest(true))
	if err != nil {
		t.Fatalf("Delete() error = %v", err)
	}
	if result.Status != "completed" || len(result.CascadedCRDs) != 1 {
		t.Fatalf("Delete() result = %#v", result)
	}

	var deletedResources []string
	for _, action := range client.Actions() {
		if action.GetVerb() == "delete" {
			deletedResources = append(deletedResources, action.GetResource().Resource)
		}
	}
	if len(deletedResources) != 3 {
		t.Fatalf("delete actions = %v, want two CRs and one CRD", deletedResources)
	}
	if deletedResources[0] != "widgets" || deletedResources[1] != "widgets" || deletedResources[2] != "customresourcedefinitions" {
		t.Fatalf("delete order = %v, want CRs before CRD", deletedResources)
	}
}

func TestK8sResourceDeletionDoesNotDeleteDefinitionWhenCustomResourceDeleteFails(t *testing.T) {
	orchestrator, client := newDeletionTestOrchestrator(t, newTestWidget("blocked", "team-a", "app-a"))
	client.PrependReactor("delete", "widgets", func(action ktesting.Action) (bool, runtime.Object, error) {
		return true, nil, fmt.Errorf("webhook unavailable")
	})

	_, err := orchestrator.Delete(context.Background(), newCRDDeletionRequest(false))
	if err == nil {
		t.Fatal("Delete() error = nil, want CR deletion failure")
	}
	for _, action := range client.Actions() {
		if action.GetVerb() == "delete" && action.GetResource() == testCRDGVR {
			t.Fatalf("Delete() removed CRD after CR deletion failure: %#v", action)
		}
	}
}

// capability_id: rainbond.k8s-resource.deletion-timeout-final-check
func TestK8sResourceDeletionFinalCheckAvoidsTimeoutRace(t *testing.T) {
	orchestrator, client := newDeletionTestOrchestrator(t, newTestWidget("owned", "team-a", "app-a"))
	orchestrator.waitInterval = 100 * time.Millisecond
	orchestrator.waitTimeout = time.Millisecond

	listCalls := 0
	client.PrependReactor("list", "widgets", func(action ktesting.Action) (bool, runtime.Object, error) {
		listCalls++
		list := &unstructured.UnstructuredList{}
		list.SetGroupVersionKind(schema.GroupVersionKind{Group: "example.com", Version: "v1", Kind: "WidgetList"})
		if listCalls <= 2 {
			list.Items = []unstructured.Unstructured{*newTestWidget("owned", "team-a", "app-a")}
		}
		return true, list, nil
	})

	result, err := orchestrator.Delete(context.Background(), newCRDDeletionRequest(false))
	if err != nil {
		t.Fatalf("Delete() error = %v, want final live check to confirm deletion", err)
	}
	if result.Status != "completed" {
		t.Fatalf("Delete() result = %#v, want completed", result)
	}
	if listCalls != 3 {
		t.Fatalf("widget list calls = %d, want build, poll, and final check", listCalls)
	}
}

func TestK8sResourceDeletionRejectsInvalidResourceBeforeMutation(t *testing.T) {
	orchestrator, client := newDeletionTestOrchestrator(t)
	req := &model.K8sResourceDeletionRequest{
		AppID: "app-a",
		K8sResources: []model.HandleResource{{
			ClientID:     "bad",
			Name:         "bad",
			Kind:         "Widget",
			ResourceYaml: "metadata:\n  name: bad\n",
			State:        model.CreateSuccess,
		}},
	}

	_, err := orchestrator.Delete(context.Background(), req)
	if !errors.Is(err, ErrInvalidK8sResourceDeletionRequest) {
		t.Fatalf("Delete() error = %v, want ErrInvalidK8sResourceDeletionRequest", err)
	}
	for _, action := range client.Actions() {
		if action.GetVerb() == "delete" {
			t.Fatalf("Delete() mutated Kubernetes for invalid input: %#v", action)
		}
	}
}

func TestK8sResourceDeletionDeletesAndConfirmsOrdinaryResource(t *testing.T) {
	configMap := &unstructured.Unstructured{Object: map[string]interface{}{
		"apiVersion": "v1",
		"kind":       "ConfigMap",
		"metadata": map[string]interface{}{
			"name":      "settings",
			"namespace": "team-a",
		},
	}}
	client := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(
		runtime.NewScheme(),
		map[schema.GroupVersionResource]string{testConfigGVR: "ConfigMapList"},
		configMap,
	)
	mapper := meta.NewDefaultRESTMapper([]schema.GroupVersion{{Version: "v1"}})
	mapper.Add(schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"}, meta.RESTScopeNamespace)
	orchestrator := &k8sResourceDeletionOrchestrator{
		dynamicClient: client,
		mapper:        mapper,
		refreshMapper: func() (meta.RESTMapper, error) { return mapper, nil },
		waitInterval:  time.Millisecond,
		waitTimeout:   50 * time.Millisecond,
	}

	result, err := orchestrator.Delete(context.Background(), &model.K8sResourceDeletionRequest{
		AppID: "app-a",
		K8sResources: []model.HandleResource{{
			ClientID:     "config-row",
			AppID:        "app-a",
			Namespace:    "team-a",
			Name:         "settings",
			Kind:         "ConfigMap",
			ResourceYaml: "apiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: settings\n  namespace: team-a\n",
			State:        model.CreateSuccess,
		}},
	})
	if err != nil {
		t.Fatalf("Delete() error = %v", err)
	}
	if len(result.DeletedClientIDs) != 1 || result.DeletedClientIDs[0] != "config-row" {
		t.Fatalf("Delete() result = %#v", result)
	}
	if _, err := client.Resource(testConfigGVR).Namespace("team-a").Get(context.Background(), "settings", metav1.GetOptions{}); !apierrors.IsNotFound(err) {
		t.Fatalf("ConfigMap still exists, Get() error = %v", err)
	}
}

// capability_id: rainbond.k8s-resource.stop-recreating-controller
func TestK8sResourceDeletionStopsManagedControllerBeforeGeneratedResources(t *testing.T) {
	orchestrator, client := newRecreatingControllerTestOrchestrator(t, false)
	client.PrependReactor("delete", testReportGVR.Resource, func(action ktesting.Action) (bool, runtime.Object, error) {
		deleteAction := action.(ktesting.DeleteAction)
		if err := client.Tracker().Delete(testReportGVR, "", deleteAction.GetName()); err != nil {
			return true, nil, err
		}
		if _, err := client.Tracker().Get(testDeploymentGVR, "rbd-plugins", "trivy-operator"); err == nil {
			recreated := newTestGeneratedReport("recreated-report", false)
			if err := client.Tracker().Add(recreated); err != nil {
				return true, nil, err
			}
		}
		return true, nil, nil
	})

	result, err := orchestrator.Delete(context.Background(), newGeneratedReportDeletionRequest())
	if err != nil {
		t.Fatalf("Delete() error = %v", err)
	}
	if result.Status != "completed" {
		t.Fatalf("Delete() result = %#v, want completed", result)
	}

	var deletedResources []string
	for _, action := range client.Actions() {
		if action.GetVerb() == "delete" {
			deletedResources = append(deletedResources, action.GetResource().Resource)
		}
	}
	want := []string{"deployments", "clusterconfigauditreports", "customresourcedefinitions"}
	if fmt.Sprint(deletedResources) != fmt.Sprint(want) {
		t.Fatalf("delete order = %v, want %v", deletedResources, want)
	}
}

func TestK8sResourceDeletionKeepsControllerUntilFinalizedResourcesAreGone(t *testing.T) {
	orchestrator, client := newRecreatingControllerTestOrchestrator(t, true)

	result, err := orchestrator.Delete(context.Background(), newGeneratedReportDeletionRequest())
	if err != nil {
		t.Fatalf("Delete() error = %v", err)
	}
	if result.Status != "completed" {
		t.Fatalf("Delete() result = %#v, want completed", result)
	}

	var deletedResources []string
	for _, action := range client.Actions() {
		if action.GetVerb() == "delete" {
			deletedResources = append(deletedResources, action.GetResource().Resource)
		}
	}
	want := []string{"clusterconfigauditreports", "deployments", "customresourcedefinitions"}
	if fmt.Sprint(deletedResources) != fmt.Sprint(want) {
		t.Fatalf("delete order = %v, want %v", deletedResources, want)
	}
}

// capability_id: rainbond.k8s-resource.metadata-reconcile
func TestK8sResourceReconcileDistinguishesMissingAndUnknown(t *testing.T) {
	orchestrator, client := newDeletionTestOrchestrator(t, newTestWidget("present", "team-a", "app-a"))
	client.PrependReactor("get", "widgets", func(action ktesting.Action) (bool, runtime.Object, error) {
		getAction := action.(ktesting.GetAction)
		if getAction.GetName() == "unknown" {
			return true, nil, apierrors.NewForbidden(
				schema.GroupResource{Group: testWidgetGVR.Group, Resource: testWidgetGVR.Resource},
				getAction.GetName(),
				fmt.Errorf("denied"),
			)
		}
		return false, nil, nil
	})

	result := orchestrator.Reconcile(context.Background(), &model.K8sResourceReconcileRequest{
		AppID: "app-a",
		K8sResources: []model.HandleResource{
			{ClientID: "1", Namespace: "team-a", Name: "present", Kind: "Widget", ResourceYaml: testWidgetYAML("present", "team-a"), State: model.CreateSuccess},
			{ClientID: "2", Namespace: "team-a", Name: "missing", Kind: "Widget", ResourceYaml: testWidgetYAML("missing", "team-a"), State: model.CreateSuccess},
			{ClientID: "3", Namespace: "team-a", Name: "unknown", Kind: "Widget", ResourceYaml: testWidgetYAML("unknown", "team-a"), State: model.CreateSuccess},
		},
	})

	if len(result.MissingClientIDs) != 1 || result.MissingClientIDs[0] != "2" {
		t.Fatalf("Reconcile() missing = %v, want [2]", result.MissingClientIDs)
	}
	if len(result.Unknown) != 1 || result.Unknown[0].ClientID != "3" {
		t.Fatalf("Reconcile() unknown = %#v, want client 3", result.Unknown)
	}
}

func TestK8sResourceReconcileTreatsConfirmedNoMatchAsMissing(t *testing.T) {
	client := newDeletionTestDynamicClient(t)
	mapper := meta.NewDefaultRESTMapper(nil)
	orchestrator := &k8sResourceDeletionOrchestrator{
		dynamicClient: client,
		mapper:        mapper,
		refreshMapper: func() (meta.RESTMapper, error) {
			return meta.NewDefaultRESTMapper(nil), nil
		},
		waitInterval: time.Millisecond,
		waitTimeout:  50 * time.Millisecond,
	}

	result := orchestrator.Reconcile(context.Background(), &model.K8sResourceReconcileRequest{
		AppID: "app-a",
		K8sResources: []model.HandleResource{{
			ClientID:     "gone-api",
			Namespace:    "team-a",
			Name:         "missing",
			Kind:         "Widget",
			ResourceYaml: testWidgetYAML("missing", "team-a"),
			State:        model.CreateSuccess,
		}},
	})
	if len(result.MissingClientIDs) != 1 || result.MissingClientIDs[0] != "gone-api" {
		t.Fatalf("Reconcile() missing = %v, want confirmed removed API", result.MissingClientIDs)
	}
}

func TestK8sResourceMetadataMatchesCRDByGroupAndKind(t *testing.T) {
	resources := []dbmodel.K8sResource{
		{Model: dbmodel.Model{ID: 1}, Kind: "Widget", Content: testWidgetYAML("one", "team-a")},
		{Model: dbmodel.Model{ID: 2}, Kind: "Widget", Content: "apiVersion: other.example.com/v1\nkind: Widget\nmetadata:\n  name: two\n"},
		{Model: dbmodel.Model{ID: 3}, Kind: "Other", Content: "apiVersion: example.com/v1\nkind: Other\nmetadata:\n  name: three\n"},
		{Model: dbmodel.Model{ID: 4}, Kind: "Widget", Content: ": invalid"},
	}
	impacts := []model.CRDDeletionImpact{{Group: "example.com", Kind: "Widget"}}

	ids := matchingCRDMetadataIDs(resources, impacts)
	if len(ids) != 1 || ids[0] != 1 {
		t.Fatalf("matchingCRDMetadataIDs() = %v, want [1]", ids)
	}
}

func TestCleanupDeletedResourceMetadataIncludesSelectedAndCascadedRows(t *testing.T) {
	k8sDAO := &deletionTestK8sResourceDAO{
		byApp: []dbmodel.K8sResource{
			{Model: dbmodel.Model{ID: 10}, AppID: "app-a", Name: "widgets.example.com", Kind: "CustomResourceDefinition", Content: testCRDYAML()},
			{Model: dbmodel.Model{ID: 11}, AppID: "app-a", Name: "keep", Kind: "ConfigMap", Content: "apiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: keep\n"},
		},
		byKind: []dbmodel.K8sResource{
			{Model: dbmodel.Model{ID: 20}, AppID: "app-b", Name: "shared", Kind: "Widget", Content: testWidgetYAML("shared", "team-b")},
			{Model: dbmodel.Model{ID: 21}, AppID: "app-c", Name: "other", Kind: "Widget", Content: "apiVersion: other.example.com/v1\nkind: Widget\nmetadata:\n  name: other\n"},
		},
	}
	db.SetTestManager(deletionTestDBManager{k8sDAO: k8sDAO})
	defer db.SetTestManager(nil)

	err := cleanupDeletedResourceMetadata(newCRDDeletionRequest(true), &model.K8sResourceDeletionResult{
		CascadedCRDs: []model.CRDDeletionImpact{{Group: "example.com", Kind: "Widget"}},
	})
	if err != nil {
		t.Fatalf("cleanupDeletedResourceMetadata() error = %v", err)
	}
	if len(k8sDAO.deletedIDs) != 2 || k8sDAO.deletedIDs[0] != 10 || k8sDAO.deletedIDs[1] != 20 {
		t.Fatalf("cleanupDeletedResourceMetadata() deleted = %v, want [10 20]", k8sDAO.deletedIDs)
	}
}

func TestClusterActionReconcileCleansOnlyConfirmedMissingRegionMetadata(t *testing.T) {
	orchestrator, _ := newDeletionTestOrchestrator(t)
	k8sDAO := &deletionTestK8sResourceDAO{}
	db.SetTestManager(deletionTestDBManager{k8sDAO: k8sDAO})
	defer db.SetTestManager(nil)
	action := &clusterAction{dynamicClient: orchestrator.dynamicClient, mapper: orchestrator.mapper}

	result, handleErr := action.ReconcileK8SResources(context.Background(), &model.K8sResourceReconcileRequest{
		AppID: "app-a",
		K8sResources: []model.HandleResource{{
			ClientID:     "missing-row",
			Name:         "missing",
			Kind:         "Widget",
			Namespace:    "team-a",
			ResourceYaml: testWidgetYAML("missing", "team-a"),
			State:        model.CreateSuccess,
		}},
	})
	if handleErr != nil {
		t.Fatalf("ReconcileK8SResources() error = %v", handleErr)
	}
	if len(result.MissingClientIDs) != 1 || len(k8sDAO.deletedResources) != 1 {
		t.Fatalf("ReconcileK8SResources() result = %#v, deleted = %v", result, k8sDAO.deletedResources)
	}
	if k8sDAO.deletedResources[0] != "app-a/missing/Widget" {
		t.Fatalf("deleted metadata = %v", k8sDAO.deletedResources)
	}
}

func newDeletionTestOrchestrator(t *testing.T, widgets ...*unstructured.Unstructured) (*k8sResourceDeletionOrchestrator, *dynamicfake.FakeDynamicClient) {
	t.Helper()
	objects := []runtime.Object{newTestCRD()}
	for _, widget := range widgets {
		objects = append(objects, widget)
	}
	client := newDeletionTestDynamicClient(t, objects...)
	mapper := meta.NewDefaultRESTMapper([]schema.GroupVersion{
		{Group: "apiextensions.k8s.io", Version: "v1"},
		{Group: "example.com", Version: "v1"},
	})
	mapper.Add(schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"}, meta.RESTScopeRoot)
	mapper.Add(schema.GroupVersionKind{Group: "example.com", Version: "v1", Kind: "Widget"}, meta.RESTScopeNamespace)
	orchestrator := &k8sResourceDeletionOrchestrator{
		dynamicClient: client,
		mapper:        mapper,
		refreshMapper: func() (meta.RESTMapper, error) { return mapper, nil },
		waitInterval:  time.Millisecond,
		waitTimeout:   100 * time.Millisecond,
	}
	return orchestrator, client
}

func newDeletionTestDynamicClient(t *testing.T, objects ...runtime.Object) *dynamicfake.FakeDynamicClient {
	t.Helper()
	client := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(
		runtime.NewScheme(),
		map[schema.GroupVersionResource]string{
			testCRDGVR:    "CustomResourceDefinitionList",
			testWidgetGVR: "WidgetList",
		},
		objects...,
	)
	return client
}

func newRecreatingControllerTestOrchestrator(t *testing.T, finalized bool) (*k8sResourceDeletionOrchestrator, *dynamicfake.FakeDynamicClient) {
	t.Helper()
	client := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(
		runtime.NewScheme(),
		map[schema.GroupVersionResource]string{
			testCRDGVR:        "CustomResourceDefinitionList",
			testReportGVR:     "ClusterConfigAuditReportList",
			testDeploymentGVR: "DeploymentList",
		},
		newTestGeneratedReportCRD(),
		newTestGeneratedReport("persistentvolume-data", finalized),
		newTestControllerDeployment(),
	)
	mapper := meta.NewDefaultRESTMapper([]schema.GroupVersion{
		{Group: "apiextensions.k8s.io", Version: "v1"},
		{Group: "aquasecurity.github.io", Version: "v1alpha1"},
		{Group: "apps", Version: "v1"},
	})
	mapper.Add(schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"}, meta.RESTScopeRoot)
	mapper.Add(schema.GroupVersionKind{Group: "aquasecurity.github.io", Version: "v1alpha1", Kind: "ClusterConfigAuditReport"}, meta.RESTScopeRoot)
	mapper.Add(schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}, meta.RESTScopeNamespace)
	orchestrator := &k8sResourceDeletionOrchestrator{
		dynamicClient: client,
		mapper:        mapper,
		refreshMapper: func() (meta.RESTMapper, error) { return mapper, nil },
		waitInterval:  time.Millisecond,
		waitTimeout:   100 * time.Millisecond,
	}
	return orchestrator, client
}

func newTestGeneratedReportCRD() *unstructured.Unstructured {
	return &unstructured.Unstructured{Object: map[string]interface{}{
		"apiVersion": "apiextensions.k8s.io/v1",
		"kind":       "CustomResourceDefinition",
		"metadata": map[string]interface{}{
			"name": "clusterconfigauditreports.aquasecurity.github.io",
		},
		"spec": map[string]interface{}{
			"group": "aquasecurity.github.io",
			"scope": "Cluster",
			"names": map[string]interface{}{
				"kind":   "ClusterConfigAuditReport",
				"plural": "clusterconfigauditreports",
			},
			"versions": []interface{}{
				map[string]interface{}{"name": "v1alpha1", "served": true, "storage": true},
			},
		},
	}}
}

func newTestGeneratedReport(name string, finalized bool) *unstructured.Unstructured {
	metadata := map[string]interface{}{
		"name": name,
		"labels": map[string]interface{}{
			"app.kubernetes.io/managed-by": "trivy-operator",
		},
	}
	if finalized {
		metadata["finalizers"] = []interface{}{"aquasecurity.github.io/finalizer"}
	}
	return &unstructured.Unstructured{Object: map[string]interface{}{
		"apiVersion": "aquasecurity.github.io/v1alpha1",
		"kind":       "ClusterConfigAuditReport",
		"metadata":   metadata,
	}}
}

func newTestControllerDeployment() *unstructured.Unstructured {
	return &unstructured.Unstructured{Object: map[string]interface{}{
		"apiVersion": "apps/v1",
		"kind":       "Deployment",
		"metadata": map[string]interface{}{
			"name":      "trivy-operator",
			"namespace": "rbd-plugins",
		},
	}}
}

func newGeneratedReportDeletionRequest() *model.K8sResourceDeletionRequest {
	return &model.K8sResourceDeletionRequest{
		AppID:      "security-app",
		CascadeCRD: true,
		K8sResources: []model.HandleResource{
			{
				ClientID:     "report-crd-row",
				AppID:        "security-app",
				Name:         "clusterconfigauditreports.aquasecurity.github.io",
				Kind:         "CustomResourceDefinition",
				ResourceYaml: generatedReportCRDYAML(),
				State:        model.CreateSuccess,
			},
			{
				ClientID:     "operator-row",
				AppID:        "security-app",
				Namespace:    "rbd-plugins",
				Name:         "trivy-operator",
				Kind:         "Deployment",
				ResourceYaml: "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: trivy-operator\n  namespace: rbd-plugins\n",
				State:        model.CreateSuccess,
			},
		},
	}
}

func generatedReportCRDYAML() string {
	return `apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: clusterconfigauditreports.aquasecurity.github.io
spec:
  group: aquasecurity.github.io
  scope: Cluster
  names:
    kind: ClusterConfigAuditReport
    plural: clusterconfigauditreports
  versions:
  - name: v1alpha1
    served: true
    storage: true
`
}

func newTestCRD() *unstructured.Unstructured {
	return &unstructured.Unstructured{Object: map[string]interface{}{
		"apiVersion": "apiextensions.k8s.io/v1",
		"kind":       "CustomResourceDefinition",
		"metadata": map[string]interface{}{
			"name": "widgets.example.com",
			"uid":  string(types.UID("crd-uid")),
		},
		"spec": map[string]interface{}{
			"group": "example.com",
			"scope": "Namespaced",
			"names": map[string]interface{}{
				"kind":   "Widget",
				"plural": "widgets",
			},
			"versions": []interface{}{
				map[string]interface{}{"name": "v1", "served": true, "storage": true},
			},
		},
	}}
}

func newTestWidget(name, namespace, appID string) *unstructured.Unstructured {
	labels := map[string]interface{}{}
	if appID != "" {
		labels["app_id"] = appID
	}
	return &unstructured.Unstructured{Object: map[string]interface{}{
		"apiVersion": "example.com/v1",
		"kind":       "Widget",
		"metadata": map[string]interface{}{
			"name":      name,
			"namespace": namespace,
			"labels":    labels,
		},
	}}
}

func newCRDDeletionRequest(cascade bool) *model.K8sResourceDeletionRequest {
	return &model.K8sResourceDeletionRequest{
		AppID:      "app-a",
		CascadeCRD: cascade,
		K8sResources: []model.HandleResource{{
			ClientID:     "crd-row",
			AppID:        "app-a",
			Name:         "widgets.example.com",
			Kind:         "CustomResourceDefinition",
			ResourceYaml: testCRDYAML(),
			State:        model.CreateSuccess,
		}},
	}
}

func testCRDYAML() string {
	return `apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: widgets.example.com
spec:
  group: example.com
  scope: Namespaced
  names:
    kind: Widget
    plural: widgets
  versions:
  - name: v1
    served: true
    storage: true
`
}

func testWidgetYAML(name, namespace string) string {
	return fmt.Sprintf(`apiVersion: example.com/v1
kind: Widget
metadata:
  name: %s
  namespace: %s
`, name, namespace)
}

// Compile-time checks keep the fake test dependencies honest across client-go upgrades.
var _ dynamic.Interface = (*dynamicfake.FakeDynamicClient)(nil)

type deletionTestDBManager struct {
	db.Manager
	k8sDAO dao.K8sResourceDao
}

func (m deletionTestDBManager) K8sResourceDao() dao.K8sResourceDao {
	return m.k8sDAO
}

type deletionTestK8sResourceDAO struct {
	dao.K8sResourceDao
	byApp            []dbmodel.K8sResource
	byKind           []dbmodel.K8sResource
	deletedIDs       []uint
	deletedResources []string
}

func (d *deletionTestK8sResourceDAO) ListByAppID(string) ([]dbmodel.K8sResource, error) {
	return d.byApp, nil
}

func (d *deletionTestK8sResourceDAO) ListByKind(string) ([]dbmodel.K8sResource, error) {
	return d.byKind, nil
}

func (d *deletionTestK8sResourceDAO) DeleteK8sResourceByIDs(ids []uint) error {
	d.deletedIDs = append(d.deletedIDs, ids...)
	return nil
}

func (d *deletionTestK8sResourceDAO) DeleteK8sResource(appID, name, kind string) error {
	d.deletedResources = append(d.deletedResources, appID+"/"+name+"/"+kind)
	return nil
}