pkg/policyreports/index_test.go
Rama main · réplica de lectura del repositorio original.
package policyreports
import (
"fmt"
"reflect"
"sort"
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// makeReport constructs a synthetic PolicyReport (or ClusterPolicyReport)
// as the dynamic cache would surface it. namespace is set on the report's
// metadata for namespaced PolicyReports; pass "" for ClusterPolicyReport.
// scope is optional (pass nil to omit). created controls
// metadata.creationTimestamp for ordering tests.
func makeReport(t *testing.T, kind, namespace, name string, scope map[string]any, created time.Time, results []map[string]any) *unstructured.Unstructured {
t.Helper()
r := &unstructured.Unstructured{}
r.SetKind(kind)
r.SetAPIVersion("wgpolicyk8s.io/v1alpha2")
r.SetName(name)
if namespace != "" {
r.SetNamespace(namespace)
}
r.SetCreationTimestamp(metav1.NewTime(created))
// Build into Object so unstructured.Nested* sees nested keys.
if scope != nil {
if err := unstructured.SetNestedMap(r.Object, scope, "scope"); err != nil {
t.Fatalf("set scope: %v", err)
}
}
// `results` is []any of map[string]any when read via NestedSlice.
resultsAny := make([]any, 0, len(results))
for _, res := range results {
resultsAny = append(resultsAny, res)
}
if err := unstructured.SetNestedSlice(r.Object, resultsAny, "results"); err != nil {
t.Fatalf("set results: %v", err)
}
return r
}
func resourceRef(kind, ns, name string) map[string]any {
m := map[string]any{"kind": kind, "name": name}
if ns != "" {
m["namespace"] = ns
}
return m
}
// resourceRefWithGroup builds a `results[].resources[]` entry that
// includes `apiVersion`, so the index can derive Subject.Group from it.
// Pass groupVersion in the standard K8s shape: "apps/v1", "v1" (core), or
// "argoproj.io/v1alpha1".
func resourceRefWithGroup(groupVersion, kind, ns, name string) map[string]any {
m := resourceRef(kind, ns, name)
if groupVersion != "" {
m["apiVersion"] = groupVersion
}
return m
}
func TestBuildIndex_PerResourceFindings(t *testing.T) {
// One report with two results, each targeting a distinct subject.
report := makeReport(t, "PolicyReport", "prod", "kyverno-report-1", nil, time.Now(), []map[string]any{
{
"policy": "disallow-privileged",
"rule": "no-privileged",
"result": "fail",
"severity": "high",
"category": "Pod Security",
"message": "container is privileged",
"resources": []any{
resourceRef("Pod", "prod", "web-abc123"),
},
},
{
"policy": "require-labels",
"rule": "app-label",
"result": "warn",
"severity": "low",
"category": "Best Practices",
"message": "missing app label",
"resources": []any{
resourceRef("Deployment", "prod", "web"),
},
},
})
idx := BuildIndex([]*unstructured.Unstructured{report})
podFindings := idx.FindingsFor("", "Pod", "prod", "web-abc123")
if len(podFindings) != 1 {
t.Fatalf("expected 1 finding for Pod/prod/web-abc123, got %d", len(podFindings))
}
if podFindings[0].Policy != "disallow-privileged" || podFindings[0].Result != "fail" {
t.Errorf("unexpected pod finding: %+v", podFindings[0])
}
if podFindings[0].Severity != "high" || podFindings[0].Category != "Pod Security" {
t.Errorf("severity/category not preserved: %+v", podFindings[0])
}
depFindings := idx.FindingsFor("", "Deployment", "prod", "web")
if len(depFindings) != 1 {
t.Fatalf("expected 1 finding for Deployment/prod/web, got %d", len(depFindings))
}
if depFindings[0].Rule != "app-label" || depFindings[0].Result != "warn" {
t.Errorf("unexpected deployment finding: %+v", depFindings[0])
}
}
func TestBuildIndex_MultipleResourcesPerResult(t *testing.T) {
// One result targeting three pods → three index entries from one
// finding row. This is the common shape for cluster-wide policies.
report := makeReport(t, "PolicyReport", "prod", "rep-multi", nil, time.Now(), []map[string]any{
{
"policy": "require-cpu-limits",
"rule": "cpu-limits",
"result": "fail",
"resources": []any{
resourceRef("Pod", "prod", "a"),
resourceRef("Pod", "prod", "b"),
resourceRef("Pod", "prod", "c"),
},
},
})
idx := BuildIndex([]*unstructured.Unstructured{report})
for _, name := range []string{"a", "b", "c"} {
got := idx.FindingsFor("", "Pod", "prod", name)
if len(got) != 1 {
t.Errorf("expected 1 finding for Pod/prod/%s, got %d", name, len(got))
}
}
if idx.Size() != 3 {
t.Errorf("expected 3 distinct subjects, got %d", idx.Size())
}
}
func TestBuildIndex_ScopeOnlyReport(t *testing.T) {
// Single-target report: no `results[].resources` array — the report
// itself is scoped to one subject via `report.scope`. Each result row
// should still produce a finding indexed under the scope subject.
scope := map[string]any{
"kind": "Deployment",
"namespace": "prod",
"name": "api",
}
report := makeReport(t, "PolicyReport", "prod", "scope-only", scope, time.Now(), []map[string]any{
{
"policy": "disallow-latest-tag",
"rule": "no-latest",
"result": "fail",
"severity": "medium",
"message": "image tag is latest",
// no "resources" key
},
{
"policy": "require-readiness",
"rule": "readiness-probe",
"result": "warn",
"message": "missing readinessProbe",
},
})
idx := BuildIndex([]*unstructured.Unstructured{report})
got := idx.FindingsFor("", "Deployment", "prod", "api")
if len(got) != 2 {
t.Fatalf("expected 2 findings under scope subject, got %d", len(got))
}
policies := []string{got[0].Policy, got[1].Policy}
sort.Strings(policies)
want := []string{"disallow-latest-tag", "require-readiness"}
for i := range policies {
if policies[i] != want[i] {
t.Errorf("policy[%d]=%q want %q", i, policies[i], want[i])
}
}
}
func TestBuildIndex_ScopeFallback_NamespaceFromReportMetadata(t *testing.T) {
// Scope omits namespace but the report is namespaced — common shape
// when engines emit only kind+name in scope and rely on the report's
// own metadata.namespace.
scope := map[string]any{
"kind": "Pod",
"name": "lonely",
// no namespace
}
report := makeReport(t, "PolicyReport", "dev", "rep-scope-no-ns", scope, time.Now(), []map[string]any{
{
"policy": "no-host-network",
"result": "fail",
},
})
idx := BuildIndex([]*unstructured.Unstructured{report})
got := idx.FindingsFor("", "Pod", "dev", "lonely")
if len(got) != 1 {
t.Fatalf("expected 1 finding inherited from report namespace, got %d", len(got))
}
}
func TestBuildIndex_ClusterPolicyReport(t *testing.T) {
// ClusterPolicyReport: no report.metadata.namespace; cluster-scoped
// resources (Node, ClusterRole) get empty-namespace keys.
report := makeReport(t, "ClusterPolicyReport", "", "cluster-rep", nil, time.Now(), []map[string]any{
{
"policy": "node-ssh-disabled",
"result": "fail",
"resources": []any{
resourceRef("Node", "", "gke-pool-a-1"),
},
},
})
idx := BuildIndex([]*unstructured.Unstructured{report})
got := idx.FindingsFor("", "Node", "", "gke-pool-a-1")
if len(got) != 1 {
t.Fatalf("expected 1 finding on Node, got %d", len(got))
}
if got[0].Policy != "node-ssh-disabled" {
t.Errorf("policy not preserved: %+v", got[0])
}
}
func TestBuildIndex_NoScopeNoResources_DropsFinding(t *testing.T) {
// Pathological / malformed report: no scope, no resources. There is
// no subject to index against, so the finding is dropped silently.
report := makeReport(t, "PolicyReport", "ns", "bad-rep", nil, time.Now(), []map[string]any{
{
"policy": "orphaned",
"result": "error",
},
})
idx := BuildIndex([]*unstructured.Unstructured{report})
if idx.Size() != 0 {
t.Errorf("expected empty index for orphaned finding, got %d entries", idx.Size())
}
}
func TestBuildIndex_EmptyResourcesArray_FallsBackToScope(t *testing.T) {
// `resources: []` (empty but present) is treated the same as
// scope-only — both engines we've seen emit it interchangeably.
scope := map[string]any{
"kind": "Service",
"namespace": "default",
"name": "svc",
}
report := makeReport(t, "PolicyReport", "default", "empty-resources", scope, time.Now(), []map[string]any{
{
"policy": "no-loadbalancer",
"result": "fail",
"resources": []any{},
},
})
idx := BuildIndex([]*unstructured.Unstructured{report})
got := idx.FindingsFor("", "Service", "default", "svc")
if len(got) != 1 {
t.Fatalf("expected scope fallback to trigger on empty resources, got %d findings", len(got))
}
}
// TestBuildIndex_MalformedResourcesEntries_FallsBackToScope pins the edge case
// reviewer caught: `resources: [{}]` is a non-empty slice that previously
// skipped scope fallback (because hasResources && len(subjects) > 0) but then
// got filtered to nothing in the index loop (every subject has empty kind/name).
// The finding was silently dropped even when scope could have rescued it.
func TestBuildIndex_MalformedResourcesEntries_FallsBackToScope(t *testing.T) {
scope := map[string]any{
"kind": "Deployment",
"namespace": "prod",
"name": "cart",
}
report := makeReport(t, "PolicyReport", "prod", "malformed-resources", scope, time.Now(), []map[string]any{
{
"policy": "require-resource-limits",
"result": "fail",
// Non-empty resources[] but every entry is empty — produced
// by some buggy emitters when policy match conditions fail.
"resources": []any{
map[string]any{},
map[string]any{},
},
},
})
idx := BuildIndex([]*unstructured.Unstructured{report})
got := idx.FindingsFor("", "Deployment", "prod", "cart")
if len(got) != 1 {
t.Fatalf("expected scope fallback when all resources entries are empty, got %d findings", len(got))
}
}
func TestBuildIndex_FindingsForUnknownSubject(t *testing.T) {
report := makeReport(t, "PolicyReport", "prod", "rep", nil, time.Now(), []map[string]any{
{
"policy": "p",
"result": "fail",
"resources": []any{
resourceRef("Pod", "prod", "a"),
},
},
})
idx := BuildIndex([]*unstructured.Unstructured{report})
if got := idx.FindingsFor("", "Pod", "prod", "missing"); got != nil {
t.Errorf("expected nil for unknown subject, got %v", got)
}
}
func TestBuildIndex_IndexesEveryReport(t *testing.T) {
const reportCount = 1000
reports := make([]*unstructured.Unstructured, 0, reportCount)
for i := 0; i < reportCount; i++ {
reports = append(reports, makeReport(t, "PolicyReport", "ns", fmt.Sprintf("rep-%d", i), nil, time.Now(), []map[string]any{
{
"policy": "p",
"result": "fail",
"resources": []any{
resourceRef("Pod", "ns", fmt.Sprintf("pod-%d", i)),
},
},
}))
}
idx := BuildIndex(reports)
if idx.Size() != reportCount {
t.Fatalf("expected all %d report subjects, got %d", reportCount, idx.Size())
}
if got := idx.FindingsFor("", "Pod", "ns", "pod-0"); len(got) != 1 {
t.Errorf("first report subject should be present, got %v", got)
}
lastName := fmt.Sprintf("pod-%d", reportCount-1)
if got := idx.FindingsFor("", "Pod", "ns", lastName); len(got) != 1 {
t.Errorf("last report subject %s should be present, got %v", lastName, got)
}
}
func TestBuildIndex_FindingOrderIsStableAcrossReportOrder(t *testing.T) {
result := func(policy string) map[string]any {
return map[string]any{
"policy": policy,
"result": "fail",
"resources": []any{
resourceRef("Pod", "ns", "pod"),
},
}
}
alpha := makeReport(t, "PolicyReport", "ns", "alpha", nil, time.Now(), []map[string]any{result("alpha")})
zeta := makeReport(t, "PolicyReport", "ns", "zeta", nil, time.Now(), []map[string]any{result("zeta"), result("alpha")})
forward := BuildIndex([]*unstructured.Unstructured{zeta, alpha}).FindingsFor("", "Pod", "ns", "pod")
reverse := BuildIndex([]*unstructured.Unstructured{alpha, zeta}).FindingsFor("", "Pod", "ns", "pod")
if !reflect.DeepEqual(forward, reverse) {
t.Fatalf("finding order changed with report order: forward=%v reverse=%v", forward, reverse)
}
if len(forward) != 2 || forward[0].Policy != "alpha" || forward[1].Policy != "zeta" {
t.Fatalf("findings = %v, want deduplicated deterministic order [alpha zeta]", forward)
}
}
func TestIndex_ReplaceSwapsContents(t *testing.T) {
// Live-update pattern: build, then replace contents on event.
idx := NewIndex()
idx.Replace([]*unstructured.Unstructured{
makeReport(t, "PolicyReport", "ns", "first", nil, time.Now(), []map[string]any{
{
"policy": "p1",
"result": "fail",
"resources": []any{
resourceRef("Pod", "ns", "first"),
},
},
}),
})
if got := idx.FindingsFor("", "Pod", "ns", "first"); len(got) != 1 {
t.Fatalf("first build missed: %v", got)
}
// Replace with a different report — old subject must disappear.
idx.Replace([]*unstructured.Unstructured{
makeReport(t, "PolicyReport", "ns", "second", nil, time.Now(), []map[string]any{
{
"policy": "p2",
"result": "warn",
"resources": []any{
resourceRef("Pod", "ns", "second"),
},
},
}),
})
if got := idx.FindingsFor("", "Pod", "ns", "first"); got != nil {
t.Errorf("old subject leaked after Replace: %v", got)
}
if got := idx.FindingsFor("", "Pod", "ns", "second"); len(got) != 1 {
t.Errorf("new subject missing after Replace: %v", got)
}
}
func TestIndex_NilSafe(t *testing.T) {
var idx *Index
if got := idx.FindingsFor("", "Pod", "ns", "name"); got != nil {
t.Errorf("nil index FindingsFor returned %v", got)
}
if got := idx.Size(); got != 0 {
t.Errorf("nil index Size returned %d", got)
}
if got := idx.All(); got != nil {
t.Errorf("nil index All returned %v", got)
}
// Replace on nil receiver should not panic.
idx.Replace(nil)
}
func TestIndex_All(t *testing.T) {
report := makeReport(t, "PolicyReport", "prod", "rep-1", nil, time.Now(), []map[string]any{
{
"policy": "p1",
"rule": "r1",
"result": "fail",
"resources": []any{
resourceRef("Pod", "prod", "web"),
resourceRef("Pod", "prod", "api"),
},
},
{
"policy": "p2",
"rule": "r2",
"result": "warn",
"resources": []any{
resourceRef("Deployment", "prod", "web"),
},
},
})
// Cluster-scoped subject via ClusterPolicyReport.
clusterReport := makeReport(t, "ClusterPolicyReport", "", "cluster-rep-1", nil, time.Now(), []map[string]any{
{
"policy": "p3",
"rule": "r3",
"result": "fail",
"resources": []any{
resourceRef("ClusterRole", "", "admin"),
},
},
})
idx := BuildIndex([]*unstructured.Unstructured{report, clusterReport})
all := idx.All()
if len(all) != 4 {
t.Fatalf("expected 4 subjects (2 pods + 1 deployment + 1 clusterrole), got %d: %+v", len(all), all)
}
// Verify subjects + findings round-trip; iteration order must be stable
// (alphabetical by key) — pin it explicitly so a regression is loud.
gotKeys := make([]string, 0, len(all))
for _, sf := range all {
gotKeys = append(gotKeys, sf.Subject.Kind+"/"+sf.Subject.Namespace+"/"+sf.Subject.Name)
}
wantKeys := []string{
"ClusterRole//admin",
"Deployment/prod/web",
"Pod/prod/api",
"Pod/prod/web",
}
if !sort.StringsAreSorted(gotKeys) {
t.Fatalf("All() iteration not sorted by key: %v", gotKeys)
}
for i := range wantKeys {
if gotKeys[i] != wantKeys[i] {
t.Errorf("subject[%d]: got %q want %q (full: %v)", i, gotKeys[i], wantKeys[i], gotKeys)
}
}
// Verify per-subject Findings: the cluster-scoped one.
for _, sf := range all {
if sf.Subject.Kind != "ClusterRole" {
continue
}
if sf.Subject.Namespace != "" || sf.Subject.Name != "admin" {
t.Errorf("cluster-scoped subject malformed: %+v", sf.Subject)
}
if len(sf.Findings) != 1 || sf.Findings[0].Policy != "p3" {
t.Errorf("cluster-scoped findings wrong: %+v", sf.Findings)
}
}
// Returned slices must be defensive copies — mutating must not
// corrupt subsequent FindingsFor lookups.
for _, sf := range all {
if sf.Subject.Kind == "Pod" && sf.Subject.Name == "web" {
sf.Findings[0].Policy = "MUTATED"
break
}
}
got := idx.FindingsFor("", "Pod", "prod", "web")
if len(got) == 0 || got[0].Policy == "MUTATED" {
t.Fatalf("All() returned non-defensive copy — mutation leaked: %+v", got)
}
}
// TestBuildIndex_GroupFromResourceAPIVersion pins the per-result resource
// group propagation. Each `results[].resources[]` entry carries its own
// apiVersion in the wg-policy schema; the index must derive Subject.Group
// from that so two CRDs sharing a Kind across different groups don't
// collide on the same index entry and so consumers (Issues, RBAC checks)
// see the group.
func TestBuildIndex_GroupFromResourceAPIVersion(t *testing.T) {
report := makeReport(t, "PolicyReport", "prod", "groups-from-resources", nil, time.Now(), []map[string]any{
{
"policy": "p-app",
"result": "fail",
"resources": []any{
resourceRefWithGroup("argoproj.io/v1alpha1", "Application", "prod", "argo-app"),
},
},
{
"policy": "p-deploy",
"result": "fail",
"resources": []any{
resourceRefWithGroup("apps/v1", "Deployment", "prod", "web"),
},
},
{
"policy": "p-pod",
"result": "warn",
"resources": []any{
// Core kind: apiVersion=v1 → group="".
resourceRefWithGroup("v1", "Pod", "prod", "web-abc"),
},
},
})
idx := BuildIndex([]*unstructured.Unstructured{report})
if got := idx.FindingsFor("argoproj.io", "Application", "prod", "argo-app"); len(got) != 1 {
t.Errorf("expected Application finding under group=argoproj.io, got %v", got)
}
// Without group, the lookup must miss — pins that group is part of
// the key, not a side-channel attribute.
if got := idx.FindingsFor("", "Application", "prod", "argo-app"); len(got) != 0 {
t.Errorf("group=\"\" lookup leaked CRD Application: %v", got)
}
if got := idx.FindingsFor("apps", "Deployment", "prod", "web"); len(got) != 1 {
t.Errorf("expected Deployment finding under group=apps, got %v", got)
}
if got := idx.FindingsFor("", "Pod", "prod", "web-abc"); len(got) != 1 {
t.Errorf("core-group Pod (apiVersion=v1) should have group=\"\", got %v", got)
}
// All() must surface Subject.Group on the projected SubjectFindings.
groupByName := map[string]string{}
for _, sf := range idx.All() {
groupByName[sf.Subject.Kind+"/"+sf.Subject.Name] = sf.Subject.Group
}
if got := groupByName["Application/argo-app"]; got != "argoproj.io" {
t.Errorf("All().Subject.Group for Application: got %q want %q", got, "argoproj.io")
}
if got := groupByName["Deployment/web"]; got != "apps" {
t.Errorf("All().Subject.Group for Deployment: got %q want %q", got, "apps")
}
if got := groupByName["Pod/web-abc"]; got != "" {
t.Errorf("All().Subject.Group for Pod: got %q want %q", got, "")
}
}
// TestBuildIndex_GroupFromScopeAPIVersion pins the scope-only path: when
// a result has no resources[] (or only malformed ones), the report's
// `scope.apiVersion` provides the group for the materialized Subject.
func TestBuildIndex_GroupFromScopeAPIVersion(t *testing.T) {
scope := map[string]any{
"apiVersion": "argoproj.io/v1alpha1",
"kind": "Application",
"namespace": "prod",
"name": "scope-app",
}
report := makeReport(t, "PolicyReport", "prod", "scope-with-group", scope, time.Now(), []map[string]any{
{
"policy": "no-sync-loop",
"result": "fail",
"message": "sync loop detected",
// no resources[] → scope fallback
},
})
idx := BuildIndex([]*unstructured.Unstructured{report})
// Lookup with the right group succeeds.
got := idx.FindingsFor("argoproj.io", "Application", "prod", "scope-app")
if len(got) != 1 {
t.Fatalf("expected scope-only finding under group=argoproj.io, got %v", got)
}
// Lookup without the group misses (proving group is materialized from
// scope.apiVersion into the index key, not silently dropped).
if got := idx.FindingsFor("", "Application", "prod", "scope-app"); len(got) != 0 {
t.Errorf("group=\"\" lookup leaked scope CRD: %v", got)
}
// Subject from All() must carry the group too.
all := idx.All()
if len(all) != 1 {
t.Fatalf("expected 1 subject, got %d", len(all))
}
if all[0].Subject.Group != "argoproj.io" {
t.Errorf("All().Subject.Group: got %q want %q", all[0].Subject.Group, "argoproj.io")
}
}
// TestBuildIndex_GroupCollisionAcrossCRDs pins the underlying motivation
// for keying on group: two CRDs sharing a Kind across different groups
// must NOT collide on the same index entry.
func TestBuildIndex_GroupCollisionAcrossCRDs(t *testing.T) {
// Two CRDs both named "Foo" but in different groups, same namespace +
// name. Without group-keying, the second would overwrite the first.
report := makeReport(t, "PolicyReport", "ns", "collision", nil, time.Now(), []map[string]any{
{
"policy": "alpha-policy",
"result": "fail",
"resources": []any{
resourceRefWithGroup("alpha.example.com/v1", "Foo", "ns", "shared"),
},
},
{
"policy": "beta-policy",
"result": "fail",
"resources": []any{
resourceRefWithGroup("beta.example.com/v1", "Foo", "ns", "shared"),
},
},
})
idx := BuildIndex([]*unstructured.Unstructured{report})
if idx.Size() != 2 {
t.Fatalf("expected 2 distinct subjects (group-keyed), got %d", idx.Size())
}
alpha := idx.FindingsFor("alpha.example.com", "Foo", "ns", "shared")
if len(alpha) != 1 || alpha[0].Policy != "alpha-policy" {
t.Errorf("alpha CRD findings wrong: %v", alpha)
}
beta := idx.FindingsFor("beta.example.com", "Foo", "ns", "shared")
if len(beta) != 1 || beta[0].Policy != "beta-policy" {
t.Errorf("beta CRD findings wrong: %v", beta)
}
}
func TestParseSubjectKey(t *testing.T) {
cases := []struct {
key string
group, kind, ns, name string
ok bool
}{
// Core-group + namespaced
{"/Pod/prod/web", "", "Pod", "prod", "web", true},
// Core-group + cluster-scoped
{"/ClusterRole//admin", "", "ClusterRole", "", "admin", true},
// Non-core CRD + namespaced
{"argoproj.io/Application/default/myapp", "argoproj.io", "Application", "default", "myapp", true},
// Non-core CRD + cluster-scoped
{"karpenter.sh/NodePool//default", "karpenter.sh", "NodePool", "", "default", true},
// Malformed: not enough parts
{"malformed", "", "", "", "", false},
{"two/parts", "", "", "", "", false},
{"three/parts/here", "", "", "", "", false},
// Empty kind invalid even when group set
{"group//ns/name", "", "", "", "", false},
// Empty name invalid
{"group/Kind/ns/", "", "", "", "", false},
}
for _, c := range cases {
g, k, ns, n, ok := parseSubjectKey(c.key)
if ok != c.ok || g != c.group || k != c.kind || ns != c.ns || n != c.name {
t.Errorf("parseSubjectKey(%q) = (%q, %q, %q, %q, %v), want (%q, %q, %q, %q, %v)",
c.key, g, k, ns, n, ok, c.group, c.kind, c.ns, c.name, c.ok)
}
}
}