diff --git a/cmd/store/.test-data/failed-store-import-001.fga.yaml b/cmd/store/.test-data/failed-store-import-001.fga.yaml new file mode 100644 index 0000000..c69cda5 --- /dev/null +++ b/cmd/store/.test-data/failed-store-import-001.fga.yaml @@ -0,0 +1,23 @@ +name: FGA Demo Store +model: |+ + model + schema 1.1 + + type user + + type doc + relations + define owner: [user] + define reader: [user] + define writer: [user] + +tuples: + - user: user:Kross + relation: writer + object: doc:dev-docs + - user: user:Modric + relation: owner + object: document:wrong-type +tests: + - name: Tests + check: [] \ No newline at end of file diff --git a/cmd/store/create.go b/cmd/store/create.go index 4e0b0ca..6cc9307 100644 --- a/cmd/store/create.go +++ b/cmd/store/create.go @@ -32,7 +32,7 @@ import ( ) type CreateStoreAndModelResponse struct { - Store client.ClientCreateStoreResponse `json:"store"` + Store *client.ClientCreateStoreResponse `json:"store,omitempty"` Model *client.ClientWriteAuthorizationModelResponse `json:"model,omitempty"` } @@ -69,7 +69,7 @@ func CreateStoreWithModel( return nil, err } - response.Store = *createStoreResponse + response.Store = createStoreResponse fgaClient.SetStoreId(response.Store.Id) if inputModel != "" { diff --git a/cmd/store/import.go b/cmd/store/import.go index c415bd9..be657a3 100644 --- a/cmd/store/import.go +++ b/cmd/store/import.go @@ -33,20 +33,55 @@ import ( "github.com/openfga/cli/internal/storetest" ) +// importStoreIODependencies defines IO dependencies for importing store +type importStoreIODependencies struct { + createStoreWithModel func( + clientConfig fga.ClientConfig, + storeName string, + inputModel string, + inputFormat authorizationmodel.ModelFormat, + ) (*CreateStoreAndModelResponse, error) + importTuples func( + fgaClient client.SdkClient, + body client.ClientWriteRequest, + maxTuplesPerWrite int, + maxParallelRequests int, + ) (*tuple.ImportResponse, error) + modelWrite func( + fgaClient client.SdkClient, + inputModel authorizationmodel.AuthzModel, + ) (*client.ClientWriteAuthorizationModelResponse, error) +} + +type ImportStoreResponse struct { + *CreateStoreAndModelResponse + Tuple *tuple.ImportResponse `json:"tuple"` +} + func importStore( clientConfig fga.ClientConfig, - fgaClient client.SdkClient, storeData *storetest.StoreData, format authorizationmodel.ModelFormat, storeID string, maxTuplesPerWrite int, maxParallelRequests int, -) (*CreateStoreAndModelResponse, error) { + ioAggregator importStoreIODependencies, +) (*ImportStoreResponse, error) { var err error - var response *CreateStoreAndModelResponse //nolint:wsl - if storeID == "" { //nolint:wsl - createStoreAndModelResponse, err := CreateStoreWithModel(clientConfig, storeData.Name, storeData.Model, format) - response = createStoreAndModelResponse + + var fgaClient client.SdkClient + + response := &ImportStoreResponse{ + CreateStoreAndModelResponse: &CreateStoreAndModelResponse{}, + } + if storeID == "" { //nolint:wsl + createStoreAndModelResponse, err := ioAggregator.createStoreWithModel( + clientConfig, + storeData.Name, + storeData.Model, + format, + ) + response.CreateStoreAndModelResponse = createStoreAndModelResponse if err != nil { //nolint:wsl return nil, err } @@ -60,10 +95,17 @@ func importStore( return nil, err //nolint:wrapcheck } - _, err := model.Write(fgaClient, authModel) + fgaClient, err = clientConfig.GetFgaClient() + if err != nil { + return nil, fmt.Errorf("failed to initialize FGA Client due to %w", err) + } + + authorizationModelResponse, err := ioAggregator.modelWrite(fgaClient, authModel) if err != nil { return nil, fmt.Errorf("failed to write model due to %w", err) } + + response.Model = authorizationModelResponse } fgaClient, err = clientConfig.GetFgaClient() @@ -75,11 +117,13 @@ func importStore( Writes: storeData.Tuples, } - _, err = tuple.ImportTuples(fgaClient, writeRequest, maxTuplesPerWrite, maxParallelRequests) + importTupleResponse, err := ioAggregator.importTuples(fgaClient, writeRequest, maxTuplesPerWrite, maxParallelRequests) if err != nil { - return nil, err //nolint:wrapcheck + return nil, err } + response.Tuple = importTupleResponse + return response, nil } @@ -90,7 +134,7 @@ var importCmd = &cobra.Command{ Long: `Import a store: updating the name, model and appending the global tuples`, Example: "fga store import --file=model.fga.yaml", RunE: func(cmd *cobra.Command, _ []string) error { - var createStoreAndModelResponse *CreateStoreAndModelResponse + var createStoreAndModelResponse *ImportStoreResponse clientConfig := cmdutils.GetClientConfig(cmd) storeID, err := cmd.Flags().GetString("store-id") @@ -118,13 +162,13 @@ var importCmd = &cobra.Command{ return err //nolint:wrapcheck } - fgaClient, err := clientConfig.GetFgaClient() - if err != nil { - return fmt.Errorf("failed to initialize FGA Client due to %w", err) + ioAggregator := importStoreIODependencies{ + createStoreWithModel: CreateStoreWithModel, + importTuples: tuple.ImportTuples, + modelWrite: model.Write, } - - createStoreAndModelResponse, err = importStore(clientConfig, fgaClient, storeData, format, - storeID, maxTuplesPerWrite, maxParallelRequests) + createStoreAndModelResponse, err = importStore(clientConfig, storeData, format, + storeID, maxTuplesPerWrite, maxParallelRequests, ioAggregator) if err != nil { return err } diff --git a/cmd/store/import_test.go b/cmd/store/import_test.go new file mode 100644 index 0000000..69b95b7 --- /dev/null +++ b/cmd/store/import_test.go @@ -0,0 +1,206 @@ +package store + +import ( + "fmt" + "github.com/openfga/cli/cmd/tuple" + "github.com/openfga/cli/internal/authorizationmodel" + "github.com/openfga/cli/internal/fga" + "github.com/openfga/cli/internal/storetest" + "github.com/openfga/go-sdk/client" + "path" + "reflect" + "testing" + "time" +) + +func TestImportStore(t *testing.T) { + t.Run("Must create store and modelID when "+ + "there's no store configured", func(t *testing.T) { + t.Parallel() + + clientConfig := fga.ClientConfig{ApiUrl: "https://localhost:8080"} + storeData := &storetest.StoreData{} + authorizationModelID := "01HWJGBQQNNQATBQ661SH6585Y001" + storeID := "Test-001" + + ioAggregator := importStoreIODependencies{ + importTuples: func(_ client.SdkClient, _ client.ClientWriteRequest, _, _ int, + ) (*tuple.ImportResponse, error) { + return &tuple.ImportResponse{}, nil + }, + createStoreWithModel: func(_ fga.ClientConfig, _, _ string, _ authorizationmodel.ModelFormat, + ) (*CreateStoreAndModelResponse, error) { + return &CreateStoreAndModelResponse{ + Store: &client.ClientCreateStoreResponse{ + Id: "01HWJGBQQHZZJHQEQZ6MCBC3B0", + Name: storeID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + Model: &client.ClientWriteAuthorizationModelResponse{ + AuthorizationModelId: authorizationModelID, + }, + }, nil + }, + } + + response, err := importStore(clientConfig, + storeData, + "", + "", + 2, + 2, + ioAggregator, + ) + // Assert + if err != nil { + t.Error(err) + } + + if response.Model.AuthorizationModelId != authorizationModelID { + t.Fatalf("expected: %s\nreturned: %s", authorizationModelID, response.Model.AuthorizationModelId) + } + + if response.Store.Name != storeID { + t.Fatalf("expected: %s\nreturned: %s", storeID, response.Store.Name) + } + }) + + t.Run("Must return the Model ID and an empty Store "+ + "when importing into an existing store", func(t *testing.T) { + t.Parallel() + + clientConfig := fga.ClientConfig{ApiUrl: "https://localhost:8080"} + storeData := &storetest.StoreData{} + authorizationModelID := "01HWJGBQQNNQATBQ661SH6585Y002" + + ioAggregator := importStoreIODependencies{ + importTuples: func(_ client.SdkClient, _ client.ClientWriteRequest, _, _ int, + ) (*tuple.ImportResponse, error) { + return &tuple.ImportResponse{}, nil + }, + modelWrite: func(_ client.SdkClient, _ authorizationmodel.AuthzModel) (*client.ClientWriteAuthorizationModelResponse, error) { + return &client.ClientWriteAuthorizationModelResponse{ + AuthorizationModelId: authorizationModelID, + }, nil + }, + createStoreWithModel: func(_ fga.ClientConfig, _, _ string, _ authorizationmodel.ModelFormat, + ) (*CreateStoreAndModelResponse, error) { + return &CreateStoreAndModelResponse{ + Model: &client.ClientWriteAuthorizationModelResponse{ + AuthorizationModelId: authorizationModelID, + }, + }, nil + }, + } + + response, err := importStore(clientConfig, + storeData, "", + "01HWJGBQQHZZJHQEQZ6MCBC3B0", + 2, + 2, + ioAggregator, + ) + // Assert + if err != nil { + t.Error(err) + } + + if response.Store != nil { + t.Fatalf("Expected: null\nReturn: %v", response.Store) + } + + if response.Model.AuthorizationModelId != authorizationModelID { + t.Fatalf("expected: %s\nreturned: %s", authorizationModelID, response.Model.AuthorizationModelId) + } + }) + + t.Run("Must returns the modelID, a null store object, and a list of "+ + "failed/successfully imported tuples when tuples containing unregistered "+ + "types are provided as input", func(t *testing.T) { + t.Parallel() + + clientConfig := fga.ClientConfig{ApiUrl: "https://localhost:8080"} + fileName := "./.test-data/failed-store-import-001.fga.yaml" + format, storeData, _ := storetest.ReadFromFile(fileName, path.Dir(fileName)) + authorizationModelID := "01HWJGBQQNNQATBQ661SH6585Y003" + + successfulTupleImport := storeData.Tuples[0] + failedTupleImport := storeData.Tuples[1] + failureReason := fmt.Sprintf("error message: Invalid tuple '%s#%s@%s'. Reason: type 'document' not found", + failedTupleImport.Object, + failedTupleImport.Relation, + failedTupleImport.User, + ) + ioAggregator := importStoreIODependencies{ + importTuples: func(_ client.SdkClient, + _ client.ClientWriteRequest, _, + _ int, + ) (*tuple.ImportResponse, error) { + return &tuple.ImportResponse{ + Successful: []client.ClientTupleKey{ + successfulTupleImport, + }, + Failed: []tuple.FailedWriteResponse{ + { + TupleKey: failedTupleImport, + Reason: failureReason, + }, + }, + }, nil + }, + modelWrite: func( + _ client.SdkClient, + _ authorizationmodel.AuthzModel, + ) (*client.ClientWriteAuthorizationModelResponse, error) { + return &client.ClientWriteAuthorizationModelResponse{ + AuthorizationModelId: authorizationModelID, + }, nil + }, + createStoreWithModel: func(_ fga.ClientConfig, + _, + _ string, + _ authorizationmodel.ModelFormat, + ) (*CreateStoreAndModelResponse, error) { + return &CreateStoreAndModelResponse{ + Model: &client.ClientWriteAuthorizationModelResponse{ + AuthorizationModelId: authorizationModelID, + }, + }, nil + }, + } + + // Act + importResponse, err := importStore(clientConfig, + storeData, + format, + "01HWJGBQQHZZJHQEQZ6MCBC3B0", + 2, + 2, + ioAggregator, + ) + if err != nil { + t.Error(err) + } + + if len(importResponse.Tuple.Successful) != 1 { + t.Fatalf("expected: %d\nreturned: %d", 1, len(importResponse.Tuple.Successful)) + } + + if len(importResponse.Tuple.Failed) != 1 { + t.Fatalf("expected: %d\nreturned: %d", 1, len(importResponse.Tuple.Failed)) + } + + if !reflect.DeepEqual(importResponse.Tuple.Successful[0], successfulTupleImport) { + t.Fatalf("expected: %v\nreturned: %v", successfulTupleImport, importResponse.Tuple.Successful[0]) + } + + if !reflect.DeepEqual(importResponse.Tuple.Failed[0].TupleKey, failedTupleImport) { + t.Fatalf("expected: %v\nreturned: %v", failedTupleImport, importResponse.Tuple.Failed[0]) + } + + if failureReason != importResponse.Tuple.Failed[0].Reason { + t.Fatalf("expected: %s\nreturned: %s", failureReason, importResponse.Tuple.Failed[0].Reason) + } + }) +} diff --git a/cmd/tuple/import.go b/cmd/tuple/import.go index 890ad24..ec635e6 100644 --- a/cmd/tuple/import.go +++ b/cmd/tuple/import.go @@ -37,14 +37,14 @@ var MaxTuplesPerWrite = 1 // MaxParallelRequests Limit the parallel writes to the API. var MaxParallelRequests = 10 -type failedWriteResponse struct { +type FailedWriteResponse struct { TupleKey client.ClientTupleKey `json:"tuple_key"` Reason string `json:"reason"` } type ImportResponse struct { - Successful []client.ClientTupleKey `json:"successful"` - Failed []failedWriteResponse `json:"failed"` + Successful []client.ClientTupleKey `json:"successful,omitempty"` + Failed []FailedWriteResponse `json:"failed,omitempty"` } // ImportTuples receives a client.ClientWriteRequest and imports the tuples to the store. It can be used to import @@ -98,10 +98,10 @@ func extractErrMssg(err error) string { func processWrites( writes []client.ClientWriteRequestWriteResponse, -) ([]client.ClientTupleKey, []failedWriteResponse) { +) ([]client.ClientTupleKey, []FailedWriteResponse) { var ( successfulWrites []client.ClientTupleKey - failedWrites []failedWriteResponse + failedWrites []FailedWriteResponse ) for _, write := range writes { @@ -109,7 +109,7 @@ func processWrites( successfulWrites = append(successfulWrites, write.TupleKey) } else { reason := extractErrMssg(write.Error) - failedWrites = append(failedWrites, failedWriteResponse{ + failedWrites = append(failedWrites, FailedWriteResponse{ TupleKey: write.TupleKey, Reason: reason, }) @@ -121,10 +121,10 @@ func processWrites( func processDeletes( deletes []client.ClientWriteRequestDeleteResponse, -) ([]client.ClientTupleKey, []failedWriteResponse) { +) ([]client.ClientTupleKey, []FailedWriteResponse) { var ( successfulDeletes []client.ClientTupleKey - failedDeletes []failedWriteResponse + failedDeletes []FailedWriteResponse ) for _, delete := range deletes { @@ -138,7 +138,7 @@ func processDeletes( successfulDeletes = append(successfulDeletes, deletedTupleKey) } else { reason := extractErrMssg(delete.Error) - failedDeletes = append(failedDeletes, failedWriteResponse{ + failedDeletes = append(failedDeletes, FailedWriteResponse{ TupleKey: deletedTupleKey, Reason: reason, })