Ctrl K
ComunidadGuías…

pkg/k8score/dynamic_cache_observation_test.go

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

Archivo original ↗Preguntar a KiBot
package k8score

import (
	"context"
	"errors"
	"reflect"
	"sync/atomic"
	"testing"
	"time"

	apierrors "k8s.io/apimachinery/pkg/api/errors"
	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
	dynamicfake "k8s.io/client-go/dynamic/fake"
	k8stesting "k8s.io/client-go/testing"
)

func TestDynamicResourceObservationUnwatchedAndUnsupported(t *testing.T) {
	watchable := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"}
	unsupported := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "summaries"}
	discovery := &ResourceDiscovery{
		resourceMap: make(map[string]APIResource),
		gvrMap:      make(map[string]schema.GroupVersionResource),
	}
	discovery.AddAPIResource(APIResource{
		Group: watchable.Group, Version: watchable.Version, Kind: "Widget", Name: watchable.Resource,
		IsCRD: true, Verbs: []string{"get", "list", "watch"},
	})
	discovery.AddAPIResource(APIResource{
		Group: unsupported.Group, Version: unsupported.Version, Kind: "Summary", Name: unsupported.Resource,
		IsCRD: true, Verbs: []string{"get", "list"},
	})
	dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{
		watchable: "WidgetList",
	})
	cache, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dyn, Discovery: discovery})
	if err != nil {
		t.Fatalf("NewDynamicResourceCache: %v", err)
	}
	t.Cleanup(cache.Stop)

	for _, test := range []struct {
		name   string
		gvr    schema.GroupVersionResource
		state  DynamicObservationState
		reason string
	}{
		{name: "unwatched", gvr: watchable, state: DynamicObservationUnwatched, reason: "not_observed"},
		{name: "unsupported", gvr: unsupported, state: DynamicObservationUnsupported, reason: "list_watch_unsupported"},
	} {
		t.Run(test.name, func(t *testing.T) {
			got := cache.Observation(test.gvr)
			if got.State != test.state || got.ReasonCode != test.reason {
				t.Fatalf("observation = %+v, want state=%s reason=%s", got, test.state, test.reason)
			}
		})
	}
	if err := cache.EnsureWatching(unsupported); !errors.Is(err, ErrCollectionNotSupported) {
		t.Fatalf("unsupported GVR cause = %v", err)
	}
	discovery.AddAPIResource(APIResource{
		Group: unsupported.Group, Version: unsupported.Version, Kind: "Summary", Name: unsupported.Resource,
		IsCRD: true, Verbs: []string{"get", "list", "watch"},
	})
	if got := cache.Observation(unsupported); got.State != DynamicObservationUnwatched || got.ReasonCode != "not_observed" {
		t.Fatalf("refreshed watchable GVR retained unsupported state: %+v", got)
	}
	if actions := dyn.Actions(); len(actions) != 0 {
		t.Fatalf("read-only observations touched the API server: %v", actions)
	}
}

