From 329bc40555ce69d798092f46be9a51db115aaddb Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Wed, 22 Jul 2026 15:12:26 +0200 Subject: [PATCH 1/2] fix: look up ContainerProfile by with-container slug, not workload slug ContainerProfile objects are named using the with-container slug (InstanceID.GetSlug(false)), but HandleImageScanningScopedRequest and handlePodWatcher looked them up using the no-container slug (GetSlug(true), the ApplicationProfile convention), so the lookup always missed and every scheduled scan silently fell back to a plain image scan instead of a relevancy scan. Both call sites also deduped container-profile scans per workload instead of per container, which would have dropped every container after the first in multi-container workloads once the slug itself was corrected. GetContainerProfileScanCommand additionally built its scan command with CommandName: apis.TypeScanApplicationProfile, which has no case in runCommand's dispatch switch (it only recognizes utils.CommandScanContainerProfile) and would have been silently dropped even once a profile was correctly found. Fixes #395 Docs-exempt: pure bug fix restoring intended behavior, no new feature or API surface described in docs/; no existing doc (cel-admission-rules.md, node-agent-autoscaler.md) covers ContainerProfile scan dispatch. Co-Authored-By: Claude Sonnet 5 Signed-off-by: Matthias Bertschy --- mainhandler/handlerequests.go | 10 +- mainhandler/handlerequests_test.go | 179 +++++++++++++++++++++++++++++ utils/containerprofile.go | 2 +- watcher/podwatcher.go | 23 +--- watcher/podwatcher_test.go | 126 ++++++++++++++++++++ 5 files changed, 310 insertions(+), 30 deletions(-) diff --git a/mainhandler/handlerequests.go b/mainhandler/handlerequests.go index e77be0ae..b45e740f 100644 --- a/mainhandler/handlerequests.go +++ b/mainhandler/handlerequests.go @@ -426,12 +426,6 @@ func (mainHandler *MainHandler) HandleImageScanningScopedRequest(ctx context.Con continue } - noContainerSlug, _ := instanceID.GetSlug(true) - if ok := slugs[noContainerSlug]; ok { - // container profile already scanned for this workload, skip remaining containers - continue - } - // get container data containerData, err := utils.PodToContainerData(mainHandler.k8sAPI, pod, instanceID, mainHandler.config.ClusterName()) if err != nil { @@ -439,7 +433,7 @@ func (mainHandler *MainHandler) HandleImageScanningScopedRequest(ctx context.Con continue } - if profile := utils.GetContainerProfileForRelevancyScan(ctx, mainHandler.ksStorageClient, noContainerSlug, ns); profile != nil { + if profile := utils.GetContainerProfileForRelevancyScan(ctx, mainHandler.ksStorageClient, s, ns); profile != nil { cmd := utils.GetContainerProfileScanCommand(profile, pod) // send specific command to the channel @@ -451,7 +445,7 @@ func (mainHandler *MainHandler) HandleImageScanningScopedRequest(ctx context.Con continue } logger.L().Info("action completed successfully", helpers.String("name", profile.Name), helpers.String("namespace", profile.Namespace)) - slugs[noContainerSlug] = true + slugs[s] = true } else { // set scanning command cmd := &apis.Command{ diff --git a/mainhandler/handlerequests_test.go b/mainhandler/handlerequests_test.go index 64dba746..c89a60ab 100644 --- a/mainhandler/handlerequests_test.go +++ b/mainhandler/handlerequests_test.go @@ -1,8 +1,27 @@ package mainhandler import ( + "context" + "fmt" "strings" "testing" + "time" + + "github.com/armosec/armoapi-go/apis" + "github.com/armosec/armoapi-go/identifiers" + instanceidhandlerv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1" + helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" + "github.com/kubescape/operator/config" + "github.com/kubescape/operator/utils" + spdxv1beta1 "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + kssfake "github.com/kubescape/storage/pkg/generated/clientset/versioned/fake" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + k8sfake "k8s.io/client-go/kubernetes/fake" + clienttesting "k8s.io/client-go/testing" ) func TestCombineKubescapeCMDArgsWithFrameworkName(t *testing.T) { @@ -39,3 +58,163 @@ func TestCombineKubescapeCMDArgsWithFrameworkName(t *testing.T) { t.Errorf("invalid kubescape args str: %v", fullCMD) } } + +// nakedRunningPodForHandler builds a pod with no OwnerReferences and no pod-template-hash +// label, so utils.PodHasParent(pod) is false and GetParentIDForPod's +// CalculateWorkloadParentRecursive call short-circuits without touching a dynamic client. +// It has one running container per name in containerNames, each with a well-formed ImageID. +func nakedRunningPodForHandler(ns, name string, containerNames []string) *corev1.Pod { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: ns, + Name: name, + CreationTimestamp: metav1.NewTime(time.Now().Add(-2 * time.Hour)), + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + } + pod.APIVersion = "v1" + pod.Kind = "Pod" + + for i, cName := range containerNames { + image := fmt.Sprintf("docker.io/library/nginx:%d", i) + imageID := fmt.Sprintf("docker.io/library/nginx@sha256:%064d", i) + pod.Spec.Containers = append(pod.Spec.Containers, corev1.Container{ + Name: cName, + Image: image, + }) + pod.Status.ContainerStatuses = append(pod.Status.ContainerStatuses, corev1.ContainerStatus{ + Name: cName, + Image: image, + ImageID: imageID, + State: corev1.ContainerState{ + Running: &corev1.ContainerStateRunning{StartedAt: metav1.NewTime(time.Now().Add(-time.Hour))}, + }, + }) + } + return pod +} + +// withContainerSlugsForHandler computes the "with-container" slug (GetSlug(false)) for every +// container of pod, in the same way HandleImageScanningScopedRequest does. +func withContainerSlugsForHandler(t *testing.T, pod *corev1.Pod) []string { + t.Helper() + instanceIDs, err := instanceidhandlerv1.GenerateInstanceIDFromRuntimeObj(pod, nil) + require.NoError(t, err) + require.Len(t, instanceIDs, len(pod.Spec.Containers)) + + slugs := make([]string, 0, len(instanceIDs)) + for _, instanceID := range instanceIDs { + slug, err := instanceID.GetSlug(false) + require.NoError(t, err) + slugs = append(slugs, slug) + } + return slugs +} + +// containerProfileForHandler builds a ContainerProfile named with the given (with-container) +// slug, carrying annotations that satisfy utils.SkipContainerProfile so it is picked up by +// utils.GetContainerProfileForRelevancyScan. +func containerProfileForHandler(ns, slug string) *spdxv1beta1.ContainerProfile { + return &spdxv1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: ns, + Name: slug, + Annotations: map[string]string{ + helpersv1.StatusMetadataKey: helpersv1.Completed, + helpersv1.InstanceIDMetadataKey: slug, + helpersv1.WlidMetadataKey: "wlid://cluster-test/namespace-" + ns + "/deployment-test", + }, + }, + } +} + +// getContainerProfileActionNames returns the resource names of every "get containerprofiles" +// action recorded against the fake storage client. +func getContainerProfileActionNames(t *testing.T, storageClient interface{ Actions() []clienttesting.Action }) []string { + t.Helper() + var names []string + for _, action := range storageClient.Actions() { + if !action.Matches("get", "containerprofiles") { + continue + } + getAction, ok := action.(clienttesting.GetAction) + require.True(t, ok, "expected a GetAction, got %T", action) + names = append(names, getAction.GetName()) + } + return names +} + +// newMainHandlerForTest builds a MainHandler wired to fake Kubernetes and storage clientsets, +// with Kubevuln disabled (so any container-profile-found path that reaches +// actionHandler.scanContainerProfile returns a harmless error immediately, with no further +// network calls). +func newMainHandlerForTest(k8sClient *k8sfake.Clientset, storageClient *kssfake.Clientset) *MainHandler { + return &MainHandler{ + k8sAPI: utils.NewK8sInterfaceFake(k8sClient), + ksStorageClient: storageClient, + config: newTestConfig(config.Config{}), + } +} + +func sessionObjForNamespaceForHandler(ns string) *utils.SessionObj { + return &utils.SessionObj{ + Command: &apis.Command{ + CommandName: apis.TypeScanImages, + Designators: []identifiers.PortalDesignator{ + {Attributes: map[string]string{identifiers.AttributeNamespace: ns}}, + }, + }, + } +} + +// TestHandleImageScanningScopedRequest_LooksUpWithContainerSlug is a regression test: the +// ContainerProfile lookup must use the with-container slug (GetSlug(false)), matching how +// ContainerProfile objects are actually named. Before the fix, the code derived and looked up +// a separate no-container slug (GetSlug(true)), which always 404'd. +func TestHandleImageScanningScopedRequest_LooksUpWithContainerSlug(t *testing.T) { + ns := "default" + pod := nakedRunningPodForHandler(ns, "my-pod", []string{"nginx"}) + slugs := withContainerSlugsForHandler(t, pod) + require.Len(t, slugs, 1) + + k8sClient := k8sfake.NewSimpleClientset(pod) + storageClient := kssfake.NewSimpleClientset(containerProfileForHandler(ns, slugs[0])) + + mainHandler := newMainHandlerForTest(k8sClient, storageClient) + sessionObj := sessionObjForNamespaceForHandler(ns) + + mainHandler.HandleImageScanningScopedRequest(context.Background(), sessionObj) + + gotNames := getContainerProfileActionNames(t, storageClient) + assert.Equal(t, slugs, gotNames, "expected a single 'get containerprofiles' call using the with-container slug") +} + +// TestHandleImageScanningScopedRequest_MultiContainerDoesNotDedupeAcrossContainers is a +// regression test for the per-workload dedup bug: the dedup map used to be keyed by the +// no-container slug (identical for every container of the same pod), so finding one +// container's profile would incorrectly skip looking up the remaining containers'. With the +// fix, dedup is keyed by the with-container slug, so every container is looked up. +func TestHandleImageScanningScopedRequest_MultiContainerDoesNotDedupeAcrossContainers(t *testing.T) { + ns := "default" + pod := nakedRunningPodForHandler(ns, "my-pod", []string{"nginx", "sidecar", "init"}) + slugs := withContainerSlugsForHandler(t, pod) + require.Len(t, slugs, 3) + + profiles := make([]runtime.Object, 0, len(slugs)) + for _, slug := range slugs { + profiles = append(profiles, containerProfileForHandler(ns, slug)) + } + + k8sClient := k8sfake.NewSimpleClientset(pod) + storageClient := kssfake.NewSimpleClientset(profiles...) + + mainHandler := newMainHandlerForTest(k8sClient, storageClient) + sessionObj := sessionObjForNamespaceForHandler(ns) + + mainHandler.HandleImageScanningScopedRequest(context.Background(), sessionObj) + + gotNames := getContainerProfileActionNames(t, storageClient) + assert.ElementsMatch(t, slugs, gotNames, "expected one 'get containerprofiles' call per container, each with its own with-container slug") +} diff --git a/utils/containerprofile.go b/utils/containerprofile.go index dd375cfe..d56ff55d 100644 --- a/utils/containerprofile.go +++ b/utils/containerprofile.go @@ -64,7 +64,7 @@ func GetContainerProfileForRelevancyScan(ctx context.Context, storageClient kssc func GetContainerProfileScanCommand(profile *v1beta1.ContainerProfile, pod *corev1.Pod) *apis.Command { return &apis.Command{ Wlid: profile.Annotations[helpersv1.WlidMetadataKey], - CommandName: apis.TypeScanApplicationProfile, + CommandName: CommandScanContainerProfile, Args: map[string]interface{}{ ArgsName: profile.Name, ArgsNamespace: profile.Namespace, diff --git a/watcher/podwatcher.go b/watcher/podwatcher.go index 8ff91d0d..de157400 100644 --- a/watcher/podwatcher.go +++ b/watcher/podwatcher.go @@ -84,8 +84,6 @@ func (wh *WatchHandler) handlePodWatcher(ctx context.Context, pod *corev1.Pod, w return } - noContainerSlugs := map[string]bool{} - // there are a few use-cases: // 1. new workload, new image - new wlid, new slug, new image // scan // 2. new workload, existing image - new wlid, new slug, existing image // scan @@ -110,17 +108,8 @@ func (wh *WatchHandler) handlePodWatcher(ctx context.Context, pod *corev1.Pod, w continue } - noContainerSlug, _ := slugToInstanceID[slug].GetSlug(true) - if _, ok := noContainerSlugs[noContainerSlug]; ok { - // already scanned the container profile - wh.SlugToImageID.Set(containerData.Slug, containerData.ImageID) - wh.WlidAndImageID.Add(getWlidAndImageID(containerData)) - continue - } - - if profile := utils.GetContainerProfileForRelevancyScan(ctx, wh.storageClient, noContainerSlug, pod.GetNamespace()); profile != nil { + if profile := utils.GetContainerProfileForRelevancyScan(ctx, wh.storageClient, slug, pod.GetNamespace()); profile != nil { wh.scanContainerProfile(ctx, profile, pod, workerPool) - noContainerSlugs[noContainerSlug] = true } else { wh.scanImage(ctx, pod, containerData, workerPool) } @@ -147,17 +136,9 @@ func (wh *WatchHandler) handlePodWatcher(ctx context.Context, pod *corev1.Pod, w continue } - noContainerSlug, _ := slugToInstanceID[slug].GetSlug(true) - if _, ok := noContainerSlugs[noContainerSlug]; ok { - // already scanned the container profile - wh.WlidAndImageID.Add(getWlidAndImageID(containerData)) - continue - } - // use-case 1, 2, 3 - if profile := utils.GetContainerProfileForRelevancyScan(ctx, wh.storageClient, noContainerSlug, pod.GetNamespace()); profile != nil { + if profile := utils.GetContainerProfileForRelevancyScan(ctx, wh.storageClient, slug, pod.GetNamespace()); profile != nil { wh.scanContainerProfile(ctx, profile, pod, workerPool) - noContainerSlugs[noContainerSlug] = true } else { wh.scanImage(ctx, pod, containerData, workerPool) } diff --git a/watcher/podwatcher_test.go b/watcher/podwatcher_test.go index 1f69623a..4b65214b 100644 --- a/watcher/podwatcher_test.go +++ b/watcher/podwatcher_test.go @@ -13,8 +13,10 @@ import ( beUtils "github.com/kubescape/backend/pkg/utils" "github.com/kubescape/k8s-interface/instanceidhandler" instanceidhandlerv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1" + helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" "github.com/kubescape/operator/config" "github.com/kubescape/operator/utils" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" kssfake "github.com/kubescape/storage/pkg/generated/clientset/versioned/fake" "github.com/panjf2000/ants/v2" "github.com/stretchr/testify/assert" @@ -419,6 +421,130 @@ func Test_handlePodWatcher(t *testing.T) { } } +func newTestContainerProfile(name, namespace, wlid string) *v1beta1.ContainerProfile { + return &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Annotations: map[string]string{ + helpersv1.StatusMetadataKey: helpersv1.Learning, + helpersv1.InstanceIDMetadataKey: "some-instance-id", + helpersv1.WlidMetadataKey: wlid, + }, + }, + } +} + +// Test_handlePodWatcher_ContainerProfile is a regression test for the bug where handlePodWatcher looked up +// ContainerProfile objects using the wrong ("no-container") slug, which always missed, and deduped container +// profile scans per-workload instead of per-container, which dropped scans for every container after the first. +func Test_handlePodWatcher_ContainerProfile(t *testing.T) { + ctx := context.Background() + clusterConfig := utilsmetadata.ClusterConfig{ + ClusterName: "gke_armo-test-clusters_us-central1-c_dwertent-syft", + } + cfg, err := config.LoadConfig("../configuration") + assert.NoError(t, err) + + t.Run("single container - container profile exists", func(t *testing.T) { + pod := bytesToPod(readFileToBytes(podKubeProxy)) + wlid := "wlid://cluster-gke_armo-test-clusters_us-central1-c_dwertent-syft/namespace-kube-system/pod-kube-proxy-gke-cluster-pool-d4e9ae18-tgdf" + slug := "pod-kube-proxy-gke-cluster-pool-d4e9ae18-tgdf-kube-proxy-ebb6-8b3d" + profile := newTestContainerProfile(slug, pod.GetNamespace(), wlid) + + operatorConfig := config.NewOperatorConfig(config.CapabilitiesConfig{}, clusterConfig, &beUtils.Credentials{}, cfg) + k8sClient := k8sfake.NewSimpleClientset() + dynClient := dynamicfake.NewSimpleDynamicClient(runtime.NewScheme()) + k8sAPI := utils.NewK8sInterfaceFake(k8sClient) + k8sAPI.DynamicClient = dynClient + storageClient := kssfake.NewSimpleClientset(profile) + + eventQueue := NewCooldownQueue() + wh := NewWatchHandler(operatorConfig, k8sAPI, storageClient, eventQueue) + + var mu sync.Mutex + actualCommands := []apis.Command{} + wg := &sync.WaitGroup{} + wg.Add(1) + pool, _ := ants.NewPoolWithFunc(1, func(i interface{}) { + j := i.(utils.Job) + command := j.Obj().Command + mu.Lock() + actualCommands = append(actualCommands, *command) + mu.Unlock() + wg.Done() + }) + + wh.handlePodWatcher(ctx, pod, pool) + wg.Wait() + + assert.Len(t, actualCommands, 1) + assert.EqualValues(t, utils.CommandScanContainerProfile, actualCommands[0].CommandName) + assert.Equal(t, profile.Name, actualCommands[0].Args[utils.ArgsName]) + assert.Equal(t, profile.Namespace, actualCommands[0].Args[utils.ArgsNamespace]) + }) + + t.Run("multi container - container profile exists for every container", func(t *testing.T) { + pod := bytesToPod(readFileToBytes(podCollection)) + wlid := "wlid://cluster-gke_armo-test-clusters_us-central1-c_dwertent-syft/namespace-default/deployment-collection" + // with-container slugs (GetSlug(false)) for each of the pod's 5 containers, per Test_mapSlugToInstanceID. + slugs := []string{ + "replicaset-collection-69c659f8cb-alpine-container-9858-6638", + "replicaset-collection-69c659f8cb-redis-beb0-de8a", + "replicaset-collection-69c659f8cb-wordpress-05df-a39f", + "replicaset-collection-69c659f8cb-busybox-b1d9-e8c6", + "replicaset-collection-69c659f8cb-alpine-3ac2-aecc", + } + var storageObjects []runtime.Object + for _, slug := range slugs { + storageObjects = append(storageObjects, newTestContainerProfile(slug, pod.GetNamespace(), wlid)) + } + + operatorConfig := config.NewOperatorConfig(config.CapabilitiesConfig{}, clusterConfig, &beUtils.Credentials{}, cfg) + parentObjects := []runtime.Object{ + bytesToRuntimeObj(readFileToBytes(deploymentCollection)), + bytesToRuntimeObj(readFileToBytes(replicaSetCollection)), + } + k8sClient := k8sfake.NewSimpleClientset(parentObjects...) + dynClient := dynamicfake.NewSimpleDynamicClient(runtime.NewScheme(), parentObjects...) + k8sAPI := utils.NewK8sInterfaceFake(k8sClient) + k8sAPI.DynamicClient = dynClient + storageClient := kssfake.NewSimpleClientset(storageObjects...) + + eventQueue := NewCooldownQueue() + wh := NewWatchHandler(operatorConfig, k8sAPI, storageClient, eventQueue) + + var mu sync.Mutex + actualCommands := []apis.Command{} + wg := &sync.WaitGroup{} + wg.Add(len(slugs)) + pool, _ := ants.NewPoolWithFunc(1, func(i interface{}) { + j := i.(utils.Job) + command := j.Obj().Command + mu.Lock() + actualCommands = append(actualCommands, *command) + mu.Unlock() + wg.Done() + }) + + wh.handlePodWatcher(ctx, pod, pool) + wg.Wait() + + // none of the 5 containers should be dropped by dedup logic. + assert.Len(t, actualCommands, len(slugs)) + seenNames := map[string]bool{} + for _, cmd := range actualCommands { + assert.EqualValues(t, utils.CommandScanContainerProfile, cmd.CommandName) + name, _ := cmd.Args[utils.ArgsName].(string) + seenNames[name] = true + assert.Equal(t, pod.GetNamespace(), cmd.Args[utils.ArgsNamespace]) + } + for _, slug := range slugs { + assert.True(t, seenNames[slug], "expected a scan command for container profile %s", slug) + } + }) +} + func Test_listPods(t *testing.T) { tt := []struct { name string From 70f45ca623fa06e4017a4b45e0cdc21bcf52e127 Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Wed, 22 Jul 2026 15:46:48 +0200 Subject: [PATCH 2/2] test: make mainhandler regression tests actually exercise dispatch Address review feedback on PR #396: - CodeRabbit nitpick: replace the deprecated k8sfake.NewSimpleClientset with k8sfake.NewClientset in the two newly-added test call sites. - matthyx found that both mainhandler tests were weaker than intended: with Kubevuln disabled, HandleSingleRequest returns an error before slugs[s] = true is ever reached, so the multi-container test's dedup assertion could never fail even with the old per-workload dedup bug reintroduced -- it was effectively a duplicate of the single-container test. Neither test covered the CommandName fix either, since a wrong CommandName is silently dropped by runCommand's default case, which also returns no error. Fix: enable Kubevuln and point KubevulnURL at an httptest.Server stub, so a correctly-dispatched scan actually completes end-to-end through actionHandler.scanContainerProfile. Assert on the stub's received request count in addition to the existing storage-client Get assertions. Verified by mutation: reverting the CommandName fix now fails both tests (0 requests reach the stub instead of 1/3), and reintroducing the old per-workload dedup now fails the multi-container test (1 request instead of 3). Docs-exempt: test-only change, no behavioral or doc-relevant code touched. Co-Authored-By: Claude Sonnet 5 Signed-off-by: Matthias Bertschy --- mainhandler/handlerequests_test.go | 80 ++++++++++++++++++++++++++---- 1 file changed, 71 insertions(+), 9 deletions(-) diff --git a/mainhandler/handlerequests_test.go b/mainhandler/handlerequests_test.go index c89a60ab..6bff2489 100644 --- a/mainhandler/handlerequests_test.go +++ b/mainhandler/handlerequests_test.go @@ -3,12 +3,18 @@ package mainhandler import ( "context" "fmt" + "net/http" + "net/http/httptest" "strings" + "sync" "testing" "time" + utilsmetadata "github.com/armosec/utils-k8s-go/armometadata" + "github.com/armosec/armoapi-go/apis" "github.com/armosec/armoapi-go/identifiers" + beUtils "github.com/kubescape/backend/pkg/utils" instanceidhandlerv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1" helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" "github.com/kubescape/operator/config" @@ -146,15 +152,61 @@ func getContainerProfileActionNames(t *testing.T, storageClient interface{ Actio return names } +// scannerStub stands in for kubevuln. HandleSingleRequest only reaches it if the dispatch +// switch in runCommand actually routes the command to actionHandler.scanContainerProfile +// (i.e. GetContainerProfileScanCommand set the right CommandName) and that handler completes +// successfully (which is also the only way slugs[s] = true gets set in +// HandleImageScanningScopedRequest). Counting requests therefore lets tests detect both the +// CommandName regression and the per-workload dedup regression, neither of which a bare +// "get containerprofiles" assertion on the storage client can distinguish from a correct fix. +type scannerStub struct { + server *httptest.Server + mu sync.Mutex + count int +} + +func newScannerStub(t *testing.T) *scannerStub { + t.Helper() + s := &scannerStub{} + s.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + s.mu.Lock() + s.count++ + s.mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(s.server.Close) + return s +} + +func (s *scannerStub) requestCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.count +} + +func (s *scannerStub) hostPort() string { + return strings.TrimPrefix(s.server.URL, "http://") +} + // newMainHandlerForTest builds a MainHandler wired to fake Kubernetes and storage clientsets, -// with Kubevuln disabled (so any container-profile-found path that reaches -// actionHandler.scanContainerProfile returns a harmless error immediately, with no further -// network calls). -func newMainHandlerForTest(k8sClient *k8sfake.Clientset, storageClient *kssfake.Clientset) *MainHandler { +// with Kubevuln enabled and pointed at scanner. This lets a found ContainerProfile's scan +// command actually complete end-to-end through actionHandler.scanContainerProfile, instead of +// short-circuiting on a "kubevuln is not enabled" error before slugs[s] = true is ever reached. +func newMainHandlerForTest(k8sClient *k8sfake.Clientset, storageClient *kssfake.Clientset, scanner *scannerStub) *MainHandler { + capabilities := config.CapabilitiesConfig{ + Components: config.Components{ + Kubevuln: config.Component{Enabled: true}, + }, + } + clusterConfig := utilsmetadata.ClusterConfig{KubevulnURL: scanner.hostPort()} + // VulnScanHttpClient is normally set once at startup (see main.go); scanContainerProfile + // uses it directly (not via config), so tests that expect a real POST to reach the + // scanner stub must set it explicitly too. + VulnScanHttpClient = utils.InitHttpClient(clusterConfig.KubevulnURL) return &MainHandler{ k8sAPI: utils.NewK8sInterfaceFake(k8sClient), ksStorageClient: storageClient, - config: newTestConfig(config.Config{}), + config: config.NewOperatorConfig(capabilities, clusterConfig, &beUtils.Credentials{}, config.Config{}), } } @@ -179,16 +231,20 @@ func TestHandleImageScanningScopedRequest_LooksUpWithContainerSlug(t *testing.T) slugs := withContainerSlugsForHandler(t, pod) require.Len(t, slugs, 1) - k8sClient := k8sfake.NewSimpleClientset(pod) + k8sClient := k8sfake.NewClientset(pod) storageClient := kssfake.NewSimpleClientset(containerProfileForHandler(ns, slugs[0])) + scanner := newScannerStub(t) - mainHandler := newMainHandlerForTest(k8sClient, storageClient) + mainHandler := newMainHandlerForTest(k8sClient, storageClient, scanner) sessionObj := sessionObjForNamespaceForHandler(ns) mainHandler.HandleImageScanningScopedRequest(context.Background(), sessionObj) gotNames := getContainerProfileActionNames(t, storageClient) assert.Equal(t, slugs, gotNames, "expected a single 'get containerprofiles' call using the with-container slug") + // Only reachable if GetContainerProfileScanCommand's CommandName correctly routes through + // runCommand's dispatch switch into actionHandler.scanContainerProfile. + assert.Equal(t, 1, scanner.requestCount(), "expected the container-profile scan to actually dispatch to the scanner") } // TestHandleImageScanningScopedRequest_MultiContainerDoesNotDedupeAcrossContainers is a @@ -207,14 +263,20 @@ func TestHandleImageScanningScopedRequest_MultiContainerDoesNotDedupeAcrossConta profiles = append(profiles, containerProfileForHandler(ns, slug)) } - k8sClient := k8sfake.NewSimpleClientset(pod) + k8sClient := k8sfake.NewClientset(pod) storageClient := kssfake.NewSimpleClientset(profiles...) + scanner := newScannerStub(t) - mainHandler := newMainHandlerForTest(k8sClient, storageClient) + mainHandler := newMainHandlerForTest(k8sClient, storageClient, scanner) sessionObj := sessionObjForNamespaceForHandler(ns) mainHandler.HandleImageScanningScopedRequest(context.Background(), sessionObj) gotNames := getContainerProfileActionNames(t, storageClient) assert.ElementsMatch(t, slugs, gotNames, "expected one 'get containerprofiles' call per container, each with its own with-container slug") + // Each container's scan must independently reach the scanner: with Kubevuln enabled, + // a reintroduced per-workload dedup (keyed by the no-container slug, identical across + // containers) would mark the workload "done" after the first successful dispatch and + // skip the rest, so this count would drop below len(slugs) if that regression returned. + assert.Equal(t, len(slugs), scanner.requestCount(), "expected one dispatched scan per container, none dropped by dedup") }