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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ dist/

# Test files
/tests/fixtures/identifiers
.DS_Store
85 changes: 73 additions & 12 deletions cmd/model/test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ limitations under the License.
package model

import (
"errors"
"fmt"
"os"
"path"
Expand All @@ -30,15 +31,22 @@ import (
"github.com/openfga/cli/internal/storetest"
)

var (
errNoTestFilesSpecified = errors.New("no test files specified")
errNoTestFilesFound = errors.New("no test files found")
errTestFileDoesNotExist = errors.New("test file does not exist")
)

// modelTestCmd represents the test command.
var modelTestCmd = &cobra.Command{
Use: "test",
Short: "Test an Authorization Model",
Long: "Run a set of tests against a particular Authorization Model.",
Example: `fga model test --tests model.fga.yaml`,
RunE: func(cmd *cobra.Command, _ []string) error {
Args: cobra.MinimumNArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
// Read and validate all flags
testsFileName, err := cmd.Flags().GetString("tests")
testsFilePatterns, err := cmd.Flags().GetStringArray("tests")
if err != nil {
return fmt.Errorf("failed to get tests flag: %w", err)
}
Expand All @@ -53,17 +61,12 @@ var modelTestCmd = &cobra.Command{
return fmt.Errorf("failed to get suppress-summary flag: %w", err)
}

fileNames, err := filepath.Glob(testsFileName)
// Expand test file patterns (handles both glob patterns and shell-expanded files)
fileNames, err := expandTestFilePatterns(testsFilePatterns, args)
if err != nil {
return fmt.Errorf("invalid tests pattern %s due to %w", testsFileName, err)
}
if len(fileNames) == 0 {
// Check if the literal path exists
if _, err := os.Stat(testsFileName); err != nil {
return fmt.Errorf("test file %s does not exist: %w", testsFileName, err)
}
fileNames = []string{testsFileName}
return err
}

multipleFiles := len(fileNames) > 1

clientConfig := cmdutils.GetClientConfig(cmd)
Expand Down Expand Up @@ -139,10 +142,68 @@ var modelTestCmd = &cobra.Command{
},
}

// expandTestFilePatterns takes test file patterns (which can be literal file paths or glob patterns)
// and positional arguments (from shell expansion), and returns a list of resolved file paths.
// It handles both quoted glob patterns (where the CLI does the expansion) and shell-expanded
// arguments (where the shell expands the glob before passing to the CLI).
func expandTestFilePatterns(patterns []string, posArgs []string) ([]string, error) {
// Combine flag values and positional args
// This handles shell expansion: when the shell expands ./example/*.fga.yaml to
// ./example/file1.yaml ./example/file2.yaml, the first file goes to the --tests flag
// and the rest end up as positional arguments
allPatterns := make([]string, 0, len(patterns)+len(posArgs))
allPatterns = append(allPatterns, patterns...)
allPatterns = append(allPatterns, posArgs...)

if len(allPatterns) == 0 {
return nil, errNoTestFilesSpecified
}

// Use a map to deduplicate file names (different globs might match the same files)
uniqueFiles := make(map[string]bool)

for _, pattern := range allPatterns {
// First, check if it's a literal file that exists
if _, err := os.Stat(pattern); err == nil {
uniqueFiles[pattern] = true

continue
}

// Otherwise, try to expand it as a glob pattern
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, fmt.Errorf("invalid tests pattern %s due to %w", pattern, err)
}

if len(matches) > 0 {
for _, match := range matches {
uniqueFiles[match] = true
}
} else {
// If glob didn't match and file doesn't exist, report error
return nil, fmt.Errorf("%w: %s", errTestFileDoesNotExist, pattern)
}
}

if len(uniqueFiles) == 0 {
return nil, errNoTestFilesFound
}

// Convert map keys to slice
fileNames := make([]string, 0, len(uniqueFiles))
for file := range uniqueFiles {
fileNames = append(fileNames, file)
}

return fileNames, nil
}

func init() {
modelTestCmd.Flags().String("store-id", "", "Store ID")
modelTestCmd.Flags().String("model-id", "", "Model ID")
modelTestCmd.Flags().String("tests", "", "Path or glob of YAML test files")
modelTestCmd.Flags().StringArray("tests", []string{},
"Path or glob of YAML test files. Can be specified multiple times or use glob patterns")
modelTestCmd.Flags().Bool("verbose", false, "Print verbose JSON output")
modelTestCmd.Flags().Bool("suppress-summary", false, "Suppress the plain text summary output")

Expand Down
194 changes: 194 additions & 0 deletions cmd/model/test_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package model

import (
"strings"
"testing"

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

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

files, err := expandTestFilePatterns([]string{"../../example/model.fga.yaml"}, []string{})
require.NoError(t, err)
assert.Len(t, files, 1)
assert.Contains(t, files[0], "example/model.fga.yaml")
}

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

files, err := expandTestFilePatterns(
[]string{"../../example/model.fga.yaml", "../../example/store_abac.fga.yaml"},
[]string{},
)
require.NoError(t, err)
assert.Len(t, files, 2)
assert.True(t, anyContains(files, "example/model.fga.yaml"))
assert.True(t, anyContains(files, "example/store_abac.fga.yaml"))
}

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

files, err := expandTestFilePatterns([]string{"../../example/*.fga.yaml"}, []string{})
require.NoError(t, err)
assert.GreaterOrEqual(t, len(files), 2, "should match at least model.fga.yaml and store_abac.fga.yaml")
assert.True(t, anyContains(files, "example/model.fga.yaml"))
assert.True(t, anyContains(files, "example/store_abac.fga.yaml"))
}

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

