Ctrl K
ComunidadGuías…

pkg/ownership/view_test.go

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

Archivo original ↗Preguntar a KiBot
package ownership_test

import (
	"fmt"
	"slices"
	"testing"

	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/types"

	"github.com/skyhook-io/radar/pkg/ownership"
	"github.com/skyhook-io/radar/pkg/ownership/ownershiptest"
	"github.com/skyhook-io/radar/pkg/readreason"
	"github.com/skyhook-io/radar/pkg/resourceid"
)

// access is a caller who may read the namespaces listed (every one when nil)
// and every kind but those denied or undecided.
type access struct {
	namespaces []string
	denied     []resourceid.GroupKind
	undecided  []resourceid.GroupKind
}

func (a access) CanRead(gk resourceid.GroupKind, namespace string) ownership.Decision {
	switch {
	case slices.Contains(a.undecided, gk):
		return ownership.Undecided
	case slices.Contains(a.denied, gk), namespace != "" && a.namespaces != nil && !slices.Contains(a.namespaces, namespace):
		return ownership.Denied
	}
	return ownership.Allowed
}

func (a access) Namespaces() ([]string, bool) { return a.namespaces, a.namespaces == nil }

func view(t *testing.T, w ownershiptest.World, a ownership.Access) *ownership.View {
	t.Helper()
	s, err := ownershiptest.Scan(w).Begin()
	if err != nil {
		t.Fatal(err)
	}
	return ownership.NewView(s, a)
}

func at(group, kind, namespace, name string) resourceid.Ref {
	return resourceid.Ref{Group: group, Kind: kind, Namespace: namespace, Name: name}
}

var (
	replicaSets = resourceid.GroupKind{Group: "apps", Kind: "ReplicaSet"}
	pods        = resourceid.GroupKind{Kind: "Pod"}
	configMaps  = resourceid.GroupKind{Kind: "ConfigMap"}
)

func TestViewOwnerIsTheControllerOrTheOnlyReference(t *testing.T) {
	w := ownershiptest.Fixture()
	rs := at("apps", "ReplicaSet", "alpha", "web-1")
	for _, tc := range []struct {
		name    string
		access  ownership.Access
		subject resourceid.Ref
		index   int
		state   ownership.OwnerState
		ref     resourceid.Ref
		several bool
		gap     ownership.Gap
	}{
		{"a controller", ownership.Unrestricted(), at("", "Pod", "alpha", "web-1-a"), 0, ownership.OwnerFound, rs, false, ownership.Gap{}},
		{"the controller of two", ownership.Unrestricted(), at("", "Pod", "alpha", "web-1-b"), 0, ownership.OwnerFound, rs, false, ownership.Gap{}},
		{"several without a controller", ownership.Unrestricted(), at("", "ConfigMap", "alpha", "shared"), -1, 0, resourceid.Ref{}, true, ownership.Gap{}},
		{"none", ownership.Unrestricted(), at("apps", "Deployment", "alpha", "web"), -1, 0, resourceid.Ref{}, false, ownership.Gap{}},
		{"an only reference to a later incarnation", ownership.Unrestricted(), at("", "Pod", "alpha", "replaced"), 0, ownership.OwnerReplaced, at("apps", "ReplicaSet", "alpha", "web-old"), false, ownership.Gap{}},
		{"an only reference to nothing", ownership.Unrestricted(), at("", "Pod", "alpha", "dangling"), 0, ownership.OwnerGone, at("apps", "ReplicaSet", "alpha", "gone"), false, ownership.Gap{}},
		{"a namespaced owner of a cluster-scoped object", ownership.Unrestricted(), at("", "Node", "", "n2"), 0, ownership.OwnerUnresolvable, resourceid.Ref{}, false, ownership.Gap{}},
		{"a withheld controller never gives way to the ConfigMap", access{denied: []resourceid.GroupKind{replicaSets}}, at("", "Pod", "alpha", "web-1-b"), 0, ownership.OwnerWithheld, rs, false, ownership.Gap{}},
		{"an undecided check", access{undecided: []resourceid.GroupKind{replicaSets}}, at("", "Pod", "alpha", "web-1-b"), 0, ownership.OwnerUnknown, rs, false,
			ownership.Gap{Kind: replicaSets, Namespace: "alpha", Reason: readreason.UserPermissionCheckFailed}},
	} {
		t.Run(tc.name, func(t *testing.T) {
			got := view(t, w, tc.access).Owner(tc.subject)
			if !got.Subject.Present() {
				t.Fatalf("subject = %v %v", got.Subject.Access, got.Subject.Read.State)
			}
			if index := referenceIndex(got); index != tc.index || got.Several != tc.several || got.Resolution.State != tc.state || got.Resolution.Ref != tc.ref || got.Resolution.Gap != tc.gap {
				t.Fatalf("Owner = index %d several %v %v %v %+v; want index %d several %v %v %v %+v",
					index, got.Several, got.Resolution.State, got.Resolution.Ref, got.Resolution.Gap, tc.index, tc.several, tc.state, tc.ref, tc.gap)
			}
		})
	}
}