func TestDynamicResourceObservationTracksOnDemandSyncAndExactGVR(t *testing.T) {
	observed := schema.GroupVersionResource{Group: "gateway.networking.k8s.io", Version: "v1", Resource: "gateways"}
	colliding := schema.GroupVersionResource{Group: "networking.istio.io", Version: "v1", Resource: "gateways"}
	discovery := &ResourceDiscovery{
		resourceMap: make(map[string]APIResource),
		gvrMap:      make(map[string]schema.GroupVersionResource),
	}
	for _, gvr := range []schema.GroupVersionResource{observed, colliding} {
		discovery.AddAPIResource(APIResource{
			Group: gvr.Group, Version: gvr.Version, Kind: "Gateway", Name: gvr.Resource,
			Namespaced: true, IsCRD: true, Verbs: []string{"get", "list", "watch"},
		})
	}
	dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{
		observed:  "GatewayList",
		colliding: "GatewayList",
	})
	releaseSync := make(chan struct{})
	var observedLists atomic.Int32
	dyn.PrependReactor("list", "gateways", func(action k8stesting.Action) (bool, runtime.Object, error) {
		if action.GetResource() != observed {
			return false, nil, nil
		}
		if observedLists.Add(1) > 1 {
			<-releaseSync
		}
		return false, nil, nil
	})
	cache, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dyn, Discovery: discovery})
	if err != nil {
		t.Fatalf("NewDynamicResourceCache: %v", err)
	}
	t.Cleanup(cache.Stop)

	if err := cache.EnsureWatching(observed); err != nil {
		t.Fatalf("EnsureWatching: %v", err)
	}
	syncing := cache.Observation(observed)
	if syncing.State != DynamicObservationSyncing {
		t.Fatalf("syncing observation = %+v", syncing)
	}
	if syncing.Scope != DynamicObservationScopeCluster || syncing.ReasonCode != "initial_sync" {
		t.Fatalf("syncing scope/reason = %+v", syncing)
	}
	if got := cache.Observation(colliding); got.State != DynamicObservationUnwatched {
		t.Fatalf("same-named GVR inherited observation from another group: %+v", got)
	}

	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
	started := time.Now()
	_, waitErr := cache.ListBlockingContext(ctx, observed, "", 3*time.Second)
	cancel()
	if waitErr != nil && !errors.Is(waitErr, context.DeadlineExceeded) {
		t.Fatalf("cancelled caller wait: %v", waitErr)
	}
	if time.Since(started) > 2*time.Second || cache.Observation(observed).State != DynamicObservationSyncing {
		t.Fatal("caller cancellation did not bound the wait independently of the shared watch")
	}

	close(releaseSync)
	if !cache.WaitForSync(observed, 3*time.Second) {
		t.Fatal("on-demand informer did not sync")
	}
	watched := cache.Observation(observed)
	if watched.State != DynamicObservationSynced || watched.ReasonCode != "informer_synced" {
		t.Fatalf("watched observation = %+v", watched)
	}
	if cache.IsStopped() {
		t.Fatal("running cache reported stopped")
	}
	cache.Stop()
	if !cache.IsStopped() {
		t.Fatal("stopped cache reported running")
	}
	if got := cache.Observation(observed); got.State != DynamicObservationUnwatched || got.ReasonCode != "cache_stopped" {
		t.Fatalf("stopped cache retained active observation: %+v", got)
	}
}

