Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
}
69 changes: 43 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,13 +66,21 @@ 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 {
return nil, fmt.Errorf("failed to initialize rule type engine: %w", err)
}

if tk.ShouldOverrideIngest() {
rte.WithCustomIngester(tk)
}

res, err := rte.Eval(ctx, entityProto, profileMap, nil, &stubResultSink{})

// Because Eval returns the error, we pass that error to formatEvalResult
Expand Down Expand Up @@ -121,12 +120,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 := 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 +175,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("fs_check")},
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("fs_check")},
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