github.com/openfga/openfga@v1.5.4-rc1/pkg/server/test/read_changes.go (about)

     1  package test
     2  
     3  import (
     4  	"context"
     5  	"sync"
     6  	"testing"
     7  
     8  	"github.com/google/go-cmp/cmp"
     9  	openfgav1 "github.com/openfga/api/proto/openfga/v1"
    10  	"github.com/stretchr/testify/require"
    11  	"google.golang.org/protobuf/protoadapt"
    12  	"google.golang.org/protobuf/testing/protocmp"
    13  	"google.golang.org/protobuf/types/known/wrapperspb"
    14  
    15  	"github.com/openfga/openfga/pkg/server/commands"
    16  	serverErrors "github.com/openfga/openfga/pkg/server/errors"
    17  	"github.com/openfga/openfga/pkg/storage"
    18  	"github.com/openfga/openfga/pkg/testutils"
    19  )
    20  
    21  type testCase struct {
    22  	_name                            string
    23  	request                          *openfgav1.ReadChangesRequest
    24  	expectedError                    error
    25  	expectedChanges                  []*openfgav1.TupleChange
    26  	expectEmptyContinuationToken     bool
    27  	saveContinuationTokenForNextTest bool
    28  }
    29  
    30  var tkMaria = &openfgav1.TupleKey{
    31  	Object:   "repo:openfga/openfgapb",
    32  	Relation: "admin",
    33  	User:     "maria",
    34  }
    35  var tkMariaOrg = &openfgav1.TupleKey{
    36  	Object:   "org:openfga",
    37  	Relation: "member",
    38  	User:     "maria",
    39  }
    40  var tkCraig = &openfgav1.TupleKey{
    41  	Object:   "repo:openfga/openfgapb",
    42  	Relation: "admin",
    43  	User:     "craig",
    44  }
    45  var tkYamil = &openfgav1.TupleKey{
    46  	Object:   "repo:openfga/openfgapb",
    47  	Relation: "admin",
    48  	User:     "yamil",
    49  }
    50  
    51  func newReadChangesRequest(store, objectType, contToken string, pageSize int32) *openfgav1.ReadChangesRequest {
    52  	return &openfgav1.ReadChangesRequest{
    53  		StoreId:           store,
    54  		Type:              objectType,
    55  		ContinuationToken: contToken,
    56  		PageSize:          wrapperspb.Int32(pageSize),
    57  	}
    58  }
    59  
    60  func TestReadChanges(t *testing.T, datastore storage.OpenFGADatastore) {
    61  	store := testutils.CreateRandomString(10)
    62  	ctx, backend, err := writeTuples(store, datastore)
    63  	require.NoError(t, err)
    64  
    65  	t.Run("read_changes_without_type", func(t *testing.T) {
    66  		testCases := []testCase{
    67  			{
    68  				_name:   "request_with_pageSize=2_returns_2_tuple_and_a_token",
    69  				request: newReadChangesRequest(store, "", "", 2),
    70  				expectedChanges: []*openfgav1.TupleChange{
    71  					{
    72  						TupleKey:  tkMaria,
    73  						Operation: openfgav1.TupleOperation_TUPLE_OPERATION_WRITE,
    74  					},
    75  					{
    76  						TupleKey:  tkCraig,
    77  						Operation: openfgav1.TupleOperation_TUPLE_OPERATION_WRITE,
    78  					},
    79  				},
    80  				expectEmptyContinuationToken:     false,
    81  				expectedError:                    nil,
    82  				saveContinuationTokenForNextTest: true,
    83  			},
    84  			{
    85  				_name:   "request_with_previous_token_returns_all_remaining_changes",
    86  				request: newReadChangesRequest(store, "", "", storage.DefaultPageSize),
    87  				expectedChanges: []*openfgav1.TupleChange{
    88  					{
    89  						TupleKey:  tkYamil,
    90  						Operation: openfgav1.TupleOperation_TUPLE_OPERATION_WRITE,
    91  					},
    92  					{
    93  						TupleKey:  tkMariaOrg,
    94  						Operation: openfgav1.TupleOperation_TUPLE_OPERATION_WRITE,
    95  					},
    96  				},
    97  				expectEmptyContinuationToken:     false,
    98  				expectedError:                    nil,
    99  				saveContinuationTokenForNextTest: true,
   100  			},
   101  			{
   102  				_name:                            "request_with_previous_token_returns_no_more_changes",
   103  				request:                          newReadChangesRequest(store, "", "", storage.DefaultPageSize),
   104  				expectedChanges:                  nil,
   105  				expectEmptyContinuationToken:     false,
   106  				expectedError:                    nil,
   107  				saveContinuationTokenForNextTest: false,
   108  			},
   109  			{
   110  				_name:                            "request_with_invalid_token_returns_invalid_token_error",
   111  				request:                          newReadChangesRequest(store, "", "foo", storage.DefaultPageSize),
   112  				expectedChanges:                  nil,
   113  				expectEmptyContinuationToken:     false,
   114  				expectedError:                    serverErrors.InvalidContinuationToken,
   115  				saveContinuationTokenForNextTest: false,
   116  			},
   117  		}
   118  
   119  		readChangesQuery := commands.NewReadChangesQuery(backend)
   120  		runTests(t, ctx, testCases, readChangesQuery)
   121  	})
   122  
   123  	t.Run("read_changes_with_type", func(t *testing.T) {
   124  		testCases := []testCase{
   125  			{
   126  				_name:                        "if_no_tuples_with_type,_return_empty_changes_and_no_token",
   127  				request:                      newReadChangesRequest(store, "type-not-found", "", 1),
   128  				expectedChanges:              nil,
   129  				expectEmptyContinuationToken: true,
   130  				expectedError:                nil,
   131  			},
   132  			{
   133  				_name:   "if_1_tuple_with_'org type',_read_changes_with_'org'_filter_returns_1_change_and_a_token",
   134  				request: newReadChangesRequest(store, "org", "", storage.DefaultPageSize),
   135  				expectedChanges: []*openfgav1.TupleChange{{
   136  					TupleKey:  tkMariaOrg,
   137  					Operation: openfgav1.TupleOperation_TUPLE_OPERATION_WRITE,
   138  				}},
   139  				expectEmptyContinuationToken: false,
   140  				expectedError:                nil,
   141  			},
   142  			{
   143  				_name:   "if_2_tuples_with_'repo'_type,_read_changes_with_'repo'_filter and page size of 1 returns 1 change and a token",
   144  				request: newReadChangesRequest(store, "repo", "", 1),
   145  				expectedChanges: []*openfgav1.TupleChange{{
   146  					TupleKey:  tkMaria,
   147  					Operation: openfgav1.TupleOperation_TUPLE_OPERATION_WRITE,
   148  				}},
   149  				expectEmptyContinuationToken:     false,
   150  				expectedError:                    nil,
   151  				saveContinuationTokenForNextTest: true,
   152  			}, {
   153  				_name:   "using_the_token_from_the_previous_test_yields_1_change_and_a_token",
   154  				request: newReadChangesRequest(store, "repo", "", storage.DefaultPageSize),
   155  				expectedChanges: []*openfgav1.TupleChange{{
   156  					TupleKey:  tkCraig,
   157  					Operation: openfgav1.TupleOperation_TUPLE_OPERATION_WRITE,
   158  				}, {
   159  					TupleKey:  tkYamil,
   160  					Operation: openfgav1.TupleOperation_TUPLE_OPERATION_WRITE,
   161  				}},
   162  				expectEmptyContinuationToken:     false,
   163  				expectedError:                    nil,
   164  				saveContinuationTokenForNextTest: true,
   165  			}, {
   166  				_name:                            "using_the_token_from_the_previous_test_yields_0_changes_and_a_token",
   167  				request:                          newReadChangesRequest(store, "repo", "", storage.DefaultPageSize),
   168  				expectedChanges:                  nil,
   169  				expectEmptyContinuationToken:     false,
   170  				expectedError:                    nil,
   171  				saveContinuationTokenForNextTest: true,
   172  			}, {
   173  				_name:         "using_the_token_from_the_previous_test_yields_an_error_because_the_types_in_the_token_and_the_request_don't_match",
   174  				request:       newReadChangesRequest(store, "does-not-match", "", storage.DefaultPageSize),
   175  				expectedError: serverErrors.MismatchObjectType,
   176  			},
   177  		}
   178  
   179  		readChangesQuery := commands.NewReadChangesQuery(backend)
   180  		runTests(t, ctx, testCases, readChangesQuery)
   181  	})
   182  
   183  	t.Run("read_changes_with_horizon_offset", func(t *testing.T) {
   184  		testCases := []testCase{
   185  			{
   186  				_name: "when_the_horizon_offset_is_non-zero_no_tuples_should_be_returned",
   187  				request: &openfgav1.ReadChangesRequest{
   188  					StoreId: store,
   189  				},
   190  				expectedChanges:              nil,
   191  				expectEmptyContinuationToken: true,
   192  				expectedError:                nil,
   193  			},
   194  		}
   195  
   196  		readChangesQuery := commands.NewReadChangesQuery(backend,
   197  			commands.WithReadChangeQueryHorizonOffset(2),
   198  		)
   199  		runTests(t, ctx, testCases, readChangesQuery)
   200  	})
   201  }
   202  
   203  func runTests(t *testing.T, ctx context.Context, testCasesInOrder []testCase, readChangesQuery *commands.ReadChangesQuery) { //nolint:revive
   204  	ignoreTimestampOpts := protocmp.IgnoreFields(protoadapt.MessageV2Of(&openfgav1.TupleChange{}), "timestamp")
   205  	var res *openfgav1.ReadChangesResponse
   206  	var err error
   207  	for i, test := range testCasesInOrder {
   208  		t.Run(test._name, func(t *testing.T) {
   209  			if i >= 1 {
   210  				previousTest := testCasesInOrder[i-1]
   211  				if previousTest.saveContinuationTokenForNextTest {
   212  					previousToken := res.GetContinuationToken()
   213  					test.request.ContinuationToken = previousToken
   214  				}
   215  			}
   216  			res, err = readChangesQuery.Execute(ctx, test.request)
   217  
   218  			if test.expectedError != nil {
   219  				require.ErrorIs(t, err, test.expectedError)
   220  			} else {
   221  				require.NoError(t, err)
   222  				require.NotNil(t, res)
   223  				if diff := cmp.Diff(test.expectedChanges, res.GetChanges(), ignoreTimestampOpts, protocmp.Transform()); diff != "" {
   224  					t.Errorf("tuple change mismatch (-want +got):\n%s", diff)
   225  				}
   226  				if test.expectEmptyContinuationToken {
   227  					require.Empty(t, res.GetContinuationToken())
   228  				} else {
   229  					require.NotEmpty(t, res.GetContinuationToken())
   230  				}
   231  			}
   232  		})
   233  	}
   234  }
   235  
   236  func TestReadChangesReturnsSameContTokenWhenNoChanges(t *testing.T, datastore storage.OpenFGADatastore) {
   237  	store := testutils.CreateRandomString(10)
   238  	ctx, backend, err := writeTuples(store, datastore)
   239  	require.NoError(t, err)
   240  
   241  	readChangesQuery := commands.NewReadChangesQuery(backend)
   242  
   243  	res1, err := readChangesQuery.Execute(ctx, newReadChangesRequest(store, "", "", storage.DefaultPageSize))
   244  	require.NoError(t, err)
   245  
   246  	res2, err := readChangesQuery.Execute(ctx, newReadChangesRequest(store, "", res1.GetContinuationToken(), storage.DefaultPageSize))
   247  	require.NoError(t, err)
   248  
   249  	require.Equal(t, res1.GetContinuationToken(), res2.GetContinuationToken())
   250  }
   251  
   252  func TestReadChangesAfterConcurrentWritesReturnsUniqueResults(t *testing.T, datastore storage.OpenFGADatastore) {
   253  	store := testutils.CreateRandomString(10)
   254  
   255  	tuplesToWriteOne := []*openfgav1.TupleKey{tkMaria, tkCraig}
   256  	tuplesToWriteTwo := []*openfgav1.TupleKey{tkYamil}
   257  	totalTuplesToWrite := len(tuplesToWriteOne) + len(tuplesToWriteTwo)
   258  	ctx, backend := writeTuplesConcurrently(t, store, datastore, tuplesToWriteOne, tuplesToWriteTwo)
   259  
   260  	readChangesQuery := commands.NewReadChangesQuery(backend)
   261  
   262  	// without type
   263  	res1, err := readChangesQuery.Execute(ctx, newReadChangesRequest(store, "", "", storage.DefaultPageSize))
   264  	require.NoError(t, err)
   265  	require.Len(t, res1.GetChanges(), totalTuplesToWrite)
   266  
   267  	// with type
   268  	res2, err := readChangesQuery.Execute(ctx, newReadChangesRequest(store, "repo", "", storage.DefaultPageSize))
   269  	require.NoError(t, err)
   270  	require.Len(t, res2.GetChanges(), totalTuplesToWrite)
   271  }
   272  
   273  func writeTuples(store string, datastore storage.OpenFGADatastore) (context.Context, storage.ChangelogBackend, error) {
   274  	ctx := context.Background()
   275  
   276  	writes := []*openfgav1.TupleKey{tkMaria, tkCraig, tkYamil, tkMariaOrg}
   277  	err := datastore.Write(
   278  		ctx,
   279  		store,
   280  		[]*openfgav1.TupleKeyWithoutCondition{},
   281  		writes,
   282  	)
   283  	if err != nil {
   284  		return nil, nil, err
   285  	}
   286  
   287  	return ctx, datastore, nil
   288  }
   289  
   290  // writeTuplesConcurrently writes two groups of tuples concurrently to expose potential race issues when reading changes.
   291  func writeTuplesConcurrently(t *testing.T, store string, datastore storage.OpenFGADatastore, tupleGroupOne, tupleGroupTwo []*openfgav1.TupleKey) (context.Context, storage.ChangelogBackend) {
   292  	t.Helper()
   293  	ctx := context.Background()
   294  
   295  	var wg sync.WaitGroup
   296  	wg.Add(2)
   297  
   298  	go func() {
   299  		err := datastore.Write(
   300  			ctx,
   301  			store,
   302  			[]*openfgav1.TupleKeyWithoutCondition{},
   303  			tupleGroupOne,
   304  		)
   305  		if err != nil {
   306  			t.Logf("failed to write tuples: %s", err)
   307  		}
   308  		wg.Done()
   309  	}()
   310  
   311  	go func() {
   312  		err := datastore.Write(
   313  			ctx,
   314  			store,
   315  			[]*openfgav1.TupleKeyWithoutCondition{},
   316  			tupleGroupTwo,
   317  		)
   318  		if err != nil {
   319  			t.Logf("failed to write tuples: %s", err)
   320  		}
   321  		wg.Done()
   322  	}()
   323  
   324  	wg.Wait()
   325  
   326  	return ctx, datastore
   327  }