func TestViewSaysNothingOfAnObjectTheCallerMayNotRead(t *testing.T) {
	v := view(t, ownershiptest.Fixture(), access{denied: []resourceid.GroupKind{pods}})
	pod := at("", "Pod", "alpha", "web-1-a")
	if got := v.Owner(pod); got.Subject.Access != ownership.Denied || got.Subject.Read.State != ownership.Unavailable || got.Subject.Read.Object.UID != "" || got.Reference != nil {
		t.Fatalf("Owner of a Pod the caller may not read = %+v", got)
	}
	if got := v.Children(pod); got.Subject.Present() || got.Children != nil || got.Gaps != nil || got.Complete() {
		t.Fatalf("Children = %+v complete=%v", got, got.Complete())
	}
	unsure := view(t, ownershiptest.Fixture(), access{undecided: []resourceid.GroupKind{pods}})
	if got := unsure.Children(pod); got.Subject.Present() || got.Complete() {
		t.Fatalf("Children when the caller's access couldn't be checked = %+v complete=%v", got, got.Complete())
	}
	if got := v.ControllerChain(pod); got.Subject.Present() || len(got.Chain) != 0 || got.UnfollowedReference != nil {
		t.Fatalf("ControllerChain = %+v", got)
	}
}

// An object the caller fetched from the API answers for itself when Radar
// doesn't collect its kind; its owners are still read from the caches.
func TestViewSeedsAnObjectTheCachesDoNotHold(t *testing.T) {
	w := ownershiptest.Fixture()
	w.Collection[configMaps] = ownershiptest.Collection{State: ownershiptest.NotCollected}
	fetched := at("", "ConfigMap", "alpha", "versioned")
	var o ownershiptest.Object
	for _, x := range w.Objects {
		if x.Ref == fetched {
			o = x
		}
	}
	observation := ownership.Observation{Ref: o.Ref, UID: o.UID, OwnerReferences: o.OwnerReferences}

	v := view(t, w, ownership.Unrestricted())
	if got := v.Owner(fetched); got.Subject.Present() {
		t.Fatalf("scenario: ConfigMaps must be uncollected, got %v", got.Subject.Read.State)
	}
	v = view(t, w, ownership.Unrestricted())
	v.Seed(observation)
	if got := v.Owner(fetched); got.Resolution.State != ownership.OwnerFound || got.Resolution.Ref != at("example.io", "Widget", "alpha", "w1") {
		t.Fatalf("Owner of the fetched ConfigMap = %v %v", got.Resolution.State, got.Resolution.Ref)
	}

	// A read that holds the object stands.
	held := view(t, ownershiptest.Fixture(), ownership.Unrestricted())
	stale := observation
	stale.UID = "uid-of-another-incarnation"
	held.Seed(stale)
	if got := held.Owner(fetched); got.Subject.Read.Object.UID != o.UID {
		t.Fatalf("a seed replaced the held read: %v", got.Subject.Read.Object.UID)
	}

	// An Event stays excluded.
	event := ownership.Observation{Ref: at("", "Event", "alpha", "e1"), UID: "uid-alpha-e1"}
	events := view(t, ownershiptest.Fixture(), ownership.Unrestricted())
	events.Seed(event)
	if got := events.Owner(event.Ref); got.Subject.Read.State != ownership.Excluded {
		t.Fatalf("a seeded Event reads %v, want excluded", got.Subject.Read.State)
	}

	// A seeded manager whose kind Radar doesn't collect has no list to read.
	managers := ownershiptest.Fixture()
	managers.Collection[ownership.ArgoApplication] = ownershiptest.Collection{State: ownershiptest.NotCollected, OnDemand: true}
	app := at("argoproj.io", "Application", "argocd", "web")
	seeded := view(t, managers, ownership.Unrestricted())
	seeded.Seed(ownership.Observation{Ref: app, UID: "uid-argocd-web"})
	if got := seeded.Members(app); got.Membership != ownership.MembersUnreadable || len(got.Gaps) != 1 || got.Gaps[0].Reason != readreason.RadarKindNotCollected {
		t.Fatalf("members of an uncollected manager = %v gaps %+v; want unreadable, as Radar doesn't collect it", got.Membership, got.Gaps)
	}

	// Seeding grants no access.
	denied := view(t, w, access{denied: []resourceid.GroupKind{configMaps}})
	denied.Seed(observation)
	if got := denied.Owner(fetched); got.Subject.Present() {
		t.Fatal("a seed let the caller read an object it may not read")
	}
}