func TestDynamicResourceObservationRetainsDeniedAndDeferredOutcomes(t *testing.T) {
	deniedGVR := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "deniedwidgets"}
	deniedDiscovery := &ResourceDiscovery{
		resourceMap: make(map[string]APIResource),
		gvrMap:      make(map[string]schema.GroupVersionResource),
		lastRefresh: time.Now(),
		cacheTTL:    time.Hour,
	}
	deniedDiscovery.AddAPIResource(APIResource{
		Group: deniedGVR.Group, Version: deniedGVR.Version, Kind: "DeniedWidget", Name: deniedGVR.Resource,
		Namespaced: true, IsCRD: true, Verbs: []string{"get", "list", "watch"},
	})
	deniedClient := fakeDynamicForListAccess(t, map[schema.GroupVersionResource]string{deniedGVR: "DeniedWidgetList"}, func(schema.GroupVersionResource, string) bool {
		return false
	})
	deniedCache, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: deniedClient, Discovery: deniedDiscovery})
	if err != nil {
		t.Fatalf("NewDynamicResourceCache denied: %v", err)
	}
	t.Cleanup(deniedCache.Stop)
	if err := deniedCache.EnsureWatching(deniedGVR); err == nil {
		t.Fatal("EnsureWatching denied GVR succeeded")
	}
	denied := deniedCache.Observation(deniedGVR)
	if denied.State != DynamicObservationDenied || denied.ReasonCode != "access_denied" {
		t.Fatalf("denied observation = %+v", denied)
	}

	truncatedClient := fakeDynamicForListAccess(t, map[schema.GroupVersionResource]string{deniedGVR: "DeniedWidgetList"}, func(schema.GroupVersionResource, string) bool {
		return false
	})
	truncatedCache, err := NewDynamicResourceCache(DynamicCacheConfig{
		DynamicClient:               truncatedClient,
		Discovery:                   deniedDiscovery,
		NamespaceFallbacks:          []string{"team-a"},
		NamespaceFallbacksTruncated: true,
	})
	if err != nil {
		t.Fatalf("NewDynamicResourceCache truncated denial: %v", err)
	}
	t.Cleanup(truncatedCache.Stop)
	if err := truncatedCache.EnsureWatching(deniedGVR); err == nil {
		t.Fatal("EnsureWatching truncated denied GVR succeeded")
	}
	truncatedDenied := truncatedCache.Observation(deniedGVR)
	if truncatedDenied.State != DynamicObservationDenied || !truncatedDenied.Truncated || truncatedDenied.ReasonCode != "access_denied_partial_probe" {
		t.Fatalf("truncated denied observation = %+v", truncatedDenied)
	}

	incompleteCandidatesClient := fakeDynamicForListAccess(t, map[schema.GroupVersionResource]string{deniedGVR: "DeniedWidgetList"}, func(schema.GroupVersionResource, string) bool {
		return false
	})
	incompleteCandidatesCache, err := NewDynamicResourceCache(DynamicCacheConfig{
		DynamicClient:               incompleteCandidatesClient,
		Discovery:                   deniedDiscovery,
		NamespaceFallbacksTruncated: true,
	})
	if err != nil {
		t.Fatalf("NewDynamicResourceCache incomplete candidates: %v", err)
	}
	t.Cleanup(incompleteCandidatesCache.Stop)
	if err := incompleteCandidatesCache.EnsureWatching(deniedGVR); err == nil {
		t.Fatal("EnsureWatching incomplete-candidate denied GVR succeeded")
	}
	incompleteDenied := incompleteCandidatesCache.Observation(deniedGVR)
	if incompleteDenied.State != DynamicObservationDenied || !incompleteDenied.Truncated || incompleteDenied.ReasonCode != "access_denied_partial_probe" {
		t.Fatalf("incomplete-candidate denied observation = %+v", incompleteDenied)
	}

	clusterScopedGVR := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "clustersummaries"}
	clusterScopedDiscovery := &ResourceDiscovery{
		resourceMap: make(map[string]APIResource),
		gvrMap:      make(map[string]schema.GroupVersionResource),
		lastRefresh: time.Now(),
		cacheTTL:    time.Hour,
	}
	clusterScopedDiscovery.AddAPIResource(APIResource{
		Group: clusterScopedGVR.Group, Version: clusterScopedGVR.Version, Kind: "ClusterSummary", Name: clusterScopedGVR.Resource,
		IsCRD: true, Verbs: []string{"get", "list", "watch"},
	})
	clusterScopedClient := fakeDynamicForListAccess(t, map[schema.GroupVersionResource]string{clusterScopedGVR: "ClusterSummaryList"}, func(schema.GroupVersionResource, string) bool {
		return false
	})
	clusterScopedCache, err := NewDynamicResourceCache(DynamicCacheConfig{
		DynamicClient:               clusterScopedClient,
		Discovery:                   clusterScopedDiscovery,
		NamespaceFallbacks:          []string{"team-a"},
		NamespaceFallbacksTruncated: true,
	})
	if err != nil {
		t.Fatalf("NewDynamicResourceCache cluster-scoped denial: %v", err)
	}
	t.Cleanup(clusterScopedCache.Stop)
	if err := clusterScopedCache.EnsureWatching(clusterScopedGVR); err == nil {
		t.Fatal("EnsureWatching denied cluster-scoped GVR succeeded")
	}
	clusterScopedDenied := clusterScopedCache.Observation(clusterScopedGVR)
	if clusterScopedDenied.State != DynamicObservationDenied || clusterScopedDenied.Truncated || clusterScopedDenied.ReasonCode != "access_denied" {
		t.Fatalf("cluster-scoped denied observation = %+v", clusterScopedDenied)
	}

	for _, test := range []struct {
		name       string
		probeError error
		panicProbe bool
		remaining  int64
		reason     string
	}{
		{name: "probe failed", probeError: errors.New("temporary proxy failure"), reason: "resource_count_probe_failed"},
		{name: "probe panicked", panicProbe: true, reason: "resource_count_probe_failed"},
		{name: "large resource", remaining: 101, reason: "resource_count_exceeds_eager_limit"},
	} {
		t.Run(test.name, func(t *testing.T) {
			gvr := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"}
			discovery := &ResourceDiscovery{
				resources: []APIResource{{
					Group: gvr.Group, Version: gvr.Version, Kind: "Widget", Name: gvr.Resource,
					Namespaced: true, IsCRD: true, Verbs: []string{"get", "list", "watch"},
				}},
				resourceMap: make(map[string]APIResource),
				gvrMap:      make(map[string]schema.GroupVersionResource),
				lastRefresh: time.Now(),
				cacheTTL:    time.Hour,
			}
			dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{gvr: "WidgetList"})
			dyn.PrependReactor("list", "widgets", func(k8stesting.Action) (bool, runtime.Object, error) {
				if test.panicProbe {
					panic("probe panic")
				}
				if test.probeError != nil {
					return true, nil, test.probeError
				}
				list := &unstructured.UnstructuredList{}
				list.SetRemainingItemCount(&test.remaining)
				return true, list, nil
			})
			cache, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dyn, Discovery: discovery})
			if err != nil {
				t.Fatalf("NewDynamicResourceCache: %v", err)
			}
			t.Cleanup(cache.Stop)
			cache.DiscoverAllCRDs()
			select {
			case <-cache.discoveryDone:
			case <-time.After(3 * time.Second):
				t.Fatal("DiscoverAllCRDs did not complete")
			}

			got := cache.Observation(gvr)
			if got.State != DynamicObservationDeferred || got.ReasonCode != test.reason {
				t.Fatalf("deferred observation = %+v, want reason=%s", got, test.reason)
			}
			if test.name == "large resource" {
				if err := cache.EnsureWatching(gvr); err != nil {
					t.Fatalf("EnsureWatching deferred GVR: %v", err)
				}
				cache.mu.Lock()
				for key, entry := range cache.informers {
					if key.gvr == gvr {
						entry.cancel()
						delete(cache.informers, key)
					}
				}
				cache.mu.Unlock()
				if after := cache.Observation(gvr); after.State != DynamicObservationUnwatched {
					t.Fatalf("successful watch retained stale deferred decision after informer removal: %+v", after)
				}
			}
		})
	}
}

