Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,13 @@ model: |
define can_write: owner or can_write from parent
define can_share: owner

# tuple_file: ./tuples.yaml # global tuples that would apply to all tests, optional
# You can use `tuples`, `tuple_file`, and `tuple_files` together or individually to provide global tuples for all tests.
# Example using a single tuple file:
# tuple_file: ./tuples.yaml
# Example using multiple tuple files:
# tuple_files:
# - ./model_tuples_2.yaml
# - ./model_tuples_3.yaml
tuples: # global tuples that would apply to all tests, optional
- user: folder:1
relation: parent
Expand Down
3 changes: 3 additions & 0 deletions example/model.fga.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ model_file: ./model.fga # a global model that would apply to all tests
# type user
# ...
tuple_file: ./model_tuples.yaml # global tuples that would apply to all tests
tuple_files: # global tuples to add multiple tuple files that would apply to all tests
- ./model_tuples_2.yaml
- ./model_tuples_3.yaml
# tuples can also be used instead of tuple_file
#tuples:
# - user: folder:5
Expand Down
6 changes: 6 additions & 0 deletions example/model_tuples_2.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
- user: folder:6
relation: parent
object: folder:product-2022
- user: folder:product-2022
relation: parent
object: folder:product-2022Q1
6 changes: 6 additions & 0 deletions example/model_tuples_3.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
- user: folder:7
relation: parent
object: folder:marketing
- user: folder:marketing
relation: parent
object: folder:marketing-Q1
153 changes: 121 additions & 32 deletions internal/storetest/storedata.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ import (
"fmt"
"path"

"github.com/openfga/cli/internal/clierrors"
"github.com/openfga/cli/internal/tuplefile"

openfga "github.com/openfga/go-sdk"
"github.com/openfga/go-sdk/client"

"github.com/openfga/cli/internal/authorizationmodel"
"github.com/openfga/cli/internal/clierrors"
"github.com/openfga/cli/internal/tuplefile"
)

// Static error variables for validation.
Expand All @@ -36,6 +37,9 @@ var (
ErrUserRequired = errors.New("must specify 'user' or 'users'")
ErrObjectAndObjectsConflict = errors.New("cannot contain both 'object' and 'objects'")
ErrObjectRequired = errors.New("must specify 'object' or 'objects'")

errMissingTuple = errors.New("either tuple_file or tuple_files or tuples must be provided")
errFailedProcessingTupleFiles = errors.New("failed to process one or more tuple files")
)

