github.com/authzed/spicedb@v1.32.1-0.20240520085336-ebda56537386/internal/dispatch/graph/lookupresources_test.go (about)

     1  package graph
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"slices"
     7  	"strings"
     8  	"testing"
     9  	"time"
    10  
    11  	"github.com/stretchr/testify/require"
    12  	"go.uber.org/goleak"
    13  
    14  	"github.com/authzed/spicedb/internal/datastore/memdb"
    15  	"github.com/authzed/spicedb/internal/dispatch"
    16  	datastoremw "github.com/authzed/spicedb/internal/middleware/datastore"
    17  	"github.com/authzed/spicedb/internal/testfixtures"
    18  	"github.com/authzed/spicedb/internal/testutil"
    19  	"github.com/authzed/spicedb/pkg/genutil/mapz"
    20  	core "github.com/authzed/spicedb/pkg/proto/core/v1"
    21  	v1 "github.com/authzed/spicedb/pkg/proto/dispatch/v1"
    22  	"github.com/authzed/spicedb/pkg/tuple"
    23  )
    24  
    25  const veryLargeLimit = 1000000000
    26  
    27  func RR(namespaceName string, relationName string) *core.RelationReference {
    28  	return &core.RelationReference{
    29  		Namespace: namespaceName,
    30  		Relation:  relationName,
    31  	}
    32  }
    33  
    34  func resolvedRes(resourceID string) *v1.ResolvedResource {
    35  	return &v1.ResolvedResource{
    36  		ResourceId:     resourceID,
    37  		Permissionship: v1.ResolvedResource_HAS_PERMISSION,
    38  	}
    39  }
    40  
    41  func TestSimpleLookupResources(t *testing.T) {
    42  	defer goleak.VerifyNone(t, goleakIgnores...)
    43  
    44  	testCases := []struct {
    45  		start                 *core.RelationReference
    46  		target                *core.ObjectAndRelation
    47  		expectedResources     []*v1.ResolvedResource
    48  		expectedDispatchCount uint32
    49  		expectedDepthRequired uint32
    50  	}{
    51  		{
    52  			RR("document", "view"),
    53  			ONR("user", "unknown", "..."),
    54  			[]*v1.ResolvedResource{},
    55  			0,
    56  			0,
    57  		},
    58  		{
    59  			RR("document", "view"),
    60  			ONR("user", "eng_lead", "..."),
    61  			[]*v1.ResolvedResource{
    62  				resolvedRes("masterplan"),
    63  			},
    64  			2,
    65  			1,
    66  		},
    67  		{
    68  			RR("document", "owner"),
    69  			ONR("user", "product_manager", "..."),
    70  			[]*v1.ResolvedResource{
    71  				resolvedRes("masterplan"),
    72  			},
    73  			2,
    74  			0,
    75  		},
    76  		{
    77  			RR("document", "view"),
    78  			ONR("user", "legal", "..."),
    79  			[]*v1.ResolvedResource{
    80  				resolvedRes("companyplan"),
    81  				resolvedRes("masterplan"),
    82  			},
    83  			6,
    84  			3,
    85  		},
    86  		{
    87  			RR("document", "view_and_edit"),
    88  			ONR("user", "multiroleguy", "..."),
    89  			[]*v1.ResolvedResource{
    90  				resolvedRes("specialplan"),
    91  			},
    92  			7,
    93  			3,
    94  		},
    95  		{
    96  			RR("folder", "view"),
    97  			ONR("user", "owner", "..."),
    98  			[]*v1.ResolvedResource{
    99  				resolvedRes("strategy"),
   100  				resolvedRes("company"),
   101  			},
   102  			8,
   103  			4,
   104  		},
   105  	}
   106  
   107  	for _, tc := range testCases {
   108  		name := fmt.Sprintf(
   109  			"%s#%s->%s",
   110  			tc.start.Namespace,
   111  			tc.start.Relation,
   112  			tuple.StringONR(tc.target),
   113  		)
   114  
   115  		tc := tc
   116  		t.Run(name, func(t *testing.T) {
   117  			defer goleak.VerifyNone(t, goleak.IgnoreCurrent())
   118  
   119  			require := require.New(t)
   120  			ctx, dispatcher, revision := newLocalDispatcher(t)
   121  			defer dispatcher.Close()
   122  
   123  			stream := dispatch.NewCollectingDispatchStream[*v1.DispatchLookupResourcesResponse](ctx)
   124  			err := dispatcher.DispatchLookupResources(&v1.DispatchLookupResourcesRequest{
   125  				ObjectRelation: tc.start,
   126  				Subject:        tc.target,
   127  				Metadata: &v1.ResolverMeta{
   128  					AtRevision:     revision.String(),
   129  					DepthRemaining: 50,
   130  				},
   131  				OptionalLimit: veryLargeLimit,
   132  			}, stream)
   133  
   134  			require.NoError(err)
   135  
   136  			foundResources, maxDepthRequired, maxDispatchCount, maxCachedDispatchCount := processResults(stream)
   137  			require.ElementsMatch(tc.expectedResources, foundResources, "Found: %v, Expected: %v", foundResources, tc.expectedResources)
   138  			require.Equal(tc.expectedDepthRequired, maxDepthRequired, "Depth required mismatch")
   139  			require.LessOrEqual(maxDispatchCount, tc.expectedDispatchCount, "Found dispatch count greater than expected")
   140  			require.Equal(uint32(0), maxCachedDispatchCount)
   141  
   142  			// We have to sleep a while to let the cache converge:
   143  			// https://github.com/outcaste-io/ristretto/blob/01b9f37dd0fd453225e042d6f3a27cd14f252cd0/cache_test.go#L17
   144  			time.Sleep(10 * time.Millisecond)
   145  
   146  			// Run again with the cache available.
   147  			stream = dispatch.NewCollectingDispatchStream[*v1.DispatchLookupResourcesResponse](ctx)
   148  			err = dispatcher.DispatchLookupResources(&v1.DispatchLookupResourcesRequest{
   149  				ObjectRelation: tc.start,
   150  				Subject:        tc.target,
   151  				Metadata: &v1.ResolverMeta{
   152  					AtRevision:     revision.String(),
   153  					DepthRemaining: 50,
   154  				},
   155  				OptionalLimit: veryLargeLimit,
   156  			}, stream)
   157  			dispatcher.Close()
   158  
   159  			require.NoError(err)
   160  
   161  			foundResources, maxDepthRequired, maxDispatchCount, maxCachedDispatchCount = processResults(stream)
   162  			require.ElementsMatch(tc.expectedResources, foundResources, "Found: %v, Expected: %v", foundResources, tc.expectedResources)
   163  			require.Equal(tc.expectedDepthRequired, maxDepthRequired, "Depth required mismatch")
   164  			require.LessOrEqual(maxCachedDispatchCount, tc.expectedDispatchCount, "Found dispatch count greater than expected")
   165  			require.Equal(uint32(0), maxDispatchCount)
   166  		})
   167  	}
   168  }
   169  
   170  func TestSimpleLookupResourcesWithCursor(t *testing.T) {
   171  	defer goleak.VerifyNone(t, goleakIgnores...)
   172  
   173  	for _, tc := range []struct {
   174  		subject        string
   175  		expectedFirst  []string
   176  		expectedSecond []string
   177  	}{
   178  		{
   179  			subject:        "owner",
   180  			expectedFirst:  []string{"ownerplan"},
   181  			expectedSecond: []string{"companyplan", "masterplan", "ownerplan"},
   182  		},
   183  		{
   184  			subject:        "chief_financial_officer",
   185  			expectedFirst:  []string{"healthplan"},
   186  			expectedSecond: []string{"healthplan", "masterplan"},
   187  		},
   188  		{
   189  			subject:        "auditor",
   190  			expectedFirst:  []string{"companyplan"},
   191  			expectedSecond: []string{"companyplan", "masterplan"},
   192  		},
   193  	} {
   194  		tc := tc
   195  		t.Run(tc.subject, func(t *testing.T) {
   196  			require := require.New(t)
   197  			ctx, dispatcher, revision := newLocalDispatcher(t)
   198  			defer dispatcher.Close()
   199  
   200  			found := mapz.NewSet[string]()
   201  
   202  			stream := dispatch.NewCollectingDispatchStream[*v1.DispatchLookupResourcesResponse](ctx)
   203  			err := dispatcher.DispatchLookupResources(&v1.DispatchLookupResourcesRequest{
   204  				ObjectRelation: RR("document", "view"),
   205  				Subject:        ONR("user", tc.subject, "..."),
   206  				Metadata: &v1.ResolverMeta{
   207  					AtRevision:     revision.String(),
   208  					DepthRemaining: 50,
   209  				},
   210  				OptionalLimit: 1,
   211  			}, stream)
   212  
   213  			require.NoError(err)
   214  
   215  			require.Equal(1, len(stream.Results()))
   216  
   217  			found.Insert(stream.Results()[0].ResolvedResource.ResourceId)
   218  			require.Equal(tc.expectedFirst, found.AsSlice())
   219  
   220  			cursor := stream.Results()[0].AfterResponseCursor
   221  			require.NotNil(cursor)
   222  
   223  			stream = dispatch.NewCollectingDispatchStream[*v1.DispatchLookupResourcesResponse](ctx)
   224  			err = dispatcher.DispatchLookupResources(&v1.DispatchLookupResourcesRequest{
   225  				ObjectRelation: RR("document", "view"),
   226  				Subject:        ONR("user", tc.subject, "..."),
   227  				Metadata: &v1.ResolverMeta{
   228  					AtRevision:     revision.String(),
   229  					DepthRemaining: 50,
   230  				},
   231  				OptionalCursor: cursor,
   232  				OptionalLimit:  2,
   233  			}, stream)
   234  
   235  			require.NoError(err)
   236  
   237  			for _, result := range stream.Results() {
   238  				found.Insert(result.ResolvedResource.ResourceId)
   239  			}
   240  
   241  			foundResults := found.AsSlice()
   242  			slices.Sort(foundResults)
   243  
   244  			require.Equal(tc.expectedSecond, foundResults)
   245  		})
   246  	}
   247  }
   248  
   249  func TestLookupResourcesCursorStability(t *testing.T) {
   250  	defer goleak.VerifyNone(t, goleakIgnores...)
   251  
   252  	require := require.New(t)
   253  	ctx, dispatcher, revision := newLocalDispatcher(t)
   254  	defer dispatcher.Close()
   255  
   256  	stream := dispatch.NewCollectingDispatchStream[*v1.DispatchLookupResourcesResponse](ctx)
   257  
   258  	// Make the first first request.
   259  	err := dispatcher.DispatchLookupResources(&v1.DispatchLookupResourcesRequest{
   260  		ObjectRelation: RR("document", "view"),
   261  		Subject:        ONR("user", "owner", "..."),
   262  		Metadata: &v1.ResolverMeta{
   263  			AtRevision:     revision.String(),
   264  			DepthRemaining: 50,
   265  		},
   266  		OptionalLimit: 2,
   267  	}, stream)
   268  
   269  	require.NoError(err)
   270  	require.Equal(2, len(stream.Results()))
   271  
   272  	cursor := stream.Results()[1].AfterResponseCursor
   273  	require.NotNil(cursor)
   274  
   275  	// Make the same request and ensure the cursor has not changed.
   276  	stream = dispatch.NewCollectingDispatchStream[*v1.DispatchLookupResourcesResponse](ctx)
   277  	err = dispatcher.DispatchLookupResources(&v1.DispatchLookupResourcesRequest{
   278  		ObjectRelation: RR("document", "view"),
   279  		Subject:        ONR("user", "owner", "..."),
   280  		Metadata: &v1.ResolverMeta{
   281  			AtRevision:     revision.String(),
   282  			DepthRemaining: 50,
   283  		},
   284  		OptionalLimit: 2,
   285  	}, stream)
   286  
   287  	require.NoError(err)
   288  
   289  	require.NoError(err)
   290  	require.Equal(2, len(stream.Results()))
   291  
   292  	cursorAgain := stream.Results()[1].AfterResponseCursor
   293  	require.NotNil(cursor)
   294  	require.Equal(cursor, cursorAgain)
   295  }
   296  
   297  func processResults(stream *dispatch.CollectingDispatchStream[*v1.DispatchLookupResourcesResponse]) ([]*v1.ResolvedResource, uint32, uint32, uint32) {
   298  	foundResources := []*v1.ResolvedResource{}
   299  	var maxDepthRequired uint32
   300  	var maxDispatchCount uint32
   301  	var maxCachedDispatchCount uint32
   302  	for _, result := range stream.Results() {
   303  		foundResources = append(foundResources, result.ResolvedResource)
   304  		maxDepthRequired = max(maxDepthRequired, result.Metadata.DepthRequired)
   305  		maxDispatchCount = max(maxDispatchCount, result.Metadata.DispatchCount)
   306  		maxCachedDispatchCount = max(maxCachedDispatchCount, result.Metadata.CachedDispatchCount)
   307  	}
   308  	return foundResources, maxDepthRequired, maxDispatchCount, maxCachedDispatchCount
   309  }
   310  
   311  func TestMaxDepthLookup(t *testing.T) {
   312  	require := require.New(t)
   313  
   314  	rawDS, err := memdb.NewMemdbDatastore(0, 0, memdb.DisableGC)
   315  	require.NoError(err)
   316  
   317  	ds, revision := testfixtures.StandardDatastoreWithData(rawDS, require)
   318  
   319  	dispatcher := NewLocalOnlyDispatcher(10)
   320  	defer dispatcher.Close()
   321  
   322  	ctx := datastoremw.ContextWithHandle(context.Background())
   323  	require.NoError(datastoremw.SetInContext(ctx, ds))
   324  	stream := dispatch.NewCollectingDispatchStream[*v1.DispatchLookupResourcesResponse](ctx)
   325  
   326  	err = dispatcher.DispatchLookupResources(&v1.DispatchLookupResourcesRequest{
   327  		ObjectRelation: RR("document", "view"),
   328  		Subject:        ONR("user", "legal", "..."),
   329  		Metadata: &v1.ResolverMeta{
   330  			AtRevision:     revision.String(),
   331  			DepthRemaining: 0,
   332  		},
   333  	}, stream)
   334  
   335  	require.Error(err)
   336  }
   337  
   338  type OrderedResolved []*v1.ResolvedResource
   339  
   340  func (a OrderedResolved) Len() int { return len(a) }
   341  
   342  func (a OrderedResolved) Less(i, j int) bool {
   343  	return strings.Compare(a[i].ResourceId, a[j].ResourceId) < 0
   344  }
   345  
   346  func (a OrderedResolved) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
   347  
   348  func TestLookupResourcesOverSchemaWithCursors(t *testing.T) {
   349  	testCases := []struct {
   350  		name                string
   351  		schema              string
   352  		relationships       []*core.RelationTuple
   353  		permission          *core.RelationReference
   354  		subject             *core.ObjectAndRelation
   355  		expectedResourceIDs []string
   356  	}{
   357  		{
   358  			"basic union",
   359  			`definition user {}
   360  		
   361  		 	 definition document {
   362  				relation editor: user
   363  				relation viewer: user
   364  				permission view = viewer + editor
   365    			 }`,
   366  			testutil.JoinTuples(
   367  				testutil.GenTuples("document", "viewer", "user", "tom", 1510),
   368  				testutil.GenTuples("document", "editor", "user", "tom", 1510),
   369  			),
   370  			RR("document", "view"),
   371  			ONR("user", "tom", "..."),
   372  			testutil.GenResourceIds("document", 1510),
   373  		},
   374  		{
   375  			"basic exclusion",
   376  			`definition user {}
   377  		
   378  		 	 definition document {
   379  				relation banned: user
   380  				relation viewer: user
   381  				permission view = viewer - banned
   382    			 }`,
   383  			testutil.GenTuples("document", "viewer", "user", "tom", 1010),
   384  			RR("document", "view"),
   385  			ONR("user", "tom", "..."),
   386  			testutil.GenResourceIds("document", 1010),
   387  		},
   388  		{
   389  			"basic intersection",
   390  			`definition user {}
   391  		
   392  		 	 definition document {
   393  				relation editor: user
   394  				relation viewer: user
   395  				permission view = viewer & editor
   396    			 }`,
   397  			testutil.JoinTuples(
   398  				testutil.GenTuples("document", "viewer", "user", "tom", 510),
   399  				testutil.GenTuples("document", "editor", "user", "tom", 510),
   400  			),
   401  			RR("document", "view"),
   402  			ONR("user", "tom", "..."),
   403  			testutil.GenResourceIds("document", 510),
   404  		},
   405  		{
   406  			"union and exclused union",
   407  			`definition user {}
   408  		
   409  		 	 definition document {
   410  				relation editor: user
   411  				relation viewer: user
   412  				relation banned: user
   413  				permission can_view = viewer - banned
   414  				permission view = can_view + editor
   415    			 }`,
   416  			testutil.JoinTuples(
   417  				testutil.GenTuples("document", "viewer", "user", "tom", 1310),
   418  				testutil.GenTuplesWithOffset("document", "editor", "user", "tom", 1250, 1200),
   419  			),
   420  			RR("document", "view"),
   421  			ONR("user", "tom", "..."),
   422  			testutil.GenResourceIds("document", 2450),
   423  		},
   424  		{
   425  			"basic caveats",
   426  			`definition user {}
   427  
   428   			 caveat somecaveat(somecondition int) {
   429  				somecondition == 42
   430  			 }
   431  		
   432  		 	 definition document {
   433  				relation viewer: user with somecaveat
   434  				permission view = viewer
   435    			 }`,
   436  			testutil.GenTuplesWithCaveat("document", "viewer", "user", "tom", "somecaveat", map[string]any{"somecondition": 42}, 0, 2450),
   437  			RR("document", "view"),
   438  			ONR("user", "tom", "..."),
   439  			testutil.GenResourceIds("document", 2450),
   440  		},
   441  		{
   442  			"excluded items",
   443  			`definition user {}
   444  		
   445  		 	 definition document {
   446  				relation banned: user
   447  				relation viewer: user
   448  				permission view = viewer - banned
   449    			 }`,
   450  			testutil.JoinTuples(
   451  				testutil.GenTuples("document", "viewer", "user", "tom", 1310),
   452  				testutil.GenTuplesWithOffset("document", "banned", "user", "tom", 1210, 100),
   453  			),
   454  			RR("document", "view"),
   455  			ONR("user", "tom", "..."),
   456  			testutil.GenResourceIds("document", 1210),
   457  		},
   458  		{
   459  			"basic caveats with missing field",
   460  			`definition user {}
   461  
   462   			 caveat somecaveat(somecondition int) {
   463  				somecondition == 42
   464  			 }
   465  		
   466  		 	 definition document {
   467  				relation viewer: user with somecaveat
   468  				permission view = viewer
   469    			 }`,
   470  			testutil.GenTuplesWithCaveat("document", "viewer", "user", "tom", "somecaveat", map[string]any{}, 0, 2450),
   471  			RR("document", "view"),
   472  			ONR("user", "tom", "..."),
   473  			testutil.GenResourceIds("document", 2450),
   474  		},
   475  		{
   476  			"larger arrow dispatch",
   477  			`definition user {}
   478  	
   479  			 definition folder {
   480  				relation viewer: user
   481  			 }
   482  
   483  		 	 definition document {
   484  				relation folder: folder
   485  				permission view = folder->viewer
   486    			 }`,
   487  			testutil.JoinTuples(
   488  				testutil.GenTuples("folder", "viewer", "user", "tom", 150),
   489  				testutil.GenSubjectTuples("document", "folder", "folder", "...", 150),
   490  			),
   491  			RR("document", "view"),
   492  			ONR("user", "tom", "..."),
   493  			testutil.GenResourceIds("document", 150),
   494  		},
   495  		{
   496  			"big",
   497  			`definition user {}
   498  		
   499  		 	 definition document {
   500  				relation editor: user
   501  				relation viewer: user
   502  				permission view = viewer + editor
   503    			 }`,
   504  			testutil.JoinTuples(
   505  				testutil.GenTuples("document", "viewer", "user", "tom", 15100),
   506  				testutil.GenTuples("document", "editor", "user", "tom", 15100),
   507  			),
   508  			RR("document", "view"),
   509  			ONR("user", "tom", "..."),
   510  			testutil.GenResourceIds("document", 15100),
   511  		},
   512  	}
   513  
   514  	for _, tc := range testCases {
   515  		tc := tc
   516  		t.Run(tc.name, func(t *testing.T) {
   517  			for _, pageSize := range []int{0, 104, 1023} {
   518  				pageSize := pageSize
   519  				t.Run(fmt.Sprintf("ps-%d_", pageSize), func(t *testing.T) {
   520  					require := require.New(t)
   521  
   522  					dispatcher := NewLocalOnlyDispatcher(10)
   523  
   524  					ds, err := memdb.NewMemdbDatastore(0, 0, memdb.DisableGC)
   525  					require.NoError(err)
   526  
   527  					ds, revision := testfixtures.DatastoreFromSchemaAndTestRelationships(ds, tc.schema, tc.relationships, require)
   528  
   529  					ctx := datastoremw.ContextWithHandle(context.Background())
   530  					require.NoError(datastoremw.SetInContext(ctx, ds))
   531  
   532  					var currentCursor *v1.Cursor
   533  					foundResourceIDs := mapz.NewSet[string]()
   534  					for {
   535  						stream := dispatch.NewCollectingDispatchStream[*v1.DispatchLookupResourcesResponse](ctx)
   536  						err = dispatcher.DispatchLookupResources(&v1.DispatchLookupResourcesRequest{
   537  							ObjectRelation: tc.permission,
   538  							Subject:        tc.subject,
   539  							Metadata: &v1.ResolverMeta{
   540  								AtRevision:     revision.String(),
   541  								DepthRemaining: 50,
   542  							},
   543  							OptionalLimit:  uint32(pageSize),
   544  							OptionalCursor: currentCursor,
   545  						}, stream)
   546  						require.NoError(err)
   547  
   548  						if pageSize > 0 {
   549  							require.LessOrEqual(len(stream.Results()), pageSize)
   550  						}
   551  
   552  						for _, result := range stream.Results() {
   553  							foundResourceIDs.Insert(result.ResolvedResource.ResourceId)
   554  							currentCursor = result.AfterResponseCursor
   555  						}
   556  
   557  						if pageSize == 0 || len(stream.Results()) < pageSize {
   558  							break
   559  						}
   560  					}
   561  
   562  					foundResourceIDsSlice := foundResourceIDs.AsSlice()
   563  					slices.Sort(foundResourceIDsSlice)
   564  					slices.Sort(tc.expectedResourceIDs)
   565  
   566  					require.Equal(tc.expectedResourceIDs, foundResourceIDsSlice)
   567  				})
   568  			}
   569  		})
   570  	}
   571  }
   572  
   573  func TestLookupResourcesImmediateTimeout(t *testing.T) {
   574  	defer goleak.VerifyNone(t, goleak.IgnoreCurrent())
   575  
   576  	require := require.New(t)
   577  
   578  	rawDS, err := memdb.NewMemdbDatastore(0, 0, memdb.DisableGC)
   579  	require.NoError(err)
   580  
   581  	ds, revision := testfixtures.StandardDatastoreWithData(rawDS, require)
   582  
   583  	dispatcher := NewLocalOnlyDispatcher(10)
   584  	defer dispatcher.Close()
   585  
   586  	ctx := datastoremw.ContextWithHandle(context.Background())
   587  	cctx, cancel := context.WithTimeout(ctx, 1*time.Nanosecond)
   588  	defer cancel()
   589  
   590  	require.NoError(datastoremw.SetInContext(cctx, ds))
   591  	stream := dispatch.NewCollectingDispatchStream[*v1.DispatchLookupResourcesResponse](cctx)
   592  
   593  	err = dispatcher.DispatchLookupResources(&v1.DispatchLookupResourcesRequest{
   594  		ObjectRelation: RR("document", "view"),
   595  		Subject:        ONR("user", "legal", "..."),
   596  		Metadata: &v1.ResolverMeta{
   597  			AtRevision:     revision.String(),
   598  			DepthRemaining: 10,
   599  		},
   600  	}, stream)
   601  
   602  	require.ErrorIs(err, context.DeadlineExceeded)
   603  	require.ErrorContains(err, "context deadline exceeded")
   604  }
   605  
   606  func TestLookupResourcesWithError(t *testing.T) {
   607  	defer goleak.VerifyNone(t, goleak.IgnoreCurrent())
   608  
   609  	require := require.New(t)
   610  
   611  	rawDS, err := memdb.NewMemdbDatastore(0, 0, memdb.DisableGC)
   612  	require.NoError(err)
   613  
   614  	ds, revision := testfixtures.StandardDatastoreWithData(rawDS, require)
   615  
   616  	dispatcher := NewLocalOnlyDispatcher(10)
   617  	defer dispatcher.Close()
   618  
   619  	ctx := datastoremw.ContextWithHandle(context.Background())
   620  	cctx, cancel := context.WithTimeout(ctx, 1*time.Nanosecond)
   621  	defer cancel()
   622  
   623  	require.NoError(datastoremw.SetInContext(cctx, ds))
   624  	stream := dispatch.NewCollectingDispatchStream[*v1.DispatchLookupResourcesResponse](cctx)
   625  
   626  	err = dispatcher.DispatchLookupResources(&v1.DispatchLookupResourcesRequest{
   627  		ObjectRelation: RR("document", "view"),
   628  		Subject:        ONR("user", "legal", "..."),
   629  		Metadata: &v1.ResolverMeta{
   630  			AtRevision:     revision.String(),
   631  			DepthRemaining: 1, // Set depth 1 to cause an error within reachable resources
   632  		},
   633  	}, stream)
   634  
   635  	require.Error(err)
   636  }