// referenceIndex is where the chosen reference sits among the subject's own
// owner references, or -1 when none was chosen or it isn't one of them.
func referenceIndex(a ownership.OwnerAnswer) int {
	refs := a.Subject.Read.Object.OwnerReferences
	for i := range refs {
		if a.Reference == &refs[i] {
			return i
		}
	}
	return -1
}

func childRefs(a ownership.ChildrenAnswer) []string {
	var out []string
	for _, c := range a.Children {
		out = append(out, c.Ref.String())
	}
	return out
}

func TestViewChildrenResolveToTheOwnersCurrentIncarnation(t *testing.T) {
	w := ownershiptest.Fixture()
	unlistedWidget := []ownership.Gap{{Kind: resourceid.GroupKind{Group: "example.io", Kind: "Widget"}, Reason: readreason.KindUnknown}}
	for _, tc := range []struct {
		name   string
		access ownership.Access
		owner  resourceid.Ref
		want   []string
		narrow bool
		// gaps are the references to the owner Radar can't resolve.
		gaps []ownership.Gap
	}{
		// The stray Pod in beta names the ReplicaSet's UID but resolves in its
		// own namespace; wrong-coordinates names the UID under another kind.
		{"a ReplicaSet's Pods", ownership.Unrestricted(), at("apps", "ReplicaSet", "alpha", "web-1"),
			[]string{"Pod alpha/web-1-a", "Pod alpha/web-1-b"}, false, nil},
		{"a controlled and a plain child", ownership.Unrestricted(), at("apps", "Deployment", "alpha", "web"),
			[]string{"ReplicaSet.apps alpha/web-1", "Widget.example.io alpha/w1"}, false, nil},
		// s1's kind can't be watched; n2 is cluster-scoped, which a namespaced
		// owner can't own. j1 names a version discovery doesn't list, which the
		// collector retries, so j1 may yet be a child.
		{"through any served version", ownership.Unrestricted(), at("example.io", "Widget", "alpha", "w1"),
			[]string{"ConfigMap alpha/shared", "ConfigMap alpha/versioned"}, false, unlistedWidget},
		{"none for an earlier incarnation's dependent", ownership.Unrestricted(), at("apps", "ReplicaSet", "alpha", "web-old"), nil, false, nil},
		{"none from an Event", ownership.Unrestricted(), at("", "Pod", "alpha", "web-1-a"), nil, false, nil},
		{"a cluster-scoped owner's, anywhere", ownership.Unrestricted(), at("", "Node", "", "n1"),
			[]string{"ClusterWidget.example.io cw1", "Gadget.example.io beta/g1"}, false, nil},
		{"only those the caller may read", access{namespaces: []string{"alpha"}}, at("", "Node", "", "n1"),
			[]string{"ClusterWidget.example.io cw1"}, true, nil},
		{"none of a kind the caller may not read", access{denied: []resourceid.GroupKind{configMaps}}, at("example.io", "Widget", "alpha", "w1"), nil, true, unlistedWidget},
	} {
		t.Run(tc.name, func(t *testing.T) {
			got := view(t, w, tc.access).Children(tc.owner)
			if !got.Subject.Present() || !slices.Equal(got.Gaps, tc.gaps) || got.Complete() != (len(tc.gaps) == 0) {
				t.Fatalf("Children = %+v; want gaps %v", got, tc.gaps)
			}
			if refs := childRefs(got); !slices.Equal(refs, tc.want) || got.Narrowed != tc.narrow {
				t.Fatalf("Children = %q narrowed=%v; want %q narrowed=%v", refs, got.Narrowed, tc.want, tc.narrow)
			}
		})
	}
}

