Ctrl K
ComunidadGuías…

pkg/policyreports/index_test.go

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

Archivo original ↗Preguntar a KiBot
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)
		}
	}
}