Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
2 changes: 2 additions & 0 deletions cmd/dev/app/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/mindersec/minder/cmd/dev/app/datasource"
"github.com/mindersec/minder/cmd/dev/app/image"
"github.com/mindersec/minder/cmd/dev/app/rule_type"
"github.com/mindersec/minder/cmd/dev/app/test"
"github.com/mindersec/minder/cmd/dev/app/testserver"
"github.com/mindersec/minder/internal/util/cli"
)
Expand All @@ -26,6 +27,7 @@ https://mindersec.github.io/`,
}

cmd.AddCommand(rule_type.CmdRuleType())
cmd.AddCommand(test.CmdTest())
cmd.AddCommand(image.CmdImage())
cmd.AddCommand(testserver.CmdTestServer())
cmd.AddCommand(bundles.CmdBundle())
Expand Down
60 changes: 60 additions & 0 deletions cmd/dev/app/test/test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// SPDX-FileCopyrightText: Copyright 2026 The Minder Authors
// SPDX-License-Identifier: Apache-2.0

// Package test provides the test command for mindev
package test
Comment thread
evankanderson marked this conversation as resolved.

import (
"errors"

"github.com/spf13/cobra"

"github.com/mindersec/minder/pkg/ruletest"
)

// CmdTest returns the test cobra command
func CmdTest() *cobra.Command {
cmd := &cobra.Command{
Use: "test [paths...]",
Short: "Run Minder rule tests",
Long: "Run Starlark-based tests for Minder rules. Each path may be a file or directory. " +
"If no paths are provided, tests the current directory.",
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
args = []string{"."}
}

runner := ruletest.NewRunner()
results, err := runner.RunPaths(args)
if err != nil {
cmd.PrintErrf("Error(s) running tests:\n%v\n", err)
}

if len(results) == 0 {
cmd.Printf("No tests found\n")
return nil
}

hasFailures := false
for _, res := range results {
if len(res.Failures) > 0 {
hasFailures = true
cmd.Printf("FAIL: %s/%s\n", res.Filename, res.Name)
for _, f := range res.Failures {
cmd.Printf(" - %s\n", f)
}
} else {
cmd.Printf("PASS: %s/%s\n", res.Filename, res.Name)
}
}

if hasFailures {
return errors.New("one or more tests failed")
}

return nil
},
}

return cmd
}
65 changes: 39 additions & 26 deletions pkg/ruletest/eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"encoding/json"
"errors"
"fmt"
"path/filepath"

"go.starlark.net/starlark"
"google.golang.org/protobuf/encoding/protojson"
Expand All @@ -17,40 +16,32 @@ import (
minderv1 "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1"
"github.com/mindersec/minder/pkg/engine/v1/interfaces"
"github.com/mindersec/minder/pkg/engine/v1/rtengine"
"github.com/mindersec/minder/pkg/fileconvert"
tkv1 "github.com/mindersec/minder/pkg/testkit/v1"
)

