From a43510d2bf1680e9c24e8e244a46a22c782c088f Mon Sep 17 00:00:00 2001 From: Srinivas Chippagiri Date: Thu, 5 Nov 2026 01:16:34 -0800 Subject: [PATCH 1/4] feat: import test tuples and context into assertions fga store import wrote the check tests as assertions with only the user, relation, object and expectation, so assertions that depend on the test's tuples or on a condition context failed in the store. Assertions support context and contextual tuples, so add the check's context and the test's tuples to each assertion. A test with more than 20 tuples, the most an assertion accepts, keeps the previous behavior and the CLI warns about it. Part of #452 Signed-off-by: Srinivas Chippagiri --- README.md | 2 + cmd/store/import.go | 33 +++++++++--- cmd/store/import_test.go | 109 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 888c3388..26e03d3b 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,8 @@ fga store **import** * `--max-parallel-requests`: Max requests to send in parallel (optional, default=4) * `--allow-external-files`: Allow `model_file`, `tuple_file` and `tuple_files` references in the store file to resolve outside the store file's directory (optional, default=false). Only enable this for store files you trust. +The `check` tests in the store file are also written to the store as assertions (at most 100). Each assertion includes the check's `context` and its test's `tuples` (or `tuple_file`) as contextual tuples. A test with more than 20 tuples is written without contextual tuples, because assertions accept at most 20, and the CLI prints a warning. + ###### Example `fga store import --file model.fga.yaml` diff --git a/cmd/store/import.go b/cmd/store/import.go index fa1673be..527f55ab 100644 --- a/cmd/store/import.go +++ b/cmd/store/import.go @@ -46,7 +46,9 @@ const ( progressBarThrottleValue = 65 progressBarUpdateDelay = 5 * time.Millisecond maxAssertionsPerWrite = 100 - expressionConditionName = "$expression" + // The API accepts at most 20 contextual tuples per assertion + maxContextualTuplesPerAssertion = 20 + expressionConditionName = "$expression" ) var errExpressionConditionNotSupported = errors.New( //nolint:err113 @@ -233,7 +235,17 @@ func importAssertions( for _, modelTest := range modelTests { if len(modelTest.Check) > 0 { - checkAssertions := getCheckAssertions(modelTest.Check) + contextualTuples := modelTest.Tuples + if len(contextualTuples) > maxContextualTuplesPerAssertion { + fmt.Fprintf(os.Stderr, + "Warning: test %q has %d tuples, but assertions accept at most %d contextual tuples, "+ + "so its assertions are written without them\n", + modelTest.Name, len(contextualTuples), maxContextualTuplesPerAssertion) + + contextualTuples = nil + } + + checkAssertions := getCheckAssertions(modelTest.Check, contextualTuples) assertions = append(assertions, checkAssertions...) } } @@ -259,7 +271,12 @@ func importAssertions( return nil } -func getCheckAssertions(checkTests []storetest.ModelTestCheck) []client.ClientAssertion { +// getCheckAssertions turns check tests into assertions. Each assertion carries the check's context and the +// test's tuples as contextual tuples, so it holds without writing those tuples to the store. +func getCheckAssertions( + checkTests []storetest.ModelTestCheck, + contextualTuples []client.ClientContextualTupleKey, +) []client.ClientAssertion { assertions := make([]client.ClientAssertion, 0, len(checkTests)) for _, checkTest := range checkTests { @@ -270,10 +287,12 @@ func getCheckAssertions(checkTests []storetest.ModelTestCheck) []client.ClientAs for _, object := range objects { for relation, expectation := range checkTest.Assertions { assertions = append(assertions, client.ClientAssertion{ - User: user, - Relation: relation, - Object: object, - Expectation: expectation, + User: user, + Relation: relation, + Object: object, + Expectation: expectation, + Context: checkTest.Context, + ContextualTuples: contextualTuples, }) } } diff --git a/cmd/store/import_test.go b/cmd/store/import_test.go index 38a44432..c2e7f5c0 100644 --- a/cmd/store/import_test.go +++ b/cmd/store/import_test.go @@ -1,6 +1,7 @@ package store import ( + "fmt" "os" "path/filepath" "testing" @@ -494,3 +495,111 @@ func setupWriteAssertionsMock( mockWriteAssertions.EXPECT().Options(expectedOptions).Return(mockWriteAssertions) mockWriteAssertions.EXPECT().Execute().Return(nil, nil) } + +func TestImportStoreAssertionsCarryTestTuplesAndContext(t *testing.T) { + t.Parallel() + + modelID, storeID := testModelID, testStoreID + expectedOptions := client.ClientWriteAssertionsOptions{AuthorizationModelId: &modelID, StoreId: &storeID} + + testTuples := []client.ClientContextualTupleKey{ + {User: "folder:5", Relation: "parent", Object: "folder:product-2021"}, + } + context := map[string]any{"some": "value"} + + expectedAssertions := []client.ClientAssertion{ + { + User: "user:anne", + Relation: "viewer", + Object: "folder:product-2021", + Expectation: true, + Context: &context, + ContextualTuples: testTuples, + }, + } + + mockCtrl := gomock.NewController(t) + mockFgaClient := mockclient.NewMockSdkClient(mockCtrl) + + defer mockCtrl.Finish() + + setupWriteAssertionsMock(mockCtrl, mockFgaClient, expectedAssertions, expectedOptions) + setupWriteModelMock(mockCtrl, mockFgaClient, modelID) + setupCreateStoreMock(mockCtrl, mockFgaClient, storeID) + + testStore := storetest.StoreData{ + Model: `type user + type folder + relations + define parent: [folder] + define viewer: [user]`, + Tests: []storetest.ModelTest{ + { + Name: "Test", + Tuples: testTuples, + Check: []storetest.ModelTestCheck{ + { + User: "user:anne", + Object: "folder:product-2021", + Context: &context, + Assertions: map[string]bool{"viewer": true}, + }, + }, + }, + }, + } + + _, err := importStore(t.Context(), &fga.ClientConfig{}, mockFgaClient, &testStore, "", "", 10, 1, "") + require.NoError(t, err) +} + +func TestImportStoreAssertionsWithoutTooManyTestTuples(t *testing.T) { + t.Parallel() + + modelID, storeID := testModelID, testStoreID + expectedOptions := client.ClientWriteAssertionsOptions{AuthorizationModelId: &modelID, StoreId: &storeID} + + // One more tuple than an assertion accepts as contextual tuples + testTuples := make([]client.ClientContextualTupleKey, maxContextualTuplesPerAssertion+1) + for i := range testTuples { + testTuples[i] = client.ClientContextualTupleKey{ + User: fmt.Sprintf("user:%d", i), Relation: "viewer", Object: "document:doc1", + } + } + + expectedAssertions := []client.ClientAssertion{ + {User: "user:anne", Relation: "viewer", Object: "document:doc1", Expectation: false}, + } + + mockCtrl := gomock.NewController(t) + mockFgaClient := mockclient.NewMockSdkClient(mockCtrl) + + defer mockCtrl.Finish() + + setupWriteAssertionsMock(mockCtrl, mockFgaClient, expectedAssertions, expectedOptions) + setupWriteModelMock(mockCtrl, mockFgaClient, modelID) + setupCreateStoreMock(mockCtrl, mockFgaClient, storeID) + + testStore := storetest.StoreData{ + Model: `type user + type document + relations + define viewer: [user]`, + Tests: []storetest.ModelTest{ + { + Name: "Test", + Tuples: testTuples, + Check: []storetest.ModelTestCheck{ + { + User: "user:anne", + Object: "document:doc1", + Assertions: map[string]bool{"viewer": false}, + }, + }, + }, + }, + } + + _, err := importStore(t.Context(), &fga.ClientConfig{}, mockFgaClient, &testStore, "", "", 10, 1, "") + require.NoError(t, err) +} From 6e0e61d6d2f93a0777a2cea865f802b5be5d2b5a Mon Sep 17 00:00:00 2001 From: Srinivas Chippagiri Date: Sat, 7 Nov 2026 00:00:51 -0800 Subject: [PATCH 2/4] fix: keep the imported assertions under the request size limit Signed-off-by: Srinivas Chippagiri --- README.md | 2 +- cmd/store/import.go | 51 ++++++++++++++++++++++++++++++++++++++-- cmd/store/import_test.go | 46 +++++++++++++++++++++++++++++++++++- 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 26e03d3b..691a3245 100644 --- a/README.md +++ b/README.md @@ -274,7 +274,7 @@ fga store **import** * `--max-parallel-requests`: Max requests to send in parallel (optional, default=4) * `--allow-external-files`: Allow `model_file`, `tuple_file` and `tuple_files` references in the store file to resolve outside the store file's directory (optional, default=false). Only enable this for store files you trust. -The `check` tests in the store file are also written to the store as assertions (at most 100). Each assertion includes the check's `context` and its test's `tuples` (or `tuple_file`) as contextual tuples. A test with more than 20 tuples is written without contextual tuples, because assertions accept at most 20, and the CLI prints a warning. +The `check` tests in the store file are also written to the store as assertions (at most 100). Each assertion includes the check's `context` and its test's `tuples` (including those loaded from `tuple_file`) as contextual tuples. A test with more than 20 tuples is written without contextual tuples, because assertions accept at most 20, and the CLI prints a warning. If the assertions together are too large for one request, the last ones are written without contextual tuples, also with a warning. ###### Example `fga store import --file model.fga.yaml` diff --git a/cmd/store/import.go b/cmd/store/import.go index 527f55ab..2ad492e0 100644 --- a/cmd/store/import.go +++ b/cmd/store/import.go @@ -18,6 +18,7 @@ package store import ( "context" + "encoding/json" "errors" "fmt" "os" @@ -46,9 +47,12 @@ const ( progressBarThrottleValue = 65 progressBarUpdateDelay = 5 * time.Millisecond maxAssertionsPerWrite = 100 - // The API accepts at most 20 contextual tuples per assertion + // The API accepts at most 20 contextual tuples per assertion. maxContextualTuplesPerAssertion = 20 - expressionConditionName = "$expression" + // The API rejects WriteAssertions requests over 64000 bytes. Assertions are measured by their JSON size, + // which is larger than their size in the request, and kept a bit under the limit. + maxAssertionsRequestBytes = 60000 + expressionConditionName = "$expression" ) var errExpressionConditionNotSupported = errors.New( //nolint:err113 @@ -262,6 +266,8 @@ func importAssertions( assertions = assertions[:maxAssertionsPerWrite] } + dropContextualTuplesOverSizeLimit(assertions) + _, err := fgaClient.WriteAssertions(ctx).Body(assertions).Options(writeOptions).Execute() if err != nil { return fmt.Errorf("failed to import test assertions: %w", err) @@ -271,6 +277,47 @@ func importAssertions( return nil } +// dropContextualTuplesOverSizeLimit removes the contextual tuples of assertions, starting from the last one, +// until the assertions fit in one WriteAssertions request. +func dropContextualTuplesOverSizeLimit(assertions []client.ClientAssertion) { + sizes := make([]int, len(assertions)) + total := 0 + + for i := range assertions { + sizes[i] = assertionSize(assertions[i]) + total += sizes[i] + } + + dropped := 0 + + for i := len(assertions) - 1; i >= 0 && total > maxAssertionsRequestBytes; i-- { + if len(assertions[i].ContextualTuples) == 0 { + continue + } + + assertions[i].ContextualTuples = nil + size := assertionSize(assertions[i]) + total -= sizes[i] - size + sizes[i] = size + dropped++ + } + + if dropped > 0 { + fmt.Fprintf(os.Stderr, + "Warning: the test assertions are over the API's request size limit, "+ + "so %d of them are written without their test's tuples\n", dropped) + } +} + +func assertionSize(assertion client.ClientAssertion) int { + encoded, err := json.Marshal(assertion) + if err != nil { + return 0 + } + + return len(encoded) +} + // getCheckAssertions turns check tests into assertions. Each assertion carries the check's context and the // test's tuples as contextual tuples, so it holds without writing those tuples to the store. func getCheckAssertions( diff --git a/cmd/store/import_test.go b/cmd/store/import_test.go index c2e7f5c0..c1638934 100644 --- a/cmd/store/import_test.go +++ b/cmd/store/import_test.go @@ -553,7 +553,7 @@ func TestImportStoreAssertionsCarryTestTuplesAndContext(t *testing.T) { require.NoError(t, err) } -func TestImportStoreAssertionsWithoutTooManyTestTuples(t *testing.T) { +func TestImportStoreAssertionsDropTestTuplesOverLimit(t *testing.T) { t.Parallel() modelID, storeID := testModelID, testStoreID @@ -603,3 +603,47 @@ func TestImportStoreAssertionsWithoutTooManyTestTuples(t *testing.T) { _, err := importStore(t.Context(), &fga.ClientConfig{}, mockFgaClient, &testStore, "", "", 10, 1, "") require.NoError(t, err) } + +func TestDropContextualTuplesOverSizeLimit(t *testing.T) { + t.Parallel() + + tuples := make([]client.ClientContextualTupleKey, maxContextualTuplesPerAssertion) + for i := range tuples { + tuples[i] = client.ClientContextualTupleKey{ + User: fmt.Sprintf("user:member-%02d", i), Relation: "viewer", Object: "document:roadmap-2026", + } + } + + t.Run("small requests are left alone", func(t *testing.T) { + t.Parallel() + + assertions := []client.ClientAssertion{ + {User: "user:anne", Relation: "viewer", Object: "document:roadmap-2026", ContextualTuples: tuples}, + } + dropContextualTuplesOverSizeLimit(assertions) + require.Len(t, assertions[0].ContextualTuples, maxContextualTuplesPerAssertion) + }) + + t.Run("the last assertions lose their tuples until the request fits", func(t *testing.T) { + t.Parallel() + + assertions := make([]client.ClientAssertion, maxAssertionsPerWrite) + for i := range assertions { + assertions[i] = client.ClientAssertion{ + User: fmt.Sprintf("user:%d", i), Relation: "viewer", Object: "document:roadmap-2026", + ContextualTuples: tuples, + } + } + + dropContextualTuplesOverSizeLimit(assertions) + + total := 0 + for _, assertion := range assertions { + total += assertionSize(assertion) + } + + require.LessOrEqual(t, total, maxAssertionsRequestBytes) + require.Len(t, assertions[0].ContextualTuples, maxContextualTuplesPerAssertion) + require.Empty(t, assertions[len(assertions)-1].ContextualTuples) + }) +} From e4a4568ec1efe50fa0a3119245a1d617af2e8e0a Mon Sep 17 00:00:00 2001 From: Srinivas Chippagiri Date: Mon, 9 Nov 2026 11:33:34 -0800 Subject: [PATCH 3/4] fix: measure assertions by their proto size and drop context as a last resort Signed-off-by: Srinivas Chippagiri --- README.md | 2 +- cmd/store/import.go | 62 +++++++++++++++++++++++++++++----------- cmd/store/import_test.go | 30 +++++++++++++++++++ 3 files changed, 76 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 691a3245..67737ed6 100644 --- a/README.md +++ b/README.md @@ -274,7 +274,7 @@ fga store **import** * `--max-parallel-requests`: Max requests to send in parallel (optional, default=4) * `--allow-external-files`: Allow `model_file`, `tuple_file` and `tuple_files` references in the store file to resolve outside the store file's directory (optional, default=false). Only enable this for store files you trust. -The `check` tests in the store file are also written to the store as assertions (at most 100). Each assertion includes the check's `context` and its test's `tuples` (including those loaded from `tuple_file`) as contextual tuples. A test with more than 20 tuples is written without contextual tuples, because assertions accept at most 20, and the CLI prints a warning. If the assertions together are too large for one request, the last ones are written without contextual tuples, also with a warning. +The `check` tests in the store file are also written to the store as assertions (at most 100). Each assertion includes the check's `context` and its test's `tuples` (including those loaded from `tuple_file`) as contextual tuples. A test with more than 20 tuples is written without contextual tuples, because assertions accept at most 20, and the CLI prints a warning. If the assertions together are too large for one request, the last ones are written without contextual tuples, and then without context if that's still not enough, also with a warning. ###### Example `fga store import --file model.fga.yaml` diff --git a/cmd/store/import.go b/cmd/store/import.go index 2ad492e0..ac7f6168 100644 --- a/cmd/store/import.go +++ b/cmd/store/import.go @@ -28,9 +28,12 @@ import ( "github.com/schollz/progressbar/v3" + openfgav1 "github.com/openfga/api/proto/openfga/v1" openfga "github.com/openfga/go-sdk" "github.com/openfga/go-sdk/client" "github.com/spf13/cobra" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" "github.com/openfga/cli/cmd/model" "github.com/openfga/cli/internal/authorizationmodel" @@ -49,9 +52,8 @@ const ( maxAssertionsPerWrite = 100 // The API accepts at most 20 contextual tuples per assertion. maxContextualTuplesPerAssertion = 20 - // The API rejects WriteAssertions requests over 64000 bytes. Assertions are measured by their JSON size, - // which is larger than their size in the request, and kept a bit under the limit. - maxAssertionsRequestBytes = 60000 + // The API rejects WriteAssertions requests whose assertions add up to more than 64000 bytes in proto encoding. + maxAssertionsRequestBytes = 64000 expressionConditionName = "$expression" ) @@ -278,7 +280,8 @@ func importAssertions( } // dropContextualTuplesOverSizeLimit removes the contextual tuples of assertions, starting from the last one, -// until the assertions fit in one WriteAssertions request. +// until the assertions fit in one WriteAssertions request. If they still don't fit, it then removes the +// context of the last assertions too, which main never wrote. func dropContextualTuplesOverSizeLimit(assertions []client.ClientAssertion) { sizes := make([]int, len(assertions)) total := 0 @@ -288,34 +291,59 @@ func dropContextualTuplesOverSizeLimit(assertions []client.ClientAssertion) { total += sizes[i] } - dropped := 0 + shrink := func(skip func(client.ClientAssertion) bool, drop func(*client.ClientAssertion)) int { + dropped := 0 - for i := len(assertions) - 1; i >= 0 && total > maxAssertionsRequestBytes; i-- { - if len(assertions[i].ContextualTuples) == 0 { - continue + for i := len(assertions) - 1; i >= 0 && total > maxAssertionsRequestBytes; i-- { + if skip(assertions[i]) { + continue + } + + drop(&assertions[i]) + size := assertionSize(assertions[i]) + total -= sizes[i] - size + sizes[i] = size + dropped++ } - assertions[i].ContextualTuples = nil - size := assertionSize(assertions[i]) - total -= sizes[i] - size - sizes[i] = size - dropped++ + return dropped } - if dropped > 0 { + droppedTuples := shrink( + func(a client.ClientAssertion) bool { return len(a.ContextualTuples) == 0 }, + func(a *client.ClientAssertion) { a.ContextualTuples = nil }, + ) + if droppedTuples > 0 { fmt.Fprintf(os.Stderr, "Warning: the test assertions are over the API's request size limit, "+ - "so %d of them are written without their test's tuples\n", dropped) + "so %d of them are written without their test's tuples\n", droppedTuples) + } + + droppedContexts := shrink( + func(a client.ClientAssertion) bool { return a.Context == nil }, + func(a *client.ClientAssertion) { a.Context = nil }, + ) + if droppedContexts > 0 { + fmt.Fprintf(os.Stderr, + "Warning: the test assertions are still over the API's request size limit, "+ + "so %d of them are written without their check's context\n", droppedContexts) } } +// assertionSize returns the size of the assertion as the server measures it: the SDK's JSON body decoded +// into the API's proto message. func assertionSize(assertion client.ClientAssertion) int { - encoded, err := json.Marshal(assertion) + encoded, err := json.Marshal(assertion.ToAssertion()) if err != nil { return 0 } - return len(encoded) + var protoAssertion openfgav1.Assertion + if err := protojson.Unmarshal(encoded, &protoAssertion); err != nil { + return len(encoded) + } + + return proto.Size(&protoAssertion) } // getCheckAssertions turns check tests into assertions. Each assertion carries the check's context and the diff --git a/cmd/store/import_test.go b/cmd/store/import_test.go index c1638934..ac5b344c 100644 --- a/cmd/store/import_test.go +++ b/cmd/store/import_test.go @@ -646,4 +646,34 @@ func TestDropContextualTuplesOverSizeLimit(t *testing.T) { require.Len(t, assertions[0].ContextualTuples, maxContextualTuplesPerAssertion) require.Empty(t, assertions[len(assertions)-1].ContextualTuples) }) + + t.Run("the last assertions lose their context when dropping tuples isn't enough", func(t *testing.T) { + t.Parallel() + + values := make([]any, 60) + for i := range values { + values[i] = i + } + + checkContext := map[string]any{"allowed": values} + + assertions := make([]client.ClientAssertion, maxAssertionsPerWrite) + for i := range assertions { + assertions[i] = client.ClientAssertion{ + User: fmt.Sprintf("user:%d", i), Relation: "viewer", Object: "document:roadmap-2026", + Context: &checkContext, + } + } + + dropContextualTuplesOverSizeLimit(assertions) + + total := 0 + for _, assertion := range assertions { + total += assertionSize(assertion) + } + + require.LessOrEqual(t, total, maxAssertionsRequestBytes) + require.NotNil(t, assertions[0].Context) + require.Nil(t, assertions[len(assertions)-1].Context) + }) } From bf81e346db77b2d889dbee9d927ef9a1fec4cfb0 Mon Sep 17 00:00:00 2001 From: Srinivas Chippagiri Date: Tue, 10 Nov 2026 14:24:44 -0800 Subject: [PATCH 4/4] test: pin assertionSize to proto.Size and the exact request limit Signed-off-by: Srinivas Chippagiri --- .golangci.yaml | 1 + cmd/store/import_test.go | 116 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/.golangci.yaml b/.golangci.yaml index 91416d5d..cd59268b 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -84,6 +84,7 @@ linters: - github.com/spf13/cobra - github.com/spf13/viper - google.golang.org/protobuf/proto + - google.golang.org/protobuf/types/known/structpb exclusions: generated: lax presets: diff --git a/cmd/store/import_test.go b/cmd/store/import_test.go index ac5b344c..868069bd 100644 --- a/cmd/store/import_test.go +++ b/cmd/store/import_test.go @@ -4,13 +4,17 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "time" + openfgav1 "github.com/openfga/api/proto/openfga/v1" openfga "github.com/openfga/go-sdk" "github.com/openfga/go-sdk/client" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" "github.com/openfga/cli/internal/fga" mockclient "github.com/openfga/cli/internal/mocks" @@ -677,3 +681,115 @@ func TestDropContextualTuplesOverSizeLimit(t *testing.T) { require.Nil(t, assertions[len(assertions)-1].Context) }) } + +func TestAssertionSizeMatchesProtoSize(t *testing.T) { + t.Parallel() + + conditionContext := map[string]any{"ip": "10.0.0.1"} + checkContext := map[string]any{"allowed": []any{1, 22, 333}, "now": "2026-11-10T00:00:00Z"} + + assertion := client.ClientAssertion{ + User: "user:anne", + Relation: "viewer", + Object: "document:roadmap", + Expectation: true, + Context: &checkContext, + ContextualTuples: []client.ClientContextualTupleKey{ + {User: "user:anne", Relation: "member", Object: "group:eng"}, + { + User: "group:eng#member", Relation: "viewer", Object: "document:roadmap", + Condition: &openfga.RelationshipCondition{Name: "in_network", Context: &conditionContext}, + }, + }, + } + + protoConditionContext, err := structpb.NewStruct(conditionContext) + require.NoError(t, err) + + protoCheckContext, err := structpb.NewStruct(checkContext) + require.NoError(t, err) + + want := &openfgav1.Assertion{ + TupleKey: &openfgav1.AssertionTupleKey{ + User: "user:anne", Relation: "viewer", Object: "document:roadmap", + }, + Expectation: true, + Context: protoCheckContext, + ContextualTuples: []*openfgav1.TupleKey{ + {User: "user:anne", Relation: "member", Object: "group:eng"}, + { + User: "group:eng#member", Relation: "viewer", Object: "document:roadmap", + Condition: &openfgav1.RelationshipCondition{Name: "in_network", Context: protoConditionContext}, + }, + }, + } + + require.Equal(t, proto.Size(want), assertionSize(assertion)) +} + +func TestDropContextualTuplesAtExactSizeLimit(t *testing.T) { + t.Parallel() + + tuples := make([]client.ClientContextualTupleKey, maxContextualTuplesPerAssertion) + for i := range tuples { + tuples[i] = client.ClientContextualTupleKey{ + User: fmt.Sprintf("user:member-%02d", i), Relation: "viewer", Object: "document:roadmap-2026", + } + } + + // build returns 50 assertions with tuples, the last one padded with a + // context string of the given length. + build := func(padding int) []client.ClientAssertion { + assertions := make([]client.ClientAssertion, 50) + for i := range assertions { + assertions[i] = client.ClientAssertion{ + User: fmt.Sprintf("user:%d", i), Relation: "viewer", Object: "document:roadmap-2026", + ContextualTuples: tuples, + } + } + + checkContext := map[string]any{"pad": strings.Repeat("x", padding)} + assertions[len(assertions)-1].Context = &checkContext + + return assertions + } + + total := func(assertions []client.ClientAssertion) int { + sum := 0 + for _, assertion := range assertions { + sum += assertionSize(assertion) + } + + return sum + } + + // Between 128 and 16383 bytes every length prefix takes two bytes, so one + // more byte of padding adds exactly one byte to the request. + const basePadding = 1000 + + padding := basePadding + maxAssertionsRequestBytes - total(build(basePadding)) + require.Greater(t, padding, 200) + require.Less(t, padding, 16000) + + atLimit := build(padding) + require.Equal(t, maxAssertionsRequestBytes, total(atLimit)) + + dropContextualTuplesOverSizeLimit(atLimit) + + for _, assertion := range atLimit { + require.Len(t, assertion.ContextualTuples, maxContextualTuplesPerAssertion) + } + + overLimit := build(padding + 1) + require.Equal(t, maxAssertionsRequestBytes+1, total(overLimit)) + + dropContextualTuplesOverSizeLimit(overLimit) + + last := len(overLimit) - 1 + for _, assertion := range overLimit[:last] { + require.Len(t, assertion.ContextualTuples, maxContextualTuplesPerAssertion) + } + + require.Empty(t, overLimit[last].ContextualTuples) + require.NotNil(t, overLimit[last].Context) +}