github.com/authzed/spicedb@v1.32.1-0.20240520085336-ebda56537386/internal/services/v1/watch_test.go (about)

     1  package v1_test
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"sort"
     9  	"strings"
    10  	"testing"
    11  	"time"
    12  
    13  	v1 "github.com/authzed/authzed-go/proto/authzed/api/v1"
    14  	"github.com/authzed/grpcutil"
    15  	"github.com/stretchr/testify/require"
    16  	"google.golang.org/grpc/codes"
    17  	"google.golang.org/grpc/status"
    18  
    19  	"github.com/authzed/spicedb/internal/datastore/memdb"
    20  	"github.com/authzed/spicedb/internal/testfixtures"
    21  	"github.com/authzed/spicedb/internal/testserver"
    22  	"github.com/authzed/spicedb/pkg/tuple"
    23  	"github.com/authzed/spicedb/pkg/zedtoken"
    24  )
    25  
    26  func update(
    27  	op v1.RelationshipUpdate_Operation,
    28  	resourceObjType,
    29  	resourceObjID,
    30  	relation,
    31  	subObjType,
    32  	subObjectID string,
    33  ) *v1.RelationshipUpdate {
    34  	return &v1.RelationshipUpdate{
    35  		Operation: op,
    36  		Relationship: &v1.Relationship{
    37  			Resource: &v1.ObjectReference{
    38  				ObjectType: resourceObjType,
    39  				ObjectId:   resourceObjID,
    40  			},
    41  			Relation: relation,
    42  			Subject: &v1.SubjectReference{
    43  				Object: &v1.ObjectReference{
    44  					ObjectType: subObjType,
    45  					ObjectId:   subObjectID,
    46  				},
    47  			},
    48  		},
    49  	}
    50  }
    51  
    52  func TestWatch(t *testing.T) {
    53  	testCases := []struct {
    54  		name                string
    55  		objectTypesFilter   []string
    56  		relationshipFilters []*v1.RelationshipFilter
    57  		startCursor         *v1.ZedToken
    58  		mutations           []*v1.RelationshipUpdate
    59  		expectedCode        codes.Code
    60  		expectedUpdates     []*v1.RelationshipUpdate
    61  	}{
    62  		{
    63  			name:         "unfiltered watch",
    64  			expectedCode: codes.OK,
    65  			mutations: []*v1.RelationshipUpdate{
    66  				update(v1.RelationshipUpdate_OPERATION_CREATE, "document", "document1", "viewer", "user", "user1"),
    67  				update(v1.RelationshipUpdate_OPERATION_DELETE, "folder", "auditors", "viewer", "user", "auditor"),
    68  				update(v1.RelationshipUpdate_OPERATION_TOUCH, "folder", "folder2", "viewer", "user", "user1"),
    69  			},
    70  			expectedUpdates: []*v1.RelationshipUpdate{
    71  				update(v1.RelationshipUpdate_OPERATION_TOUCH, "document", "document1", "viewer", "user", "user1"),
    72  				update(v1.RelationshipUpdate_OPERATION_DELETE, "folder", "auditors", "viewer", "user", "auditor"),
    73  				update(v1.RelationshipUpdate_OPERATION_TOUCH, "folder", "folder2", "viewer", "user", "user1"),
    74  			},
    75  		},
    76  		{
    77  			name:              "watch with objectType filter",
    78  			expectedCode:      codes.OK,
    79  			objectTypesFilter: []string{"document"},
    80  			mutations: []*v1.RelationshipUpdate{
    81  				update(v1.RelationshipUpdate_OPERATION_CREATE, "document", "document1", "viewer", "user", "user1"),
    82  				update(v1.RelationshipUpdate_OPERATION_TOUCH, "document", "document2", "viewer", "user", "user1"),
    83  				update(v1.RelationshipUpdate_OPERATION_DELETE, "folder", "auditors", "viewer", "user", "auditor"),
    84  			},
    85  			expectedUpdates: []*v1.RelationshipUpdate{
    86  				update(v1.RelationshipUpdate_OPERATION_TOUCH, "document", "document1", "viewer", "user", "user1"),
    87  				update(v1.RelationshipUpdate_OPERATION_TOUCH, "document", "document2", "viewer", "user", "user1"),
    88  			},
    89  		},
    90  		{
    91  			name:         "watch with relationship filters",
    92  			expectedCode: codes.OK,
    93  			relationshipFilters: []*v1.RelationshipFilter{
    94  				{
    95  					ResourceType: "document",
    96  				},
    97  				{
    98  					OptionalResourceIdPrefix: "d",
    99  				},
   100  			},
   101  			mutations: []*v1.RelationshipUpdate{
   102  				update(v1.RelationshipUpdate_OPERATION_CREATE, "document", "document1", "viewer", "user", "user1"),
   103  				update(v1.RelationshipUpdate_OPERATION_TOUCH, "document", "document2", "viewer", "user", "user1"),
   104  				update(v1.RelationshipUpdate_OPERATION_DELETE, "folder", "auditors", "viewer", "user", "auditor"),
   105  			},
   106  			expectedUpdates: []*v1.RelationshipUpdate{
   107  				update(v1.RelationshipUpdate_OPERATION_TOUCH, "document", "document1", "viewer", "user", "user1"),
   108  				update(v1.RelationshipUpdate_OPERATION_TOUCH, "document", "document2", "viewer", "user", "user1"),
   109  			},
   110  		},
   111  		{
   112  			name:         "watch with modified relationship filters",
   113  			expectedCode: codes.OK,
   114  			relationshipFilters: []*v1.RelationshipFilter{
   115  				{
   116  					ResourceType: "folder",
   117  				},
   118  			},
   119  			mutations: []*v1.RelationshipUpdate{
   120  				update(v1.RelationshipUpdate_OPERATION_CREATE, "document", "document1", "viewer", "user", "user1"),
   121  				update(v1.RelationshipUpdate_OPERATION_TOUCH, "document", "document2", "viewer", "user", "user1"),
   122  				update(v1.RelationshipUpdate_OPERATION_DELETE, "folder", "auditors", "viewer", "user", "auditor"),
   123  			},
   124  			expectedUpdates: []*v1.RelationshipUpdate{
   125  				update(v1.RelationshipUpdate_OPERATION_DELETE, "folder", "auditors", "viewer", "user", "auditor"),
   126  			},
   127  		},
   128  		{
   129  			name:         "watch with resource ID prefix",
   130  			expectedCode: codes.OK,
   131  			relationshipFilters: []*v1.RelationshipFilter{
   132  				{
   133  					OptionalResourceIdPrefix: "document1",
   134  				},
   135  			},
   136  			mutations: []*v1.RelationshipUpdate{
   137  				update(v1.RelationshipUpdate_OPERATION_CREATE, "document", "document1", "viewer", "user", "user1"),
   138  				update(v1.RelationshipUpdate_OPERATION_TOUCH, "document", "document2", "viewer", "user", "user1"),
   139  				update(v1.RelationshipUpdate_OPERATION_DELETE, "folder", "auditors", "viewer", "user", "auditor"),
   140  			},
   141  			expectedUpdates: []*v1.RelationshipUpdate{
   142  				update(v1.RelationshipUpdate_OPERATION_TOUCH, "document", "document1", "viewer", "user", "user1"),
   143  			},
   144  		},
   145  		{
   146  			name:         "watch with shorter resource ID prefix",
   147  			expectedCode: codes.OK,
   148  			relationshipFilters: []*v1.RelationshipFilter{
   149  				{
   150  					OptionalResourceIdPrefix: "doc",
   151  				},
   152  			},
   153  			mutations: []*v1.RelationshipUpdate{
   154  				update(v1.RelationshipUpdate_OPERATION_CREATE, "document", "document1", "viewer", "user", "user1"),
   155  				update(v1.RelationshipUpdate_OPERATION_TOUCH, "document", "document2", "viewer", "user", "user1"),
   156  				update(v1.RelationshipUpdate_OPERATION_DELETE, "folder", "auditors", "viewer", "user", "auditor"),
   157  			},
   158  			expectedUpdates: []*v1.RelationshipUpdate{
   159  				update(v1.RelationshipUpdate_OPERATION_TOUCH, "document", "document1", "viewer", "user", "user1"),
   160  				update(v1.RelationshipUpdate_OPERATION_TOUCH, "document", "document2", "viewer", "user", "user1"),
   161  			},
   162  		},
   163  		{
   164  			name:         "invalid zedtoken",
   165  			startCursor:  &v1.ZedToken{Token: "bad-token"},
   166  			expectedCode: codes.InvalidArgument,
   167  		},
   168  		{
   169  			name:         "empty zedtoken fails validation",
   170  			startCursor:  &v1.ZedToken{Token: ""},
   171  			expectedCode: codes.InvalidArgument,
   172  		},
   173  		{
   174  			name: "watch with both kinds of filters",
   175  			relationshipFilters: []*v1.RelationshipFilter{
   176  				{
   177  					OptionalResourceIdPrefix: "doc",
   178  				},
   179  			},
   180  			objectTypesFilter: []string{"document"},
   181  			expectedCode:      codes.InvalidArgument,
   182  		},
   183  		{
   184  			name: "watch with both fields of filter",
   185  			relationshipFilters: []*v1.RelationshipFilter{
   186  				{
   187  					OptionalResourceIdPrefix: "doc",
   188  					OptionalResourceId:       "document1",
   189  				},
   190  			},
   191  			expectedCode: codes.InvalidArgument,
   192  		},
   193  		{
   194  			name: "watch with invalid filter resource type",
   195  			relationshipFilters: []*v1.RelationshipFilter{
   196  				{
   197  					ResourceType: "invalid",
   198  				},
   199  			},
   200  			expectedCode: codes.FailedPrecondition,
   201  		},
   202  	}
   203  
   204  	for _, tc := range testCases {
   205  		tc := tc
   206  		t.Run(tc.name, func(t *testing.T) {
   207  			require := require.New(t)
   208  
   209  			conn, cleanup, _, revision := testserver.NewTestServer(require, 0, memdb.DisableGC, true, testfixtures.StandardDatastoreWithData)
   210  			t.Cleanup(cleanup)
   211  			client := v1.NewWatchServiceClient(conn)
   212  
   213  			cursor := zedtoken.MustNewFromRevision(revision)
   214  			if tc.startCursor != nil {
   215  				cursor = tc.startCursor
   216  			}
   217  
   218  			ctx, cancel := context.WithCancel(context.Background())
   219  			defer cancel()
   220  
   221  			stream, err := client.Watch(ctx, &v1.WatchRequest{
   222  				OptionalObjectTypes:         tc.objectTypesFilter,
   223  				OptionalRelationshipFilters: tc.relationshipFilters,
   224  				OptionalStartCursor:         cursor,
   225  			})
   226  			require.NoError(err)
   227  
   228  			if tc.expectedCode == codes.OK {
   229  				updatesChan := make(chan []*v1.RelationshipUpdate, len(tc.mutations))
   230  
   231  				go func() {
   232  					defer close(updatesChan)
   233  
   234  					for {
   235  						select {
   236  						case <-ctx.Done():
   237  							return
   238  						case <-time.After(3 * time.Second):
   239  							panic(fmt.Errorf("timed out waiting for stream updates"))
   240  						default:
   241  							resp, err := stream.Recv()
   242  							if err != nil {
   243  								errStatus, ok := status.FromError(err)
   244  								if (ok && (errStatus.Code() == codes.Canceled || errStatus.Code() == codes.Unavailable)) || errors.Is(err, io.EOF) {
   245  									break
   246  								}
   247  
   248  								panic(fmt.Errorf("received a stream read error: %w", err))
   249  							}
   250  
   251  							updatesChan <- resp.Updates
   252  						}
   253  					}
   254  				}()
   255  
   256  				_, err := v1.NewPermissionsServiceClient(conn).WriteRelationships(context.Background(), &v1.WriteRelationshipsRequest{
   257  					Updates: tc.mutations,
   258  				})
   259  				require.NoError(err)
   260  
   261  				var receivedUpdates []*v1.RelationshipUpdate
   262  
   263  				for len(receivedUpdates) < len(tc.expectedUpdates) {
   264  					select {
   265  					case updates := <-updatesChan:
   266  						receivedUpdates = append(receivedUpdates, updates...)
   267  					case <-time.After(1 * time.Second):
   268  						require.FailNow("timed out waiting for updates")
   269  						return
   270  					}
   271  				}
   272  
   273  				require.Equal(sortUpdates(tc.expectedUpdates), sortUpdates(receivedUpdates))
   274  			} else {
   275  				_, err := stream.Recv()
   276  				grpcutil.RequireStatus(t, tc.expectedCode, err)
   277  			}
   278  		})
   279  	}
   280  }
   281  
   282  func sortUpdates(in []*v1.RelationshipUpdate) []*v1.RelationshipUpdate {
   283  	out := make([]*v1.RelationshipUpdate, 0, len(in))
   284  	out = append(out, in...)
   285  	sort.Slice(out, func(i, j int) bool {
   286  		left, right := out[i], out[j]
   287  		compareResult := strings.Compare(tuple.MustRelString(left.Relationship), tuple.MustRelString(right.Relationship))
   288  		if compareResult < 0 {
   289  			return true
   290  		}
   291  		if compareResult > 0 {
   292  			return false
   293  		}
   294  
   295  		return left.Operation < right.Operation
   296  	})
   297  
   298  	return out
   299  }