func builtinEval(
func (tr *testCaseRunner) builtinEval(
thread *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple,
) (starlark.Value, error) {
var rulePath string
var ruleName string
var entityDict *starlark.Dict
var profileDict *starlark.Dict
var mockHttpDict *starlark.Dict
var mockFSDict *starlark.Dict

err := starlark.UnpackArgs("eval", args, kwargs,
"rule", &rulePath, "entity?", &entityDict, "profile?", &profileDict, "mock_http?", &mockHttpDict)
"rule", &ruleName, "entity?", &entityDict, "profile?", &profileDict, "mock_http?", &mockHttpDict, "mock_fs?", &mockFSDict)
if err != nil {
return nil, err
}

if !filepath.IsAbs(rulePath) {
callerFrame := thread.CallFrame(1)
if callerFile := callerFrame.Pos.Filename(); callerFile != "" {
rulePath = filepath.Join(filepath.Dir(callerFile), rulePath)
}
}

decoder, closer := fileconvert.DecoderForFile(rulePath)
if decoder == nil {
return nil, fmt.Errorf("error opening file: %s", rulePath)
mockFSMap, err := parseMockFSDict(mockFSDict)
if err != nil {
return nil, err
}
defer closer.Close()

rt, err := fileconvert.ReadResourceTyped[*minderv1.RuleType](decoder)
rt, err := tr.lookupRuleType(ruleName)
if err != nil {
return nil, fmt.Errorf("failed to parse rule type: %w", err)
return nil, err
}

profileMap, err := dictToGoMap(profileDict)
Expand All @@ -75,7 +66,11 @@ func builtinEval(

ctx := context.Background()

tk := tkv1.NewTestKit(tkv1.WithHandlerFunc(mockHandler.ServeHTTP))
tkOpts := []tkv1.Option{tkv1.WithHandlerFunc(mockHandler.ServeHTTP)}
if len(mockFSMap) > 0 {
tkOpts = append(tkOpts, tkv1.WithGitFiles(mockFSMap))
}
tk := tkv1.NewTestKit(tkOpts...)

rte, err := rtengine.NewRuleTypeEngine(ctx, rt, tk)
if err != nil {
Expand Down Expand Up @@ -121,12 +116,33 @@ func formatEvalResult(evalErr error) *starlark.Dict {
return result
}

//nolint:gocyclo // this is a simple switch over many entity types
func mapToProto(entityType string, entityMap map[string]any) (proto.Message, error) {
if len(entityMap) == 0 {
return nil, nil
func parseMockFSDict(mockFSDict *starlark.Dict) (map[string]string, error) {
mockFSMap := make(map[string]string)
if mockFSDict != nil {
for _, item := range mockFSDict.Items() {
k, v := item[0], item[1]
ks, ok1 := k.(starlark.String)
vs, ok2 := v.(starlark.String)
if !ok1 || !ok2 {
return nil, fmt.Errorf("mock_fs keys and values must be strings")
}
mockFSMap[string(ks)] = string(vs)
}
}
return mockFSMap, nil
}

func (tr *testCaseRunner) lookupRuleType(ruleName string) (*minderv1.RuleType, error) {
if tr.ruleTypes != nil {
if ruleType, ok := tr.ruleTypes[ruleName]; ok {
return ruleType, nil
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that you're storing *minderv1.RuleType, you don't need the ,ok = formulation, since a nil pointer could just be the error case below:

Suggested change
if ruleType, ok := tr.ruleTypes[ruleName]; ok {
return ruleType, nil
}
if ruleType := tr.ruleTypes[ruleName]; ruleType != nil {
return ruleType, nil
}

}
return nil, fmt.Errorf("rule %q not found; make sure the rule type YAML is in the same directory as the test file", ruleName)
}

//nolint:gocyclo // this is a simple switch over many entity types
func mapToProto(entityType string, entityMap map[string]any) (proto.Message, error) {
b, err := json.Marshal(entityMap)
if err != nil {
return nil, err
Expand Down Expand Up @@ -155,9 +171,6 @@ func mapToProto(entityType string, entityMap map[string]any) (proto.Message, err
minderv1.Entity_ENTITY_PULL_REQUESTS:
fallthrough
default:
// Some entities like PullRequest or BuildEnvironment may not have concrete protobuf structs available here.
// For mocking purposes, returning nil is acceptable if the template doesn't strict check them,
// but returning an error is safer to flag unsupported mocking right now.
return nil, fmt.Errorf("unsupported entity type for mapping to proto: %s", entityType)
}

Expand Down
26 changes: 25 additions & 1 deletion pkg/ruletest/eval_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,36 @@ func TestBuiltinEval_InvalidArgs(t *testing.T) {
kwargs: []starlark.Tuple{{starlark.String("entity"), starlark.String("not a dict")}},
wantErr: "got string, want dict",
},
{
name: "invalid mock_fs type",
args: starlark.Tuple{starlark.String("rule.yaml")},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I know we don't really use this here, but this should be "fs_check", right?)

kwargs: []starlark.Tuple{
{starlark.String("mock_fs"), starlark.String("not a dict")},
},
wantErr: "got string, want dict",
},
{
name: "mock_fs non-string keys",
args: starlark.Tuple{starlark.String("rule.yaml")},
kwargs: []starlark.Tuple{
{
starlark.String("mock_fs"),
func() *starlark.Dict {
d := starlark.NewDict(1)
_ = d.SetKey(starlark.MakeInt(1), starlark.String("content"))
return d
}(),
},
},
wantErr: "mock_fs keys and values must be strings",
},
Comment thread
evankanderson marked this conversation as resolved.
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := builtinEval(thread, starlark.NewBuiltin("eval", builtinEval), tt.args, tt.kwargs)
tr := &testCaseRunner{}
Comment thread
evankanderson marked this conversation as resolved.
_, err := tr.builtinEval(thread, starlark.NewBuiltin("eval", tr.builtinEval), tt.args, tt.kwargs)
if err == nil {
t.Fatal("expected error, got nil")
}
Expand Down
Loading
Loading