func TestViewChildrenReportWhatTheSearchCouldNotRead(t *testing.T) {
	w := ownershiptest.Fixture()
	w.Collection[pods] = ownershiptest.Collection{State: ownershiptest.Loading}
	got := view(t, w, ownership.Unrestricted()).Children(at("apps", "ReplicaSet", "alpha", "web-1"))
	// One store serves every namespace, so its gap names none.
	want := []ownership.Gap{{Kind: pods, Reason: readreason.KindSyncPending}}
	if len(got.Children) != 0 || !slices.Equal(got.Gaps, want) || got.Complete() {
		t.Fatalf("Children while Pods load = %q gaps %+v; want none and %+v", childRefs(got), got.Gaps, want)
	}
	// A caller who may not read Pods hears nothing of Radar's Pod stores.
	got = view(t, w, access{denied: []resourceid.GroupKind{pods}}).Children(at("apps", "ReplicaSet", "alpha", "web-1"))
	if len(got.Gaps) != 0 || !got.Narrowed {
		t.Fatalf("Children for a caller denied Pods = gaps %+v narrowed=%v", got.Gaps, got.Narrowed)
	}
}

func TestViewControllerChain(t *testing.T) {
	w := ownershiptest.Fixture()
	stray := at("", "Pod", "beta", "stray")
	for _, tc := range []struct {
		name   string
		access ownership.Access
		start  resourceid.Ref
		end    resourceid.Ref
		stop   ownership.OwnerState
	}{
		{"through a ReplicaSet", ownership.Unrestricted(), at("", "Pod", "alpha", "web-1-a"), at("apps", "Deployment", "alpha", "web"), 0},
		{"an object with no controller", ownership.Unrestricted(), at("apps", "Deployment", "alpha", "web"), at("apps", "Deployment", "alpha", "web"), 0},
		{"stops at a controller that's gone", ownership.Unrestricted(), stray, stray, ownership.OwnerGone},
		{"stops at a withheld controller", access{denied: []resourceid.GroupKind{replicaSets}}, at("", "Pod", "alpha", "web-1-a"), at("", "Pod", "alpha", "web-1-a"), ownership.OwnerWithheld},
		{"ends at a Node, outside the chain", ownership.Unrestricted(), at("example.io", "Gadget", "beta", "g1"), at("example.io", "Gadget", "beta", "g1"), 0},
	} {
		t.Run(tc.name, func(t *testing.T) {
			got := view(t, w, tc.access).ControllerChain(tc.start)
			stop := ownership.OwnerState(0)
			if got.UnfollowedReference != nil {
				stop = got.UnfollowedReference.State
			}
			if got.Chain[len(got.Chain)-1] != tc.end || (got.UnfollowedReference != nil) != (tc.stop != 0) || stop != tc.stop || got.Cycle || got.Truncated {
				t.Fatalf("ControllerChain = %+v (stop %v); want to end at %v, stop %v", got, stop, tc.end, tc.stop)
			}
		})
	}
}