func TestDynamicResourceObservationPreferredNamespaceDenialDoesNotOverwriteGVRState(t *testing.T) {
	gvr := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"}
	discovery := &ResourceDiscovery{
		resourceMap: make(map[string]APIResource),
		gvrMap:      make(map[string]schema.GroupVersionResource),
		lastRefresh: time.Now(),
		cacheTTL:    time.Hour,
	}
	discovery.AddAPIResource(APIResource{
		Group: gvr.Group, Version: gvr.Version, Kind: "Widget", Name: gvr.Resource,
		Namespaced: true, IsCRD: true, Verbs: []string{"get", "list", "watch"},
	})
	dyn := fakeDynamicForListAccess(t, map[schema.GroupVersionResource]string{gvr: "WidgetList"}, func(schema.GroupVersionResource, string) bool {
		return false
	})
	cache, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dyn, Discovery: discovery})
	if err != nil {
		t.Fatalf("NewDynamicResourceCache: %v", err)
	}
	t.Cleanup(cache.Stop)
	cache.retainObservation(gvr, DynamicObservationDeferred, "prior_global_state", false)

	if err := cache.ensureWatching(gvr, "team-a"); err == nil {
		t.Fatal("ensureWatching denied preferred namespace succeeded")
	}
	got := cache.Observation(gvr)
	if got.State != DynamicObservationDeferred || got.ReasonCode != "prior_global_state" {
		t.Fatalf("preferred-namespace denial overwrote GVR observation: %+v", got)
	}
}

