Skip to content

Commit cd66b53

Browse files
feat: implement mindev test cli command and rule loading by name
1 parent 99eb4cd commit cd66b53

4 files changed

Lines changed: 162 additions & 28 deletions

File tree

cmd/dev/app/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/mindersec/minder/cmd/dev/app/datasource"
1212
"github.com/mindersec/minder/cmd/dev/app/image"
1313
"github.com/mindersec/minder/cmd/dev/app/rule_type"
14+
"github.com/mindersec/minder/cmd/dev/app/test"
1415
"github.com/mindersec/minder/cmd/dev/app/testserver"
1516
"github.com/mindersec/minder/internal/util/cli"
1617
)
@@ -26,6 +27,7 @@ https://mindersec.github.io/`,
2627
}
2728

2829
cmd.AddCommand(rule_type.CmdRuleType())
30+
cmd.AddCommand(test.CmdTest())
2931
cmd.AddCommand(image.CmdImage())
3032
cmd.AddCommand(testserver.CmdTestServer())
3133
cmd.AddCommand(bundles.CmdBundle())

cmd/dev/app/test/test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// SPDX-FileCopyrightText: Copyright 2026 The Minder Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package test
5+
6+
import (
7+
"fmt"
8+
9+
"github.com/spf13/cobra"
10+
11+
"github.com/mindersec/minder/pkg/ruletest"
12+
)
13+
14+
// CmdTest returns the test cobra command
15+
func CmdTest() *cobra.Command {
16+
cmd := &cobra.Command{
17+
Use: "test [directories...]",
18+
Short: "Run Minder rule tests",
19+
Long: `Run Starlark-based tests for Minder rules. If no directories are provided, tests the current directory.`,
20+
RunE: func(cmd *cobra.Command, args []string) error {
21+
if len(args) == 0 {
22+
args = []string{"."}
23+
}
24+
25+
runner := ruletest.NewRunner()
26+
hasFailures := false
27+
28+
for _, dir := range args {
29+
fmt.Printf("Running tests in %s...\n", dir)
30+
results, err := runner.RunDir(dir)
31+
if err != nil {
32+
return fmt.Errorf("error running tests in %s: %w", dir, err)
33+
}
34+
35+
if len(results) == 0 {
36+
fmt.Printf("No tests found in %s\n", dir)
37+
continue
38+
}
39+
40+
for _, res := range results {
41+
if len(res.Failures) > 0 {
42+
hasFailures = true
43+
fmt.Printf("FAIL: %s\n", res.Name)
44+
for _, f := range res.Failures {
45+
fmt.Printf(" - %s\n", f)
46+
}
47+
} else {
48+
fmt.Printf("PASS: %s\n", res.Name)
49+
}
50+
}
51+
}
52+
53+
if hasFailures {
54+
return fmt.Errorf("one or more tests failed")
55+
}
56+
57+
return nil
58+
},
59+
}
60+
61+
return cmd
62+
}

pkg/ruletest/eval.go

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,36 +21,47 @@ import (
2121
tkv1 "github.com/mindersec/minder/pkg/testkit/v1"
2222
)
2323