type ModelTestCheck struct {
Expand Down Expand Up @@ -76,12 +80,13 @@ type ModelTest struct {
}

type StoreData struct {
Name string `json:"name" yaml:"name"`
Model string `json:"model" yaml:"model"`
ModelFile string `json:"model_file" yaml:"model_file,omitempty"` //nolint:tagliatelle
Tuples []client.ClientContextualTupleKey `json:"tuples" yaml:"tuples"`
TupleFile string `json:"tuple_file" yaml:"tuple_file,omitempty"` //nolint:tagliatelle
Tests []ModelTest `json:"tests" yaml:"tests"`
Name string `json:"name" yaml:"name"`
Model string `json:"model" yaml:"model"`
ModelFile string `json:"model_file" yaml:"model_file,omitempty"` //nolint:tagliatelle
Tuples []client.ClientContextualTupleKey `json:"tuples" yaml:"tuples"`
TupleFile string `json:"tuple_file" yaml:"tuple_file,omitempty"` //nolint:tagliatelle
TupleFiles []string `json:"tuple_files" yaml:"tuple_files,omitempty"` //nolint:tagliatelle
Tests []ModelTest `json:"tests" yaml:"tests"`
}

func (storeData *StoreData) LoadModel(basePath string) (authorizationmodel.ModelFormat, error) {
Expand Down Expand Up @@ -113,37 +118,49 @@ func (storeData *StoreData) LoadModel(basePath string) (authorizationmodel.Model
}

func (storeData *StoreData) LoadTuples(basePath string) error {
var errs error
var (
errs error
allTuples []client.ClientContextualTupleKey
)

if storeData.TupleFile != "" {
tuples, err := tuplefile.ReadTupleFile(path.Join(basePath, storeData.TupleFile))
if err != nil { //nolint:gocritic
errs = fmt.Errorf("failed to process global tuple %s file due to %w", storeData.TupleFile, err)
} else if storeData.Tuples == nil {
storeData.Tuples = tuples
} else {
storeData.Tuples = append(storeData.Tuples, tuples...)
}
addTuples := func(tuples []client.ClientContextualTupleKey) {
allTuples = append(allTuples, tuples...)
}

for index, test := range storeData.Tests {
if test.TupleFile == "" {
continue
}
if storeData.Tuples != nil {
addTuples(storeData.Tuples)
}

tuples, err := tuplefile.ReadTupleFile(path.Join(basePath, test.TupleFile))
if err != nil {
errs = errors.Join(
errs,
fmt.Errorf("failed to process tuple file %s for test %s due to %w", test.TupleFile, test.Name, err),
)
} else {
storeData.Tests[index].Tuples = tuples
}
if storeData.TupleFile == "" && len(storeData.TupleFiles) == 0 && len(allTuples) == 0 {
errs = errors.Join(
errs,
errMissingTuple,
)
}

errs = errors.Join(
errs,
storeData.loadAndAddTuplesFromFile(basePath, storeData.TupleFile, addTuples),
)

errs = errors.Join(
errs,
storeData.loadAndAddTuplesFromFiles(basePath, storeData.TupleFiles, addTuples),
)

if len(allTuples) > 0 {
storeData.Tuples = allTuples
}

errs = errors.Join(
errs,
storeData.loadTestTuples(basePath),
)
if errs != nil {
return errors.Join(errors.New("failed to process one or more tuple files"), errs) //nolint:err113
return errors.Join(
errFailedProcessingTupleFiles,
errs,
)
}

return nil
Expand Down Expand Up @@ -179,3 +196,75 @@ func (storeData *StoreData) Validate() error {

return nil
}

func (storeData *StoreData) loadAndAddTuplesFromFile(
basePath string,
file string,
add func([]client.ClientContextualTupleKey),
) error {
if file == "" {
return nil
}

tuples, err := tuplefile.ReadTupleFile(path.Join(basePath, file))
if err != nil {
return fmt.Errorf("failed to process global tuple %s file due to %w", file, err)
}

add(tuples)

return nil
}

func (storeData *StoreData) loadAndAddTuplesFromFiles(
basePath string,
files []string,
add func([]client.ClientContextualTupleKey),
) error {
var errs error

for _, file := range files {
tuples, err := tuplefile.ReadTupleFile(path.Join(basePath, file))
if err != nil {
errs = errors.Join(
errs,
fmt.Errorf("failed to process tuple file %s due to %w", file, err),
)

continue
}

add(tuples)
}

return errs
}

func (storeData *StoreData) loadTestTuples(basePath string) error {
var errs error

for testIndex, testCase := range storeData.Tests {
if testCase.TupleFile == "" {
continue
}

tuples, err := tuplefile.ReadTupleFile(path.Join(basePath, testCase.TupleFile))
if err != nil {
errs = errors.Join(
errs,
fmt.Errorf(
"failed to process tuple file %s for test %s due to %w",
testCase.TupleFile,
testCase.Name,
err,
),
)

continue
}

storeData.Tests[testIndex].Tuples = append(storeData.Tests[testIndex].Tuples, tuples...)
}

return errs
}
100 changes: 100 additions & 0 deletions internal/storetest/storedata_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,112 @@
package storetest

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func writeTempFile(t *testing.T, dir, name, content string) string {
t.Helper()

file := filepath.Join(dir, name)

err := os.WriteFile(file, []byte(content), 0o600)
if err != nil {
t.Fatalf("failed to write temp file: %v", err)
}

return file
}

func TestLoadTuples(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()

tupleContent := `[
{"user": "user:jon", "relation": "viewer", "object": "document:doc1"},
{"user": "user:sam", "relation": "editor", "object": "document:doc2"}
]`

tupleFile := writeTempFile(t, tempDir, "tuples1.json", tupleContent)

tupleFile2 := writeTempFile(t, tempDir, "tuples2.json", `[
{"user": "user:jon", "relation": "viewer", "object": "document:doc1"},
{"user": "user:amy", "relation": "editor", "object": "document:doc3"}
]`)

cases := []struct {
name string
storeData StoreData
expectErr bool
expectTuples int
}{
{
name: "no tuple file or tuple files",
storeData: StoreData{},
expectErr: true,
},
{
name: "single tuple_file",
storeData: StoreData{
TupleFile: filepath.Base(tupleFile),
},
expectTuples: 2,
},
{
name: "multiple tuple_files no dedup",
storeData: StoreData{
TupleFiles: []string{filepath.Base(tupleFile), filepath.Base(tupleFile2)},
},
expectTuples: 4,
},
{
name: "combined tuple_file and tuple_files",
storeData: StoreData{
TupleFile: filepath.Base(tupleFile),
TupleFiles: []string{filepath.Base(tupleFile2)},
},
expectTuples: 4,
},
{
name: "test-level tuple file with error",
storeData: StoreData{
TupleFile: filepath.Base(tupleFile),
Tests: []ModelTest{{
Name: "test1",
TupleFile: "invalid.json",
}},
},
expectErr: true,
},
}

for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()

err := testCase.storeData.LoadTuples(tempDir)

if testCase.expectErr {
if err == nil {
t.Errorf("expected error but got nil")
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}

if got := len(testCase.storeData.Tuples); got != testCase.expectTuples {
t.Errorf("expected %d tuples, got %d", testCase.expectTuples, got)
}
}
})
}
}

func TestStoreDataValidate(t *testing.T) {
t.Parallel()

Expand Down
Loading