func TestViewControllerChainTerminals(t *testing.T) {
	b := ownershiptest.NewBuilder()
	missingNode := metav1.OwnerReference{APIVersion: "v1", Kind: "Node", Name: "gone", UID: "uid-gone"}
	yes := true
	missingNode.Controller = &yes
	mirror := b.Add("", "Pod", "kube-system", "etcd-gone", missingNode)
	refTo := func(name string) metav1.OwnerReference {
		return metav1.OwnerReference{APIVersion: "example.io/v1", Kind: "Widget", Name: name, UID: types.UID("uid-x-" + name), Controller: &yes}
	}
	loopA := b.Add("example.io", "Widget", "x", "a", refTo("b"))
	loopB := b.Add("example.io", "Widget", "x", "b", refTo("a"))
	tail := b.Add("example.io", "Widget", "x", "tail", refTo("b"))
	const length = 48
	for i := range length {
		b.Add("example.io", "Widget", "chain", fmt.Sprint(i), metav1.OwnerReference{APIVersion: "example.io/v1", Kind: "Widget", Name: fmt.Sprint(i + 1), UID: types.UID(fmt.Sprintf("uid-chain-%d", i+1)), Controller: &yes})
	}
	v := view(t, b.World(), ownership.Unrestricted())

	if got := v.ControllerChain(mirror.Ref); !slices.Equal(got.Chain, []resourceid.Ref{mirror.Ref}) || got.UnfollowedReference == nil || got.UnfollowedReference.State != ownership.OwnerGone {
		t.Errorf("a mirror Pod of a deleted Node = %+v; want itself alone, stopped by the Node, which is gone", got)
	}
	for _, start := range []ownershiptest.Object{loopA, loopB, tail} {
		if got := v.ControllerChain(start.Ref); !got.Cycle || !slices.Contains(got.Chain, loopA.Ref) || !slices.Contains(got.Chain, loopB.Ref) || got.UnfollowedReference != nil {
			t.Errorf("from %v = %+v; want a cycle through a and b from any start", start.Ref, got)
		}
	}
	if got := v.ControllerChain(at("example.io", "Widget", "chain", "0")); !got.Truncated || got.Chain[len(got.Chain)-1] != at("example.io", "Widget", "chain", "16") || len(got.Chain) != 17 {
		t.Errorf("an over-long chain = %+v; want a walk bounded at 16 references", got)
	}
	if got := v.ControllerChain(tail.Ref); !slices.Equal(got.Chain, []resourceid.Ref{tail.Ref, loopB.Ref, loopA.Ref}) {
		t.Errorf("chain into a loop = %v; want tail, b, a", got.Chain)
	}
}

func TestViewControllerChainFollowsEachController(t *testing.T) {
	v := view(t, ownershiptest.Fixture(), ownership.Unrestricted())
	pod := at("", "Pod", "alpha", "web-1-a")
	want := []resourceid.Ref{pod, at("apps", "ReplicaSet", "alpha", "web-1"), at("apps", "Deployment", "alpha", "web")}
	if got := v.ControllerChain(pod); !slices.Equal(got.Chain, want) {
		t.Fatalf("chain = %v, want %v", got.Chain, want)
	}
	stray := at("", "Pod", "beta", "stray")
	if got := v.ControllerChain(stray); !slices.Equal(got.Chain, []resourceid.Ref{stray}) {
		t.Fatalf("chain stopped at the first reference = %v", got.Chain)
	}
}

func TestViewMembers(t *testing.T) {
	w := ownershiptest.Fixture()
	app := func(name string) resourceid.Ref { return at("argoproj.io", "Application", "argocd", name) }
	refs := func(a ownership.MembersAnswer) []resourceid.Ref {
		var out []resourceid.Ref
		for _, m := range a.Members {
			out = append(out, m.Ref)
		}
		return out
	}
	full := view(t, w, ownership.Unrestricted()).Members(app("web"))
	want := []resourceid.Ref{
		at("", "ConfigMap", "alpha", "cfg"), at("", "Node", "", "n1"), at("apps", "Deployment", "alpha", "web"),
		at("example.io", "Gadget", "beta", "g1"), at("example.io", "Node", "alpha", "n1"), at("example.io", "Widget", "alpha", "w1"),
	}
	unserved := ownership.Gap{Kind: resourceid.GroupKind{Group: "example.io", Kind: "Unserved"}, Namespace: "alpha", Reason: readreason.KindUnknown}
	if full.Membership != ownership.MembersRecorded || !slices.Equal(refs(full), want) || full.Missing != 1 || full.Withheld != 0 || full.Unknown != 1 || !slices.Equal(full.Gaps, []ownership.Gap{unserved}) {
		t.Fatalf("members = %v %v missing %d withheld %d unknown %d gaps %+v", full.Membership, refs(full), full.Missing, full.Withheld, full.Unknown, full.Gaps)
	}
	alpha := view(t, w, access{namespaces: []string{"alpha", "argocd"}}).Members(app("web"))
	if slices.Contains(refs(alpha), at("example.io", "Gadget", "beta", "g1")) || alpha.Withheld != 1 || len(alpha.Members) != len(want)-1 {
		t.Fatalf("members a caller in alpha sees = %v withheld %d", refs(alpha), alpha.Withheld)
	}
	unsure := view(t, w, access{undecided: []resourceid.GroupKind{configMaps}}).Members(app("web"))
	checkFailed := ownership.Gap{Kind: configMaps, Namespace: "alpha", Reason: readreason.UserPermissionCheckFailed}
	if slices.Contains(refs(unsure), at("", "ConfigMap", "alpha", "cfg")) || unsure.Unknown != 2 || unsure.Withheld != 0 || !slices.Equal(unsure.Gaps, []ownership.Gap{checkFailed, unserved}) {
		t.Fatalf("members when the caller's ConfigMap access couldn't be checked = %v unknown %d withheld %d gaps %+v", refs(unsure), unsure.Unknown, unsure.Withheld, unsure.Gaps)
	}
	for name, state := range map[string]ownership.MembershipState{"elsewhere": ownership.MembersRemote, "new": ownership.MembersUnreadable, "empty-list": ownership.MembersRecorded} {
		if got := view(t, w, ownership.Unrestricted()).Members(app(name)); got.Membership != state || len(got.Members) != 0 || got.Missing+got.Withheld+got.Unknown != 0 {
			t.Errorf("%s = %+v; want %v and no members", name, got, state)
		}
	}
}

