Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(model): add test command #131

Merged
merged 4 commits into from
Aug 22, 2023
Merged
Show file tree
Hide file tree
Changes from 3 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
89 changes: 82 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ A cross-platform CLI to interact with an OpenFGA server
- [Read a Single Authorization Model](#read-a-single-authorization-model)
- [Read the Latest Authorization Model](#read-the-latest-authorization-model)
- [Validate an Authorization Model](#validate-an-authorization-model)
- [Run tests on an Authorization Model](#run-tests-on-an-authorization-model)
- [Transform an Authorization Model](#transform-an-authorization-model)
- [Relationship Tuples](#relationship-tuples)
- [Read Relationship Tuple Changes (Watch)](#read-relationship-tuple-changes-watch)
Expand Down Expand Up @@ -276,13 +277,14 @@ fga store **delete**

* `model`

| Description | command | parameters | example |
|-------------------------------------------------------------------------|---------|----------------------------|---------------------------------------------------------------------------------------------|
| [Read Authorization Models](#read-authorization-models) | `list` | `--store-id` | `fga model list --store-id=01H0H015178Y2V4CX10C2KGHF4` |
| [Write Authorization Model ](#write-authorization-model) | `write` | `--store-id`, `--file` | `fga model write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file model.fga` |
| [Read a Single Authorization Model](#read-a-single-authorization-model) | `get` | `--store-id`, `--model-id` | `fga model get --store-id=01H0H015178Y2V4CX10C2KGHF4 --model-id=01GXSA8YR785C4FYS3C0RTG7B1` |
| [Validate an Authorization Model](#validate-an-authorization-model) | `validate` | `--file`, `--format` | `fga model validate --file model.fga` |
| [Transform an Authorization Model](#transform-an-authorization-model) | `transform` | `--file`, `--input-format` | `fga model transform --file model.json` |
| Description | command | parameters | example |
|-----------------------------------------------------------------------------|-------------|---------------------------------------|---------------------------------------------------------------------------------------------|
| [Read Authorization Models](#read-authorization-models) | `list` | `--store-id` | `fga model list --store-id=01H0H015178Y2V4CX10C2KGHF4` |
| [Write Authorization Model ](#write-authorization-model) | `write` | `--store-id`, `--file` | `fga model write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file model.fga` |
| [Read a Single Authorization Model](#read-a-single-authorization-model) | `get` | `--store-id`, `--model-id` | `fga model get --store-id=01H0H015178Y2V4CX10C2KGHF4 --model-id=01GXSA8YR785C4FYS3C0RTG7B1` |
| [Validate an Authorization Model](#validate-an-authorization-model) | `validate` | `--file`, `--format` | `fga model validate --file model.fga` |
| [Run tests on an Authorization Model](#run-tests-on-an-authorization-model) | `test` | `--tests`, `--file`, `--input-format` | `fga model test --file model.fga --tests tests.fga.yaml` |
| [Transform an Authorization Model](#transform-an-authorization-model) | `transform` | `--file`, `--input-format` | `fga model transform --file model.json` |


##### Read Authorization Models
Expand Down Expand Up @@ -418,6 +420,79 @@ fga model **validate**
{"is_valid":false,"error":"the relation type 'employee' on 'member' in object type 'group' is not valid"}
```

##### Run tests on an Authorization Model

Given a model, and a set of tests (tuples, check and list objects requests, and expected results) report back on any tests that do not return the same results as expected.

###### Command
fga model **test**

###### Parameters

* `--tests`: Name of the tests file. Must be in yaml format (see below),
* `--file`: File containing the authorization model. (optional) [WARNING: If given a model file, this will write it to the store and it will become the latest model.]
* `--input-format`: Authorization model input format. Can be "fga" or "json". Defaults to the file extension if provided (optional)

<details>
<summary>The format of the test file</summary>

```yaml
---
- name: some-test
description: testing that the model works
tuples:
- user: user:anne
relation: owner
object: folder:product
check:
- user: user:anne
object: folder:product-2021
assertions:
# a set of expected results for each relation
can_view: true
can_write: false
can_share: false
list-objects:
- user: user:anne
type: folder
assertions:
# a set of expected results for each relation
can_view:
- folder:product
- folder:product-2021
can_write:
- folder:product
- folder:product-2021
can_share:
- folder:product
- folder:product-2021
- user: user:beth
type: folder
assertions:
# a set of expected results for each relation
can_view:
- folder:product-2021
can_write: []
can_share: []
```
</details>

###### Example
`fga model test --file model.fga --tests tests.fga.yaml`

###### Response
* Passing test
```shell
(PASSING) test-name: Checks (2/2 passing) | ListObjects (0/0 passing)
```
* Failing Test
```shell
(FAILING) test-name: Checks (1/2 passing) | ListObjects (1/1 passing)
✓ Check(user=user:anne,relation=can_write,object=folder:product-2021)
ⅹ Check(user=user:anne,relation=can_share,object=folder:product-2021): expected=true, got=false, error=<nil>
✓ ListObjects(user=user:anne,relation=can_write,type=folder)
```

##### Transform an Authorization Model

###### Command
Expand Down
1 change: 1 addition & 0 deletions cmd/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,6 @@ func init() {
ModelCmd.AddCommand(getCmd)
ModelCmd.AddCommand(validateCmd)
ModelCmd.AddCommand(transformCmd)
ModelCmd.AddCommand(testCmd)
ModelCmd.PersistentFlags().String("store-id", "", "Store ID")
}
139 changes: 139 additions & 0 deletions cmd/model/test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
Copyright © 2023 OpenFGA

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package model

import (
"fmt"
"os"
"strings"

"github.com/openfga/cli/internal/authorizationmodel"
"github.com/openfga/cli/internal/cmdutils"
"github.com/openfga/cli/internal/output"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)

// testCmd represents the test command.
var testCmd = &cobra.Command{
Use: "test",
Short: "Test an Authorization Model",
Long: "Run a set of tests against a particular Authorization Model.",
Example: `fga model test --file model.fga --tests tests.fga.yaml`,
RunE: func(cmd *cobra.Command, args []string) error {
clientConfig := cmdutils.GetClientConfig(cmd)

fgaClient, err := clientConfig.GetFgaClient()
if err != nil {
return fmt.Errorf("failed to initialize FGA Client due to %w", err)
}

modelFileName, err := cmd.Flags().GetString("file")
if err != nil {
return fmt.Errorf("failed to parse file name due to %w", err)
}

var modelID *string
if modelFileName != "" {
inputModel, err := authorizationmodel.ReadFromInputFile(
modelFileName,
&testInputFormat)
if err != nil {
return err //nolint:wrapcheck
}

authModel := authorizationmodel.AuthzModel{}

if writeInputFormat == authorizationmodel.ModelFormatJSON {
err = authModel.ReadFromJSONString(*inputModel)
} else {
err = authModel.ReadFromDSLString(*inputModel)
}

if err != nil {
return err //nolint:wrapcheck
}

writtenModel, err := Write(fgaClient, authModel)
if err != nil {
return err
}

modelID = writtenModel.AuthorizationModelId
}

testsFileName, err := cmd.Flags().GetString("tests")
if err != nil {
return err //nolint:wrapcheck
}

testFileContents, err := os.ReadFile(testsFileName)
if err != nil {
return fmt.Errorf("failed to read file %s due to %w", testsFileName, err)
}

var tests []authorizationmodel.ModelTest
if err := yaml.Unmarshal(testFileContents, &tests); err != nil {
return err //nolint:wrapcheck
}

results := authorizationmodel.RunTests(fgaClient, tests, modelID)

verbose, err := cmd.Flags().GetBool("verbose")
if err != nil {
return err //nolint:wrapcheck
}

if verbose {
return output.Display(results) //nolint:wrapcheck
}

friendlyResults := []string{}

for index := 0; index < len(results); index++ {
friendlyResults = append(friendlyResults, results[index].FriendlyDisplay())
}

fmt.Printf("%v", strings.Join(friendlyResults, "\n---\n"))

return nil
},
}

var testInputFormat = authorizationmodel.ModelFormatDefault

func init() {
testCmd.Flags().String("store-id", "", "Store ID")
testCmd.Flags().String("model-id", "", "Model ID")
testCmd.Flags().String("file", "", "File Name. The file should have the model in the JSON or DSL format")
testCmd.Flags().Var(&testInputFormat, "input-format", `Authorization model input format. Can be "fga" or "json"`)
testCmd.Flags().String("tests", "", "Tests file Name. The file should have the OpenFGA tests in a valid YAML or JSON format") //nolint:lll

testCmd.MarkFlagsMutuallyExclusive("model-id", "file")
testCmd.MarkFlagsMutuallyExclusive("model-id", "input-format")
testCmd.Flags().Bool("verbose", false, "Print verbose JSON output")

if err := testCmd.MarkFlagRequired("store-id"); err != nil {
fmt.Printf("error setting flag as required - %v: %v\n", "cmd/models/test", err)
os.Exit(1)
}

if err := testCmd.MarkFlagRequired("tests"); err != nil {
fmt.Printf("error setting flag as required - %v: %v\n", "cmd/models/test", err)
os.Exit(1)
}
}
12 changes: 12 additions & 0 deletions example/model.fga
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
model
schema 1.1
type user
type folder
relations
define parent: [folder]
define owner: [user]
define parent_owner: owner from parent or parent_owner from parent
define viewer: [user] or owner
define can_share: owner
define can_write: owner or parent_owner
define can_view: viewer
46 changes: 46 additions & 0 deletions example/tests.fga.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
- name: "folder-document-access"
description: ""
tuples:
- user: user:anne
relation: owner
object: folder:product
- user: folder:product
relation: parent
object: folder:product-2021
- user: user:beth
relation: viewer
object: folder:product-2021
check:
- user: user:anne
object: folder:product-2021
assertions:
can_view: true
can_write: true
can_share: true
- user: user:beth
object: folder:product-2021
assertions:
can_view: true
can_write: false
can_share: false
list-objects:
- user: user:anne
type: folder
assertions:
can_view:
- folder:product
- folder:product-2021
can_write:
- folder:product
- folder:product-2021
can_share:
- folder:product
- folder:product-2021
- user: user:beth
type: folder
assertions:
can_view:
- folder:product-2021
can_write: []
can_share: []
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,26 @@ func ReadFromInputFileOrArg( //nolint:cyclop

return nil
}

func ReadFromInputFile(
fileName string,
format *ModelFormat,
) (*string, error) {
file, err := os.ReadFile(fileName)
if err != nil {
return nil, fmt.Errorf("failed to read file %s due to %w", fileName, err)
}

model := string(file)

// if the input format is set as the default, set it from the file extension (and default to fga)
if *format == ModelFormatDefault {
if strings.HasSuffix(fileName, "json") {
*format = ModelFormatJSON
} else {
*format = ModelFormatFGA
}
}

return &model, nil
}
Loading