func TestDynamicResourceObservationTracksWarmupEagerAndNamespaceFanout(t *testing.T) {
	t.Run("warmup", func(t *testing.T) {
		gvr := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"}
		dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{gvr: "WidgetList"})
		cache, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dyn})
		if err != nil {
			t.Fatalf("NewDynamicResourceCache: %v", err)
		}
		t.Cleanup(cache.Stop)
		cache.WarmupParallel([]schema.GroupVersionResource{gvr}, 3*time.Second)
		got := cache.Observation(gvr)
		if got.State != DynamicObservationSynced {
			t.Fatalf("warmup observation = %+v", got)
		}
	})

	t.Run("small eager", func(t *testing.T) {
		gvr := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"}
		discovery := &ResourceDiscovery{
			resources: []APIResource{{
				Group: gvr.Group, Version: gvr.Version, Kind: "Widget", Name: gvr.Resource,
				Namespaced: true, IsCRD: true, Verbs: []string{"get", "list", "watch"},
			}},
			resourceMap: make(map[string]APIResource),
			gvrMap:      make(map[string]schema.GroupVersionResource),
			lastRefresh: time.Now(),
			cacheTTL:    time.Hour,
		}
		dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{gvr: "WidgetList"})
		cache, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dyn, Discovery: discovery})
		if err != nil {
			t.Fatalf("NewDynamicResourceCache: %v", err)
		}
		t.Cleanup(cache.Stop)
		cache.DiscoverAllCRDs()
		select {
		case <-cache.discoveryDone:
		case <-time.After(3 * time.Second):
			t.Fatal("DiscoverAllCRDs did not complete")
		}
		got := cache.Observation(gvr)
		if got.State != DynamicObservationSynced {
			t.Fatalf("small-eager observation = %+v", got)
		}
	})

	t.Run("namespace partial truncated", func(t *testing.T) {
		gvr := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"}
		dyn := fakeDynamicForListAccess(t, map[schema.GroupVersionResource]string{gvr: "WidgetList"}, func(_ schema.GroupVersionResource, namespace string) bool {
			return namespace == "team-a" || namespace == "team-b"
		})
		cache, err := NewDynamicResourceCache(DynamicCacheConfig{
			DynamicClient:               dyn,
			NamespaceFallbacks:          []string{"team-b", "team-a"},
			NamespaceFallbacksTruncated: true,
		})
		if err != nil {
			t.Fatalf("NewDynamicResourceCache: %v", err)
		}
		t.Cleanup(cache.Stop)
		if err := cache.EnsureWatching(gvr); err != nil {
			t.Fatalf("EnsureWatching: %v", err)
		}
		if !cache.WaitForSync(gvr, 3*time.Second) {
			t.Fatal("namespace-scoped informers did not sync")
		}
		got := cache.Observation(gvr)
		if got.State != DynamicObservationSynced || got.Scope != DynamicObservationScopeExplicitNamespaces {
			t.Fatalf("namespace observation = %+v", got)
		}
		if !got.Truncated || got.ReasonCode != "namespace_fanout_truncated" {
			t.Fatalf("namespace bounds = %+v", got)
		}
		if !reflect.DeepEqual(got.Namespaces, []string{"team-a", "team-b"}) {
			t.Fatalf("namespaces = %v", got.Namespaces)
		}
	})
}

func TestDynamicObservationTimeoutDoesNotProveDenial(t *testing.T) {
	gvr := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"}
	dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{gvr: "WidgetList"})
	dyn.PrependReactor("list", "widgets", func(a k8stesting.Action) (bool, runtime.Object, error) {
		if a.GetNamespace() == "" {
			return true, nil, apierrors.NewForbidden(gvr.GroupResource(), "", errors.New("cluster denied"))
		}
		return true, nil, context.DeadlineExceeded
	})
	discovery := &ResourceDiscovery{resourceMap: make(map[string]APIResource), gvrMap: make(map[string]schema.GroupVersionResource)}
	discovery.AddAPIResource(APIResource{Group: gvr.Group, Version: gvr.Version, Name: gvr.Resource, Kind: "Widget", Namespaced: true, IsCRD: true, Verbs: []string{"list", "watch"}})
	d, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dyn, Discovery: discovery, NamespaceFallbacks: []string{"slow"}})
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(d.Stop)
	if err := d.EnsureWatching(gvr); err == nil {
		t.Fatal("incomplete probe unexpectedly succeeded")
	}
	got := d.Observation(gvr)
	if got.State != DynamicObservationDeferred || got.ReasonCode != "scope_probe_incomplete" || !got.Truncated || got.ObservedAt == nil {
		t.Fatalf("timeout became denial: %+v", got)
	}
}

func TestDynamicObservationStalledNamespaceIsNotHiddenByNewerInformer(t *testing.T) {
	gvr := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"}
	dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{gvr: "WidgetList"})
	release := make(chan struct{})
	defer close(release)
	dyn.PrependReactor("list", "widgets", func(a k8stesting.Action) (bool, runtime.Object, error) {
		if a.GetNamespace() == "old" {
			<-release
		}
		return false, nil, nil
	})
	d, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dyn})
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(d.Stop)
	if err := d.startWatching(gvr, "new"); err != nil {
		t.Fatal(err)
	}
	deadline := time.Now().Add(3 * time.Second)
	for !d.IsNamespaceSynced(gvr, "new") && time.Now().Before(deadline) {
		time.Sleep(10 * time.Millisecond)
	}
	if !d.IsNamespaceSynced(gvr, "new") {
		t.Fatal("new namespace did not sync")
	}
	if err := d.startWatching(gvr, "old"); err != nil {
		t.Fatal(err)
	}
	d.mu.Lock()
	oldStart := time.Now().Add(-time.Minute)
	d.informers[informerKey{gvr: gvr, ns: "old"}].startedAt = oldStart
	d.mu.Unlock()
	got := d.Observation(gvr)
	if got.State != DynamicObservationSyncing || got.ReasonCode != "sync_stalled" {
		t.Fatalf("old stalled namespace masked: %+v", got)
	}
}

