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

     1  package graph
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"go/ast"
     7  	"go/printer"
     8  	"go/token"
     9  	"os"
    10  	"testing"
    11  
    12  	"github.com/google/go-cmp/cmp"
    13  	"github.com/stretchr/testify/require"
    14  	"go.uber.org/goleak"
    15  	"google.golang.org/protobuf/encoding/prototext"
    16  	"google.golang.org/protobuf/testing/protocmp"
    17  
    18  	"github.com/authzed/spicedb/internal/datastore/common"
    19  	"github.com/authzed/spicedb/internal/datastore/memdb"
    20  	expand "github.com/authzed/spicedb/internal/graph"
    21  	datastoremw "github.com/authzed/spicedb/internal/middleware/datastore"
    22  	"github.com/authzed/spicedb/internal/testfixtures"
    23  	"github.com/authzed/spicedb/pkg/graph"
    24  	core "github.com/authzed/spicedb/pkg/proto/core/v1"
    25  	v1 "github.com/authzed/spicedb/pkg/proto/dispatch/v1"
    26  	"github.com/authzed/spicedb/pkg/testutil"
    27  	"github.com/authzed/spicedb/pkg/tuple"
    28  )
    29  
    30  func DS(objectType string, objectID string, objectRelation string) *core.DirectSubject {
    31  	return &core.DirectSubject{
    32  		Subject: ONR(objectType, objectID, objectRelation),
    33  	}
    34  }
    35  
    36  var (
    37  	companyOwner = graph.Leaf(ONR("folder", "company", "owner"),
    38  		(DS("user", "owner", expand.Ellipsis)),
    39  	)
    40  	companyEditor = graph.Leaf(ONR("folder", "company", "editor"))
    41  
    42  	companyEdit = graph.Union(ONR("folder", "company", "edit"),
    43  		companyEditor,
    44  		companyOwner,
    45  	)
    46  
    47  	companyViewer = graph.Leaf(ONR("folder", "company", "viewer"),
    48  		(DS("user", "legal", "...")),
    49  		(DS("folder", "auditors", "viewer")),
    50  	)
    51  
    52  	companyView = graph.Union(ONR("folder", "company", "view"),
    53  		companyViewer,
    54  		companyEdit,
    55  		graph.Union(ONR("folder", "company", "view")),
    56  	)
    57  
    58  	auditorsOwner = graph.Leaf(ONR("folder", "auditors", "owner"))
    59  
    60  	auditorsEditor = graph.Leaf(ONR("folder", "auditors", "editor"))
    61  
    62  	auditorsEdit = graph.Union(ONR("folder", "auditors", "edit"),
    63  		auditorsEditor,
    64  		auditorsOwner,
    65  	)
    66  
    67  	auditorsViewer = graph.Leaf(ONR("folder", "auditors", "viewer"),
    68  		(DS("user", "auditor", "...")),
    69  	)
    70  
    71  	auditorsViewRecursive = graph.Union(ONR("folder", "auditors", "view"),
    72  		auditorsViewer,
    73  		auditorsEdit,
    74  		graph.Union(ONR("folder", "auditors", "view")),
    75  	)
    76  
    77  	companyViewRecursive = graph.Union(ONR("folder", "company", "view"),
    78  		graph.Union(ONR("folder", "company", "viewer"),
    79  			graph.Leaf(ONR("folder", "auditors", "viewer"),
    80  				(DS("user", "auditor", "..."))),
    81  			graph.Leaf(ONR("folder", "company", "viewer"),
    82  				(DS("user", "legal", "...")),
    83  				(DS("folder", "auditors", "viewer")))),
    84  		graph.Union(ONR("folder", "company", "edit"),
    85  			graph.Leaf(ONR("folder", "company", "editor")),
    86  			graph.Leaf(ONR("folder", "company", "owner"),
    87  				(DS("user", "owner", "...")))),
    88  		graph.Union(ONR("folder", "company", "view")))
    89  
    90  	docOwner = graph.Leaf(ONR("document", "masterplan", "owner"),
    91  		(DS("user", "product_manager", "...")),
    92  	)
    93  	docEditor = graph.Leaf(ONR("document", "masterplan", "editor"))
    94  
    95  	docEdit = graph.Union(ONR("document", "masterplan", "edit"),
    96  		docOwner,
    97  		docEditor,
    98  	)
    99  
   100  	docViewer = graph.Leaf(ONR("document", "masterplan", "viewer"),
   101  		(DS("user", "eng_lead", "...")),
   102  	)
   103  
   104  	docView = graph.Union(ONR("document", "masterplan", "view"),
   105  		docViewer,
   106  		docEdit,
   107  		graph.Union(ONR("document", "masterplan", "view"),
   108  			graph.Union(ONR("folder", "plans", "view"),
   109  				graph.Leaf(ONR("folder", "plans", "viewer"),
   110  					(DS("user", "chief_financial_officer", "...")),
   111  				),
   112  				graph.Union(ONR("folder", "plans", "edit"),
   113  					graph.Leaf(ONR("folder", "plans", "editor")),
   114  					graph.Leaf(ONR("folder", "plans", "owner"))),
   115  				graph.Union(ONR("folder", "plans", "view"))),
   116  			graph.Union(ONR("folder", "strategy", "view"),
   117  				graph.Leaf(ONR("folder", "strategy", "viewer")),
   118  				graph.Union(ONR("folder", "strategy", "edit"),
   119  					graph.Leaf(ONR("folder", "strategy", "editor")),
   120  					graph.Leaf(ONR("folder", "strategy", "owner"),
   121  						(DS("user", "vp_product", "...")))),
   122  				graph.Union(ONR("folder", "strategy", "view"),
   123  					graph.Union(ONR("folder", "company", "view"),
   124  						graph.Leaf(ONR("folder", "company", "viewer"),
   125  							(DS("user", "legal", "...")),
   126  							(DS("folder", "auditors", "viewer"))),
   127  						graph.Union(ONR("folder", "company", "edit"),
   128  							graph.Leaf(ONR("folder", "company", "editor")),
   129  							graph.Leaf(ONR("folder", "company", "owner"),
   130  								(DS("user", "owner", "...")))),
   131  						graph.Union(ONR("folder", "company", "view")),
   132  					),
   133  				),
   134  			),
   135  		),
   136  	)
   137  )
   138  
   139  func TestExpand(t *testing.T) {
   140  	defer goleak.VerifyNone(t, goleakIgnores...)
   141  
   142  	testCases := []struct {
   143  		start                 *core.ObjectAndRelation
   144  		expansionMode         v1.DispatchExpandRequest_ExpansionMode
   145  		expected              *core.RelationTupleTreeNode
   146  		expectedDispatchCount int
   147  		expectedDepthRequired int
   148  	}{
   149  		{start: ONR("folder", "company", "owner"), expansionMode: v1.DispatchExpandRequest_SHALLOW, expected: companyOwner, expectedDispatchCount: 1, expectedDepthRequired: 1},
   150  		{start: ONR("folder", "company", "edit"), expansionMode: v1.DispatchExpandRequest_SHALLOW, expected: companyEdit, expectedDispatchCount: 3, expectedDepthRequired: 2},
   151  		{start: ONR("folder", "company", "view"), expansionMode: v1.DispatchExpandRequest_SHALLOW, expected: companyView, expectedDispatchCount: 5, expectedDepthRequired: 3},
   152  		{start: ONR("document", "masterplan", "owner"), expansionMode: v1.DispatchExpandRequest_SHALLOW, expected: docOwner, expectedDispatchCount: 1, expectedDepthRequired: 1},
   153  		{start: ONR("document", "masterplan", "edit"), expansionMode: v1.DispatchExpandRequest_SHALLOW, expected: docEdit, expectedDispatchCount: 3, expectedDepthRequired: 2},
   154  		{start: ONR("document", "masterplan", "view"), expansionMode: v1.DispatchExpandRequest_SHALLOW, expected: docView, expectedDispatchCount: 20, expectedDepthRequired: 5},
   155  
   156  		{start: ONR("folder", "auditors", "owner"), expansionMode: v1.DispatchExpandRequest_RECURSIVE, expected: auditorsOwner, expectedDispatchCount: 1, expectedDepthRequired: 1},
   157  		{start: ONR("folder", "auditors", "edit"), expansionMode: v1.DispatchExpandRequest_RECURSIVE, expected: auditorsEdit, expectedDispatchCount: 3, expectedDepthRequired: 2},
   158  		{start: ONR("folder", "auditors", "view"), expansionMode: v1.DispatchExpandRequest_RECURSIVE, expected: auditorsViewRecursive, expectedDispatchCount: 5, expectedDepthRequired: 3},
   159  
   160  		{start: ONR("folder", "company", "owner"), expansionMode: v1.DispatchExpandRequest_RECURSIVE, expected: companyOwner, expectedDispatchCount: 1, expectedDepthRequired: 1},
   161  		{start: ONR("folder", "company", "edit"), expansionMode: v1.DispatchExpandRequest_RECURSIVE, expected: companyEdit, expectedDispatchCount: 3, expectedDepthRequired: 2},
   162  		{start: ONR("folder", "company", "view"), expansionMode: v1.DispatchExpandRequest_RECURSIVE, expected: companyViewRecursive, expectedDispatchCount: 6, expectedDepthRequired: 3},
   163  	}
   164  
   165  	for _, tc := range testCases {
   166  		tc := tc
   167  		t.Run(fmt.Sprintf("%s-%s", tuple.StringONR(tc.start), tc.expansionMode), func(t *testing.T) {
   168  			require := require.New(t)
   169  
   170  			ctx, dispatch, revision := newLocalDispatcher(t)
   171  
   172  			expandResult, err := dispatch.DispatchExpand(ctx, &v1.DispatchExpandRequest{
   173  				ResourceAndRelation: tc.start,
   174  				Metadata: &v1.ResolverMeta{
   175  					AtRevision:     revision.String(),
   176  					DepthRemaining: 50,
   177  				},
   178  				ExpansionMode: tc.expansionMode,
   179  			})
   180  
   181  			require.NoError(err)
   182  			require.NotNil(expandResult.TreeNode)
   183  			require.GreaterOrEqual(expandResult.Metadata.DepthRequired, uint32(1))
   184  			require.Equal(tc.expectedDispatchCount, int(expandResult.Metadata.DispatchCount), "mismatch in dispatch count")
   185  			require.Equal(tc.expectedDepthRequired, int(expandResult.Metadata.DepthRequired), "mismatch in depth required")
   186  
   187  			if diff := cmp.Diff(tc.expected, expandResult.TreeNode, protocmp.Transform()); diff != "" {
   188  				fset := token.NewFileSet()
   189  				err := printer.Fprint(os.Stdout, fset, serializeToFile(expandResult.TreeNode))
   190  				require.NoError(err)
   191  				t.Errorf("unexpected difference:\n%v", diff)
   192  			}
   193  		})
   194  	}
   195  }
   196  
   197  func serializeToFile(node *core.RelationTupleTreeNode) *ast.File {
   198  	return &ast.File{
   199  		Package: 1,
   200  		Name: &ast.Ident{
   201  			Name: "main",
   202  		},
   203  		Decls: []ast.Decl{
   204  			&ast.FuncDecl{
   205  				Name: &ast.Ident{
   206  					Name: "main",
   207  				},
   208  				Type: &ast.FuncType{
   209  					Params: &ast.FieldList{},
   210  				},
   211  				Body: &ast.BlockStmt{
   212  					List: []ast.Stmt{
   213  						&ast.ExprStmt{
   214  							X: serialize(node),
   215  						},
   216  					},
   217  				},
   218  			},
   219  		},
   220  	}
   221  }
   222  
   223  func serialize(node *core.RelationTupleTreeNode) *ast.CallExpr {
   224  	var expanded ast.Expr = ast.NewIdent("_this")
   225  	if node.Expanded != nil {
   226  		expanded = onrExpr(node.Expanded)
   227  	}
   228  
   229  	children := []ast.Expr{expanded}
   230  
   231  	var fName string
   232  	switch node.NodeType.(type) {
   233  	case *core.RelationTupleTreeNode_IntermediateNode:
   234  		switch node.GetIntermediateNode().Operation {
   235  		case core.SetOperationUserset_EXCLUSION:
   236  			fName = "graph.Exclusion"
   237  		case core.SetOperationUserset_INTERSECTION:
   238  			fName = "graph.Intersection"
   239  		case core.SetOperationUserset_UNION:
   240  			fName = "graph.Union"
   241  		default:
   242  			panic("Unknown set operation")
   243  		}
   244  
   245  		for _, child := range node.GetIntermediateNode().ChildNodes {
   246  			children = append(children, serialize(child))
   247  		}
   248  
   249  	case *core.RelationTupleTreeNode_LeafNode:
   250  		fName = "graph.Leaf"
   251  		for _, subject := range node.GetLeafNode().Subjects {
   252  			onrExpr := onrExpr(subject.Subject)
   253  			children = append(children, &ast.CallExpr{
   254  				Fun:  ast.NewIdent(""),
   255  				Args: []ast.Expr{onrExpr},
   256  			})
   257  		}
   258  	}
   259  
   260  	return &ast.CallExpr{
   261  		Fun:  ast.NewIdent(fName),
   262  		Args: children,
   263  	}
   264  }
   265  
   266  func onrExpr(onr *core.ObjectAndRelation) ast.Expr {
   267  	return &ast.CallExpr{
   268  		Fun: ast.NewIdent("ONR"),
   269  		Args: []ast.Expr{
   270  			ast.NewIdent("\"" + onr.Namespace + "\""),
   271  			ast.NewIdent("\"" + onr.ObjectId + "\""),
   272  			ast.NewIdent("\"" + onr.Relation + "\""),
   273  		},
   274  	}
   275  }
   276  
   277  func TestMaxDepthExpand(t *testing.T) {
   278  	defer goleak.VerifyNone(t, goleakIgnores...)
   279  
   280  	require := require.New(t)
   281  
   282  	rawDS, err := memdb.NewMemdbDatastore(0, 0, memdb.DisableGC)
   283  	require.NoError(err)
   284  
   285  	ds, _ := testfixtures.StandardDatastoreWithSchema(rawDS, require)
   286  
   287  	tpl := tuple.Parse("folder:oops#parent@folder:oops")
   288  	ctx := datastoremw.ContextWithHandle(context.Background())
   289  
   290  	revision, err := common.WriteTuples(ctx, ds, core.RelationTupleUpdate_CREATE, tpl)
   291  	require.NoError(err)
   292  	require.NoError(datastoremw.SetInContext(ctx, ds))
   293  
   294  	dispatch := NewLocalOnlyDispatcher(10)
   295  
   296  	_, err = dispatch.DispatchExpand(ctx, &v1.DispatchExpandRequest{
   297  		ResourceAndRelation: ONR("folder", "oops", "view"),
   298  		Metadata: &v1.ResolverMeta{
   299  			AtRevision:     revision.String(),
   300  			DepthRemaining: 50,
   301  		},
   302  		ExpansionMode: v1.DispatchExpandRequest_SHALLOW,
   303  	})
   304  
   305  	require.Error(err)
   306  }
   307  
   308  func TestCaveatedExpand(t *testing.T) {
   309  	defer goleak.VerifyNone(t, goleakIgnores...)
   310  
   311  	testCases := []struct {
   312  		name          string
   313  		schema        string
   314  		relationships []*core.RelationTuple
   315  
   316  		start *core.ObjectAndRelation
   317  
   318  		expansionMode    v1.DispatchExpandRequest_ExpansionMode
   319  		expectedTreeText string
   320  	}{
   321  		{
   322  			"basic caveated subject",
   323  			`
   324  			definition user {}
   325  
   326  			caveat somecaveat(somecondition int) {
   327  				somecondition == 42
   328  			}
   329  
   330  			definition document {
   331  				relation viewer: user with somecaveat | user
   332  			}
   333  			`,
   334  			[]*core.RelationTuple{
   335  				tuple.MustParse("document:testdoc#viewer@user:sarah[somecaveat]"),
   336  				tuple.MustParse("document:testdoc#viewer@user:mary"),
   337  			},
   338  			tuple.ParseONR("document:testdoc#viewer"),
   339  			v1.DispatchExpandRequest_SHALLOW,
   340  			`
   341  			leaf_node: {
   342  				subjects: {
   343  					subject: {
   344  						namespace: "user"
   345  						object_id: "mary"
   346  						relation: "..."
   347  					}
   348  				}
   349  				subjects: {
   350  					subject: {
   351  						namespace: "user"
   352  						object_id: "sarah"
   353  						relation: "..."
   354  					}
   355  					caveat_expression: {
   356  						caveat: {
   357  							caveat_name: "somecaveat"
   358  							context: {}
   359  						}
   360  					}
   361  				}
   362  			}
   363  			expanded: {
   364  				namespace: "document"
   365  				object_id: "testdoc"
   366  				relation: "viewer"
   367  			}
   368  			`,
   369  		},
   370  		{
   371  			"caveated shallow indirect",
   372  			`
   373  			definition user {}
   374  
   375  			caveat somecaveat(somecondition int) {
   376  				somecondition == 42
   377  			}
   378  
   379  			definition group {
   380  				relation member: user
   381  			}
   382  
   383  			definition document {
   384  				relation viewer: group#member with somecaveat
   385  			}
   386  			`,
   387  			[]*core.RelationTuple{
   388  				tuple.MustParse("document:testdoc#viewer@group:test#member[somecaveat]"),
   389  				tuple.MustParse("group:test#member@user:mary"),
   390  			},
   391  			tuple.ParseONR("document:testdoc#viewer"),
   392  			v1.DispatchExpandRequest_SHALLOW,
   393  			`
   394  			leaf_node: {
   395  				subjects: {
   396  					subject: {
   397  						namespace: "group"
   398  						object_id: "test"
   399  						relation: "member"
   400  					}
   401  					caveat_expression: {
   402  						caveat: {
   403  							caveat_name: "somecaveat"
   404  							context: {}
   405  						}
   406  					}
   407  				}
   408  			}
   409  			expanded: {
   410  				namespace: "document"
   411  				object_id: "testdoc"
   412  				relation: "viewer"
   413  			}
   414  			`,
   415  		},
   416  		{
   417  			"caveated recursive indirect",
   418  			`
   419  			definition user {}
   420  
   421  			caveat somecaveat(somecondition int) {
   422  				somecondition == 42
   423  			}
   424  
   425  			caveat anothercaveat(somecondition int) {
   426  				somecondition != 42
   427  			}
   428  
   429  			definition group {
   430  				relation member: user
   431  			}
   432  
   433  			definition document {
   434  				relation viewer: group#member with somecaveat
   435  			}
   436  			`,
   437  			[]*core.RelationTuple{
   438  				tuple.MustParse("document:testdoc#viewer@group:test#member[somecaveat]"),
   439  				tuple.MustParse("group:test#member@user:mary"),
   440  				tuple.MustParse("group:test#member@user:sarah[anothercaveat]"),
   441  			},
   442  			tuple.ParseONR("document:testdoc#viewer"),
   443  			v1.DispatchExpandRequest_RECURSIVE,
   444  			`
   445  			intermediate_node: {
   446  				operation: UNION
   447  				child_nodes: {
   448  					leaf_node: {
   449  						subjects: {
   450  							subject: {
   451  								namespace: "user"
   452  								object_id: "mary"
   453  								relation: "..."
   454  							}
   455  						}
   456  						subjects: {
   457  							subject: {
   458  								namespace: "user"
   459  								object_id: "sarah"
   460  								relation: "..."
   461  							}
   462  							caveat_expression: {
   463  								caveat: {
   464  									caveat_name: "anothercaveat"
   465  									context: {}
   466  								}
   467  							}
   468  						}
   469  					}
   470  					expanded: {
   471  						namespace: "group"
   472  						object_id: "test"
   473  						relation: "member"
   474  					}
   475  					caveat_expression: {
   476  						caveat: {
   477  							caveat_name: "somecaveat"
   478  							context: {}
   479  						}
   480  					}
   481  				}
   482  				child_nodes: {
   483  					leaf_node: {
   484  						subjects: {
   485  							subject: {
   486  								namespace: "group"
   487  								object_id: "test"
   488  								relation: "member"
   489  							}
   490  							caveat_expression: {
   491  								caveat: {
   492  									caveat_name: "somecaveat"
   493  									context: {}
   494  								}
   495  							}
   496  						}
   497  					}
   498  					expanded: {
   499  						namespace: "document"
   500  						object_id: "testdoc"
   501  						relation: "viewer"
   502  					}
   503  				}
   504  			}
   505  			expanded: {
   506  				namespace: "document"
   507  				object_id: "testdoc"
   508  				relation: "viewer"
   509  			}
   510  			`,
   511  		},
   512  		{
   513  			"shallow caveated arrow",
   514  			`
   515  			definition user {}
   516  
   517  			caveat somecaveat(somecondition int) {
   518  				somecondition == 42
   519  			}
   520  
   521  			caveat anothercaveat(somecondition int) {
   522  				somecondition != 42
   523  			}
   524  
   525  			caveat orgcaveat(somecondition int) {
   526  				somecondition < 42
   527  			}
   528  
   529  			definition organization {
   530  				relation admin: user | user with orgcaveat
   531  			}
   532  
   533  			definition document {
   534  				relation org: organization with somecaveat
   535  				relation viewer: user
   536  				permission view = viewer + org->admin
   537  			}
   538  			`,
   539  			[]*core.RelationTuple{
   540  				tuple.MustParse("document:testdoc#viewer@user:tom"),
   541  				tuple.MustParse("document:testdoc#viewer@user:fred[anothercaveat]"),
   542  				tuple.MustParse("document:testdoc#org@organization:someorg[somecaveat]"),
   543  				tuple.MustParse("organization:someorg#admin@user:sarah"),
   544  				tuple.MustParse("organization:someorg#admin@user:mary[orgcaveat]"),
   545  			},
   546  			tuple.ParseONR("document:testdoc#view"),
   547  			v1.DispatchExpandRequest_SHALLOW,
   548  			`
   549  			intermediate_node:  {
   550  				operation:  UNION
   551  				child_nodes:  {
   552  					leaf_node:  {
   553  						subjects:  {
   554  							subject:  {
   555  								namespace:  "user"
   556  								object_id:  "fred"
   557  								relation:  "..."
   558  							}
   559  							caveat_expression:  {
   560  								caveat:  {
   561  									caveat_name:  "anothercaveat"
   562  									context:  {}
   563  								}
   564  							}
   565  						}
   566  						subjects:  {
   567  							subject:  {
   568  								namespace:  "user"
   569  								object_id:  "tom"
   570  								relation:  "..."
   571  							}
   572  						}
   573  					}
   574  					expanded:  {
   575  						namespace:  "document"
   576  						object_id:  "testdoc"
   577  						relation:  "viewer"
   578  					}
   579  				}
   580  				child_nodes:  {
   581  					intermediate_node:  {
   582  						operation:  UNION
   583  						child_nodes:  {
   584  							leaf_node:  {
   585  								subjects:  {
   586  									subject:  {
   587  										namespace:  "user"
   588  										object_id:  "mary"
   589  										relation:  "..."
   590  									}
   591  									caveat_expression:  {
   592  										caveat:  {
   593  											caveat_name:  "orgcaveat"
   594  											context:  {}
   595  										}
   596  									}
   597  								}
   598  								subjects:  {
   599  									subject:  {
   600  										namespace:  "user"
   601  										object_id:  "sarah"
   602  										relation:  "..."
   603  									}
   604  								}
   605  							}
   606  							expanded:  {
   607  								namespace:  "organization"
   608  								object_id:  "someorg"
   609  								relation:  "admin"
   610  							}
   611  							caveat_expression:  {
   612  								caveat:  {
   613  									caveat_name:  "somecaveat"
   614  									context:  {}
   615  								}
   616  							}
   617  						}
   618  					}
   619  					expanded:  {
   620  						namespace:  "document"
   621  						object_id:  "testdoc"
   622  						relation:  "view"
   623  					}
   624  				}
   625  			}
   626  			expanded:  {
   627  				namespace:  "document"
   628  				object_id:  "testdoc"
   629  				relation:  "view"
   630  			}
   631  			`,
   632  		},
   633  		{
   634  			"recursive caveated indirect arrow",
   635  			`definition user {}
   636  
   637  			caveat somecaveat(somecondition int) {
   638  				somecondition == 42
   639  			}
   640  		  
   641  			definition folder {
   642  				relation container: folder with somecaveat
   643  				relation member: user
   644  				permission view = container->member
   645  			}
   646  		  
   647  			definition resource {
   648  				relation folder: folder
   649  				permission view = folder->view
   650  			}`,
   651  			[]*core.RelationTuple{
   652  				tuple.MustParse("resource:someresource#folder@folder:first"),
   653  				tuple.MustParse("folder:first#container@folder:second[somecaveat]"),
   654  				tuple.MustParse("folder:first#member@user:notreachable"),
   655  				tuple.MustParse("folder:second#member@user:tom"),
   656  			},
   657  			tuple.ParseONR("resource:someresource#view"),
   658  			v1.DispatchExpandRequest_RECURSIVE,
   659  			`
   660  			intermediate_node: {
   661  				operation: UNION
   662  				child_nodes: {
   663  				  intermediate_node: {
   664  					operation: UNION
   665  					child_nodes: {
   666  					  intermediate_node: {
   667  						operation: UNION
   668  						child_nodes: {
   669  						  intermediate_node: {
   670  							operation: UNION
   671  							child_nodes: {
   672  							  leaf_node: {
   673  								subjects: {
   674  								  subject: {
   675  									namespace: "user"
   676  									object_id: "tom"
   677  									relation: "..."
   678  								  }
   679  								}
   680  							  }
   681  							  expanded: {
   682  								namespace: "folder"
   683  								object_id: "second"
   684  								relation: "member"
   685  							  }
   686  							  caveat_expression: {
   687  								caveat: {
   688  								  caveat_name: "somecaveat"
   689  								  context: {}
   690  								}
   691  							  }
   692  							}
   693  						  }
   694  						  expanded: {
   695  							namespace: "folder"
   696  							object_id: "first"
   697  							relation: "view"
   698  						  }
   699  						}
   700  					  }
   701  					  expanded: {
   702  						namespace: "folder"
   703  						object_id: "first"
   704  						relation: "view"
   705  					  }
   706  					}
   707  				  }
   708  				  expanded: {
   709  					namespace: "resource"
   710  					object_id: "someresource"
   711  					relation: "view"
   712  				  }
   713  				}
   714  			  }
   715  			  expanded: {
   716  				namespace: "resource"
   717  				object_id: "someresource"
   718  				relation: "view"
   719  			  }
   720  			`,
   721  		},
   722  	}
   723  
   724  	for _, tc := range testCases {
   725  		tc := tc
   726  		t.Run(tc.name, func(t *testing.T) {
   727  			require := require.New(t)
   728  
   729  			ctx, dispatch, revision := newLocalDispatcherWithSchemaAndRels(t, tc.schema, tc.relationships)
   730  
   731  			expandResult, err := dispatch.DispatchExpand(ctx, &v1.DispatchExpandRequest{
   732  				ResourceAndRelation: tc.start,
   733  				Metadata: &v1.ResolverMeta{
   734  					AtRevision:     revision.String(),
   735  					DepthRemaining: 50,
   736  				},
   737  				ExpansionMode: tc.expansionMode,
   738  			})
   739  			require.NoError(err)
   740  
   741  			expectedTree := &core.RelationTupleTreeNode{}
   742  			err = prototext.Unmarshal([]byte(tc.expectedTreeText), expectedTree)
   743  			require.NoError(err)
   744  
   745  			require.NoError(err)
   746  			require.NotNil(expandResult.TreeNode)
   747  			testutil.RequireProtoEqual(t, expectedTree, expandResult.TreeNode, "Got different expansion trees")
   748  		})
   749  	}
   750  }