// A reference carrying an owner's UID under other coordinates never resolves
// to the owner, since UIDs are unique: however the read of those coordinates
// goes, the object isn't a child and leaves the answer complete. A reference
// Radar can't map to a kind may still be the owner's, and stays a gap.
func TestViewChildrenIgnoreReferencesThatCantNameTheOwner(t *testing.T) {
	b := ownershiptest.NewBuilder()
	rs := b.Add("apps", "ReplicaSet", "alpha", "web")
	b.Add("", "Pod", "alpha", "web-a", rs.OwnerRef(true))
	odd := rs.OwnerRef(false)
	odd.APIVersion, odd.Kind = "v1", "Node"
	b.Add("", "Pod", "alpha", "odd", odd)
	elsewhere := rs.OwnerRef(false)
	elsewhere.APIVersion, elsewhere.Name = "apps/v9", "other"
	b.Add("", "Pod", "alpha", "elsewhere", elsewhere)
	w := b.World()
	w.Collection[resourceid.GroupKind{Kind: "Node"}] = ownershiptest.Collection{State: ownershiptest.Loading}
	if got := view(t, w, ownership.Unrestricted()).Children(rs.Ref); !got.Complete() || len(got.Children) != 1 {
		t.Fatalf("children %q gaps %+v; a Node named web, or a reference to another name, can't be to the ReplicaSet", childRefs(got), got.Gaps)
	}

	unmapped := rs.OwnerRef(false)
	unmapped.APIVersion = "apps/v9"
	b.Add("", "Pod", "alpha", "unmapped", unmapped)
	got := view(t, b.World(), ownership.Unrestricted()).Children(rs.Ref)
	if want := (ownership.Gap{Kind: rs.Ref.GroupKind(), Reason: readreason.KindUnknown}); !slices.Contains(got.Gaps, want) {
		t.Fatalf("gaps %+v; want %+v for a reference to web at a version discovery doesn't list", got.Gaps, want)
	}
}

// An object naming its owner twice is one child.
func TestViewChildrenListAnObjectNamingItsOwnerTwiceOnce(t *testing.T) {
	b := ownershiptest.NewBuilder()
	rs := b.Add("apps", "ReplicaSet", "alpha", "web")
	b.Add("", "Pod", "alpha", "dup", rs.OwnerRef(true), rs.OwnerRef(false))
	v := view(t, b.World(), ownership.Unrestricted())
	v.Owner(at("", "Pod", "alpha", "dup"))
	if got := v.Children(rs.Ref); len(got.Children) != 1 {
		t.Fatalf("children = %q; want dup once", childRefs(got))
	}
}