// Per-namespace informers sync independently, so a namespace's observation
// comes from its own informer, never from another namespace's.
func TestNamespaceObservationReadsOnlyTheServingInformer(t *testing.T) {
	gvr := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"}
	dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{gvr: "WidgetList"})
	release := make(chan struct{})
	defer close(release)
	dyn.PrependReactor("list", "widgets", func(a k8stesting.Action) (bool, runtime.Object, error) {
		if a.GetNamespace() != "synced" {
			<-release
		}
		return false, nil, nil
	})
	d, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dyn, SyncStallAfter: 45 * time.Second})
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(d.Stop)
	if err := d.startWatching(gvr, "synced"); err != nil {
		t.Fatal(err)
	}
	deadline := time.Now().Add(3 * time.Second)
	for !d.IsNamespaceSynced(gvr, "synced") && time.Now().Before(deadline) {
		time.Sleep(10 * time.Millisecond)
	}
	if !d.IsNamespaceSynced(gvr, "synced") {
		t.Fatal("synced namespace did not sync")
	}
	for _, ns := range []string{"stalled", "slow", "fresh"} {
		if err := d.startWatching(gvr, ns); err != nil {
			t.Fatal(err)
		}
	}
	d.mu.Lock()
	d.informers[informerKey{gvr: gvr, ns: "stalled"}].startedAt = time.Now().Add(-time.Minute)
	// Past the 30-second default, inside the configured SyncStallAfter.
	d.informers[informerKey{gvr: gvr, ns: "slow"}].startedAt = time.Now().Add(-40 * time.Second)
	d.mu.Unlock()

	watched := []string{"fresh", "slow", "stalled", "synced"}
	for _, tc := range []struct {
		namespace string
		state     DynamicObservationState
		reason    string
	}{
		{"synced", DynamicObservationSynced, "namespace_partial"},
		{"stalled", DynamicObservationSyncing, "sync_stalled"},
		{"slow", DynamicObservationSyncing, "initial_sync"},
		{"fresh", DynamicObservationSyncing, "initial_sync"},
		{"unwatched", DynamicObservationUnwatched, "not_observed"},
	} {
		got := d.NamespaceObservation(gvr, tc.namespace)
		if got.State != tc.state || got.ReasonCode != tc.reason || got.Scope != DynamicObservationScopeExplicitNamespaces || !reflect.DeepEqual(got.Namespaces, watched) {
			t.Errorf("%s: %+v, want state=%s reason=%s over the kind's namespaces", tc.namespace, got, tc.state, tc.reason)
		}
	}
	if since, ok := d.NamespaceWatchedSince(gvr, "synced"); !ok || since.IsZero() {
		t.Fatalf("synced namespace lost its watch boundary: %v %v", since, ok)
	}
	for _, ns := range []string{"stalled", "unwatched", ""} {
		if since, ok := d.NamespaceWatchedSince(gvr, ns); ok || !since.IsZero() {
			t.Fatalf("%q: unavailable boundary = %v %v", ns, since, ok)
		}
	}
	whole := d.Observation(gvr)
	if whole.State != DynamicObservationSyncing || whole.ReasonCode != "sync_stalled" {
		t.Fatalf("kind observation must still report its stalled namespace: %+v", whole)
	}
	if got := d.NamespaceObservation(gvr, ""); !reflect.DeepEqual(got, whole) {
		t.Fatalf("all-namespaces observation %+v differs from the kind's %+v", got, whole)
	}

	clusterGVR := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "gadgets"}
	clusterWide, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{clusterGVR: "GadgetList"})})
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(clusterWide.Stop)
	if err := clusterWide.startWatching(clusterGVR, ""); err != nil {
		t.Fatal(err)
	}
	if !clusterWide.WaitForSync(clusterGVR, 3*time.Second) {
		t.Fatal("cluster-wide informer did not sync")
	}
	if got, want := clusterWide.NamespaceObservation(clusterGVR, "any"), clusterWide.Observation(clusterGVR); !reflect.DeepEqual(got, want) || got.ReasonCode != "informer_synced" {
		t.Fatalf("a cluster-wide informer serves every namespace: %+v, want %+v", got, want)
	}
}

