From dc6d53fd256212c66c4f0cf7d1c84954af35ba41 Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Wed, 20 May 2026 15:55:53 +1000 Subject: [PATCH] feat(opa): run policy tests at startup Add a `test` field to the opa block holding a Rego test module. Every test_* rule is executed against the configured policy when cachewd starts; a failing test exits the process. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 21 ++++++++++ cachew.hcl | 9 ++++ cmd/cachewd/main.go | 11 +++++ internal/opa/opa.go | 70 +++++++++++++++++++++++++++++++ internal/opa/opa_test.go | 90 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 201 insertions(+) diff --git a/README.md b/README.md index 98dbf22..a75289c 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,27 @@ opa { **Input fields:** `input.method`, `input.path` (string array), `input.headers`, `input.remote_addr` (includes port — use `startswith` to match by IP). +### Testing policies + +The `test` field holds a Rego test module that is run against the policy when `cachewd` starts. Any rule prefixed with `test_` is executed; if a test fails, `cachewd` exits. + +```hcl +opa { + policy = < 0 { + logger.InfoContext(ctx, "OPA tests passed", "count", passed) + } +} + func fatalIfError(ctx context.Context, logger *slog.Logger, err error, msg string) { if err == nil { return diff --git a/internal/opa/opa.go b/internal/opa/opa.go index 230355d..60786da 100644 --- a/internal/opa/opa.go +++ b/internal/opa/opa.go @@ -4,12 +4,16 @@ package opa import ( "context" "encoding/json" + "fmt" "net/http" "os" "strings" "github.com/alecthomas/errors" + "github.com/open-policy-agent/opa/v1/ast" "github.com/open-policy-agent/opa/v1/rego" + "github.com/open-policy-agent/opa/v1/storage/inmem" + "github.com/open-policy-agent/opa/v1/tester" "github.com/block/cachew/internal/logging" ) @@ -29,6 +33,7 @@ type Config struct { PolicyFile string `hcl:"policy-file,optional" help:"Path to a Rego policy file."` Data string `hcl:"data,optional" help:"Inline JSON object loaded as OPA data.*"` DataFile string `hcl:"data-file,optional" help:"Path to a JSON file loaded as OPA data.*"` + Test string `hcl:"test,optional" help:"Inline Rego test module run against the policy when cachewd starts."` } // Middleware returns an http.Handler that evaluates OPA policy before delegating to next. @@ -70,6 +75,71 @@ func Middleware(ctx context.Context, cfg Config, next http.Handler) (http.Handle }), nil } +// RunTests compiles the configured policy together with the Rego test module in +// cfg.Test and executes every test_* rule. It returns the number of tests that +// passed and an error enumerating any that failed or errored. When cfg.Test is +// empty it is a no-op. The policy under test is loaded the same way as +// Middleware, so an empty policy config exercises DefaultPolicy. +func RunTests(ctx context.Context, cfg Config) (int, error) { + if cfg.Test == "" { + return 0, nil + } + + policy, err := loadPolicy(cfg) + if err != nil { + return 0, err + } + modules, err := parseTestModules(policy, cfg.Test) + if err != nil { + return 0, err + } + + runner := tester.NewRunner().SetModules(modules) + if cfg.Data != "" || cfg.DataFile != "" { + opaData, err := loadData(cfg) + if err != nil { + return 0, err + } + runner = runner.SetStore(inmem.NewFromObject(opaData)) + } + + ch, err := runner.RunTests(ctx, nil) + if err != nil { + return 0, errors.Errorf("run OPA tests: %w", err) + } + + passed := 0 + var failures []string + for result := range ch { + switch { + case result.Pass(): + passed++ + case result.Skip: + case result.Error != nil: + failures = append(failures, fmt.Sprintf("%s.%s: %v", result.Package, result.Name, result.Error)) + default: + failures = append(failures, fmt.Sprintf("%s.%s: failed", result.Package, result.Name)) + } + } + if len(failures) > 0 { + return passed, errors.Errorf("OPA tests failed: %s", strings.Join(failures, "; ")) + } + return passed, nil +} + +// parseTestModules parses the policy and test Rego sources into modules keyed by filename. +func parseTestModules(policy, test string) (map[string]*ast.Module, error) { + policyModule, err := ast.ParseModule("policy.rego", policy) + if err != nil { + return nil, errors.Errorf("parse OPA policy: %w", err) + } + testModule, err := ast.ParseModule("test.rego", test) + if err != nil { + return nil, errors.Errorf("parse OPA test: %w", err) + } + return map[string]*ast.Module{"policy.rego": policyModule, "test.rego": testModule}, nil +} + // prepareQuery compiles a single Rego query against the given policy and data options. func prepareQuery(ctx context.Context, query, policy string, dataOpts []func(*rego.Rego)) (rego.PreparedEvalQuery, error) { opts := make([]func(*rego.Rego), 0, 2+len(dataOpts)) diff --git a/internal/opa/opa_test.go b/internal/opa/opa_test.go index 65ab8b5..065b2e4 100644 --- a/internal/opa/opa_test.go +++ b/internal/opa/opa_test.go @@ -275,6 +275,96 @@ allow if input.headers["authorization"] assert.Equal(t, http.StatusOK, w.Code) } +func TestRunTests(t *testing.T) { + tests := []struct { + Name string + Config opa.Config + ExpectError bool + ExpectPass int + }{ + { + Name: "NoTestIsNoOp", + Config: opa.Config{}, + }, + { + Name: "PassingTestsAgainstInlinePolicy", + Config: opa.Config{ + Policy: `package cachew.authz +default allow := false +allow if input.method == "POST" +`, + Test: `package cachew.authz_test +import data.cachew.authz + +test_post_allowed if authz.allow with input as {"method": "POST"} +test_get_denied if not authz.allow with input as {"method": "GET"} +`, + }, + ExpectPass: 2, + }, + { + Name: "FailingTest", + Config: opa.Config{ + Policy: `package cachew.authz +default allow := false +allow if input.method == "POST" +`, + Test: `package cachew.authz_test +import data.cachew.authz + +test_get_allowed if authz.allow with input as {"method": "GET"} +`, + }, + ExpectError: true, + }, + { + Name: "TestsAgainstDefaultPolicy", + Config: opa.Config{ + Test: `package cachew.authz_test +import data.cachew.authz + +test_localhost_allowed if authz.allow with input as {"remote_addr": "127.0.0.1:1", "path": ["api"]} +test_remote_admin_denied if not authz.allow with input as {"remote_addr": "10.0.0.1:1", "path": ["admin"]} +`, + }, + ExpectPass: 2, + }, + { + Name: "TestsWithData", + Config: opa.Config{ + Policy: `package cachew.authz +default allow := false +allow if data.allowed_methods[input.method] +`, + Data: `{"allowed_methods": {"DELETE": true}}`, + Test: `package cachew.authz_test +import data.cachew.authz + +test_delete_allowed if authz.allow with input as {"method": "DELETE"} +`, + }, + ExpectPass: 1, + }, + { + Name: "InvalidTestModule", + Config: opa.Config{Test: "not valid rego {"}, + ExpectError: true, + }, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + passed, err := opa.RunTests(t.Context(), test.Config) + if test.ExpectError { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, test.ExpectPass, passed) + }) + } +} + func TestMiddlewareEmptyPolicyDeniesAll(t *testing.T) { policy := `package cachew.authz `