// A manager may list an Event; ownership doesn't read Events, so the answer
// leaves it out rather than counting a member it can't say anything about.
func TestViewMembersLeaveOutEvents(t *testing.T) {
	b := ownershiptest.NewBuilder()
	cm := b.Add("", "ConfigMap", "alpha", "cm")
	app := b.Manager(ownership.ArgoApplication, "argocd", "app", ownership.MembersRecorded, "alpha", at("", "Event", "alpha", "e"), cm.Ref)
	for name, a := range map[string]ownership.Access{"everything": ownership.Unrestricted(), "no Events": access{denied: []resourceid.GroupKind{{Kind: "Event"}}}} {
		got := view(t, b.World(), a).Members(app.Ref)
		if len(got.Members) != 1 || got.Missing+got.Withheld+got.Unknown != 0 || len(got.Gaps) != 0 {
			t.Fatalf("%s: members %d missing %d withheld %d unknown %d gaps %+v; want the ConfigMap alone", name, len(got.Members), got.Missing, got.Withheld, got.Unknown, got.Gaps)
		}
	}
}

func TestDecisionNames(t *testing.T) {
	for d, want := range map[ownership.Decision]string{ownership.Denied: "denied", ownership.Allowed: "allowed", ownership.Undecided: "undecided", ownership.Decision(99): "denied"} {
		if got := d.String(); got != want {
			t.Errorf("Decision(%d) = %q, want %q", d, got, want)
		}
	}
}

// Owner and Children are one relation read from both ends: an object is its
// owner's child exactly when its reference to that owner resolves.
func TestViewOwnersAndChildrenAgree(t *testing.T) {
	worlds := []ownershiptest.World{ownershiptest.Fixture()}
	for seed := range uint64(30) {
		worlds = append(worlds, ownershiptest.Generate(seed))
	}
	for i, w := range worlds {
		v := view(t, w, ownership.Unrestricted())
		for _, o := range w.Objects {
			subject, resolutions := v.References(o.Ref)
			if !subject.Present() {
				continue
			}
			for _, r := range resolutions {
				if r.State != ownership.OwnerFound {
					continue
				}
				children := v.Children(r.Ref)
				if !children.Complete() {
					continue
				}
				if !slices.ContainsFunc(children.Children, func(c ownership.Observation) bool { return c.Ref == o.Ref }) {
					t.Errorf("world %d: %v resolves its owner %v, which doesn't list it among %q", i, o.Ref, r.Ref, childRefs(children))
				}
			}
			for _, c := range v.Children(o.Ref).Children {
				_, theirs := v.References(c.Ref)
				if !slices.ContainsFunc(theirs, func(r ownership.OwnerResolution) bool { return r.State == ownership.OwnerFound && r.Ref == o.Ref }) {
					t.Errorf("world %d: %v lists %v, whose references don't resolve to it", i, o.Ref, c.Ref)
				}
			}
		}
	}
}

// A caller who may read less gets withheld or narrowed answers, never a
// different named one, and never a name it may not read.
func TestViewAnswersOnlyNarrowForACallerWhoMayReadLess(t *testing.T) {
	worlds := []ownershiptest.World{ownershiptest.Fixture()}
	for seed := range uint64(30) {
		worlds = append(worlds, ownershiptest.Generate(seed))
	}
	callers := []access{
		{namespaces: []string{"alpha"}},
		{namespaces: []string{"beta", "gamma"}},
		{namespaces: []string{}},
		{denied: []resourceid.GroupKind{replicaSets}},
		{denied: []resourceid.GroupKind{{Kind: "Node"}, {Group: "example.io", Kind: "Widget"}}},
		{namespaces: []string{"alpha"}, undecided: []resourceid.GroupKind{configMaps}},
	}
	for i, w := range worlds {
		full := view(t, w, ownership.Unrestricted())
		for _, caller := range callers {
			v := view(t, w, caller)
			for _, o := range w.Objects {
				checkNarrows(t, fmt.Sprintf("world %d, %+v, %v", i, caller, o.Ref), full, v, caller, o.Ref)
			}
		}
	}
}