func TestServedEntriesFollowAClusterWideInformerReplacingNamespaceOnes(t *testing.T) {
	gvr := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"}
	dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{gvr: "WidgetList"})
	release := make(chan struct{})
	defer close(release)
	dyn.PrependReactor("list", "widgets", func(a k8stesting.Action) (bool, runtime.Object, error) {
		if a.GetNamespace() == "" {
			<-release
		}
		return false, nil, nil
	})
	d, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dyn})
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(d.Stop)
	if err := d.startWatching(gvr, "alpha"); err != nil {
		t.Fatal(err)
	}
	deadline := time.Now().Add(3 * time.Second)
	for !d.IsNamespaceSynced(gvr, "alpha") && time.Now().Before(deadline) {
		time.Sleep(10 * time.Millisecond)
	}
	if _, synced := d.servedEntries(gvr, "alpha"); !synced {
		t.Fatal("alpha's informer did not sync")
	}
	if _, synced := d.servedEntries(gvr, ""); synced {
		t.Fatal("namespace informers must not answer a cluster-wide read")
	}
	// The cluster-wide informer replaces alpha's; its initial list is held.
	if err := d.startWatching(gvr, ""); err != nil {
		t.Fatal(err)
	}
	if _, synced := d.servedEntries(gvr, "alpha"); synced {
		t.Fatal("after the replacement, alpha is served by the cluster-wide informer, which hasn't loaded")
	}
}

// A 401 rejects Radar's credentials, not one scope, so every probe stops on it
// instead of narrowing the kind to the namespaces that answered, and each path
// that records a failed probe says credentials were rejected, not access denied.
func TestProbesStopOnRejectedCredentials(t *testing.T) {
	gvr := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"}
	for name, rejected := range map[string]string{"cluster-wide probe": "", "a fallback namespace": "beta"} {
		newCache := func(t *testing.T, fallbacks ...string) *DynamicResourceCache {
			dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{gvr: "WidgetList"})
			dyn.PrependReactor("list", "widgets", func(action k8stesting.Action) (bool, runtime.Object, error) {
				switch ns := action.GetNamespace(); {
				case ns == rejected:
					return true, nil, apierrors.NewUnauthorized("token rejected")
				case ns == "":
					return true, nil, apierrors.NewForbidden(gvr.GroupResource(), "", errors.New("cluster-wide list denied"))
				}
				return false, nil, nil
			})
			if len(fallbacks) == 0 {
				fallbacks = []string{"alpha", "beta"}
			}
			cache, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dyn, NamespaceFallbacks: fallbacks})
			if err != nil {
				t.Fatal(err)
			}
			t.Cleanup(cache.Stop)
			return cache
		}
		rejectedCredentials := func(t *testing.T, cache *DynamicResourceCache) {
			t.Helper()
			if got := cache.Observation(gvr); got.State != DynamicObservationDenied || got.ReasonCode != "credentials_rejected" {
				t.Fatalf("observation=%+v, want a credential rejection, not an access denial", got)
			}
		}
		t.Run(name, func(t *testing.T) {
			t.Run("probeScopes", func(t *testing.T) {
				scopes, _, err := newCache(t).probeScopes(gvr, "")
				if !apierrors.IsUnauthorized(err) || scopes != nil {
					t.Fatalf("scopes=%v err=%v, want the credential rejection", scopes, err)
				}
			})
			t.Run("EnsureWatching", func(t *testing.T) {
				cache := newCache(t)
				if err := cache.EnsureWatching(gvr); !apierrors.IsUnauthorized(err) {
					t.Fatalf("EnsureWatching err=%v, want the credential rejection", err)
				}
				rejectedCredentials(t, cache)
			})
			t.Run("WarmupParallel", func(t *testing.T) {
				cache := newCache(t)
				cache.WarmupParallel([]schema.GroupVersionResource{gvr}, time.Second)
				rejectedCredentials(t, cache)
			})
			t.Run("probeCount", func(t *testing.T) {
				// The count probe samples the first fallback namespace that
				// answers, so the rejected one comes first here.
				cache := newCache(t, "beta", "alpha")
				count, err := cache.probeCount(gvr)
				if count != -1 || !apierrors.IsUnauthorized(err) {
					t.Fatalf("count=%d err=%v, want -1 with the credential rejection", count, err)
				}
				if !cache.retainAuthProbeFailure(gvr, "", true, err) {
					t.Fatal("the count probe's rejection was not recorded")
				}
				rejectedCredentials(t, cache)
			})
		})
	}
}

