Files
releases/v1/config/validate_test.go
T
Sebastian Spaink 8e2f1807ac config: validate configuration with Rego and warn on unknown options (#8891)
Part of #2745

Like most of his ideas, @anderseknert's suggestion to use Rego to
replace the `validateAndInjectDefaults` functions throughout the
codebase is another winner.

This PR starts the migration by replacing the top-level
`validateAndInjectDefaults` in `v1/config/config.go` with an embedded
policy, `validate.rego`. The policy injects the top-level defaults
(`default_decision`, `default_authorization_decision`, `labels`) and
reports unrecognized configuration options, so a typo such as
`decision_log` instead of `decision_logs` is logged as a warning at
startup rather than silently ignored.

It's evaluated in `ParseConfig` using the low-level `ast`/`topdown`
packages rather than the top-level `rego` package. This keeps `config`
off the heavy `rego → bundle → …` dependency web (which would otherwise
create import cycles as more packages' tests reach `config`), and we
don't need any of the `rego` package's conveniences here — it's one
module compiled once and a single query. The Rego unit tests run in CI
via `build/run-rego-tests.sh` (and locally with `make rego-test`).

This sets the foundation for the other plugin
`validateAndInjectDefaults` functions to migrate to Rego as well; where
the logic isn't too complicated it should be a fairly easy replacement.
At the moment all known keys live in `validate.rego` under `_specs` to
support the "warn on unrecognized options" check, but the
plugin-specific entries can move closer to each plugin as it migrates.
It would also be nice for `_specs` to be auto-generated somehow in the
future.

Supporting extension of config validation with custom policies is
something I'd like to follow up with, so keeping #2745 open for now.

I also think these policies could be reusable with
[java-opa-sdk](https://github.com/open-policy-agent/java-opa-sdk) 👀

Signed-off-by: Sebastian Spaink <sebastianspaink@gmail.com>
2026-07-15 11:16:03 -05:00

170 lines
4.9 KiB
Go

// Copyright 2025 The OPA Authors. All rights reserved.
// Use of this source code is governed by an Apache2
// license that can be found in the LICENSE file.
package config
import (
"context"
"reflect"
"slices"
"strings"
"testing"
"github.com/open-policy-agent/opa/v1/ast"
"github.com/open-policy-agent/opa/v1/storage/inmem"
"github.com/open-policy-agent/opa/v1/topdown"
)
func TestParseConfigWarnsOnUnknownOption(t *testing.T) {
// The motivating example from issue #2745.
conf, err := ParseConfig([]byte(`{"decision_log": {"console": true}}`), "id")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := `unknown configuration option "decision_log" encountered`
if !slices.Contains(conf.Warnings, want) {
t.Fatalf("expected warning %q, got %v", want, conf.Warnings)
}
}
func TestParseConfigNoWarningsForValidConfig(t *testing.T) {
conf, err := ParseConfig([]byte(`{"decision_logs": {"console": true}}`), "id")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(conf.Warnings) != 0 {
t.Fatalf("expected no warnings, got %v", conf.Warnings)
}
}
func TestParseConfigEmptyInjectsDefaults(t *testing.T) {
// The SDK and other callers parse an absent configuration (nil/empty bytes);
// defaults must still be injected and no error returned.
for name, raw := range map[string][]byte{
"nil": nil,
"empty": []byte(``),
"empty-obj": []byte(`{}`),
"null": []byte(`null`),
"whitespace": []byte(` `),
} {
t.Run(name, func(t *testing.T) {
conf, err := ParseConfig(raw, "id")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if conf.DefaultDecision == nil || *conf.DefaultDecision != "/system/main" {
t.Fatalf("expected default decision to be injected, got %v", conf.DefaultDecision)
}
if conf.Labels["id"] != "id" {
t.Fatalf("expected id label to be injected, got %v", conf.Labels)
}
})
}
}
func TestParseConfigNullDecisionDefaults(t *testing.T) {
// A field explicitly set to null must fall back to the default (not error),
// matching the pre-Rego behavior where a nil pointer was treated as unset.
conf, err := ParseConfig([]byte(`{"default_decision": null}`), "id")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if conf.DefaultDecision == nil || *conf.DefaultDecision != "/system/main" {
t.Fatalf("expected default decision to be injected, got %v", conf.DefaultDecision)
}
}
func TestParseConfigNonStringDecisionErrors(t *testing.T) {
// A present, non-null, non-string value is still a fatal error.
if _, err := ParseConfig([]byte(`{"default_decision": 42}`), "id"); err == nil {
t.Fatal("expected error for non-string default_decision, got nil")
}
}
// TestCoreValidationRootSpecMatchesConfigStruct is a drift guard: the set of
// top-level keys known to the core validation policy must exactly match the
// JSON-tagged fields of the Config struct. If a field is added to Config without
// updating validate.rego (or vice versa), this test fails.
func TestCoreValidationRootSpecMatchesConfigStruct(t *testing.T) {
structKeys := map[string]struct{}{}
objType := reflect.TypeOf(Config{})
for i := range objType.NumField() {
name := strings.Split(objType.Field(i).Tag.Get("json"), ",")[0]
if name == "" || name == "-" {
continue
}
structKeys[name] = struct{}{}
}
policyKeys := rootSpecKeys(t)
for k := range structKeys {
if _, ok := policyKeys[k]; !ok {
t.Errorf("config key %q is present in Config struct but missing from validate.rego root spec", k)
}
}
for k := range policyKeys {
if _, ok := structKeys[k]; !ok {
t.Errorf("config key %q is present in validate.rego root spec but not in Config struct", k)
}
}
}
// rootSpecKeys evaluates the core policy and returns the key set of the spec
// whose pattern is empty (i.e. the top-level configuration object).
func rootSpecKeys(t *testing.T) map[string]struct{} {
t.Helper()
ctx := context.Background()
compiler, err := compileValidationPolicy()
if err != nil {
t.Fatalf("compile: %v", err)
}
store := inmem.New()
txn, err := store.NewTransaction(ctx)
if err != nil {
t.Fatalf("txn: %v", err)
}
defer store.Abort(ctx, txn)
qrs, err := topdown.NewQuery(ast.MustParseBody("data.opa.config._specs = x")).
WithCompiler(compiler).
WithStore(store).
WithTransaction(txn).
Run(ctx)
if err != nil {
t.Fatalf("eval _specs: %v", err)
}
if len(qrs) != 1 {
t.Fatalf("unexpected result set: %v", qrs)
}
value, err := ast.JSON(qrs[0][ast.Var("x")].Value)
if err != nil {
t.Fatalf("convert _specs: %v", err)
}
specs, ok := value.([]any)
if !ok {
t.Fatalf("unexpected _specs type %T", value)
}
for _, s := range specs {
spec := s.(map[string]any)
pattern := spec["pattern"].([]any)
if len(pattern) != 0 {
continue
}
keys := map[string]struct{}{}
for _, k := range spec["keys"].([]any) {
keys[k.(string)] = struct{}{}
}
return keys
}
t.Fatal("no root spec (empty pattern) found in validate.rego")
return nil
}