// Simulate what happens when the shell expands: --tests file1 file2
// The first file goes to the flag, the second becomes a positional arg
files, err := expandTestFilePatterns(
[]string{"../../example/model.fga.yaml"},
[]string{"../../example/store_abac.fga.yaml"},
)
require.NoError(t, err)
assert.Len(t, files, 2)
assert.True(t, anyContains(files, "example/model.fga.yaml"))
assert.True(t, anyContains(files, "example/store_abac.fga.yaml"))
}

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

files, err := expandTestFilePatterns(
[]string{"../../example/model.fga.yaml", "../../example/*.fga.yaml"},
[]string{},
)
require.NoError(t, err)
// Should include model.fga.yaml and store_abac.fga.yaml
assert.GreaterOrEqual(t, len(files), 2, "should have at least 2 files")
assert.True(t, anyContains(files, "example/model.fga.yaml"))
assert.True(t, anyContains(files, "example/store_abac.fga.yaml"))
}

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

files, err := expandTestFilePatterns([]string{"../../example/subdir/*.fga.yaml"}, []string{})
require.NoError(t, err)
assert.Len(t, files, 1)
assert.True(t, anyContains(files, "example/subdir/simple.fga.yaml"))
}

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

files, err := expandTestFilePatterns([]string{"../../example/nonexistent.fga.yaml"}, []string{})
require.Error(t, err)
assert.Nil(t, files)
assert.Contains(t, err.Error(), "does not exist", "error should mention file doesn't exist")
}

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

files, err := expandTestFilePatterns([]string{"../../example/*.nonexistent"}, []string{})
require.Error(t, err)
assert.Nil(t, files)
assert.Contains(t, err.Error(), "does not exist", "error should mention no files found")
}

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

files, err := expandTestFilePatterns([]string{}, []string{})
require.Error(t, err)
assert.Nil(t, files)
assert.Contains(t, err.Error(), "no test files specified")
}

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

// Simulate shell expanding multiple globs that result in 3 files
files, err := expandTestFilePatterns(
[]string{"../../example/model.fga.yaml"},
[]string{"../../example/store_abac.fga.yaml", "../../example/subdir/simple.fga.yaml"},
)
require.NoError(t, err)
assert.Len(t, files, 3)
assert.True(t, anyContains(files, "example/model.fga.yaml"))
assert.True(t, anyContains(files, "example/store_abac.fga.yaml"))
assert.True(t, anyContains(files, "example/subdir/simple.fga.yaml"))
}

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

// When user quotes the pattern, shell doesn't expand it
// So the CLI receives the glob pattern itself
files, err := expandTestFilePatterns([]string{"../../example/*.fga.yaml"}, []string{})
require.NoError(t, err)
assert.GreaterOrEqual(t, len(files), 2, "should expand glob to at least 2 files")
}

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

// Mixed scenario: one file and one glob pattern as positional arg
files, err := expandTestFilePatterns(
[]string{"../../example/model.fga.yaml"},
[]string{"../../example/subdir/*.fga.yaml"},
)
require.NoError(t, err)
assert.Len(t, files, 2)
assert.True(t, anyContains(files, "example/model.fga.yaml"))
assert.True(t, anyContains(files, "example/subdir/simple.fga.yaml"))
}

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

// Test with an invalid glob pattern (contains invalid characters for glob)
files, err := expandTestFilePatterns([]string{"../../example/[.fga.yaml"}, []string{})
require.Error(t, err)
assert.Nil(t, files)
assert.Contains(t, err.Error(), "invalid tests pattern")
}

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

// Test that duplicate files from overlapping patterns are deduplicated
files, err := expandTestFilePatterns(
[]string{
"../../example/model.fga.yaml",
"../../example/*.fga.yaml", // This will also match model.fga.yaml
"../../example/model.fga.yaml",
},
[]string{},
)
require.NoError(t, err)
// Should have at least 2 files (model.fga.yaml and store_abac.fga.yaml)
assert.GreaterOrEqual(t, len(files), 2)

// Count occurrences of model.fga.yaml - should only appear once
count := 0

for _, file := range files {
if strings.Contains(file, "example/model.fga.yaml") {
count++
}
}

assert.Equal(t, 1, count, "model.fga.yaml should only appear once despite being matched multiple times")
}

// anyContains checks if any string in the slice contains the given substring.
func anyContains(slice []string, substr string) bool {
for _, s := range slice {
if strings.Contains(s, substr) {
return true
}
}

return false
}
23 changes: 23 additions & 0 deletions example/subdir/simple.fga.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Simple Test Store
model: |
model
schema 1.1

type user

type document
relations
define reader: [user]

tuples:
- user: user:alice
relation: reader
object: document:readme

tests:
- name: "simple-test"
check:
- user: user:alice
object: document:readme
assertions:
reader: true
Loading