// Stop keeps the informers, which go on reporting synced; nothing that vouches
// for an answer may do so once the cache has stopped.
func TestStoppedCacheVouchesForNothing(t *testing.T) {
	widgets := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"}
	gizmos := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "gizmos"}
	dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{widgets: "WidgetList", gizmos: "GizmoList"})
	d, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dyn})
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(d.Stop)
	if err := d.startWatching(widgets, "alpha"); err != nil {
		t.Fatal(err)
	}
	if err := d.startWatching(gizmos, ""); err != nil {
		t.Fatal(err)
	}
	deadline := time.Now().Add(3 * time.Second)
	for !(d.IsNamespaceSynced(widgets, "alpha") && d.IsClusterWideSynced(gizmos)) && time.Now().Before(deadline) {
		time.Sleep(10 * time.Millisecond)
	}
	if _, synced := d.servedEntries(widgets, "alpha"); !synced {
		t.Fatal("alpha's informer did not sync")
	}
	if _, synced := d.servedEntries(gizmos, ""); !synced {
		t.Fatal("the cluster-wide informer did not sync")
	}

	d.Stop()

	if _, synced := d.servedEntries(widgets, "alpha"); synced {
		t.Error("a namespace informer of a stopped cache vouches for reads")
	}
	if _, synced := d.servedEntries(gizmos, ""); synced {
		t.Error("a cluster-wide informer of a stopped cache vouches for reads")
	}
	if d.IsNamespaceSynced(widgets, "alpha") || d.IsClusterWideSynced(gizmos) || d.IsSynced(gizmos) {
		t.Error("a stopped cache reports synced")
	}
	if _, ok := d.WatchedSince(gizmos); ok {
		t.Error("WatchedSince vouches for a stopped cache")
	}
	if _, err := d.Count(gizmos, nil); err == nil {
		t.Error("Count answers for a stopped cache")
	}
	if counts := d.CountWatched(nil); counts != nil {
		t.Errorf("CountWatched answers for a stopped cache: %v", counts)
	}
}

func newWidgetDynamicClient(objects ...runtime.Object) *dynamicfake.FakeDynamicClient {
	gvr := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"}
	return dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{gvr: "WidgetList"}, objects...)
}

func TestDynamicReadableObjectAnswersOnlyFromSyncedServingInformers(t *testing.T) {
	gvr := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"}
	dyn := newWidgetDynamicClient(&unstructured.Unstructured{Object: map[string]any{
		"apiVersion": "example.io/v1", "kind": "Widget",
		"metadata": map[string]any{"name": "present", "namespace": "alpha"},
	}})
	release := make(chan struct{})
	defer close(release)
	dyn.PrependReactor("list", "widgets", func(a k8stesting.Action) (bool, runtime.Object, error) {
		if a.GetNamespace() == "" {
			<-release
		}
		return false, nil, nil
	})
	d, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dyn})
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(d.Stop)
	if _, found, ok := d.ReadableObject(gvr, "alpha", "present"); found || ok {
		t.Fatal("with no informer, a read must prove nothing")
	}
	if err := d.startWatching(gvr, "alpha"); err != nil {
		t.Fatal(err)
	}
	deadline := time.Now().Add(3 * time.Second)
	for !d.IsNamespaceSynced(gvr, "alpha") && time.Now().Before(deadline) {
		time.Sleep(10 * time.Millisecond)
	}
	obj, found, ok := d.ReadableObject(gvr, "alpha", "present")
	if !found || !ok || obj.GetName() != "present" {
		t.Fatalf("synced read: found=%v ok=%v", found, ok)
	}
	if _, found, ok := d.ReadableObject(gvr, "alpha", "missing"); found || !ok {
		t.Fatalf("a synced store's miss: found=%v ok=%v; want an established absence", found, ok)
	}
	if _, _, ok := d.ReadableObject(gvr, "beta", "present"); ok {
		t.Fatal("no informer serves beta, so the read proves nothing")
	}
	// A cluster-wide informer replaces alpha's; its initial list is held.
	if err := d.startWatching(gvr, ""); err != nil {
		t.Fatal(err)
	}
	if _, found, ok := d.ReadableObject(gvr, "alpha", "present"); found || ok {
		t.Fatalf("after the replacement: found=%v ok=%v; want the unloaded cluster-wide informer to prove nothing", found, ok)
	}
}