func checkNarrows(t *testing.T, where string, full, v *ownership.View, caller access, ref resourceid.Ref) {
	t.Helper()
	readable := func(r resourceid.Ref) bool { return caller.CanRead(r.GroupKind(), r.Namespace) == ownership.Allowed }
	subject, got := v.References(ref)
	if !readable(ref) {
		if subject.Present() || got != nil {
			t.Errorf("%s: answers for an object the caller may not read", where)
		}
		return
	}
	_, want := full.References(ref)
	if len(got) != len(want) {
		t.Fatalf("%s: %d references, want %d", where, len(got), len(want))
	}
	for i := range got {
		switch {
		case got[i].State == want[i].State && got[i].Ref == want[i].Ref && got[i].Gap == want[i].Gap:
		case got[i].Ref == want[i].Ref && !readable(want[i].Ref) && (got[i].State == ownership.OwnerWithheld || got[i].Gap.Reason == readreason.UserPermissionCheckFailed):
		default:
			t.Errorf("%s: reference %d = %v %v %+v, full %v %v %+v", where, i, got[i].State, got[i].Ref, got[i].Gap, want[i].State, want[i].Ref, want[i].Gap)
		}
	}
	children, all := v.Children(ref), full.Children(ref)
	for _, c := range children.Children {
		if !readable(c.Ref) || !slices.ContainsFunc(all.Children, func(f ownership.Observation) bool { return f.Ref == c.Ref }) {
			t.Errorf("%s: child %v isn't one the full view has and the caller may read", where, c.Ref)
		}
	}
	if len(children.Children) < len(all.Children) && !children.Narrowed {
		t.Errorf("%s: %d of %d children without saying the answer is narrowed", where, len(children.Children), len(all.Children))
	}
	chain, fullChain := v.ControllerChain(ref), full.ControllerChain(ref)
	if !slices.Equal(chain.Chain, fullChain.Chain) && (chain.UnfollowedReference == nil || chain.UnfollowedReference.State != ownership.OwnerWithheld && chain.UnfollowedReference.Gap.Reason != readreason.UserPermissionCheckFailed) {
		t.Errorf("%s: chain %v, full %v, without a withheld stop", where, chain.Chain, fullChain.Chain)
	}
	if ownership.IsRecordingManager(ref.GroupKind()) {
		members, allMembers := v.Members(ref), full.Members(ref)
		for _, m := range members.Members {
			if !readable(m.Ref) || !slices.ContainsFunc(allMembers.Members, func(f ownership.Observation) bool { return f.Ref == m.Ref }) {
				t.Errorf("%s: member %v isn't one the full view has and the caller may read", where, m.Ref)
			}
		}
		if hidden := len(allMembers.Members) - len(members.Members); hidden > members.Withheld+members.Unknown {
			t.Errorf("%s: %d members dropped, %d withheld and %d unknown", where, hidden, members.Withheld, members.Unknown)
		}
	}
}

// managersWithGaps answers RecordingManagers with the session's answer plus gaps.
type managersWithGaps struct {
	ownership.ReadSession
	gaps []ownership.Gap
}

func (s managersWithGaps) RecordingManagers(ref resourceid.Ref) ownership.RecordingManagersRead {
	r := s.ReadSession.RecordingManagers(ref)
	r.Gaps = append(r.Gaps, s.gaps...)
	return r
}

// A caller learns which manager kinds Radar couldn't search, and where among
// the namespaces it may read, but nothing of the namespaces it can't.
func TestRecordingManagersKeepGapsTheCallerMayRead(t *testing.T) {
	s, err := ownershiptest.Scan(ownershiptest.Fixture()).Begin()
	if err != nil {
		t.Fatal(err)
	}
	everywhere := ownership.Gap{Kind: ownership.FluxHelmRelease, Reason: readreason.KindSyncPending}
	alpha := ownership.Gap{Kind: ownership.ArgoApplication, Namespace: "alpha", Reason: readreason.KindSyncPending}
	beta := ownership.Gap{Kind: ownership.ArgoApplication, Namespace: "beta", Reason: readreason.RadarForbidden}
	v := ownership.NewView(managersWithGaps{s, []ownership.Gap{everywhere, alpha, beta}}, access{namespaces: []string{"alpha"}})
	got := v.RecordingManagers(at("", "ConfigMap", "alpha", "cfg"))
	if !slices.Contains(got.Gaps, everywhere) || !slices.Contains(got.Gaps, alpha) || slices.Contains(got.Gaps, beta) {
		t.Fatalf("gaps = %v; want %v and %v, not %v", got.Gaps, everywhere, alpha, beta)
	}
}