24-
func builtinEval(
24+
func (tr *testCaseRunner) builtinEval(
2525
thread *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple,
2626
) (starlark.Value, error) {
27-
var rulePath string
27+
var ruleNameOrPath string
2828
var entityDict *starlark.Dict
2929
var profileDict *starlark.Dict
3030
var mockHttpDict *starlark.Dict
3131

3232
err := starlark.UnpackArgs("eval", args, kwargs,
33-
"rule", &rulePath, "entity?", &entityDict, "profile?", &profileDict, "mock_http?", &mockHttpDict)
33+
"rule", &ruleNameOrPath, "entity?", &entityDict, "profile?", &profileDict, "mock_http?", &mockHttpDict)
3434
if err != nil {
3535
return nil, err
3636
}
3737

38-
if !filepath.IsAbs(rulePath) {
39-
callerFrame := thread.CallFrame(1)
40-
if callerFile := callerFrame.Pos.Filename(); callerFile != "" {
41-
rulePath = filepath.Join(filepath.Dir(callerFile), rulePath)
38+
var rt *minderv1.RuleType
39+
40+
if tr.ruleTypes != nil {
41+
if ruleType, ok := tr.ruleTypes[ruleNameOrPath]; ok {
42+
rt = ruleType
4243
}
4344
}
4445

45-
decoder, closer := fileconvert.DecoderForFile(rulePath)
46-
if decoder == nil {
47-
return nil, fmt.Errorf("error opening file: %s", rulePath)
48-
}
49-
defer closer.Close()
46+
if rt == nil {
47+
rulePath := ruleNameOrPath
48+
if !filepath.IsAbs(rulePath) {
49+
callerFrame := thread.CallFrame(1)
50+
if callerFile := callerFrame.Pos.Filename(); callerFile != "" {
51+
rulePath = filepath.Join(filepath.Dir(callerFile), rulePath)
52+
}
53+
}
5054

51-
rt, err := fileconvert.ReadResourceTyped[*minderv1.RuleType](decoder)
52-
if err != nil {
53-
return nil, fmt.Errorf("failed to parse rule type: %w", err)
55+
decoder, closer := fileconvert.DecoderForFile(rulePath)
56+
if decoder == nil {
57+
return nil, fmt.Errorf("error opening file: %s (or rule not found in loaded rule types)", rulePath)
58+
}
59+
defer closer.Close()
60+
61+
rt, err = fileconvert.ReadResourceTyped[*minderv1.RuleType](decoder)
62+
if err != nil {
63+
return nil, fmt.Errorf("failed to parse rule type: %w", err)
64+
}
5465
}
5566

5667
profileMap, err := dictToGoMap(profileDict)

pkg/ruletest/runner.go

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import (
1414
"strings"
1515
"testing"
1616

17+
minderv1 "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1"
18+
"github.com/mindersec/minder/pkg/fileconvert"
1719
"go.starlark.net/starlark"
1820
"go.starlark.net/starlarktest"
1921
"go.starlark.net/syntax"
@@ -26,23 +28,25 @@ type testCaseRunner struct {
2628
fs fs.FS
2729
predeclared starlark.StringDict
2830
failures []string
31+
ruleTypes map[string]*minderv1.RuleType
2932
}
3033

31-
func (r *Runner) newTestCaseRunner(name string, fileSystem fs.FS) *testCaseRunner {
34+
func (r *Runner) newTestCaseRunner(name string, fileSystem fs.FS, ruleTypes map[string]*minderv1.RuleType) *testCaseRunner {
3235
if fileSystem == nil {
3336
panic("fileSystem cannot be nil")
3437
}
3538
tr := &testCaseRunner{
3639
fs: fileSystem,
3740
predeclared: starlark.StringDict{},
41+
ruleTypes: ruleTypes,
3842
}
3943
tr.thread = &starlark.Thread{
4044
Name: name,
4145
Print: func(_ *starlark.Thread, msg string) { fmt.Println(msg) },
4246
}
4347
starlarktest.SetReporter(tr.thread, tr)
4448

45-
tr.predeclared["eval"] = starlark.NewBuiltin("eval", builtinEval)
49+
tr.predeclared["eval"] = starlark.NewBuiltin("eval", tr.builtinEval)
4650
tr.predeclared["read_file"] = starlark.NewBuiltin("read_file", tr.builtinReadFile)
4751
tr.predeclared["txtar"] = starlark.NewBuiltin("txtar", builtinTxtar)
4852
tr.predeclared["body"] = starlark.NewBuiltin("body", builtinBody)
@@ -90,10 +94,13 @@ func NewRunner() *Runner {
9094
}
9195
}
9296

93-
// RunFile executes a single Starlark test file and returns the results
94-
// for each test_* function found in it.
95-
// src may be nil, or a string, []byte, or io.Reader containing the file source.
97+
// RunFile executes a single Starlark test file. If src is non-nil, it is
98+
// used as the file contents.
9699
func (r *Runner) RunFile(filename string, src any) ([]TestResult, error) {
100+
return r.runFileWithRules(filename, src, nil)
101+
}
102+
103+
func (r *Runner) runFileWithRules(filename string, src any, ruleTypes map[string]*minderv1.RuleType) ([]TestResult, error) {
97104
if filename == "" {
98105
return nil, errors.New("filename cannot be empty")
99106
}
@@ -102,11 +109,11 @@ func (r *Runner) RunFile(filename string, src any) ([]TestResult, error) {
102109
fileSystem := os.DirFS(baseDir)
103110

104111
name := filepath.Base(filename)
105-
tr := r.newTestCaseRunner(name, fileSystem)
112+
tr := r.newTestCaseRunner(name, fileSystem, ruleTypes)
106113

107114
globals, err := tr.runFile(filename, src)
108115
if err != nil {
109-
if evalErr, ok := errors.AsType[*starlark.EvalError](err); ok {
116+
if evalErr, ok := err.(*starlark.EvalError); ok {
110117
return nil, fmt.Errorf("loading %s: %w\n%s", filename, err, evalErr.Backtrace())
111118
}
112119
return nil, fmt.Errorf("loading %s: %w", filename, err)
@@ -129,15 +136,15 @@ func (r *Runner) RunFile(filename string, src any) ([]TestResult, error) {
129136

130137
var results []TestResult
131138
for name, fn := range testFns {
132-
result := r.runOneTest(name, fn, fileSystem)
139+
result := r.runOneTest(name, fn, fileSystem, ruleTypes)
133140
results = append(results, result)
134141
}
135142

136143
return results, nil
137144
}
138145

139-
func (r *Runner) runOneTest(name string, fn *starlark.Function, fileSystem fs.FS) TestResult {
140-
tr := r.newTestCaseRunner(name, fileSystem)
146+
func (r *Runner) runOneTest(name string, fn *starlark.Function, fileSystem fs.FS, ruleTypes map[string]*minderv1.RuleType) TestResult {
147+
tr := r.newTestCaseRunner(name, fileSystem, ruleTypes)
141148
result := TestResult{Name: name}
142149

143150
_, err := starlark.Call(tr.thread, fn, nil, nil)
@@ -174,11 +181,63 @@ func DiscoverFiles(root string) ([]string, error) {
174181
return files, nil
175182
}
176183

184+
// loadRulesFromDir finds and parses all *.yaml files in the given directory
185+
// into a map of RuleTypes keyed by rule name.
186+
func loadRulesFromDir(dir string) (map[string]*minderv1.RuleType, error) {
187+
ruleTypes := make(map[string]*minderv1.RuleType)
188+
yamlFiles, err := filepath.Glob(filepath.Join(dir, "*.yaml"))
189+
if err != nil {
190+
return nil, fmt.Errorf("globbing yaml files: %w", err)
191+
}
192+
for _, yf := range yamlFiles {
193+
decoder, closer := fileconvert.DecoderForFile(yf)
194+
if decoder == nil {
195+
return nil, fmt.Errorf("error opening file: %s", yf)
196+
}
197+
rt, err := fileconvert.ReadResourceTyped[*minderv1.RuleType](decoder)
198+
closer.Close()
199+
if err == nil && rt != nil && rt.Name != "" {
200+
ruleTypes[rt.Name] = rt
201+
}
202+
}
203+
return ruleTypes, nil
204+
}
205+
177206
// RunDir discovers and executes all *.star test files under the given
178-
// directory, reporting results through t.
179-
func (r *Runner) RunDir(t *testing.T, dir string) {
207+
// directory. It also discovers and loads any *.yaml rule files in the directory.
208+
func (r *Runner) RunDir(dir string) ([]TestResult, error) {
209+
ruleTypes, err := loadRulesFromDir(dir)
210+
if err != nil {
211+
return nil, fmt.Errorf("loading rules: %w", err)
212+
}
213+
214+
files, err := DiscoverFiles(dir)
215+
if err != nil {
216+
return nil, fmt.Errorf("discovering test files: %w", err)
217+
}
218+
219+
var allResults []TestResult
220+
for _, file := range files {
221+
results, err := r.runFileWithRules(file, nil, ruleTypes)
222+
if err != nil {
223+
return nil, err
224+
}
225+
allResults = append(allResults, results...)
226+
}
227+
228+
return allResults, nil
229+
}
230+
231+
// TestDir discovers and executes all *.star test files under the given
232+
// directory, reporting results through t. It also loads *.yaml rules.
233+
func (r *Runner) TestDir(t *testing.T, dir string) {
180234
t.Helper()
181235

236+
ruleTypes, err := loadRulesFromDir(dir)
237+
if err != nil {
238+
t.Fatalf("loading rules: %v", err)
239+
}
240+
182241
files, err := DiscoverFiles(dir)
183242
if err != nil {
184243
t.Fatalf("discovering test files: %v", err)
@@ -196,7 +255,7 @@ func (r *Runner) RunDir(t *testing.T, dir string) {
196255
}
197256

198257
t.Run(rel, func(t *testing.T) {
199-
results, err := r.RunFile(file, nil)
258+
results, err := r.runFileWithRules(file, nil, ruleTypes)
200259
if err != nil {
201260
t.Fatalf("running %s: %v", file, err)
202261
}

0 commit comments

Comments
 (0)