diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index c189b5efe7..8d1c291c2c 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -502,6 +502,47 @@ jobs: env: DOCKER_RUNNING: 0 + # TEMPORARY JOB - safe to delete once Go 1.27 is released and OPA is updated to it + go-1-27-compat: + name: Go 1.27 compat build/test (${{ matrix.version }}) + needs: [generate, check-changes] + if: ${{ needs.check-changes.outputs.go == 'true' }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + include: + - version: "1.26.5" + sha256: 5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053 + - version: "1.27rc2" + sha256: e2dfdfc2b2d4092bf23d5ffb0a11221c2f3eed2d8acfc51344066b9c83a368db + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Download generated artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: generated + - name: Install Go ${{ matrix.version }} + env: + VERSION: ${{ matrix.version }} + SHA256: ${{ matrix.sha256 }} + run: | + set -euo pipefail + tarball="go${VERSION}.linux-amd64.tar.gz" + curl -fsSL --retry 3 -o "${RUNNER_TEMP}/${tarball}" "https://go.dev/dl/${tarball}" + echo "${SHA256} ${RUNNER_TEMP}/${tarball}" | sha256sum --check --strict - + mkdir -p "${RUNNER_TEMP}/toolchain" + tar -C "${RUNNER_TEMP}/toolchain" -xzf "${RUNNER_TEMP}/${tarball}" + echo "${RUNNER_TEMP}/toolchain/go/bin" >> "${GITHUB_PATH}" + - name: Report Go version + run: go version + - run: make go-test + env: + DOCKER_RUNNING: 0 + GOTOOLCHAIN: local + # Run PR metadata against Rego policies rego-check-pr: name: Rego PR checks @@ -713,6 +754,7 @@ jobs: smoke-test-docker-images, smoke-test-binaries, go-version-build, + go-1-27-compat, rego-check-pr, docs-build, docs-fmt-check, diff --git a/cmd/bench_jsonv2_test.go b/cmd/bench_jsonv2_test.go new file mode 100644 index 0000000000..a38d50475e --- /dev/null +++ b/cmd/bench_jsonv2_test.go @@ -0,0 +1,1570 @@ +//go:build go1.27 + +// Copyright 2020 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 cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/open-policy-agent/opa/cmd/formats" + "github.com/open-policy-agent/opa/internal/presentation" + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/bundle" + "github.com/open-policy-agent/opa/v1/rego" + "github.com/open-policy-agent/opa/v1/util" + "github.com/open-policy-agent/opa/v1/util/test" +) + +// Minimize the number of tests that *actually* run the benchmarks, they are pretty slow. +// Have one test that exercises the whole flow. +func TestRunBenchmark(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + params := testBenchParams() + + args := []string{"1 + 1"} + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + + // Expect a json serialized benchmark result with histogram fields + var br testing.BenchmarkResult + err = util.UnmarshalJSON(buf.Bytes(), &br) + if err != nil { + t.Fatalf("Unexpected error unmarshalling output: %s", err) + } + + if br.N == 0 || br.T == 0 || br.MemAllocs == 0 || br.MemBytes == 0 { + t.Fatalf("Expected benchmark results to be non-zero, got: %+v", br) + } + + if _, ok := br.Extra["histogram_timer_rego_query_eval_ns_count"]; !ok { + t.Fatalf("Expected benchmark results to contain histogram_timer_rego_query_eval_ns_count, got: %+v", br) + } + + if float64(br.N) != br.Extra["histogram_timer_rego_query_eval_ns_count"] { + t.Fatalf("Expected 'histogram_timer_rego_query_eval_ns_count' to be equal to N") + } +} + +func TestRunBenchmarkWithQueryImport(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + params := testBenchParams() + // We add the rego.v1 import .. + params.imports = newrepeatedStringFlag([]string{"rego.v1"}) + + // .. which provides the 'in' keyword + args := []string{`"a" in ["a", "b", "c"]`} + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + + // Expect a json serialized benchmark result with histogram fields + var br testing.BenchmarkResult + err = util.UnmarshalJSON(buf.Bytes(), &br) + if err != nil { + t.Fatalf("Unexpected error unmarshalling output: %s", err) + } + + if br.N == 0 || br.T == 0 || br.MemAllocs == 0 || br.MemBytes == 0 { + t.Fatalf("Expected benchmark results to be non-zero, got: %+v", br) + } + + if _, ok := br.Extra["histogram_timer_rego_query_eval_ns_count"]; !ok { + t.Fatalf("Expected benchmark results to contain histogram_timer_rego_query_eval_ns_count, got: %+v", br) + } + + if float64(br.N) != br.Extra["histogram_timer_rego_query_eval_ns_count"] { + t.Fatalf("Expected 'histogram_timer_rego_query_eval_ns_count' to be equal to N") + } +} + +func TestRunBenchmarkE2E(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + params := testBenchParams() + params.e2e = true + + args := []string{"1 + 1"} + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + + // Expect a json serialized benchmark result with histogram fields + var br testing.BenchmarkResult + err = util.UnmarshalJSON(buf.Bytes(), &br) + if err != nil { + t.Fatalf("Unexpected error unmarshalling output: %s", err) + } + + if br.N == 0 || br.T == 0 || br.MemAllocs == 0 || br.MemBytes == 0 { + t.Fatalf("Expected benchmark results to be non-zero, got: %+v", br) + } + + if _, ok := br.Extra["histogram_timer_rego_query_eval_ns_count"]; !ok { + t.Fatalf("Expected benchmark results to contain 'histogram_timer_rego_query_eval_ns_count', got: %+v", br) + } + + if float64(br.N) != br.Extra["histogram_timer_rego_query_eval_ns_count"] { + t.Fatalf("Expected 'histogram_timer_rego_query_eval_ns_count' to be equal to N") + } + + if _, ok := br.Extra["histogram_timer_server_handler_ns_count"]; !ok { + t.Fatalf("Expected benchmark results to contain 'histogram_timer_server_handler_ns_count', got: %+v", br) + } + + if float64(br.N) != br.Extra["histogram_timer_server_handler_ns_count"] { + t.Fatalf("Expected 'histogram_timer_server_handler_ns_count' to be equal to N") + } +} + +func TestRunBenchmarkE2EWithOPAConfigFile(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + fs := map[string]string{ + "/config.yaml": `{"decision_logs": {"console": true}}`, + } + + test.WithTempFS(fs, func(testDirRoot string) { + + params := testBenchParams() + params.e2e = true + params.configFile = filepath.Join(testDirRoot, "config.yaml") + + args := []string{"1 + 1"} + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + + // Expect a json serialized benchmark result with histogram fields + var br testing.BenchmarkResult + err = util.UnmarshalJSON(buf.Bytes(), &br) + if err != nil { + t.Fatalf("Unexpected error unmarshalling output: %s", err) + } + + if br.N == 0 || br.T == 0 || br.MemAllocs == 0 || br.MemBytes == 0 { + t.Fatalf("Expected benchmark results to be non-zero, got: %+v", br) + } + + if _, ok := br.Extra["histogram_timer_rego_query_eval_ns_count"]; !ok { + t.Fatalf("Expected benchmark results to contain 'histogram_timer_rego_query_eval_ns_count', got: %+v", br) + } + + if float64(br.N) != br.Extra["histogram_timer_rego_query_eval_ns_count"] { + t.Fatalf("Expected 'histogram_timer_rego_query_eval_ns_count' to be equal to N") + } + + if _, ok := br.Extra["histogram_timer_server_handler_ns_count"]; !ok { + t.Fatalf("Expected benchmark results to contain 'histogram_timer_server_handler_ns_count', got: %+v", br) + } + + if float64(br.N) != br.Extra["histogram_timer_server_handler_ns_count"] { + t.Fatalf("Expected 'histogram_timer_server_handler_ns_count' to be equal to N") + } + }) +} + +func TestRunBenchmarkFailFastE2E(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + params := testBenchParams() + params.fail = true // configured to fail on undefined results + params.e2e = true + + args := []string{"a := 1; a > 2"} + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 1 { + t.Fatalf("Unexpected return code %d, expected 1", rc) + } + + // Expect a json serialized benchmark result with histogram fields + var pr presentation.Output + err = util.UnmarshalJSON(buf.Bytes(), &pr) + if err != nil { + t.Fatalf("Unexpected error unmarshalling output: %s", err) + } + + if len(pr.Errors) != 1 { + t.Fatalf("Expected 1 error in result, got:\n\n%s\n", buf.String()) + } +} + +func TestBenchPartialE2E(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + params := testBenchParams() + params.partial = true + params.fail = true + params.e2e = true + params.unknowns = []string{"input"} + args := []string{"input.x > 0"} + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + + var br testing.BenchmarkResult + err = util.UnmarshalJSON(buf.Bytes(), &br) + if err != nil { + t.Fatalf("Unexpected error unmarshalling output: %s", err) + } + + if br.N == 0 || br.T == 0 || br.MemAllocs == 0 || br.MemBytes == 0 { + t.Fatalf("Expected benchmark results to be non-zero, got: %+v", br) + } + + if _, ok := br.Extra["histogram_timer_rego_partial_eval_ns_count"]; !ok { + t.Fatalf("Expected benchmark results to contain 'histogram_timer_rego_partial_eval_ns_count', got: %+v", br) + } + + if float64(br.N) != br.Extra["histogram_timer_rego_partial_eval_ns_count"] { + t.Fatalf("Expected 'histogram_timer_rego_partial_eval_ns_count' to be equal to N") + } + + if _, ok := br.Extra["histogram_timer_server_handler_ns_count"]; !ok { + t.Fatalf("Expected benchmark results to contain 'histogram_timer_server_handler_ns_count', got: %+v", br) + } + + if float64(br.N) != br.Extra["histogram_timer_server_handler_ns_count"] { + t.Fatalf("Expected 'histogram_timer_server_handler_ns_count' to be equal to N") + } +} + +func TestRunBenchmarkPartialFailFastE2E(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + params := testBenchParams() + params.partial = true + params.unknowns = []string{} + params.fail = true + params.e2e = true + args := []string{"1 == 2"} + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 1 { + t.Fatalf("Unexpected return code %d, expected 1", rc) + } + + actual := buf.String() + expected := `{ + "errors": [ + { + "message": "undefined result" + } + ] +} +` + + if actual != expected { + t.Fatalf("\nExpected:\n%s\n\nGot:\n%s\n", expected, actual) + } +} + +func TestRunBenchmarkFailFast(t *testing.T) { + t.Parallel() + + params := testBenchParams() + params.fail = true // configured to fail on undefined results + + args := []string{"a := 1; a > 2"} + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 1 { + t.Fatalf("Unexpected return code %d, expected 1", rc) + } + + // Expect a json serialized benchmark result with histogram fields + var pr presentation.Output + err = util.UnmarshalJSON(buf.Bytes(), &pr) + if err != nil { + t.Fatalf("Unexpected error unmarshalling output: %s", err) + } + + if len(pr.Errors) != 1 { + t.Fatalf("Expected 1 error in result, got:\n\n%s\n", buf.String()) + } +} + +// mockBenchRunner lets us test the bench CLI operations without having to wait ~10 seconds +// while the actual benchmark runner does its thing. +type mockBenchRunner struct { + onRun func(ctx context.Context, ectx *evalContext, params benchmarkCommandParams, f func(context.Context, ...rego.EvalOption) error) (testing.BenchmarkResult, error) +} + +func (r *mockBenchRunner) run(ctx context.Context, ectx *evalContext, params benchmarkCommandParams, f func(context.Context, ...rego.EvalOption) error) (testing.BenchmarkResult, error) { + if r.onRun != nil { + return r.onRun(ctx, ectx, params, f) + } + return testing.BenchmarkResult{}, nil +} + +func TestBenchPartial(t *testing.T) { + t.Parallel() + + params := testBenchParams() + params.partial = true + params.fail = true + args := []string{"input=1"} + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &mockBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } +} + +func TestBenchMainErrPreparing(t *testing.T) { + t.Parallel() + + params := testBenchParams() + args := []string{"???"} // query compile error + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &mockBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 1 { + t.Fatalf("Unexpected return code %d, expected 1", rc) + } +} + +func TestBenchMainErrRunningBenchmark(t *testing.T) { + t.Parallel() + + params := testBenchParams() + args := []string{"1+1"} + var buf bytes.Buffer + + mockRunner := &mockBenchRunner{} + mockRunner.onRun = func(_ context.Context, _ *evalContext, _ benchmarkCommandParams, _ func(context.Context, ...rego.EvalOption) error) (testing.BenchmarkResult, error) { + return testing.BenchmarkResult{}, errors.New("error error error") + } + + rc, err := benchMain(args, params, &buf, nil, mockRunner) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 1 { + t.Fatalf("Unexpected return code %d, expected 1", rc) + } +} + +func TestBenchMainWithCount(t *testing.T) { + t.Parallel() + + params := testBenchParams() + args := []string{"1+1"} + var buf bytes.Buffer + + mockRunner := &mockBenchRunner{} + + params.count = 25 + actualCount := 0 + mockRunner.onRun = func(_ context.Context, _ *evalContext, _ benchmarkCommandParams, _ func(context.Context, ...rego.EvalOption) error) (testing.BenchmarkResult, error) { + actualCount++ + return testing.BenchmarkResult{}, nil + } + + rc, err := benchMain(args, params, &buf, nil, mockRunner) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + + if actualCount != params.count { + t.Fatalf("Expected benchmark to be run %d times, only ran %d", params.count, actualCount) + } +} + +func TestBenchMainWithNegativeCount(t *testing.T) { + t.Parallel() + + params := testBenchParams() + args := []string{"1+1"} + var buf bytes.Buffer + + mockRunner := &mockBenchRunner{} + + params.count = -1 + actualCount := 0 + mockRunner.onRun = func(_ context.Context, _ *evalContext, _ benchmarkCommandParams, _ func(context.Context, ...rego.EvalOption) error) (testing.BenchmarkResult, error) { + actualCount++ + return testing.BenchmarkResult{}, nil + } + + rc, err := benchMain(args, params, &buf, nil, mockRunner) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + + if actualCount != 0 { + t.Fatalf("Expected benchmark to not be run, instead ran %d times", actualCount) + } +} + +func validateBenchMainPrep(t *testing.T, args []string, params benchmarkCommandParams) { + t.Helper() + + var buf bytes.Buffer + + mockRunner := &mockBenchRunner{} + + mockRunner.onRun = func(ctx context.Context, ectx *evalContext, _ benchmarkCommandParams, _ func(context.Context, ...rego.EvalOption) error) (testing.BenchmarkResult, error) { + + // cheat and use the ectx to evalute the query to ensure the input setup on it was valid + r := rego.New(ectx.regoArgs...) + pq, err := r.PrepareForEval(ctx) + if err != nil { + return testing.BenchmarkResult{}, err + } + + rs, err := pq.Eval(ctx, ectx.evalArgs...) + if err != nil { + return testing.BenchmarkResult{}, err + } + + if len(rs) == 0 { + return testing.BenchmarkResult{}, errors.New("expected result, got none") + } + + return testing.BenchmarkResult{}, nil + } + + rc, err := benchMain(args, params, &buf, nil, mockRunner) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } +} + +func TestBenchMainWithJSONInputFile(t *testing.T) { + t.Parallel() + + params := testBenchParams() + files := map[string]string{ + "/input.json": `{"x": 42}`, + } + args := []string{"input.x == 42"} + test.WithTempFS(files, func(path string) { + params.inputPath = filepath.Join(path, "input.json") + + validateBenchMainPrep(t, args, params) + }) +} + +func TestBenchMainWithYAMLInputFile(t *testing.T) { + t.Parallel() + + params := testBenchParams() + files := map[string]string{ + "/input.yaml": `x: 42`, + } + args := []string{"input.x == 42"} + test.WithTempFS(files, func(path string) { + params.inputPath = filepath.Join(path, "input.yaml") + + validateBenchMainPrep(t, args, params) + }) +} + +func TestBenchMainInvalidInputFile(t *testing.T) { + t.Parallel() + + params := testBenchParams() + files := map[string]string{ + "/input.yaml": `x: 42`, + } + args := []string{"1+1"} + test.WithTempFS(files, func(path string) { + params.inputPath = filepath.Join(path, "definitely", "not", "input.yaml") + + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &mockBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + if rc != 1 { + t.Fatalf("Unexpected return code %d, expected 1", rc) + } + }) +} + +func TestBenchMainWithJSONInputFileE2E(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + params := testBenchParams() + params.e2e = true + files := map[string]string{ + "/input.json": `{"x": 42}`, + } + args := []string{"input.x == 42"} + test.WithTempFS(files, func(path string) { + params.inputPath = filepath.Join(path, "input.json") + + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + }) +} + +func TestBenchMainWithYAMLInputFileE2E(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + params := testBenchParams() + params.e2e = true + files := map[string]string{ + "/input.yaml": `x: 42`, + } + args := []string{"input.x == 42"} + test.WithTempFS(files, func(path string) { + params.inputPath = filepath.Join(path, "input.yaml") + + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + }) +} + +func TestBenchMainInvalidInputFileE2E(t *testing.T) { + t.Parallel() + + params := testBenchParams() + params.e2e = true + files := map[string]string{ + "/input.yaml": `x: 42`, + } + args := []string{"1+1"} + test.WithTempFS(files, func(path string) { + params.inputPath = filepath.Join(path, "definitely", "not", "input.yaml") + + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 1 { + t.Fatalf("Unexpected return code %d, expected 1", rc) + } + }) +} + +func TestBenchMainWithBundleData(t *testing.T) { + t.Parallel() + + params := testBenchParams() + + b := testBundle() + + files := map[string]string{ + "bundle.tar.gz": "", + } + + test.WithTempFS(files, func(path string) { + bundlePath := filepath.Join(path, "bundle.tar.gz") + f, err := os.OpenFile(bundlePath, os.O_WRONLY, os.ModePerm) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + err = bundle.Write(f, b) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + err = params.bundlePaths.Set(bundlePath) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + args := []string{"data.a.b.x"} + + validateBenchMainPrep(t, args, params) + }) +} + +func TestBenchMainWithBundleDataE2E(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + params := testBenchParams() + params.e2e = true + + b := testBundle() + + files := map[string]string{ + "bundle.tar.gz": "", + } + + test.WithTempFS(files, func(path string) { + bundlePath := filepath.Join(path, "bundle.tar.gz") + f, err := os.OpenFile(bundlePath, os.O_WRONLY, os.ModePerm) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + err = bundle.Write(f, b) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + err = params.bundlePaths.Set(bundlePath) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + args := []string{"data.a.b.x"} + + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + }) +} + +func TestBenchMainWithDataE2E(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + params := testBenchParams() + params.e2e = true + + mod := `package a.b + import rego.v1 + + x if { + data.a.b.c == 42 + } + ` + + files := map[string]string{ + "p.rego": mod, + } + + test.WithTempFS(files, func(path string) { + err := params.dataPaths.Set(filepath.Join(path, "p.rego")) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + args := []string{"data.a.b.x"} + + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + }) +} + +func TestBenchMainBadQueryE2E(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + params := testBenchParams() + params.e2e = true + args := []string{"foo.bar"} + + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 1 { + t.Fatalf("Unexpected return code %d, expected 1", rc) + } +} + +func TestBenchMain_DefaultRegoVersion(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + tests := []struct { + note string + module string + query string + expErrs []string + }{ + // These tests are slow, so we're not being completely exhaustive here. + { + note: "v0 module", + module: `package test +a[x] { + x := 42 +}`, + query: `data.test.a`, + expErrs: []string{ + "mod.rego:2: rego_parse_error: `if` keyword is required before rule body", + "mod.rego:2: rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v1 module", + module: `package test +a contains x if { + x := 42 +}`, + query: `data.test.a`, + }, + } + + modes := []struct { + name string + e2e bool + }{ + { + name: "run", + }, + { + name: "e2e", + e2e: true, + }, + } + + for _, mode := range modes { + for _, tc := range tests { + t.Run(fmt.Sprintf("%s, %s", tc.note, mode.name), func(t *testing.T) { + t.Parallel() + + files := map[string]string{ + "mod.rego": tc.module, + } + + test.WithTempFS(files, func(path string) { + params := testBenchParams() + _ = params.outputFormat.Set(formats.Pretty) + params.e2e = mode.e2e + + for n := range files { + err := params.dataPaths.Set(filepath.Join(path, n)) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + } + + args := []string{tc.query} + + var buf, errBuf bytes.Buffer + rc, err := benchMain(args, params, &buf, &errBuf, &goBenchRunner{}) + + if len(tc.expErrs) > 0 { + if rc == 0 { + t.Fatalf("Expected non-zero return code") + } + + output := errBuf.String() + for _, expErr := range tc.expErrs { + if !strings.Contains(output, expErr) { + t.Fatalf("Expected error:\n\n%s\n\ngot:\n\n%s", expErr, output) + } + } + } else { + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + } + }) + }) + } + } +} + +func TestBenchMainCompatibleFlags(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + tests := []struct { + note string + v0Compatible bool + v1Compatible bool + module string + query string + expErrs []string + }{ + // These tests are slow, so we're not being completely exhaustive here. + { + note: "v0, keywords not used", + v0Compatible: true, + module: `package test +a[4] { + 1 == 1 +}`, + query: `data.test.a`, + }, + { + note: "v0, no keywords imported", + v0Compatible: true, + module: `package test +a contains 4 if { + 1 == 1 +}`, + query: `data.test.a`, + expErrs: []string{ + "rego_parse_error: var cannot be used for rule name", + "rego_parse_error: number cannot be used for rule name", + }, + }, + { + note: "v1, keywords not used", + v1Compatible: true, + module: `package test +a[4] { + 1 == 1 +}`, + query: `data.test.a`, + expErrs: []string{ + "rego_parse_error: `if` keyword is required before rule body", + "rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v1, no keywords imported", + v1Compatible: true, + module: `package test +a contains 4 if { + 1 == 1 +}`, + query: `data.test.a`, + }, + { + note: "v0+v1, keywords not used (v0 takes precedence)", + v0Compatible: true, + v1Compatible: true, + module: `package test +a[4] { + 1 == 1 +}`, + query: `data.test.a`, + }, + } + + modes := []struct { + name string + e2e bool + }{ + { + name: "run", + }, + { + name: "e2e", + e2e: true, + }, + } + + for _, mode := range modes { + for _, tc := range tests { + t.Run(fmt.Sprintf("%s, %s", tc.note, mode.name), func(t *testing.T) { + t.Parallel() + + files := map[string]string{ + "mod.rego": tc.module, + } + + test.WithTempFS(files, func(path string) { + params := testBenchParams() + _ = params.outputFormat.Set(formats.Pretty) + params.v0Compatible = tc.v0Compatible + params.v1Compatible = tc.v1Compatible + params.e2e = mode.e2e + + for n := range files { + err := params.dataPaths.Set(filepath.Join(path, n)) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + } + + args := []string{tc.query} + + var buf, errBuf bytes.Buffer + rc, err := benchMain(args, params, &buf, &errBuf, &goBenchRunner{}) + + if len(tc.expErrs) > 0 { + if rc == 0 { + t.Fatalf("Expected non-zero return code") + } + + output := errBuf.String() + for _, expErr := range tc.expErrs { + if !strings.Contains(output, expErr) { + t.Fatalf("Expected error:\n\n%s\n\ngot:\n\n%s", expErr, output) + } + } + } else { + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + } + }) + }) + } + } +} + +func TestBenchMainWithBundleRegoVersion(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + tests := []struct { + note string + bundleRegoVersion int + bundleFileRegoVersions map[string]int + modules map[string]string + query string + expErrs []string + }{ + // These tests are slow, so we're not being completely exhaustive here. + { + note: "v0 bundle", + bundleRegoVersion: 0, + modules: map[string]string{ + "test.rego": `package test +a[4] { + 1 == 1 +}`, + }, + query: `data.test.a`, + }, + { + note: "v0 bundle, no keywords imported", + bundleRegoVersion: 0, + modules: map[string]string{ + "test.rego": `package test +a contains 4 if { + 1 == 1 +}`, + }, + query: `data.test.a`, + expErrs: []string{ + "rego_parse_error: var cannot be used for rule name", + "rego_parse_error: number cannot be used for rule name", + }, + }, + { + note: "v0 bundle, v1 per-file override", + bundleRegoVersion: 0, + bundleFileRegoVersions: map[string]int{ + "*/test2.rego": 1, + }, + modules: map[string]string{ + "test1.rego": `package test +a[4] { + 1 == 1 +}`, + "test2.rego": `package test +b contains 4 if { + 1 == 1 +}`, + }, + query: `data.test.a`, + }, + { + note: "v1 bundle, keywords not used", + bundleRegoVersion: 1, + modules: map[string]string{ + "test.rego": `package test +a[4] { + 1 == 1 +}`, + }, + query: `data.test.a`, + expErrs: []string{ + "rego_parse_error: `if` keyword is required before rule body", + "rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v1, no keywords imported", + bundleRegoVersion: 1, + modules: map[string]string{ + "test.rego": `package test +a contains 4 if { + 1 == 1 +}`, + }, + query: `data.test.a`, + }, + } + + bundleTypeCases := []struct { + note string + tar bool + }{ + { + "bundle dir", false, + }, + { + "bundle tar", true, + }, + } + + modes := []struct { + name string + e2e bool + }{ + { + name: "run", + }, + { + name: "e2e", + e2e: true, + }, + } + + for _, bundleType := range bundleTypeCases { + for _, mode := range modes { + for _, tc := range tests { + t.Run(fmt.Sprintf("%s, %s, %s", bundleType.note, tc.note, mode.name), func(t *testing.T) { + t.Parallel() + + files := map[string]string{} + + if bundleType.tar { + files["bundle.tar.gz"] = "" + } else { + maps.Copy(files, tc.modules) + + manifest := bundle.Manifest{ + RegoVersion: &tc.bundleRegoVersion, + FileRegoVersions: tc.bundleFileRegoVersions, + } + manifest.Init() + if b, err := json.Marshal(manifest); err != nil { + t.Fatalf("Unexpected error: %s", err) + } else { + files[".manifest"] = string(b) + } + } + + test.WithTempFS(files, func(root string) { + p := root + if bundleType.tar { + b := bundle.Bundle{ + Manifest: bundle.Manifest{ + RegoVersion: &tc.bundleRegoVersion, + FileRegoVersions: tc.bundleFileRegoVersions, + }, + Data: map[string]any{}, + } + for k, v := range tc.modules { + b.Modules = append(b.Modules, bundle.ModuleFile{ + Path: k, + Raw: []byte(v), + }) + } + p = filepath.Join(root, "bundle.tar.gz") + f, err := os.OpenFile(p, os.O_WRONLY, os.ModePerm) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + err = bundle.Write(f, b) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + } + + params := testBenchParams() + _ = params.outputFormat.Set(formats.Pretty) + + params.e2e = mode.e2e + err := params.bundlePaths.Set(p) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + args := []string{tc.query} + + var buf, errBuf bytes.Buffer + rc, err := benchMain(args, params, &buf, &errBuf, &goBenchRunner{}) + + if len(tc.expErrs) > 0 { + if rc == 0 { + t.Fatalf("Expected non-zero return code") + } + + output := errBuf.String() + for _, expErr := range tc.expErrs { + if !strings.Contains(output, expErr) { + t.Fatalf("Expected error:\n\n%s\n\ngot:\n\n%s", expErr, output) + } + } + } else { + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + } + }) + }) + } + } + } +} + +func TestRenderBenchmarkResultJSONOutput(t *testing.T) { + t.Parallel() + + params := testBenchParams() + err := params.outputFormat.Set(formats.JSON) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + br := fakeBenchResults() + + var buf bytes.Buffer + renderBenchmarkResult(params, br, &buf) + + actual := buf.String() + + expected := `{ + "N": 134844, + "T": 1088294120, + "Bytes": 0, + "MemAllocs": 8360721, + "MemBytes": 449906736, + "Extra": { + "histogram_timer_rego_query_eval_ns_75%": 4953.75, + "histogram_timer_rego_query_eval_ns_90%": 6309.6, + "histogram_timer_rego_query_eval_ns_95%": 7872.55, + "histogram_timer_rego_query_eval_ns_99%": 14947.34000000001, + "histogram_timer_rego_query_eval_ns_99.9%": 174377.08200000023, + "histogram_timer_rego_query_eval_ns_99.99%": 176301, + "histogram_timer_rego_query_eval_ns_count": 134844, + "histogram_timer_rego_query_eval_ns_max": 176301, + "histogram_timer_rego_query_eval_ns_mean": 5118.3706225680935, + "histogram_timer_rego_query_eval_ns_median": 4312, + "histogram_timer_rego_query_eval_ns_min": 3553, + "histogram_timer_rego_query_eval_ns_stddev": 6587.830963916497 + } +} +` + if actual != expected { + t.Fatalf("\nExpected:\n%s\n\nGot:\n%s\n", expected, actual) + } +} + +func TestRenderBenchmarkResultPrettyOutput(t *testing.T) { + t.Parallel() + + params := testBenchParams() + params.benchMem = false + err := params.outputFormat.Set(formats.Pretty) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + br := fakeBenchResults() + + var buf bytes.Buffer + renderBenchmarkResult(params, br, &buf) + + actual := buf.String() + + expected := `┌───────────────────────────────────────────┬────────┐ +│ samples │ 134844 │ +│ ns/op │ 8071 │ +│ histogram_timer_rego_query_eval_ns_75% │ 4954 │ +│ histogram_timer_rego_query_eval_ns_90% │ 6310 │ +│ histogram_timer_rego_query_eval_ns_95% │ 7873 │ +│ histogram_timer_rego_query_eval_ns_99% │ 14947 │ +│ histogram_timer_rego_query_eval_ns_99.9% │ 174377 │ +│ histogram_timer_rego_query_eval_ns_99.99% │ 176301 │ +│ histogram_timer_rego_query_eval_ns_count │ 134844 │ +│ histogram_timer_rego_query_eval_ns_max │ 176301 │ +│ histogram_timer_rego_query_eval_ns_mean │ 5118 │ +│ histogram_timer_rego_query_eval_ns_median │ 4312 │ +│ histogram_timer_rego_query_eval_ns_min │ 3553 │ +│ histogram_timer_rego_query_eval_ns_stddev │ 6588 │ +└───────────────────────────────────────────┴────────┘ +` + if actual != expected { + t.Fatalf("\nExpected:\n%s\n\nGot:\n%s\n", expected, actual) + } +} + +func TestRenderBenchmarkResultPrettyOutputShowAllocs(t *testing.T) { + t.Parallel() + + params := testBenchParams() + params.benchMem = true + err := params.outputFormat.Set(formats.Pretty) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + br := fakeBenchResults() + + var buf bytes.Buffer + renderBenchmarkResult(params, br, &buf) + + actual := buf.String() + + expected := `┌───────────────────────────────────────────┬────────┐ +│ samples │ 134844 │ +│ ns/op │ 8071 │ +│ B/op │ 3336 │ +│ allocs/op │ 62 │ +│ histogram_timer_rego_query_eval_ns_75% │ 4954 │ +│ histogram_timer_rego_query_eval_ns_90% │ 6310 │ +│ histogram_timer_rego_query_eval_ns_95% │ 7873 │ +│ histogram_timer_rego_query_eval_ns_99% │ 14947 │ +│ histogram_timer_rego_query_eval_ns_99.9% │ 174377 │ +│ histogram_timer_rego_query_eval_ns_99.99% │ 176301 │ +│ histogram_timer_rego_query_eval_ns_count │ 134844 │ +│ histogram_timer_rego_query_eval_ns_max │ 176301 │ +│ histogram_timer_rego_query_eval_ns_mean │ 5118 │ +│ histogram_timer_rego_query_eval_ns_median │ 4312 │ +│ histogram_timer_rego_query_eval_ns_min │ 3553 │ +│ histogram_timer_rego_query_eval_ns_stddev │ 6588 │ +└───────────────────────────────────────────┴────────┘ +` + if actual != expected { + t.Fatalf("\nExpected:\n%s\n\nGot:\n%s\n", expected, actual) + } +} + +func TestRenderBenchmarkResultGoBenchOutputShowAllocs(t *testing.T) { + t.Parallel() + + params := testBenchParams() + params.benchMem = true + err := params.outputFormat.Set(formats.GoBench) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + br := fakeBenchResults() + + var buf bytes.Buffer + renderBenchmarkResult(params, br, &buf) + + actual := buf.String() + + if !strings.HasPrefix(actual, "Benchmark") { + t.Fatalf("Expected line output to start with 'Benchmark', got: \n\n%s\n", actual) + } + + if len(strings.Split(strings.TrimSpace(actual), "\n")) != 1 { + t.Fatalf("Expected only a single line of output") + } +} + +func TestRenderBenchmarkErrorJSONOutput(t *testing.T) { + t.Parallel() + + params := testBenchParams() + err := params.outputFormat.Set(formats.JSON) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + var buf bytes.Buffer + + _, err = ast.ParseBody("???") + + err = renderBenchmarkError(params, err, &buf, nil) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + actual := buf.String() + expected := `{ + "errors": [ + { + "message": "illegal token", + "code": "rego_parse_error", + "location": { + "file": "", + "row": 1, + "col": 1 + }, + "details": { + "line": "???", + "idx": 0 + } + } + ] +} +` + + if actual != expected { + t.Fatalf("\nExpected:\n%s\n\nGot:\n%s\n", expected, actual) + } +} + +func TestRenderBenchmarkErrorPrettyOutput(t *testing.T) { + t.Parallel() + + params := testBenchParams() + err := params.outputFormat.Set(formats.Pretty) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + testPrettyBenchmarkOutput(t, params) +} + +func TestRenderBenchmarkErrorGoBenchOutput(t *testing.T) { + t.Parallel() + + params := testBenchParams() + err := params.outputFormat.Set(formats.GoBench) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + testPrettyBenchmarkOutput(t, params) +} + +func testPrettyBenchmarkOutput(t *testing.T, params benchmarkCommandParams) { + var buf bytes.Buffer + + _, err := ast.ParseBody("???") + + err = renderBenchmarkError(params, err, &buf, &buf) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + actual := buf.String() + expected := `1 error occurred: 1:1: rego_parse_error: illegal token + ??? + ^ +` + if actual != expected { + t.Fatalf("\nExpected:\n%s\n\nGot:\n%s\n", expected, actual) + } +} + +func testBenchParams() benchmarkCommandParams { + params := newBenchmarkEvalParams() + params.benchMem = true + params.metrics = true + _ = params.outputFormat.Set(formats.JSON) + params.count = 1 + return params +} + +func fakeBenchResults() testing.BenchmarkResult { + return testing.BenchmarkResult{ + N: 134844, + T: 1088294120, + Bytes: 0, + MemAllocs: 8360721, + MemBytes: 449906736, + Extra: map[string]float64{ + "histogram_timer_rego_query_eval_ns_75%": 4953.75, + "histogram_timer_rego_query_eval_ns_90%": 6309.6, + "histogram_timer_rego_query_eval_ns_95%": 7872.55, + "histogram_timer_rego_query_eval_ns_99%": 14947.34000000001, + "histogram_timer_rego_query_eval_ns_99.9%": 174377.08200000023, + "histogram_timer_rego_query_eval_ns_99.99%": 176301, + "histogram_timer_rego_query_eval_ns_count": 134844, + "histogram_timer_rego_query_eval_ns_max": 176301, + "histogram_timer_rego_query_eval_ns_mean": 5118.3706225680935, + "histogram_timer_rego_query_eval_ns_median": 4312, + "histogram_timer_rego_query_eval_ns_min": 3553, + "histogram_timer_rego_query_eval_ns_stddev": 6587.830963916497, + }, + } +} + +func testBundle() bundle.Bundle { + mod := `package a.b + import rego.v1 + + x if { + data.a.b.c == 42 + } + ` + + return bundle.Bundle{ + Manifest: bundle.Manifest{}, + Data: map[string]any{ + "a": map[string]any{ + "b": map[string]any{ + "c": 42, + }, + }, + }, + Modules: []bundle.ModuleFile{ + { + Path: "/a/b/policy.rego", + Raw: []byte(mod), + Parsed: ast.MustParseModule(mod), + }, + }, + } +} diff --git a/cmd/bench_test.go b/cmd/bench_test.go index 7c27ef4585..7682f58385 100644 --- a/cmd/bench_test.go +++ b/cmd/bench_test.go @@ -1,3 +1,5 @@ +//go:build !go1.27 + // Copyright 2020 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. diff --git a/cmd/build_jsonv2_test.go b/cmd/build_jsonv2_test.go new file mode 100644 index 0000000000..2b9f3f0f07 --- /dev/null +++ b/cmd/build_jsonv2_test.go @@ -0,0 +1,3484 @@ +//go:build go1.27 + +package cmd + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "reflect" + "slices" + "strconv" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/open-policy-agent/opa/internal/file/archive" + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/loader" + "github.com/open-policy-agent/opa/v1/util" + "github.com/open-policy-agent/opa/v1/util/test" +) + +func TestBuildProducesBundle(t *testing.T) { + + files := map[string]string{ + "test.rego": ` + package test + p = 1 + `, + } + + test.WithTempFS(files, func(root string) { + params := newBuildParams() + params.outputFile = path.Join(root, "bundle.tar.gz") + + err := dobuild(params, []string{root}) + if err != nil { + t.Fatal(err) + } + + _, err = loader.NewFileLoader().AsBundle(params.outputFile) + if err != nil { + t.Fatal(err) + } + + // Check that manifest is not written given no input manifest and no other flags + f, err := os.Open(params.outputFile) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + gr, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + + tr := tar.NewReader(gr) + + for { + f, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + if f.Name == "/.manifest" || f.Name == "/data.json" || strings.HasSuffix(f.Name, "/test.rego") { + continue + } + t.Fatal("unexpected file:", f.Name) + } + }) +} + +func TestBuildRespectsCapabilities(t *testing.T) { + //nolint:prealloc // test slice is extended dynamically, initial values are clearer as slice literal + tests := []struct { + note string + caps string + policy string + err string + bundleMode bool // build with "-b" flag + }{ + { + note: "builtin defined in caps", + caps: `{ + "builtins": [ + { + "name": "is_foo", + "decl": { + "args": [ + { + "type": "string" + } + ], + "result": { + "type": "boolean" + }, + "type": "function" + } + } + ] + }`, + policy: `package test +p { is_foo("bar") }`, + }, + { + note: "future kw NOT defined in caps", + caps: func() string { + c := ast.CapabilitiesForThisVersion() + c.FutureKeywords = []string{"in"} + c.Features = []string{} + j, err := json.Marshal(c) + if err != nil { + panic(err) + } + return string(j) + }(), + policy: `package test +import future.keywords.if +import future.keywords.in +p if "opa" in input.tools`, + err: "rego_parse_error: unexpected keyword, must be one of [in]", + }, + { + note: "future kw NOT defined in caps, rego-v1 feature", + caps: func() string { + c := ast.CapabilitiesForThisVersion() + c.FutureKeywords = []string{"in"} + c.Features = []string{ast.FeatureRegoV1} + j, err := json.Marshal(c) + if err != nil { + panic(err) + } + return string(j) + }(), + policy: `package test +import future.keywords.if +import future.keywords.in +p if "opa" in input.tools`, + }, + { + note: "future kw are defined in caps", + caps: func() string { + c := ast.CapabilitiesForThisVersion() + c.FutureKeywords = []string{"in", "if"} + j, err := json.Marshal(c) + if err != nil { + panic(err) + } + return string(j) + }(), + policy: `package test +import future.keywords.if +import future.keywords.in +p if "opa" in input.tools`, + }, + { + note: "rego.v1 imported AND defined in capabilities", + caps: func() string { + c := ast.CapabilitiesForThisVersion() + c.Features = []string{ast.FeatureRegoV1Import} + j, err := json.Marshal(c) + if err != nil { + panic(err) + } + return string(j) + }(), + policy: `package test +import rego.v1`, + }, + { + note: "rego.v1 imported AND rego-v1 in capabilities", + caps: func() string { + c := ast.CapabilitiesForThisVersion() + c.Features = []string{ast.FeatureRegoV1} + j, err := json.Marshal(c) + if err != nil { + panic(err) + } + return string(j) + }(), + policy: `package test +import rego.v1`, + }, + } + + // add same tests for bundle-mode == true: + for i := range tests { + tc := tests[i] + tc.bundleMode = true + tc.note += " (as bundle)" + tests = append(tests, tc) + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + files := map[string]string{ + "capabilities.json": tc.caps, + "test.rego": tc.policy, + } + + test.WithTempFS(files, func(root string) { + caps := newCapabilitiesFlag() + if err := caps.Set(path.Join(root, "capabilities.json")); err != nil { + t.Fatal(err) + } + params := newBuildParams() + params.outputFile = path.Join(root, "bundle.tar.gz") + params.capabilities = caps + params.bundleMode = tc.bundleMode + // Test capabilities are all pre-v1 + params.v0Compatible = true + + err := dobuild(params, []string{root}) + switch { + case err != nil && tc.err != "": + if !strings.Contains(err.Error(), tc.err) { + t.Fatalf("expected err %v, got %v", tc.err, err) + } + return // don't read back bundle below + case err != nil && tc.err == "": + t.Fatalf("unexpected error: %v", err) + case err == nil && tc.err != "": + t.Fatalf("expected error %v, got nil", tc.err) + } + + // check that the resulting bundle is readable + _, err = loader.NewFileLoader().AsBundle(params.outputFile) + if err != nil { + t.Fatal(err) + } + }) + }) + } +} + +func TestBuildFilesystemModeIgnoresTarGz(t *testing.T) { + + files := map[string]string{ + "test.rego": ` + package test + p = 1 + `, + } + + test.WithTempFS(files, func(root string) { + params := newBuildParams() + params.outputFile = path.Join(root, "bundle.tar.gz") + + err := dobuild(params, []string{root}) + if err != nil { + t.Fatal(err) + } + + _, err = loader.NewFileLoader().AsBundle(params.outputFile) + if err != nil { + t.Fatal(err) + } + + // Just run the build again to simulate the user doing back-to-back builds. + err = dobuild(params, []string{root}) + if err != nil { + t.Fatal(err) + } + + }) +} + +func TestBuildErrorDoesNotWriteFile(t *testing.T) { + + files := map[string]string{ + "test.rego": ` + package test + + p if { p } + `, + } + + test.WithTempFS(files, func(root string) { + params := newBuildParams() + params.outputFile = path.Join(root, "bundle.tar.gz") + + err := dobuild(params, []string{root}) + exp := fmt.Sprintf("1 error occurred: %s/test.rego:4: rego_recursion_error: rule data.test.p is recursive: data.test.p -> data.test.p", + root) + if err == nil || err.Error() != exp { + t.Fatalf("expected recursion error %q but got: %q", exp, err) + } + + if _, err := os.Stat(params.outputFile); !os.IsNotExist(err) { + t.Fatalf("expected stat \"not found\" error, got %v", err) + } + }) +} + +func TestBuildErrorVerifyNonBundle(t *testing.T) { + + files := map[string]string{ + "test.rego": ` + package test + + p if { p } + `, + } + + test.WithTempFS(files, func(root string) { + params := newBuildParams() + params.outputFile = path.Join(root, "bundle.tar.gz") + params.pubKey = "secret" + + err := dobuild(params, []string{root}) + if err == nil { + t.Fatal("expected error but got nil") + } + + exp := "enable bundle mode (ie. --bundle) to verify or sign bundle files or directories" + if err.Error() != exp { + t.Fatalf("expected error message %v but got %v", exp, err.Error()) + } + }) +} + +func TestBuildVerificationConfigError(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("cannot be run as root") + } + + files := map[string]string{ + "public.pem": "foo", + } + + test.WithTempFS(files, func(rootDir string) { + // simulate error while reading file + err := os.Chmod(filepath.Join(rootDir, "public.pem"), 0111) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + _, err = buildVerificationConfig(filepath.Join(rootDir, "public.pem"), "default", "", "", nil) + if err == nil { + t.Fatal("Expected error but got nil") + } + }) +} + +func TestBuildSigningConfigError(t *testing.T) { + tests := []struct { + note string + key, plugin, claimsFile string + expErr bool + }{ + { + note: "key+plugin+claimsFile unset", + }, + { + note: "key+claimsFile unset", + plugin: "plugin", + expErr: true, + }, + { + note: "key+plugin unset", + claimsFile: "claims", + expErr: true, + }, + } + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + _, err := buildSigningConfig(tc.key, defaultTokenSigningAlg, tc.claimsFile, tc.plugin) + switch { + case tc.expErr && err == nil: + t.Fatal("Expected error but got nil") + case !tc.expErr && err != nil: + t.Fatalf("Expected no error but got %v", err) + } + }) + } +} + +func TestBuildPlanWithPruneUnused(t *testing.T) { + + files := map[string]string{ + "test.rego": ` + package test + + p contains 1 + + f(x) if { p[x] } + `, + } + + test.WithTempFS(files, func(root string) { + params := newBuildParams() + if err := params.target.Set("plan"); err != nil { + t.Fatal(err) + } + params.pruneUnused = true + params.entrypoints.v = []string{"test"} + params.outputFile = path.Join(root, "bundle.tar.gz") + + err := dobuild(params, []string{root}) + if err != nil { + t.Fatal(err) + } + + _, err = loader.NewFileLoader().AsBundle(params.outputFile) + if err != nil { + t.Fatal(err) + } + + // Check that manifest is not written given no input manifest and no other flags + f, err := os.Open(params.outputFile) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + gr, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + + tr := tar.NewReader(gr) + + found := false // for plan.json + + for { + f, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + switch { + case f.Name == "/plan.json": + found = true + case f.Name == "/.manifest" || f.Name == "/data.json" || strings.HasSuffix(f.Name, "/test.rego"): // expected + default: + t.Errorf("unexpected file: %s", f.Name) + } + } + if !found { + t.Error("plan.json not found") + } + }) +} + +func TestBuildPlanProtoFormat(t *testing.T) { + files := map[string]string{ + "test.rego": ` + package test + default p := false + p if input.user == "alice" + `, + } + + test.WithTempFS(files, func(root string) { + params := newBuildParams() + if err := params.target.Set("plan"); err != nil { + t.Fatal(err) + } + if err := params.planFormat.Set("proto"); err != nil { + t.Fatal(err) + } + params.entrypoints.v = []string{"test"} + params.outputFile = path.Join(root, "bundle.tar.gz") + + if err := dobuild(params, []string{root}); err != nil { + t.Fatal(err) + } + + f, err := os.Open(params.outputFile) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + gr, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + tr := tar.NewReader(gr) + + var foundPlan, foundManifest bool + var sawJSONPlan, sawJSONManifest bool + + for { + h, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + switch h.Name { + case "/plan.pb": + foundPlan = true + case "/.manifest.pb": + foundManifest = true + case "/plan.json": + sawJSONPlan = true + case "/.manifest": + sawJSONManifest = true + } + } + + if !foundPlan { + t.Error("plan.pb not found in bundle") + } + if !foundManifest { + t.Error("/.manifest.pb not found in bundle") + } + if sawJSONPlan { + t.Error("plan.json should not be present when --format=proto") + } + if sawJSONManifest { + t.Error("/.manifest should not be present when --format=proto") + } + + loaded, err := loader.NewFileLoader().AsBundle(params.outputFile) + if err != nil { + t.Fatalf("load proto bundle: %v", err) + } + if len(loaded.PlanModules) != 1 { + t.Fatalf("expected 1 plan module, got %d", len(loaded.PlanModules)) + } + if filepath.Base(loaded.PlanModules[0].Path) != "plan.pb" { + t.Errorf("plan module path should end in plan.pb, got %q", loaded.PlanModules[0].Path) + } + if loaded.Manifest.Empty() { + t.Error("manifest should not be empty after loading proto bundle") + } + }) +} + +func TestBuildFormatRequiresPlanTarget(t *testing.T) { + files := map[string]string{ + "test.rego": `package test`, + } + test.WithTempFS(files, func(root string) { + params := newBuildParams() + if err := params.planFormat.Set("proto"); err != nil { + t.Fatal(err) + } + params.outputFile = path.Join(root, "bundle.tar.gz") + + err := dobuild(params, []string{root}) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "plan") { + t.Fatalf("error should mention plan target, got: %v", err) + } + }) +} + +func TestBuildPlanWithPrintStatements(t *testing.T) { + + files := map[string]string{ + "test.rego": ` + package test + + p if { print("hello") } + `, + } + + test.WithTempFS(files, func(root string) { + params := newBuildParams() + if err := params.target.Set("plan"); err != nil { + t.Fatal(err) + } + params.entrypoints.v = []string{"test"} + params.outputFile = path.Join(root, "bundle.tar.gz") + + err := dobuild(params, []string{root}) + if err != nil { + t.Fatal(err) + } + + _, err = loader.NewFileLoader().AsBundle(params.outputFile) + if err != nil { + t.Fatal(err) + } + + f, err := os.Open(params.outputFile) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + gr, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + + tr := tar.NewReader(gr) + var found bool + + for { + f, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + if f.Name == "/plan.json" { + found = true + plan, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(plan), "internal.print") { + t.Error("expected plan.json to contain reference to internal.print built-in function") + } + } + } + + if !found { + t.Error("plan.json not found") + } + }) +} + +func TestBuildPlanWithRegoEntrypointAnnotations(t *testing.T) { + + tests := []struct { + note string + files map[string]string + err error + v0Compatible bool + }{ + { + note: "annotated entrypoint", + files: map[string]string{ + "test.rego": ` +# METADATA +# entrypoint: true +package test + +p contains 1 + +f(x) if { p[x] } + `, + }, + err: nil, + }, + { + note: "set generation with annotated entrypoint (v0)", + v0Compatible: true, + files: map[string]string{ + "test.rego": ` +package test + +# METADATA +# entrypoint: true +p[x] { + {"a", "b"}[x] +} + `, + }, + err: nil, + }, + { + note: "set generation with annotated entrypoint (contains if)", + files: map[string]string{ + "test.rego": ` +package test + +# METADATA +# entrypoint: true +p contains x if { + {"a", "b"}[x] +} + `, + }, + err: nil, + }, + { + note: "object generation with annotated entrypoint (v0)", + v0Compatible: true, + files: map[string]string{ + "test.rego": ` +package test + +# METADATA +# entrypoint: true +p[i] := x { + x := ["a", "b"][i] +} + `, + }, + err: nil, + }, + { + note: "object generation with annotated entrypoint (if)", + files: map[string]string{ + "test.rego": ` +package test + +# METADATA +# entrypoint: true +p[i] if { + {"a", "b"}[i] +} + `, + }, + err: nil, + }, + { + note: "dots in head with annotated entrypoint", + files: map[string]string{ + "test.rego": ` +package test + +# METADATA +# entrypoint: true +p.a.b if { + true +} + `, + }, + err: nil, + }, + { + note: "dots in head object generation with annotated entrypoint", + files: map[string]string{ + "test.rego": ` +package test + +# METADATA +# entrypoint: true +p.a.b[i] := x if { + x := ["a", "b"][i] +} + `, + }, + err: nil, + }, + { + note: "no annotated entrypoint", + files: map[string]string{ + "test.rego": ` +package test + +p contains 1 + +f(x) if { p[x] } +`, + }, + err: errors.New("plan compilation requires at least one entrypoint"), + }, + } + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + test.WithTempFS(tc.files, func(root string) { + params := newBuildParams() + if err := params.target.Set("plan"); err != nil { + t.Fatal(err) + } + params.pruneUnused = true + params.outputFile = path.Join(root, "bundle.tar.gz") + params.v0Compatible = tc.v0Compatible + + // Build should fail if entrypoint is not discovered from annotations. + err := dobuild(params, []string{root}) + if err != nil { + if tc.err == nil || tc.err.Error() != err.Error() { + t.Fatal(err) + } + return // Bail out if this was an expected test failure. + } + + // Attempt to load up the built bundle. + _, err = loader.NewFileLoader().AsBundle(params.outputFile) + if err != nil { + t.Fatal(err) + } + }) + }) + } +} + +func TestBuildWasmWithAnnotations(t *testing.T) { + tests := []struct { + note string + files map[string]string + entrypoints []string + manifest string + }{ + { + note: "last rule is annotated entrypoint", + files: map[string]string{ + "test.rego": ` +package test + +# METADATA +# title: P1 +p1 := 1 + +# METADATA +# title: P2 +# entrypoint: true +p2 := 2 +`, + }, + manifest: ` +{ + "revision":"", + "rego_version": %REGO_VERSION%, + "roots":[""], + "wasm":[{ + "entrypoint":"test/p2", + "module":"/policy.wasm", + "annotations":[{ + "scope":"document", + "title":"P2", + "entrypoint":true + }] + }] +} +`, + }, + { + note: "last rule is (not annotated) entrypoint", + files: map[string]string{ + "test.rego": ` +package test + +# METADATA +# title: P1 +p1 := 1 + +# METADATA +# title: P2 +p2 := 2 +`, + }, + entrypoints: []string{"test/p2"}, + manifest: ` +{ + "revision":"", + "rego_version": %REGO_VERSION%, + "roots":[""], + "wasm":[{ + "entrypoint":"test/p2", + "module":"/policy.wasm", + "annotations":[{ + "scope":"rule", + "title":"P2" + }] + }] +} +`, + }, + { + note: "rules in multiple files are entrypoints", + files: map[string]string{ + "test1.rego": ` +package test + +# METADATA +# title: P1 +p1 := 1 + +# METADATA +# title: P2 +# entrypoint: true +p2 := 2 +`, + "test2.rego": ` +package test + +# METADATA +# title: P3 +p3 := 3 + +# METADATA +# title: P4 +p4 := 4 +`, + "test3.rego": ` +package test.foo + +# METADATA +# title: BAR +# entrypoint: true +bar := "baz" +`, + }, + entrypoints: []string{"test/p3"}, + manifest: ` +{ + "revision":"", + "rego_version": %REGO_VERSION%, + "roots":[""], + "wasm":[{ + "entrypoint":"test/p3", + "annotations":[{"scope":"rule","title":"P3"}], + "module":"/policy.wasm" + },{ + "entrypoint":"test/foo/bar", + "module":"/policy.wasm", + "annotations":[{ + "scope":"document", + "title":"BAR", + "entrypoint":true + }] + },{ + "entrypoint":"test/p2", + "module":"/policy.wasm", + "annotations":[{ + "scope":"document", + "title":"P2", + "entrypoint":true + }] + }] +} +`, + }, + { + note: "rule with multiple metadata blocks", + files: map[string]string{ + "test.rego": ` +package test + +# METADATA +# title: P doc +# scope: document +# entrypoint: true + +# METADATA +# title: P +p := 1 +`, + }, + manifest: ` +{ + "revision":"", + "rego_version": %REGO_VERSION%, + "roots":[""], + "wasm":[{ + "entrypoint":"test/p", + "module":"/policy.wasm", + "annotations":[{ + "scope":"document", + "title":"P doc", + "entrypoint":true + },{ + "scope":"rule", + "title":"P" + }] + }] +} +`, + }, + + // Package annotations are not injected into manifest, as package definition is always retained in Rego source. + { + note: "package is annotated entrypoint", + files: map[string]string{ + "test.rego": ` +# METADATA +# title: PKG +# entrypoint: true +package test + +# METADATA +# title: P1 +p1 := 1 + +# METADATA +# title: P2 +p2 := 2 +`, + }, + manifest: ` +{ + "revision":"", + "rego_version": %REGO_VERSION%, + "roots":[""], + "wasm":[{ + "entrypoint":"test", + "module":"/policy.wasm" + }] +} +`, + }, + { + note: "package is (not annotated) entrypoint", + files: map[string]string{ + "test.rego": ` +package test + +# METADATA +# title: P1 +p1 := 1 + +# METADATA +# title: P2 +p2 := 2 +`, + }, + entrypoints: []string{"test"}, + manifest: ` +{ + "revision":"", + "rego_version": %REGO_VERSION%, + "roots":[""], + "wasm":[{ + "entrypoint":"test", + "module":"/policy.wasm" + }] +} +`, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + test.WithTempFS(tc.files, func(root string) { + params := newBuildParams() + if err := params.target.Set("wasm"); err != nil { + t.Fatal(err) + } + params.pruneUnused = true + params.outputFile = path.Join(root, "bundle.tar.gz") + params.entrypoints.v = tc.entrypoints + + // Build should fail if entrypoint is not discovered from annotations. + err := dobuild(params, []string{root}) + if err != nil { + t.Fatal(err) + } + + _, err = loader.NewFileLoader().AsBundle(params.outputFile) + if err != nil { + t.Fatal(err) + } + + // Check that manifest has expected content + f, err := os.Open(params.outputFile) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + gr, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + + tr := tar.NewReader(gr) + + expManifest := strings.ReplaceAll(tc.manifest, "%REGO_VERSION%", + strconv.Itoa(ast.DefaultRegoVersion.Int())) + + found := false + for { + f, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + if f.Name == "/.manifest" { + found = true + data, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + manifest := util.MustUnmarshalJSON(data) + if !reflect.DeepEqual(manifest, util.MustUnmarshalJSON([]byte(expManifest))) { + t.Fatalf("expected manifest\n\n%v\n\nbut got\n\n%v", expManifest, string(util.MustMarshalJSON(manifest))) + } + break + } + } + + if !found { + t.Fatal("no manifest found in bundle") + } + }) + }) + } +} + +func TestBuildBundleModeIgnoreFlag(t *testing.T) { + + files := map[string]string{ + "/a/b/d/data.json": `{"e": "f"}`, + "/policy.rego": "package foo\n p = 1", + "/policy_test.rego": "package foo\n test_p { p }", + "/roles/policy.rego": "package bar\n p = 1", + "/roles/policy_test.rego": "package bar\n test_p { p }", + "/deeper/dir/path/than/others/policy.rego": "package baz\n p = 1", + "/deeper/dir/path/than/others/policy_test.rego": "package baz\n test_p { p }", + } + + test.WithTempFS(files, func(root string) { + params := newBuildParams() + params.outputFile = path.Join(root, "bundle.tar.gz") + params.bundleMode = true + params.ignore = []string{"*_test.rego"} + + err := dobuild(params, []string{root}) + if err != nil { + t.Fatal(err) + } + + _, err = loader.NewFileLoader().AsBundle(params.outputFile) + if err != nil { + t.Fatal(err) + } + + // Check that test files are not included in the output bundle + f, err := os.Open(params.outputFile) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + gr, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + + tr := tar.NewReader(gr) + + files := []string{} + + for { + f, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + + files = append(files, filepath.Base(f.Name)) + } + + // We additionally expect a manifest file + expected := 5 + if len(files) != expected { + t.Fatalf("expected %v files but got %v", expected, len(files)) + } + }) +} + +func TestBuildBundleModeWithManifestRegoVersion(t *testing.T) { + tests := []struct { + note string + roots []string + files map[string]string + expManifest string + expErrs []string + v0Compatible bool + v1Compatible bool + capabilities *ast.Capabilities + }{ + { + note: "v0 bundle rego-version", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "test.rego": `package test + +p[42] { + input.x == 1 +}`, + }, + expManifest: `{"revision":"","roots":[""],"rego_version":0}`, + }, + { + note: "v1 bundle rego-version", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "test.rego": `package test + +p contains 42 if { + input.x == 1 +}`, + }, + expManifest: `{"revision":"","roots":[""],"rego_version":1}`, + }, + { + note: "v0 bundle rego-version, v1 per-file override", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "*/test2.rego": 1 + } +}`, + "test1.rego": `package test + +p[1] { + input.x == 1 +}`, + "test2.rego": `package test + +p contains 2 if { + input.x == 1 +}`, + }, + expManifest: `{"revision":"","roots":[""],"rego_version":0,"file_rego_versions":{"%ROOT%/test2.rego":1}}`, + }, + { + note: "v0 bundle rego-version, v1 per-file override, missing v1 keywords in v1 file", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "*/test2.rego": 1 + } +}`, + "test1.rego": `package test + +p[1] { + input.x == 1 +}`, + "test2.rego": `package test + +p[2] { + input.x == 1 +}`, + }, + expErrs: []string{ + "rego_parse_error: `if` keyword is required before rule body", + "rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v0 bundle rego-version, v1 per-file override, v1 keywords but no v1 imports in v0 file", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "*/test2.rego": 1 + } +}`, + "test1.rego": `package test + +p contains 1 if { + input.x == 1 +}`, + "test2.rego": `package test + +p contains 2 if { + input.x == 1 +}`, + }, + expErrs: []string{ + "rego_parse_error: var cannot be used for rule name", + "rego_parse_error: number cannot be used for rule name", + }, + }, + { + note: "multiple bundles with different rego-versions, v0-compatible", + v0Compatible: true, + roots: []string{"bundle1", "bundle2"}, + files: map[string]string{ + "bundle1/.manifest": `{ + "roots": ["test1"], + "rego_version": 0, + "file_rego_versions": { + "*/test2.rego": 1 + } +}`, + "bundle1/test1.rego": `package test1 +p[1] { + input.x == 1 +}`, + "bundle1/test2.rego": `package test1 +p contains 2 if { + input.x == 1 +}`, + "bundle2/.manifest": `{ + "roots": ["test2"], + "rego_version": 1, + "file_rego_versions": { + "*/test4.rego": 0 + } +}`, + "bundle2/test3.rego": `package test2 +p contains 3 if { + input.x == 1 +}`, + "bundle2/test4.rego": `package test2 +p[4] { + input.x == 1 +}`, + }, + expManifest: `{"revision":"","roots":["test1","test2"],"rego_version":0,"file_rego_versions":{"%ROOT%/bundle1/test2.rego":1,"%ROOT%/bundle2/test3.rego":1}}`, + }, + { + note: "multiple bundles with different rego-versions, v0-compatible, no rego_v1 capabilities feature", + v0Compatible: true, + roots: []string{"bundle1", "bundle2"}, + files: map[string]string{ + "bundle1/.manifest": `{ + "roots": ["test1"], + "rego_version": 0, + "file_rego_versions": { + "*/test2.rego": 1 + } +}`, + "bundle1/test1.rego": `package test1 +p[1] { + input.x == 1 +}`, + "bundle1/test2.rego": `package test1 +p contains 2 if { + input.x == 1 +}`, + "bundle2/.manifest": `{ + "roots": ["test2"], + "rego_version": 1, + "file_rego_versions": { + "*/test4.rego": 0 + } +}`, + "bundle2/test3.rego": `package test2 +p contains 3 if { + input.x == 1 +}`, + "bundle2/test4.rego": `package test2 +p[4] { + input.x == 1 +}`, + }, + capabilities: capsWithoutFeat(ast.RegoV0, ast.FeatureRegoV1), + expErrs: []string{ + // capabilities doesn't include rego_v1 feature, which must be respected + "rego_parse_error: illegal capabilities: rego_v1 feature required for parsing v1 Rego", + }, + }, + { + note: "multiple bundles with different rego-versions, v0-compatible, rego_v1 capabilities feature", + v0Compatible: true, + roots: []string{"bundle1", "bundle2"}, + files: map[string]string{ + "bundle1/.manifest": `{ + "roots": ["test1"], + "rego_version": 0, + "file_rego_versions": { + "*/test2.rego": 1 + } +}`, + "bundle1/test1.rego": `package test1 +p[1] { + input.x == 1 +}`, + "bundle1/test2.rego": `package test1 +p contains 2 if { + input.x == 1 +}`, + "bundle2/.manifest": `{ + "roots": ["test2"], + "rego_version": 1, + "file_rego_versions": { + "*/test4.rego": 0 + } +}`, + "bundle2/test3.rego": `package test2 +p contains 3 if { + input.x == 1 +}`, + "bundle2/test4.rego": `package test2 +p[4] { + input.x == 1 +}`, + "capabilities.json": func() string { + caps := ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)) + caps.Features = append(caps.Features, ast.FeatureRegoV1) + bs, err := json.Marshal(caps) + if err != nil { + t.Fatal(err) + } + return string(bs) + }(), + }, + expManifest: `{"revision":"","roots":["test1","test2"],"rego_version":0,"file_rego_versions":{"%ROOT%/bundle1/test2.rego":1,"%ROOT%/bundle2/test3.rego":1}}`, + }, + { + note: "multiple bundles with different rego-versions, v1-compatible", + v1Compatible: true, + roots: []string{"bundle1", "bundle2"}, + files: map[string]string{ + "bundle1/.manifest": `{ + "roots": ["test1"], + "rego_version": 0, + "file_rego_versions": { + "*/test2.rego": 1 + } +}`, + "bundle1/test1.rego": `package test1 +p[1] { + input.x == 1 +}`, + "bundle1/test2.rego": `package test1 +p contains 2 if { + input.x == 1 +}`, + "bundle2/.manifest": `{ + "roots": ["test2"], + "rego_version": 1, + "file_rego_versions": { + "*/test4.rego": 0 + } +}`, + "bundle2/test3.rego": `package test2 +p contains 3 if { + input.x == 1 +}`, + "bundle2/test4.rego": `package test2 +p[4] { + input.x == 1 +}`, + }, + expManifest: `{"revision":"","roots":["test1","test2"],"rego_version":1,"file_rego_versions":{"%ROOT%/bundle1/test1.rego":0,"%ROOT%/bundle2/test4.rego":0}}`, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + test.WithTempFS(tc.files, func(root string) { + params := newBuildParams() + params.outputFile = path.Join(root, "bundle.tar.gz") + params.bundleMode = true + params.v0Compatible = tc.v0Compatible + params.v1Compatible = tc.v1Compatible + + if tc.capabilities != nil { + params.capabilities = newCapabilitiesFlag() + params.capabilities.C = tc.capabilities + } + + if _, ok := tc.files["capabilities.json"]; ok { + _ = params.capabilities.Set(path.Join(root, "capabilities.json")) + } + + var roots []string + if len(tc.roots) == 0 { + roots = []string{root} + } else { + for _, r := range tc.roots { + roots = append(roots, path.Join(root, r)) + } + } + err := dobuild(params, roots) + if tc.expErrs != nil { + if err == nil { + t.Fatal("expected error but got none") + } + for _, expErr := range tc.expErrs { + if !strings.Contains(err.Error(), expErr) { + t.Fatalf("expected error:\n\n%q\n\nbut got:\n\n%v", expErr, err) + } + } + } else { + if err != nil { + t.Fatal(err) + } + + _, err = loader.NewFileLoader().AsBundle(params.outputFile) + if err != nil { + t.Fatal(err) + } + + f, err := os.Open(params.outputFile) + if err != nil { + t.Fatal(err) + } + defer func() { + _ = f.Close() + }() + + gr, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + + tr := tar.NewReader(gr) + + for { + f, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + + if f.Name == "/.manifest" { + b, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + expManifest := strings.ReplaceAll(tc.expManifest, "%ROOT%", root) + if !strings.Contains(string(b), expManifest) { + t.Fatalf("expected manifest:\n\n%v\n\nbut got:\n\n%v", expManifest, string(b)) + } + } + } + } + }) + }) + } +} + +func capsWithoutFeat(regoVersion ast.RegoVersion, feat ...string) *ast.Capabilities { + caps := ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(regoVersion)) + + feats := make([]string, 0, len(caps.Features)) + for _, f := range caps.Features { + skip := slices.Contains(feat, f) + if !skip { + feats = append(feats, f) + } + } + caps.Features = feats + + return caps +} + +func TestBuildBundleFromOtherBundles(t *testing.T) { + type bundleInfo map[string]string + + tests := []struct { + note string + v0Compatible bool + v1Compatible bool + capabilities *ast.Capabilities + bundles map[string]bundleInfo + expBundle bundleInfo + expErrs []string + }{ + { + note: "single bundle", + bundles: map[string]bundleInfo{ + "bundle.tar.gz": { + "policy.rego": `package test + +p := input.x == 1 +`, + }, + }, + expBundle: bundleInfo{ + "/data.json": `{} +`, + "/.manifest": `{"revision":"","roots":[""],"rego_version":%DEFAULT_REGO_VERSION%} +`, + "%ROOT%/bundle.tar.gz/policy.rego": `package test + +p := input.x == 1 +`, + }, + }, + { + note: "single bundle, --v1-compatible", + v1Compatible: true, + bundles: map[string]bundleInfo{ + "bundle.tar.gz": { + "policy.rego": `package test +p if { + input.x == 1 +}`, + }, + }, + expBundle: bundleInfo{ + "/data.json": `{} +`, + "/.manifest": `{"revision":"","roots":[""],"rego_version":1} +`, + "%ROOT%/bundle.tar.gz/policy.rego": `package test + +p if { + input.x == 1 +} +`, + }, + }, + { + note: "single v0 bundle", + bundles: map[string]bundleInfo{ + "bundle.tar.gz": { + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +p { + input.x == 1 +}`, + }, + }, + expBundle: bundleInfo{ + "/data.json": `{} +`, + "/.manifest": `{"revision":"","roots":[""],"rego_version":0} +`, + "%ROOT%/bundle.tar.gz/policy.rego": `package test + +p { + input.x == 1 +} +`, + }, + }, + { + note: "single v0 bundle, --v1-compatible", + v1Compatible: true, + bundles: map[string]bundleInfo{ + "bundle.tar.gz": { + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +p { + input.x == 1 +}`, + }, + }, + // We don't expect parse/compile errors, as the bundle rego-version is 0, which overrides the --v1-compatible flag. + expBundle: bundleInfo{ + "/data.json": `{} +`, + "/.manifest": `{"revision":"","roots":[""],"rego_version":0} +`, + "%ROOT%/bundle.tar.gz/policy.rego": `package test + +p { + input.x == 1 +} +`, + }, + }, + { + note: "single v1 bundle, --v0-compatible", + v1Compatible: true, + bundles: map[string]bundleInfo{ + "bundle.tar.gz": { + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +p if { + input.x == 1 +}`, + }, + }, + // We don't expect parse/compile errors, as the bundle rego-version is 0, which overrides the --v1-compatible flag. + expBundle: bundleInfo{ + "/data.json": `{} +`, + "/.manifest": `{"revision":"","roots":[""],"rego_version":1} +`, + "%ROOT%/bundle.tar.gz/policy.rego": `package test + +p if { + input.x == 1 +} +`, + }, + }, + { + note: "single v0 bundle, v1 per-file override", + bundles: map[string]bundleInfo{ + "bundle.tar.gz": { + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "/policy_1.rego": 1 + } +}`, + "policy_0.rego": `package test +p { + input.x == 1 +}`, + "policy_1.rego": `package test +q contains 1 if { + input.x == 1 +}`, + }, + }, + expBundle: bundleInfo{ + "/data.json": `{} +`, + "/.manifest": `{"revision":"","roots":[""],"rego_version":0,"file_rego_versions":{"%ROOT%/bundle.tar.gz/policy_1.rego":1}} +`, + "%ROOT%/bundle.tar.gz/policy_0.rego": `package test + +p { + input.x == 1 +} +`, + "%ROOT%/bundle.tar.gz/policy_1.rego": `package test + +q contains 1 if { + input.x == 1 +} +`, + }, + }, + { + note: "single v0 bundle, v1 per-file override, --v1-compatible", + v1Compatible: true, + bundles: map[string]bundleInfo{ + "bundle.tar.gz": { + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "/policy_1.rego": 1 + } +}`, + "policy_0.rego": `package test +p { + input.x == 1 +}`, + "policy_1.rego": `package test +q contains 1 if { + input.x == 1 +}`, + }, + }, + expBundle: bundleInfo{ + "/data.json": `{} +`, + "/.manifest": `{"revision":"","roots":[""],"rego_version":0,"file_rego_versions":{"%ROOT%/bundle.tar.gz/policy_1.rego":1}} +`, + "%ROOT%/bundle.tar.gz/policy_0.rego": `package test + +p { + input.x == 1 +} +`, + "%ROOT%/bundle.tar.gz/policy_1.rego": `package test + +q contains 1 if { + input.x == 1 +} +`, + }, + }, + { + note: "single v1 bundle, v0 per-file override", + bundles: map[string]bundleInfo{ + "bundle.tar.gz": { + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/policy_0.rego": 0 + } +}`, + "policy_0.rego": `package test +p { + input.x == 1 +}`, + "policy_1.rego": `package test +q contains 1 if { + input.x == 1 +}`, + }, + }, + expBundle: bundleInfo{ + "/data.json": `{} +`, + "/.manifest": `{"revision":"","roots":[""],"rego_version":1,"file_rego_versions":{"%ROOT%/bundle.tar.gz/policy_0.rego":0}} +`, + "%ROOT%/bundle.tar.gz/policy_0.rego": `package test + +p { + input.x == 1 +} +`, + "%ROOT%/bundle.tar.gz/policy_1.rego": `package test + +q contains 1 if { + input.x == 1 +} +`, + }, + }, + { + note: "single v1 bundle, v0 per-file override, --v0-compatible", + v0Compatible: true, + bundles: map[string]bundleInfo{ + "bundle.tar.gz": { + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/policy_0.rego": 0 + } +}`, + "policy_0.rego": `package test +p { + input.x == 1 +}`, + "policy_1.rego": `package test +q contains 1 if { + input.x == 1 +}`, + }, + }, + expBundle: bundleInfo{ + "/data.json": `{} +`, + "/.manifest": `{"revision":"","roots":[""],"rego_version":1,"file_rego_versions":{"%ROOT%/bundle.tar.gz/policy_0.rego":0}} +`, + "%ROOT%/bundle.tar.gz/policy_0.rego": `package test + +p { + input.x == 1 +} +`, + "%ROOT%/bundle.tar.gz/policy_1.rego": `package test + +q contains 1 if { + input.x == 1 +} +`, + }, + }, + { + note: "single v1 bundle, v0 per-file override, --v0-compatible, no rego_v1 capabilities feature", + v0Compatible: true, + capabilities: capsWithoutFeat(ast.RegoV0, ast.FeatureRegoV1), + bundles: map[string]bundleInfo{ + "bundle.tar.gz": { + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/policy_0.rego": 0 + } +}`, + "policy_0.rego": `package test +p { + input.x == 1 +}`, + "policy_1.rego": `package test +q contains 1 if { + input.x == 1 +}`, + }, + }, + expErrs: []string{ + // capabilities doesn't include rego_v1 feature, which must be respected + "rego_parse_error: illegal capabilities: rego_v1 feature required for parsing v1 Rego", + }, + }, + { + note: "v0 bundle + v1 bundle, --v0-compatible", + v0Compatible: true, + bundles: map[string]bundleInfo{ + "bundle_v0.tar.gz": { + ".manifest": `{"roots": ["test1"], "rego_version": 0}`, + "policy.rego": `package test1 +p { + input.x == 1 +}`, + }, + "bundle_v1.tar.gz": { + ".manifest": `{"roots": ["test2"], "rego_version": 1}`, + "policy.rego": `package test2 +q contains 1 if { + input.x == 1 +}`, + }, + }, + expBundle: bundleInfo{ + "/data.json": `{} +`, + "/.manifest": `{"revision":"","roots":["test1","test2"],"rego_version":0,"file_rego_versions":{"%ROOT%/bundle_v1.tar.gz/policy.rego":1}} +`, + "%ROOT%/bundle_v0.tar.gz/policy.rego": `package test1 + +p { + input.x == 1 +} +`, + "%ROOT%/bundle_v1.tar.gz/policy.rego": `package test2 + +q contains 1 if { + input.x == 1 +} +`, + }, + }, + { + note: "v0 bundle + v1 bundle, --v0-compatible, no rego_v1 capabilities feature", + v0Compatible: true, + capabilities: capsWithoutFeat(ast.RegoV0, ast.FeatureRegoV1), + bundles: map[string]bundleInfo{ + "bundle_v0.tar.gz": { + ".manifest": `{"roots": ["test1"], "rego_version": 0}`, + "policy.rego": `package test1 +p { + input.x == 1 +}`, + }, + "bundle_v1.tar.gz": { + ".manifest": `{"roots": ["test2"], "rego_version": 1}`, + "policy.rego": `package test2 +q contains 1 if { + input.x == 1 +}`, + }, + }, + expErrs: []string{ + // capabilities inferred from --v0-compatible doesn't include rego_v1 feature, which must be respected + "rego_parse_error: illegal capabilities: rego_v1 feature required for parsing v1 Rego", + }, + }, + { + note: "v0 bundle + v1 bundle, --v1-compatible", + v1Compatible: true, + bundles: map[string]bundleInfo{ + "bundle_v0.tar.gz": { + ".manifest": `{"roots": ["test1"], "rego_version": 0}`, + "policy.rego": `package test1 +p { + input.x == 1 +}`, + }, + "bundle_v1.tar.gz": { + ".manifest": `{"roots": ["test2"], "rego_version": 1}`, + "policy.rego": `package test2 +q contains 1 if { + input.x == 1 +}`, + }, + }, + expBundle: bundleInfo{ + "/data.json": `{} +`, + // We get a v1 bundle with a v0 per-file override + "/.manifest": `{"revision":"","roots":["test1","test2"],"rego_version":1,"file_rego_versions":{"%ROOT%/bundle_v0.tar.gz/policy.rego":0}} +`, + "%ROOT%/bundle_v0.tar.gz/policy.rego": `package test1 + +p { + input.x == 1 +} +`, + "%ROOT%/bundle_v1.tar.gz/policy.rego": `package test2 + +q contains 1 if { + input.x == 1 +} +`, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + root := t.TempDir() + var roots []string + for name, files := range tc.bundles { + p := filepath.Join(root, name) + roots = append(roots, p) + filePairs := make([][2]string, 0, len(files)) + for k, v := range files { + filePairs = append(filePairs, [2]string{k, v}) + } + buf := archive.MustWriteTarGz(filePairs) + bf, err := os.Create(p) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + _, err = bf.Write(buf.Bytes()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + } + + params := newBuildParams() + params.outputFile = path.Join(root, "bundle.tar.gz") + params.bundleMode = true + params.v0Compatible = tc.v0Compatible + params.v1Compatible = tc.v1Compatible + + if tc.capabilities != nil { + params.capabilities.C = tc.capabilities + } + + err := dobuild(params, roots) + if tc.expErrs != nil { + if err == nil { + t.Fatal("expected error but got none") + } + for _, expErr := range tc.expErrs { + if !strings.Contains(err.Error(), expErr) { + t.Fatalf("expected error:\n\n%q\n\nbut got:\n\n%v", expErr, err) + } + } + } else { + if err != nil { + t.Fatal(err) + } + + _, err = loader.NewFileLoader().AsBundle(params.outputFile) + if err != nil { + t.Fatal(err) + } + + f, err := os.Open(params.outputFile) + if err != nil { + t.Fatal(err) + } + defer func() { + _ = f.Close() + }() + + gr, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + + tr := tar.NewReader(gr) + + for { + f, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + + found := false + for expName, expVal := range tc.expBundle { + expName = strings.ReplaceAll(expName, "%ROOT%", root) + if f.Name == expName { + found = true + b, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + expVal = strings.ReplaceAll(expVal, "%ROOT%", root) + expVal = strings.ReplaceAll(expVal, "%DEFAULT_REGO_VERSION%", + strconv.Itoa(ast.DefaultRegoVersion.Int())) + if string(b) != expVal { + t.Fatalf("expected %v:\n\n%v\n\nbut got:\n\n%v", expName, expVal, string(b)) + } + break + } + } + if !found { + t.Fatalf("unexpected file in bundle: %v", f.Name) + } + } + } + }) + } +} + +func TestBuild_DefaultRegoVersion(t *testing.T) { + tests := []struct { + note string + files map[string]string + expFiles map[string]string + expErrs []string + }{ + { + note: "v0 module", + files: map[string]string{ + "test.rego": `package test + p[x] { + x := 42 + }`, + }, + expErrs: []string{ + "test.rego:2: rego_parse_error: `if` keyword is required before rule body", + "test.rego:2: rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v1 module", + files: map[string]string{ + "test.rego": `package test + + p contains x if { + x := 42 + }`, + }, + expFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":1} +`, + "test.rego": `package test + +p contains x if { + x := 42 +} +`, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + test.WithTempFS(tc.files, func(root string) { + params := newBuildParams() + params.outputFile = path.Join(root, "bundle.tar.gz") + + err := dobuild(params, []string{root}) + + if len(tc.expErrs) > 0 { + if err == nil { + t.Fatal("expected error but got nil") + } + for _, expErr := range tc.expErrs { + if !strings.Contains(err.Error(), expErr) { + t.Fatalf("expected error:\n\n%v\n\ngot:\n\n%v", expErr, err) + } + } + } else { + if err != nil { + t.Fatal(err) + } + + fl := loader.NewFileLoader() + _, err = fl.AsBundle(params.outputFile) + if err != nil { + t.Fatal(err) + } + + // Check that manifest is not written given no input manifest and no other flags + f, err := os.Open(params.outputFile) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + gr, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + + tr := tar.NewReader(gr) + + foundFiles := map[string]struct{}{} + for { + f, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + foundFiles[path.Base(f.Name)] = struct{}{} + expectedFile := tc.expFiles[path.Base(f.Name)] + if expectedFile != "" { + data, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + actualFile := string(data) + if actualFile != expectedFile { + t.Fatalf("expected file %s to be:\n\n%v\n\ngot:\n\n%v", f.Name, expectedFile, actualFile) + } + } + } + + for expectedFile := range tc.expFiles { + if _, ok := foundFiles[expectedFile]; !ok { + t.Fatalf("expected file %s not found in bundle, got: %v", expectedFile, foundFiles) + } + } + } + }) + }) + } +} + +func TestBuildWithRegoV1Capability(t *testing.T) { + tests := []struct { + note string + v0Compatible bool + capabilities *ast.Capabilities + files map[string]string + expFiles map[string]string + expErrs []string + }{ + { + note: "v0 module, v0-compatible, no capabilities", + v0Compatible: true, + files: map[string]string{ + "test.rego": `package test + p[x] { + x := 42 + }`, + }, + expFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":0} +`, + "test.rego": `package test + +p[x] { + x := 42 +} +`, + }, + }, + { + note: "v0 module, v0-compatible, v0 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + files: map[string]string{ + "test.rego": `package test + p[x] { + x := 42 + }`, + }, + expFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":0} +`, + "test.rego": `package test + +p[x] { + x := 42 +} +`, + }, + }, + { + note: "v0 module, v0-compatible, v1 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + files: map[string]string{ + "test.rego": `package test + p[x] { + x := 42 + }`, + }, + expFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":0} +`, + "test.rego": `package test + +p[x] { + x := 42 +} +`, + }, + }, + + { + note: "v0 module, not v0-compatible, no capabilities", + files: map[string]string{ + "test.rego": `package test + p[x] { + x := 42 + }`, + }, + expErrs: []string{ + "test.rego:2: rego_parse_error: `if` keyword is required before rule body", + "test.rego:2: rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v0 module, not v0-compatible, v0 capabilities", + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + files: map[string]string{ + "test.rego": `package test + p[x] { + x := 42 + }`, + }, + expErrs: []string{ + "test.rego:2: rego_parse_error: `if` keyword is required before rule body", + "test.rego:2: rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v0 module, not v0-compatible, v0 capabilities without rego_v1 feature", + capabilities: capsWithoutFeat(ast.RegoV0, ast.FeatureRegoV1), + files: map[string]string{ + "test.rego": `package test + p[x] { + x := 42 + }`, + }, + expErrs: []string{ + "rego_parse_error: illegal capabilities: rego_v1 feature required for parsing v1 Rego", + }, + }, + { + note: "v0 module, not v0-compatible, v1 capabilities", + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + files: map[string]string{ + "test.rego": `package test + p[x] { + x := 42 + }`, + }, + expErrs: []string{ + "test.rego:2: rego_parse_error: `if` keyword is required before rule body", + "test.rego:2: rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + + { + note: "v1 module, v0-compatible, no capabilities", + v0Compatible: true, + files: map[string]string{ + "test.rego": `package test + + p contains x if { + x := 42 + }`, + }, + expErrs: []string{ + "test.rego:3: rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v1 module, v0-compatible, v0 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + files: map[string]string{ + "test.rego": `package test + + p contains x if { + x := 42 + }`, + }, + expErrs: []string{ + "test.rego:3: rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v1 module, v0-compatible, v1 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + files: map[string]string{ + "test.rego": `package test + + p contains x if { + x := 42 + }`, + }, + expErrs: []string{ + "test.rego:3: rego_parse_error: var cannot be used for rule name", + }, + }, + + { + note: "v1 module, not v0-compatible, no capabilities", + files: map[string]string{ + "test.rego": `package test + + p contains x if { + x := 42 + }`, + }, + expFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":1} +`, + "test.rego": `package test + +p contains x if { + x := 42 +} +`, + }, + }, + { + note: "v1 module, not v0-compatible, v0 capabilities", + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + files: map[string]string{ + "test.rego": `package test + + p contains x if { + x := 42 + }`, + }, + }, + { + note: "v1 module, not v0-compatible, v0 capabilities without rego_v1 feature", + capabilities: capsWithoutFeat(ast.RegoV0, ast.FeatureRegoV1), + files: map[string]string{ + "test.rego": `package test + + p contains x if { + x := 42 + }`, + }, + expErrs: []string{ + "rego_parse_error: illegal capabilities: rego_v1 feature required for parsing v1 Rego", + }, + }, + { + note: "v1 module, not v0-compatible, v1 capabilities", + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + files: map[string]string{ + "test.rego": `package test + + p contains x if { + x := 42 + }`, + }, + expFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":1} +`, + "test.rego": `package test + +p contains x if { + x := 42 +} +`, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + test.WithTempFS(tc.files, func(root string) { + params := newBuildParams() + params.outputFile = path.Join(root, "bundle.tar.gz") + params.v0Compatible = tc.v0Compatible + params.capabilities.C = tc.capabilities + + err := dobuild(params, []string{root}) + + if len(tc.expErrs) > 0 { + if err == nil { + t.Fatal("expected error but got nil") + } + for _, expErr := range tc.expErrs { + if !strings.Contains(err.Error(), expErr) { + t.Fatalf("expected error:\n\n%v\n\ngot:\n\n%v", expErr, err) + } + } + } else { + if err != nil { + t.Fatal(err) + } + + fl := loader.NewFileLoader() + _, err = fl.AsBundle(params.outputFile) + if err != nil { + t.Fatal(err) + } + + // Check that manifest is not written given no input manifest and no other flags + f, err := os.Open(params.outputFile) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + gr, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + + tr := tar.NewReader(gr) + + foundFiles := map[string]struct{}{} + for { + f, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + foundFiles[path.Base(f.Name)] = struct{}{} + expectedFile := tc.expFiles[path.Base(f.Name)] + if expectedFile != "" { + data, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + actualFile := string(data) + if actualFile != expectedFile { + t.Fatalf("expected file %s to be:\n\n%v\n\ngot:\n\n%v", f.Name, expectedFile, actualFile) + } + } + } + + for expectedFile := range tc.expFiles { + if _, ok := foundFiles[expectedFile]; !ok { + t.Fatalf("expected file %s not found in bundle, got: %v", expectedFile, foundFiles) + } + } + } + }) + }) + } +} + +func TestBuildWithCompatibleFlags(t *testing.T) { + tests := []struct { + note string + v0Compatible bool + v1Compatible bool + files map[string]string + expectedFiles map[string]string + expectedErr string + }{ + { + note: "v0 compatibility: policy with no rego.v1 or future.keywords imports", + v0Compatible: true, + files: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + expectedErr: "rego_parse_error", + }, + { + note: "v0 compatibility: policy with rego.v1 imports", + v0Compatible: true, + files: map[string]string{ + "test.rego": `package test + import rego.v1 + allow if { + 1 < 2 + }`, + }, + // Imports are preserved + expectedFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":0} +`, + "test.rego": `package test + +import rego.v1 + +allow if { + 1 < 2 +} +`, + }, + }, + { + note: "v0 compatibility: policy with future.keywords imports", + v0Compatible: true, + files: map[string]string{ + "test.rego": `package test + import future.keywords.if + allow if { + 1 < 2 + }`, + }, + // Imports are preserved + expectedFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":0} +`, + "test.rego": `package test + +import future.keywords.if + +allow if { + 1 < 2 +} +`, + }, + }, + { + note: "v1 compatibility: policy with no rego.v1 or future.keywords imports", + v1Compatible: true, + files: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + // Imports are not added in + expectedFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":1} +`, + "test.rego": `package test + +allow if { + 1 < 2 +} +`, + }, + }, + { + note: "v1 compatibility: policy with rego.v1 import", + v1Compatible: true, + files: map[string]string{ + "test.rego": `package test + import rego.v1 + allow if { + 1 < 2 + }`, + }, + // the rego.v1 import is kept to maximize compatibility surface + expectedFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":1} +`, + "test.rego": `package test + +import rego.v1 + +allow if { + 1 < 2 +} +`, + }, + }, + { + note: "v1 compatibility: policy with future.keywords import", + v1Compatible: true, + files: map[string]string{ + "test.rego": `package test + import future.keywords.if + allow if { + 1 < 2 + }`, + }, + // future.keywords imports are kept to maximize compatibility surface + expectedFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":1} +`, + "test.rego": `package test + +import future.keywords.if + +allow if { + 1 < 2 +} +`, + }, + }, + { + note: "v1 compatibility: policy with rego.v1 and future.keywords imports", + v1Compatible: true, + files: map[string]string{ + "test.rego": `package test + import rego.v1 + import future.keywords.if + allow if { + 1 < 2 + }`, + }, + // future.keywords are dropped as these are covered by rego.v1 + expectedFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":1} +`, + "test.rego": `package test + +import rego.v1 + +allow if { + 1 < 2 +} +`, + }, + }, + { + note: "v1 compatibility: missing keywords", + v1Compatible: true, + files: map[string]string{ + "test.rego": `package test + allow[1] { + 1 < 2 + }`, + }, + expectedErr: "rego_parse_error", + }, + // v0 takes precedence over v1 + { + note: "v0+v1 compatibility: policy with no rego.v1 or future.keywords imports", + v0Compatible: true, + v1Compatible: true, + files: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + expectedErr: "rego_parse_error", + }, + { + note: "v0+v1 compatibility: policy with rego.v1 imports", + v0Compatible: true, + v1Compatible: true, + files: map[string]string{ + "test.rego": `package test + import rego.v1 + allow if { + 1 < 2 + }`, + }, + // Imports are preserved + expectedFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":0} +`, + "test.rego": `package test + +import rego.v1 + +allow if { + 1 < 2 +} +`, + }, + }, + { + note: "v0+v1 compatibility: policy with future.keywords imports", + v0Compatible: true, + v1Compatible: true, + files: map[string]string{ + "test.rego": `package test + import future.keywords.if + allow if { + 1 < 2 + }`, + }, + // Imports are preserved + expectedFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":0} +`, + "test.rego": `package test + +import future.keywords.if + +allow if { + 1 < 2 +} +`, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + test.WithTempFS(tc.files, func(root string) { + params := newBuildParams() + params.outputFile = path.Join(root, "bundle.tar.gz") + params.v0Compatible = tc.v0Compatible + params.v1Compatible = tc.v1Compatible + + err := dobuild(params, []string{root}) + + if tc.expectedErr != "" { + if err == nil { + t.Fatal("expected error but got nil") + } + if !strings.Contains(err.Error(), tc.expectedErr) { + t.Fatalf("expected error %v, got %v", tc.expectedErr, err) + } + } else { + if err != nil { + t.Fatal(err) + } + + fl := loader.NewFileLoader() + if tc.v1Compatible { + fl = fl.WithRegoVersion(ast.RegoV1) + } + _, err = fl.AsBundle(params.outputFile) + if err != nil { + t.Fatal(err) + } + + // Check that manifest is not written given no input manifest and no other flags + f, err := os.Open(params.outputFile) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + gr, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + + tr := tar.NewReader(gr) + + foundFiles := map[string]struct{}{} + for { + f, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + foundFiles[path.Base(f.Name)] = struct{}{} + expectedFile := tc.expectedFiles[path.Base(f.Name)] + if expectedFile != "" { + data, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + actualFile := string(data) + if actualFile != expectedFile { + t.Fatalf("expected file %s to be:\n\n%v\n\ngot:\n\n%v", f.Name, expectedFile, actualFile) + } + } + } + + for expectedFile := range tc.expectedFiles { + if _, ok := foundFiles[expectedFile]; !ok { + t.Fatalf("expected file %s not found in bundle, got: %v", expectedFile, foundFiles) + } + } + } + }) + }) + } +} + +func TestBuildOptimizedLevel2(t *testing.T) { + tests := []struct { + note string + files map[string]string + expectedFiles map[string]string + }{ + { + note: "Parse package path correctly", + files: map[string]string{ + "test.rego": `package demo["test-policy"] + +# METADATA +# entrypoint: true +default allow := false + +allow if { + some value in input +} +`, + }, + expectedFiles: map[string]string{ + "/optimized/demo/test-policy.rego": `package demo["test-policy"] + +default allow := false + +allow if __local2__1 = input[__local1__1] +`, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + test.WithTempFS(tc.files, func(root string) { + params := newBuildParams() + params.outputFile = path.Join(root, "bundle.tar.gz") + params.optimizationLevel = 2 + + err := dobuild(params, []string{root}) + + if err != nil { + t.Fatal(err) + } + + f, err := os.Open(params.outputFile) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + gr, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + + tr := tar.NewReader(gr) + + foundFiles := map[string]struct{}{} + for { + f, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + foundFiles[f.Name] = struct{}{} + expectedFile := tc.expectedFiles[f.Name] + if expectedFile != "" { + data, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + actualFile := string(data) + if actualFile != expectedFile { + t.Fatalf("expected file %s to be:\n\n%v\n\ngot:\n\n%v", f.Name, expectedFile, actualFile) + } + } + } + + for expectedFile := range tc.expectedFiles { + if _, ok := foundFiles[expectedFile]; !ok { + t.Fatalf("expected file %s not found in bundle, got: %v", expectedFile, foundFiles) + } + } + }) + }) + } +} + +func TestBuildOptimizedWithRegoVersion(t *testing.T) { + tests := []struct { + note string + v1Compatible bool + regoV1ImportCapable bool + files map[string]string + expectedFiles map[string]string + }{ + { + note: "v0, no future keywords", + v1Compatible: false, + regoV1ImportCapable: true, + files: map[string]string{ + "test.rego": `package test +# METADATA +# entrypoint: true +p[v] { + v := input.v +} +`, + }, + expectedFiles: map[string]string{ + "/.manifest": `{"revision":"","roots":[""],"rego_version":0} +`, + // rego.v1 import added to optimized support module + "/optimized/test.rego": `package test + +import rego.v1 + +p contains __local0__1 if { + __local0__1 = input.v +} +`, + }, + }, + { + note: "v0, No future keywords, not rego.v1 import capable", + v1Compatible: false, + regoV1ImportCapable: false, + files: map[string]string{ + "test.rego": `package test +# METADATA +# entrypoint: true +p[v] { + v := input.v +} +`, + }, + expectedFiles: map[string]string{ + "/.manifest": `{"revision":"","roots":[""],"rego_version":0} +`, + // rego.v1 import NOT added to optimized support module + "/optimized/test.rego": `package test + +p[__local0__1] { + __local0__1 = input.v +} +`, + }, + }, + { + note: "v1, No imports", + v1Compatible: true, + regoV1ImportCapable: true, + files: map[string]string{ + "test.rego": `package test +# METADATA +# entrypoint: true +p[k] contains v if { + k := "foo" + v := input.v +} +`, + }, + expectedFiles: map[string]string{ + "/.manifest": `{"revision":"","roots":[""],"rego_version":1} +`, + "/optimized/test/p.rego": `package test.p + +foo contains __local1__1 if { + __local1__1 = input.v +} +`, + }, + }, + { + note: "v1, rego.v1 imported", + v1Compatible: true, + regoV1ImportCapable: true, + files: map[string]string{ + "test.rego": `package test +import rego.v1 +# METADATA +# entrypoint: true +p[k] contains v if { + k := "foo" + v := input.v +} +`, + }, + // Note: the rego.v1 import isn't added to the optimized module. + // This is ok, as the bundle was built with the --v1-compatible flag, + // and is tagged with a rego-version to inform the consumer. + expectedFiles: map[string]string{ + "/.manifest": `{"revision":"","roots":[""],"rego_version":1} +`, + "/optimized/test/p.rego": `package test.p + +foo contains __local1__1 if { + __local1__1 = input.v +} +`, + }, + }, + { + note: "v1, future.keywords imported", + v1Compatible: true, + regoV1ImportCapable: true, + files: map[string]string{ + "test.rego": `package test +import future.keywords +# METADATA +# entrypoint: true +p[k] contains v if { + k := "foo" + v := input.v +} +`, + }, + expectedFiles: map[string]string{ + "/.manifest": `{"revision":"","roots":[""],"rego_version":1} +`, + "/optimized/test/p.rego": `package test.p + +foo contains __local1__1 if { + __local1__1 = input.v +} +`, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + test.WithTempFS(tc.files, func(root string) { + params := newBuildParams() + params.outputFile = path.Join(root, "bundle.tar.gz") + params.v0Compatible = !tc.v1Compatible + params.v1Compatible = tc.v1Compatible + params.optimizationLevel = 1 + + if !tc.regoV1ImportCapable { + caps := newCapabilitiesFlag() + caps.C = ast.CapabilitiesForThisVersion() + caps.C.Features = []string{ + ast.FeatureRefHeadStringPrefixes, + ast.FeatureRefHeads, + } + params.capabilities = caps + } + + err := dobuild(params, []string{root}) + + if err != nil { + t.Fatal(err) + } + + f, err := os.Open(params.outputFile) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + gr, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + + tr := tar.NewReader(gr) + + foundFiles := map[string]struct{}{} + for { + f, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + foundFiles[f.Name] = struct{}{} + expectedFile := tc.expectedFiles[f.Name] + if expectedFile != "" { + data, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + actualFile := string(data) + if actualFile != expectedFile { + t.Fatalf("expected file %s to be:\n\n%v\n\ngot:\n\n%v", f.Name, expectedFile, actualFile) + } + } + } + + for expectedFile := range tc.expectedFiles { + if _, ok := foundFiles[expectedFile]; !ok { + t.Fatalf("expected file %s not found in bundle, got: %v", expectedFile, foundFiles) + } + } + }) + }) + } +} + +// TestBuildWithFollowSymlinks tests that the build command follows symlinks when building a bundle. +// This test uses a local tmp filesystem to create a directory with a symlink to a file in it's root +// and a local file in the bundle directory, and verifies that the built bundle contains both the symlink +// and the regular file. +// There's probably some common utilities that could be extracted at some point but for now this code is +// local to the test until we need to reuse it elsewhere. +func TestBuildWithFollowSymlinks(t *testing.T) { + rootDir := t.TempDir() + bundleDir := path.Join(rootDir, "bundle") + err := os.Mkdir(bundleDir, 0777) + if err != nil { + t.Fatal(err) + } + + // create a regular file in our temp bundle directory + err = os.WriteFile(filepath.Join(bundleDir, "foo.rego"), []byte("package foo\none = 1"), 0777) + if err != nil { + t.Fatal(err) + } + + // create a regular file in the root directory of our tmp directory that we will symlink into the bundle directory later + err = os.WriteFile(filepath.Join(rootDir, "bar.rego"), []byte("package foo\ntwo = 2"), 0777) + if err != nil { + t.Fatal(err) + } + + // create a symlink in the bundle directory to the file in the root directory + err = os.Symlink(filepath.Join(rootDir, "bar.rego"), filepath.Join(bundleDir, "bar.rego")) + if err != nil { + t.Fatal(err) + } + + params := newBuildParams() + params.outputFile = path.Join(rootDir, "test.tar.gz") + params.bundleMode = true + params.followSymlinks = true + params.v1Compatible = true + + err = dobuild(params, []string{bundleDir}) + if err != nil { + t.Fatal(err) + } + + // verify that the bundle is a loadable bundle + _, err = loader.NewFileLoader().AsBundle(params.outputFile) + if err != nil { + t.Fatal(err) + } + + f, err := os.Open(params.outputFile) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + gr, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + + tr := tar.NewReader(gr) + + // map of file name -> file content + expectedFiles := map[string]string{ + bundleDir + "/foo.rego": "package foo\n\none := 1", + bundleDir + "/bar.rego": "package foo\n\ntwo := 2", + "/.manifest": `{"revision":"","roots":[""],"rego_version":1}`, + "/data.json": "{}", + } + + foundFiles := make(map[string]string, 4) + for f, err := tr.Next(); err != io.EOF; f, err = tr.Next() { + if err != nil { + t.Fatal(err) + } + + // ensure that all the files are regular files in the bundle + // and that no symlinks were copied + if mode := f.FileInfo().Mode(); !mode.IsRegular() { + t.Fatalf("expected regular file for file %s but got %s", f.FileInfo().Name(), mode.String()) + } + // read the file content + data, err := io.ReadAll(tr) + if err != nil { + t.Fatalf("failed to read file %s: %v", f.FileInfo().Name(), err) + } + foundFiles[f.Name] = string(data) + } + + if len(foundFiles) != 4 { + t.Fatalf("expected four files in bundle but got %d", len(foundFiles)) + } + + for name, contents := range foundFiles { + // trim added whitespace because it's annoying and makes the test less readable + contents := strings.Trim(contents, "\n") + // check that the file content matches the expected content + expectedContent, ok := expectedFiles[name] + if !ok { + t.Fatalf("unexpected file %s in bundle", name) + } + + if contents != expectedContent { + t.Fatalf("expected file %s to contain:\n\n%v\n\ngot:\n\n%v", name, expectedContent, contents) + } + } +} + +// TestBuildWithFollowSymlinksEntireDir tests that the build command can build a bundle from a symlinked directory. +// This test uses a local tmp filesystem to create a directory with a local file in the bundle directory, and +// verifies that the built bundle contains the files from the symlinked directory. +func TestBuildWithFollowSymlinksEntireDir(t *testing.T) { + rootDir := t.TempDir() + defer func() { + if err := os.RemoveAll(rootDir); err != nil { + t.Fatal(err) + } + }() + bundleDir := path.Join(rootDir, "src") + err := os.Mkdir(bundleDir, 0777) + if err != nil { + t.Fatal(err) + } + + // create a regular file in our temp bundle directory + err = os.WriteFile(filepath.Join(bundleDir, "foo.rego"), []byte("package foo\none = 1"), 0777) + if err != nil { + t.Fatal(err) + } + + symlinkDir := path.Join(rootDir, "symlink") + err = os.Mkdir(symlinkDir, 0777) + if err != nil { + t.Fatal(err) + } + + // create a symlink in the symlink directory to the src directory + err = os.Symlink(bundleDir, filepath.Join(symlinkDir, "linked")) + if err != nil { + t.Fatal(err) + } + + params := newBuildParams() + params.outputFile = path.Join(rootDir, "test.tar.gz") + params.bundleMode = true + params.followSymlinks = true + params.v1Compatible = true + + err = dobuild(params, []string{symlinkDir + "/linked/"}) + if err != nil { + t.Fatal(err) + } + + // verify that the bundle is a loadable bundle + _, err = loader.NewFileLoader().AsBundle(params.outputFile) + if err != nil { + t.Fatal(err) + } + + f, err := os.Open(params.outputFile) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + gr, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + + tr := tar.NewReader(gr) + + // map of file name -> file content + expectedFiles := map[string]string{ + path.Join(symlinkDir, "linked", "foo.rego"): "package foo\n\none := 1", + "/.manifest": `{"revision":"","roots":[""],"rego_version":1}`, + "/data.json": "{}", + } + + foundFiles := make(map[string]string, 3) + for f, err := tr.Next(); err != io.EOF; f, err = tr.Next() { + if err != nil { + t.Fatal(err) + } + + // ensure that all the files are regular files in the bundle + // and that no symlinks were copied + if mode := f.FileInfo().Mode(); !mode.IsRegular() { + t.Fatalf("expected regular file for file %s but got %s", f.FileInfo().Name(), mode.String()) + } + // read the file content + data, err := io.ReadAll(tr) + if err != nil { + t.Fatalf("failed to read file %s: %v", f.FileInfo().Name(), err) + } + foundFiles[f.Name] = string(data) + } + + if len(foundFiles) != 3 { + t.Fatalf("expected three files in bundle but got %d", len(foundFiles)) + } + + for name, contents := range foundFiles { + // trim added whitespace because it's annoying and makes the test less readable + contents := strings.Trim(contents, "\n") + // check that the file content matches the expected content + expectedContent, ok := expectedFiles[name] + if !ok { + t.Fatalf("unexpected file %s in bundle", name) + } + + if contents != expectedContent { + t.Fatalf("expected file %s to contain:\n\n%v\n\ngot:\n\n%v", name, expectedContent, contents) + } + } +} + +func TestBuildManifestWarning(t *testing.T) { + testCases := map[string]struct { + files map[string]string + bundleMode bool + buildArgs []string + expectedStderr func(root string) string + }{ + "warns when manifest ignored": { + files: map[string]string{ + "bundle/.manifest": `{"revision":"1.0.0","roots":["foo"]}`, + "bundle/data.json": `{"data": "value"}`, + }, + bundleMode: false, + buildArgs: []string{"bundle"}, + expectedStderr: func(root string) string { + return fmt.Sprintf("Warning: .manifest file found in %q but -b flag not specified. Manifest will be ignored.\n", path.Join(root, "bundle")) + }, + }, + "no warning when bundle mode enabled": { + files: map[string]string{ + "bundle/.manifest": `{"revision":"1.0.0","roots":["foo"]}`, + "bundle/foo/data.json": `{"data": "value"}`, + }, + bundleMode: true, + buildArgs: []string{"bundle"}, + expectedStderr: func(root string) string { + return "" + }, + }, + "no warning when no manifest exists": { + files: map[string]string{ + "bundle/data.json": `{"data": "value"}`, + }, + bundleMode: false, + buildArgs: []string{"bundle"}, + expectedStderr: func(root string) string { + return "" + }, + }, + "warns for multiple bundles with manifests": { + files: map[string]string{ + "bundle1/.manifest": `{"revision":"1.0.0","roots":["foo"]}`, + "bundle1/foo/data.json": `{"data1": "value1"}`, + "bundle2/.manifest": `{"revision":"2.0.0","roots":["bar"]}`, + "bundle2/bar/data.json": `{"data2": "value2"}`, + "bundle3/baz/data.json": `{"data3": "value3"}`, + }, + bundleMode: false, + buildArgs: []string{"bundle1", "bundle2", "bundle3"}, + expectedStderr: func(root string) string { + return fmt.Sprintf(`Warning: .manifest file found in %q but -b flag not specified. Manifest will be ignored. +Warning: .manifest file found in %q but -b flag not specified. Manifest will be ignored. +`, + path.Join(root, "bundle1"), path.Join(root, "bundle2")) + }, + }, + "warns when proto manifest ignored": { + files: map[string]string{ + "bundle/.manifest.pb": "ignored-content", + "bundle/data.json": `{"data": "value"}`, + }, + bundleMode: false, + buildArgs: []string{"bundle"}, + expectedStderr: func(root string) string { + return fmt.Sprintf("Warning: .manifest.pb file found in %q but -b flag not specified. Manifest will be ignored.\n", path.Join(root, "bundle")) + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + var stderr bytes.Buffer + test.WithTempFS(tc.files, func(root string) { + params := newBuildParams() + params.outputFile = path.Join(root, "output.tar.gz") + params.bundleMode = tc.bundleMode + params.stderr = &stderr + + args := make([]string, 0, len(tc.buildArgs)) + for _, arg := range tc.buildArgs { + args = append(args, path.Join(root, arg)) + } + + err := dobuild(params, args) + if err != nil { + t.Fatal(err) + } + + stderrOutput := stderr.String() + expectedStderr := tc.expectedStderr(root) + + if stderrOutput != expectedStderr { + t.Fatalf("Expected stderr:\n%q\nGot:\n%q", expectedStderr, stderrOutput) + } + }) + }) + } +} + +func TestBuildPlanJSONOutputBytes(t *testing.T) { + files := map[string]string{ + "test.rego": ` + package test + p = 1 + `, + } + + test.WithTempFS(files, func(root string) { + params := newBuildParams() + if err := params.target.Set("plan"); err != nil { + t.Fatal(err) + } + params.entrypoints.v = []string{"test"} + params.outputFile = path.Join(root, "bundle.tar.gz") + + if err := dobuild(params, []string{root}); err != nil { + t.Fatal(err) + } + + f, err := os.Open(params.outputFile) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + gr, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + + tr := tar.NewReader(gr) + var planBytes []byte + var found bool + + for { + h, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + if h.Name == "/plan.json" { + found = true + if planBytes, err = io.ReadAll(tr); err != nil { + t.Fatal(err) + } + } + } + + if !found { + t.Fatal("plan.json not found") + } + + got := strings.ReplaceAll(string(planBytes), root, "TEMPDIR") + + expected := `{"static":{"strings":[{"value":"result"},{"value":"p"},{"value":"1"},{"value":"test"}],"files":[{"value":"TEMPDIR/test.rego"}]},"plans":{"plans":[{"name":"test","blocks":[{"stmts":[{"type":"MakeObjectStmt","stmt":{"target":2,"file":0,"col":0,"row":0}},{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.test.p","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":3,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":1},"value":{"type":"local","value":3},"object":2,"file":0,"col":0,"row":0}}]}],"file":0,"col":0,"row":0}},{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"DotStmt","stmt":{"source":{"type":"local","value":1},"key":{"type":"string_index","value":3},"target":5,"file":0,"col":0,"row":0}},{"type":"ObjectMergeStmt","stmt":{"a":5,"b":2,"target":4,"file":0,"col":0,"row":0}},{"type":"BreakStmt","stmt":{"index":1,"file":0,"col":0,"row":0}}]}],"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":2},"target":4,"file":0,"col":0,"row":0}}]}],"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":4},"target":6,"file":0,"col":0,"row":0}},{"type":"MakeObjectStmt","stmt":{"target":7,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":0},"value":{"type":"local","value":6},"object":7,"file":0,"col":0,"row":0}},{"type":"ResultSetAddStmt","stmt":{"value":7,"file":0,"col":0,"row":0}}]}]}]},"funcs":{"funcs":[{"name":"g0.data.test.p","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":4,"row":3}},{"type":"MakeNumberRefStmt","stmt":{"file":0,"col":4,"row":3,"index":2,"Index":2,"target":4}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":4},"target":3,"file":0,"col":4,"row":3}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":0,"col":4,"row":3}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":0,"col":4,"row":3}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":0,"col":4,"row":3}}]}],"path":["g0","test","p"]}]}}` + + if diff := cmp.Diff(expected, got); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } + }) +} diff --git a/cmd/build_test.go b/cmd/build_test.go index e49f971629..5edcc2046f 100644 --- a/cmd/build_test.go +++ b/cmd/build_test.go @@ -1,3 +1,5 @@ +//go:build !go1.27 + package cmd import ( @@ -17,6 +19,7 @@ import ( "strings" "testing" + "github.com/google/go-cmp/cmp" "github.com/open-policy-agent/opa/internal/file/archive" "github.com/open-policy-agent/opa/v1/ast" "github.com/open-policy-agent/opa/v1/loader" @@ -3415,3 +3418,67 @@ Warning: .manifest file found in %q but -b flag not specified. Manifest will be }) } } + +func TestBuildPlanJSONOutputBytes(t *testing.T) { + files := map[string]string{ + "test.rego": ` + package test + p = 1 + `, + } + + test.WithTempFS(files, func(root string) { + params := newBuildParams() + if err := params.target.Set("plan"); err != nil { + t.Fatal(err) + } + params.entrypoints.v = []string{"test"} + params.outputFile = path.Join(root, "bundle.tar.gz") + + if err := dobuild(params, []string{root}); err != nil { + t.Fatal(err) + } + + f, err := os.Open(params.outputFile) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + gr, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + + tr := tar.NewReader(gr) + var planBytes []byte + var found bool + + for { + h, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + if h.Name == "/plan.json" { + found = true + if planBytes, err = io.ReadAll(tr); err != nil { + t.Fatal(err) + } + } + } + + if !found { + t.Fatal("plan.json not found") + } + + got := strings.ReplaceAll(string(planBytes), root, "TEMPDIR") + + expected := `{"static":{"strings":[{"value":"result"},{"value":"p"},{"value":"1"},{"value":"test"}],"files":[{"value":"TEMPDIR/test.rego"}]},"plans":{"plans":[{"name":"test","blocks":[{"stmts":[{"type":"MakeObjectStmt","stmt":{"target":2,"file":0,"col":0,"row":0}},{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.test.p","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":3,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":1},"value":{"type":"local","value":3},"object":2,"file":0,"col":0,"row":0}}]}],"file":0,"col":0,"row":0}},{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"DotStmt","stmt":{"source":{"type":"local","value":1},"key":{"type":"string_index","value":3},"target":5,"file":0,"col":0,"row":0}},{"type":"ObjectMergeStmt","stmt":{"a":5,"b":2,"target":4,"file":0,"col":0,"row":0}},{"type":"BreakStmt","stmt":{"index":1,"file":0,"col":0,"row":0}}]}],"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":2},"target":4,"file":0,"col":0,"row":0}}]}],"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":4},"target":6,"file":0,"col":0,"row":0}},{"type":"MakeObjectStmt","stmt":{"target":7,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":0},"value":{"type":"local","value":6},"object":7,"file":0,"col":0,"row":0}},{"type":"ResultSetAddStmt","stmt":{"value":7,"file":0,"col":0,"row":0}}]}]}]},"funcs":{"funcs":[{"name":"g0.data.test.p","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":4,"row":3}},{"type":"MakeNumberRefStmt","stmt":{"file":0,"col":4,"row":3,"index":2,"Index":2,"target":4}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":4},"target":3,"file":0,"col":4,"row":3}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":0,"col":4,"row":3}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":0,"col":4,"row":3}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":0,"col":4,"row":3}}]}],"path":["g0","test","p"]}]}}` + + if diff := cmp.Diff(expected, got); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } + }) +} diff --git a/cmd/capabilities_jsonv2_test.go b/cmd/capabilities_jsonv2_test.go new file mode 100644 index 0000000000..b5397e00da --- /dev/null +++ b/cmd/capabilities_jsonv2_test.go @@ -0,0 +1,218 @@ +//go:build go1.27 + +// Copyright 2022 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 cmd + +import ( + "bytes" + "path" + "slices" + "sort" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/util/test" +) + +func TestCapabilitiesNoArgs(t *testing.T) { + t.Run("test with no arguments", func(t *testing.T) { + _, err := doCapabilities(capabilitiesParams{}) + if err != nil { + t.Fatal("expected success", err) + } + }) +} + +func TestCapabilitiesVersion(t *testing.T) { + t.Run("test with version", func(t *testing.T) { + params := capabilitiesParams{ + version: "v0.39.0", + } + _, err := doCapabilities(params) + if err != nil { + t.Fatal("expected success", err) + } + }) +} + +func TestCapabilitiesFile(t *testing.T) { + t.Run("test with file", func(t *testing.T) { + files := map[string]string{ + "test-capabilities.json": ` + { + "builtins": [ + { + "name": "plus", + "infix": "+", + "decl": { + "type": "function", + "args": [ + { + "type": "number" + }, + { + "type": "number" + } + ], + "result": { + "type": "number" + } + } + } + ] + } + `, + } + + test.WithTempFS(files, func(root string) { + params := capabilitiesParams{ + file: path.Join(root, "test-capabilities.json"), + } + _, err := doCapabilities(params) + + if err != nil { + t.Fatal("expected success", err) + } + }) + + }) +} + +func TestCapabilitiesJSONOutputBytes(t *testing.T) { + files := map[string]string{ + "test-capabilities.json": ` + { + "builtins": [ + { + "name": "plus", + "infix": "+", + "decl": { + "type": "function", + "args": [ + { + "type": "number" + }, + { + "type": "number" + } + ], + "result": { + "type": "number" + } + } + } + ] + } + `, + } + + test.WithTempFS(files, func(root string) { + params := capabilitiesParams{ + file: path.Join(root, "test-capabilities.json"), + } + got, err := doCapabilities(params) + if err != nil { + t.Fatal("expected success", err) + } + + expected := `{ + "builtins": [ + { + "name": "plus", + "decl": { + "args": [ + { + "type": "number" + }, + { + "type": "number" + } + ], + "result": { + "type": "number" + }, + "type": "function" + }, + "infix": "+" + } + ] +}` + + if diff := cmp.Diff(expected, got); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } + }) +} + +func TestCapabilitiesCurrent(t *testing.T) { + tests := []struct { + note string + v0Compatible bool + expFeatures []string + expFutureKeywords []string + }{ + { + note: "current", + expFeatures: []string{ + ast.FeatureRegoV1, + ast.FeatureKeywordsInRefs, + ast.FeatureTemplateStrings, + }, + expFutureKeywords: []string{ + "not", + }, + }, + { + note: "current --v0-compatible", + v0Compatible: true, + expFeatures: []string{ + ast.FeatureRefHeadStringPrefixes, + ast.FeatureRefHeads, + ast.FeatureRegoV1Import, + ast.FeatureRegoV1, + ast.FeatureKeywordsInRefs, + }, + expFutureKeywords: []string{ + "in", + "every", + "contains", + "if", + "not", + }, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + // These are sorted in the output + sort.Strings(tc.expFutureKeywords) + sort.Strings(tc.expFeatures) + + params := capabilitiesParams{ + showCurrent: true, + v0Compatible: tc.v0Compatible, + } + capsStr, err := doCapabilities(params) + if err != nil { + t.Fatal("expected success", err) + } + + caps, err := ast.LoadCapabilitiesJSON(bytes.NewReader([]byte(capsStr))) + if err != nil { + t.Fatal("expected success", err) + } + + if !slices.Equal(caps.Features, tc.expFeatures) { + t.Errorf("expected features:\n\n%v\n\nbut got:\n\n%v", tc.expFeatures, caps.Features) + } + + if !slices.Equal(caps.FutureKeywords, tc.expFutureKeywords) { + t.Errorf("expected future keywords:\n\n%v\n\nbut got:\n\n%v", tc.expFutureKeywords, caps.FutureKeywords) + } + }) + } +} diff --git a/cmd/capabilities_test.go b/cmd/capabilities_test.go index cc3b9dfda2..4dc715f414 100644 --- a/cmd/capabilities_test.go +++ b/cmd/capabilities_test.go @@ -1,3 +1,5 @@ +//go:build !go1.27 + // Copyright 2022 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. @@ -11,6 +13,7 @@ import ( "sort" "testing" + "github.com/google/go-cmp/cmp" "github.com/open-policy-agent/opa/v1/ast" "github.com/open-policy-agent/opa/v1/util/test" ) @@ -79,6 +82,72 @@ func TestCapabilitiesFile(t *testing.T) { }) } +func TestCapabilitiesJSONOutputBytes(t *testing.T) { + files := map[string]string{ + "test-capabilities.json": ` + { + "builtins": [ + { + "name": "plus", + "infix": "+", + "decl": { + "type": "function", + "args": [ + { + "type": "number" + }, + { + "type": "number" + } + ], + "result": { + "type": "number" + } + } + } + ] + } + `, + } + + test.WithTempFS(files, func(root string) { + params := capabilitiesParams{ + file: path.Join(root, "test-capabilities.json"), + } + got, err := doCapabilities(params) + if err != nil { + t.Fatal("expected success", err) + } + + expected := `{ + "builtins": [ + { + "name": "plus", + "decl": { + "args": [ + { + "type": "number" + }, + { + "type": "number" + } + ], + "result": { + "type": "number" + }, + "type": "function" + }, + "infix": "+" + } + ] +}` + + if diff := cmp.Diff(expected, got); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } + }) +} + func TestCapabilitiesCurrent(t *testing.T) { tests := []struct { note string diff --git a/cmd/check_jsonv2_test.go b/cmd/check_jsonv2_test.go new file mode 100644 index 0000000000..b9f148a4ff --- /dev/null +++ b/cmd/check_jsonv2_test.go @@ -0,0 +1,1386 @@ +//go:build go1.27 + +// Copyright 2022 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 cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "maps" + "os" + "path" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/open-policy-agent/opa/internal/file/archive" + pr "github.com/open-policy-agent/opa/internal/presentation" + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/util/test" +) + +func TestCheckRespectsCapabilities(t *testing.T) { + //nolint:prealloc // test slice is extended dynamically, initial values are clearer as slice literal + tests := []struct { + note string + caps string + policy string + err string + bundleMode bool // check with "-b" flag + }{ + { + note: "builtin defined in caps", + caps: `{ + "builtins": [ + { + "name": "is_foo", + "decl": { + "args": [ + { + "type": "string" + } + ], + "result": { + "type": "boolean" + }, + "type": "function" + } + } + ] + }`, + policy: `package test +p { is_foo("bar") }`, + }, + { + note: "future kw NOT defined in caps", + caps: func() string { + c := ast.CapabilitiesForThisVersion() + c.FutureKeywords = []string{"in"} + c.Features = []string{} + j, err := json.Marshal(c) + if err != nil { + panic(err) + } + return string(j) + }(), + policy: `package test +import future.keywords.if +import future.keywords.in +p if "opa" in input.tools`, + err: "rego_parse_error: unexpected keyword, must be one of [in]", + }, + { + note: "future kw NOT defined in caps, rego-v1 feature", + caps: func() string { + c := ast.CapabilitiesForThisVersion() + c.FutureKeywords = []string{"in"} + c.Features = []string{ast.FeatureRegoV1} + j, err := json.Marshal(c) + if err != nil { + panic(err) + } + return string(j) + }(), + policy: `package test +import future.keywords.if +import future.keywords.in +p if "opa" in input.tools`, + }, + { + note: "future kw are defined in caps", + caps: func() string { + c := ast.CapabilitiesForThisVersion() + c.FutureKeywords = []string{"in", "if"} + j, err := json.Marshal(c) + if err != nil { + panic(err) + } + return string(j) + }(), + policy: `package test +import future.keywords.if +import future.keywords.in +p if "opa" in input.tools`, + }, + { + note: "rego.v1 imported but NOT defined in capabilities", + caps: func() string { + c := ast.CapabilitiesForThisVersion() + c.Features = []string{} + j, err := json.Marshal(c) + if err != nil { + panic(err) + } + return string(j) + }(), + policy: `package test +import rego.v1`, + err: "rego_parse_error: invalid import, `rego.v1` is not supported by current capabilities", + }, + { + note: "rego.v1 imported AND defined in capabilities", + caps: func() string { + c := ast.CapabilitiesForThisVersion() + c.Features = []string{ast.FeatureRegoV1Import} + j, err := json.Marshal(c) + if err != nil { + panic(err) + } + return string(j) + }(), + policy: `package test +import rego.v1`, + }, + { + note: "rego.v1 imported AND rego-v1 in capabilities", + caps: func() string { + c := ast.CapabilitiesForThisVersion() + c.Features = []string{ast.FeatureRegoV1} + j, err := json.Marshal(c) + if err != nil { + panic(err) + } + return string(j) + }(), + policy: `package test +import rego.v1`, + }, + } + + // add same tests for bundle-mode == true: + for i := range tests { + tc := tests[i] + tc.bundleMode = true + tc.note += " (as bundle)" + tests = append(tests, tc) + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + files := map[string]string{ + "capabilities.json": tc.caps, + "test.rego": tc.policy, + } + + test.WithTempFS(files, func(root string) { + caps := newCapabilitiesFlag() + if err := caps.Set(path.Join(root, "capabilities.json")); err != nil { + t.Fatal(err) + } + params := newCheckParams() + params.capabilities = caps + params.bundleMode = tc.bundleMode + // Capabilities in test cases is pre v1 + params.v0Compatible = true + + err := checkModules(params, []string{root}) + switch { + case err != nil && tc.err != "": + if !strings.Contains(err.Error(), tc.err) { + t.Fatalf("expected err %v, got %v", tc.err, err) + } + return // don't read back bundle below + case err != nil && tc.err == "": + t.Fatalf("unexpected error: %v", err) + case err == nil && tc.err != "": + t.Fatalf("expected error %v, got nil", tc.err) + } + }) + }) + } +} + +func testCheckWithSchemasAnnotationButNoSchemaFlag(policy string) error { + files := map[string]string{ + "test.rego": policy, + } + + var err error + test.WithTempFS(files, func(path string) { + params := newCheckParams() + + err = checkModules(params, []string{path}) + }) + + return err +} + +func TestCheckIgnoresNonRegoFiles(t *testing.T) { + files := map[string]string{ + "test.rego": `package test`, + "test.json": `{"foo": "bar"}`, + "test.yaml": `foo: bar`, + } + + test.WithTempFS(files, func(root string) { + params := newCheckParams() + + err := checkModules(params, []string{root}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) +} + +func TestCheckIgnoreBundleMode(t *testing.T) { + t.Parallel() + + files := map[string]string{ + "ignore.rego": `invalid rego`, + "include.rego": `package valid`, + } + + test.WithTempFS(files, func(root string) { + params := newCheckParams() + + params.ignore = []string{"ignore.rego"} + params.bundleMode = true + + err := checkModules(params, []string{root}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) +} + +func TestCheckBundleReportsPolicyVsDataConflict(t *testing.T) { + t.Parallel() + + files := map[string]string{ + "policy.rego": "package p\nallow := false\n", + "data.json": `{"p":{"allow":false}}`, + } + + test.WithTempFS(files, func(root string) { + params := newCheckParams() + // Bundle mode required as the check command *should* ignore data entirely otherwise + params.bundleMode = true + + err := checkModules(params, []string{root}) + if err == nil { + t.Fatal("expected error but received none") + } + + exp := fmt.Sprintf( + "1 error occurred: %s:2: rego_compile_error: conflicting rule for data path p/allow found", + filepath.Join(root, "policy.rego"), + ) + if err.Error() != exp { + t.Fatalf("expected error %q, got %q", exp, err.Error()) + } + }) +} + +func TestCheckFailsOnInvalidRego(t *testing.T) { + files := map[string]string{ + "test.rego": `package test +{}`, + "test.json": `{"foo": "bar"}`, + } + expectedError := "rego_parse_error: object cannot be used for rule name" + + test.WithTempFS(files, func(root string) { + params := newCheckParams() + + err := checkModules(params, []string{root}) + if err == nil { + t.Fatalf("expected error %v but received none", expectedError) + } + if !strings.Contains(err.Error(), expectedError) { + t.Fatalf("expected error %v but received %v", expectedError, err) + } + }) +} + +func TestCheckJSONOutputBytes(t *testing.T) { + files := map[string]string{ + "test.rego": `package test +{}`, + } + + test.WithTempFS(files, func(root string) { + params := newCheckParams() + + checkErr := checkModules(params, []string{root}) + if checkErr == nil { + t.Fatal("expected error but received none") + } + + var buf bytes.Buffer + if err := pr.JSON(&buf, pr.Output{Errors: pr.NewOutputErrors(checkErr)}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := strings.ReplaceAll(`{ + "errors": [ + { + "message": "object cannot be used for rule name", + "code": "rego_parse_error", + "location": { + "file": "TEMPDIR/test.rego", + "row": 2, + "col": 1 + } + } + ] +} +`, "TEMPDIR", root) + + if diff := cmp.Diff(expected, buf.String()); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } + }) +} + +// Assert that 'schemas' annotations with schema refs are only informing the type checker when the --schema flag is used +func TestCheckWithSchemasAnnotationButNoSchemaFlag(t *testing.T) { + policiesWithSchemaRef := []string{` +package test +import rego.v1 +# METADATA +# schemas: +# - input: schema["input"] +p if { + rego.metadata.rule() # presence of rego.metadata.* calls must not trigger unwanted schema evaluation + input.foo == 42 # type mismatch with schema that should be ignored +}`, + ` +package p + +# METADATA +# schemas: +# - data.p.x: schema["nope"] +bug := data.p.x +`} + + for i, pol := range policiesWithSchemaRef { + err := testCheckWithSchemasAnnotationButNoSchemaFlag(pol) + if err != nil { + t.Fatalf("unexpected error from eval policy %d with schema ref: %v", i, err) + } + } + + policyWithInlinedSchema := ` +package test +import rego.v1 +# METADATA +# schemas: +# - input.foo: {"type": "boolean"} +p if { + rego.metadata.rule() # presence of rego.metadata.* calls must not trigger unwanted schema evaluation + input.foo == 42 # type mismatch with schema that should be ignored +}` + + err := testCheckWithSchemasAnnotationButNoSchemaFlag(policyWithInlinedSchema) + // We expect an error here, as inlined schemas are always used for type checking + if !strings.Contains(err.Error(), "rego_type_error: match error") { + t.Fatalf("unexpected error from eval with inlined schema, got: %v", err) + } +} + +func TestCheckRegoV1(t *testing.T) { + cases := []struct { + note string + policy string + expErrs []string + }{ + { + note: "rego.v1 imported, v1 compliant", + policy: `package test +import rego.v1 +p contains x if { + x := [1,2,3] +}`, + }, + { + note: "rego.v1 imported, NOT v1 compliant (parser)", + policy: `package test +import rego.v1 +p contains x { + x := [1,2,3] +} + +q.r`, + expErrs: []string{ + "test.rego:3: rego_parse_error: `if` keyword is required before rule body", + "test.rego:7: rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "rego.v1 imported, NOT v1 compliant (compiler)", + policy: `package test +import rego.v1 + +import data.foo +import data.bar as foo +`, + expErrs: []string{ + "test.rego:5: rego_compile_error: import must not shadow import data.foo", + }, + }, + { + note: "keywords imported, v1 compliant", + policy: `package test +import future.keywords.if +import future.keywords.contains +p contains x if { + x := [1,2,3] +}`, + }, + { + note: "keywords imported, NOT v1 compliant", + policy: `package test +import future.keywords.contains +p contains x { + x := [1,2,3] +} + +q.r`, + expErrs: []string{ + "test.rego:3: rego_parse_error: `if` keyword is required before rule body", + "test.rego:7: rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "keywords imported, NOT v1 compliant (compiler)", + policy: `package test +import future.keywords.if + +input := 1 if { + 1 == 2 +}`, + expErrs: []string{ + "test.rego:4: rego_compile_error: rules must not shadow input (use a different rule name)", + }, + }, + { + note: "no imports, v1 compliant", + policy: `package test +p := 1 +`, + }, + { + note: "no imports, NOT v1 compliant but v0 compliant (compiler)", + policy: `package test +p.x`, + expErrs: []string{ + "test.rego:2: rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "no imports, v1 compliant but NOT v0 compliant", + policy: `package test +p contains x if { + x := [1,2,3] +}`, + expErrs: []string{ + "test.rego:2: rego_parse_error: var cannot be used for rule name", // This error actually appears three times: once for 'p'; once for 'contains'; and once for 'x'. All are interpreted as [invalid] rule declarations with no value and body. + "test.rego:2: rego_parse_error: `if` keyword is required before rule body", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.note, func(t *testing.T) { + files := map[string]string{ + "test.rego": tc.policy, + } + + test.WithTempFS(files, func(root string) { + params := newCheckParams() + params.regoV1 = true + + err := checkModules(params, []string{root}) + switch { + case err != nil && len(tc.expErrs) > 0: + for _, expErr := range tc.expErrs { + if !strings.Contains(err.Error(), expErr) { + t.Fatalf("expected err:\n\n%v\n\ngot:\n\n%v", expErr, err) + } + } + return // don't read back bundle below + case err != nil && len(tc.expErrs) == 0: + t.Fatalf("unexpected error: %v", err) + case err == nil && len(tc.expErrs) > 0: + t.Fatalf("expected error:\n\n%v\n\ngot: none", tc.expErrs) + } + }) + }) + } +} + +func TestCheck_DefaultRegoVersion(t *testing.T) { + cases := []struct { + note string + policy string + expErrs []string + }{ + { + note: "v0 module", + policy: `package test +a[x] { + x := 42 +}`, + expErrs: []string{ + "test.rego:2: rego_parse_error: `if` keyword is required before rule body", + "test.rego:2: rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v1 module", + policy: `package test +a contains x if { + x := 42 +}`, + }, + } + + for _, tc := range cases { + t.Run(tc.note, func(t *testing.T) { + files := map[string]string{ + "test.rego": tc.policy, + } + + test.WithTempFS(files, func(root string) { + params := newCheckParams() + + err := checkModules(params, []string{root}) + switch { + case err != nil && len(tc.expErrs) > 0: + for _, expErr := range tc.expErrs { + if !strings.Contains(err.Error(), expErr) { + t.Fatalf("expected err:\n\n%v\n\ngot:\n\n%v", expErr, err) + } + } + return // don't read back bundle below + case err != nil && len(tc.expErrs) == 0: + t.Fatalf("unexpected error: %v", err) + case err == nil && len(tc.expErrs) > 0: + t.Fatalf("expected error:\n\n%v\n\ngot: none", tc.expErrs) + } + }) + }) + } +} + +func TestCheckWithRegoV1Capability(t *testing.T) { + cases := []struct { + note string + v0Compatible bool + capabilities *ast.Capabilities + policy string + expErrs []string + }{ + { + note: "v0 module, v0-compatible, no capabilities", + v0Compatible: true, + policy: `package test +a[x] { + x := 42 +}`, + }, + { + note: "v0 module, v0-compatible, v0 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + policy: `package test +a[x] { + x := 42 +}`, + }, + { + note: "v0 module, v0-compatible, v1 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + policy: `package test +a[x] { + x := 42 +}`, + }, + + { + note: "v0 module, not v0-compatible, no capabilities", + policy: `package test +a[x] { + x := 42 +}`, + expErrs: []string{ + "test.rego:2: rego_parse_error: `if` keyword is required before rule body", + "test.rego:2: rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v0 module, not v0-compatible, v0 capabilities", + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + policy: `package test +a[x] { + x := 42 +}`, + expErrs: []string{ + "test.rego:2: rego_parse_error: `if` keyword is required before rule body", + "test.rego:2: rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v0 module, not v0-compatible, v0 capabilities without rego_v1 feature", + capabilities: capsWithoutFeat(ast.RegoV0, ast.FeatureRegoV1), + policy: `package test +a[x] { + x := 42 +}`, + expErrs: []string{ + "rego_parse_error: illegal capabilities: rego_v1 feature required for parsing v1 Rego", + }, + }, + { + note: "v0 module, not v0-compatible, v1 capabilities", + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + policy: `package test +a[x] { + x := 42 +}`, + expErrs: []string{ + "test.rego:2: rego_parse_error: `if` keyword is required before rule body", + "test.rego:2: rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + + { + note: "v1 module, v0-compatible, no capabilities", + v0Compatible: true, + policy: `package test +a contains x if { + x := 42 +}`, + expErrs: []string{ + "test.rego:2: rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v1 module, v0-compatible, v0 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + policy: `package test +a contains x if { + x := 42 +}`, + expErrs: []string{ + "test.rego:2: rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v1 module, v0-compatible, v1 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + policy: `package test +a contains x if { + x := 42 +}`, + expErrs: []string{ + "test.rego:2: rego_parse_error: var cannot be used for rule name", + }, + }, + + { + note: "v1 module, not v0-compatible, no capabilities", + policy: `package test +a contains x if { + x := 42 +}`, + }, + { + note: "v1 module, not v0-compatible, v0 capabilities", + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + policy: `package test +a contains x if { + x := 42 +}`, + }, + { + note: "v1 module, not v0-compatible, v0 capabilities without rego_v1 feature", + capabilities: capsWithoutFeat(ast.RegoV0, ast.FeatureRegoV1), + policy: `package test +a contains x if { + x := 42 +}`, + expErrs: []string{ + "rego_parse_error: illegal capabilities: rego_v1 feature required for parsing v1 Rego", + }, + }, + { + note: "v1 module, not v0-compatible, v1 capabilities", + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + policy: `package test +a contains x if { + x := 42 +}`, + }, + } + + for _, tc := range cases { + t.Run(tc.note, func(t *testing.T) { + files := map[string]string{ + "test.rego": tc.policy, + } + + test.WithTempFS(files, func(root string) { + params := newCheckParams() + params.v0Compatible = tc.v0Compatible + params.capabilities.C = tc.capabilities + + err := checkModules(params, []string{root}) + switch { + case err != nil && len(tc.expErrs) > 0: + for _, expErr := range tc.expErrs { + if !strings.Contains(err.Error(), expErr) { + t.Fatalf("expected err:\n\n%v\n\ngot:\n\n%v", expErr, err) + } + } + return // don't read back bundle below + case err != nil && len(tc.expErrs) == 0: + t.Fatalf("unexpected error: %v", err) + case err == nil && len(tc.expErrs) > 0: + t.Fatalf("expected error:\n\n%v\n\ngot: none", tc.expErrs) + } + }) + }) + } +} + +func TestCheckCompatibleFlags(t *testing.T) { + cases := []struct { + note string + v0Compatible bool + v1Compatible bool + policy string + expErrs []string + }{ + { + note: "v0, no illegal keywords", + v0Compatible: true, + policy: `package test +p[x] { + x := [1,2,3] +}`, + }, + { + note: "v0, illegal keywords", + v0Compatible: true, + policy: `package test +p contains x if { + x := [1,2,3] +}`, + expErrs: []string{ + "test.rego:2: rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v0, future.keywords imported", + v0Compatible: true, + policy: `package test +import future.keywords +p contains x if { + x := [1,2,3] +}`, + }, + { + note: "v0, rego.v1 imported", + v0Compatible: true, + policy: `package test +import rego.v1 +p contains x if { + x := [1,2,3] +}`, + }, + { + note: "v1, rego.v1 imported, v1 compliant", + v1Compatible: true, + policy: `package test +import rego.v1 +p contains x if { + x := [1,2,3] +}`, + }, + { + note: "v1, rego.v1 imported, NOT v1 compliant (parser)", + v1Compatible: true, + policy: `package test +import rego.v1 +p contains x { + x := [1,2,3] +} + +q.r`, + expErrs: []string{ + "test.rego:3: rego_parse_error: `if` keyword is required before rule body", + "test.rego:7: rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v1, rego.v1 imported, NOT v1 compliant (compiler)", + v1Compatible: true, + policy: `package test +import rego.v1 + +import data.foo +import data.bar as foo +`, + expErrs: []string{ + "test.rego:5: rego_compile_error: import must not shadow import data.foo", + }, + }, + { + note: "v1, keywords imported, v1 compliant", + v1Compatible: true, + policy: `package test +import future.keywords.if +import future.keywords.contains +p contains x if { + x := [1,2,3] +}`, + }, + { + note: "v1, keywords imported, NOT v1 compliant", + v1Compatible: true, + policy: `package test +import future.keywords.contains +p contains x { + x := [1,2,3] +} + +q.r`, + expErrs: []string{ + "test.rego:3: rego_parse_error: `if` keyword is required before rule body", + "test.rego:7: rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v1, keywords imported, NOT v1 compliant (compiler)", + v1Compatible: true, + policy: `package test +import future.keywords.if + +input := 1 if { + 1 == 2 +}`, + expErrs: []string{ + "test.rego:4: rego_compile_error: rules must not shadow input (use a different rule name)", + }, + }, + { + note: "v1, no imports, v1 compliant", + v1Compatible: true, + policy: `package test +p := 1 +`, + }, + { + note: "v1, no imports, NOT v1 compliant but v0 compliant (compiler)", + v1Compatible: true, + policy: `package test +p.x`, + expErrs: []string{ + "test.rego:2: rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v1, no imports, v1 compliant but NOT v0 compliant", + v1Compatible: true, + policy: `package test +p contains x if { + x := [1,2,3] +}`, + }, + // v0 takes precedence over v1 + { + note: "v0+v1, no illegal keywords", + v0Compatible: true, + v1Compatible: true, + policy: `package test +p[x] { + x := [1,2,3] +}`, + }, + { + note: "v0+v1, illegal keywords", + v0Compatible: true, + v1Compatible: true, + policy: `package test +p contains x if { + x := [1,2,3] +}`, + expErrs: []string{ + "test.rego:2: rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v0+v1, future.keywords imported", + v0Compatible: true, + v1Compatible: true, + policy: `package test +import future.keywords +p contains x if { + x := [1,2,3] +}`, + }, + { + note: "v0+v1, rego.v1 imported", + v0Compatible: true, + v1Compatible: true, + policy: `package test +import rego.v1 +p contains x if { + x := [1,2,3] +}`, + }, + } + + for _, tc := range cases { + t.Run(tc.note, func(t *testing.T) { + files := map[string]string{ + "test.rego": tc.policy, + } + + test.WithTempFS(files, func(root string) { + params := newCheckParams() + params.v0Compatible = tc.v0Compatible + params.v1Compatible = tc.v1Compatible + + err := checkModules(params, []string{root}) + switch { + case err != nil && len(tc.expErrs) > 0: + for _, expErr := range tc.expErrs { + if !strings.Contains(err.Error(), expErr) { + t.Fatalf("expected err:\n\n%v\n\ngot:\n\n%v", expErr, err) + } + } + return // don't read back bundle below + case err != nil && len(tc.expErrs) == 0: + t.Fatalf("unexpected error: %v", err) + case err == nil && len(tc.expErrs) > 0: + t.Fatalf("expected error:\n\n%v\n\ngot: none", tc.expErrs) + } + }) + }) + } +} + +func TestCheckWithBundleRegoVersion(t *testing.T) { + cases := []struct { + note string + files map[string]string + expErrs []string + }{ + { + note: "v0.x bundle, illegal keywords", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +p contains x if { + x := [1,2,3] +}`, + }, + expErrs: []string{ + "rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v0.x bundle, rego.v1 imported, v1 compliant", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +import rego.v1 +p contains x if { + x := [1,2,3] +}`, + }, + }, + { + note: "v0.x bundle, rego.v1 imported, NOT v1 compliant (parser)", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +import rego.v1 +p contains x { + x := [1,2,3] +} + +q.r`, + }, + expErrs: []string{ + "rego_parse_error: `if` keyword is required before rule body", + "rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v0.x bundle, rego.v1 imported, NOT v1 compliant (compiler)", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +import rego.v1 + +import data.foo +import data.bar as foo +`, + }, + expErrs: []string{ + "rego_compile_error: import must not shadow import data.foo", + }, + }, + { + note: "v0.x bundle, keywords imported, v1 compliant", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +import future.keywords.if +import future.keywords.contains +p contains x if { + x := [1,2,3] +}`, + }, + }, + { + note: "v0.x bundle, no imports, v1 compliant", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +p := 1 +`, + }, + }, + { + note: "v0 bundle, v1 per-file overrides, compliant", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "/policy2.rego": 1 + } +}`, + "policy1.rego": `package test +p[x] { + x := [1,2,3] +}`, + "policy2.rego": `package test +q contains x if { + x := [1,2,3] +}`, + }, + }, + { + note: "v0 bundle, v1 per-file overrides (glob), compliant", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "*/policy2.rego": 1 + } +}`, + "policy1.rego": `package test +p[x] { + x := [1,2,3] +}`, + "policy2.rego": `package test +q contains x if { + x := [1,2,3] +}`, + }, + }, + { + note: "v0 bundle, v1 per-file overrides, incompliant", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "/policy2.rego": 1 + } +}`, + "policy1.rego": `package test +p[x] { + x := [1,2,3] +}`, + "policy2.rego": `package test +q[x] { + x := [1,2,3] +}`, + }, + expErrs: []string{ + "rego_parse_error: `if` keyword is required before rule body", + "rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + + { + note: "v1.0 bundle, keywords used but not imported", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +p contains x if { + x := [1,2,3] +}`, + }, + }, + { + note: "v1.0 bundle, rego.v1 imported, v1 compliant", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +import rego.v1 +p contains x if { + x := [1,2,3] +}`, + }, + }, + { + note: "v1.0 bundle, rego.v1 imported, NOT v1 compliant (parser)", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +import rego.v1 +p contains x { + x := [1,2,3] +} + +q.r`, + }, + expErrs: []string{ + "rego_parse_error: `if` keyword is required before rule body", + "rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v1.0 bundle, rego.v1 imported, NOT v1 compliant (compiler)", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +import rego.v1 + +import data.foo +import data.bar as foo +`, + }, + expErrs: []string{ + "rego_compile_error: import must not shadow import data.foo", + }, + }, + { + note: "v1.0 bundle, keywords imported, v1 compliant", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +import future.keywords.if +import future.keywords.contains +p contains x if { + x := [1,2,3] +}`, + }, + }, + { + note: "v1.0 bundle, keywords imported, NOT v1 compliant", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +import future.keywords.contains +p contains x { + x := [1,2,3] +} + +q.r`, + }, + expErrs: []string{ + "rego_parse_error: `if` keyword is required before rule body", + "rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v1.0 bundle, keywords imported, NOT v1 compliant (compiler)", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +import future.keywords.if + +input := 1 if { + 1 == 2 +}`, + }, + expErrs: []string{ + "rego_compile_error: rules must not shadow input (use a different rule name)", + }, + }, + { + note: "v1.0 bundle, no imports, v1 compliant", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +p := 1 +`, + }, + }, + { + note: "v1.0 bundle, no imports, NOT v1 compliant but v0 compliant (compiler)", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +p.x`, + }, + expErrs: []string{ + "rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v1.0 bundle, no imports, v1 compliant but NOT v0 compliant", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +p contains x if { + x := [1,2,3] +}`, + }, + }, + { + note: "v1 bundle, v0 per-file overrides, compliant", + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/policy1.rego": 0 + } +}`, + "policy1.rego": `package test +p[x] { + x := [1,2,3] +}`, + "policy2.rego": `package test +q contains x if { + x := [1,2,3] +}`, + }, + }, + { + note: "v1 bundle, v0 per-file overrides (glob), compliant", + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "*/policy1.rego": 0 + } +}`, + "policy1.rego": `package test +p[x] { + x := [1,2,3] +}`, + "policy2.rego": `package test +q contains x if { + x := [1,2,3] +}`, + }, + }, + { + note: "v1 bundle, v0 per-file overrides, incompliant", + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/policy1.rego": 0 + } +}`, + "policy1.rego": `package test +p contains x if { + x := [1,2,3] +}`, + "policy2.rego": `package test +q contains x if { + x := [1,2,3] +}`, + }, + expErrs: []string{ + "rego_parse_error: var cannot be used for rule name", + }, + }, + } + + bundleTypeCases := []struct { + note string + tar bool + }{ + { + "bundle dir", false, + }, + { + "bundle tar", true, + }, + } + + v1CompatibleFlagCases := []struct { + note string + used bool + }{ + { + "no --v1-compatible", false, + }, + { + "--v1-compatible", true, + }, + } + + for _, bundleType := range bundleTypeCases { + for _, v1CompatibleFlag := range v1CompatibleFlagCases { + for _, tc := range cases { + t.Run(fmt.Sprintf("%s, %s, %s", bundleType.note, v1CompatibleFlag.note, tc.note), func(t *testing.T) { + files := map[string]string{} + + if bundleType.tar { + files["bundle.tar.gz"] = "" + } else { + maps.Copy(files, tc.files) + } + + test.WithTempFS(files, func(root string) { + p := root + if bundleType.tar { + p = filepath.Join(root, "bundle.tar.gz") + files := make([][2]string, 0, len(tc.files)) + for k, v := range tc.files { + files = append(files, [2]string{k, v}) + } + buf := archive.MustWriteTarGz(files) + bf, err := os.Create(p) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + _, err = bf.Write(buf.Bytes()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + } + + params := newCheckParams() + params.bundleMode = true + params.v1Compatible = v1CompatibleFlag.used + + err := checkModules(params, []string{p}) + switch { + case err != nil && len(tc.expErrs) > 0: + for _, expErr := range tc.expErrs { + if !strings.Contains(err.Error(), expErr) { + t.Fatalf("expected err:\n\n%v\n\ngot:\n\n%v", expErr, err) + } + } + return // don't read back bundle below + case err != nil && len(tc.expErrs) == 0: + t.Fatalf("unexpected error: %v", err) + case err == nil && len(tc.expErrs) > 0: + t.Fatalf("expected error:\n\n%v\n\ngot: none", tc.expErrs) + } + }) + }) + } + } + } +} diff --git a/cmd/check_test.go b/cmd/check_test.go index 8bcf87863f..fd84ca8df4 100644 --- a/cmd/check_test.go +++ b/cmd/check_test.go @@ -1,3 +1,5 @@ +//go:build !go1.27 + // Copyright 2022 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. @@ -5,6 +7,7 @@ package cmd import ( + "bytes" "encoding/json" "fmt" "maps" @@ -14,7 +17,10 @@ import ( "strings" "testing" + "github.com/google/go-cmp/cmp" + "github.com/open-policy-agent/opa/internal/file/archive" + pr "github.com/open-policy-agent/opa/internal/presentation" "github.com/open-policy-agent/opa/v1/ast" "github.com/open-policy-agent/opa/v1/util/test" ) @@ -292,6 +298,46 @@ func TestCheckFailsOnInvalidRego(t *testing.T) { }) } +func TestCheckJSONOutputBytes(t *testing.T) { + files := map[string]string{ + "test.rego": `package test +{}`, + } + + test.WithTempFS(files, func(root string) { + params := newCheckParams() + + checkErr := checkModules(params, []string{root}) + if checkErr == nil { + t.Fatal("expected error but received none") + } + + var buf bytes.Buffer + if err := pr.JSON(&buf, pr.Output{Errors: pr.NewOutputErrors(checkErr)}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := strings.ReplaceAll(`{ + "errors": [ + { + "message": "object cannot be used for rule name", + "code": "rego_parse_error", + "location": { + "file": "TEMPDIR/test.rego", + "row": 2, + "col": 1 + } + } + ] +} +`, "TEMPDIR", root) + + if diff := cmp.Diff(expected, buf.String()); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } + }) +} + // Assert that 'schemas' annotations with schema refs are only informing the type checker when the --schema flag is used func TestCheckWithSchemasAnnotationButNoSchemaFlag(t *testing.T) { policiesWithSchemaRef := []string{` diff --git a/cmd/deps_jsonv2_test.go b/cmd/deps_jsonv2_test.go new file mode 100644 index 0000000000..86827f6ab9 --- /dev/null +++ b/cmd/deps_jsonv2_test.go @@ -0,0 +1,634 @@ +//go:build go1.27 + +// Copyright 2024 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 cmd + +import ( + "bytes" + "fmt" + "io" + "maps" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/open-policy-agent/opa/cmd/formats" + "github.com/open-policy-agent/opa/internal/file/archive" + "github.com/open-policy-agent/opa/v1/util/test" +) + +func TestDepsJSONOutputBytes(t *testing.T) { + files := map[string]string{ + "test.rego": `package test +p if { input.x }`, + } + + test.WithTempFS(files, func(rootPath string) { + params := newDepsCommandParams() + _ = params.outputFormat.Set(formats.JSON) + + for f := range files { + _ = params.dataPaths.Set(filepath.Join(rootPath, f)) + } + + var buf bytes.Buffer + if err := deps([]string{"data.test.p"}, params, &buf); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + expectedOutput := `{ + "base": [ + [ + { + "type": "var", + "value": "input" + }, + { + "type": "string", + "value": "x" + } + ] + ], + "virtual": [ + [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "test" + }, + { + "type": "string", + "value": "p" + } + ] + ] +} +` + + if diff := cmp.Diff(expectedOutput, buf.String()); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } + }) +} + +func TestDeps_DefaultRegoVersion(t *testing.T) { + tests := []struct { + note string + module string + query string + expErrs []string + }{ + { + note: "v0 module", + module: `package test +a[x] { + x := 42 +}`, + query: `data.test.p`, + expErrs: []string{ + "test.rego:2: rego_parse_error: `if` keyword is required before rule body", + "test.rego:2: rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v1 module", + module: `package test +a contains x if { + x := 42 +}`, + query: `data.test.a`, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + files := map[string]string{ + "test.rego": tc.module, + } + + test.WithTempFS(files, func(rootPath string) { + params := newDepsCommandParams() + _ = params.outputFormat.Set(formats.Pretty) + + for f := range files { + _ = params.dataPaths.Set(filepath.Join(rootPath, f)) + } + + err := deps([]string{tc.query}, params, io.Discard) + + if len(tc.expErrs) > 0 { + if err == nil { + t.Fatalf("Expected error but got nil") + } + for _, expErr := range tc.expErrs { + if !strings.Contains(err.Error(), expErr) { + t.Fatalf("Expected error:\n\n%s\n\ngot:\n\n%s", expErr, err.Error()) + } + } + } else if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + }) + }) + } +} + +func TestDepsCompatibleFlags(t *testing.T) { + tests := []struct { + note string + v0Compatible bool + v1Compatible bool + module string + query string + expErrs []string + }{ + { + note: "v0, no keywords", + v0Compatible: true, + module: `package test +p[3] { + input.x = 1 +}`, + query: `data.test.p`, + }, + { + note: "v0, keywords not imported, but used", + v0Compatible: true, + module: `package test +p contains 3 if { + input.x = 1 +}`, + query: `data.test.p`, + expErrs: []string{ + "rego_parse_error: var cannot be used for rule name", + "rego_parse_error: number cannot be used for rule name", + }, + }, + { + note: "v0, keywords imported", + v0Compatible: true, + module: `package test +import future.keywords +p contains 3 if { + input.x = 1 +}`, + query: `data.test.p`, + }, + { + note: "v0, rego.v1 imported", + v0Compatible: true, + module: `package test +import rego.v1 +p contains 3 if { + input.x = 1 +}`, + query: `data.test.p`, + }, + { + note: "v1, no keywords", + v1Compatible: true, + module: `package test +p[3] { + input.x = 1 +}`, + query: `data.test.p`, + expErrs: []string{ + "rego_parse_error: `if` keyword is required before rule body", + "rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v1, no keyword imports", + v1Compatible: true, + module: `package test +p contains 3 if { + input.x = 1 +}`, + query: `data.test.p`, + }, + { + note: "v1, keywords imported", + v1Compatible: true, + module: `package test +import future.keywords +p contains 3 if { + input.x = 1 +}`, + query: `data.test.p`, + }, + { + note: "v1, rego.v1 imported", + v1Compatible: true, + module: `package test +import rego.v1 +p contains 3 if { + input.x = 1 +}`, + query: `data.test.p`, + }, + // v0 takes precedence over v1 + { + note: "v0+v1, no keywords", + v0Compatible: true, + v1Compatible: true, + module: `package test +p[3] { + input.x = 1 +}`, + query: `data.test.p`, + }, + { + note: "v0+v1, keywords not imported, but used", + v0Compatible: true, + v1Compatible: true, + module: `package test +p contains 3 if { + input.x = 1 +}`, + query: `data.test.p`, + expErrs: []string{ + "rego_parse_error: var cannot be used for rule name", + "rego_parse_error: number cannot be used for rule name", + }, + }, + { + note: "v0+v1, keywords imported", + v0Compatible: true, + v1Compatible: true, + module: `package test +import future.keywords +p contains 3 if { + input.x = 1 +}`, + query: `data.test.p`, + }, + { + note: "v0+v1, rego.v1 imported", + v0Compatible: true, + v1Compatible: true, + module: `package test +import rego.v1 +p contains 3 if { + input.x = 1 +}`, + query: `data.test.p`, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + files := map[string]string{ + "test.rego": tc.module, + } + + test.WithTempFS(files, func(rootPath string) { + params := newDepsCommandParams() + params.v0Compatible = tc.v0Compatible + params.v1Compatible = tc.v1Compatible + _ = params.outputFormat.Set(formats.Pretty) + + for f := range files { + _ = params.dataPaths.Set(filepath.Join(rootPath, f)) + } + + err := deps([]string{tc.query}, params, io.Discard) + + if len(tc.expErrs) > 0 { + if err == nil { + t.Fatalf("Expected error but got nil") + } + for _, expErr := range tc.expErrs { + if !strings.Contains(err.Error(), expErr) { + t.Fatalf("Expected error:\n\n%s\n\ngot:\n\n%s", expErr, err.Error()) + } + } + } else if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + }) + }) + } +} + +func TestDepsV1WithBundleRegoVersion(t *testing.T) { + tests := []struct { + note string + files map[string]string + query string + expErrs []string + }{ + { + note: "v0.x bundle, no keywords", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +p[3] { + input.x = 1 +}`, + }, + query: `data.test.p`, + }, + { + note: "v0.x bundle, keywords not imported, but used", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +p contains 3 if { + input.x = 1 +}`, + }, + query: `data.test.p`, + expErrs: []string{ + "rego_parse_error: var cannot be used for rule name", + "rego_parse_error: number cannot be used for rule name", + }, + }, + { + note: "v0.x bundle, keywords imported", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +import future.keywords +p contains 3 if { + input.x = 1 +}`, + }, + query: `data.test.p`, + }, + { + note: "v0.x bundle, rego.v1 imported", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +import rego.v1 +p contains 3 if { + input.x = 1 +}`, + }, + query: `data.test.p`, + }, + { + note: "v0 bundle, v1 per-file override", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "/policy2.rego": 1 + } +}`, + "policy1.rego": `package test +p[3] { + input.x = 1 +}`, + "policy2.rego": `package test +p contains 4 if { + input.x = 1 +}`, + }, + }, + { + note: "v0 bundle, v1 per-file override (glob)", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "/bar/*.rego": 1 + } +}`, + "foo/policy1.rego": `package test +p[3] { + input.x = 1 +}`, + "bar/policy2.rego": `package test +p contains 4 if { + input.x = 1 +}`, + }, + }, + { + note: "v0 bundle, v1 per-file override, incompliant", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "/policy2.rego": 1 + } +}`, + "policy1.rego": `package test +p[3] { + input.x = 1 +}`, + "policy2.rego": `package test +p[4] { + input.x = 1 +}`, + }, + expErrs: []string{ + "rego_parse_error: `if` keyword is required before rule body", + "rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v1.0 bundle, no keywords", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +p[3] { + input.x = 1 +}`, + }, + query: `data.test.p`, + expErrs: []string{ + "rego_parse_error: `if` keyword is required before rule body", + "rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v1.0 bundle, no keyword imports", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +p contains 3 if { + input.x = 1 +}`, + }, + query: `data.test.p`, + }, + { + note: "v1.0 bundle, keywords imported", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +import future.keywords +p contains 3 if { + input.x = 1 +}`, + }, + query: `data.test.p`, + }, + { + note: "v1.0 bundle, rego.v1 imported", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +import rego.v1 +p contains 3 if { + input.x = 1 +}`, + }, + query: `data.test.p`, + }, + { + note: "v1 bundle, v0 per-file override", + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/policy1.rego": 0 + } +}`, + "policy1.rego": `package test +p[3] { + input.x = 1 +}`, + "policy2.rego": `package test +p contains 4 if { + input.x = 1 +}`, + }, + }, + { + note: "v1 bundle, v0 per-file override (glob)", + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/foo/*.rego": 0 + } +}`, + "foo/policy1.rego": `package test +p[3] { + input.x = 1 +}`, + "bar/policy2.rego": `package test +p contains 4 if { + input.x = 1 +}`, + }, + }, + { + note: "v1 bundle, v0 per-file override, incompliant", + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/policy1.rego": 0 + } +}`, + "policy1.rego": `package test +p contains 3 if { + input.x = 1 +}`, + "policy2.rego": `package test +p contains 4 if { + input.x = 1 +}`, + }, + expErrs: []string{ + "rego_parse_error: var cannot be used for rule name", + "rego_parse_error: number cannot be used for rule name", + }, + }, + } + + bundleTypeCases := []struct { + note string + tar bool + }{ + { + "bundle dir", false, + }, + { + "bundle tar", true, + }, + } + + v1CompatibleFlagCases := []struct { + note string + used bool + }{ + { + "no --v1-compatible", false, + }, + { + "--v1-compatible", true, + }, + } + + for _, bundleType := range bundleTypeCases { + for _, v1CompatibleFlag := range v1CompatibleFlagCases { + for _, tc := range tests { + t.Run(fmt.Sprintf("%s, %s, %s", bundleType.note, v1CompatibleFlag.note, tc.note), func(t *testing.T) { + files := map[string]string{} + + if bundleType.tar { + files["bundle.tar.gz"] = "" + } else { + maps.Copy(files, tc.files) + } + + test.WithTempFS(files, func(root string) { + p := root + if bundleType.tar { + p = filepath.Join(root, "bundle.tar.gz") + files := make([][2]string, 0, len(tc.files)) + for k, v := range tc.files { + files = append(files, [2]string{k, v}) + } + buf := archive.MustWriteTarGz(files) + bf, err := os.Create(p) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + _, err = bf.Write(buf.Bytes()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + } + + params := newDepsCommandParams() + if err := params.bundlePaths.Set(p); err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + params.v1Compatible = v1CompatibleFlag.used + _ = params.outputFormat.Set(formats.Pretty) + + err := deps([]string{tc.query}, params, io.Discard) + + if len(tc.expErrs) > 0 { + if err == nil { + t.Fatalf("Expected error but got nil") + } + for _, expErr := range tc.expErrs { + if !strings.Contains(err.Error(), expErr) { + t.Fatalf("Expected error:\n\n%s\n\ngot:\n\n%s", expErr, err.Error()) + } + } + } else if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + }) + }) + } + } + } +} diff --git a/cmd/deps_test.go b/cmd/deps_test.go index bfde943a16..a41bcaecaf 100644 --- a/cmd/deps_test.go +++ b/cmd/deps_test.go @@ -1,3 +1,5 @@ +//go:build !go1.27 + // Copyright 2024 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. @@ -5,6 +7,7 @@ package cmd import ( + "bytes" "fmt" "io" "maps" @@ -13,11 +16,69 @@ import ( "strings" "testing" + "github.com/google/go-cmp/cmp" "github.com/open-policy-agent/opa/cmd/formats" "github.com/open-policy-agent/opa/internal/file/archive" "github.com/open-policy-agent/opa/v1/util/test" ) +func TestDepsJSONOutputBytes(t *testing.T) { + files := map[string]string{ + "test.rego": `package test +p if { input.x }`, + } + + test.WithTempFS(files, func(rootPath string) { + params := newDepsCommandParams() + _ = params.outputFormat.Set(formats.JSON) + + for f := range files { + _ = params.dataPaths.Set(filepath.Join(rootPath, f)) + } + + var buf bytes.Buffer + if err := deps([]string{"data.test.p"}, params, &buf); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + expectedOutput := `{ + "base": [ + [ + { + "type": "var", + "value": "input" + }, + { + "type": "string", + "value": "x" + } + ] + ], + "virtual": [ + [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "test" + }, + { + "type": "string", + "value": "p" + } + ] + ] +} +` + + if diff := cmp.Diff(expectedOutput, buf.String()); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } + }) +} + func TestDeps_DefaultRegoVersion(t *testing.T) { tests := []struct { note string diff --git a/cmd/eval_jsonv2_test.go b/cmd/eval_jsonv2_test.go new file mode 100644 index 0000000000..7bcb1edcf4 --- /dev/null +++ b/cmd/eval_jsonv2_test.go @@ -0,0 +1,3985 @@ +//go:build go1.27 + +// Copyright 2018 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. + +// nolint: goconst // string duplication is for test readability. +package cmd + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "maps" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/open-policy-agent/opa/cmd/formats" + "github.com/open-policy-agent/opa/internal/file/archive" + "github.com/open-policy-agent/opa/internal/presentation" + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/loader" + "github.com/open-policy-agent/opa/v1/rego" + "github.com/open-policy-agent/opa/v1/topdown" + "github.com/open-policy-agent/opa/v1/util" + "github.com/open-policy-agent/opa/v1/util/test" +) + +func TestEvalWithIllegalUnknownArgs(t *testing.T) { + + tests := []struct { + name string + unknowns string + expectedErr error + }{ + { + name: "happy path: passing input ref as unknown", + unknowns: "input", + expectedErr: nil, + }, + { + name: "happy path: passing input.users ref as unknown", + unknowns: "input.users", + expectedErr: nil, + }, + { + name: "passing multiple refs with ; separated", + unknowns: "input;input.users", + expectedErr: errors.New("expected exactly one term but got: input; input.users"), + }, + { + name: "passing array as unknown", + unknowns: "[input, data.posts]", + expectedErr: errIllegalUnknownsArg, + }, + { + name: "passing set as unknown", + unknowns: "{input, data.posts}", + expectedErr: errIllegalUnknownsArg, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + params := newEvalCommandParams() + params.unknowns = []string{tt.unknowns} + params.partial = true + + err := validateEvalParams(¶ms, []string{"data"}) + + if tt.expectedErr != nil && !strings.EqualFold(err.Error(), tt.expectedErr.Error()) { + t.Errorf("expected %s; got %s", errIllegalUnknownsArg.Error(), err.Error()) + } + }) + } +} + +func TestEvalExitCode(t *testing.T) { + params := newEvalCommandParams() + params.fail = true + + tests := []struct { + note string + query string + wantDefined bool + wantErr bool + }{ + {"defined result", "true=true", true, false}, + {"undefined result", "true = false", false, false}, + {"on error", `{k: v | k = ["a", "a"][_]; v = [0,1][_]}`, false, true}, + } + + var b bytes.Buffer + writer := bufio.NewWriter(&b) + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + defined, err := eval([]string{tc.query}, params, writer, nil) + if tc.wantErr && err == nil { + t.Fatal("wanted error but got success") + } else if !tc.wantErr && err != nil { + t.Fatal("wanted success but got error:", err) + } else if (tc.wantDefined && !defined) || (!tc.wantDefined && defined) { + t.Fatalf("wanted defined %v but got defined %v", tc.wantDefined, defined) + } + }) + } +} + +func TestEvalWithShowBuiltinErrors(t *testing.T) { + files := map[string]string{ + "x.rego": `package x + +p if { + 1/0 +} + +q if { + 1/0 +}`, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.showBuiltinErrors = true + params.dataPaths = newrepeatedStringFlag([]string{path}) + + var buf bytes.Buffer + + defined, err := eval([]string{"data.x"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("unexpected undefined or error: %v", err) + } + + var output presentation.Output + + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + + if len(output.Errors) != 2 { + t.Fatalf("Expected 2 errors in result, got:%v", len(output.Errors)) + } + + expectedCode := "eval_builtin_error" + expectedMessage := "div: divide by zero" + + if code := output.Errors[0].Code; code != expectedCode { + t.Fatalf("expected code '%v', got '%v'", expectedCode, code) + } + if msg := output.Errors[0].Message; msg != expectedMessage { + t.Fatalf("expected message '%v', got '%v'", expectedMessage, msg) + } + + if code := output.Errors[1].Code; code != expectedCode { + t.Fatalf("expected code '%v', got '%v'", expectedCode, code) + } + if msg := output.Errors[1].Message; msg != expectedMessage { + t.Fatalf("expected message '%v', got '%v'", expectedMessage, msg) + } + + loc1 := output.Errors[0].Location + if loc1 == nil { + t.Fatal("unexpected nil location") + } + + loc2 := output.Errors[1].Location + if loc2 == nil { + t.Fatal("unexpected nil location") + } + + if loc1.Row == loc2.Row { + t.Fatal("expected 2 distinct error occurrences in policy") + } + }) +} + +func TestEvalWithProfiler(t *testing.T) { + files := map[string]string{ + "x.rego": `package x + +p if { + a := 1 + b := 2 + c := 3 + x = a + b * c +}`, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.profile = true + params.profileCriteria = newrepeatedStringFlag([]string{"line"}) + params.dataPaths = newrepeatedStringFlag([]string{path}) + + var buf bytes.Buffer + + defined, err := eval([]string{"data"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + + var output presentation.Output + + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + + if len(output.Profile) == 0 { + t.Fatal("Expected profile output to be non-empty") + } + + expectedNumEval := []int{3, 1, 1, 1, 1} + expectedNumRedo := []int{3, 1, 1, 1, 1} + expectedRow := []int{7, 6, 5, 4, 1} + expectedNumGenExpr := []int{3, 1, 1, 1, 1} + + for idx, actualExprStat := range output.Profile { + if actualExprStat.NumEval != expectedNumEval[idx] { + t.Fatalf("Index %v: Expected number of evals %v but got %v", idx, expectedNumEval[idx], actualExprStat.NumEval) + } + + if actualExprStat.NumRedo != expectedNumRedo[idx] { + t.Fatalf("Index %v: Expected number of redos %v but got %v", idx, expectedNumRedo[idx], actualExprStat.NumRedo) + } + + if actualExprStat.Location.Row != expectedRow[idx] { + t.Fatalf("Index %v: Expected row %v but got %v", idx, expectedRow[idx], actualExprStat.Location.Row) + } + + if actualExprStat.NumGenExpr != expectedNumGenExpr[idx] { + t.Fatalf("Index %v: Expected number of generated expressions %v but got %v", idx, expectedNumGenExpr[idx], actualExprStat.NumGenExpr) + } + } + }) +} + +func TestEvalWithCoverage(t *testing.T) { + + files := map[string]string{ + "x.rego": `package x + +p = 1`, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.coverage = true + params.dataPaths = newrepeatedStringFlag([]string{path}) + + var buf bytes.Buffer + + defined, err := eval([]string{"data"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + + var output presentation.Output + + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + + if output.Coverage == nil || output.Coverage.Coverage != 100.0 { + t.Fatalf("Expected coverage in output but got: %v", buf.String()) + } + }) +} + +func TestEvalWithOptimizeErrors(t *testing.T) { + files := map[string]string{ + "x.rego": `package x + +p = 1`, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.optimizationLevel = 1 + params.dataPaths = newrepeatedStringFlag([]string{path}) + if err := params.bundlePaths.Set(path); err != nil { + t.Fatal(err) + } + + err := validateEvalParams(¶ms, []string{"data"}) + if err == nil { + t.Fatal("Expected error but got nil") + } + + expected := "specify either --data or --bundle flag with optimization level greater than 0" + if err.Error() != expected { + t.Fatalf("Expected error %v but got %v", expected, err.Error()) + } + + params = newEvalCommandParams() + params.optimizationLevel = 1 + params.dataPaths = newrepeatedStringFlag([]string{path}) + + var buf bytes.Buffer + + _, err = eval([]string{"data.test"}, params, &buf, nil) + if err == nil { + t.Fatal("Expected error but got nil") + } + + expected = "bundle optimizations require at least one entrypoint" + if err.Error() != expected { + t.Fatalf("Expected error %v but got %v", expected, err.Error()) + } + }) +} + +func TestEvalWithOptimize(t *testing.T) { + files := map[string]string{ + "test.rego": ` + package test + + default p = false + p if { q } + q if { input.x = data.foo }`, + "data.json": ` + {"foo": 1}`, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.optimizationLevel = 1 + params.dataPaths = newrepeatedStringFlag([]string{path}) + params.entrypoints = newrepeatedStringFlag([]string{"test/p"}) + + var buf bytes.Buffer + + defined, err := eval([]string{"data.test.p"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + }) +} + +// Ensure that entrypoint annotations don't cause panics when using +// higher levels of optimization. +// Reference: https://github.com/open-policy-agent/opa/issues/5368 +func TestEvalIssue5368(t *testing.T) { + files := map[string]string{ + "test.rego": ` +package system + +object_key_exists(object, key) if { + _ = object[key] +} + +default main = false + +# METADATA +# entrypoint: true +main := results if { + object_key_exists(input, "queries") + results := {key: result | + result := input.queries[key] + } +}`, + "input.json": `{}`, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.optimizationLevel = 2 + params.dataPaths = newrepeatedStringFlag([]string{path}) + params.inputPath = filepath.Join(path, "input.json") + + var buf bytes.Buffer + + defined, err := eval([]string{"data.system.main"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + }) +} + +func TestEvalWithOptimizeBundleData(t *testing.T) { + files := map[string]string{ + "test.rego": ` + package test + + default p = false + p if { q } + q if { input.x = data.foo }`, + "data.json": ` + {"foo": 1}`, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.optimizationLevel = 1 + if err := params.bundlePaths.Set(path); err != nil { + t.Fatal(err) + } + params.entrypoints = newrepeatedStringFlag([]string{"test/p"}) + + var buf bytes.Buffer + + defined, err := eval([]string{"data.test.p"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + }) +} + +func testEvalWithInputFile(t *testing.T, input string, query string, params evalCommandParams) error { + files := map[string]string{ + "input.json": input, + } + + var err error + test.WithTempFS(files, func(path string) { + + params.inputPath = filepath.Join(path, "input.json") + + var buf bytes.Buffer + var defined bool + defined, err = eval([]string{query}, params, &buf, nil) + if !defined || err != nil { + err = fmt.Errorf("Unexpected error or undefined from evaluation: %v", err) + return + } + + var output presentation.Output + + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + + rs := output.Result + if exp, act := true, rs.Allowed(); exp != act { + t.Errorf("expected %v, got %v", exp, act) + } + }) + + return err +} + +func TestEvalWithInvalidInputFile(t *testing.T) { + input := `{badjson` + query := "input.b[0].a == 1" + err := testEvalWithInputFile(t, input, query, newEvalCommandParams()) + if err == nil { + t.Fatalf("expected error but err == nil") + } +} + +func testEvalWithSchemaFile(t *testing.T, input string, query string, schema string, policy string, expTypeErr bool) error { + files := map[string]string{ + "input.json": input, + "schema.json": schema, + } + + policyFilePresent := policy != "" + if policyFilePresent { + files["policy.rego"] = policy + } + + var err error + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.inputPath = filepath.Join(path, "input.json") + if policyFilePresent { + params.dataPaths = newrepeatedStringFlag([]string{path}) + } + params.schema = &schemaFlags{path: filepath.Join(path, "schema.json")} + + var buf bytes.Buffer + defined, evalErr := eval([]string{query}, params, &buf, nil) + if !expTypeErr && (!defined || evalErr != nil) { + err = fmt.Errorf("unexpected error or undefined from evaluation: %v", evalErr) + return + } + + var output presentation.Output + + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + + if expTypeErr { + if len(output.Errors) != 1 || output.Errors[0].Code != "rego_type_error" { + err = fmt.Errorf("expected type conflict, got %v", output.Errors) + } + return + } + + rs := output.Result + if exp, act := true, rs.Allowed(); exp != act { + t.Errorf("expected %v, got %v", exp, act) + } + }) + + return err +} + +func testEvalWithInvalidSchemaFile(input string, query string, schema string) error { + files := map[string]string{ + "input.json": input, + "schema.json": schema, + } + + var err error + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.inputPath = filepath.Join(path, "input.json") + params.schema = &schemaFlags{path: filepath.Join(path, "schemaBad.json")} + + var buf bytes.Buffer + var defined bool + defined, err = eval([]string{query}, params, &buf, nil) + if !defined || err != nil { + err = fmt.Errorf("Unexpected error or undefined from evaluation: %v", err) + return + } + }) + + return err +} + +func testEvalWithSchemasAnnotationButNoSchemaFlag(policy string) error { + query := "data.test.p" + + files := map[string]string{ + "input.json": `{ + "foo": 42 + }`, + "test.rego": policy, + } + + var err error + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.inputPath = filepath.Join(path, "input.json") + params.dataPaths = newrepeatedStringFlag([]string{path}) + + var buf bytes.Buffer + var defined bool + defined, err = eval([]string{query}, params, &buf, nil) + if !defined || err != nil { + err = errors.New(buf.String()) + } + }) + + return err +} + +// Assert that 'schemas' annotations with schema refs are only informing the type checker when the --schema flag is used +func TestEvalWithSchemasAnnotationButNoSchemaFlag(t *testing.T) { + policyWithSchemaRef := ` +package test + +# METADATA +# schemas: +# - input: schema["input"] +p if { + rego.metadata.rule() # presence of rego.metadata.* calls must not trigger unwanted schema evaluation + input.foo == 42 # type mismatch with schema that should be ignored +}` + + err := testEvalWithSchemasAnnotationButNoSchemaFlag(policyWithSchemaRef) + if err != nil { + t.Fatalf("unexpected error from eval with schema ref: %v", err) + } + + policyWithInlinedSchema := ` +package test + +# METADATA +# schemas: +# - input.foo: {"type": "boolean"} +p if { + rego.metadata.rule() # presence of rego.metadata.* calls must not trigger unwanted schema evaluation + input.foo == 42 # type mismatch with schema that should NOT be ignored since it is an inlined schema format +}` + + err = testEvalWithSchemasAnnotationButNoSchemaFlag(policyWithInlinedSchema) + // We expect an error here, as inlined schemas are always used for type checking + if !strings.Contains(err.Error(), `"code": "rego_type_error"`) { + t.Fatalf("unexpected error from eval with inlined schema, got: %v", err) + } +} + +func testReadParamWithSchemaDir(input string, inputSchema string) error { + files := map[string]string{ + "input.json": input, + "schemas/input.json": inputSchema, + "schemas/kubernetes/data-schema.json": inputSchema, + } + + var err error + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.inputPath = filepath.Join(path, "input.json") + params.schema = &schemaFlags{path: filepath.Join(path, "schemas")} + + // Don't assign over "err" or "err =" does nothing. + schemaSet, errSchema := loader.Schemas(params.schema.path) + if errSchema != nil { + err = fmt.Errorf("Unexpected error or undefined from evaluation: %v", errSchema) + return + } + + if schemaSet == nil { + err = errors.New("Schema set is empty") + return + } + + if schemaSet.Get(ast.MustParseRef("schema.input")) == nil { + err = errors.New("Expected schema for input in schemaSet but got none") + return + } + + if schemaSet.Get(ast.MustParseRef(`schema.kubernetes["data-schema"]`)) == nil { + err = errors.New("Expected schemas for data in schemaSet but got none") + return + } + + }) + + return err +} + +func TestEvalWithRecursiveJSONSchema(t *testing.T) { + tests := []struct { + note string + input string + query string + schema string + policy string + expTypeErr bool + }{ + { + note: "recursive object ref - valid usage", + input: `{"foo": {"foo": {}}}`, + query: "data.p.allow", + schema: `{ + "$ref": "#/$defs/foo", + "$defs": { + "foo": { + "type": "object", + "properties": { + "foo": { + "$ref": "#/$defs/foo" + } + } + } + } +}`, + policy: `package p + +allow if input.foo`, + }, + { + note: "recursive object ref - type mismatch at top level", + input: `{"foo": {"foo": {}}}`, + query: "data.test.p", + schema: `{ + "$ref": "#/$defs/foo", + "$defs": { + "foo": { + "type": "object", + "properties": { + "foo": { + "$ref": "#/$defs/foo" + } + } + } + } +}`, + policy: ` +package test + +# METADATA +# schemas: +# - input: schema +p if { + input.foo == 42 +}`, + expTypeErr: true, + }, + { + note: "recursive object ref - nested type mismatch", + input: `{"foo": {"foo": {}}}`, + query: "data.test.p", + schema: `{ + "$ref": "#/$defs/foo", + "$defs": { + "foo": { + "type": "object", + "properties": { + "foo": { + "$ref": "#/$defs/foo" + } + } + } + } +}`, + policy: ` +package test + +# METADATA +# schemas: +# - input: schema +p if { + input.foo.foo == "hello" +}`, + expTypeErr: true, + }, + { + note: "recursive object ref - inlined schema type mismatch", + input: `{"foo": {"foo": {}}}`, + query: "data.test.p", + schema: `{ + "$ref": "#/$defs/foo", + "$defs": { + "foo": { + "type": "object", + "properties": { + "foo": { + "$ref": "#/$defs/foo" + } + } + } + } +}`, + policy: ` +package test + +# METADATA +# schemas: +# - input.foo: {"type": "boolean"} +p if { + input.foo == 42 +}`, + expTypeErr: true, + }, + { + note: "recursive object ref - valid with schema annotation", + input: `{"foo": {"foo": {}}}`, + query: "data.test.p", + schema: `{ + "$ref": "#/$defs/foo", + "$defs": { + "foo": { + "type": "object", + "properties": { + "foo": { + "$ref": "#/$defs/foo" + } + } + } + } +}`, + policy: ` +package test + +# METADATA +# schemas: +# - input: schema +p if { + input.foo +}`, + }, + { + note: "recursive array ref - valid usage", + input: `{"tree": [[[]]]}`, + query: "data.p.allow", + schema: `{ + "type": "object", + "properties": { + "tree": { + "$ref": "#/$defs/tree" + } + }, + "$defs": { + "tree": { + "type": "array", + "items": { + "$ref": "#/$defs/tree" + } + } + } +}`, + policy: `package p + +allow if input.tree`, + }, + { + note: "recursive array ref - type mismatch on element", + input: `{"tree": [[[]]]}`, + query: "data.test.p", + schema: `{ + "type": "object", + "properties": { + "tree": { + "$ref": "#/$defs/tree" + } + }, + "$defs": { + "tree": { + "type": "array", + "items": { + "$ref": "#/$defs/tree" + } + } + } +}`, + policy: ` +package test + +# METADATA +# schemas: +# - input: schema +p if { + input.tree[0] == "hello" +}`, + expTypeErr: true, + }, + { + note: "recursive anyOf ref - valid usage", + input: `{"node": ["hello", ["world"]]}`, + query: "data.p.allow", + schema: `{ + "type": "object", + "properties": { + "node": { + "$ref": "#/$defs/node" + } + }, + "$defs": { + "node": { + "anyOf": [ + { "type": "string" }, + { + "type": "array", + "items": { "$ref": "#/$defs/node" } + } + ] + } + } +}`, + policy: `package p + +allow if input.node`, + }, + { + note: "non-recursive ref - valid usage", + input: `{"addr": {"street": "Main St", "city": "Springfield"}}`, + query: "data.p.allow", + schema: `{ + "type": "object", + "properties": { + "addr": { + "$ref": "#/$defs/address" + } + }, + "$defs": { + "address": { + "type": "object", + "properties": { + "street": { "type": "string" }, + "city": { "type": "string" } + } + } + } +}`, + policy: `package p + +allow if input.addr.street`, + }, + { + note: "non-recursive ref - type mismatch", + input: `{"addr": {"street": "Main St", "city": "Springfield"}}`, + query: "data.test.p", + schema: `{ + "type": "object", + "properties": { + "addr": { + "$ref": "#/$defs/address" + } + }, + "$defs": { + "address": { + "type": "object", + "properties": { + "street": { "type": "string" }, + "city": { "type": "string" } + } + } + } +}`, + policy: ` +package test + +# METADATA +# schemas: +# - input: schema +p if { + input.addr.street == 42 +}`, + expTypeErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + err := testEvalWithSchemaFile(t, tc.input, tc.query, tc.schema, tc.policy, tc.expTypeErr) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + }) + } +} + +func TestEvalWithJSONSchema(t *testing.T) { + + input := `{ + "foo": "a", + "b": [ + { + "a": 1, + "b": [1, 2, 3], + "c": null + } + ] +}` + + schema := `{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "http://example.com/example.json", + "type": "object", + "title": "The root schema", + "description": "The root schema comprises the entire JSON document.", + "required": [ + "foo", + "b" + ], + "properties": { + "foo": { + "$id": "#/properties/foo", + "type": "string", + "title": "The foo schema", + "description": "An explanation about the purpose of this instance." + }, + "b": { + "$id": "#/properties/b", + "type": "array", + "title": "The b schema", + "description": "An explanation about the purpose of this instance.", + "additionalItems": false, + "items": { + "$id": "#/properties/b/items", + "type": "object", + "title": "The items schema", + "description": "An explanation about the purpose of this instance.", + "required": [ + "a", + "b", + "c" + ], + "properties": { + "a": { + "$id": "#/properties/b/items/properties/a", + "type": "integer", + "title": "The a schema", + "description": "An explanation about the purpose of this instance." + }, + "b": { + "$id": "#/properties/b/items/properties/b", + "type": "array", + "title": "The b schema", + "description": "An explanation about the purpose of this instance.", + "additionalItems": false, + "items": { + "$id": "#/properties/b/items/properties/b/items", + "type": "integer", + "title": "The items schema", + "description": "An explanation about the purpose of this instance." + } + }, + "c": { + "$id": "#/properties/b/items/properties/c", + "type": "null", + "title": "The c schema", + "description": "An explanation about the purpose of this instance." + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }` + + query := "input.b[0].a == 1" + err := testEvalWithSchemaFile(t, input, query, schema, "", false) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + policyWithSchemasAnnotation := ` +package test + +# METADATA +# schemas: +# - input: schema +p if { + input.foo == 42 # type mismatch +}` + err = testEvalWithSchemaFile(t, input, query, schema, policyWithSchemasAnnotation, true) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + policyWithInlinedSchemasAnnotation := ` +package test + +# METADATA +# schemas: +# - input.foo: {"type": "boolean"} +p if { + input.foo == 42 # type mismatch +}` + err = testEvalWithSchemaFile(t, input, query, schema, policyWithInlinedSchemasAnnotation, true) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + err = testReadParamWithSchemaDir(input, schema) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } +} + +func TestEvalWithInvalidSchemaFile(t *testing.T) { + + input := `{ + "foo": "a", + "b": [ + { + "a": 1, + "b": [1, 2, 3], + "c": null + } + ] + }` + + schema := `{badjson` + + query := "input.b[0].a == 1" + err := testEvalWithSchemaFile(t, input, query, schema, "", false) + if err == nil { + t.Fatalf("expected error but err == nil") + } + + err = testEvalWithInvalidSchemaFile(input, query, schema) + if err == nil { + t.Fatalf("expected error but err == nil") + } +} + +func TestEvalWithSchemaFileWithRemoteRef(t *testing.T) { + + input := `{"metadata": {"clusterName": "NAME"}}` + schemaFmt := `{ + "type": "object", + "properties": { + "metadata": { + "$ref": "%s/v1.14.0/_definitions.json#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta", + "description": "Standard object's metadata. More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#metadata" + } + } +}` + ts := kubeSchemaServer(t) + t.Cleanup(ts.Close) + + query := "data.p.r" + files := map[string]string{ + "input.json": input, + "schema.json": fmt.Sprintf(schemaFmt, ts.URL), + "p.rego": `package p + +r if { + input.metadata.clusterName == "NAME" +}`, + } + + t.Run("all remote refs disabled", func(t *testing.T) { + test.WithTempFS(files, func(path string) { + params := newEvalCommandParams() + params.inputPath = filepath.Join(path, "input.json") + params.schema = &schemaFlags{path: filepath.Join(path, "schema.json")} + params.capabilities.C = ast.CapabilitiesForThisVersion() + params.capabilities.C.AllowNet = []string{} + _ = params.dataPaths.Set(filepath.Join(path, "p.rego")) + + var buf bytes.Buffer + _, err := eval([]string{query}, params, &buf, nil) + if err == nil { + t.Fatal("expected error, got nil") + } + var output presentation.Output + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + if exp, act := 1, len(output.Errors); exp != act { + t.Fatalf("expected %d errors, got %d", exp, act) + } + if exp, act := "rego_type_error", output.Errors[0].Code; exp != act { + t.Errorf("expected code %v, got %v", exp, act) + } + }) + }) + + t.Run("all remote refs enabled", func(t *testing.T) { + test.WithTempFS(files, func(path string) { + params := newEvalCommandParams() + params.inputPath = filepath.Join(path, "input.json") + params.schema = &schemaFlags{path: filepath.Join(path, "schema.json")} + _ = params.dataPaths.Set(filepath.Join(path, "p.rego")) + + var buf bytes.Buffer + defined, err := eval([]string{query}, params, &buf, nil) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if exp, act := true, defined; exp != act { + t.Errorf("expected defined %v, got %v", exp, act) + } + }) + }) + + t.Run("required remote ref host not enabled", func(t *testing.T) { + test.WithTempFS(files, func(path string) { + params := newEvalCommandParams() + params.inputPath = filepath.Join(path, "input.json") + params.schema = &schemaFlags{path: filepath.Join(path, "schema.json")} + params.capabilities.C = ast.CapabilitiesForThisVersion() + params.capabilities.C.AllowNet = []string{"something.else"} + _ = params.dataPaths.Set(filepath.Join(path, "p.rego")) + + var buf bytes.Buffer + _, err := eval([]string{query}, params, &buf, nil) + if err == nil { + t.Fatal("expected error, got nil") + } + var output presentation.Output + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + if exp, act := 1, len(output.Errors); exp != act { + t.Fatalf("expected %d errors, got %d", exp, act) + } + if exp, act := "rego_type_error", output.Errors[0].Code; exp != act { + t.Errorf("expected code %v, got %v", exp, act) + } + }) + }) + + t.Run("only required remote ref host enabled", func(t *testing.T) { + test.WithTempFS(files, func(path string) { + params := newEvalCommandParams() + params.inputPath = filepath.Join(path, "input.json") + params.schema = &schemaFlags{path: filepath.Join(path, "schema.json")} + params.capabilities.C = ast.CapabilitiesForThisVersion() + params.capabilities.C.AllowNet = []string{"127.0.0.1"} + _ = params.dataPaths.Set(filepath.Join(path, "p.rego")) + + var buf bytes.Buffer + defined, err := eval([]string{query}, params, &buf, nil) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if exp, act := true, defined; exp != act { + t.Errorf("expected defined %v, got %v", exp, act) + } + }) + }) +} + +func TestBuiltinsCapabilities(t *testing.T) { + tests := []struct { + note string + policy string + query string + ruleName string + expectedCode string + expectedMessage string + }{ + { + note: "rego.metadata.chain() not allowed", + policy: "package p\n r := rego.metadata.chain()", + query: "data.p", + ruleName: "rego.metadata.chain", + expectedCode: "rego_type_error", + expectedMessage: "undefined function rego.metadata.chain", + }, + { + note: "rego.metadata.rule() not allowed", + policy: "package p\n r := rego.metadata.rule()", + query: "data.p", + ruleName: "rego.metadata.rule", + expectedCode: "rego_type_error", + expectedMessage: "undefined function rego.metadata.rule", + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + + files := map[string]string{ + "p.rego": tc.policy, + } + + test.WithTempFS(files, func(path string) { + params := newEvalCommandParams() + params.capabilities.C = ast.CapabilitiesForThisVersion() + params.capabilities.C.Builtins = removeBuiltin(params.capabilities.C.Builtins, tc.ruleName) + + _ = params.dataPaths.Set(filepath.Join(path, "p.rego")) + + var buf bytes.Buffer + _, err := eval([]string{tc.query}, params, &buf, nil) + if err == nil { + t.Fatal("expected error, got nil") + } + var output presentation.Output + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + if exp, act := 1, len(output.Errors); exp != act { + t.Fatalf("expected %d errors, got %d", exp, act) + } + if code := output.Errors[0].Code; code != tc.expectedCode { + t.Errorf("expected code '%v', got '%v'", tc.expectedCode, code) + } + if msg := output.Errors[0].Message; msg != tc.expectedMessage { + t.Errorf("expected message '%v', got '%v'", tc.expectedMessage, msg) + } + }) + }) + } +} + +func removeBuiltin(builtins []*ast.Builtin, name string) []*ast.Builtin { + var cpy []*ast.Builtin + for _, builtin := range builtins { + if builtin.Name != name { + cpy = append(cpy, builtin) + } + } + return cpy +} + +// Nearly identical to TestEvalWithOptimizeBundleData, but uses +// Rego entrypoint annotations instead of explicitly providing +// the entrypoints as CLI arguments. +func TestEvalWithRegoEntrypointAnnotations(t *testing.T) { + files := map[string]string{ + "test.rego": ` +package test + +default p = false +# METADATA +# entrypoint: true +p if { q } +q if { input.x = data.foo }`, + "data.json": ` +{"foo": 1}`, + } + + test.WithTempFS(files, func(path string) { + params := newEvalCommandParams() + if err := params.bundlePaths.Set(path); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + + defined, err := eval([]string{"data.test.p"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + }) +} + +func TestEvalReturnsRegoError(t *testing.T) { + buf := new(bytes.Buffer) + _, err := eval([]string{`{k: v | k = ["a", "a"][_]; v = [0,1][_]}`}, newEvalCommandParams(), buf, nil) + if _, ok := err.(regoError); !ok { + t.Fatal("expected regoError but got:", err) + } +} + +func TestEvalBundlePathWithIgnoreFlag(t *testing.T) { + files := map[string]string{ + "good_policy.rego": ` + package example + p1 if { data.foo }`, + "bad_policy.rego": ` + package example + var `, + "data.json": ` + {"foo": true, "bar": false}`, + } + + test.WithTempFS(files, func(path string) { + params := newEvalCommandParams() + if err := params.bundlePaths.Set(path); err != nil { + t.Fatalf("Unable to set bundle path: %v", err) + } + params.ignore = []string{"bad_policy.rego"} + + var buf bytes.Buffer + + // Evaluate policies + defined, err := eval([]string{"data.example.p1"}, params, &buf, &buf) + + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error for p1: %v", err) + } + }) +} + +func TestEvalWithBundleData(t *testing.T) { + files := map[string]string{ + "x/x.rego": "package x\np = 1", + "x/data.json": `{"b": "bar"}`, + "other/not-data.json": `{"ignored": "data"}`, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + if err := params.bundlePaths.Set(path); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + + defined, err := eval([]string{"data"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + + var output presentation.Output + + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + + assertResultSet(t, output.Result, `[[{"x": {"p": 1, "b": "bar"}}]]`) + }) +} + +func TestEvalWithBundleDuplicateFileNames(t *testing.T) { + files := map[string]string{ + // bundle a + "a/policy.rego": "package a\np = 1", + "a/.manifest": `{"roots":["a"]}`, + + // bundle b + "b/policy.rego": "package b\nq = 1", + "b/.manifest": `{"roots":["b"]}`, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + if err := params.bundlePaths.Set(filepath.Join(path, "a")); err != nil { + t.Fatal(err) + } + if err := params.bundlePaths.Set(filepath.Join(path, "b")); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + + defined, err := eval([]string{"data"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + + var output presentation.Output + + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + + assertResultSet(t, output.Result, `[[{"a":{"p":1},"b":{"q":1}}]]`) + }) +} + +func TestEvalWithReadASTValuesFromStore(t *testing.T) { + // Note: This test is a bit of a hack. It's difficult to discern whether AST values were actually read from the store. + // This just ensures that we don't get any unexpected errors when enabling the flag. + + tests := []struct { + note string + readAst bool + }{ + { + note: "read raw data from store", + readAst: false, + }, + { + note: "read AST values from store", + readAst: true, + }, + } + + files := map[string]string{ + "test.rego": ` + package test + p = 1`, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + test.WithTempFS(files, func(path string) { + params := newEvalCommandParams() + params.dataPaths = newrepeatedStringFlag([]string{path}) + params.ReadAstValuesFromStore = tc.readAst + + var buf bytes.Buffer + + defined, err := eval([]string{"data.test.p"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + }) + }) + } +} + +func TestEvalWithStrictBuiltinErrors(t *testing.T) { + params := newEvalCommandParams() + params.strictBuiltinErrors = true + + var buf bytes.Buffer + _, err := eval([]string{"1/0"}, params, &buf, nil) + if err == nil { + t.Fatal("expected error") + } + + params.strictBuiltinErrors = false + buf.Reset() + + _, err = eval([]string{"1/0"}, params, &buf, nil) + if err != nil { + t.Fatal("unexpected error:", err) + } + + if buf.String() != "{}\n" { + t.Fatal("expected undefined output but got:", buf.String()) + } +} + +func assertResultSet(t *testing.T, rs rego.ResultSet, expected string) { + t.Helper() + result := make([]any, 0, len(rs)) + + for i := range rs { + values := make([]any, 0, len(rs[i].Expressions)) + for j := range rs[i].Expressions { + values = append(values, rs[i].Expressions[j].Value) + } + result = append(result, values) + } + + parsedExpected := util.MustUnmarshalJSON([]byte(expected)) + if !reflect.DeepEqual(result, parsedExpected) { + t.Fatalf("Expected:\n\n%v\n\nGot:\n\n%v", parsedExpected, result) + } +} + +func TestEvalErrorJSONOutput(t *testing.T) { + params := newEvalCommandParams() + err := params.outputFormat.Set(formats.JSON) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + var buf bytes.Buffer + + defined, err := eval([]string{"{1,2,3} == {1,x,3}"}, params, &buf, nil) + if defined && err == nil { + t.Fatalf("Expected an error") + } + + // Only check that it *can* be loaded as valid JSON, and that the errors + // are populated. + var output map[string]any + + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + + if output["errors"] == nil { + t.Fatalf("Expected error to be non-nil") + } +} + +func TestEvalDebugTraceJSONOutput(t *testing.T) { + params := newEvalCommandParams() + err := params.outputFormat.Set(formats.JSON) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + err = params.explain.Set(explainModeFull) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + params.disableIndexing = true + + mod := `package x + + p contains a if { + a := input.z + a == 1 + } + + p contains b if { + b := input.y + b == 1 + } + ` + + input := `{"z": 1}` + + files := map[string]string{ + "policy.rego": mod, + "input.json": input, + } + + var buf bytes.Buffer + var policyFile string + + test.WithTempFS(files, func(path string) { + params.inputPath = filepath.Join(path, "input.json") + policyFile = filepath.Join(path, "policy.rego") + err := params.dataPaths.Set(policyFile) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + _, err = eval([]string{"data.x.p"}, params, &buf, nil) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + }) + + var output struct { + Explanation []struct { + Op string `json:"Op"` + Node any `json:"Node"` + Location *ast.Location `json:"Location"` + Locals []map[string]any `json:"Locals"` + LocalMetadata map[string]struct { + Name string `json:"name"` + } `json:"LocalMetadata"` + } + } + + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + if len(output.Explanation) == 0 { + t.Fatalf("Expected explanations to be non-nil") + } + + type locationAndVars struct { + location *ast.Location + varBindings map[string]string + } + + var evals []locationAndVars + for _, e := range output.Explanation { + if e.Op == string(topdown.EvalOp) { + bindings := map[string]string{} + for k, v := range e.LocalMetadata { + bindings[k] = v.Name + } + + evals = append(evals, locationAndVars{location: e.Location, varBindings: bindings}) + } + } + + expectedEvalLocationsAndVars := []locationAndVars{ + { + location: ast.NewLocation(nil, policyFile, 4, 3), // a := input.z + varBindings: map[string]string{"__local0__": "a"}, + }, + { + location: ast.NewLocation(nil, policyFile, 5, 3), // a == 1 + varBindings: map[string]string{"__local0__": "a"}, + }, + { + location: ast.NewLocation(nil, policyFile, 9, 3), // b := input.y + varBindings: map[string]string{"__local1__": "b"}, + }, + } + + for _, expected := range expectedEvalLocationsAndVars { + found := false + for _, actual := range evals { + if expected.location.Compare(actual.location) == 0 { + found = true + if !maps.Equal(expected.varBindings, actual.varBindings) { + t.Errorf("Expected var bindings:\n\n\t%+v\n\nGot\n\n\t%+v\n\n", expected.varBindings, actual.varBindings) + } + } + } + if !found { + t.Fatalf("Missing expected eval node in trace: %+v\nGot: %+v\n", expected, evals) + } + } +} + +func TestEvalPrettyTrace(t *testing.T) { + tests := []struct { + note string + query string + includeVars bool + files map[string]string + expected string + }{ + { + note: "simple without vars", + query: "data.test.p", + includeVars: false, + files: map[string]string{ + "test.rego": `package test +import rego.v1 + +p if { + x := 1 + y := 2 + z := 3 + x == z - y +} +`, + }, + expected: `%SKIP_LINE% +query:1 %.*% Enter data.test.p = _ +query:1 %.*% | Eval data.test.p = _ +query:1 %.*% | Index data.test.p (matched 1 rule, early exit) +%.*%/test.rego:4 | Enter data.test.p +%.*%/test.rego:5 | | Eval x = 1 +%.*%/test.rego:6 | | Eval y = 2 +%.*%/test.rego:7 | | Eval z = 3 +%.*%/test.rego:8 | | Eval minus(z, y, __local3__) +%.*%/test.rego:8 | | Eval x = __local3__ +%.*%/test.rego:4 | | Exit data.test.p early +query:1 %.*% | Exit data.test.p = _ +query:1 %.*% Redo data.test.p = _ +query:1 %.*% | Redo data.test.p = _ +%.*%/test.rego:4 | Redo data.test.p +%.*%/test.rego:8 | | Redo x = __local3__ +%.*%/test.rego:8 | | Redo minus(z, y, __local3__) +%.*%/test.rego:7 | | Redo z = 3 +%.*%/test.rego:6 | | Redo y = 2 +%.*%/test.rego:5 | | Redo x = 1 +true +`, + }, + { + note: "simple with vars", + query: "data.test.p", + includeVars: true, + files: map[string]string{ + "test.rego": `package test +import rego.v1 + +p if { + x := 1 + y := 2 + z := 3 + x == z - y +} +`, + }, + expected: `%SKIP_LINE% +query:1 %.*% Enter data.test.p = _ {} +query:1 %.*% | Eval data.test.p = _ {} +query:1 %.*% | Index data.test.p (matched 1 rule, early exit) {} +%.*%/test.rego:4 | Enter data.test.p {} +%.*%/test.rego:5 | | Eval x = 1 {} +%.*%/test.rego:6 | | Eval y = 2 {} +%.*%/test.rego:7 | | Eval z = 3 {} +%.*%/test.rego:8 | | Eval minus(z, y, __local3__) {y: 2, z: 3} +%.*%/test.rego:8 | | Eval x = __local3__ {__local3__: 1, x: 1} +%.*%/test.rego:4 | | Exit data.test.p early {} +query:1 %.*% | Exit data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% Redo data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% | Redo data.test.p = _ {_: true, data.test.p: true} +%.*%/test.rego:4 | Redo data.test.p {} +%.*%/test.rego:8 | | Redo x = __local3__ {__local3__: 1, x: 1} +%.*%/test.rego:8 | | Redo minus(z, y, __local3__) {__local3__: 1, y: 2, z: 3} +%.*%/test.rego:7 | | Redo z = 3 {z: 3} +%.*%/test.rego:6 | | Redo y = 2 {y: 2} +%.*%/test.rego:5 | | Redo x = 1 {x: 1} +true +`, + }, + { + note: "large var", + query: "data.test.p", + includeVars: true, + files: map[string]string{ + "test.rego": `package test +import rego.v1 + +v := { + "foo": ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], + "bar": ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], + "baz": ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], + "qux": ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], + } + +p if { + x := v + + x.foo[_] == "a" +} +`, + }, + expected: `%SKIP_LINE% +query:1 %.*% Enter data.test.p = _ {} +query:1 %.*% | Eval data.test.p = _ {} +query:1 %.*% | Index data.test.p (matched 1 rule, early exit) {} +%.*%/test.rego:11 | Enter data.test.p {} +%.*%/test.rego:12 | | Eval x = data.test.v {} +%.*%/test.rego:12 | | Index data.test.v (matched 1 rule, early exit) {} +%.*%/test.rego:4 | | Enter data.test.v {} +%.*%/test.rego:4 | | | Eval true {} +%.*%/test.rego:4 | | | Exit data.test.v early {} +%.*%/test.rego:14 | | Eval x.foo[_] = "a" {x: {"bar": ["a", "b", "c", "d", ...} +%.*%/test.rego:11 | | Exit data.test.p early {} +query:1 %.*% | Exit data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% Redo data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% | Redo data.test.p = _ {_: true, data.test.p: true} +%.*%/test.rego:11 | Redo data.test.p {} +%.*%/test.rego:14 | | Redo x.foo[_] = "a" {_: 0, x: {"bar": ["a", "b", "c", "d", ...} +%.*%/test.rego:12 | | Redo x = data.test.v {data.test.v: {"bar": ["a", "b", "c", "d", ..., x: {"bar": ["a", "b", "c", "d", ...} +%.*%/test.rego:4 | | | Redo true {} +true +`, + }, + { + note: "func call", + query: "data.test.p", + includeVars: true, + files: map[string]string{ + "test.rego": `package test +import rego.v1 + +p if { + x := 1 + y := 2 + z := 3 + z == f(x, y) +} + +f(a, b) := c if { + c := a + b +} +`, + }, + expected: `%SKIP_LINE% +query:1 %.*% Enter data.test.p = _ {} +query:1 %.*% | Eval data.test.p = _ {} +query:1 %.*% | Index data.test.p (matched 1 rule, early exit) {} +%.*%/test.rego:4 | Enter data.test.p {} +%.*%/test.rego:5 | | Eval x = 1 {} +%.*%/test.rego:6 | | Eval y = 2 {} +%.*%/test.rego:7 | | Eval z = 3 {} +%.*%/test.rego:8 | | Eval data.test.f(x, y, __local6__) {x: 1, y: 2} +%.*%/test.rego:8 | | Index data.test.f (matched 1 rule) {x: 1, y: 2} +%.*%/test.rego:11 | | Enter data.test.f {} +%.*%/test.rego:12 | | | Eval plus(a, b, __local7__) {a: 1, b: 2} +%.*%/test.rego:12 | | | Eval c = __local7__ {__local7__: 3} +%.*%/test.rego:11 | | | Exit data.test.f {a: 1, b: 2, c: 3} +%.*%/test.rego:8 | | Eval z = __local6__ {__local6__: 3, z: 3} +%.*%/test.rego:4 | | Exit data.test.p early {} +query:1 %.*% | Exit data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% Redo data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% | Redo data.test.p = _ {_: true, data.test.p: true} +%.*%/test.rego:4 | Redo data.test.p {} +%.*%/test.rego:8 | | Redo z = __local6__ {__local6__: 3, z: 3} +%.*%/test.rego:8 | | Redo data.test.f(x, y, __local6__) {__local6__: 3, x: 1, y: 2} +%.*%/test.rego:12 | | | Redo c = __local7__ {__local7__: 3, c: 3} +%.*%/test.rego:12 | | | Redo plus(a, b, __local7__) {__local7__: 3, a: 1, b: 2} +%.*%/test.rego:7 | | Redo z = 3 {z: 3} +%.*%/test.rego:6 | | Redo y = 2 {y: 2} +%.*%/test.rego:5 | | Redo x = 1 {x: 1} +true +`, + }, + { + note: "every", + query: "data.test.p", + includeVars: true, + files: map[string]string{ + "test.rego": `package test +import rego.v1 + +p if { + l := ["a", "b", "c"] + every x in l { + count(x) == 1 + } +} + +f(a, b) := c if { + c := a + b +} +`, + }, + expected: `%SKIP_LINE% +query:1 %.*% Enter data.test.p = _ {} +query:1 %.*% | Eval data.test.p = _ {} +query:1 %.*% | Index data.test.p (matched 1 rule, early exit) {} +%.*%/test.rego:4 | Enter data.test.p {} +%.*%/test.rego:5 | | Eval l = ["a", "b", "c"] {} +%.*%/test.rego:6 | | Eval __local6__ = l {l: ["a", "b", "c"]} +%.*%/test.rego:6 | | Eval every x in __local6__ { count(x, __local7__); __local7__ = 1 } {__local6__: ["a", "b", "c"]} +%.*%/test.rego:6 | | Enter every x in __local6__ { count(x, __local7__); __local7__ = 1 } {__local6__: ["a", "b", "c"]} +%.*%/test.rego:6 | | | Eval __local6__[__local1__] = x {__local6__: ["a", "b", "c"]} +%.*%/test.rego:7 | | | Enter count(x, __local7__); __local7__ = 1 {x: "a"} +%.*%/test.rego:7 | | | | Eval count(x, __local7__) {x: "a"} +%.*%/test.rego:7 | | | | Eval __local7__ = 1 {__local7__: 1} +%.*%/test.rego:7 | | | | Exit count(x, __local7__); __local7__ = 1 early {__local7__: 1, x: "a"} +%.*%/test.rego:7 | | | Redo count(x, __local7__); __local7__ = 1 {__local7__: 1, x: "a"} +%.*%/test.rego:7 | | | | Redo __local7__ = 1 {__local7__: 1} +%.*%/test.rego:7 | | | | Redo count(x, __local7__) {__local7__: 1, x: "a"} +%.*%/test.rego:6 | | | Redo every x in __local6__ { count(x, __local7__); __local7__ = 1 } {__local1__: 0, __local6__: ["a", "b", "c"], x: "a"} +%.*%/test.rego:6 | | | Redo __local6__[__local1__] = x {__local1__: 0, __local6__: ["a", "b", "c"], x: "a"} +%.*%/test.rego:7 | | | Enter count(x, __local7__); __local7__ = 1 {x: "b"} +%.*%/test.rego:7 | | | | Eval count(x, __local7__) {x: "b"} +%.*%/test.rego:7 | | | | Eval __local7__ = 1 {__local7__: 1} +%.*%/test.rego:7 | | | | Exit count(x, __local7__); __local7__ = 1 early {__local7__: 1, x: "b"} +%.*%/test.rego:7 | | | Redo count(x, __local7__); __local7__ = 1 {__local7__: 1, x: "b"} +%.*%/test.rego:7 | | | | Redo __local7__ = 1 {__local7__: 1} +%.*%/test.rego:7 | | | | Redo count(x, __local7__) {__local7__: 1, x: "b"} +%.*%/test.rego:6 | | | Redo every x in __local6__ { count(x, __local7__); __local7__ = 1 } {__local1__: 1, __local6__: ["a", "b", "c"], x: "b"} +%.*%/test.rego:6 | | | Redo __local6__[__local1__] = x {__local1__: 1, __local6__: ["a", "b", "c"], x: "b"} +%.*%/test.rego:7 | | | Enter count(x, __local7__); __local7__ = 1 {x: "c"} +%.*%/test.rego:7 | | | | Eval count(x, __local7__) {x: "c"} +%.*%/test.rego:7 | | | | Eval __local7__ = 1 {__local7__: 1} +%.*%/test.rego:7 | | | | Exit count(x, __local7__); __local7__ = 1 early {__local7__: 1, x: "c"} +%.*%/test.rego:7 | | | Redo count(x, __local7__); __local7__ = 1 {__local7__: 1, x: "c"} +%.*%/test.rego:7 | | | | Redo __local7__ = 1 {__local7__: 1} +%.*%/test.rego:7 | | | | Redo count(x, __local7__) {__local7__: 1, x: "c"} +%.*%/test.rego:6 | | | Redo every x in __local6__ { count(x, __local7__); __local7__ = 1 } {__local1__: 2, __local6__: ["a", "b", "c"], x: "c"} +%.*%/test.rego:6 | | | Redo __local6__[__local1__] = x {__local1__: 2, __local6__: ["a", "b", "c"], x: "c"} +%.*%/test.rego:4 | | Exit data.test.p early {} +query:1 %.*% | Exit data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% Redo data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% | Redo data.test.p = _ {_: true, data.test.p: true} +%.*%/test.rego:4 | Redo data.test.p {} +%.*%/test.rego:6 | | Redo every x in __local6__ { count(x, __local7__); __local7__ = 1 } {__local6__: ["a", "b", "c"]} +%.*%/test.rego:6 | | | Exit every x in __local6__ { count(x, __local7__); __local7__ = 1 } {__local6__: ["a", "b", "c"]} +%.*%/test.rego:6 | | Redo __local6__ = l {__local6__: ["a", "b", "c"], l: ["a", "b", "c"]} +%.*%/test.rego:5 | | Redo l = ["a", "b", "c"] {l: ["a", "b", "c"]} +true +`, + }, + { + note: "rule value", + query: "data.test.p", + includeVars: true, + files: map[string]string{ + "test.rego": `package test +import rego.v1 + +a := 1 + +p if { + a + 1 == 2 + a + 2 == 3 +} +`, + }, + expected: `%SKIP_LINE% +query:1 %.*% Enter data.test.p = _ {} +query:1 %.*% | Eval data.test.p = _ {} +query:1 %.*% | Index data.test.p (matched 1 rule, early exit) {} +%.*%/test.rego:6 | Enter data.test.p {} +%.*%/test.rego:7 | | Eval __local2__ = data.test.a {} +%.*%/test.rego:7 | | Index data.test.a (matched 1 rule, early exit) {} +%.*%/test.rego:4 | | Enter data.test.a {} +%.*%/test.rego:4 | | | Eval true {} +%.*%/test.rego:4 | | | Exit data.test.a early {} +%.*%/test.rego:7 | | Eval plus(__local2__, 1, __local0__) {__local2__: 1} +%.*%/test.rego:7 | | Eval __local0__ = 2 {__local0__: 2} +%.*%/test.rego:8 | | Eval __local3__ = data.test.a {data.test.a: 1} +%.*%/test.rego:8 | | Index data.test.a (matched 1 rule, early exit) {data.test.a: 1} +%.*%/test.rego:8 | | Eval plus(__local3__, 2, __local1__) {__local3__: 1} +%.*%/test.rego:8 | | Eval __local1__ = 3 {__local1__: 3} +%.*%/test.rego:6 | | Exit data.test.p early {} +query:1 %.*% | Exit data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% Redo data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% | Redo data.test.p = _ {_: true, data.test.p: true} +%.*%/test.rego:6 | Redo data.test.p {} +%.*%/test.rego:8 | | Redo __local1__ = 3 {__local1__: 3} +%.*%/test.rego:8 | | Redo plus(__local3__, 2, __local1__) {__local1__: 3, __local3__: 1} +%.*%/test.rego:8 | | Redo __local3__ = data.test.a {__local3__: 1, data.test.a: 1} +%.*%/test.rego:7 | | Redo __local0__ = 2 {__local0__: 2} +%.*%/test.rego:7 | | Redo plus(__local2__, 1, __local0__) {__local0__: 2, __local2__: 1} +%.*%/test.rego:7 | | Redo __local2__ = data.test.a {__local2__: 1, data.test.a: 1} +%.*%/test.rego:4 | | | Redo true {} +true +`, + }, + { + note: "input values", + query: "data.test.p", + includeVars: true, + files: map[string]string{ + "test.rego": `package test +import rego.v1 + +p if { + input.x == 1 + input.x + input.y == input.z +} +`, + "input.json": `{ + "x": 1, + "y": 2, + "z": 3 +}`, + }, + expected: `%SKIP_LINE% +query:1 %.*% Enter data.test.p = _ {} +query:1 %.*% | Eval data.test.p = _ {} +query:1 %.*% | Index data.test.p (matched 1 rule, early exit) {} +%.*%/test.rego:4 | Enter data.test.p {} +%.*%/test.rego:5 | | Eval input.x = 1 {} +%.*%/test.rego:6 | | Eval __local1__ = input.x {} +%.*%/test.rego:6 | | Eval __local2__ = input.y {} +%.*%/test.rego:6 | | Eval plus(__local1__, __local2__, __local0__) {__local1__: 1, __local2__: 2} +%.*%/test.rego:6 | | Eval __local0__ = input.z {__local0__: 3} +%.*%/test.rego:4 | | Exit data.test.p early {} +query:1 %.*% | Exit data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% Redo data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% | Redo data.test.p = _ {_: true, data.test.p: true} +%.*%/test.rego:4 | Redo data.test.p {} +%.*%/test.rego:6 | | Redo __local0__ = input.z {__local0__: 3} +%.*%/test.rego:6 | | Redo plus(__local1__, __local2__, __local0__) {__local0__: 3, __local1__: 1, __local2__: 2} +%.*%/test.rego:6 | | Redo __local2__ = input.y {__local2__: 2} +%.*%/test.rego:6 | | Redo __local1__ = input.x {__local1__: 1} +%.*%/test.rego:5 | | Redo input.x = 1 {} +true +`, + }, + { + note: "data values", + query: "data.test.p", + includeVars: true, + files: map[string]string{ + "test.rego": `package test +import rego.v1 + +p if { + data.x == 1 + data.x + data.y == data.z +} +`, + "data.json": `{ + "x": 1, + "y": 2, + "z": 3 +}`, + }, + expected: `%SKIP_LINE% +query:1 %.*% Enter data.test.p = _ {} +query:1 %.*% | Eval data.test.p = _ {} +query:1 %.*% | Index data.test.p (matched 1 rule, early exit) {} +%.*%/test.rego:4 | Enter data.test.p {} +%.*%/test.rego:5 | | Eval data.x = 1 {} +%.*%/test.rego:6 | | Eval __local1__ = data.x {} +%.*%/test.rego:6 | | Eval __local2__ = data.y {} +%.*%/test.rego:6 | | Eval plus(__local1__, __local2__, __local0__) {__local1__: 1, __local2__: 2} +%.*%/test.rego:6 | | Eval __local0__ = data.z {__local0__: 3} +%.*%/test.rego:4 | | Exit data.test.p early {} +query:1 %.*% | Exit data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% Redo data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% | Redo data.test.p = _ {_: true, data.test.p: true} +%.*%/test.rego:4 | Redo data.test.p {} +%.*%/test.rego:6 | | Redo __local0__ = data.z {__local0__: 3} +%.*%/test.rego:6 | | Redo plus(__local1__, __local2__, __local0__) {__local0__: 3, __local1__: 1, __local2__: 2} +%.*%/test.rego:6 | | Redo __local2__ = data.y {__local2__: 2} +%.*%/test.rego:6 | | Redo __local1__ = data.x {__local1__: 1} +%.*%/test.rego:5 | | Redo data.x = 1 {} +true +`, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + var buf bytes.Buffer + + test.WithTempFS(tc.files, func(path string) { + params := newEvalCommandParams() + _ = params.bundlePaths.Set(path) + inputFile := filepath.Join(path, "input.json") + if _, err := os.Stat(inputFile); err == nil { + params.inputPath = inputFile + } + _ = params.outputFormat.Set(formats.Pretty) + _ = params.explain.Set(explainModeFull) + params.traceVarValues = tc.includeVars + params.disableIndexing = true + _ = params.bundlePaths.Set(path) + + _, err := eval([]string{tc.query}, params, &buf, nil) + if err != nil { + t.Fatalf("Unexpected error: %s\n\n%s", err, buf.String()) + } + }) + + actual := buf.String() + if !stringsMatch(t, tc.expected, actual) { + t.Fatalf("Expected:\n\n%v\n\nGot:\n\n%v", tc.expected, actual) + } + }) + } +} + +func stringsMatch(t *testing.T, expected, actual string) bool { + t.Helper() + + var expectedLines []string + for l := range strings.SplitSeq(expected, "\n") { + if !strings.Contains(l, "%SKIP_LINE%") { + expectedLines = append(expectedLines, l) + } + } + + actualLines := strings.Split(actual, "\n") + + if len(expectedLines) != len(actualLines) { + t.Errorf("Expected %d lines but got %d", len(expectedLines), len(actualLines)) + return false + } + + for i, expectedLine := range expectedLines { + actualLine := actualLines[i] + + expectedParts := strings.Split(expectedLine, "%.*%") + if len(expectedParts) == 1 { + if expectedLine != actualLine { + t.Errorf("Mismatch on line %d. Expected:\n\n%s\n\nGot:\n\n%s", i, expectedLine, actualLine) + return false + } + } else if len(expectedParts) == 2 { + if !strings.HasPrefix(actualLine, expectedParts[0]) { + t.Errorf("Expected line %d to start with:\n\n%s\n\nbut got:\n\n%s", i, expectedParts[0], actualLine) + return false + } + if !strings.HasSuffix(actualLine, expectedParts[1]) { + t.Errorf("Expected line %d to end with:\n\n%s\n\nbut got:\n\n%s", i, expectedParts[1], actualLine) + return false + } + } else { + t.Fatalf("At most one .* is allowed per line but found %d on line %d:\n\n%s", len(expectedParts)-1, i, expectedLine) + return false + } + } + + return true +} + +func TestResetExprLocations(t *testing.T) { + + // Make sure no panic if passed nil. + resetExprLocations(nil) + + // Run partial evaluation on this fake module and check results. + // The content of the module is not very important it just has to generate + // support and cases where the locaiton is unset. The default causes support + // and exprs with no location information. + pq, err := rego.New(rego.Query("data.test.p = x"), rego.Module("test.rego", ` + package test + + default p = false + + p if { + input.x = q[_] + } + + q contains 1 + q contains 2 + `)).Partial(t.Context()) + + if err != nil { + t.Fatal(err) + } + + resetExprLocations(pq) + + var exp int + + vis := ast.NewGenericVisitor(func(x any) bool { + if expr, ok := x.(*ast.Expr); ok { + if expr.Location.Row != exp { + t.Fatalf("Expected %v to have row %v but got %v", expr, exp, expr.Location.Row) + } + exp++ + } + return false + }) + + for i := range pq.Queries { + vis.Walk(pq.Queries[i]) + } + + for i := range pq.Support { + vis.Walk(pq.Support[i]) + } + +} +func kubeSchemaServer(t *testing.T) *httptest.Server { + t.Helper() + bs, err := os.ReadFile("../v1/ast/testdata/_definitions.json") + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write(bs) + if err != nil { + panic(err) + } + })) + return ts +} + +func TestEvalPartialFormattedOutput(t *testing.T) { + + query := `time.clock(input.x) == time.clock(input.y)` + tests := []struct { + format, expected string + }{ + { + format: formats.Pretty, + expected: `┌─────────┬──────────────────────────────────────────┐ +│ Query 1 │ time.clock(input.y, time.clock(input.x)) │ +└─────────┴──────────────────────────────────────────┘ +`}, + { + format: formats.Source, + expected: `# Query 1 +time.clock(input.y, time.clock(input.x)) + +`}, + } + + for _, tc := range tests { + t.Run(tc.format, func(t *testing.T) { + buf := new(bytes.Buffer) + params := newEvalCommandParams() + params.partial = true + _ = params.outputFormat.Set(tc.format) + _, err := eval([]string{query}, params, buf, nil) + if err != nil { + t.Fatal("unexpected error:", err) + } + if diff := cmp.Diff(buf.String(), tc.expected); diff != "" { + t.Error("output mismatch (-want +got):\n", diff) + } + }) + } +} + +func TestEvalPartialOutput_RegoVersion(t *testing.T) { + tests := []struct { + note string + regoV1ImportCapable bool + v0Compatible bool + query string + module string + expected map[string]string + }{ + { + note: "v0, no future keywords", + v0Compatible: true, + regoV1ImportCapable: true, + query: "data.test.p", + module: `package test + +p[v] { + v := input.v +} +`, + expected: map[string]string{ + formats.Source: `# Query 1 +data.partial.test.p + +# Module 1 +package partial.test + +import rego.v1 + +p contains __local0__1 if __local0__1 = input.v +`, + formats.Pretty: `┌───────────┬─────────────────────────────────────────────────┐ +│ Query 1 │ data.partial.test.p │ +├───────────┼─────────────────────────────────────────────────┤ +│ Support 1 │ package partial.test │ +│ │ │ +│ │ import rego.v1 │ +│ │ │ +│ │ p contains __local0__1 if __local0__1 = input.v │ +└───────────┴─────────────────────────────────────────────────┘ +`, + }, + }, + { + note: "v0, no future keywords, not rego.v1 import capable", + v0Compatible: true, + regoV1ImportCapable: false, + query: "data.test.p", + module: `package test + +p[v] { + v := input.v +} +`, + expected: map[string]string{ + formats.Source: `# Query 1 +data.partial.test.p + +# Module 1 +package partial.test + +p[__local0__1] { + __local0__1 = input.v +} +`, + formats.Pretty: `┌───────────┬─────────────────────────┐ +│ Query 1 │ data.partial.test.p │ +├───────────┼─────────────────────────┤ +│ Support 1 │ package partial.test │ +│ │ │ +│ │ p[__local0__1] { │ +│ │ __local0__1 = input.v │ +│ │ } │ +└───────────┴─────────────────────────┘ +`, + }, + }, + { + note: "v0, future keywords", + v0Compatible: true, + regoV1ImportCapable: true, + query: "data.test.p", + module: `package test + +import rego.v1 + +p contains v if { + v := input.v +} +`, + expected: map[string]string{ + formats.Source: `# Query 1 +data.partial.test.p + +# Module 1 +package partial.test + +import rego.v1 + +p contains __local0__1 if __local0__1 = input.v +`, + formats.Pretty: `┌───────────┬─────────────────────────────────────────────────┐ +│ Query 1 │ data.partial.test.p │ +├───────────┼─────────────────────────────────────────────────┤ +│ Support 1 │ package partial.test │ +│ │ │ +│ │ import rego.v1 │ +│ │ │ +│ │ p contains __local0__1 if __local0__1 = input.v │ +└───────────┴─────────────────────────────────────────────────┘ +`, + }, + }, + { + note: "v1", + regoV1ImportCapable: true, + v0Compatible: false, + query: "data.test.p", + module: `package test + +p contains v if { + v := input.v +} +`, + expected: map[string]string{ + formats.Source: `# Query 1 +data.partial.test.p + +# Module 1 +package partial.test + +p contains __local0__1 if __local0__1 = input.v +`, + formats.Pretty: `┌───────────┬─────────────────────────────────────────────────┐ +│ Query 1 │ data.partial.test.p │ +├───────────┼─────────────────────────────────────────────────┤ +│ Support 1 │ package partial.test │ +│ │ │ +│ │ p contains __local0__1 if __local0__1 = input.v │ +└───────────┴─────────────────────────────────────────────────┘ +`, + }, + }, + { + note: "v1, rego.v1 import", + regoV1ImportCapable: true, + v0Compatible: false, + query: "data.test.p", + module: `package test + +import rego.v1 + +p contains v if { + v := input.v +} +`, + expected: map[string]string{ + formats.Source: `# Query 1 +data.partial.test.p + +# Module 1 +package partial.test + +p contains __local0__1 if __local0__1 = input.v +`, + formats.Pretty: `┌───────────┬─────────────────────────────────────────────────┐ +│ Query 1 │ data.partial.test.p │ +├───────────┼─────────────────────────────────────────────────┤ +│ Support 1 │ package partial.test │ +│ │ │ +│ │ p contains __local0__1 if __local0__1 = input.v │ +└───────────┴─────────────────────────────────────────────────┘ +`, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + for format, expected := range tc.expected { + t.Run(format, func(t *testing.T) { + files := map[string]string{ + "test.rego": tc.module, + } + + test.WithTempFS(files, func(path string) { + params := newEvalCommandParams() + _ = params.dataPaths.Set(filepath.Join(path, "test.rego")) + params.partial = true + params.v0Compatible = tc.v0Compatible + _ = params.outputFormat.Set(format) + + if !tc.regoV1ImportCapable { + caps := newCapabilitiesFlag() + caps.C = ast.CapabilitiesForThisVersion() + caps.C.Features = []string{ + ast.FeatureRefHeadStringPrefixes, + ast.FeatureRefHeads, + } + params.capabilities = caps + } + + buf := new(bytes.Buffer) + _, err := eval([]string{tc.query}, params, buf, nil) + if err != nil { + t.Fatal("unexpected error:", err) + } + + if diff := cmp.Diff(buf.String(), expected); diff != "" { + t.Error("output mismatch (-want +got):\n", diff) + } + }) + }) + } + }) + } +} + +func TestEvalDiscardOutput(t *testing.T) { + tests := map[string]struct { + query, format, expected string + params evalCommandParams + }{ + "success example": { + query: "1*2+3", + params: func() evalCommandParams { + params := newEvalCommandParams() + err := params.outputFormat.Set(formats.Discard) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + return params + }(), + expected: `{ + "result": "discarded" +} +`}, + "error example": { + query: "1/0", + params: func() evalCommandParams { + params := newEvalCommandParams() + err := params.outputFormat.Set(formats.Discard) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + return params + }(), + expected: `{} +`}, + "error example show built-in-errors": { + query: "1/0", + params: func() evalCommandParams { + params := newEvalCommandParams() + err := params.outputFormat.Set(formats.Discard) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + params.showBuiltinErrors = true + return params + }(), + expected: `{ + "errors": [ + { + "code": "eval_builtin_error", + "location": { + "col": 1, + "file": "", + "row": 1 + }, + "message": "div: divide by zero" + } + ] +} +`}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + var buf bytes.Buffer + _, err := eval([]string{tc.query}, tc.params, &buf, nil) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if diff := cmp.Diff(buf.String(), tc.expected); diff != "" { + t.Error("output mismatch (-want +got):\n", diff) + } + }) + } +} + +func TestEvalDiscardProfilerOutput(t *testing.T) { + params := newEvalCommandParams() + err := params.outputFormat.Set(formats.Discard) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + params.profile = true + + query := "1*2+3" + + var buf bytes.Buffer + _, err = eval([]string{query}, params, &buf, nil) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + var output map[string]any + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + + // assert that the result is set to discarded + result, ok := output["result"].(string) + if !ok { + t.Fatal("error extracting result as string from output") + } + + if result != "discarded" { + t.Fatal("Expected result field to be set to 'discarded'") + } + + // assert that profile is still set + _, ok = output["profile"] + if !ok { + t.Fatal("error in parsing profile output") + } +} + +func TestPolicyWithStrictFlag(t *testing.T) { + testsShouldError := []struct { + note string + v0Compatible bool + policy string + query string + expectedCode string + expectedMessage string + }{ + { + note: "strict mode should error on unused imports", + policy: `package x + import future.keywords.if + import data.foo + foo = 2`, + query: "data.foo", + expectedCode: "rego_compile_error", + expectedMessage: "import data.foo unused", + }, + { + note: "v0 compat, strict mode should error on duplicate imports", + v0Compatible: true, + policy: `package x + import data.bar + import data.bar + foo = bar`, + query: "data.foo", + expectedCode: "rego_compile_error", + expectedMessage: "import must not shadow import data.bar", + }, + { + note: "v0 compat, strict mode should error on unused imports", + v0Compatible: true, + policy: `package x + import future.keywords.if + import data.foo + foo = 2`, + query: "data.foo", + expectedCode: "rego_compile_error", + expectedMessage: "import data.foo unused", + }, + { + note: "v0 compat, strict mode should error when reserved vars data or input is used", + v0Compatible: true, + policy: `package x + data { x = 1}`, + query: "data.foo", + expectedCode: "rego_compile_error", + expectedMessage: "rules must not shadow data (use a different rule name)", + }, + } + + for _, tc := range testsShouldError { + t.Run(tc.note, func(t *testing.T) { + + files := map[string]string{ + "test.rego": tc.policy, + } + + test.WithTempFS(files, func(path string) { + for _, strict := range []bool{true, false} { + params := newEvalCommandParams() + params.strict = strict + params.v0Compatible = tc.v0Compatible + + _ = params.dataPaths.Set(filepath.Join(path, "test.rego")) + + var buf bytes.Buffer + _, err := eval([]string{tc.query}, params, &buf, nil) + + if strict { + if err == nil { + t.Fatal("expected error, got nil") + } + var output presentation.Output + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + + if code := output.Errors[0].Code; code != tc.expectedCode { + t.Errorf("expected code '%v', got '%v'", tc.expectedCode, code) + } + if msg := output.Errors[0].Message; msg != tc.expectedMessage { + t.Errorf("expected message '%v', got '%v'", tc.expectedMessage, msg) + } + } else if err != nil { + var output presentation.Output + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + t.Fatal("unexpected error when non-strict:", output) + } + } + }) + }) + } + + testsShouldPass := []struct { + note string + policy string + query string + }{ + { + note: "This should not error as it is valid", + policy: `package x + import future.keywords.if + foo = 2`, + query: "data.foo", + }, + { + note: "Strict mode should not validate the query, only the policy, this should not error", + policy: `package x + import future.keywords.if + foo = 2`, + query: "x := data.x.foo", + }, + } + for _, tc := range testsShouldPass { + t.Run(tc.note, func(t *testing.T) { + + files := map[string]string{ + "test.rego": tc.policy, + } + + test.WithTempFS(files, func(_ string) { + params := newEvalCommandParams() + params.strict = true + + var buf bytes.Buffer + _, err := eval([]string{tc.query}, params, &buf, nil) + if err != nil { + t.Errorf("Should not error, got error: '%v'", err) + } + }) + }) + } + +} + +func TestBundleWithStrictFlag(t *testing.T) { + testsShouldError := []struct { + note string + v0Compatible bool + policy string + query string + expectedCode string + expectedMessage string + }{ + { + note: "strict mode should error on unused imports in this bundle", + policy: `package x + import data.foo + foo = 2`, + query: "data.foo", + expectedCode: "rego_compile_error", + expectedMessage: "import data.foo unused", + }, + { + note: "v0 compat, strict mode should error on duplicate imports in this bundle", + v0Compatible: true, + policy: `package x + import data.bar + import data.bar + foo = bar`, + query: "data.foo", + expectedCode: "rego_compile_error", + expectedMessage: "import must not shadow import data.bar", + }, + { + note: "v0 compat, strict mode should error on unused imports in this bundle", + v0Compatible: true, + policy: `package x + import data.foo + foo = 2`, + query: "data.foo", + expectedCode: "rego_compile_error", + expectedMessage: "import data.foo unused", + }, + { + note: "v0 compat, strict mode should error when reserved vars data or input is used in this bundle", + v0Compatible: true, + policy: `package x + data { x = 1}`, + query: "data.foo", + expectedCode: "rego_compile_error", + expectedMessage: "rules must not shadow data (use a different rule name)", + }, + } + + for _, tc := range testsShouldError { + t.Run(tc.note, func(t *testing.T) { + + files := map[string]string{ + "test.rego": tc.policy, + } + + test.WithTempFS(files, func(path string) { + for _, strict := range []bool{true, false} { + params := newEvalCommandParams() + if err := params.bundlePaths.Set(path); err != nil { + t.Fatal(err) + } + params.strict = strict + params.v0Compatible = tc.v0Compatible + + var buf bytes.Buffer + _, err := eval([]string{tc.query}, params, &buf, nil) + + if strict { + if err == nil { + t.Fatal("expected error, got nil") + } + var output presentation.Output + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + + if code := output.Errors[0].Code; code != tc.expectedCode { + t.Errorf("expected code '%v', got '%v'", tc.expectedCode, code) + } + if msg := output.Errors[0].Message; msg != tc.expectedMessage { + t.Errorf("expected message '%v', got '%v'", tc.expectedMessage, msg) + } + } else if err != nil { + var output presentation.Output + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + t.Fatal("unexpected error when non-strict:", output) + } + } + }) + }) + } + + testsShouldPass := []struct { + note string + policy string + query string + }{ + { + note: "This bundle should not error as it is valid", + policy: `package x + import future.keywords.if + foo = 2`, + query: "data.foo", + }, + { + note: "Strict mode should not validate the query, only the policy, this bundle should not error", + policy: `package x + import future.keywords.if + foo = 2`, + query: "x := data.x.foo", + }, + } + for _, tc := range testsShouldPass { + t.Run(tc.note, func(t *testing.T) { + + files := map[string]string{ + "test.rego": tc.policy, + } + + test.WithTempFS(files, func(path string) { + params := newEvalCommandParams() + if err := params.bundlePaths.Set(path); err != nil { + t.Fatal(err) + } + params.strict = true + + var buf bytes.Buffer + _, err := eval([]string{tc.query}, params, &buf, nil) + if err != nil { + t.Errorf("Should not error, got error: '%v'", err) + } + }) + }) + } + +} + +func TestIfElseIfElseNoBrace(t *testing.T) { + files := map[string]string{ + "bug.rego": `package bug + + p if false + else := 1 if false + else := 2`, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.optimizationLevel = 1 + params.dataPaths = newrepeatedStringFlag([]string{path}) + params.entrypoints = newrepeatedStringFlag([]string{"bug/p"}) + + var buf bytes.Buffer + + defined, err := eval([]string{"data.bug.p"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + }) +} + +func TestIfElseIfElseBrace(t *testing.T) { + files := map[string]string{ + "bug.rego": `package bug + + p if false + else := 1 if { false } + else := 2`, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.optimizationLevel = 1 + params.dataPaths = newrepeatedStringFlag([]string{path}) + params.entrypoints = newrepeatedStringFlag([]string{"bug/p"}) + + var buf bytes.Buffer + + defined, err := eval([]string{"data.bug.p"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + }) +} + +func TestIfElse(t *testing.T) { + files := map[string]string{ + "bug.rego": `package bug + + p if false + else := 1 `, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.optimizationLevel = 1 + params.dataPaths = newrepeatedStringFlag([]string{path}) + params.entrypoints = newrepeatedStringFlag([]string{"bug/p"}) + + var buf bytes.Buffer + + defined, err := eval([]string{"data.bug.p"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + }) +} + +// TestElseNoIfV0 only applies to v0 Rego +func TestElseNoIfV0(t *testing.T) { + files := map[string]string{ + "bug.rego": `package bug + import future.keywords.if + p if false + else = x { + x=2 + } `, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.optimizationLevel = 1 + params.dataPaths = newrepeatedStringFlag([]string{path}) + params.entrypoints = newrepeatedStringFlag([]string{"bug/p"}) + params.v0Compatible = true + + var buf bytes.Buffer + + defined, err := eval([]string{"data.bug.p"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + }) +} + +func TestElseIf(t *testing.T) { + files := map[string]string{ + "bug.rego": `package bug + + p if false + else := x if { + x=2 + } `, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.optimizationLevel = 1 + params.dataPaths = newrepeatedStringFlag([]string{path}) + params.entrypoints = newrepeatedStringFlag([]string{"bug/p"}) + + var buf bytes.Buffer + + defined, err := eval([]string{"data.bug.p"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + }) +} + +// TestElseIfElseV0 only applies to v0 Rego +func TestElseIfElseV0(t *testing.T) { + files := map[string]string{ + "bug.rego": `package bug + import future.keywords.if + p if false + else := x if { + x=2 + 1==2 + } else =x { + x=3 + }`, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.optimizationLevel = 1 + params.dataPaths = newrepeatedStringFlag([]string{path}) + params.entrypoints = newrepeatedStringFlag([]string{"bug/p"}) + params.v0Compatible = true + + var buf bytes.Buffer + + defined, err := eval([]string{"data.bug.p"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + }) +} + +func TestUnexpectedElseIfElseErr(t *testing.T) { + files := map[string]string{ + "bug.rego": `package bug + + p if false + else := x if { + x=2 + 1==2 + } else + x=3 + `, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.optimizationLevel = 1 + params.dataPaths = newrepeatedStringFlag([]string{path}) + params.entrypoints = newrepeatedStringFlag([]string{"bug/p"}) + + var buf bytes.Buffer + + _, err := eval([]string{"data.bug.p"}, params, &buf, nil) + + // Check if there was an error + if err == nil { + t.Fatalf("expected an error, but got nil") + } + + // Check the error message + errorMessage := err.Error() + expectedErrorMessage := "rego_parse_error: unexpected identifier token: expected else value term or rule body" + if !strings.Contains(errorMessage, expectedErrorMessage) { + t.Fatalf("expected error message to contain '%s', but got '%s'", expectedErrorMessage, errorMessage) + } + }) +} + +func TestUnexpectedElseIfErr(t *testing.T) { + files := map[string]string{ + "bug.rego": `package bug + + q := 1 if false + else := 2 if + `, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.optimizationLevel = 1 + params.dataPaths = newrepeatedStringFlag([]string{path}) + params.entrypoints = newrepeatedStringFlag([]string{"bug/p"}) + + var buf bytes.Buffer + + _, err := eval([]string{"data.bug.p"}, params, &buf, nil) + + // Check if there was an error + if err == nil { + t.Fatalf("expected an error, but got nil") + } + + // Check the error message + errorMessage := err.Error() + expectedErrorMessage := "rego_parse_error: unexpected eof token: rule body expected" + if !strings.Contains(errorMessage, expectedErrorMessage) { + t.Fatalf("expected error message to contain '%s', but got '%s'", expectedErrorMessage, errorMessage) + } + }) +} + +func TestEval_DefaultRegoVersion(t *testing.T) { + tests := []struct { + note string + modules map[string]string + query string + expErrs []string + }{ + { + note: "v0 module", + modules: map[string]string{ + "test.rego": `package test +a[x] { + x := 42 +}`, + }, + query: `data.test.a`, + expErrs: []string{ + "test.rego:2: rego_parse_error: `if` keyword is required before rule body", + "test.rego:2: rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v1 module", + modules: map[string]string{ + "test.rego": `package test +a contains x if { + x := 42 +}`, + }, + query: `data.test.a`, + }, + } + + setup := []struct { + name string + commandParams func(params *evalCommandParams, path string) + }{ + { + name: "Files", + commandParams: func(params *evalCommandParams, path string) { + params.dataPaths = newrepeatedStringFlag([]string{path}) + }, + }, + { + name: "Bundle", + commandParams: func(params *evalCommandParams, path string) { + if err := params.bundlePaths.Set(path); err != nil { + t.Fatal(err) + } + }, + }, + } + + for _, s := range setup { + for _, tc := range tests { + t.Run(fmt.Sprintf("%s: %s", s.name, tc.note), func(t *testing.T) { + test.WithTempFS(tc.modules, func(path string) { + params := newEvalCommandParams() + _ = params.outputFormat.Set(formats.Pretty) + s.commandParams(¶ms, path) + + var buf bytes.Buffer + + defined, err := eval([]string{tc.query}, params, &buf, &buf) + + if len(tc.expErrs) > 0 { + if err == nil { + t.Fatal("expected error, got none") + } + + actual := buf.String() + for _, expErr := range tc.expErrs { + if !strings.Contains(actual, expErr) { + t.Fatalf("expected error:\n\n%v\n\ngot\n\n%v", expErr, actual) + } + } + } else { + if err != nil { + t.Fatalf("Unexpected error: %v, buf: %s", err, buf.String()) + } else if !defined { + t.Fatal("expected result to be defined") + } + } + }) + }) + } + } +} + +func TestEvalPolicyWithCompatibleFlags(t *testing.T) { + tests := []struct { + note string + v0Compatible bool + v1Compatible bool + modules map[string]string + query string + expectedErr string + }{ + { + note: "v0 compatibility: policy with no rego.v1 or future.keywords imports", + v0Compatible: true, + modules: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + query: "data.test.allow", + expectedErr: "rego_parse_error", + }, + { + note: "v0 compatibility: policy with rego.v1 import", + v0Compatible: true, + modules: map[string]string{ + "test.rego": `package test + import rego.v1 + allow if { + 1 < 2 + }`, + }, + query: "data.test.allow", + }, + { + note: "v0 compatibility: policy with future.keywords import", + v0Compatible: true, + modules: map[string]string{ + "test.rego": `package test + import future.keywords + allow if { + 1 < 2 + }`, + }, + query: "data.test.allow", + }, + { + note: "v1 compatibility: policy with no rego.v1 or future.keywords imports", + v1Compatible: true, + modules: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + query: "data.test.allow", + }, + { + note: "v1 compatibility: policy with rego.v1 import", + v1Compatible: true, + modules: map[string]string{ + "test.rego": `package test + import rego.v1 + allow if { + 1 < 2 + }`, + }, + query: "data.test.allow", + }, + { + note: "v1 compatibility: policy with future.keywords import", + v1Compatible: true, + modules: map[string]string{ + "test.rego": `package test + import future.keywords.if + allow if { + 1 < 2 + }`, + }, + query: "data.test.allow", + }, + { + note: "v0 + v1 compatibility: policy with no rego.v1 or future.keywords imports", + v0Compatible: true, + v1Compatible: true, + modules: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + query: "data.test.allow", + expectedErr: "rego_parse_error", + }, + { + note: "v0 + v1 compatibility: policy with rego.v1 import", + v0Compatible: true, + v1Compatible: true, + modules: map[string]string{ + "test.rego": `package test + import rego.v1 + allow if { + 1 < 2 + }`, + }, + query: "data.test.allow", + }, + { + note: "v0 + v1 compatibility: policy with future.keywords import", + v0Compatible: true, + v1Compatible: true, + modules: map[string]string{ + "test.rego": `package test + import future.keywords + allow if { + 1 < 2 + }`, + }, + query: "data.test.allow", + }, + { + note: "v1 compatibility: policy with no rego.v1 or future.keywords imports", + v1Compatible: true, + modules: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + query: "data.test.allow", + }, + } + + setup := []struct { + name string + commandParams func(params *evalCommandParams, path string) + }{ + { + name: "Files", + commandParams: func(params *evalCommandParams, path string) { + params.dataPaths = newrepeatedStringFlag([]string{path}) + }, + }, + { + name: "Bundle", + commandParams: func(params *evalCommandParams, path string) { + if err := params.bundlePaths.Set(path); err != nil { + t.Fatal(err) + } + }, + }, + } + + for _, s := range setup { + for _, tc := range tests { + t.Run(fmt.Sprintf("%s: %s", s.name, tc.note), func(t *testing.T) { + test.WithTempFS(tc.modules, func(path string) { + params := newEvalCommandParams() + s.commandParams(¶ms, path) + params.v0Compatible = tc.v0Compatible + params.v1Compatible = tc.v1Compatible + + var buf bytes.Buffer + + defined, err := eval([]string{tc.query}, params, &buf, nil) + + if tc.expectedErr == "" { + if err != nil { + t.Fatalf("Unexpected error: %v, buf: %s", err, buf.String()) + } else if !defined { + t.Fatal("expected result to be defined") + } + } else { + if err == nil { + t.Fatal("expected error, got none") + } + + actual := buf.String() + if !strings.Contains(actual, tc.expectedErr) { + t.Fatalf("expected error:\n\n%v\n\ngot\n\n%v", tc.expectedErr, actual) + } + } + }) + }) + } + } +} + +func TestEvalPolicyWithRegoV1Capability(t *testing.T) { + tests := []struct { + note string + v0Compatible bool + capabilities *ast.Capabilities + modules map[string]string + expErrs []string + }{ + { + note: "v0 module, v0-compatible, no capabilities", + v0Compatible: true, + modules: map[string]string{ + "test.rego": `package test + allow { + 1 < 2 + }`, + }, + }, + { + note: "v0 module, v0-compatible, v0 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + modules: map[string]string{ + "test.rego": `package test + allow { + 1 < 2 + }`, + }, + }, + { + note: "v0 module, v0-compatible, v1 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + modules: map[string]string{ + "test.rego": `package test + allow { + 1 < 2 + }`, + }, + }, + { + note: "v0 module, not v0-compatible, no capabilities", + v0Compatible: false, + modules: map[string]string{ + "test.rego": `package test + allow { + 1 < 2 + }`, + }, + expErrs: []string{ + "test.rego:2: rego_parse_error: `if` keyword is required before rule body", + }, + }, + { + note: "v0 module, not v0-compatible, v0 capabilities", + v0Compatible: false, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + modules: map[string]string{ + "test.rego": `package test + allow { + 1 < 2 + }`, + }, + expErrs: []string{ + "test.rego:2: rego_parse_error: `if` keyword is required before rule body", + }, + }, + { + note: "v0 module, not v0-compatible, v0 capabilities without rego_v1 feature", + v0Compatible: false, + capabilities: capsWithoutFeat(ast.RegoV0, ast.FeatureRegoV1), + modules: map[string]string{ + "test.rego": `package test + allow { + 1 < 2 + }`, + }, + expErrs: []string{ + "rego_parse_error: illegal capabilities: rego_v1 feature required for parsing v1 Rego", + }, + }, + { + note: "v0 module, not v0-compatible, v1 capabilities", + v0Compatible: false, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + modules: map[string]string{ + "test.rego": `package test + allow { + 1 < 2 + }`, + }, + expErrs: []string{ + "test.rego:2: rego_parse_error: `if` keyword is required before rule body", + }, + }, + + { + note: "v1 module, v0-compatible, no capabilities", + v0Compatible: true, + modules: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + expErrs: []string{ + "test.rego:2: rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v1 module, v0-compatible, v0 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + modules: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + expErrs: []string{ + "test.rego:2: rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v1 module, v0-compatible, v1 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + modules: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + expErrs: []string{ + "test.rego:2: rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v1 module, not v0-compatible, no capabilities", + v0Compatible: false, + modules: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + }, + { + note: "v1 module, not v0-compatible, v0 capabilities", + v0Compatible: false, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + modules: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + }, + { + note: "v1 module, not v0-compatible, v0 capabilities without rego_v1 feature", + v0Compatible: false, + capabilities: capsWithoutFeat(ast.RegoV0, ast.FeatureRegoV1), + modules: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + expErrs: []string{ + "rego_parse_error: illegal capabilities: rego_v1 feature required for parsing v1 Rego", + }, + }, + { + note: "v1 module, not v0-compatible, v1 capabilities", + v0Compatible: false, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + modules: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + }, + } + + setup := []struct { + name string + commandParams func(params *evalCommandParams, path string) + }{ + { + name: "Files", + commandParams: func(params *evalCommandParams, path string) { + params.dataPaths = newrepeatedStringFlag([]string{path}) + }, + }, + { + name: "Bundle", + commandParams: func(params *evalCommandParams, path string) { + if err := params.bundlePaths.Set(path); err != nil { + t.Fatal(err) + } + }, + }, + } + + for _, s := range setup { + for _, tc := range tests { + t.Run(fmt.Sprintf("%s: %s", s.name, tc.note), func(t *testing.T) { + test.WithTempFS(tc.modules, func(path string) { + params := newEvalCommandParams() + s.commandParams(¶ms, path) + _ = params.outputFormat.Set(formats.Pretty) + params.v0Compatible = tc.v0Compatible + params.capabilities.C = tc.capabilities + + var buf bytes.Buffer + + defined, err := eval([]string{"data.test.allow"}, params, &buf, &buf) + + if len(tc.expErrs) > 0 { + if err == nil { + t.Fatal("expected error, got none") + } + + actual := buf.String() + for _, expErr := range tc.expErrs { + if !strings.Contains(actual, expErr) { + t.Fatalf("expected error:\n\n%v\n\ngot\n\n%v", expErr, actual) + } + } + } else { + if err != nil { + t.Fatalf("Unexpected error: %v, buf: %s", err, buf.String()) + } else if !defined { + t.Fatal("expected result to be defined") + } + } + }) + }) + } + } +} + +func TestEvalPolicyWithBundleRegoVersion(t *testing.T) { + tests := []struct { + note string + files map[string]string + query string + expectedErr string + }{ + { + note: "v0.x bundle, no rego.v1 or future.keywords imports", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +allow if { + 1 < 2 +}`, + }, + query: "data.test.allow", + expectedErr: "rego_parse_error", + }, + { + note: "v0 bundle, v1 per-file override", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "/policy2.rego": 1 + } +}`, + "policy1.rego": `package test +p[1] { + 1 < 2 +} +`, + "policy2.rego": `package test +p contains 2 if { + 1 < 2 +} +`, + }, + query: "data.test.p", + }, + { + note: "v0 bundle, v1 per-file override (glob)", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "/bar/*.rego": 1 + } +}`, + "foo/policy1.rego": `package test +p[1] { + 1 < 2 +} +`, + "bar/policy1.rego": `package test +p contains 2 if { + 1 < 2 +} +`, + "bar/policy2.rego": `package test +p contains 3 if { + 1 < 2 +} +`, + }, + query: "data.test.p", + }, + { + note: "v0 bundle, v1 per-file override, incompliant", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "/policy2.rego": 1 + } +}`, + "policy1.rego": `package test +p[1] { + 1 < 2 +} +`, + "policy2.rego": `package test +p[2] { + 1 < 2 +} +`, + }, + query: "data.test.p", + expectedErr: "rego_parse_error", + }, + + { + note: "v1.0 bundle, no rego.v1 or future.keywords imports", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +allow if { + 1 < 2 +}`, + }, + query: "data.test.allow", + }, + { + note: "v1.0 bundle, policy with rego.v1 import", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +import rego.v1 +allow if { + 1 < 2 +}`, + }, + query: "data.test.allow", + }, + { + note: "v1.0 bundle, future.keywords import", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +import future.keywords.if +allow if { + 1 < 2 +}`, + }, + query: "data.test.allow", + }, + { + note: "v1.0 bundle, keywords not used", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +allow { + 1 < 2 +}`, + }, + query: "data.test.allow", + expectedErr: "rego_parse_error", + }, + { + note: "v1 bundle, v0 per-file override", + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/policy1.rego": 0 + } +}`, + "policy1.rego": `package test +p[1] { + 1 < 2 +} +`, + "policy2.rego": `package test +p contains 2 if { + 1 < 2 +} +`, + }, + query: "data.test.p", + }, + { + note: "v1 bundle, v0 per-file override (glob)", + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/foo/*.rego": 0 + } +}`, + "foo/policy1.rego": `package test +p[1] { + 1 < 2 +} +`, + "foo/policy2.rego": `package test +p[2] { + 1 < 2 +} +`, + "bar/policy1.rego": `package test +p contains 3 if { + 1 < 2 +} +`, + }, + query: "data.test.p", + }, + { + note: "v1 bundle, v0 per-file override, incompliant", + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "*/policy2.rego": 0 + } +}`, + "policy1.rego": `package test +p contains 1 if { + input.x == 1 +} +`, + "policy2.rego": `package test +p contains 2 if { + input.x == 1 +} +`, + }, + query: "data.test.p", + expectedErr: "rego_parse_error", + }, + } + + bundleTypeCases := []struct { + note string + tar bool + }{ + { + "bundle dir", false, + }, + { + "bundle tar", true, + }, + } + + v0CompatibleFlagCases := []struct { + note string + used bool + }{ + { + "no --v0-compatible", false, + }, + { + "--v0-compatible", true, + }, + } + + for _, bundleType := range bundleTypeCases { + for _, v0CompatibleFlag := range v0CompatibleFlagCases { + for _, tc := range tests { + t.Run(fmt.Sprintf("%s, %s, %s", bundleType.note, v0CompatibleFlag.note, tc.note), func(t *testing.T) { + files := map[string]string{} + + if bundleType.tar { + files["bundle.tar.gz"] = "" + } else { + maps.Copy(files, tc.files) + } + + test.WithTempFS(files, func(root string) { + p := root + if bundleType.tar { + p = filepath.Join(root, "bundle.tar.gz") + files := make([][2]string, 0, len(tc.files)) + for k, v := range tc.files { + files = append(files, [2]string{k, v}) + } + buf := archive.MustWriteTarGz(files) + bf, err := os.Create(p) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + _, err = bf.Write(buf.Bytes()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + } + + params := newEvalCommandParams() + params.v0Compatible = v0CompatibleFlag.used + if err := params.bundlePaths.Set(p); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + + defined, err := eval([]string{tc.query}, params, &buf, nil) + + if tc.expectedErr == "" { + if err != nil { + t.Fatalf("Unexpected error: %v, buf: %s", err, buf.String()) + } else if !defined { + t.Fatal("expected result to be defined") + } + } else { + if err == nil { + t.Fatal("expected error, got none") + } + + actual := buf.String() + if !strings.Contains(actual, tc.expectedErr) { + t.Fatalf("expected error:\n\n%v\n\ngot\n\n%v", tc.expectedErr, actual) + } + } + }) + }) + } + } + } +} + +func TestWithQueryImports(t *testing.T) { + tests := []struct { + note string + query string + imports []string + v0Compatible bool + v1Compatible bool + exp string + expErrs []string + }{ + { + note: "no imports, none required", + query: "1 + 2", + exp: "3\n", + }, + { + note: "future keyword used, future.keywords imported", + query: `"b" in ["a", "b", "c"]`, + imports: []string{"future.keywords.in"}, + exp: "true\n", + }, + { + note: "future keyword used, rego.v1 imported", + query: `"b" in ["a", "b", "c"]`, + imports: []string{"rego.v1"}, + exp: "true\n", + }, + { + note: "future keyword used, invalid rego.v2 imported", + v0Compatible: true, + query: `"b" in ["a", "b", "c"]`, + imports: []string{"rego.v2"}, + expErrs: []string{ + "1:8: rego_parse_error: invalid import `rego.v2`, must be `rego.v1`", + }, + }, + { + note: "future keyword used, no imports (v0)", + v0Compatible: true, + query: `"b" in ["a", "b", "c"]`, + expErrs: []string{ + "1:5: rego_unsafe_var_error: var in is unsafe (hint: `import future.keywords.in` to import a future keyword)", + }, + }, + { + note: "future keyword used, no imports (v1)", + v1Compatible: true, + query: `"b" in ["a", "b", "c"]`, + exp: "true\n", + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + params := newEvalCommandParams() + _ = params.outputFormat.Set(formats.Pretty) + params.imports = newrepeatedStringFlag(tc.imports) + params.v0Compatible = tc.v0Compatible + params.v1Compatible = tc.v1Compatible + + var buf bytes.Buffer + + defined, err := eval([]string{tc.query}, params, &buf, &buf) + + if len(tc.expErrs) == 0 { + if err != nil { + t.Fatalf("Unexpected error: %v, buf: %s", err, buf.String()) + } + + if !defined { + t.Fatal("expected result to be defined") + } + + if buf.String() != tc.exp { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", tc.exp, buf.String()) + } + } else { + if err == nil { + t.Fatal("expected error, got none") + } + + actual := buf.String() + for _, expErr := range tc.expErrs { + if !strings.Contains(actual, expErr) { + t.Fatalf("expected error:\n\n%v\n\ngot\n\n%v", expErr, actual) + } + } + } + }) + } +} + +func TestEvalJSONOutputBytes(t *testing.T) { + params := newEvalCommandParams() + + var buf bytes.Buffer + + defined, err := eval([]string{"1 == 1"}, params, &buf, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if !defined { + t.Fatal("expected result to be defined") + } + + expected := `{ + "result": [ + { + "expressions": [ + { + "value": true, + "text": "1 == 1", + "location": { + "row": 1, + "col": 1 + } + } + ] + } + ] +} +` + if diff := cmp.Diff(expected, buf.String()); diff != "" { + t.Fatalf("unexpected JSON output (-want +got):\n%s", diff) + } +} diff --git a/cmd/eval_test.go b/cmd/eval_test.go old mode 100755 new mode 100644 index 372fbdcee0..73fb9159ff --- a/cmd/eval_test.go +++ b/cmd/eval_test.go @@ -1,3 +1,5 @@ +//go:build !go1.27 + // Copyright 2018 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. @@ -3946,3 +3948,38 @@ func TestWithQueryImports(t *testing.T) { }) } } + +func TestEvalJSONOutputBytes(t *testing.T) { + params := newEvalCommandParams() + + var buf bytes.Buffer + + defined, err := eval([]string{"1 == 1"}, params, &buf, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if !defined { + t.Fatal("expected result to be defined") + } + + expected := `{ + "result": [ + { + "expressions": [ + { + "value": true, + "text": "1 == 1", + "location": { + "row": 1, + "col": 1 + } + } + ] + } + ] +} +` + if diff := cmp.Diff(expected, buf.String()); diff != "" { + t.Fatalf("unexpected JSON output (-want +got):\n%s", diff) + } +} diff --git a/cmd/exec_jsonv2_test.go b/cmd/exec_jsonv2_test.go new file mode 100644 index 0000000000..e954fd6248 --- /dev/null +++ b/cmd/exec_jsonv2_test.go @@ -0,0 +1,1603 @@ +//go:build go1.27 + +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + + "github.com/open-policy-agent/opa/cmd/internal/exec" + "github.com/open-policy-agent/opa/internal/file/archive" + loggingtest "github.com/open-policy-agent/opa/v1/logging/test" + "github.com/open-policy-agent/opa/v1/plugins" + "github.com/open-policy-agent/opa/v1/sdk" + sdk_test "github.com/open-policy-agent/opa/v1/sdk/test" + "github.com/open-policy-agent/opa/v1/util/test" +) + +type execOutput struct { + Result []execResultItem `json:"result"` +} + +type execResultItem struct { + DecisionID string `json:"decision_id,omitempty"` + Path string `json:"path"` + Error execResultItemError `json:"error"` + Result *any `json:"result,omitempty"` +} + +type execResultItemError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func (r execResultItemError) isEmpty() bool { + return r.Code == "" && r.Message == "" +} + +func toAnyPtr(a any) *any { + return &a +} + +func toStringSlice(a *any) []string { + switch a := (*a).(type) { + case []string: + return a + case []any: + strSlice := make([]string, len(a)) + for i := range a { + strSlice[i] = a[i].(string) + } + return strSlice + } + + return nil +} + +func resultSliceEquals(t *testing.T, expected, output []execResultItem) { + t.Helper() + + if len(expected) != len(output) { + t.Fatalf("Expected %d results but got %d", len(expected), len(output)) + } + + for i := range output { + if expected[i].Path != output[i].Path { + t.Fatalf("Expected path %v but got %v", expected[i].Path, output[i].Path) + } + + if expected[i].Error.isEmpty() { + if !output[i].Error.isEmpty() { + t.Fatalf("Expected no error but got %v", output[i].Error) + } + + if !slices.Equal(toStringSlice(expected[i].Result), toStringSlice(output[i].Result)) { + t.Fatalf("Expected result %v but got %v", expected[i].Result, output[i].Result) + } + + if !uuidPattern.MatchString(output[i].DecisionID) { + t.Fatalf("Expected decision ID to be a UUID but got %v", output[i].DecisionID) + } + } else { + if expected[i].Error.Code != output[i].Error.Code { + t.Fatalf("Expected error code %v but got %v", expected[i].Error.Code, output[i].Error.Code) + } + + if expected[i].Error.Message != output[i].Error.Message { + t.Fatalf("Expected error message %v but got %v", expected[i].Error.Message, output[i].Error.Message) + } + + if output[i].DecisionID != "" { + t.Fatalf("Expected no decision ID but got %v", output[i].DecisionID) + } + } + } +} + +var uuidPattern = regexp.MustCompile(`^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$`) + +func TestExecBasic(t *testing.T) { + files := map[string]string{ + "test.json": `{"foo": 7}`, + "test2.yaml": `bar: 8`, + "test3.yml": `baz: 9`, + "ignore": `garbage`, // do not recognize this filetype + } + + test.WithTempFS(files, func(dir string) { + s := sdk_test.MustNewServer(sdk_test.MockBundle("/bundles/bundle.tar.gz", map[string]string{ + "test.rego": ` + package system + main contains "hello" + `, + })) + + defer s.Stop() + + var buf bytes.Buffer + params := exec.NewParams(&buf) + _ = params.OutputFormat.Set("json") + params.ConfigOverrides = []string{ + "services.test.url=" + s.URL(), + "bundles.test.resource=/bundles/bundle.tar.gz", + } + + params.Paths = append(params.Paths, dir) + err := runExec(params) + if err != nil { + t.Fatal(err) + } + + var output execOutput + if err := json.Unmarshal(bytes.ReplaceAll(buf.Bytes(), []byte(dir), nil), &output); err != nil { + t.Fatal(err) + } + + resultSliceEquals(t, []execResultItem{ + { + Path: "/test.json", + Result: toAnyPtr([]string{"hello"}), + }, + { + Path: "/test2.yaml", + Result: toAnyPtr([]string{"hello"}), + }, + { + Path: "/test3.yml", + Result: toAnyPtr([]string{"hello"}), + }, + }, output.Result) + }) +} + +func TestExecDecisionOption(t *testing.T) { + files := map[string]string{ + "test.json": `{"foo": 7}`, + } + + test.WithTempFS(files, func(dir string) { + s := sdk_test.MustNewServer(sdk_test.MockBundle("/bundles/bundle.tar.gz", map[string]string{ + "test.rego": ` + package foo + + main contains "hello" + `, + })) + + defer s.Stop() + + var buf bytes.Buffer + params := exec.NewParams(&buf) + _ = params.OutputFormat.Set("json") + params.Decision = "foo/main" + params.ConfigOverrides = []string{ + "services.test.url=" + s.URL(), + "bundles.test.resource=/bundles/bundle.tar.gz", + } + + params.Paths = append(params.Paths, dir) + err := runExec(params) + if err != nil { + t.Fatal(err) + } + + var output execOutput + if err := json.Unmarshal(bytes.ReplaceAll(buf.Bytes(), []byte(dir), nil), &output); err != nil { + t.Fatal(err) + } + + resultSliceEquals(t, []execResultItem{ + { + Path: "/test.json", + Result: toAnyPtr([]string{"hello"}), + }, + }, output.Result) + }) +} + +func TestExecBundleFlag(t *testing.T) { + files := map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package system + + main contains "hello"`, + } + + test.WithTempFS(files, func(dir string) { + var buf bytes.Buffer + params := exec.NewParams(&buf) + _ = params.OutputFormat.Set("json") + params.BundlePaths = []string{dir + "/bundle/"} + params.Paths = append(params.Paths, dir+"/files/") + + err := runExec(params) + if err != nil { + t.Fatal(err) + } + + var output execOutput + if err := json.Unmarshal(bytes.ReplaceAll(buf.Bytes(), []byte(dir), nil), &output); err != nil { + t.Fatal(err) + } + + resultSliceEquals(t, []execResultItem{ + { + Path: "/files/test.json", + Result: toAnyPtr([]string{"hello"}), + }, + }, output.Result) + }) +} + +func TestExec_DefaultRegoVersion(t *testing.T) { + tests := []struct { + note string + module string + expErrs []string + }{ + { + note: "v0, module", + module: `package system +main["hello"] { + input.foo == "bar" +}`, + expErrs: []string{ + "test.rego:2: rego_parse_error: `if` keyword is required before rule body", + "test.rego:2: rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v1 module", + module: `package system +main contains "hello" if { + input.foo == "bar" +}`, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + files := map[string]string{ + "test.json": `{"foo": "bar"}`, + } + + test.WithTempFS(files, func(dir string) { + s := sdk_test.MustNewServer( + sdk_test.MockBundle("/bundles/bundle.tar.gz", map[string]string{"test.rego": tc.module}), + sdk_test.RawBundles(true), + ) + + defer s.Stop() + + var buf bytes.Buffer + params := exec.NewParams(&buf) + _ = params.OutputFormat.Set("json") + params.ConfigOverrides = []string{ + "services.test.url=" + s.URL(), + "bundles.test.resource=/bundles/bundle.tar.gz", + } + + params.Paths = append(params.Paths, dir) + + if len(tc.expErrs) > 0 { + testLogger := loggingtest.New() + params.Logger = testLogger + + // Wait for bundle server to be ready + bundleURL := s.URL() + "/bundles/bundle.tar.gz" + test.EventuallyOrFatal(t, 1*time.Second, func() bool { + resp, err := http.Get(bundleURL) + if resp != nil { + defer resp.Body.Close() + } + return err == nil && resp.StatusCode == 200 + }) + + _ = runExec(params) + + // Check logged errors + for _, expErr := range tc.expErrs { + found := false + for _, e := range testLogger.Entries() { + if strings.Contains(e.Message, expErr) { + found = true + break + } + } + if !found { + t.Errorf("Could not find expected logged error: %s in %v", expErr, testLogger.Entries()) + } + } + } else { + err := runExec(params) + if err != nil { + t.Fatal(err) + } + + var output execOutput + if err := json.Unmarshal(bytes.ReplaceAll(buf.Bytes(), []byte(dir), nil), &output); err != nil { + t.Fatal(err) + } + + resultSliceEquals(t, []execResultItem{ + { + Path: "/test.json", + Result: toAnyPtr([]string{"hello"}), + }, + }, output.Result) + } + }) + }) + } +} + +func TestExecCompatibleFlags(t *testing.T) { + tests := []struct { + note string + v0Compatible bool + v1Compatible bool + module string + expErrs []string + }{ + { + note: "v0, no keywords used", + v0Compatible: true, + module: `package system +main["hello"] { + input.foo == "bar" +}`, + }, + { + note: "v0, no keywords imported", + v0Compatible: true, + module: `package system +main contains "hello" if { + input.foo == "bar" +}`, + expErrs: []string{ + "rego_parse_error: var cannot be used for rule name", + "rego_parse_error: string cannot be used for rule name", + }, + }, + { + note: "v0, keywords imported", + v0Compatible: true, + module: `package system +import future.keywords +main contains "hello" if { + input.foo == "bar" +}`, + }, + { + note: "v0, rego.v1 imported", + v0Compatible: true, + module: `package system +import rego.v1 +main contains "hello" if { + input.foo == "bar" +}`, + }, + + { + note: "v1, no keywords used", + v1Compatible: true, + module: `package system +main["hello"] { + input.foo == "bar" +}`, + expErrs: []string{ + "rego_parse_error: `if` keyword is required before rule body", + "rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v1, no keywords imported", + v1Compatible: true, + module: `package system +main contains "hello" if { + input.foo == "bar" +}`, + }, + { + note: "v1, keywords imported", + v1Compatible: true, + module: `package system +import future.keywords +main contains "hello" if { + input.foo == "bar" +}`, + }, + { + note: "v1, rego.v1 imported", + v1Compatible: true, + module: `package system +import rego.v1 +main contains "hello" if { + input.foo == "bar" +}`, + }, + + // v0 takes precedence over v1 + { + note: "v0+v1, no keywords used", + v0Compatible: true, + v1Compatible: true, + module: `package system +main["hello"] { + input.foo == "bar" +}`, + }, + { + note: "v0+v1, no keywords imported", + v0Compatible: true, + v1Compatible: true, + module: `package system +main contains "hello" if { + input.foo == "bar" +}`, + expErrs: []string{ + "rego_parse_error: var cannot be used for rule name", + "rego_parse_error: string cannot be used for rule name", + }, + }, + { + note: "v0+v1, keywords imported", + v0Compatible: true, + v1Compatible: true, + module: `package system +import future.keywords +main contains "hello" if { + input.foo == "bar" +}`, + }, + { + note: "v0+v1, rego.v1 imported", + v0Compatible: true, + v1Compatible: true, + module: `package system +import rego.v1 +main contains "hello" if { + input.foo == "bar" +}`, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + files := map[string]string{ + "test.json": `{"foo": "bar"}`, + } + + test.WithTempFS(files, func(dir string) { + s := sdk_test.MustNewServer( + sdk_test.MockBundle("/bundles/bundle.tar.gz", map[string]string{"test.rego": tc.module}), + sdk_test.RawBundles(true), + ) + + defer s.Stop() + + var buf bytes.Buffer + params := exec.NewParams(&buf) + params.V0Compatible = tc.v0Compatible + params.V1Compatible = tc.v1Compatible + _ = params.OutputFormat.Set("json") + params.ConfigOverrides = []string{ + "services.test.url=" + s.URL(), + "bundles.test.resource=/bundles/bundle.tar.gz", + } + + params.Paths = append(params.Paths, dir) + + if len(tc.expErrs) > 0 { + testLogger := loggingtest.New() + params.Logger = testLogger + + // Wait for bundle server to be ready + bundleURL := s.URL() + "/bundles/bundle.tar.gz" + test.EventuallyOrFatal(t, 1*time.Second, func() bool { + resp, err := http.Get(bundleURL) + if resp != nil { + defer resp.Body.Close() + } + return err == nil && resp.StatusCode == 200 + }) + + _ = runExec(params) + + // Check logged errors + for _, expErr := range tc.expErrs { + found := false + for _, e := range testLogger.Entries() { + if strings.Contains(e.Message, expErr) { + found = true + break + } + } + if !found { + t.Errorf("Could not find expected logged error: %s in %v", expErr, testLogger.Entries()) + } + } + } else { + err := runExec(params) + if err != nil { + t.Fatal(err) + } + + var output execOutput + if err := json.Unmarshal(bytes.ReplaceAll(buf.Bytes(), []byte(dir), nil), &output); err != nil { + t.Fatal(err) + } + + resultSliceEquals(t, []execResultItem{ + { + Path: "/test.json", + Result: toAnyPtr([]string{"hello"}), + }, + }, output.Result) + } + }) + }) + } +} + +func TestExecWithBundleRegoVersion(t *testing.T) { + tests := []struct { + note string + files map[string]string + expErrs []string + }{ + { + note: "v0.x bundle, no keywords used", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package system +main["hello"] { + input.foo == "bar" +}`, + }, + }, + { + note: "v0.x bundle, no keywords imported", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package system +main contains "hello" if { + input.foo == "bar" +}`, + }, + expErrs: []string{ + "rego_parse_error: var cannot be used for rule name", + "rego_parse_error: string cannot be used for rule name", + }, + }, + { + note: "v0.x bundle, keywords imported", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package system +import future.keywords +main contains "hello" if { + input.foo == "bar" +}`, + }, + }, + { + note: "v0.x bundle, rego.v1 imported", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package system +import rego.v1 +main contains "hello" if { + input.foo == "bar" +}`, + }, + }, + { + note: "v0 bundle, v1 per-file override", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "/policy2.rego": 1 + } +}`, + "policy1.rego": `package system +p[42] { + input.foo == "bar" +}`, + "policy2.rego": `package system +main contains "hello" if { + 42 in p +}`, + }, + }, + { + note: "v0 bundle, v1 per-file override (glob)", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "*/policy2.rego": 1 + } +}`, + "policy1.rego": `package system +p[42] { + input.foo == "bar" +}`, + "policy2.rego": `package system +main contains "hello" if { + 42 in p +}`, + }, + }, + { + note: "v0 bundle, v1 per-file override, incompatible", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "/policy2.rego": 1 + } +}`, + "policy1.rego": `package system +p[42] { + input.foo == "bar" +}`, + "policy2.rego": `package system +main["hello"] { + p[_] == 42 +}`, + }, + expErrs: []string{ + "rego_parse_error: `if` keyword is required before rule body", + "rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + + { + note: "v1.0 bundle, no keywords used", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package system +main["hello"] { + input.foo == "bar" +}`, + }, + expErrs: []string{ + "rego_parse_error: `if` keyword is required before rule body", + "rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v1.0 bundle, no keywords imported", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package system +main contains "hello" if { + input.foo == "bar" +}`, + }, + }, + { + note: "v1.0 bundle, keywords imported", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package system +import future.keywords +main contains "hello" if { + input.foo == "bar" +}`, + }, + }, + { + note: "v1.0 bundle, rego.v1 imported", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package system +import rego.v1 +main contains "hello" if { + input.foo == "bar" +}`, + }, + }, + { + note: "v1 bundle, v0 per-file override", + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/policy1.rego": 0 + } +}`, + "policy1.rego": `package system +p[42] { + input.foo == "bar" +}`, + "policy2.rego": `package system +main contains "hello" if { + 42 in p +}`, + }, + }, + { + note: "v1 bundle, v0 per-file override (glob)", + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "*/policy1.rego": 0 + } +}`, + "policy1.rego": `package system +p[42] { + input.foo == "bar" +}`, + "policy2.rego": `package system +main contains "hello" if { + 42 in p +}`, + }, + }, + { + note: "v1 bundle, v0 per-file override, incompatible", + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/policy1.rego": 0 + } +}`, + "policy1.rego": `package system +p contains 42 { + input.foo == "bar" +}`, + "policy2.rego": `package system +main contains "hello" if { + 42 in p +}`, + }, + expErrs: []string{ + "rego_parse_error: var cannot be used for rule name", + "rego_parse_error: number cannot be used for rule name", + "rego_parse_error: set cannot be used for rule name", + }, + }, + } + + bundleTypeCases := []struct { + note string + tar bool + }{ + { + "bundle dir", false, + }, + { + "bundle tar", true, + }, + } + + v1CompatibleFlagCases := []struct { + note string + used bool + }{ + { + "no --v1-compatible", false, + }, + { + "--v1-compatible", true, + }, + } + + for _, bundleType := range bundleTypeCases { + for _, v1CompatibleFlag := range v1CompatibleFlagCases { + for _, tc := range tests { + t.Run(fmt.Sprintf("%s, %s, %s", bundleType.note, v1CompatibleFlag.note, tc.note), func(t *testing.T) { + files := map[string]string{ + "files/test.json": `{"foo": "bar"}`, + } + if bundleType.tar { + files["bundle.tar.gz"] = "" + } else { + maps.Copy(files, tc.files) + } + + test.WithTempFS(files, func(root string) { + p := root + if bundleType.tar { + p = filepath.Join(root, "bundle.tar.gz") + files := make([][2]string, 0, len(tc.files)) + for k, v := range tc.files { + files = append(files, [2]string{k, v}) + } + buf := archive.MustWriteTarGz(files) + bf, err := os.Create(p) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + _, err = bf.Write(buf.Bytes()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + } + + var buf bytes.Buffer + params := exec.NewParams(&buf) + params.Paths = append(params.Paths, root+"/files/") + params.BundlePaths = []string{p} + params.V1Compatible = v1CompatibleFlag.used + _ = params.OutputFormat.Set("json") + + if len(tc.expErrs) > 0 { + testLogger := loggingtest.New() + params.Logger = testLogger + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + // when bundles fail to parse, OPA never signals the ready channel, causing + // runExec to hang indefinitely. WithContext allows us to cancel the context + // when we have the required errors logged. + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + err := runExecWithContext(ctx, params) + // we cancelled the context, so we expect that error + if err != nil && err.Error() != "context canceled" { + // make sure it isn't an expected error + for _, msg := range tc.expErrs { + if strings.Contains(err.Error(), msg) { + return + } + } + + t.Error(err) + return + } + }() + + test.EventuallyOrFatal(t, 5*time.Second, func() bool { + for _, expErr := range tc.expErrs { + found := false + for _, e := range testLogger.Entries() { + if strings.Contains(e.Message, expErr) { + found = true + break + } + } + if !found { + return false + } + } + return true + }) + + cancel() + wg.Wait() + } else { + err := runExec(params) + if err != nil { + t.Fatal(err) + } + + var output execOutput + if err := json.Unmarshal(bytes.ReplaceAll(buf.Bytes(), []byte(root), nil), &output); err != nil { + t.Fatal(err) + } + + resultSliceEquals(t, []execResultItem{ + { + Path: "/files/test.json", + Result: toAnyPtr([]string{"hello"}), + }, + }, output.Result) + } + }) + }) + } + } + } +} + +func TestInvalidConfig(t *testing.T) { + var buf bytes.Buffer + params := exec.NewParams(&buf) + params.Fail = true + params.FailDefined = true + + err := exec.Exec(t.Context(), nil, params) + if err == nil || err.Error() != "specify --fail or --fail-defined but not both" { + t.Fatalf("Expected error '%s' but got '%s'", "specify --fail or --fail-defined but not both", err.Error()) + } +} + +func TestInvalidConfigAllThree(t *testing.T) { + var buf bytes.Buffer + params := exec.NewParams(&buf) + params.Fail = true + params.FailDefined = true + params.FailNonEmpty = true + + err := exec.Exec(t.Context(), nil, params) + if err == nil || err.Error() != "specify --fail or --fail-defined but not both" { + t.Fatalf("Expected error '%s' but got '%s'", "specify --fail or --fail-defined but not both", err.Error()) + } +} + +func TestInvalidConfigNonEmptyAndFail(t *testing.T) { + var buf bytes.Buffer + params := exec.NewParams(&buf) + params.FailNonEmpty = true + params.Fail = true + + err := exec.Exec(t.Context(), nil, params) + if err == nil || err.Error() != "specify --fail-non-empty or --fail but not both" { + t.Fatalf("Expected error '%s' but got '%s'", "specify --fail-non-empty or --fail but not both", err.Error()) + } +} + +func TestInvalidConfigNonEmptyAndFailDefined(t *testing.T) { + var buf bytes.Buffer + params := exec.NewParams(&buf) + params.FailNonEmpty = true + params.FailDefined = true + + err := exec.Exec(t.Context(), nil, params) + if err == nil || err.Error() != "specify --fail-non-empty or --fail-defined but not both" { + t.Fatalf("Expected error '%s' but got '%s'", "specify --fail-non-empty or --fail-defined but not both", err.Error()) + } +} + +func TestFailFlagCases(t *testing.T) { + tests := []struct { + description string + files map[string]string + decision string + expectError bool + expected []byte + fail bool + failDefined bool + failNonEmpty bool + }{ + { + description: "--fail-defined with undefined result", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package system + import rego.v1 + + test_fun := x if { + x = false + x + } + + undefined_test if { + test_fun + }`, + }, + expectError: false, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "error": { + "code": "opa_undefined_error", + "message": "/system/main decision was undefined" + } + }]}`), + failDefined: true, + }, + { + description: "--fail-defined with populated result", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package system + import rego.v1 + + main contains "hello"`, + }, + decision: "", + expectError: true, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "result": ["hello"] + }]}`), + failDefined: true, + }, + { + description: "--fail-defined with true boolean result", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package fail.defined.flag + import rego.v1 + + some_function if { + input.foo == 7 + } + + default fail_test := false + fail_test if { + some_function + }`, + }, + decision: "fail/defined/flag/fail_test", + expectError: true, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "result": true + }]}`), + failDefined: true, + }, + { + description: "--fail-defined with false boolean result", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package fail.defined.flag + import rego.v1 + + default fail_test := false + fail_test if { + false + }`, + }, + decision: "fail/defined/flag/fail_test", + expectError: true, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "result": false + }]}`), + failDefined: true, + }, + { + description: "--fail with undefined result", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package system + import rego.v1 + + test_fun := x if { + x = false + x + } + + undefined_test if { + test_fun + }`, + }, + expectError: true, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "error": { + "code": "opa_undefined_error", + "message": "/system/main decision was undefined" + } + }]}`), + fail: true, + }, + { + description: "--fail with populated result", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package system + import rego.v1 + + main contains "hello"`, + }, + expectError: false, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "result": ["hello"] + }]}`), + fail: true, + }, + { + description: "--fail with true boolean result", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package fail.defined.flag + import rego.v1 + + some_function if { + input.foo == 7 + } + + default fail_test := false + fail_test if { + some_function + }`, + }, + decision: "fail/defined/flag/fail_test", + expectError: false, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "result": true + }]}`), + fail: true, + }, + { + description: "--fail with false boolean result", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package fail.defined.flag + import rego.v1 + + default fail_test := false + fail_test if { + false + }`, + }, + decision: "fail/defined/flag/fail_test", + expectError: false, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "result": false + }]}`), + fail: true, + }, + { + description: "--fail-non-empty with undefined result", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package system + import rego.v1 + + test_fun := x if { + x = false + x + } + + undefined_test if { + test_fun + }`, + }, + expectError: false, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "error": { + "code": "opa_undefined_error", + "message": "/system/main decision was undefined" + } + }]}`), + failNonEmpty: true, + }, + { + description: "--fail-non-empty with populated result", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package system + import rego.v1 + + main contains "hello"`, + }, + decision: "", + expectError: true, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "result": ["hello"] + }]}`), + failNonEmpty: true, + }, + { + description: "--fail-non-empty with true boolean result", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package fail.non.empty.flag + import rego.v1 + + some_function if { + input.foo == 7 + } + + default fail_test := false + fail_test if { + some_function + }`, + }, + decision: "fail/non/empty/flag/fail_test", + expectError: true, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "result": true + }]}`), + failNonEmpty: true, + }, + { + description: "--fail-non-empty with false boolean result", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package fail.non.empty.flag + import rego.v1 + + default fail_test := false + fail_test if { + false + }`, + }, + decision: "fail/non/empty/flag/fail_test", + expectError: true, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "result": false + }]}`), + failNonEmpty: true, + }, + { + description: "--fail-non-empty with an empty array", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package fail.non.empty.flag + import rego.v1 + + default fail_test := ["something", "hello"] + fail_test := [] if { + input.foo == 7 + }`, + }, + decision: "fail/non/empty/flag/fail_test", + expectError: false, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "result": [] + }]}`), + failNonEmpty: true, + }, + { + description: "--fail-non-empty for an empty set coming from a partial rule", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package fail.non.empty.flag + import rego.v1 + + fail_test contains message if { + false + message := "not gonna happen" + }`, + }, + decision: "fail/non/empty/flag/fail_test", + expectError: false, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "result": [] + }]}`), + failNonEmpty: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + test.WithTempFS(tt.files, func(dir string) { + var buf bytes.Buffer + params := exec.NewParams(&buf) + _ = params.OutputFormat.Set("json") + params.BundlePaths = []string{dir + "/bundle/"} + params.Paths = append(params.Paths, dir+"/files/") + if tt.decision != "" { + params.Decision = tt.decision + } + params.FailDefined = tt.failDefined + params.Fail = tt.fail + params.FailNonEmpty = tt.failNonEmpty + + err := runExec(params) + if err != nil && !tt.expectError { + t.Fatal("unexpected error in test") + } + if err == nil && tt.expectError { + t.Fatal("expected error, but none occurred in test") + } + + var output execOutput + if err := json.Unmarshal(bytes.ReplaceAll(buf.Bytes(), []byte(dir), nil), &output); err != nil { + t.Fatal(err) + } + + var expected execOutput + if err := json.Unmarshal(tt.expected, &expected); err != nil { + t.Fatal(err) + } + + resultSliceEquals(t, expected.Result, output.Result) + }) + }) + } +} + +func TestExecJSONOutputBytes(t *testing.T) { + files := map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package system + import rego.v1 + + test_fun := x if { + x = false + x + } + + undefined_test if { + test_fun + }`, + } + + test.WithTempFS(files, func(dir string) { + var buf bytes.Buffer + params := exec.NewParams(&buf) + _ = params.OutputFormat.Set("json") + params.BundlePaths = []string{dir + "/bundle/"} + params.Paths = append(params.Paths, dir+"/files/") + params.FailDefined = true + + if err := runExec(params); err != nil { + t.Fatal("unexpected error in test:", err) + } + + actual := bytes.ReplaceAll(buf.Bytes(), []byte(dir), nil) + + expected := `{ + "result": [ + { + "path": "/files/test.json", + "error": { + "code": "opa_undefined_error", + "message": "/system/main decision was undefined" + } + } + ] +} +` + + if diff := cmp.Diff(expected, string(actual)); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } + }) +} + +func TestExecWithInvalidInputOptions(t *testing.T) { + tests := []struct { + description string + files map[string]string + stdIn bool + input string + expectError bool + expected string + }{ + { + description: "path passed in as arg should not raise error", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package system + import rego.v1 + + test_fun := x if { + x = false + x + } + + undefined_test if { + test_fun + }`, + }, + expectError: false, + expected: "", + }, + { + description: "no paths passed in as args should raise error if --stdin-input flag not set", + files: map[string]string{ + "bundle/x.rego": `package system + import rego.v1 + + test_fun := x if { + x = false + x + } + + undefined_test if { + test_fun + }`, + }, + expectError: true, + expected: "requires at least 1 path arg, or the --stdin-input flag", + }, + { + description: "should not raise error if --stdin-input flag is set when no paths passed in as args", + files: map[string]string{ + "bundle/x.rego": `package system + import rego.v1 + + test_fun := x if { + x = false + x + } + + undefined_test if { + test_fun + }`, + }, + stdIn: true, + input: `{"foo": 7}`, + expectError: false, + expected: "", + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + test.WithTempFS(tt.files, func(dir string) { + var buf bytes.Buffer + params := exec.NewParams(&buf) + _ = params.OutputFormat.Set("json") + params.BundlePaths = []string{dir + "/bundle/"} + if tt.stdIn { + params.StdIn = true + tempFile, err := os.CreateTemp(t.TempDir(), "test") + if err != nil { + t.Fatalf("unexpected error creating temp file: %q", err.Error()) + } + if _, err := tempFile.WriteString(tt.input); err != nil { + t.Fatalf("unexpeced error when writing to temp file: %q", err.Error()) + } + if _, err := tempFile.Seek(0, 0); err != nil { + t.Fatalf("unexpected error when rewinding temp file: %q", err.Error()) + } + oldStdin := os.Stdin + defer func() { + os.Stdin = oldStdin + os.Remove(tempFile.Name()) + }() + os.Stdin = tempFile + } else { + if _, ok := tt.files["files/test.json"]; ok { + params.Paths = append(params.Paths, dir+"/files/") + } + } + + err := runExec(params) + if err != nil && !tt.expectError { + t.Fatalf("unexpected error in test: %q", err.Error()) + } + if err == nil && tt.expectError { + t.Fatalf("expected error %q, but none occurred in test", tt.expected) + } + if err != nil && err.Error() != tt.expected { + t.Fatalf("expected error %q, but got %q", tt.expected, err.Error()) + } + }) + }) + } +} + +func TestExecMalformedRemoteBundle(t *testing.T) { + bundlePath := "/bundles/bundle.tar.gz" + // Note(philipc): We add the "raw bundles" flag so that we can stuff a + // malformed bundle into the mock bundle server. Otherwise, the server + // will just return 503 errors forever, because it won't be able to + // build the bundle on its end. + s := sdk_test.MustNewServer( + sdk_test.RawBundles(true), + sdk_test.MockBundle(bundlePath, map[string]string{ + "example.rego": ` + package example + + p := bits.sand(42, 43) # typo of bits.and + `, + })) + + defer s.Stop() + + // Wait for the bundle server to be ready before running exec + bundleURL := s.URL() + bundlePath + test.EventuallyOrFatal(t, 1*time.Second, func() bool { + resp, err := http.Get(bundleURL) + if resp != nil { + defer resp.Body.Close() + } + return err == nil && resp.StatusCode == 200 + }) + + var buf bytes.Buffer + params := exec.NewParams(&buf) + _ = params.OutputFormat.Set("json") + params.ConfigOverrides = []string{ + "services.test.url=" + s.URL(), + "bundles.test.resource=" + bundlePath, + } + + // Note(philipc): We can set this timeout almost arbitrarily high or + // low-- the test will time out before it ever succeeds, due to the + // faulty bundle. + params.Timeout = time.Millisecond * 50 + + params.Paths = append(params.Paths, t.TempDir()) + err := runExec(params) + if err == nil { + t.Fatalf("Expected error, got nil instead.") + } + + exp := "runtime error: Bundle name: test, Code: bundle_error, HTTPCode: -1, Message: 1 error occurred: /example.rego:4: rego_type_error: undefined function bits.sand" + if !strings.HasPrefix(err.Error(), exp) { + t.Fatalf("Expected error: %s, got %s", exp, err.Error()) + } +} + +func TestExecStopsPlugins(t *testing.T) { + fact := &factory{} + + sdk.SetDefaultOptions(sdk.Options{ + Plugins: map[string]plugins.Factory{ + "test_plugin": fact, + }, + }) + + s := sdk_test.MustNewServer(sdk_test.MockBundle("/bundles/bundle.tar.gz", map[string]string{ + "test.rego": ` + package system + main contains "hello" + `, + })) + defer s.Stop() + + cfg := filepath.Join(t.TempDir(), "opa.yaml") + if err := os.WriteFile(cfg, []byte(` +plugins: + test_plugin: {} +`), 0x777); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + params := exec.NewParams(&buf) + params.ConfigFile = cfg + _ = params.OutputFormat.Set("json") + params.ConfigOverrides = []string{ + "services.test.url=" + s.URL(), + "bundles.test.resource=/bundles/bundle.tar.gz", + } + + dir := t.TempDir() + params.Paths = append(params.Paths, dir) + + err := runExec(params) + if err != nil { + t.Fatal(err) + } + + if !fact.stopped { + t.Errorf("expected plugin to be stopped") + } +} + +type factory struct { + stopped bool + m *plugins.Manager +} + +func (f *factory) New(m *plugins.Manager, _ any) plugins.Plugin { + f.m = m + return f +} + +func (*factory) Validate(*plugins.Manager, []byte) (any, error) { + return nil, nil +} + +func (f *factory) Start(context.Context) error { + f.m.UpdatePluginStatus("test_plugin", &plugins.Status{State: plugins.StateOK}) + return nil +} + +func (f *factory) Stop(context.Context) { + f.stopped = true +} + +func (*factory) Reconfigure(context.Context, any) { +} diff --git a/cmd/exec_test.go b/cmd/exec_test.go index b36198b7ea..53fa30e901 100644 --- a/cmd/exec_test.go +++ b/cmd/exec_test.go @@ -1,3 +1,5 @@ +//go:build !go1.27 + package cmd import ( @@ -16,6 +18,8 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" + "github.com/open-policy-agent/opa/cmd/internal/exec" "github.com/open-policy-agent/opa/internal/file/archive" loggingtest "github.com/open-policy-agent/opa/v1/logging/test" @@ -1310,6 +1314,55 @@ func TestFailFlagCases(t *testing.T) { } } +func TestExecJSONOutputBytes(t *testing.T) { + files := map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package system + import rego.v1 + + test_fun := x if { + x = false + x + } + + undefined_test if { + test_fun + }`, + } + + test.WithTempFS(files, func(dir string) { + var buf bytes.Buffer + params := exec.NewParams(&buf) + _ = params.OutputFormat.Set("json") + params.BundlePaths = []string{dir + "/bundle/"} + params.Paths = append(params.Paths, dir+"/files/") + params.FailDefined = true + + if err := runExec(params); err != nil { + t.Fatal("unexpected error in test:", err) + } + + actual := bytes.ReplaceAll(buf.Bytes(), []byte(dir), nil) + + expected := `{ + "result": [ + { + "path": "/files/test.json", + "error": { + "code": "opa_undefined_error", + "message": "/system/main decision was undefined" + } + } + ] +} +` + + if diff := cmp.Diff(expected, string(actual)); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } + }) +} + func TestExecWithInvalidInputOptions(t *testing.T) { tests := []struct { description string diff --git a/cmd/inspect_jsonv2_test.go b/cmd/inspect_jsonv2_test.go new file mode 100644 index 0000000000..faf82aace7 --- /dev/null +++ b/cmd/inspect_jsonv2_test.go @@ -0,0 +1,2299 @@ +//go:build go1.27 + +// Copyright 2021 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 cmd + +import ( + "bytes" + "fmt" + "maps" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/open-policy-agent/opa/cmd/formats" + "github.com/open-policy-agent/opa/internal/file/archive" + "github.com/open-policy-agent/opa/v1/util" + + "github.com/open-policy-agent/opa/v1/util/test" +) + +func TestDoInspectJSONOutputBytes(t *testing.T) { + files := [][2]string{ + {"/.manifest", `{"revision": "rev", "roots": ["foo", "bar", "fuz", "baz", "a", "x"]}`}, + {"/data.json", `{"x": {"y": true}, "a": {"b": {"z": true}}}`}, + {"/example/foo.rego", `package foo`}, + } + + buf := archive.MustWriteTarGz(files) + bundleFile := filepath.Join(t.TempDir(), "bundle.tar.gz") + if err := os.WriteFile(bundleFile, buf.Bytes(), 0o644); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var out bytes.Buffer + params := newInspectCommandParams() + if err := params.outputFormat.Set(formats.JSON); err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if err := doInspect(params, bundleFile, &out); err != nil { + t.Fatalf("Unexpected error %v", err) + } + + expected := `{ + "manifest": { + "revision": "rev", + "roots": [ + "foo", + "bar", + "fuz", + "baz", + "a", + "x" + ] + }, + "signatures_config": {}, + "namespaces": { + "data": [ + "/data.json" + ], + "data.foo": [ + "/example/foo.rego" + ] + }, + "capabilities": { + "features": [ + "rego_v1" + ] + } +} +` + if diff := cmp.Diff(expected, out.String()); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } +} + +func TestDoInspect(t *testing.T) { + files := [][2]string{ + {"/.manifest", `{"revision": "rev", "roots": ["foo", "bar", "fuz", "baz", "a", "x"]}`}, + {"/data.json", `{"x": {"y": true}, "a": {"b": {"z": true}}}`}, + {"/example/foo.rego", `package foo`}, + } + + buf := archive.MustWriteTarGz(files) + + rootDir := t.TempDir() + bundleFile := filepath.Join(rootDir, "bundle.tar.gz") + + bf, err := os.Create(bundleFile) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + _, err = bf.Write(buf.Bytes()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var out bytes.Buffer + params := newInspectCommandParams() + err = params.outputFormat.Set(formats.JSON) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + err = doInspect(params, bundleFile, &out) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + res := `{ + "capabilities": {"features": ["rego_v1"]}, + "manifest": {"revision": "rev", "roots": ["foo", "bar", "fuz", "baz", "a", "x"]}, + "signatures_config": {}, + "namespaces": {"data": ["/data.json"], "data.foo": ["/example/foo.rego"]} + }` + + exp := util.MustUnmarshalJSON([]byte(res)) + result := util.MustUnmarshalJSON(out.Bytes()) + if !reflect.DeepEqual(exp, result) { + t.Fatalf("expected inspect output to be:\n\n%v\n\ngot:\n\n%v", exp, result) + } +} + +func TestDoInspectPretty(t *testing.T) { + + root := fmt.Sprintf("metadata/%v/features", strings.Repeat("foobar", 20)) + + manifest := fmt.Sprintf(`{"revision": "%s", +"roots": ["foo", "bar", "fuz", "http", "a", "x", "%s"], +"metadata": {"hello": "%s"}, +"wasm": [{"entrypoint": "http/example/authz", "module": "/policy.wasm"}, {"entrypoint": "http/example/foo/allow", "module": "/example/policy.wasm"}]}`, strings.Repeat("foobar", 10), root, strings.Repeat("world", 100)) + + files := [][2]string{ + {"/.manifest", manifest}, + {"/data.json", `{"x": {"y": true}, "a": {"b": {"z": true}}}`}, + {"/http/example/authz/foo.rego", `package http.example.authz`}, + {"/http/example/authz/data.json", `{"faz": "baz"}`}, + {"/example/foo.rego", `package foo`}, + {"/a/b/y/foo.rego", `package a.b.y`}, + {"/a/xxxxxxxxxxxxxxxxxxxxxx/yyyyyyyyyyyyyyyyyyyy/foo.rego", `package a.b.y`}, + {"/example/policy.wasm", `modules-compiled-as-wasm-binary`}, + {"/http/example/policy.wasm", `modules-compiled-as-wasm-binary`}, + {"/policy.wasm", `modules-compiled-as-wasm-binary`}, + } + + buf := archive.MustWriteTarGz(files) + bundleFile := filepath.Join(t.TempDir(), "bundle.tar.gz") + + bf, err := os.Create(bundleFile) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + _, err = bf.Write(buf.Bytes()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var out bytes.Buffer + err = doInspect(newInspectCommandParams(), bundleFile, &out) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + output := strings.TrimSpace(out.String()) + expected := strings.TrimSpace(` + MANIFEST: +┌──────────┬────────────────────────────────────────────────────┐ +│ FIELD │ VALUE │ +├──────────┼────────────────────────────────────────────────────┤ +│ Revision │ foobarfoobarfoobarfoobarfoobarfoobarfoobarfooba... │ +│ Roots │ a │ +│ │ bar │ +│ │ foo │ +│ │ fuz │ +│ │ http │ +│ │ metadata/...oobarfoobarfoobarfoobarfoobar/features │ +│ │ x │ +│ Metadata │ {"hello":"worldworldworldworldworldworldworldwo... │ +└──────────┴────────────────────────────────────────────────────┘ +NAMESPACES: +┌─────────────────────────────┬────────────────────────────────────────────────────┐ +│ NAMESPACE │ FILE │ +├─────────────────────────────┼────────────────────────────────────────────────────┤ +│ data │ /data.json │ +│ data.a.b.y │ /a/b/y/foo.rego │ +│ │ /a/...xxxxxxxxxxxxxx/yyyyyyyyyyyyyyyyyyyy/foo.rego │ +│ data.foo │ /example/foo.rego │ +│ data.http.example.authz │ /http/example/authz/foo.rego │ +│ │ /http/example/authz/data.json │ +│ │ /policy.wasm │ +│ data.http.example.foo.allow │ /example/policy.wasm │ +└─────────────────────────────┴────────────────────────────────────────────────────┘ +`) + + if output != expected { + t.Fatalf("Unexpected output. Expected:\n\n%v\n\nGot:\n\n%v", expected, output) + } +} + +func TestDoInspectPrettyManifestOnlySingleRoot(t *testing.T) { + root := fmt.Sprintf("metadata/%v/features", strings.Repeat("foobar", 6)) + + manifest := fmt.Sprintf(`{"roots": ["%s"], +"metadata": {"hello": "world"}}`, root) + + files := [][2]string{ + {"/.manifest", manifest}, + } + + buf := archive.MustWriteTarGz(files) + bundleFile := filepath.Join(t.TempDir(), "bundle.tar.gz") + + bf, err := os.Create(bundleFile) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + _, err = bf.Write(buf.Bytes()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var out bytes.Buffer + err = doInspect(newInspectCommandParams(), bundleFile, &out) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + output := strings.TrimSpace(out.String()) + expected := strings.TrimSpace(` +MANIFEST: +┌──────────┬────────────────────────────────────────────────────┐ +│ FIELD │ VALUE │ +├──────────┼────────────────────────────────────────────────────┤ +│ Roots │ metadata/...oobarfoobarfoobarfoobarfoobar/features │ +│ Metadata │ {"hello":"world"} │ +└──────────┴────────────────────────────────────────────────────┘ +`) + + if output != expected { + t.Fatalf("Unexpected output. Expected:\n\n%v\n\nGot:\n\n%v", expected, output) + } +} + +func TestInspectMultiBundleError(t *testing.T) { + params := newInspectCommandParams() + err := validateInspectParams(¶ms, []string{"foo", "bar"}) + if err == nil { + t.Fatal("Expected error but got nil") + } + + exp := "specify exactly one OPA bundle or path" + if err.Error() != exp { + t.Fatalf("Expected error %v but got %v", exp, err.Error()) + } +} + +func TestDoInspectWithAnnotations(t *testing.T) { + + files := map[string]string{ + "x.rego": `# METADATA +# title: pkg-title +# description: pkg-descr +# organizations: +# - pkg-org +# related_resources: +# - https://pkg +# - ref: https://pkg +# description: rr-pkg-note +# authors: +# - pkg-author +# schemas: +# - input: {"type": "boolean"} +# custom: +# pkg: pkg-custom +package test + +# METADATA +# scope: document +# title: doc-title +# description: doc-descr +# organizations: +# - doc-org +# related_resources: +# - https://doc +# - ref: https://doc +# description: rr-doc-note +# authors: +# - doc-author +# schemas: +# - input: {"type": "integer"} +# custom: +# doc: doc-custom + +# METADATA +# title: rule-title +# description: rule-title +# organizations: +# - rule-org +# related_resources: +# - https://rule +# - ref: https://rule +# description: rr-rule-note +# authors: +# - rule-author +# schemas: +# - input: {"type": "string"} +# custom: +# rule: rule-custom +p = 1`, + } + + t.Run("pretty", func(t *testing.T) { + test.WithTempFS(files, func(rootDir string) { + ps := newInspectCommandParams() + ps.listAnnotations = true + var out bytes.Buffer + err := doInspect(ps, rootDir, &out) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + bs := out.Bytes() + idx := bytes.Index(bs, []byte(`ANNOTATIONS`)) // skip NAMESPACE box + output := strings.TrimSpace(string(bs[idx:])) + expected := strings.TrimSpace(fmt.Sprintf(` +ANNOTATIONS: +pkg-title +========= + +pkg-descr + +Package: test +Location: %[1]s/x.rego:16 +Scope: package + +Organizations: + pkg-org + +Authors: + pkg-author + +Schemas: + input: {"type":"boolean"} + +Related Resources: + https://pkg + https://pkg rr-pkg-note + +Custom: + pkg: "pkg-custom" + +doc-title +========= + +doc-descr + +Package: test +Rule: p +Location: %[1]s/x.rego:50 +Scope: document + +Organizations: + doc-org + +Authors: + doc-author + +Schemas: + input: {"type":"integer"} + +Related Resources: + https://doc + https://doc rr-doc-note + +Custom: + doc: "doc-custom" + +rule-title +========== + +rule-title + +Package: test +Rule: p +Location: %[1]s/x.rego:50 +Scope: rule + +Organizations: + rule-org + +Authors: + rule-author + +Schemas: + input: {"type":"string"} + +Related Resources: + https://rule + https://rule rr-rule-note + +Custom: + rule: "rule-custom"`, rootDir)) + + if output != expected { + t.Fatalf("Unexpected output. Expected:\n\n%q\n\nGot:\n\n%q", expected, output) + } + }) + }) + + t.Run("json", func(t *testing.T) { + test.WithTempFS(files, func(rootDir string) { + ps := newInspectCommandParams() + ps.listAnnotations = true + err := ps.outputFormat.Set(formats.JSON) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + var out bytes.Buffer + err = doInspect(ps, rootDir, &out) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + bs := out.Bytes() + expected := strings.TrimSpace(fmt.Sprintf(`{ + "manifest": { + "revision": "", + "roots": [ + "" + ] + }, + "signatures_config": {}, + "namespaces": { + "data.test": [ + "%[1]s/x.rego" + ] + }, + "annotations": [ + { + "annotations": { + "authors": [ + { + "name": "pkg-author" + } + ], + "custom": { + "pkg": "pkg-custom" + }, + "description": "pkg-descr", + "organizations": [ + "pkg-org" + ], + "related_resources": [ + { + "ref": "https://pkg" + }, + { + "description": "rr-pkg-note", + "ref": "https://pkg" + } + ], + "schemas": [ + { + "path": [ + { + "type": "var", + "value": "input" + } + ], + "definition": { + "type": "boolean" + } + } + ], + "scope": "package", + "title": "pkg-title" + }, + "location": { + "file": "%[1]s/x.rego", + "row": 16, + "col": 1 + }, + "path": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "test" + } + ] + }, + { + "annotations": { + "authors": [ + { + "name": "doc-author" + } + ], + "custom": { + "doc": "doc-custom" + }, + "description": "doc-descr", + "organizations": [ + "doc-org" + ], + "related_resources": [ + { + "ref": "https://doc" + }, + { + "description": "rr-doc-note", + "ref": "https://doc" + } + ], + "schemas": [ + { + "path": [ + { + "type": "var", + "value": "input" + } + ], + "definition": { + "type": "integer" + } + } + ], + "scope": "document", + "title": "doc-title" + }, + "location": { + "file": "%[1]s/x.rego", + "row": 50, + "col": 1 + }, + "path": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "test" + }, + { + "type": "string", + "value": "p" + } + ] + }, + { + "annotations": { + "authors": [ + { + "name": "rule-author" + } + ], + "custom": { + "rule": "rule-custom" + }, + "description": "rule-title", + "organizations": [ + "rule-org" + ], + "related_resources": [ + { + "ref": "https://rule" + }, + { + "description": "rr-rule-note", + "ref": "https://rule" + } + ], + "schemas": [ + { + "path": [ + { + "type": "var", + "value": "input" + } + ], + "definition": { + "type": "string" + } + } + ], + "scope": "rule", + "title": "rule-title" + }, + "location": { + "file": "%[1]s/x.rego", + "row": 50, + "col": 1 + }, + "path": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "test" + }, + { + "type": "string", + "value": "p" + } + ] + } + ], + "capabilities": { + "features": [ + "rego_v1" + ] + } +}`, rootDir)) + + exp := util.MustUnmarshalJSON([]byte(expected)) + result := util.MustUnmarshalJSON(bs) + if !reflect.DeepEqual(exp, result) { + t.Fatalf("expected inspect output to be:\n\n%v\n\ngot:\n\n%v", exp, result) + } + }) + }) +} + +func TestDoInspectTarballWithAnnotations(t *testing.T) { + + files := [][2]string{ + {"x.rego", `# METADATA +# title: pkg-title +# description: pkg-descr +# organizations: +# - pkg-org +# related_resources: +# - https://pkg +# - ref: https://pkg +# description: rr-pkg-note +# authors: +# - pkg-author +# schemas: +# - input: {"type": "boolean"} +# custom: +# pkg: pkg-custom +package test + +# METADATA +# scope: document +# title: doc-title +# description: doc-descr +# organizations: +# - doc-org +# related_resources: +# - https://doc +# - ref: https://doc +# description: rr-doc-note +# authors: +# - doc-author +# schemas: +# - input: {"type": "integer"} +# custom: +# doc: doc-custom + +# METADATA +# title: rule-title +# description: rule-title +# organizations: +# - rule-org +# related_resources: +# - https://rule +# - ref: https://rule +# description: rr-rule-note +# authors: +# - rule-author +# schemas: +# - input: {"type": "string"} +# custom: +# rule: rule-custom +p = 1`}, + {".manifest", ` +{ + "revision": "", + "roots": [ + "" + ], + "wasm": [ + { + "entrypoint": "test/a", + "module": "/policy.wasm" + }, + { + "entrypoint": "test/b", + "module": "/policy.wasm", + "annotations": [ + { + "scope": "rule", + "title": "WASM RULE B", + "entrypoint": true + } + ] + } + ] +}`}, + {"policy.wasm", ""}, + } + + buf := archive.MustWriteTarGz(files) + + t.Run("pretty", func(t *testing.T) { + bundleFile := filepath.Join(t.TempDir(), "bundle.tar.gz") + + bf, err := os.Create(bundleFile) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + _, err = bf.Write(buf.Bytes()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + ps := newInspectCommandParams() + ps.listAnnotations = true + var out bytes.Buffer + + err = doInspect(ps, bundleFile, &out) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + bs := out.Bytes() + idx := bytes.Index(bs, []byte(`ANNOTATIONS`)) // skip NAMESPACE box + output := strings.TrimSpace(string(bs[idx:])) + expected := strings.TrimSpace(` +ANNOTATIONS: +pkg-title +========= + +pkg-descr + +Package: test +Location: /x.rego:16 +Scope: package + +Organizations: + pkg-org + +Authors: + pkg-author + +Schemas: + input: {"type":"boolean"} + +Related Resources: + https://pkg + https://pkg rr-pkg-note + +Custom: + pkg: "pkg-custom" + +WASM RULE B +=========== + +Location: /policy.wasm:0 +Scope: rule +Entrypoint: true + +doc-title +========= + +doc-descr + +Package: test +Rule: p +Location: /x.rego:50 +Scope: document + +Organizations: + doc-org + +Authors: + doc-author + +Schemas: + input: {"type":"integer"} + +Related Resources: + https://doc + https://doc rr-doc-note + +Custom: + doc: "doc-custom" + +rule-title +========== + +rule-title + +Package: test +Rule: p +Location: /x.rego:50 +Scope: rule + +Organizations: + rule-org + +Authors: + rule-author + +Schemas: + input: {"type":"string"} + +Related Resources: + https://rule + https://rule rr-rule-note + +Custom: + rule: "rule-custom"`) + + if output != expected { + t.Fatalf("Unexpected output. Expected:\n\n%q\n\nGot:\n\n%q", expected, output) + } + + }) + + t.Run("json", func(t *testing.T) { + bundleFile := filepath.Join(t.TempDir(), "bundle.tar.gz") + + bf, err := os.Create(bundleFile) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + _, err = bf.Write(buf.Bytes()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + ps := newInspectCommandParams() + ps.listAnnotations = true + err = ps.outputFormat.Set(formats.JSON) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + var out bytes.Buffer + + err = doInspect(ps, bundleFile, &out) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + expected := strings.TrimSpace(fmt.Sprintf(`{ + "manifest": { + "revision": "", + "roots": [ + "" + ], + "wasm": [ + { + "entrypoint": "test/a", + "module": "/policy.wasm" + }, + { + "entrypoint": "test/b", + "module": "/policy.wasm", + "annotations": [ + { + "entrypoint": true, + "scope": "rule", + "title": "WASM RULE B" + } + ] + } + ] + }, + "signatures_config": {}, + "wasm_modules": [ + { + "entrypoints": [ + "data.test.a", + "data.test.b" + ], + "path": "/policy.wasm", + "url": "%[1]s/policy.wasm" + } + ], + "namespaces": { + "data.test": [ + "/x.rego" + ], + "data.test.a": [ + "/policy.wasm" + ], + "data.test.b": [ + "/policy.wasm" + ] + }, + "annotations": [ + { + "annotations": { + "authors": [ + { + "name": "pkg-author" + } + ], + "custom": { + "pkg": "pkg-custom" + }, + "description": "pkg-descr", + "organizations": [ + "pkg-org" + ], + "related_resources": [ + { + "ref": "https://pkg" + }, + { + "description": "rr-pkg-note", + "ref": "https://pkg" + } + ], + "schemas": [ + { + "path": [ + { + "type": "var", + "value": "input" + } + ], + "definition": { + "type": "boolean" + } + } + ], + "scope": "package", + "title": "pkg-title" + }, + "location": { + "file": "/x.rego", + "row": 16, + "col": 1 + }, + "path": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "test" + } + ] + }, + { + "annotations": { + "entrypoint": true, + "scope": "rule", + "title": "WASM RULE B" + }, + "location": { + "file": "/policy.wasm", + "row": 0, + "col": 0 + }, + "path": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "test" + }, + { + "type": "string", + "value": "b" + } + ] + }, + { + "annotations": { + "authors": [ + { + "name": "doc-author" + } + ], + "custom": { + "doc": "doc-custom" + }, + "description": "doc-descr", + "organizations": [ + "doc-org" + ], + "related_resources": [ + { + "ref": "https://doc" + }, + { + "description": "rr-doc-note", + "ref": "https://doc" + } + ], + "schemas": [ + { + "path": [ + { + "type": "var", + "value": "input" + } + ], + "definition": { + "type": "integer" + } + } + ], + "scope": "document", + "title": "doc-title" + }, + "location": { + "file": "/x.rego", + "row": 50, + "col": 1 + }, + "path": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "test" + }, + { + "type": "string", + "value": "p" + } + ] + }, + { + "annotations": { + "authors": [ + { + "name": "rule-author" + } + ], + "custom": { + "rule": "rule-custom" + }, + "description": "rule-title", + "organizations": [ + "rule-org" + ], + "related_resources": [ + { + "ref": "https://rule" + }, + { + "description": "rr-rule-note", + "ref": "https://rule" + } + ], + "schemas": [ + { + "path": [ + { + "type": "var", + "value": "input" + } + ], + "definition": { + "type": "string" + } + } + ], + "scope": "rule", + "title": "rule-title" + }, + "location": { + "file": "/x.rego", + "row": 50, + "col": 1 + }, + "path": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "test" + }, + { + "type": "string", + "value": "p" + } + ] + } + ], + "capabilities": { + "features": [ + "rego_v1" + ] + } +}`, bundleFile)) + exp := util.MustUnmarshalJSON([]byte(expected)) + + result := util.MustUnmarshalJSON(out.Bytes()) + if !reflect.DeepEqual(exp, result) { + t.Fatalf("expected inspect output to be:\n\n%v\n\ngot:\n\n%v", exp, result) + } + }) +} + +func TestDoInspect_V0Compatible(t *testing.T) { + tests := []struct { + note string + v0Compatible bool + module string + expErrs []string + }{ + { + note: "v0, keywords not used", + v0Compatible: true, + module: `package test +p[v] { + v := input.x +}`, + }, + { + note: "v0, no keywords imported, but used", + v0Compatible: true, + module: `package test +p contains v if { + v := input.x +}`, + expErrs: []string{ + "rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v0, keywords imported", + module: `package test +import future.keywords +p contains v if { + v := input.x +}`, + }, + { + note: "v0, rego.v1 imported", + module: `package test +import rego.v1 +p contains v if { + v := input.x +}`, + }, + { + note: "v1, keywords not used", + module: `package test +p[v] { + v := input.x +}`, + expErrs: []string{ + "rego_parse_error: `if` keyword is required before rule body", + "rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v1, no keywords imported", + module: `package test +p contains v if { + v := input.x +}`, + }, + { + note: "v1, keywords imported", + module: `package test +import future.keywords +p contains v if { + v := input.x +}`, + }, + { + note: "v1, rego.v1 imported", + module: `package test +import rego.v1 +p contains v if { + v := input.x +}`, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + buf := archive.MustWriteTarGz([][2]string{{"/policy.rego", tc.module}}) + bundleFile := filepath.Join(t.TempDir(), "bundle.tar.gz") + + bf, err := os.Create(bundleFile) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + _, err = bf.Write(buf.Bytes()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var out bytes.Buffer + params := newInspectCommandParams() + params.v0Compatible = tc.v0Compatible + err = params.outputFormat.Set(formats.JSON) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + err = doInspect(params, bundleFile, &out) + if len(tc.expErrs) > 0 { + if err == nil { + t.Fatalf("Expected error but got nil") + } + + for _, expErr := range tc.expErrs { + if !strings.Contains(err.Error(), expErr) { + t.Fatalf("Expected error:\n\n%v\n\nbut got:\n\n%v", expErr, err.Error()) + } + } + } else if err != nil { + t.Fatalf("Unexpected error %v", err) + } + }) + } +} + +func TestDoInspectWithBundleRegoVersion(t *testing.T) { + tests := []struct { + note string + bundleRegoVersion int + files map[string]string + expErrs []string + }{ + { + note: "v0.x bundle, keywords not used", + bundleRegoVersion: 0, + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +p[v] { + v := input.x +}`, + }, + }, + { + note: "v0.x bundle, no keywords imported, but used", + bundleRegoVersion: 0, + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +p contains v if { + v := input.x +}`, + }, + expErrs: []string{ + "rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v0.x bundle, keywords imported", + bundleRegoVersion: 0, + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +import future.keywords +p contains v if { + v := input.x +}`, + }, + }, + { + note: "v0.x bundle, rego.v1 imported", + bundleRegoVersion: 0, + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +import rego.v1 +p contains v if { + v := input.x +}`, + }, + }, + { + note: "v0 bundle, v1 per-file override", + bundleRegoVersion: 0, + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "/policy2.rego": 1 + } +}`, + "policy1.rego": `package test +p[1] { + v := input.x +}`, + "policy2.rego": `package test +p contains 2 if { + v := input.x +}`, + }, + }, + { + note: "v0 bundle, v1 per-file override (glob)", + bundleRegoVersion: 0, + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "/bar/*.rego": 1 + } +}`, + "foo/policy1.rego": `package test +p[1] { + v := input.x +}`, + "bar/policy2.rego": `package test +p contains 2 if { + v := input.x +}`, + }, + }, + { + note: "v0 bundle, v1 per-file override, incompatible", + bundleRegoVersion: 0, + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "/policy2.rego": 1 + } +}`, + "policy1.rego": `package test +p[1] { + v := input.x +}`, + "policy2.rego": `package test +p[2] { + v := input.x +}`, + }, + expErrs: []string{ + "rego_parse_error: `if` keyword is required before rule body", + "rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v1.0 bundle, keywords not used", + bundleRegoVersion: 1, + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +p[v] { + v := input.x +}`, + }, + expErrs: []string{ + "rego_parse_error: `if` keyword is required before rule body", + "rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v1.0 bundle, no keywords imported", + bundleRegoVersion: 1, + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +p contains v if { + v := input.x +}`, + }, + }, + { + note: "v1.0 bundle, keywords imported", + bundleRegoVersion: 1, + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +import future.keywords +p contains v if { + v := input.x +}`, + }, + }, + { + note: "v1.0 bundle, rego.v1 imported", + bundleRegoVersion: 1, + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +import rego.v1 +p contains v if { + v := input.x +}`, + }, + }, + { + note: "v1 bundle, v0 per-file override", + bundleRegoVersion: 1, + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/policy1.rego": 0 + } +}`, + "policy1.rego": `package test +p[1] { + v := input.x +}`, + "policy2.rego": `package test +p contains 2 if { + v := input.x +}`, + }, + }, + { + note: "v1 bundle, v0 per-file override", + bundleRegoVersion: 1, + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/foo/*.rego": 0 + } +}`, + "foo/policy1.rego": `package test +p[1] { + v := input.x +}`, + "bar/policy2.rego": `package test +p contains 2 if { + v := input.x +}`, + }, + }, + { + note: "v1 bundle, v0 per-file override, incompatible", + bundleRegoVersion: 1, + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/policy1.rego": 0 + } +}`, + "policy1.rego": `package test +p contains 1 if { + v := input.x +}`, + "policy2.rego": `package test +p contains 2 if { + v := input.x +}`, + }, + expErrs: []string{ + "rego_parse_error: var cannot be used for rule name", + "rego_parse_error: number cannot be used for rule name", + }, + }, + } + + bundleTypeCases := []struct { + note string + tar bool + }{ + { + "bundle dir", false, + }, + { + "bundle tar", true, + }, + } + + v1CompatibleFlagCases := []struct { + note string + used bool + }{ + { + "no --v1-compatible", false, + }, + { + "--v1-compatible", true, + }, + } + + for _, bundleType := range bundleTypeCases { + for _, v1CompatibleFlag := range v1CompatibleFlagCases { + for _, tc := range tests { + t.Run(fmt.Sprintf("%s, %s, %s", bundleType.note, v1CompatibleFlag.note, tc.note), func(t *testing.T) { + files := map[string]string{} + if bundleType.tar { + files["bundle.tar.gz"] = "" + } else { + maps.Copy(files, tc.files) + } + + test.WithTempFS(files, func(root string) { + p := root + if bundleType.tar { + p = filepath.Join(root, "bundle.tar.gz") + files := make([][2]string, 0, len(tc.files)) + for k, v := range tc.files { + files = append(files, [2]string{k, v}) + } + buf := archive.MustWriteTarGz(files) + bf, err := os.Create(p) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + _, err = bf.Write(buf.Bytes()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + } + + var out bytes.Buffer + params := newInspectCommandParams() + params.v1Compatible = v1CompatibleFlag.used + err := params.outputFormat.Set(formats.Pretty) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + err = doInspect(params, p, &out) + + if len(tc.expErrs) > 0 { + if err == nil { + t.Fatalf("Expected error but got output: %s", out.String()) + } + + for _, expErr := range tc.expErrs { + if !strings.Contains(err.Error(), expErr) { + t.Fatalf("Expected error:\n\n%v\n\nbut got:\n\n%v", expErr, err.Error()) + } + } + } else { + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + expOut := fmt.Sprintf(`MANIFEST: +┌──────────────┬───────┐ +│ FIELD │ VALUE │ +├──────────────┼───────┤ +│ Rego Version │ %d │ +└──────────────┴───────┘`, + tc.bundleRegoVersion) + if !strings.Contains(out.String(), expOut) { + t.Fatalf("Expected output to contain:\n\n%s\n\nbut got:\n\n%s", expOut, out.String()) + } + } + }) + }) + } + } + } +} + +func TestUnknownRefs(t *testing.T) { + tests := []struct { + note string + files [][2]string + expected string + }{ + { + note: "unknown built-in func call", + files: [][2]string{ + { + "/policy.rego", `package test +p if { + foo.bar(42) + contains("foo", "o") +}`, + }, + }, + // Note: unknown foo.bar() built-in doesn't appear in the output, but also didn't cause an error. + expected: `{ + "manifest": { + "revision": "", + "roots": [ + "" + ] + }, + "signatures_config": {}, + "namespaces": { + "data.test": [ + "/policy.rego" + ] + }, + "capabilities": { + "builtins": [ + { + "name": "contains", + "decl": { + "args": [ + { + "type": "string" + }, + { + "type": "string" + } + ], + "result": { + "type": "boolean" + }, + "type": "function" + } + } + ], + "features": [ + "rego_v1" + ] + } +}`, + }, + { + // Happy path + note: "known ref replaced inside 'with' stmt", + files: [][2]string{ + {"/policy.rego", `package test + +foo.bar(_) := false + +p if { + foo.bar(42) +} + +mock(_) := true + +test_p if { + p with data.test.foo.bar as mock +}`}, + }, + expected: `{ + "manifest": { + "revision": "", + "roots": [ + "" + ] + }, + "signatures_config": {}, + "namespaces": { + "data.test": [ + "/policy.rego" + ] + }, + "capabilities": { + "features": [ + "rego_v1" + ] + } +}`, + }, + { + note: "unknown ref replaced inside 'with' stmt", + files: [][2]string{ + {"/policy.rego", `package test + +p if { + data.foo.bar(42) +} + +mock(_) := true + +test_p if { + p with data.foo.bar as mock +}`}, + }, + expected: `{ + "manifest": { + "revision": "", + "roots": [ + "" + ] + }, + "signatures_config": {}, + "namespaces": { + "data.test": [ + "/policy.rego" + ] + }, + "capabilities": { + "features": [ + "rego_v1" + ] + } +}`, + }, + { + note: "unknown built-in (var) replaced inside 'with' stmt", + files: [][2]string{ + {"/policy.rego", `package test + +p if { + foo(42) +} + +mock(_) := true + +test_p if { + p with foo as mock +}`}, + }, + expected: `{ + "manifest": { + "revision": "", + "roots": [ + "" + ] + }, + "signatures_config": {}, + "namespaces": { + "data.test": [ + "/policy.rego" + ] + }, + "capabilities": { + "features": [ + "rego_v1" + ] + } +}`, + }, + { + note: "unknown built-in (ref) replaced inside 'with' stmt", + files: [][2]string{ + {"/policy.rego", `package test + +p if { + foo.bar(42) +} + +mock(_) := true + +test_p if { + p with foo.bar as mock +}`}, + }, + expected: `{ + "manifest": { + "revision": "", + "roots": [ + "" + ] + }, + "signatures_config": {}, + "namespaces": { + "data.test": [ + "/policy.rego" + ] + }, + "capabilities": { + "features": [ + "rego_v1" + ] + } +}`, + }, + { + note: "call replaced by unknown data ref inside 'with' stmt", + files: [][2]string{ + {"/policy.rego", `package test + +p if { + foo(42) +} + +foo(_) := false + +test_p if { + p with foo as data.bar +}`}, + }, + expected: `{ + "manifest": { + "revision": "", + "roots": [ + "" + ] + }, + "signatures_config": {}, + "namespaces": { + "data.test": [ + "/policy.rego" + ] + }, + "capabilities": { + "builtins": [ + { + "name": "eq", + "decl": { + "args": [ + { + "type": "any" + }, + { + "type": "any" + } + ], + "result": { + "type": "boolean" + }, + "type": "function" + }, + "infix": "=" + } + ], + "features": [ + "rego_v1" + ] + } +}`, + }, + { + note: "call replaced by unknown built-in (var) inside 'with' stmt", + files: [][2]string{ + {"/policy.rego", `package test + +p if { + foo(42) +} + +foo(_) := false + +test_p if { + # bar is unknown built-in + p with foo as bar +}`}, + }, + expected: `{ + "manifest": { + "revision": "", + "roots": [ + "" + ] + }, + "signatures_config": {}, + "namespaces": { + "data.test": [ + "/policy.rego" + ] + }, + "capabilities": { + "features": [ + "rego_v1" + ] + } +}`, + }, + { + note: "call replaced by unknown built-in (ref) inside 'with' stmt", + files: [][2]string{ + {"/policy.rego", `package test + +p if { + foo(42) +} + +foo(_) := false + +test_p if { + # bar.baz is unknown built-in + p with foo as bar.baz +}`}, + }, + expected: `{ + "manifest": { + "revision": "", + "roots": [ + "" + ] + }, + "signatures_config": {}, + "namespaces": { + "data.test": [ + "/policy.rego" + ] + }, + "capabilities": { + "builtins": [ + { + "name": "eq", + "decl": { + "args": [ + { + "type": "any" + }, + { + "type": "any" + } + ], + "result": { + "type": "boolean" + }, + "type": "function" + }, + "infix": "=" + } + ], + "features": [ + "rego_v1" + ] + } +}`, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + buf := archive.MustWriteTarGz(tc.files) + bundleFile := filepath.Join(t.TempDir(), "bundle.tar.gz") + + bf, err := os.Create(bundleFile) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + _, err = bf.Write(buf.Bytes()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var out bytes.Buffer + params := newInspectCommandParams() + err = params.outputFormat.Set(formats.JSON) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + err = doInspect(params, bundleFile, &out) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + bs := out.Bytes() + output := strings.TrimSpace(string(bs)) + if output != tc.expected { + t.Fatalf("Unexpected output. Expected:\n\n%s\n\nGot:\n\n%s", tc.expected, output) + } + }) + } +} + +func TestCallToUnknownRegoFunction(t *testing.T) { + files := [][2]string{ + {"/policy.rego", `package test +import data.x.y + +p if { + y(1) == true +} + `}, + } + + buf := archive.MustWriteTarGz(files) + bundleFile := filepath.Join(t.TempDir(), "bundle.tar.gz") + + bf, err := os.Create(bundleFile) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + _, err = bf.Write(buf.Bytes()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var out bytes.Buffer + params := newInspectCommandParams() + err = params.outputFormat.Set(formats.JSON) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + err = doInspect(params, bundleFile, &out) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + bs := out.Bytes() + output := strings.TrimSpace(string(bs)) + // Note: unknown data.x.y() function doesn't appear in the output, but also didn't cause an error. + expected := strings.TrimSpace(`{ + "manifest": { + "revision": "", + "roots": [ + "" + ] + }, + "signatures_config": {}, + "namespaces": { + "data.test": [ + "/policy.rego" + ] + }, + "capabilities": { + "builtins": [ + { + "name": "eq", + "decl": { + "args": [ + { + "type": "any" + }, + { + "type": "any" + } + ], + "result": { + "type": "boolean" + }, + "type": "function" + }, + "infix": "=" + }, + { + "name": "equal", + "decl": { + "args": [ + { + "type": "any" + }, + { + "type": "any" + } + ], + "result": { + "type": "boolean" + }, + "type": "function" + }, + "infix": "==" + } + ], + "features": [ + "rego_v1" + ] + } +}`) + + if output != expected { + t.Fatalf("Unexpected output. Expected:\n\n%s\n\nGot:\n\n%s", expected, output) + } +} + +func TestDoInspectSingleFileWithAnnotations(t *testing.T) { + files := map[string]string{ + "/a/xxxxxxxxxxxxxxxxxxxxxx/yyyyyyyyyyyyyyyyyyyy/foo.rego": `# METADATA +# title: pkg-title +# description: pkg-descr +# organizations: +# - pkg-org +# related_resources: +# - https://pkg +# - ref: https://pkg +# description: rr-pkg-note +# authors: +# - pkg-author +# schemas: +# - input: {"type": "boolean"} +# custom: +# pkg: pkg-custom +package test + +# METADATA +# scope: document +# title: doc-title +# description: doc-descr +# organizations: +# - doc-org +# related_resources: +# - https://doc +# - ref: https://doc +# description: rr-doc-note +# authors: +# - doc-author +# schemas: +# - input: {"type": "integer"} +# custom: +# doc: doc-custom + +# METADATA +# title: rule-title +# description: rule-title +# organizations: +# - rule-org +# related_resources: +# - https://rule +# - ref: https://rule +# description: rr-rule-note +# authors: +# - rule-author +# schemas: +# - input: {"type": "string"} +# custom: +# rule: rule-custom +p = 1`, + } + + test.WithTempFS(files, func(rootDir string) { + fileName := rootDir + "/a/xxxxxxxxxxxxxxxxxxxxxx/yyyyyyyyyyyyyyyyyyyy/foo.rego" + ps := newInspectCommandParams() + ps.listAnnotations = true + var out bytes.Buffer + err := doInspect(ps, fileName, &out) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + shortFileName := truncateFileName(fileName) + output := strings.TrimSpace(out.String()) + expected := strings.TrimSpace(fmt.Sprintf(` +NAMESPACES: +┌───────────┬────────────────────────────────────────────────────┐ +│ NAMESPACE │ FILE │ +├───────────┼────────────────────────────────────────────────────┤ +│ data.test │ %[1]s │ +└───────────┴────────────────────────────────────────────────────┘ +ANNOTATIONS: +pkg-title +========= + +pkg-descr + +Package: test +Location: %[2]s:16 +Scope: package + +Organizations: + pkg-org + +Authors: + pkg-author + +Schemas: + input: {"type":"boolean"} + +Related Resources: + https://pkg + https://pkg rr-pkg-note + +Custom: + pkg: "pkg-custom" + +doc-title +========= + +doc-descr + +Package: test +Rule: p +Location: %[2]s:50 +Scope: document + +Organizations: + doc-org + +Authors: + doc-author + +Schemas: + input: {"type":"integer"} + +Related Resources: + https://doc + https://doc rr-doc-note + +Custom: + doc: "doc-custom" + +rule-title +========== + +rule-title + +Package: test +Rule: p +Location: %[2]s:50 +Scope: rule + +Organizations: + rule-org + +Authors: + rule-author + +Schemas: + input: {"type":"string"} + +Related Resources: + https://rule + https://rule rr-rule-note + +Custom: + rule: "rule-custom"`, shortFileName, fileName)) + + if output != expected { + t.Fatalf("Unexpected output. Expected:\n\n%q\n\nGot:\n\n%q", expected, output) + } + }) +} + +func TestDoInspectSingleFile(t *testing.T) { + files := map[string]string{ + "/a/xxxxxxxxxxxxxxxxxxxxxx/yyyyyyyyyyyyyyyyyyyy/foo.rego": `# METADATA +# title: pkg-title +# description: pkg-descr +# organizations: +# - pkg-org +# related_resources: +# - https://pkg +# - ref: https://pkg +# description: rr-pkg-note +# authors: +# - pkg-author +# schemas: +# - input: {"type": "boolean"} +# custom: +# pkg: pkg-custom +package test + +# METADATA +# scope: document +# title: doc-title +# description: doc-descr +# organizations: +# - doc-org +# related_resources: +# - https://doc +# - ref: https://doc +# description: rr-doc-note +# authors: +# - doc-author +# schemas: +# - input: {"type": "integer"} +# custom: +# doc: doc-custom + +# METADATA +# title: rule-title +# description: rule-title +# organizations: +# - rule-org +# related_resources: +# - https://rule +# - ref: https://rule +# description: rr-rule-note +# authors: +# - rule-author +# schemas: +# - input: {"type": "string"} +# custom: +# rule: rule-custom +p = 1`, + } + + test.WithTempFS(files, func(rootDir string) { + fileName := rootDir + "/a/xxxxxxxxxxxxxxxxxxxxxx/yyyyyyyyyyyyyyyyyyyy/foo.rego" + ps := newInspectCommandParams() + var out bytes.Buffer + err := doInspect(ps, fileName, &out) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + shortFileName := truncateFileName(fileName) + output := strings.TrimSpace(out.String()) + expected := strings.TrimSpace(fmt.Sprintf(` +NAMESPACES: +┌───────────┬────────────────────────────────────────────────────┐ +│ NAMESPACE │ FILE │ +├───────────┼────────────────────────────────────────────────────┤ +│ data.test │ %v │ +└───────────┴────────────────────────────────────────────────────┘ +`, shortFileName)) + + if output != expected { + t.Fatalf("Unexpected output. Expected:\n%v\nGot:\n%v", expected, output) + } + }) +} diff --git a/cmd/inspect_test.go b/cmd/inspect_test.go index 35d355bb8f..df46960ee8 100644 --- a/cmd/inspect_test.go +++ b/cmd/inspect_test.go @@ -1,3 +1,5 @@ +//go:build !go1.27 + // Copyright 2021 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. @@ -14,6 +16,7 @@ import ( "strings" "testing" + "github.com/google/go-cmp/cmp" "github.com/open-policy-agent/opa/cmd/formats" "github.com/open-policy-agent/opa/internal/file/archive" "github.com/open-policy-agent/opa/v1/util" @@ -21,6 +24,62 @@ import ( "github.com/open-policy-agent/opa/v1/util/test" ) +func TestDoInspectJSONOutputBytes(t *testing.T) { + files := [][2]string{ + {"/.manifest", `{"revision": "rev", "roots": ["foo", "bar", "fuz", "baz", "a", "x"]}`}, + {"/data.json", `{"x": {"y": true}, "a": {"b": {"z": true}}}`}, + {"/example/foo.rego", `package foo`}, + } + + buf := archive.MustWriteTarGz(files) + bundleFile := filepath.Join(t.TempDir(), "bundle.tar.gz") + if err := os.WriteFile(bundleFile, buf.Bytes(), 0o644); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var out bytes.Buffer + params := newInspectCommandParams() + if err := params.outputFormat.Set(formats.JSON); err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if err := doInspect(params, bundleFile, &out); err != nil { + t.Fatalf("Unexpected error %v", err) + } + + expected := `{ + "manifest": { + "revision": "rev", + "roots": [ + "foo", + "bar", + "fuz", + "baz", + "a", + "x" + ] + }, + "signatures_config": {}, + "namespaces": { + "data": [ + "/data.json" + ], + "data.foo": [ + "/example/foo.rego" + ] + }, + "capabilities": { + "features": [ + "rego_v1" + ] + } +} +` + if diff := cmp.Diff(expected, out.String()); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } +} + func TestDoInspect(t *testing.T) { files := [][2]string{ {"/.manifest", `{"revision": "rev", "roots": ["foo", "bar", "fuz", "baz", "a", "x"]}`}, diff --git a/cmd/oracle_jsonv2_test.go b/cmd/oracle_jsonv2_test.go new file mode 100644 index 0000000000..0a757492e6 --- /dev/null +++ b/cmd/oracle_jsonv2_test.go @@ -0,0 +1,244 @@ +//go:build go1.27 + +package cmd + +import ( + "bytes" + "errors" + "fmt" + "path" + "reflect" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/open-policy-agent/opa/v1/util" + "github.com/open-policy-agent/opa/v1/util/test" +) + +func TestOracleFindDefinition(t *testing.T) { + cases := []struct { + note string + v0Compatible bool + onDiskModule string + stdin string + paths []string + }{ + { + note: "v0", + v0Compatible: true, + onDiskModule: `package test + +p { r } + +r = true`, + stdin: `package test + +p { q } + +q = true`, + paths: []string{ + "test.rego:10", + "test.rego:15", + "test.rego:18", + }, + }, + { + note: "v1", + onDiskModule: `package test + +p if { r } + +r = true`, + stdin: `package test + +p if { q } + +q = true`, + paths: []string{ + "test.rego:10", + "test.rego:15", + "test.rego:21", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.note, func(t *testing.T) { + stdin := bytes.NewBufferString(tc.stdin) + + files := map[string]string{ + "test.rego": tc.onDiskModule, + "document.txt": "this should not be included", + "ignore.json": `{"neither": "should this"}`, + } + + test.WithTempFS(files, func(rootDir string) { + + params := findDefinitionParams{ + bundlePaths: repeatedStringFlag{ + v: []string{rootDir}, + isSet: true, + }, + stdinBuffer: true, + v0Compatible: tc.v0Compatible, + } + + stdout := bytes.NewBuffer(nil) + + err := dofindDefinition(params, stdin, stdout, []string{path.Join(rootDir, tc.paths[0])}) + expectJSON(t, err, stdout, `{"error": {"code": "oracle_no_match_found"}}`) + + err = dofindDefinition(params, stdin, stdout, []string{path.Join(rootDir, tc.paths[1])}) + expectJSON(t, err, stdout, `{"error": {"code": "oracle_no_definition_found"}}`) + + err = dofindDefinition(params, stdin, stdout, []string{path.Join(rootDir, tc.paths[2])}) + expectJSON(t, err, stdout, fmt.Sprintf(`{"result": { + "file": %q, + "row": 5, + "col": 1 + }}`, path.Join(rootDir, "test.rego"))) + }) + }) + } + +} + +func TestOracleFindDefinitionJSONOutputBytes(t *testing.T) { + onDiskModule := `package test + +p if { r } + +r = true` + stdin := bytes.NewBufferString(`package test + +p if { q } + +q = true`) + + files := map[string]string{ + "test.rego": onDiskModule, + "document.txt": "this should not be included", + "ignore.json": `{"neither": "should this"}`, + } + + test.WithTempFS(files, func(rootDir string) { + params := findDefinitionParams{ + bundlePaths: repeatedStringFlag{ + v: []string{rootDir}, + isSet: true, + }, + stdinBuffer: true, + } + + stdout := bytes.NewBuffer(nil) + + err := dofindDefinition(params, stdin, stdout, []string{path.Join(rootDir, "test.rego:10")}) + if err != nil { + t.Fatal(err) + } + + exp := `{ + "error": { + "code": "oracle_no_match_found" + } +} +` + if diff := cmp.Diff(exp, stdout.String()); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } + }) +} + +func expectJSON(t *testing.T, err error, buffer *bytes.Buffer, exp string) { + t.Helper() + if err != nil { + t.Fatal(err) + } + var x any + if err := util.UnmarshalJSON(buffer.Bytes(), &x); err != nil { + t.Fatal(err) + } + var y any + if err := util.UnmarshalJSON([]byte(exp), &y); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(x, y) { + t.Fatalf("expected %v but got %v", y, x) + } + buffer.Reset() +} + +func TestOracleParseFilenameOffset(t *testing.T) { + + tests := []struct { + input string + wantFile string + wantPos int + }{ + { + input: "x.rego:10", + wantFile: "x.rego", + wantPos: 10, + }, + { + input: "/x.rego:10", + wantFile: "/x.rego", + wantPos: 10, + }, + { + input: "x.rego:0x10", + wantFile: "x.rego", + wantPos: 16, + }, + { + input: "file://x.rego:10", + wantFile: "x.rego", + wantPos: 10, + }, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + filename, pos, err := parseFilenameOffset(tc.input) + if err != nil { + t.Fatal(err) + } + if tc.wantFile != filename || tc.wantPos != pos { + t.Fatalf("expected %v:%v but got %v:%v", tc.wantFile, tc.wantPos, filename, pos) + } + }) + } + +} + +func TestOracleParseFilenameOffsetError(t *testing.T) { + + tests := []struct { + input string + wantErr error + }{ + { + input: "x.rego", + wantErr: errors.New("expected : argument"), + }, + { + input: "x.rego:", + wantErr: errors.New("invalid syntax"), + }, + { + input: "x.rego:3.14", + wantErr: errors.New("invalid syntax"), + }, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + _, _, err := parseFilenameOffset(tc.input) + if err == nil || !strings.Contains(err.Error(), tc.wantErr.Error()) { + t.Fatalf("expected %v but got %v", tc.wantErr, err) + } + }) + } + +} diff --git a/cmd/oracle_test.go b/cmd/oracle_test.go index 4f90a2a61b..f3e12ef651 100644 --- a/cmd/oracle_test.go +++ b/cmd/oracle_test.go @@ -1,3 +1,5 @@ +//go:build !go1.27 + package cmd import ( @@ -9,6 +11,7 @@ import ( "strings" "testing" + "github.com/google/go-cmp/cmp" "github.com/open-policy-agent/opa/v1/util" "github.com/open-policy-agent/opa/v1/util/test" ) @@ -101,6 +104,52 @@ q = true`, } +func TestOracleFindDefinitionJSONOutputBytes(t *testing.T) { + onDiskModule := `package test + +p if { r } + +r = true` + stdin := bytes.NewBufferString(`package test + +p if { q } + +q = true`) + + files := map[string]string{ + "test.rego": onDiskModule, + "document.txt": "this should not be included", + "ignore.json": `{"neither": "should this"}`, + } + + test.WithTempFS(files, func(rootDir string) { + params := findDefinitionParams{ + bundlePaths: repeatedStringFlag{ + v: []string{rootDir}, + isSet: true, + }, + stdinBuffer: true, + } + + stdout := bytes.NewBuffer(nil) + + err := dofindDefinition(params, stdin, stdout, []string{path.Join(rootDir, "test.rego:10")}) + if err != nil { + t.Fatal(err) + } + + exp := `{ + "error": { + "code": "oracle_no_match_found" + } +} +` + if diff := cmp.Diff(exp, stdout.String()); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } + }) +} + func expectJSON(t *testing.T, err error, buffer *bytes.Buffer, exp string) { t.Helper() if err != nil { diff --git a/cmd/parse_jsonv2_test.go b/cmd/parse_jsonv2_test.go new file mode 100644 index 0000000000..ef71481a0d --- /dev/null +++ b/cmd/parse_jsonv2_test.go @@ -0,0 +1,1531 @@ +//go:build go1.27 + +package cmd + +import ( + "bytes" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/open-policy-agent/opa/cmd/formats" + "github.com/open-policy-agent/opa/v1/util/test" +) + +func TestParseExit0(t *testing.T) { + files := map[string]string{ + "x.rego": `package x + + p = 1 + `, + } + errc, stdout, stderr, _ := testParse(t, files, &configuredParseParams) + if errc != 0 { + t.Fatalf("Expected exit code 0, got %v", errc) + } + if len(stderr) > 0 { + t.Fatalf("Expected no stderr output, got:\n%s\n", string(stderr)) + } + + expectedOutput := `module + package + ref + data + "x" + rule + head + ref + p + 1 + body + expr index=0 + true +` + + if got, want := string(stdout), expectedOutput; got != want { + t.Fatalf("Expected output\n%v\n, got\n%v", want, got) + } +} + +func TestParseExit1(t *testing.T) { + + files := map[string]string{ + "x.rego": `???`, + } + errc, _, stderr, _ := testParse(t, files, &configuredParseParams) + if errc != 1 { + t.Fatalf("Expected exit code 1, got %v", errc) + } + if len(stderr) == 0 { + t.Fatalf("Expected output in stderr") + } +} + +func TestParseJSONOutput(t *testing.T) { + files := map[string]string{ + "x.rego": `package x + + p = 1 + `, + } + errc, stdout, stderr, _ := testParse(t, files, &parseParams{ + format: formats.Flag(formats.JSON, formats.Pretty), + }) + if errc != 0 { + t.Fatalf("Expected exit code 0, got %v", errc) + } + if len(stderr) > 0 { + t.Fatalf("Expected no stderr output, got:\n%s\n", string(stderr)) + } + + expectedOutput := `{ + "package": { + "path": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "x" + } + ] + }, + "rules": [ + { + "head": { + "name": "p", + "ref": [ + { + "type": "var", + "value": "p" + } + ], + "value": { + "type": "number", + "value": 1 + } + }, + "body": [ + { + "index": 0, + "terms": { + "type": "boolean", + "value": true + } + } + ] + } + ] +} +` + + if diff := cmp.Diff(expectedOutput, string(stdout)); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } +} + +func TestParseJSONOutputWithLocations(t *testing.T) { + files := map[string]string{ + "x.rego": `package x + +p = 1 +`, + } + errc, stdout, stderr, tempDirPath := testParse(t, files, &parseParams{ + format: formats.Flag(formats.JSON, formats.Pretty), + jsonInclude: "locations", + }) + if errc != 0 { + t.Fatalf("Expected exit code 0, got %v", errc) + } + if len(stderr) > 0 { + t.Fatalf("Expected no stderr output, got:\n%s\n", string(stderr)) + } + + expectedOutput := strings.ReplaceAll(`{ + "package": { + "location": { + "file": "TEMPDIR/x.rego", + "row": 1, + "col": 1, + "text": "cGFja2FnZQ==" + }, + "path": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 1, + "col": 9, + "text": "eA==" + }, + "type": "var", + "value": "data" + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 1, + "col": 9, + "text": "eA==" + }, + "type": "string", + "value": "x" + } + ] + }, + "rules": [ + { + "head": { + "name": "p", + "ref": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 1, + "text": "cA==" + }, + "type": "var", + "value": "p" + } + ], + "value": { + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 5, + "text": "MQ==" + }, + "type": "number", + "value": 1 + }, + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 1, + "text": "cCA9IDE=" + } + }, + "body": [ + { + "index": 0, + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 5, + "text": "MQ==" + }, + "terms": { + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 5, + "text": "MQ==" + }, + "type": "boolean", + "value": true + } + } + ], + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 1, + "text": "cCA9IDE=" + } + } + ] +} +`, "TEMPDIR", tempDirPath) + + if diff := cmp.Diff(expectedOutput, string(stdout)); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } +} + +func TestParseOutputWithNotImport(t *testing.T) { + cases := []struct { + format string + exp string + }{ + { + format: formats.JSON, + exp: `{ + "package": { + "path": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "test" + } + ] + }, + "imports": [ + { + "path": { + "type": "ref", + "value": [ + { + "type": "var", + "value": "future" + }, + { + "type": "string", + "value": "keywords" + }, + { + "type": "string", + "value": "not" + } + ] + } + } + ], + "rules": [ + { + "head": { + "name": "implicit_body", + "ref": [ + { + "type": "var", + "value": "implicit_body" + } + ], + "value": { + "type": "boolean", + "value": true + } + }, + "body": [ + { + "index": 0, + "terms": { + "type": "not", + "body": [ + { + "index": 0, + "terms": [ + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "equal" + } + ] + }, + { + "type": "call", + "value": [ + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "plus" + } + ] + }, + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "input" + }, + { + "type": "string", + "value": "x" + } + ] + }, + { + "type": "number", + "value": 2 + } + ] + }, + { + "type": "number", + "value": 42 + } + ] + } + ], + "explicit_body": false + } + } + ] + }, + { + "head": { + "name": "explicit_body", + "ref": [ + { + "type": "var", + "value": "explicit_body" + } + ], + "value": { + "type": "boolean", + "value": true + } + }, + "body": [ + { + "index": 0, + "terms": { + "type": "not", + "body": [ + { + "index": 0, + "terms": [ + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "assign" + } + ] + }, + { + "type": "var", + "value": "x" + }, + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "input" + }, + { + "type": "string", + "value": "x" + } + ] + } + ] + }, + { + "index": 1, + "terms": [ + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "assign" + } + ] + }, + { + "type": "var", + "value": "y" + }, + { + "type": "number", + "value": 2 + } + ] + }, + { + "index": 2, + "terms": [ + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "assign" + } + ] + }, + { + "type": "var", + "value": "z" + }, + { + "type": "call", + "value": [ + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "plus" + } + ] + }, + { + "type": "var", + "value": "x" + }, + { + "type": "var", + "value": "y" + } + ] + } + ] + }, + { + "index": 3, + "terms": [ + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "equal" + } + ] + }, + { + "type": "var", + "value": "z" + }, + { + "type": "number", + "value": 42 + } + ] + } + ], + "explicit_body": true + } + } + ] + } + ] +} +`, + }, + { + format: formats.Pretty, + exp: `module + package + ref + data + "test" + import + ref + future + "keywords" + "not" + + rule + head + ref + implicit_body + true + body + expr index=0 + not + body + expr index=0 + ref + equal + call + ref + plus + ref + input + "x" + 2 + 42 + rule + head + ref + explicit_body + true + body + expr index=0 + not + body + expr index=0 + ref + assign + x + ref + input + "x" + expr index=1 + ref + assign + y + 2 + expr index=2 + ref + assign + z + call + ref + plus + x + y + expr index=3 + ref + equal + z + 42 +`, + }, + } + + files := map[string]string{ + "x.rego": `package test + + import future.keywords.not + + implicit_body if { + not input.x + 2 == 42 + } + + explicit_body if { + not { + x := input.x + y := 2 + z := x + y + z == 42 + } + } + `, + } + + for _, tc := range cases { + t.Run(tc.format, func(t *testing.T) { + errc, stdout, stderr, _ := testParse(t, files, &parseParams{ + format: formats.Flag(tc.format), + }) + if errc != 0 { + t.Fatalf("Expected exit code 0, got %v", errc) + } + if len(stderr) > 0 { + t.Fatalf("Expected no stderr output, got:\n%s\n", string(stderr)) + } + + if diff := cmp.Diff(tc.exp, string(stdout)); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } + }) + } +} + +func TestParseRefsJSONOutput(t *testing.T) { + files := map[string]string{ + "x.rego": `package x + + a.b.c := true + `, + } + errc, stdout, stderr, _ := testParse(t, files, &parseParams{ + format: formats.Flag(formats.JSON, formats.Pretty), + }) + if errc != 0 { + t.Fatalf("Expected exit code 0, got %v", errc) + } + if len(stderr) > 0 { + t.Fatalf("Expected no stderr output, got:\n%s\n", string(stderr)) + } + + expectedOutput := `{ + "package": { + "path": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "x" + } + ] + }, + "rules": [ + { + "head": { + "ref": [ + { + "type": "var", + "value": "a" + }, + { + "type": "string", + "value": "b" + }, + { + "type": "string", + "value": "c" + } + ], + "value": { + "type": "boolean", + "value": true + }, + "assign": true + }, + "body": [ + { + "index": 0, + "terms": { + "type": "boolean", + "value": true + } + } + ] + } + ] +} +` + + if diff := cmp.Diff(expectedOutput, string(stdout)); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } +} + +func TestParseRefsJSONOutputWithLocations(t *testing.T) { + files := map[string]string{ + "x.rego": `package x + +a.b.c := true +`, + } + errc, stdout, stderr, tempDirPath := testParse(t, files, &parseParams{ + format: formats.Flag(formats.JSON, formats.Pretty), + jsonInclude: "locations", + }) + if errc != 0 { + t.Fatalf("Expected exit code 0, got %v", errc) + } + if len(stderr) > 0 { + t.Fatalf("Expected no stderr output, got:\n%s\n", string(stderr)) + } + + expectedOutput := strings.ReplaceAll(`{ + "package": { + "location": { + "file": "TEMPDIR/x.rego", + "row": 1, + "col": 1, + "text": "cGFja2FnZQ==" + }, + "path": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 1, + "col": 9, + "text": "eA==" + }, + "type": "var", + "value": "data" + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 1, + "col": 9, + "text": "eA==" + }, + "type": "string", + "value": "x" + } + ] + }, + "rules": [ + { + "head": { + "ref": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 1, + "text": "YQ==" + }, + "type": "var", + "value": "a" + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 3, + "text": "Yg==" + }, + "type": "string", + "value": "b" + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 5, + "text": "Yw==" + }, + "type": "string", + "value": "c" + } + ], + "value": { + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 10, + "text": "dHJ1ZQ==" + }, + "type": "boolean", + "value": true + }, + "assign": true, + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 1, + "text": "YS5iLmMgOj0gdHJ1ZQ==" + } + }, + "body": [ + { + "index": 0, + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 10, + "text": "dHJ1ZQ==" + }, + "terms": { + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 10, + "text": "dHJ1ZQ==" + }, + "type": "boolean", + "value": true + } + } + ], + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 1, + "text": "YS5iLmMgOj0gdHJ1ZQ==" + } + } + ] +} +`, "TEMPDIR", tempDirPath) + + if diff := cmp.Diff(expectedOutput, string(stdout)); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } +} +func TestParseRulesBlockJSONOutputWithLocations(t *testing.T) { + + files := map[string]string{ + "x.rego": `package x +import rego.v1 + +default allow = false +allow = true if { + input.method == "GET" + input.path = ["getUser", user] + input.user == user +} +`, + } + errc, stdout, stderr, tempDirPath := testParse(t, files, &parseParams{ + format: formats.Flag(formats.JSON, formats.Pretty), + jsonInclude: "locations", + }) + if errc != 0 { + t.Fatalf("Expected exit code 0, got %v", errc) + } + if len(stderr) > 0 { + t.Fatalf("Expected no stderr output, got:\n%s\n", string(stderr)) + } + + expectedOutput := strings.ReplaceAll(`{ + "package": { + "location": { + "file": "TEMPDIR/x.rego", + "row": 1, + "col": 1, + "text": "cGFja2FnZQ==" + }, + "path": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 1, + "col": 9, + "text": "eA==" + }, + "type": "var", + "value": "data" + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 1, + "col": 9, + "text": "eA==" + }, + "type": "string", + "value": "x" + } + ] + }, + "imports": [ + { + "path": { + "location": { + "file": "TEMPDIR/x.rego", + "row": 2, + "col": 8, + "text": "cmVnby52MQ==" + }, + "type": "ref", + "value": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 2, + "col": 8, + "text": "cmVnbw==" + }, + "type": "var", + "value": "rego" + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 2, + "col": 13, + "text": "djE=" + }, + "type": "string", + "value": "v1" + } + ] + }, + "location": { + "file": "TEMPDIR/x.rego", + "row": 2, + "col": 1, + "text": "aW1wb3J0" + } + } + ], + "rules": [ + { + "default": true, + "head": { + "name": "allow", + "ref": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 4, + "col": 9, + "text": "YWxsb3c=" + }, + "type": "var", + "value": "allow" + } + ], + "value": { + "location": { + "file": "TEMPDIR/x.rego", + "row": 4, + "col": 17, + "text": "ZmFsc2U=" + }, + "type": "boolean", + "value": false + }, + "location": { + "file": "TEMPDIR/x.rego", + "row": 4, + "col": 9, + "text": "YWxsb3cgPSBmYWxzZQ==" + } + }, + "body": [ + { + "index": 0, + "location": { + "file": "TEMPDIR/x.rego", + "row": 4, + "col": 1, + "text": "ZGVmYXVsdA==" + }, + "terms": { + "location": { + "file": "TEMPDIR/x.rego", + "row": 4, + "col": 1, + "text": "ZGVmYXVsdA==" + }, + "type": "boolean", + "value": true + } + } + ], + "location": { + "file": "TEMPDIR/x.rego", + "row": 4, + "col": 1, + "text": "ZGVmYXVsdA==" + } + }, + { + "head": { + "name": "allow", + "ref": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 5, + "col": 1, + "text": "YWxsb3c=" + }, + "type": "var", + "value": "allow" + } + ], + "value": { + "location": { + "file": "TEMPDIR/x.rego", + "row": 5, + "col": 9, + "text": "dHJ1ZQ==" + }, + "type": "boolean", + "value": true + }, + "location": { + "file": "TEMPDIR/x.rego", + "row": 5, + "col": 1, + "text": "YWxsb3cgPSB0cnVl" + } + }, + "body": [ + { + "index": 0, + "location": { + "file": "TEMPDIR/x.rego", + "row": 6, + "col": 3, + "text": "aW5wdXQubWV0aG9kID09ICJHRVQi" + }, + "terms": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 6, + "col": 16, + "text": "PT0=" + }, + "type": "ref", + "value": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 6, + "col": 16, + "text": "PT0=" + }, + "type": "var", + "value": "equal" + } + ] + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 6, + "col": 3, + "text": "aW5wdXQubWV0aG9k" + }, + "type": "ref", + "value": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 6, + "col": 3, + "text": "aW5wdXQ=" + }, + "type": "var", + "value": "input" + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 6, + "col": 9, + "text": "bWV0aG9k" + }, + "type": "string", + "value": "method" + } + ] + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 6, + "col": 19, + "text": "IkdFVCI=" + }, + "type": "string", + "value": "GET" + } + ] + }, + { + "index": 1, + "location": { + "file": "TEMPDIR/x.rego", + "row": 7, + "col": 3, + "text": "aW5wdXQucGF0aCA9IFsiZ2V0VXNlciIsIHVzZXJd" + }, + "terms": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 7, + "col": 14, + "text": "PQ==" + }, + "type": "ref", + "value": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 7, + "col": 14, + "text": "PQ==" + }, + "type": "var", + "value": "eq" + } + ] + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 7, + "col": 3, + "text": "aW5wdXQucGF0aA==" + }, + "type": "ref", + "value": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 7, + "col": 3, + "text": "aW5wdXQ=" + }, + "type": "var", + "value": "input" + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 7, + "col": 9, + "text": "cGF0aA==" + }, + "type": "string", + "value": "path" + } + ] + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 7, + "col": 16, + "text": "WyJnZXRVc2VyIiwgdXNlcl0=" + }, + "type": "array", + "value": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 7, + "col": 17, + "text": "ImdldFVzZXIi" + }, + "type": "string", + "value": "getUser" + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 7, + "col": 28, + "text": "dXNlcg==" + }, + "type": "var", + "value": "user" + } + ] + } + ] + }, + { + "index": 2, + "location": { + "file": "TEMPDIR/x.rego", + "row": 8, + "col": 3, + "text": "aW5wdXQudXNlciA9PSB1c2Vy" + }, + "terms": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 8, + "col": 14, + "text": "PT0=" + }, + "type": "ref", + "value": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 8, + "col": 14, + "text": "PT0=" + }, + "type": "var", + "value": "equal" + } + ] + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 8, + "col": 3, + "text": "aW5wdXQudXNlcg==" + }, + "type": "ref", + "value": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 8, + "col": 3, + "text": "aW5wdXQ=" + }, + "type": "var", + "value": "input" + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 8, + "col": 9, + "text": "dXNlcg==" + }, + "type": "string", + "value": "user" + } + ] + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 8, + "col": 17, + "text": "dXNlcg==" + }, + "type": "var", + "value": "user" + } + ] + } + ], + "location": { + "file": "TEMPDIR/x.rego", + "row": 5, + "col": 1, + "text": "YWxsb3cgPSB0cnVlIGlmIHsKICBpbnB1dC5tZXRob2QgPT0gIkdFVCIKICBpbnB1dC5wYXRoID0gWyJnZXRVc2VyIiwgdXNlcl0KICBpbnB1dC51c2VyID09IHVzZXIKfQ==" + } + } + ] +} +`, "TEMPDIR", tempDirPath) + + if diff := cmp.Diff(expectedOutput, string(stdout)); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } +} + +func TestParseJSONOutputComments(t *testing.T) { + files := map[string]string{ + "x.rego": `package x + + # comment + p = 1 + `, + } + errc, stdout, stderr, _ := testParse(t, files, &parseParams{ + format: formats.Flag(formats.JSON, formats.Pretty), + jsonInclude: "comments", + }) + if errc != 0 { + t.Fatalf("Expected exit code 0, got %v", errc) + } + if len(stderr) > 0 { + t.Fatalf("Expected no stderr output, got:\n%s\n", string(stderr)) + } + + expectedCommentTextValue := "IGNvbW1lbnQ=" + + if !strings.Contains(string(stdout), expectedCommentTextValue) { + t.Fatalf("Comment text value %q missing in output: %s", expectedCommentTextValue, string(stdout)) + } +} + +func TestParse_DefaultRegoVersion(t *testing.T) { + tests := []struct { + note string + module string + expErrs []string + }{ + { + note: "v0 module", + module: `package test +a[x] { + x := 42 +}`, + expErrs: []string{ + "`if` keyword is required before rule body", + "`contains` keyword is required for partial set rules", + }, + }, + { + note: "v1 module", + module: `package test +a contains x if { + x := 42 +}`, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + files := map[string]string{ + "test.rego": tc.module, + } + + _, _, stderr, _ := testParse(t, files, &parseParams{ + format: formats.Flag(formats.Pretty, formats.JSON), + }) + + if len(tc.expErrs) > 0 { + errs := string(stderr) + for _, expErr := range tc.expErrs { + if !strings.Contains(errs, expErr) { + t.Fatalf("Expected error:\n\n%q\n\ngot:\n\n%s", expErr, errs) + } + } + } else if len(stderr) > 0 { + t.Fatalf("Expected no stderr output, got:\n%s\n", string(stderr)) + } + }) + } +} + +func TestParseCompatibleFlags(t *testing.T) { + tests := []struct { + note string + v0Compatible bool + v1Compatible bool + policy string + expErrs []string + }{ + { + note: "v0, keywords not used", + v0Compatible: true, + policy: `package test +p[v] { + v := input.x +}`, + }, + { + note: "v0, keywords not imported", + v0Compatible: true, + policy: `package test +p contains v if { + v := input.x +}`, + expErrs: []string{ + "var cannot be used for rule name", + }, + }, + { + note: "v0, keywords imported", + v0Compatible: true, + policy: `package test +import future.keywords +p contains v if { + v := input.x +}`, + }, + { + note: "v0, rego.v1 imported", + v0Compatible: true, + policy: `package test +import rego.v1 +p contains v if { + v := input.x +}`, + }, + + { + note: "v1, keywords not used", + v1Compatible: true, + policy: `package test +p[v] { + v := input.x +}`, + expErrs: []string{ + "`if` keyword is required before rule body", + "`contains` keyword is required for partial set rules", + }, + }, + { + note: "v1, keywords not imported", + v1Compatible: true, + policy: `package test +p contains v if { + v := input.x +}`, + }, + { + note: "v1, keywords imported", + v1Compatible: true, + policy: `package test +import future.keywords +p contains v if { + v := input.x +}`, + }, + { + note: "v1, rego.v1 imported", + v1Compatible: true, + policy: `package test +import rego.v1 +p contains v if { + v := input.x +}`, + }, + + // v0 takes precedence over v1 + { + note: "v0+v1, keywords not used", + v0Compatible: true, + v1Compatible: true, + policy: `package test +p[v] { + v := input.x +}`, + }, + { + note: "v0+v1, keywords not imported", + v0Compatible: true, + v1Compatible: true, + policy: `package test +p contains v if { + v := input.x +}`, + expErrs: []string{ + "var cannot be used for rule name", + }, + }, + { + note: "v0+1, keywords imported", + v0Compatible: true, + v1Compatible: true, + policy: `package test +import future.keywords +p contains v if { + v := input.x +}`, + }, + { + note: "v0+v1, rego.v1 imported", + v0Compatible: true, + v1Compatible: true, + policy: `package test +import rego.v1 +p contains v if { + v := input.x +}`, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + files := map[string]string{ + "policy.rego": tc.policy, + } + + _, _, stderr, _ := testParse(t, files, &parseParams{ + format: formats.Flag(formats.Pretty, formats.JSON), + v0Compatible: tc.v0Compatible, + v1Compatible: tc.v1Compatible, + }) + + if len(tc.expErrs) > 0 { + errs := string(stderr) + for _, expErr := range tc.expErrs { + if !strings.Contains(errs, expErr) { + t.Fatalf("Expected error:\n\n%q\n\ngot:\n\n%s", expErr, errs) + } + } + } else if len(stderr) > 0 { + t.Fatalf("Expected no stderr output, got:\n%s\n", string(stderr)) + } + }) + } +} + +// Runs parse and returns the exit code, stdout, and stderr contents +func testParse(t *testing.T, files map[string]string, params *parseParams) (int, []byte, []byte, string) { + t.Helper() + + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + var errc int + + var tempDirUsed string + path := test.TempDir(t, files) + + args := make([]string, 0, len(files)) + for file := range files { + args = append(args, filepath.Join(path, file)) + } + errc = parse(args, params, stdout, stderr) + + tempDirUsed = path + + return errc, stdout.Bytes(), stderr.Bytes(), tempDirUsed +} diff --git a/cmd/parse_test.go b/cmd/parse_test.go index a3da2e1484..a82052e06a 100644 --- a/cmd/parse_test.go +++ b/cmd/parse_test.go @@ -1,3 +1,5 @@ +//go:build !go1.27 + package cmd import ( @@ -12,7 +14,6 @@ import ( ) func TestParseExit0(t *testing.T) { - files := map[string]string{ "x.rego": `package x @@ -62,7 +63,6 @@ func TestParseExit1(t *testing.T) { } func TestParseJSONOutput(t *testing.T) { - files := map[string]string{ "x.rego": `package x @@ -121,13 +121,12 @@ func TestParseJSONOutput(t *testing.T) { } ` - if got, want := string(stdout), expectedOutput; got != want { - t.Fatalf("Expected output\n%v\n, got\n%v", want, got) + if diff := cmp.Diff(expectedOutput, string(stdout)); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) } } func TestParseJSONOutputWithLocations(t *testing.T) { - files := map[string]string{ "x.rego": `package x @@ -241,21 +240,8 @@ p = 1 } `, "TEMPDIR", tempDirPath) - gotLines := strings.Split(string(stdout), "\n") - wantLines := strings.Split(expectedOutput, "\n") - min := len(gotLines) - if len(wantLines) < min { - min = len(wantLines) - } - - for i := range min { - if gotLines[i] != wantLines[i] { - t.Fatalf("Expected line %d to be\n%v\n, got\n%v", i, wantLines[i], gotLines[i]) - } - } - - if len(gotLines) != len(wantLines) { - t.Fatalf("Expected %d lines, got %d", len(wantLines), len(gotLines)) + if diff := cmp.Diff(expectedOutput, string(stdout)); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) } } @@ -633,7 +619,6 @@ func TestParseOutputWithNotImport(t *testing.T) { } func TestParseRefsJSONOutput(t *testing.T) { - files := map[string]string{ "x.rego": `package x @@ -700,13 +685,12 @@ func TestParseRefsJSONOutput(t *testing.T) { } ` - if got, want := string(stdout), expectedOutput; got != want { - t.Fatalf("Expected output\n%v\n, got\n%v", want, got) + if diff := cmp.Diff(expectedOutput, string(stdout)); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) } } func TestParseRefsJSONOutputWithLocations(t *testing.T) { - files := map[string]string{ "x.rego": `package x @@ -840,21 +824,8 @@ a.b.c := true } `, "TEMPDIR", tempDirPath) - gotLines := strings.Split(string(stdout), "\n") - wantLines := strings.Split(expectedOutput, "\n") - min := len(gotLines) - if len(wantLines) < min { - min = len(wantLines) - } - - for i := range min { - if gotLines[i] != wantLines[i] { - t.Fatalf("Expected line %d to be\n%v\n, got\n%v", i, wantLines[i], gotLines[i]) - } - } - - if len(gotLines) != len(wantLines) { - t.Fatalf("Expected %d lines, got %d", len(wantLines), len(gotLines)) + if diff := cmp.Diff(expectedOutput, string(stdout)); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) } } func TestParseRulesBlockJSONOutputWithLocations(t *testing.T) { @@ -1301,26 +1272,12 @@ allow = true if { } `, "TEMPDIR", tempDirPath) - gotLines := strings.Split(string(stdout), "\n") - wantLines := strings.Split(expectedOutput, "\n") - min := len(gotLines) - if len(wantLines) < min { - min = len(wantLines) - } - - for i := range min { - if gotLines[i] != wantLines[i] { - t.Fatalf("Expected line %d to be\n%v\n, got\n%v", i, wantLines[i], gotLines[i]) - } - } - - if len(gotLines) != len(wantLines) { - t.Fatalf("Expected %d lines, got %d", len(wantLines), len(gotLines)) + if diff := cmp.Diff(expectedOutput, string(stdout)); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) } } func TestParseJSONOutputComments(t *testing.T) { - files := map[string]string{ "x.rego": `package x @@ -1560,15 +1517,15 @@ func testParse(t *testing.T, files map[string]string, params *parseParams) (int, var errc int var tempDirUsed string - test.WithTempFS(files, func(path string) { - args := make([]string, 0, len(files)) - for file := range files { - args = append(args, filepath.Join(path, file)) - } - errc = parse(args, params, stdout, stderr) + path := test.TempDir(t, files) - tempDirUsed = path - }) + args := make([]string, 0, len(files)) + for file := range files { + args = append(args, filepath.Join(path, file)) + } + errc = parse(args, params, stdout, stderr) + + tempDirUsed = path return errc, stdout.Bytes(), stderr.Bytes(), tempDirUsed } diff --git a/cmd/run_jsonv2_test.go b/cmd/run_jsonv2_test.go new file mode 100644 index 0000000000..74625c7e9a --- /dev/null +++ b/cmd/run_jsonv2_test.go @@ -0,0 +1,510 @@ +//go:build go1.27 + +// Copyright 2020 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 cmd + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + + internal_logging "github.com/open-policy-agent/opa/internal/logging" + "github.com/open-policy-agent/opa/v1/logging" + "github.com/open-policy-agent/opa/v1/repl" + "github.com/open-policy-agent/opa/v1/storage/inmem" + "github.com/open-policy-agent/opa/v1/test/e2e" + "github.com/open-policy-agent/opa/v1/util/test" + "github.com/spf13/cobra" +) + +func TestREPLJSONOutputBytes(t *testing.T) { + store := inmem.New() + var buf bytes.Buffer + r := repl.New(store, "", &buf, "json", 0, "") + + ctx := context.Background() + if err := r.OneShot(ctx, "1 == 1"); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + expected := `{ + "result": [ + { + "expressions": [ + { + "value": true, + "text": "1 == 1", + "location": { + "row": 1, + "col": 1 + } + } + ] + } + ] +} +` + + if diff := cmp.Diff(expected, buf.String()); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } +} + +func TestRunServerBase(t *testing.T) { + params := newTestRunParams() + ctx, cancel := context.WithCancel(t.Context()) + + rt, err := initRuntime(ctx, params, nil, false) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + testRuntime := e2e.WrapRuntime(ctx, cancel, rt) + + done := make(chan bool) + go func() { + err := rt.Serve(ctx) + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + done <- true + }() + + err = testRuntime.WaitForServer() + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + validateBasicServe(t, testRuntime) + + cancel() + <-done +} + +func TestRunServerBaseListenOnLocalhost(t *testing.T) { + params := newTestRunParams() + params.rt.V1Compatible = true + + ctx, cancel := context.WithCancel(t.Context()) + + rt, err := initRuntime(ctx, params, nil, true) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + testRuntime := e2e.WrapRuntime(ctx, cancel, rt) + + done := make(chan bool) + go func() { + err := rt.Serve(ctx) + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + done <- true + }() + + err = testRuntime.WaitForServer() + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + validateBasicServe(t, testRuntime) + + if len(rt.Addrs()) != 1 { + t.Fatalf("Expected 1 listening address but got %v", len(rt.Addrs())) + } + + expected := "127.0.0.1:8181" + if rt.Addrs()[0] != expected { + t.Fatalf("Expected listening address %v but got %v", expected, rt.Addrs()[0]) + } + + cancel() + <-done +} + +func TestRunServerWithDiagnosticAddr(t *testing.T) { + params := newTestRunParams() + params.rt.DiagnosticAddrs = &[]string{"localhost:0"} + ctx, cancel := context.WithCancel(t.Context()) + + rt, err := initRuntime(ctx, params, nil, false) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + testRuntime := e2e.WrapRuntime(ctx, cancel, rt) + + done := make(chan bool) + go func() { + err := rt.Serve(ctx) + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + done <- true + }() + + err = testRuntime.WaitForServer() + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + validateBasicServe(t, testRuntime) + + diagURL, err := testRuntime.AddrToURL(rt.DiagnosticAddrs()[0]) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + if err := testRuntime.HealthCheck(diagURL); err != nil { + t.Error(err) + } + + cancel() + <-done +} + +func TestInitRuntimeVerifyNonBundle(t *testing.T) { + + params := newTestRunParams() + params.pubKey = "secret" + params.serverMode = false + + _, err := initRuntime(t.Context(), params, nil, false) + if err == nil { + t.Fatal("Expected error but got nil") + } + + exp := "enable bundle mode (ie. --bundle) to verify bundle files or directories" + if err.Error() != exp { + t.Fatalf("expected error message %v but got %v", exp, err.Error()) + } +} + +func TestInitRuntimeCipherSuites(t *testing.T) { + testCases := []struct { + name string + cipherSuites []string + expErr bool + expCipherSuites []uint16 + }{ + {"no cipher suites", []string{}, false, []uint16{}}, + {"secure and insecure cipher suites", []string{"TLS_RSA_WITH_AES_128_CBC_SHA", "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA", "TLS_RSA_WITH_RC4_128_SHA"}, false, []uint16{tls.TLS_RSA_WITH_AES_128_CBC_SHA, tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, tls.TLS_RSA_WITH_RC4_128_SHA}}, + {"invalid cipher suites", []string{"foo"}, true, []uint16{}}, + {"tls 1.3 cipher suite", []string{"TLS_AES_128_GCM_SHA256"}, true, []uint16{}}, + {"tls 1.2-1.3 cipher suite", []string{"TLS_RSA_WITH_AES_128_GCM_SHA256", "TLS_AES_128_GCM_SHA256"}, true, []uint16{}}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + + params := newTestRunParams() + + if len(tc.cipherSuites) != 0 { + params.cipherSuites = tc.cipherSuites + } + + rt, err := initRuntime(t.Context(), params, nil, false) + fmt.Println(err) + + if !tc.expErr && err != nil { + t.Fatal("Unexpected error occurred:", err) + } else if tc.expErr && err == nil { + t.Fatal("Expected error but got nil") + } else if err == nil { + if len(tc.expCipherSuites) > 0 { + if !slices.Equal(*rt.Params.CipherSuites, tc.expCipherSuites) { + t.Fatalf("expected cipher suites %v but got %v", tc.expCipherSuites, *rt.Params.CipherSuites) + } + } else { + if rt.Params.CipherSuites != nil { + t.Fatal("expected no value defined for cipher suites") + } + } + } + }) + } +} + +func TestInitRuntimeSkipKnownSchemaCheck(t *testing.T) { + + fs := map[string]string{ + "test/authz.rego": `package system.authz + import rego.v1 + + default allow := false + + allow if { + input.identty = "foo" # this is a typo + }`, + } + + test.WithTempFS(fs, func(rootDir string) { + rootDir = filepath.Join(rootDir, "test") + + params := newTestRunParams() + err := params.authorization.Set("basic") + if err != nil { + t.Fatal(err) + } + + _, err = initRuntime(t.Context(), params, []string{rootDir}, false) + if err == nil { + t.Fatal("Expected error but got nil") + } + + if !strings.Contains(err.Error(), "undefined ref: input.identty") { + t.Errorf("Expected error \"%v\" not found", "undefined ref: input.identty") + } + + // skip type checking for known input schemas + params.skipKnownSchemaCheck = true + _, err = initRuntime(t.Context(), params, []string{rootDir}, false) + if err != nil { + t.Fatal(err) + } + }) +} + +func TestRunServerUploadPolicy(t *testing.T) { + v0Policy := `package test + p { q["a"] } + q[x] { + x = "a" + }` + + v1Policy := `package test + p if { q["a"] } + q contains x if { + x = "a" + }` + + tests := []struct { + note string + v0Compatible bool + module string + expErr bool + }{ + { + note: "v0-compatible, v0 policy", + v0Compatible: true, + module: v0Policy, + }, + { + note: "v0-compatible, v1 policy", + v0Compatible: true, + module: v1Policy, + expErr: true, + }, + { + note: "v1, v0 policy", + v0Compatible: false, + module: v0Policy, + expErr: true, + }, + { + note: "v1, v1 policy", + v0Compatible: false, + module: v1Policy, + }, + } + + for i, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + + params := newTestRunParams() + params.rt.V0Compatible = tc.v0Compatible + + rt, err := initRuntime(ctx, params, nil, false) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + testRuntime := e2e.WrapRuntime(ctx, cancel, rt) + + done := make(chan bool) + go func() { + err := rt.Serve(ctx) + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + done <- true + }() + + err = testRuntime.WaitForServer() + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + // upload policy + err = testRuntime.UploadPolicy(fmt.Sprintf("mod%d", i), bytes.NewBufferString(tc.module)) + + if tc.expErr { + if err == nil { + t.Fatalf("Expected error but got nil") + } + } else { + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + } + + cancel() + <-done + }) + } +} + +func TestRunServerCheckLogTimestampFormat(t *testing.T) { + for _, format := range []string{time.Kitchen, time.RFC3339Nano} { + t.Run(format, func(t *testing.T) { + t.Run("parameter", func(t *testing.T) { + params := newTestRunParams() + params.logTimestampFormat = format + params.rt.Addrs = &[]string{"localhost:0"} + checkLogTimeStampFormat(t, params, format) + }) + t.Run("environment variable", func(t *testing.T) { + t.Setenv("OPA_LOG_TIMESTAMP_FORMAT", format) + params := newTestRunParams() + params.rt.Addrs = &[]string{"localhost:0"} + checkLogTimeStampFormat(t, params, format) + }) + }) + } +} + +func checkLogTimeStampFormat(t *testing.T, params runCmdParams, format string) { + ctx, cancel := context.WithCancel(t.Context()) + + // Pass a pre-configured StandardLogger to bypass BufferedLogger and capture logs directly. + var buf bytes.Buffer + stdLogger := logging.New() + stdLogger.SetFormatter(internal_logging.GetFormatter(params.logFormat.String(), format)) + stdLogger.SetOutput(&buf) + params.rt.Logger = stdLogger + + rt, err := initRuntime(ctx, params, nil, false) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + testRuntime := e2e.WrapRuntime(ctx, cancel, rt) + + done := make(chan bool) + go func() { + err := rt.Serve(ctx) + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + done <- true + }() + + err = testRuntime.WaitForServer() + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + validateBasicServe(t, testRuntime) + + cancel() + <-done + + for line := range strings.SplitSeq(buf.String(), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var rec struct { + Time string `json:"time"` + } + if err := json.Unmarshal([]byte(line), &rec); err != nil { + t.Fatalf("incorrect log message %s: %v", line, err) + } + if rec.Time == "" { + t.Fatalf("the time field is empty in log message: %s", line) + } + if _, err := time.Parse(format, rec.Time); err != nil { + t.Fatalf("incorrect timestamp format %q: %v", rec.Time, err) + } + } +} + +func TestInitRuntimeAddrSetByUser(t *testing.T) { + testCases := []struct { + name string + addrValue string + addrFlagSet bool + }{ + {"AddrSetByUser_True", "localhost:8181", true}, + {"AddrSetByUser_False", "", false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().String("addr", "", "set address") + if tc.addrFlagSet { + if err := cmd.Flags().Set("addr", tc.addrValue); err != nil { + t.Fatalf("Failed to set addr flag: %v", err) + } + } + + params := newTestRunParams() + params.rt.Addrs = &[]string{"localhost:0"} + ctx, cancel := context.WithCancel(t.Context()) + + rt, err := initRuntime(ctx, params, []string{}, cmd.Flags().Changed("addr")) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if rt.Params.AddrSetByUser != tc.addrFlagSet { + t.Errorf("Expected AddrSetByUser to be %v, but got %v", tc.addrFlagSet, rt.Params.AddrSetByUser) + } + + cancel() + }) + } +} + +func newTestRunParams() runCmdParams { + params := newRunParams() + params.rt.GracefulShutdownPeriod = 1 + params.rt.Addrs = &[]string{"localhost:8181"} + params.rt.DiagnosticAddrs = &[]string{} + params.serverMode = true + return params +} + +func validateBasicServe(t *testing.T, runtime *e2e.TestRuntime) { + t.Helper() + + err := runtime.UploadData(bytes.NewBufferString(`{"x": 1}`)) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + resp := struct { + Result int `json:"result"` + }{} + err = runtime.GetDataWithInputTyped("x", nil, &resp) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if resp.Result != 1 { + t.Fatalf("Expected x to be 1, got %v", resp) + } +} diff --git a/cmd/run_test.go b/cmd/run_test.go index b8355cb0f4..bc0b305772 100644 --- a/cmd/run_test.go +++ b/cmd/run_test.go @@ -1,3 +1,5 @@ +//go:build !go1.27 + // Copyright 2020 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. @@ -16,13 +18,50 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" + internal_logging "github.com/open-policy-agent/opa/internal/logging" "github.com/open-policy-agent/opa/v1/logging" + "github.com/open-policy-agent/opa/v1/repl" + "github.com/open-policy-agent/opa/v1/storage/inmem" "github.com/open-policy-agent/opa/v1/test/e2e" "github.com/open-policy-agent/opa/v1/util/test" "github.com/spf13/cobra" ) +func TestREPLJSONOutputBytes(t *testing.T) { + store := inmem.New() + var buf bytes.Buffer + r := repl.New(store, "", &buf, "json", 0, "") + + ctx := context.Background() + if err := r.OneShot(ctx, "1 == 1"); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + expected := `{ + "result": [ + { + "expressions": [ + { + "value": true, + "text": "1 == 1", + "location": { + "row": 1, + "col": 1 + } + } + ] + } + ] +} +` + + if diff := cmp.Diff(expected, buf.String()); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } +} + func TestRunServerBase(t *testing.T) { params := newTestRunParams() ctx, cancel := context.WithCancel(t.Context()) diff --git a/cmd/sign_jsonv2_test.go b/cmd/sign_jsonv2_test.go new file mode 100644 index 0000000000..840a19cc04 --- /dev/null +++ b/cmd/sign_jsonv2_test.go @@ -0,0 +1,207 @@ +//go:build go1.27 + +// Copyright 2018 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 cmd + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/open-policy-agent/opa/internal/file/archive" + "github.com/open-policy-agent/opa/v1/bundle" + "github.com/open-policy-agent/opa/v1/keys" + "github.com/open-policy-agent/opa/v1/util/test" +) + +func TestWriteTokenToFile(t *testing.T) { + + token := `eyJhbGciOiJSUzI1NiJ9.eyJmaWxlcyI6W3sibmFtZSI6ImJ1bmRsZS8ubWFuaWZlc3QiLCJoYXNoIjoiZWUwZWRiZGZkMjgzNTBjNDk2ZjA4ODI3Y2E1Y2VhYjgwMzA2NzI0YjYyZGY1ZjY0MDRlNzBjYjc2NjYxNWQ5ZCIsImFsZ29yaXRobSI6IlNIQTI1NiJ9LHsibmFtZSI6ImJ1bmRsZS9odHRwL2V4YW1wbGUvYXV0aHovYXV0aHoucmVnbyIsImhhc2giOiI2MDJiZTcwMWIyYmE4ZTc3YTljNTNmOWIzM2QwZTkwM2MzNGMwMGMzMDkzM2Y2NDZiYmU3NGI3YzE2NGY2OGM2IiwiYWxnb3JpdGhtIjoiU0hBMjU2In0seyJuYW1lIjoiYnVuZGxlL3JvbGVzL2JpbmRpbmcvZGF0YS5qc29uIiwiaGFzaCI6ImIxODg1NTViZjczMGVlNDdkZjBiY2Y4MzVlYTNmNTQ1MjlmMzc4N2Y0ODQxZjFhZGE2MDM5M2RhYWZhZmJkYzciLCJhbGdvcml0aG0iOiJTSEEyNTYifV0sImtleWlkIjoiZm9vIiwic2NvcGUiOiJyZWFkIn0.YojuPnGWutdlDL7lwFGBXqPfDtxOG2BuZmShN5zm-G9zfMprI1AMqKDoPoNv4tuCGIBNXwoNsYHYiK538CHfJEfY1v4iDX3JFEWQlwx_CfJWDonwqT9SY9tHUW7PUUrI_WgJXZ5zei8RAMYMymKSb9hpSAtfGg_PU0kZr52WzjbPUj4SRiB19Swi61r0CFXYjbfx3GDJdjrGTNBSWrUCMrdhHYLEWqJPfSQ-fYfRrgQVhq3BJLwJJe66dgBEGnHEgA7XMuxkNIOv7mj3Y_EChbv2tjrD9NJPekDcYH1zCEc4BycHjNCcsGiQXDE6sFtoNZiCXLB2D0sLqUnBx4TCw27wTPfcOuL2KauLPahZitnH5mYvQD8NI76Pm4NSyJfevwdWjSsrT7vf0DCLS-dU6r9dJ79xM_hJU7136CT8ARcmSrk-EvCqfkrH2c4WwZyAzdyyyFumMZh4CYc2vcC7ap0NANHJT193fTud1i23mx1PBslwXdsIqXvBGlTbR7nb9o661m-B_mxbHMkG4nIeoGpZoaBJw8RVaA6-4D55gtk8aaMyLJIlIIlV2_AKOLk3nPG3ACHiLSndasLDOIRIYkCluIEaM2FLEEPEtJfKNR6e1K-EK2TvNKMDAEUtJW71ggOuGQ3b5otYOoVVENJLwm-PsO7qb2Tq6PyAquI3ExU` + expected := make(map[string]any) + expected["signatures"] = []string{token} + + files := map[string]string{} + + test.WithTempFS(files, func(rootDir string) { + err := writeTokenToFile(token, rootDir) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + bs, err := os.ReadFile(filepath.Join(rootDir, ".signatures.json")) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + expectedBytes, err := json.MarshalIndent(expected, "", " ") + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(expectedBytes, bs) { + t.Fatal("Unexpected content in \".signatures.json\" file") + } + }) +} + +func TestWriteTokenToFileJSONOutputBytes(t *testing.T) { + test.WithTempFS(map[string]string{}, func(rootDir string) { + if err := writeTokenToFile("footoken", rootDir); err != nil { + t.Fatalf("Unexpected error %v", err) + } + + gotBytes, err := os.ReadFile(filepath.Join(rootDir, ".signatures.json")) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + expected := "{\n \"signatures\": [\n \"footoken\"\n ]\n}" + + if diff := cmp.Diff(expected, string(gotBytes)); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } + }) +} + +func TestDoSign(t *testing.T) { + files := map[string]string{ + "foo/bar/data.json": `{"y": 2}`, + "/example/example.rego": `package example`, + "/.signatures.json": `{"signatures": []}`, + } + test.WithTempFS(files, func(rootDir string) { + params := signCmdParams{ + algorithm: "HS256", + key: "mysecret", + outputFilePath: rootDir, + bundleMode: true, + } + + err := doSign([]string{rootDir}, params) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + }) +} + +func TestBundleSignVerification(t *testing.T) { + + // files to be included in the bundle + files := map[string]string{ + "/.manifest": `{"revision": "quickbrownfaux"}`, + "/a/b/c/data.json": "[1,2,3]", + "/a/b/d/data.json": "true", + "/a/b/y/data.yaml": `foo: 1`, + "/example/example.rego": `package example`, + "/policy.wasm": `modules-compiled-as-wasm-binary`, + "/data.json": `{"x": {"y": true}, "a": {"b": {"z": true}}}`, + } + + test.WithTempFS(files, func(rootDir string) { + params := signCmdParams{ + algorithm: "HS256", + key: "mysecret", + outputFilePath: rootDir, + bundleMode: true, + } + + err := doSign([]string{rootDir}, params) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + // create gzipped tarball + var filesInBundle [][2]string + err = filepath.Walk(rootDir, func(path string, info os.FileInfo, _ error) error { + if !info.IsDir() { + bs, err := os.ReadFile(path) + if err != nil { + return err + } + filesInBundle = append(filesInBundle, [2]string{path, string(bs)}) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + buf := archive.MustWriteTarGz(filesInBundle) + + // bundle verification config + kc := keys.Config{ + Key: "mysecret", + Algorithm: "HS256", + } + + bvc := bundle.NewVerificationConfig(map[string]*keys.Config{"foo": &kc}, "foo", "", nil) + reader := bundle.NewReader(buf).WithBundleVerificationConfig(bvc).WithBaseDir(rootDir) + + _, err = reader.Read() + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + }) +} + +func TestValidateSignParams(t *testing.T) { + + tests := map[string]struct { + args []string + params signCmdParams + wantErr bool + err error + }{ + "no_args": { + []string{}, + newSignCmdParams(), + true, errors.New("specify atleast one path containing policy and/or data files"), + }, + "no_signing_key": { + []string{"foo"}, + newSignCmdParams(), + true, errors.New("specify the secret (HMAC) or path of the PEM file containing the private key (RSA and ECDSA)"), + }, + "empty_signing_key": { + []string{"foo"}, + signCmdParams{key: "", bundleMode: true}, + true, errors.New("specify the secret (HMAC) or path of the PEM file containing the private key (RSA and ECDSA)"), + }, + "non_bundle_mode": { + []string{"foo"}, + signCmdParams{key: "foo"}, + true, errors.New("enable bundle mode (ie. --bundle) to sign bundle files or directories"), + }, + "no_error": { + []string{"foo"}, + signCmdParams{key: "foo", bundleMode: true}, + false, nil, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + + err := validateSignParams(tc.args, tc.params) + + if tc.wantErr { + if err == nil { + t.Fatal("Expected error but got nil") + } + + if tc.err != nil && tc.err.Error() != err.Error() { + t.Fatalf("Expected error message %v but got %v", tc.err.Error(), err.Error()) + } + } else if err != nil { + t.Fatalf("Unexpected error %v", err) + } + }) + } +} diff --git a/cmd/sign_test.go b/cmd/sign_test.go index 38025350cb..2751cdd649 100644 --- a/cmd/sign_test.go +++ b/cmd/sign_test.go @@ -1,3 +1,5 @@ +//go:build !go1.27 + // Copyright 2018 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. @@ -11,6 +13,8 @@ import ( "path/filepath" "testing" + "github.com/google/go-cmp/cmp" + "github.com/open-policy-agent/opa/internal/file/archive" "github.com/open-policy-agent/opa/v1/bundle" "github.com/open-policy-agent/opa/v1/keys" @@ -47,6 +51,25 @@ func TestWriteTokenToFile(t *testing.T) { }) } +func TestWriteTokenToFileJSONOutputBytes(t *testing.T) { + test.WithTempFS(map[string]string{}, func(rootDir string) { + if err := writeTokenToFile("footoken", rootDir); err != nil { + t.Fatalf("Unexpected error %v", err) + } + + gotBytes, err := os.ReadFile(filepath.Join(rootDir, ".signatures.json")) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + expected := "{\n \"signatures\": [\n \"footoken\"\n ]\n}" + + if diff := cmp.Diff(expected, string(gotBytes)); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } + }) +} + func TestDoSign(t *testing.T) { files := map[string]string{ "foo/bar/data.json": `{"y": 2}`, diff --git a/cmd/test_jsonv2_test.go b/cmd/test_jsonv2_test.go new file mode 100644 index 0000000000..03bd2eff42 --- /dev/null +++ b/cmd/test_jsonv2_test.go @@ -0,0 +1,3918 @@ +//go:build go1.27 + +package cmd + +import ( + "bytes" + "context" + "fmt" + "io" + "maps" + "os" + "path" + "path/filepath" + "regexp" + "strings" + "syscall" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + + "github.com/open-policy-agent/opa/cmd/formats" + "github.com/open-policy-agent/opa/internal/file/archive" + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/bundle" + "github.com/open-policy-agent/opa/v1/ir" + "github.com/open-policy-agent/opa/v1/rego" + "github.com/open-policy-agent/opa/v1/repl" + "github.com/open-policy-agent/opa/v1/storage/inmem" + "github.com/open-policy-agent/opa/v1/topdown" + "github.com/open-policy-agent/opa/v1/util/test" +) + +func TestFilterTraceDefault(t *testing.T) { + p := newTestCommandParams() + p.verbose = false + expected := `Enter data.testing.test_p = _ +| Enter data.testing.test_p +| | Enter data.testing.p +| | | Enter data.testing.q +| | | | Enter data.testing.r +| | | | | Fail x = data.x +| | | | Fail data.testing.r[x] +| | | Fail data.testing.q.foo +| | Fail data.testing.p with data.x as "bar" +| Fail data.testing.test_p = _ +` + verifyFilteredTrace(t, &p, expected) +} + +func TestFilterTraceVerbose(t *testing.T) { + p := newTestCommandParams() + p.verbose = true + expected := `Enter data.testing.test_p = _ +| Enter data.testing.test_p +| | Enter data.testing.p +| | | Note "test test" +| | | Enter data.testing.q +| | | | Note "got this far" +| | | | Enter data.testing.r +| | | | | Note "got this far2" +| | | | | Fail x = data.x +| | | | Fail data.testing.r[x] +| | | Fail data.testing.q.foo +| | Fail data.testing.p with data.x as "bar" +| Fail data.testing.test_p = _ +` + verifyFilteredTrace(t, &p, expected) +} + +func TestFilterTraceExplainFails(t *testing.T) { + p := newTestCommandParams() + err := p.explain.Set(explainModeFails) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + expected := `Enter data.testing.test_p = _ +| Enter data.testing.test_p +| | Enter data.testing.p +| | | Enter data.testing.q +| | | | Enter data.testing.r +| | | | | Fail x = data.x +| | | | Fail data.testing.r[x] +| | | Fail data.testing.q.foo +| | Fail data.testing.p with data.x as "bar" +| Fail data.testing.test_p = _ +` + verifyFilteredTrace(t, &p, expected) +} + +func TestFilterTraceExplainNotes(t *testing.T) { + p := newTestCommandParams() + err := p.explain.Set(explainModeNotes) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + expected := `Enter data.testing.test_p = _ +| Enter data.testing.test_p +| | Enter data.testing.p +| | | Note "test test" +| | | Enter data.testing.q +| | | | Note "got this far" +| | | | Enter data.testing.r +| | | | | Note "got this far2" +` + verifyFilteredTrace(t, &p, expected) +} + +func TestFilterTraceExplainFull(t *testing.T) { + p := newTestCommandParams() + err := p.explain.Set(explainModeFull) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + expected := `Enter data.testing.test_p = _ +| Eval data.testing.test_p = _ +| Index data.testing.test_p (matched 1 rule, early exit) +| Enter data.testing.test_p +| | Eval data.testing.p with data.x as "bar" +| | Index data.testing.p (matched 1 rule, early exit) +| | Enter data.testing.p +| | | Eval data.testing.x +| | | Index data.testing.x (matched 1 rule, early exit) +| | | Enter data.testing.x +| | | | Eval data.testing.y +| | | | Index data.testing.y (matched 1 rule, early exit) +| | | | Enter data.testing.y +| | | | | Eval true +| | | | | Exit data.testing.y early +| | | | Exit data.testing.x early +| | | Eval trace("test test") +| | | Note "test test" +| | | Eval data.testing.q.foo +| | | Index data.testing.q (matched 1 rule) +| | | Enter data.testing.q +| | | | Eval trace("got this far") +| | | | Note "got this far" +| | | | Eval data.testing.r[x] +| | | | Index data.testing.r (matched 1 rule) +| | | | Enter data.testing.r +| | | | | Eval trace("got this far2") +| | | | | Note "got this far2" +| | | | | Eval x = data.x +| | | | | Fail x = data.x +| | | | | Redo trace("got this far2") +| | | | Fail data.testing.r[x] +| | | | Redo trace("got this far") +| | | Fail data.testing.q.foo +| | | Redo trace("test test") +| | | Redo data.testing.x +| | | Redo data.testing.x +| | | | Redo data.testing.y +| | | | | Redo true +| | Fail data.testing.p with data.x as "bar" +| Fail data.testing.test_p = _ +` + verifyFilteredTrace(t, &p, expected) +} + +func TestThresholdRange(t *testing.T) { + thresholds := []float64{-1, 101} + for _, threshold := range thresholds { + if isThresholdValid(threshold) { + t.Fatalf("invalid threshold %2f shoul be reported", threshold) + } + } +} + +func verifyFilteredTrace(t *testing.T, params *testCommandParams, expected string) { + filtered := filterTrace(params, failTrace(t)) + + var buff bytes.Buffer + topdown.PrettyTrace(&buff, filtered) + actual := buff.String() + + if actual != expected { + t.Fatalf("Expected:\n\n%s\n\nGot:\n\n%s\n\n", expected, actual) + } +} + +func failTrace(t *testing.T) []*topdown.Event { + t.Helper() + mod := ` + package testing + + p if { + x # Always true + trace("test test") + q["foo"] + } + + x if { + y + } + + y if { + true + } + + q contains x if { + some x + trace("got this far") + r[x] + trace("got this far1") + } + + r contains x if { + trace("got this far2") + x := data.x + } + + test_p if { + p with data.x as "bar" + } + ` + + tracer := topdown.NewBufferTracer() + + _, err := rego.New( + rego.Module("test.rego", mod), + rego.Trace(true), + rego.QueryTracer(tracer), + rego.Query("data.testing.test_p"), + ).Eval(t.Context()) + + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + return *tracer +} + +func TestPrettyTraceWithLocalVars(t *testing.T) { + tests := []struct { + note string + includeVars bool + files map[string]string + expected string + }{ + { + note: "without vars", + includeVars: false, + files: map[string]string{ + "test.rego": `package test + +test_p if { + x := 1 + y := 2 + z := 3 + x == z + y +} +`, + }, + expected: `%.*%/test.rego:3: +data.test.test_p: FAIL (%.*%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_p: FAIL (%.*%) + + query:1 %.*% Enter data.test.test_p = _ + query:1 %.*% | Eval data.test.test_p = _ + query:1 %.*% | Index data.test.test_p (matched 1 rule, early exit) + %.*%/test.rego:3 | Enter data.test.test_p + %.*%/test.rego:4 | | Eval x = 1 + %.*%/test.rego:5 | | Eval y = 2 + %.*%/test.rego:6 | | Eval z = 3 + %.*%/test.rego:7 | | Eval plus(z, y, __local3__) + %.*%/test.rego:7 | | Eval x = __local3__ + %.*%/test.rego:7 | | Fail x = __local3__ + %.*%/test.rego:7 | | Redo plus(z, y, __local3__) + %.*%/test.rego:6 | | Redo z = 3 + %.*%/test.rego:5 | | Redo y = 2 + %.*%/test.rego:4 | | Redo x = 1 + query:1 %.*% | Fail data.test.test_p = _ + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "with vars", + includeVars: true, + files: map[string]string{ + "test.rego": `package test + +test_p if { + x := 1 + y := 2 + z := 3 + x == z + y +} +`, + }, + expected: `%.*%/test.rego:3: +data.test.test_p: FAIL (%.*%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_p: FAIL (%.*%) + + query:1 %.*% Enter data.test.test_p = _ {} + query:1 %.*% | Eval data.test.test_p = _ {} + query:1 %.*% | Index data.test.test_p (matched 1 rule, early exit) {} + %.*%/test.rego:3 | Enter data.test.test_p {} + %.*%/test.rego:4 | | Eval x = 1 {} + %.*%/test.rego:5 | | Eval y = 2 {} + %.*%/test.rego:6 | | Eval z = 3 {} + %.*%/test.rego:7 | | Eval plus(z, y, __local3__) {y: 2, z: 3} + %.*%/test.rego:7 | | Eval x = __local3__ {__local3__: 5, x: 1} + %.*%/test.rego:7 | | Fail x = __local3__ {__local3__: 5, x: 1} + %.*%/test.rego:7 | | Redo plus(z, y, __local3__) {__local3__: 5, y: 2, z: 3} + %.*%/test.rego:6 | | Redo z = 3 {z: 3} + %.*%/test.rego:5 | | Redo y = 2 {y: 2} + %.*%/test.rego:4 | | Redo x = 1 {x: 1} + query:1 %.*% | Fail data.test.test_p = _ {} + + %.*%/test.rego:7: + x == z + y + | | | + | | 2 + | z + y: 5 + | z: 3 + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + test.WithTempFS(tc.files, func(root string) { + buf := new(bytes.Buffer) + testParams := newTestCommandParams() + testParams.count = 1 + testParams.output = buf + testParams.errOutput = io.Discard + testParams.bundleMode = true + testParams.verbose = true + testParams.varValues = tc.includeVars + _ = testParams.explain.Set(explainModeFull) + + errorCode := opaTest([]string{root}, testParams) + if errorCode != 2 { + t.Fatalf("Unexpected error code: %d", errorCode) + } + + actual := buf.String() + if !stringsMatch(t, tc.expected, actual) { + t.Fatalf("Expected:\n\n%v\n\nGot:\n\n%v", tc.expected, actual) + } + }) + }) + } +} + +func TestFailVarValues(t *testing.T) { + tests := []struct { + note string + files map[string]string + expected string + }{ + { + note: "simple", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + x := 1 + y := 2 + z := 3 + x == y + z +} +`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:7: + x == y + z + | | | + | | 3 + | y + z: 5 + | y: 2 + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "simple (not)", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + x := 5 + y := 2 + z := 3 + not x == y + z +} +`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:7: + not x == y + z + | | | + | | 3 + | y + z: 5 + | y: 2 + 5 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "array", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + x := 1 + y := [1, 2, 3] + z := 3 + x == y[2] + z +} +`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:7: + x == y[2] + z + | | | + | | 3 + | y[2] + z: 6 + | y[2]: 3 + | y: [1, 2, 3] + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "array, var key", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + x := 1 + y := [1, 2, 3] + z := 3 + i := 2 + x == y[i] + z +} +`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:8: + x == y[i] + z + | | | | + | | | 3 + | | 2 + | y[i] + z: 6 + | y[i]: 3 + | y: [1, 2, 3] + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "array containing vars", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + x := 1 + y := 2 + z := 3 + [x, y, z] == [4, 5, 6] +} +`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:7: + [x, y, z] == [4, 5, 6] + | | | + | | 3 + | 2 + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "array containing refs", + files: map[string]string{ + "/test.rego": `package test + +a := 1 + +b := 2 + +test_foo if { + [a, data.test.b, data.c] == [4, 5, 6] +} +`, + "data.json": `{"c": 3}`, + }, + expected: `%ROOT%/test.rego:7: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:8: + [a, data.test.b, data.c] == [4, 5, 6] + | | | + | | 3 + | 2 + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "array containing refs, undefined", + files: map[string]string{ + "/test.rego": `package test + +a := 1 + +b := data.b + +test_foo if { + [a, b, data.c] == [4, 5, 6] +} +`, + "data.json": `{"c": 3}`, + }, + // Note: each dynamic array element is broken out into a separate "co-expression" by the compiler. + // Since we failed on the 2nd element (b), we don't have value for the 3rd element (data.c). + expected: `%ROOT%/test.rego:7: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:8: + [a, b, data.c] == [4, 5, 6] + | | + | undefined + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "nested collections containing vars", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + x := 1 + y := 2 + z := 3 + [x, {y, {"a": z}}] == [4, {5, {"a": 6}}] +} +`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:7: + [x, {y, {"a": z}}] == [4, {5, {"a": 6}}] + | | | + | | 3 + | 2 + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "single line expression containing tabs", + files: map[string]string{ + "/test.rego": `package test + + test_foo if { + x := 1 + y := 2 + z := 3 + x == y + z + } +`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:7: + x == y + z + | | | + | | 3 + | y + z: 5 + | y: 2 + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "single line expression containing tabs #2", + files: map[string]string{ + "/test.rego": `package test + + test_foo if { + x := 1 + y := 2 + z := 3 + x == y + z + } +`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:7: + x == y + z + | | | + | | 3 + | y + z: 5 + | y: 2 + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "multi-line expression containing tabs", + files: map[string]string{ + "/test.rego": `package test + + test_foo if { + x := 1 + y := 2 + z := 3 + obj := { + "foo_": 1, + "bar__": 42, + "baz": 3, + } + obj == { + "foo_": x, + "bar__": y, + "baz": z, + } + } +`, + }, + // We can't deal with tabs in a consistent manner when they occur on multiple lines + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:12: + obj == { + "foo_": x, + "bar__": y, + "baz": z, + } + + Where: + + obj: {"bar__": 42, "baz": 3, "foo_": 1} + x: 1 + y: 2 + z: 3 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "composite rule", + files: map[string]string{ + "/test.rego": `package test + +p contains v if { + some v in numbers.range(1, 3) +} + +test_p if { + p == {4, 5, 6} +}`, + }, + expected: `%ROOT%/test.rego:7: +data.test.test_p: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_p: FAIL (%TIME%) + + %ROOT%/test.rego:8: + p == {4, 5, 6} + | + {1, 2, 3} + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "composite rule with ref-head", + files: map[string]string{ + "/test.rego": `package test + +p.q contains v if { + some v in numbers.range(1, 3) +} + +test_p if { + p.q == {4, 5, 6} +}`, + }, + expected: `%ROOT%/test.rego:7: +data.test.test_p: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_p: FAIL (%TIME%) + + %ROOT%/test.rego:8: + p.q == {4, 5, 6} + | + {1, 2, 3} + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "composite rule with ref-head, partial ref", + files: map[string]string{ + "/test.rego": `package test + +p.q contains v if { + some v in numbers.range(1, 3) +} + +test_p if { + p == { + "q": {4, 5, 6} + } +}`, + }, + expected: `%ROOT%/test.rego:7: +data.test.test_p: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_p: FAIL (%TIME%) + + %ROOT%/test.rego:8: + p == { + "q": {4, 5, 6} + } + | + {"q": {1, 2, 3}} + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "composite rules with ref-head, composite value", + files: map[string]string{ + "/test.rego": `package test + +p.q contains v if { + some v in numbers.range(1, 3) +} + +p.r := "foo" + +test_p if { + p == { + "q": {4, 5, 6}, + "r": "bar" + } +}`, + }, + expected: `%ROOT%/test.rego:9: +data.test.test_p: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_p: FAIL (%TIME%) + + %ROOT%/test.rego:10: + p == { + "q": {4, 5, 6}, + "r": "bar" + } + | + {"q": {1, 2, 3}, "r": "foo"} + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "refs in different compiled sub-expressions", + files: map[string]string{ + "/test.rego": `package test + +a := 1 +b := 2 +c := 3 + +test_p if { + # This expression is split into multiple final expressions by the compiler, each containing a rule ref + a == b + c +} +`, + }, + expected: `%ROOT%/test.rego:7: +data.test.test_p: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_p: FAIL (%TIME%) + + %ROOT%/test.rego:9: + a == b + c + | | | + | | 3 + | b + c: 5 + | b: 2 + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "rule not defined", + files: map[string]string{ + "/test.rego": `package test + +p if { + input.x == 1 +} + +test_p if { + p with input.x as 2 +}`, + }, + expected: `%ROOT%/test.rego:7: +data.test.test_p: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_p: FAIL (%TIME%) + + %ROOT%/test.rego:8: + p with input.x as 2 + | + undefined + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "rule defined (not)", + files: map[string]string{ + "/test.rego": `package test + +p if { + input.x == 1 +} + +test_p if { + not p with input.x as 1 +}`, + }, + expected: `%ROOT%/test.rego:7: +data.test.test_p: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_p: FAIL (%TIME%) + + %ROOT%/test.rego:8: + not p with input.x as 1 + | + true + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "data ref", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + y := 1 + data.x == y +} +`, + "data.json": `{"x": 2}`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:5: + data.x == y + | | + | 1 + 2 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "data + virtual extent ref", + files: map[string]string{ + "/test.rego": `package test + +foo.x := 1 + +test_foo if { + y := {"x": 1, "y": 42} + foo == y +} +`, + "data.json": `{"test": {"foo": {"y": 2}}}`, + }, + expected: `%ROOT%/test.rego:5: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:7: + foo == y + | | + | {"x": 1, "y": 42} + {"x": 1, "y": 2} + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "in (array)", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + l := ["a", "b", "c"] + x := "q" + x in l +} +`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:6: + x in l + | | + | ["a", "b", "c"] + "q" + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "in (set)", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + l := {"a", "b", "c"} + x := "q" + x in l +} +`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:6: + x in l + | | + | {"a", "b", "c"} + "q" + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "comprehension (array)", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + l := ["a", "b", "c"] + [x | x := l[_]] == ["d", "e", "f"] +} +`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:5: + [x | x := l[_]] == ["d", "e", "f"] + | + ["a", "b", "c"] + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "comprehension (set)", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + l := ["a"] + {x | x := l[_]} == {"b"} +} +`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:5: + {x | x := l[_]} == {"b"} + | + {"a"} + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "comprehension (object)", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + l := ["a", "b", "c"] + {k: x | x := l[k]} == {3: "d", 4: "e", 5: "f"} +} +`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:5: + {k: x | x := l[k]} == {3: "d", 4: "e", 5: "f"} + | + {0: "a", 1: "b", 2: "c"} + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "every", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + l := [1, 2, 3] + every x in l { + x == 1 + } +}`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:6: + x == 1 + | + 2 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "comprehension inside every", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + l := [1, 2, 3] + every x in l { + [v | v := x] == [42] + } +}`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:6: + [v | v := x] == [42] + | + [1] + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "nested every", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + l := [[1, 2], [3, 4], [5, 6]] + every x in l { + every y in x { + y < 4 + } + } +}`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:7: + y < 4 + | + 4 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "nested every with comprehension", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + l := [[1, 2], [3, 4], [5, 6]] + every x in l { + every y in x { + [v | v := y] == [42] + } + } +}`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:7: + [v | v := y] == [42] + | + [1] + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "ref equality", + files: map[string]string{ + "/test.rego": `package test + +a := 1 +b := 2 + +test_foo if { + a == b +}`, + }, + expected: `%ROOT%/test.rego:6: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:7: + a == b + | | + | 2 + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "ref equality (data)", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + data.a == data.b +}`, + "data.json": `{"a": 1, "b": 2}`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:4: + data.a == data.b + | | + | 2 + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "object ref selection from local var", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + tc := {"data": false, "expected": {"data": true}} + tc.data == tc.expected.data +}`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:5: + tc.data == tc.expected.data + | | + | true + tc.data: false + tc: {"data": false, "expected": {"data": true}} + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "object ref selection alongside function call", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + tc := {"content": "big string value", "inp": {"foo": false}, "outp": {"foo": true}} + foo(tc.inp) == tc.outp +} + +foo(inp) := {"foo": inp.foo}`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:5: + foo(tc.inp) == tc.outp + | | | + | | {"foo": true} + | tc.inp: {"foo": false} + | tc: {"content": "big string value", "inp": {"foo": false}, "outp"... + {"foo": false} + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "with, containing local vars", + files: map[string]string{ + "/test.rego": `package test + +p := input.x + +test_p if { + a := 1 + p == 2 with input.x as a +}`, + }, + expected: `%ROOT%/test.rego:5: +data.test.test_p: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_p: FAIL (%TIME%) + + %ROOT%/test.rego:7: + p == 2 with input.x as a + | | + | 1 + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "with, containing ref", + files: map[string]string{ + "/test.rego": `package test + +p := input.x + +testInput := {"x": 1} + +test_p if { + p == 2 with input as testInput +}`, + }, + expected: `%ROOT%/test.rego:7: +data.test.test_p: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_p: FAIL (%TIME%) + + %ROOT%/test.rego:8: + p == 2 with input as testInput + | | + | {"x": 1} + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "negated rule ref", + files: map[string]string{ + "/test.rego": `package test + +a if {true} + +test_foo if { + not a +}`, + "data.json": `{"a": true}`, + }, + expected: `%ROOT%/test.rego:5: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:6: + not a + | + true + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "negated data ref", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + not data.a +}`, + "data.json": `{"a": true}`, + }, + // Because of the negated expr, the compiler will have opted out of rewriting the expression to + // capture the value of data.a in a local variable, and since data.a isn't in the local bindings + // or in the virtual cache, we don't know if it's undefined or unknown, and therefore can't report + // on a value. + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:4: + not data.a + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + } + + r := regexp.MustCompile(`FAIL \(.*s\)`) + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + test.WithTempFS(tc.files, func(root string) { + buf := new(bytes.Buffer) + testParams := newTestCommandParams() + testParams.count = 1 + testParams.output = buf + testParams.errOutput = io.Discard + testParams.bundleMode = true + testParams.varValues = true + _ = testParams.explain.Set(explainModeFull) + + exitCode := opaTest([]string{root}, testParams) + if exitCode != 2 { + t.Fatalf("Unexpected error code: %d", exitCode) + } + + actual := r.ReplaceAllString(buf.String(), "FAIL (%TIME%)") + expected := strings.ReplaceAll(tc.expected, "%ROOT%", root) + + if !stringsMatch(t, expected, actual) { + t.Fatalf("Expected output to be:\n\n%s\n\ngot:\n\n%s", expected, actual) + } + }) + }) + } +} + +// Assert that ignore flag is correctly used when the bundle flag is activated +func TestIgnoreFlag(t *testing.T) { + files := map[string]string{ + "/test.rego": `package test + +p := input.foo == 42 +test_p if { + p with input.foo as 42 +}`, + "/broken.rego": "package foo\n bar {", + } + + var exitCode int + test.WithTempFS(files, func(root string) { + testParams := newTestCommandParams() + testParams.count = 1 + testParams.errOutput = io.Discard + testParams.bundleMode = false + testParams.ignore = []string{"broken.rego"} + + exitCode = opaTest([]string{root}, testParams) + }) + + if exitCode > 0 { + t.Fatalf("unexpected exit code: %d", exitCode) + } +} + +// Assert that ignore flag is correctly used when the bundle flag is activated +func TestIgnoreFlagWithBundleFlag(t *testing.T) { + files := map[string]string{ + "/test.rego": `package test + +p := input.foo == 42 +test_p if { + p with input.foo as 42 +}`, + "/broken.rego": "package foo\n bar {", + } + + var exitCode int + test.WithTempFS(files, func(root string) { + testParams := newTestCommandParams() + testParams.count = 1 + testParams.errOutput = io.Discard + testParams.bundleMode = true + testParams.ignore = []string{"broken.rego"} + exitCode = opaTest([]string{root}, testParams) + }) + + if exitCode > 0 { + t.Fatalf("unexpected exit code: %d", exitCode) + } +} + +func testSchemasAnnotation(rego string) (int, []byte) { + + files := map[string]string{ + "test.rego": rego, + } + + var exitCode int + var buf bytes.Buffer + test.WithTempFS(files, func(path string) { + regoFilePath := filepath.Join(path, "test.rego") + + testParams := newTestCommandParams() + testParams.count = 1 + testParams.errOutput = &buf + + exitCode = opaTest([]string{regoFilePath}, testParams) + }) + return exitCode, buf.Bytes() +} + +// Assert that 'schemas' annotations with schema ref are ignored, but not inlined schemas +func TestSchemasAnnotation(t *testing.T) { + policyWithSchemaRef := ` +package test + +# METADATA +# schemas: +# - input: schema["input"] +p if { + rego.metadata.rule() # presence of rego.metadata.* calls must not trigger unwanted schema evaluation + input.foo == 42 # type mismatch with schema that should be ignored +} + +test_p if { + p with input.foo as 42 +}` + + exitCode, _ := testSchemasAnnotation(policyWithSchemaRef) + if exitCode > 0 { + t.Fatalf("unexpected error when schema ref is present") + } +} +func TestSchemasAnnotationInline(t *testing.T) { + policyWithInlinedSchema := ` +package test + +# METADATA +# schemas: +# - input.foo: {"type": "boolean"} +p if { + input.foo == 42 # type mismatch with schema that should NOT be ignored since it is an inlined schema format +} + +test_p if { + p with input.foo as 42 +}` + + exitCode, errOutput := testSchemasAnnotation(policyWithInlinedSchema) + // We expect an error here, as inlined schemas are always used for type checking + + if exitCode == 0 { + t.Fatalf("didn't get expected error when inlined schema is present") + } + + if !strings.Contains(string(errOutput), "rego_type_error: match error") { + t.Fatalf("didn't get expected %s error when inlined schema is present; got: %v", ast.TypeErr, string(errOutput)) + } +} + +func testSchemasAnnotationWithJSONFile(rego string, schema string) (int, []byte) { + + files := map[string]string{ + "test.rego": rego, + "demo_schema.json": schema, + } + + var exitCode int + var buf bytes.Buffer + test.WithTempFS(files, func(path string) { + regoFilePath := filepath.Join(path, "test.rego") + + testParams := newTestCommandParams() + testParams.count = 1 + testParams.schema.path = path + testParams.errOutput = &buf + + exitCode = opaTest([]string{regoFilePath}, testParams) + }) + + return exitCode, buf.Bytes() +} +func TestJSONSchemaSuccess(t *testing.T) { + + regoContents := `package test + +# METADATA +# schemas: +# - input: schema.demo_schema +p if { + input.foo == 42 +} + +test_p if { + p with input.foo as 42 +}` + + schema := `{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "schema", + "type": "object", + "description": "The root schema comprises the entire JSON document.", + "required": [ + "foo" + ], + "properties": { + "foo": { + "$id": "#/properties/foo", + "type": "number", + "description": "foo" + } + }, + "additionalProperties": false + }` + + errorCode, _ := testSchemasAnnotationWithJSONFile(regoContents, schema) + if errorCode != 0 { + t.Fatalf("unexpected error code: %d", errorCode) + } +} + +func TestJSONSchemaFail(t *testing.T) { + + regoContents := `package test + +# METADATA +# schemas: +# - input: schema.demo_schema +p if { + input.foo == 42 +} + +test_p if { + p with input.foo as 42 +}` + + schema := `{ +"$schema": "http://json-schema.org/draft-07/schema", +"$id": "schema", +"type": "object", +"description": "The root schema comprises the entire JSON document.", +"required": [ + "foo" +], +"properties": { + "foo": { + "$id": "#/properties/foo", + "type": "boolean", + "description": "foo" + } +}, +"additionalProperties": false +}` + + exitCode, errOutput := testSchemasAnnotationWithJSONFile(regoContents, schema) + if exitCode == 0 { + t.Fatalf("didn't get expected error when schema is present and is defining a different type than being used.") + } + + if !strings.Contains(string(errOutput), "rego_type_error: match error") { + t.Fatalf("didn't get expected %s error when schema is defining a different type than being used; got: %v", ast.TypeErr, string(errOutput)) + } +} + +func TestWatchMode(t *testing.T) { + + files := map[string]string{ + "/policy.rego": `package foo +p := 1`, + "/policy_test.rego": `package foo + +test_p if { + p == 1 +}`, + } + + test.WithTempFS(files, func(root string) { + buf := test.BlockingWriter{} + + testParams := newTestCommandParams() + testParams.output = &buf + testParams.watch = true + testParams.count = 1 + + done := make(chan struct{}) + go func() { + _ = opaTest([]string{root}, testParams) + <-done + }() + + expected := "Watching for changes ..." + if !test.Eventually(t, 2*time.Second, func() bool { + return strings.Contains(buf.String(), expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + // update the test + f, _ := os.OpenFile(path.Join(root, "policy_test.rego"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + _, err := f.WriteString("package foo\n test_p if { p == 2 }") + if err != nil { + t.Fatal(err) + } + err = f.Close() + if err != nil { + t.Fatal(err) + } + + r := regexp.MustCompile(`FAIL \(.*s\)`) + expected = `%ROOT%/policy_test.rego:2: +data.foo.test_p: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAIL: 1/1 +******************************************************************************** +Watching for changes ... +` + if !test.Eventually(t, 2*time.Second, func() bool { + actual := r.ReplaceAllString(buf.String(), "FAIL (%TIME%)") + expected := strings.ReplaceAll(expected, "%ROOT%", root) + return strings.Contains(actual, expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + // update policy so test passes + f, _ = os.OpenFile(path.Join(root, "policy.rego"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + _, err = f.WriteString("package foo\n p := 2") + if err != nil { + t.Fatal(err) + } + + err = f.Close() + if err != nil { + t.Fatal(err) + } + + expected = `PASS: 1/1 +******************************************************************************** +Watching for changes ... +` + if !test.Eventually(t, 2*time.Second, func() bool { + expected := strings.ReplaceAll(expected, "%ROOT%", root) + return strings.Contains(buf.String(), expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + // add new policy and test + if err := os.WriteFile(path.Join(root, "policy2.rego"), []byte("package bar\n q := \"hello\""), 0644); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(path.Join(root, "policy2_test.rego"), []byte("package bar\n test_q if { q == \"hello\" }"), 0644); err != nil { + t.Fatal(err) + } + + expected = `PASS: 2/2 +******************************************************************************** +Watching for changes ... +` + if !test.Eventually(t, 2*time.Second, func() bool { + expected := strings.ReplaceAll(expected, "%ROOT%", root) + return strings.Contains(buf.String(), expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + testParams.stopChan <- syscall.SIGINT + done <- struct{}{} + }) +} + +func TestWatchMode_v0(t *testing.T) { + + files := map[string]string{ + "/policy.rego": `package foo +p := 1`, + "/policy_test.rego": `package foo + +test_p { + p == 1 +}`, + } + + test.WithTempFS(files, func(root string) { + buf := test.BlockingWriter{} + + testParams := newTestCommandParams() + testParams.output = &buf + testParams.watch = true + testParams.count = 1 + testParams.v0Compatible = true + + done := make(chan struct{}) + go func() { + _ = opaTest([]string{root}, testParams) + <-done + }() + + expected := "Watching for changes ..." + if !test.Eventually(t, 2*time.Second, func() bool { + return strings.Contains(buf.String(), expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + // update the test + f, _ := os.OpenFile(path.Join(root, "policy_test.rego"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + _, err := f.WriteString("package foo\n test_p { p == 2 }") + if err != nil { + t.Fatal(err) + } + err = f.Close() + if err != nil { + t.Fatal(err) + } + + r := regexp.MustCompile(`FAIL \(.*s\)`) + expected = `%ROOT%/policy_test.rego:2: +data.foo.test_p: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAIL: 1/1 +******************************************************************************** +Watching for changes ... +` + if !test.Eventually(t, 2*time.Second, func() bool { + actual := r.ReplaceAllString(buf.String(), "FAIL (%TIME%)") + expected := strings.ReplaceAll(expected, "%ROOT%", root) + return strings.Contains(actual, expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + // update policy so test passes + f, _ = os.OpenFile(path.Join(root, "policy.rego"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + _, err = f.WriteString("package foo\n p := 2") + if err != nil { + t.Fatal(err) + } + + err = f.Close() + if err != nil { + t.Fatal(err) + } + + expected = `PASS: 1/1 +******************************************************************************** +Watching for changes ... +` + if !test.Eventually(t, 2*time.Second, func() bool { + expected := strings.ReplaceAll(expected, "%ROOT%", root) + return strings.Contains(buf.String(), expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + // add new policy and test + if err := os.WriteFile(path.Join(root, "policy2.rego"), []byte("package bar\n q := \"hello\""), 0644); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(path.Join(root, "policy2_test.rego"), []byte("package bar\n test_q { q == \"hello\" }"), 0644); err != nil { + t.Fatal(err) + } + + expected = `PASS: 2/2 +******************************************************************************** +Watching for changes ... +` + if !test.Eventually(t, 2*time.Second, func() bool { + expected := strings.ReplaceAll(expected, "%ROOT%", root) + return strings.Contains(buf.String(), expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + testParams.stopChan <- syscall.SIGINT + done <- struct{}{} + }) +} + +func TestWatchModeWithDataFile(t *testing.T) { + + files := map[string]string{ + "/policy.rego": `package foo + +test_p if { + data.y == 1 +}`, + "/data.json": `{"y": 1}`, + } + + test.WithTempFS(files, func(root string) { + buf := test.BlockingWriter{} + + testParams := newTestCommandParams() + testParams.output = &buf + testParams.watch = true + testParams.count = 1 + + done := make(chan struct{}) + go func() { + _ = opaTest([]string{root}, testParams) + <-done + }() + + expected := "Watching for changes ..." + if !test.Eventually(t, 2*time.Second, func() bool { + return strings.Contains(buf.String(), expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + // update the data + f, _ := os.OpenFile(path.Join(root, "data.json"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + _, err := f.WriteString(`{"y": 2}`) + if err != nil { + t.Fatal(err) + } + err = f.Close() + if err != nil { + t.Fatal(err) + } + + r := regexp.MustCompile(`FAIL \(.*s\)`) + expected = `%ROOT%/policy.rego:3: +data.foo.test_p: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAIL: 1/1 +******************************************************************************** +Watching for changes ... +` + if !test.Eventually(t, 2*time.Second, func() bool { + actual := r.ReplaceAllString(buf.String(), "FAIL (%TIME%)") + expected := strings.ReplaceAll(expected, "%ROOT%", root) + return strings.Contains(actual, expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + // update policy so test passes + f, _ = os.OpenFile(path.Join(root, "policy.rego"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + _, err = f.WriteString("package foo\n test_p if { data.y == 2 }") + if err != nil { + t.Fatal(err) + } + + err = f.Close() + if err != nil { + t.Fatal(err) + } + + expected = `PASS: 1/1 +******************************************************************************** +Watching for changes ... +` + + if !test.Eventually(t, 2*time.Second, func() bool { + expected := strings.ReplaceAll(expected, "%ROOT%", root) + return strings.Contains(buf.String(), expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + testParams.stopChan <- syscall.SIGINT + done <- struct{}{} + }) +} + +func TestWatchModeWhenDataFileRemoved(t *testing.T) { + files := map[string]string{ + "/policy.rego": `package foo + +test_p if { + data.y == 1 +}`, + "/data.json": `{"y": 1}`, + } + + test.WithTempFS(files, func(root string) { + buf := test.BlockingWriter{} + + testParams := newTestCommandParams() + testParams.output = &buf + testParams.watch = true + testParams.count = 1 + + done := make(chan struct{}) + go func() { + _ = opaTest([]string{root}, testParams) + <-done + }() + + expected := "Watching for changes ..." + if !test.Eventually(t, 2*time.Second, func() bool { + return strings.Contains(buf.String(), expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + // update the data + f, _ := os.OpenFile(path.Join(root, "data.json"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + _, err := f.WriteString(`{"y": 2}`) + if err != nil { + t.Fatal(err) + } + err = f.Close() + if err != nil { + t.Fatal(err) + } + + r := regexp.MustCompile(`FAIL \(.*s\)`) + expected = `%ROOT%/policy.rego:3: +data.foo.test_p: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAIL: 1/1 +******************************************************************************** +Watching for changes ... +` + if !test.Eventually(t, 2*time.Second, func() bool { + actual := r.ReplaceAllString(buf.String(), "FAIL (%TIME%)") + expected := strings.ReplaceAll(expected, "%ROOT%", root) + return strings.Contains(actual, expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + // update the data back to the original state, so the opa test passes + f, _ = os.OpenFile(path.Join(root, "data.json"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + _, err = f.WriteString(`{"y": 1}`) + if err != nil { + t.Fatal(err) + } + err = f.Close() + if err != nil { + t.Fatal(err) + } + + expected = `PASS: 1/1 +******************************************************************************** +Watching for changes ... +` + + if !test.Eventually(t, 2*time.Second, func() bool { + expected := strings.ReplaceAll(expected, "%ROOT%", root) + return strings.Contains(buf.String(), expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + // remove the data file, check that test fails afterward + err = os.Remove(path.Join(root, "data.json")) + if err != nil { + t.Fatal(err) + } + + time.Sleep(500 * time.Millisecond) + + testParams.stopChan <- syscall.SIGINT + done <- struct{}{} + }) +} + +func TestWatchModeBrokenFileRecovery(t *testing.T) { + + tests := []struct { + note string + fileName string + brokenFile string + fixedFile string + expectedOutput string + }{ + { + note: "empty data file (EOF read by watcher)", + fileName: "data.json", + fixedFile: `{"foo": "bar"}`, + expectedOutput: `1 error occurred during loading: %ROOT%/data.json: EOF +******************************************************************************** +Watching for changes ...`, + }, + { + note: "broken policy", + fileName: "broken_policy.rego", + brokenFile: "package foo\n bar {", + fixedFile: "package foo\n bar if {true}", + expectedOutput: `1 error occurred during loading: %ROOT%/broken_policy.rego:2: rego_parse_error: unexpected eof token + bar { + ^ +******************************************************************************** +Watching for changes ...`, + }, + } + + files := map[string]string{ + "/policy.rego": `package foo +p := 1`, + "/policy_test.rego": `package foo + +test_p if { + p == 1 +}`, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + test.WithTempFS(files, func(root string) { + buf := test.BlockingWriter{} + + testParams := newTestCommandParams() + testParams.output = &buf + testParams.watch = true + testParams.count = 1 + testParams.errOutput = io.Discard + + done := make(chan struct{}) + go func() { + _ = opaTest([]string{root}, testParams) + <-done + }() + + expected := "Watching for changes ..." + if !test.Eventually(t, 2*time.Second, func() bool { + return strings.Contains(buf.String(), expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + // create broken (possibly empty) file + f, _ := os.OpenFile(path.Join(root, tc.fileName), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + if len(tc.brokenFile) > 0 { + _, err := f.WriteString(tc.brokenFile) + if err != nil { + t.Fatal(err) + } + } + err := f.Close() + if err != nil { + t.Fatal(err) + } + + if !test.Eventually(t, 2*time.Second, func() bool { + expected := strings.ReplaceAll(tc.expectedOutput, "%ROOT%", root) + return strings.Contains(buf.String(), expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", tc.expectedOutput, buf.String()) + } + buf.Reset() + + // write data to empty file + f, _ = os.OpenFile(path.Join(root, tc.fileName), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + _, err = f.WriteString(tc.fixedFile) + if err != nil { + t.Fatal(err) + } + err = f.Close() + if err != nil { + t.Fatal(err) + } + + expected = "Watching for changes ..." + if !test.Eventually(t, 2*time.Second, func() bool { + expected := strings.ReplaceAll(expected, "%ROOT%", root) + return strings.Contains(buf.String(), expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + testParams.stopChan <- syscall.SIGINT + done <- struct{}{} + }) + }) + } +} + +func testExitCode(rego string, skipExitZero bool) int { + files := map[string]string{ + "test.rego": rego, + } + + var exitCode int + test.WithTempFS(files, func(path string) { + regoFilePath := filepath.Join(path, "test.rego") + + testParams := newTestCommandParams() + testParams.count = 1 + testParams.skipExitZero = skipExitZero + testParams.errOutput = io.Discard + testParams.output = io.Discard + + exitCode = opaTest([]string{regoFilePath}, testParams) + }) + return exitCode +} + +func testExitCodeWithFailOnEmpty(rego string, failOnEmpty bool) int { + files := map[string]string{ + "test.rego": rego, + } + + var exitCode int + test.WithTempFS(files, func(path string) { + regoFilePath := filepath.Join(path, "test.rego") + + testParams := newTestCommandParams() + testParams.count = 1 + testParams.failOnEmpty = failOnEmpty + testParams.errOutput = io.Discard + testParams.output = io.Discard + + exitCode = opaTest([]string{regoFilePath}, testParams) + }) + return exitCode +} + +func TestExitCode(t *testing.T) { + testCases := map[string]struct { + Test string + ExitZeroOnSkipped bool + ExpectedExitCode int + }{ + "pass when no failed or skipped tests": { + Test: `package foo + + test_pass if { true } + `, + ExitZeroOnSkipped: false, + ExpectedExitCode: 0, + }, + "fail when failed tests": { + Test: `package foo + + test_pass if { true } + test_fail if { false } + `, + ExitZeroOnSkipped: false, + ExpectedExitCode: 2, + }, + "fail when skipped tests": { + Test: `package foo + + test_pass if { true } + todo_test_skip if { true } + `, + ExitZeroOnSkipped: false, + ExpectedExitCode: 2, + }, + "fail when failed tests and skipped tests": { + Test: `package foo + + test_pass if { true } + test_fail if { false } + todo_test_skip if { true } + `, + ExitZeroOnSkipped: false, + ExpectedExitCode: 2, + }, + "pass when skipped tests and exit zero on skipped": { + Test: `package foo + + test_pass if { true } + todo_test_skip if { true } + `, + ExitZeroOnSkipped: true, + ExpectedExitCode: 0, + }, + "fail when failed tests and exit zero on skipped": { + Test: `package foo + + test_pass if { true } + test_fail if { false } + `, + ExitZeroOnSkipped: true, + ExpectedExitCode: 2, + }, + "fail when failed tests, skipped tests and exit zero on skipped": { + Test: `package foo + + test_pass if { true } + test_fail if { false } + todo_test_skip if { true } + `, + ExitZeroOnSkipped: true, + ExpectedExitCode: 2, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + exitCode := testExitCode(tc.Test, tc.ExitZeroOnSkipped) + + if exitCode != tc.ExpectedExitCode { + t.Errorf("Expected exit code to be %d but got %d", tc.ExpectedExitCode, exitCode) + } + }) + } +} + +func TestCoverageThreshold(t *testing.T) { + testCases := []struct { + note string + modules map[string]string + threshold float64 + verbose bool + expectedErrOutput string + expectedExitCode int + }{ + { + note: "coverage threshold met", + modules: map[string]string{ + "test.rego": `package test + + p := 1 + test_p if { p == 1 }`, + }, + expectedExitCode: 0, + }, + { + note: "coverage threshold not met", + modules: map[string]string{ + "test.rego": `package test + + p := 1 if { + 1 == 1 + } + q := 2 + r := 3 + test_q if { q == 2 }`, + }, + threshold: 100, + expectedExitCode: 2, + expectedErrOutput: "Code coverage threshold not met: got 40.00 instead of 100.00\n", + }, + { + note: "coverage threshold not met (verbose)", + modules: map[string]string{ + "test.rego": `package test + + p := 1 if { + 1 == 1 + } + q := 2 + r := 3 + test_q if { q == 2 }`, + }, + threshold: 100, + expectedExitCode: 2, + verbose: true, + expectedErrOutput: `Code coverage threshold not met: got 40.00 instead of 100.00 +Lines not covered: + %ROOT%/test.rego:3-4 + %ROOT%/test.rego:7 +`, + }, + { + note: "coverage threshold not met (verbose, multiple files)", + modules: map[string]string{ + "policy1.rego": `package test + + p := 1 if { + 1 == 1 + } + q := 2 + r := 3`, + "policy2.rego": `package test + + s := 4 if { + 1 == 1 + 2 == 2 + } + t := 5 + u := 6 + v := 7`, + "test.rego": `package test + + test_q if { q == 2 } + test_t if { t == 5 }`, + }, + threshold: 100, + expectedExitCode: 2, + verbose: true, + expectedErrOutput: `Code coverage threshold not met: got 33.33 instead of 100.00 +Lines not covered: + %ROOT%/policy1.rego:3-4 + %ROOT%/policy1.rego:7 + %ROOT%/policy2.rego:3-5 + %ROOT%/policy2.rego:8-9 +`, + }, + } + + for _, tc := range testCases { + t.Run(tc.note, func(t *testing.T) { + test.WithTempFS(tc.modules, func(root string) { + var buf bytes.Buffer + + testParams := newTestCommandParams() + testParams.threshold = tc.threshold + testParams.verbose = tc.verbose + testParams.count = 1 + testParams.errOutput = &buf + + exitCode := opaTest([]string{root}, testParams) + if exitCode != tc.expectedExitCode { + t.Fatalf("unexpected exit code: %d", exitCode) + } + + if len(tc.expectedErrOutput) == 0 && buf.Len() > 0 { + t.Fatalf("expected no error output but got:\n\n%q", buf.String()) + } + + expectedErrOutput := strings.ReplaceAll(tc.expectedErrOutput, "%ROOT%", root) + if buf.String() != expectedErrOutput { + t.Fatalf("expected error output to contain:\n\n%q\n\nbut got:\n\n%q", expectedErrOutput, buf.String()) + } + }) + }) + } +} + +func TestCoverageThresholdWithBundles(t *testing.T) { + modules := map[string]string{ + "authz/policy.rego": `package authz + +import rego.v1 + +allow if { + input.path == ["users"] + input.method == "POST" +} + +allow if { + some profile_id + input.path = ["users", profile_id] + input.method == "GET" + profile_id == input.user_id +} +`, + "authz/policy_test.rego": `package authz + +import rego.v1 + +test_post_allowed if { + allow with input as {"path": ["users"], "method": "POST"} +} + +test_get_anonymous_denied if { + not allow with input as {"path": ["users"], "method": "GET"} +} +test_get_another_user_denied if { + not allow with input as {"path": ["users", "bob"], "method": "GET", "user_id": "alice"} +} +`, + } + + for _, loadType := range []loadType{loadBundle, loadTarball} { + t.Run(loadType.String(), func(t *testing.T) { + var files map[string]string + if loadType != loadTarball { + files = modules + } + test.WithTempFS(files, func(root string) { + if loadType == loadTarball { + f, err := os.Create(filepath.Join(root, "bundle.tar.gz")) + if err != nil { + t.Fatal(err) + } + + testBundle := bundle.Bundle{ + Data: map[string]any{}, + } + for k, v := range modules { + testBundle.Modules = append(testBundle.Modules, bundle.ModuleFile{ + Path: k, + Raw: []byte(v), + }) + } + + if err := bundle.Write(f, testBundle); err != nil { + t.Fatal(err) + } + } + + var buf bytes.Buffer + + testParams := newTestCommandParams() + testParams.threshold = 100 + testParams.verbose = true + testParams.count = 1 + testParams.bundleMode = true + testParams.errOutput = &buf + testParams.coverage = true + + var paths []string + if loadType == loadTarball { + paths = []string{filepath.Join(root, "bundle.tar.gz")} + } else { + paths = []string{root} + } + + exitCode := opaTest(paths, testParams) + // Coverage should NOT be 100% since the second allow rule is never exercised + if exitCode != 2 { + t.Fatalf("expected exit code 2 (threshold not met), got %d\noutput: %s", exitCode, buf.String()) + } + + if !strings.Contains(buf.String(), "Code coverage threshold not met: got 92.31 instead of 100.00") { + t.Fatalf("expected coverage of 92.31, got:\n\n%q", buf.String()) + } + }) + }) + } +} + +type loadType int + +const ( + loadFile loadType = iota + loadBundle + loadTarball +) + +func (t loadType) String() string { + return [...]string{"file", "bundle", "bundle tarball"}[t] +} + +func TestRun_DefaultRegoVersion(t *testing.T) { + tests := []struct { + note string + files map[string]string + expErrs []string + }{ + { + note: "v0 module", + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2[v] { + v := l1[_] +} + +test_l { + l1 == l2 +}`, + }, + expErrs: []string{ + "test.rego:4: rego_parse_error: `if` keyword is required before rule body", + "test.rego:4: rego_parse_error: `contains` keyword is required for partial set rules", + "test.rego:8: rego_parse_error: `if` keyword is required before rule body", + }, + }, + { + note: "v1 module", + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + } + + loadTypes := []loadType{loadFile, loadBundle, loadTarball} + + for _, tc := range tests { + for _, loadType := range loadTypes { + t.Run(fmt.Sprintf("%s (%s)", tc.note, loadType), func(t *testing.T) { + var files map[string]string + if loadType != loadTarball { + files = tc.files + } + test.WithTempFS(files, func(root string) { + if loadType == loadTarball { + f, err := os.Create(filepath.Join(root, "bundle.tar.gz")) + if err != nil { + t.Fatal(err) + } + + testBundle := bundle.Bundle{ + Data: map[string]any{}, + } + for k, v := range tc.files { + testBundle.Modules = append(testBundle.Modules, bundle.ModuleFile{ + Path: k, + Raw: []byte(v), + }) + } + + if err := bundle.Write(f, testBundle); err != nil { + t.Fatal(err) + } + } + + var buf bytes.Buffer + var errBuf bytes.Buffer + + testParams := newTestCommandParams() + testParams.bundleMode = loadType == loadBundle + testParams.count = 1 + testParams.output = &buf + testParams.errOutput = &errBuf + + var paths []string + if loadType == loadTarball { + paths = []string{filepath.Join(root, "bundle.tar.gz")} + } else { + paths = []string{root} + } + + exitCode := opaTest(paths, testParams) + if len(tc.expErrs) > 0 { + if exitCode == 0 { + t.Fatalf("expected non-zero exit code") + } + + for _, expErr := range tc.expErrs { + if actual := errBuf.String(); !strings.Contains(actual, expErr) { + t.Fatalf("expected error output to contain:\n\n%q\n\nbut got:\n\n%q", expErr, actual) + } + } + } else { + if exitCode != 0 { + t.Fatalf("unexpected exit code: %d", exitCode) + } + + if errBuf.Len() > 0 { + t.Fatalf("expected no error output but got:\n\n%q", buf.String()) + } + + expected := "PASS: 1/1" + if actual := buf.String(); !strings.Contains(actual, expected) { + t.Fatalf("expected output to contain:\n\n%s\n\nbut got:\n\n%q", expected, actual) + } + } + }) + }) + } + } +} + +func TestRunWithRegoV1Capability(t *testing.T) { + tests := []struct { + note string + v0Compatible bool + capabilities *ast.Capabilities + files map[string]string + expErrs []string + }{ + { + note: "v0 module, v0-compatible, no capabilities", + v0Compatible: true, + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2[v] { + v := l1[_] +} + +test_l { + l1 == l2 +}`, + }, + }, + { + note: "v0 module, v0-compatible, v0 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2[v] { + v := l1[_] +} + +test_l { + l1 == l2 +}`, + }, + }, + { + note: "v0 module, v0-compatible, v1 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2[v] { + v := l1[_] +} + +test_l { + l1 == l2 +}`, + }, + }, + + { + note: "v0 module, not v0-compatible, no capabilities", + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2[v] { + v := l1[_] +} + +test_l { + l1 == l2 +}`, + }, + expErrs: []string{ + "test.rego:4: rego_parse_error: `if` keyword is required before rule body", + "test.rego:4: rego_parse_error: `contains` keyword is required for partial set rules", + "test.rego:8: rego_parse_error: `if` keyword is required before rule body", + }, + }, + { + note: "v0 module, not v0-compatible, v0 capabilities", + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2[v] { + v := l1[_] +} + +test_l { + l1 == l2 +}`, + }, + expErrs: []string{ + "test.rego:4: rego_parse_error: `if` keyword is required before rule body", + "test.rego:4: rego_parse_error: `contains` keyword is required for partial set rules", + "test.rego:8: rego_parse_error: `if` keyword is required before rule body", + }, + }, + { + note: "v0 module, not v0-compatible, v0 capabilities without rego_v1 feature", + capabilities: capsWithoutFeat(ast.RegoV0, ast.FeatureRegoV1), + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2[v] { + v := l1[_] +} + +test_l { + l1 == l2 +}`, + }, + expErrs: []string{ + "rego_parse_error: illegal capabilities: rego_v1 feature required for parsing v1 Rego", + }, + }, + { + note: "v0 module, not v0-compatible, v1 capabilities", + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2[v] { + v := l1[_] +} + +test_l { + l1 == l2 +}`, + }, + expErrs: []string{ + "test.rego:4: rego_parse_error: `if` keyword is required before rule body", + "test.rego:4: rego_parse_error: `contains` keyword is required for partial set rules", + "test.rego:8: rego_parse_error: `if` keyword is required before rule body", + }, + }, + + { + note: "v1 module, v0-compatible, no capabilities", + v0Compatible: true, + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + expErrs: []string{ + "test.rego:4: rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v1 module, v0-compatible, v0 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + expErrs: []string{ + "test.rego:4: rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v1 module, v0-compatible, v1 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + expErrs: []string{ + "test.rego:4: rego_parse_error: var cannot be used for rule name", + }, + }, + + { + note: "v1 module, not v0-compatible, no capabilities", + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v1 module, not v0-compatible, v0 capabilities", + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v1 module, not v0-compatible, v0 capabilities without rego_v1 feature", + capabilities: capsWithoutFeat(ast.RegoV0, ast.FeatureRegoV1), + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + expErrs: []string{ + "rego_parse_error: illegal capabilities: rego_v1 feature required for parsing v1 Rego", + }, + }, + { + note: "v1 module, not v0-compatible, v1 capabilities", + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + } + + loadTypes := []loadType{loadFile, loadBundle, loadTarball} + + for _, tc := range tests { + for _, loadType := range loadTypes { + t.Run(fmt.Sprintf("%s (%s)", tc.note, loadType), func(t *testing.T) { + var files map[string]string + if loadType != loadTarball { + files = tc.files + } + test.WithTempFS(files, func(root string) { + if loadType == loadTarball { + f, err := os.Create(filepath.Join(root, "bundle.tar.gz")) + if err != nil { + t.Fatal(err) + } + + testBundle := bundle.Bundle{ + Data: map[string]any{}, + } + for k, v := range tc.files { + testBundle.Modules = append(testBundle.Modules, bundle.ModuleFile{ + Path: k, + Raw: []byte(v), + }) + } + + if err := bundle.Write(f, testBundle); err != nil { + t.Fatal(err) + } + } + + var buf bytes.Buffer + var errBuf bytes.Buffer + + testParams := newTestCommandParams() + testParams.bundleMode = loadType == loadBundle + testParams.count = 1 + testParams.output = &buf + testParams.errOutput = &errBuf + testParams.v0Compatible = tc.v0Compatible + testParams.capabilities.C = tc.capabilities + + var paths []string + if loadType == loadTarball { + paths = []string{filepath.Join(root, "bundle.tar.gz")} + } else { + paths = []string{root} + } + + exitCode := opaTest(paths, testParams) + if len(tc.expErrs) > 0 { + if exitCode == 0 { + t.Fatalf("expected non-zero exit code") + } + + for _, expErr := range tc.expErrs { + if actual := errBuf.String(); !strings.Contains(actual, expErr) { + t.Fatalf("expected error output to contain:\n\n%q\n\nbut got:\n\n%q", expErr, actual) + } + } + } else { + if exitCode != 0 { + t.Fatalf("unexpected exit code: %d", exitCode) + } + + if errBuf.Len() > 0 { + t.Fatalf("expected no error output but got:\n\n%q", buf.String()) + } + + expected := "PASS: 1/1" + if actual := buf.String(); !strings.Contains(actual, expected) { + t.Fatalf("expected output to contain:\n\n%s\n\nbut got:\n\n%q", expected, actual) + } + } + }) + }) + } + } +} + +func TestRun_CompatibleFlags(t *testing.T) { + tests := []struct { + note string + v0Compatible bool + v1Compatible bool + files map[string]string + expErr string + }{ + { + note: "v0 module, no imports", + v0Compatible: true, + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + expErr: "rego_parse_error", + }, + { + note: "v0 module, rego.v1 imported", + v0Compatible: true, + files: map[string]string{ + "/test.rego": `package test + +import rego.v1 + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v0 module, future.keywords imported", + v0Compatible: true, + files: map[string]string{ + "/test.rego": `package test + +import future.keywords + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + + { + note: "v1 compatible module, no imports", + v1Compatible: true, + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v1 compatible module, rego.v1 imported", + v1Compatible: true, + files: map[string]string{ + "/test.rego": `package test + +import rego.v1 + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v1 compatible module, future.keywords imported", + v1Compatible: true, + files: map[string]string{ + "/test.rego": `package test + +import future.keywords + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + + // v0 takes precedence over v1 + { + note: "v0+v1 module, no imports", + v0Compatible: true, + v1Compatible: true, + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + expErr: "rego_parse_error", + }, + { + note: "v0+v1 module, rego.v1 imported", + v0Compatible: true, + v1Compatible: true, + files: map[string]string{ + "/test.rego": `package test + +import rego.v1 + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v0+v1 module, future.keywords imported", + v0Compatible: true, + v1Compatible: true, + files: map[string]string{ + "/test.rego": `package test + +import future.keywords + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + } + + loadTypes := []loadType{loadFile, loadBundle, loadTarball} + + for _, tc := range tests { + for _, loadType := range loadTypes { + t.Run(fmt.Sprintf("%s (%s)", tc.note, loadType), func(t *testing.T) { + var files map[string]string + if loadType != loadTarball { + files = tc.files + } + test.WithTempFS(files, func(root string) { + if loadType == loadTarball { + f, err := os.Create(filepath.Join(root, "bundle.tar.gz")) + if err != nil { + t.Fatal(err) + } + + testBundle := bundle.Bundle{ + Data: map[string]any{}, + } + for k, v := range tc.files { + testBundle.Modules = append(testBundle.Modules, bundle.ModuleFile{ + Path: k, + Raw: []byte(v), + }) + } + + if err := bundle.Write(f, testBundle); err != nil { + t.Fatal(err) + } + } + + var buf bytes.Buffer + var errBuf bytes.Buffer + + testParams := newTestCommandParams() + testParams.v0Compatible = tc.v0Compatible + testParams.v1Compatible = tc.v1Compatible + testParams.bundleMode = loadType == loadBundle + testParams.count = 1 + testParams.output = &buf + testParams.errOutput = &errBuf + + var paths []string + if loadType == loadTarball { + paths = []string{filepath.Join(root, "bundle.tar.gz")} + } else { + paths = []string{root} + } + + exitCode := opaTest(paths, testParams) + if tc.expErr != "" { + if exitCode == 0 { + t.Fatalf("expected non-zero exit code") + } + + if actual := errBuf.String(); !strings.Contains(actual, tc.expErr) { + t.Fatalf("expected error output to contain:\n\n%q\n\nbut got:\n\n%q", tc.expErr, actual) + } + } else { + if exitCode != 0 { + t.Fatalf("unexpected exit code: %d", exitCode) + } + + if errBuf.Len() > 0 { + t.Fatalf("expected no error output but got:\n\n%q", buf.String()) + } + + expected := "PASS: 1/1" + if actual := buf.String(); !strings.Contains(actual, expected) { + t.Fatalf("expected output to contain:\n\n%s\n\nbut got:\n\n%q", expected, actual) + } + } + }) + }) + } + } +} + +func TestWithBundleRegoVersion(t *testing.T) { + tests := []struct { + note string + files map[string]string + expErr string + }{ + { + note: "v0.x bundle, no imports", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + expErr: "rego_parse_error", + }, + { + note: "v0.x bundle, rego.v1 imported", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test + +import rego.v1 + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v0.x bundle, future.keywords imported", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test + +import future.keywords + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v0 bundle, v1 per-file override", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "/policy2.rego": 1 + } +}`, + "policy1.rego": `package test +l1 := {1, 3, 5} +l2[v] { + v := l1[_] +}`, + "policy2.rego": `package test +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v0 bundle, v1 per-file override (glob)", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "*/policy2.rego": 1 + } +}`, + "policy1.rego": `package test +l1 := {1, 3, 5} +l2[v] { + v := l1[_] +}`, + "policy2.rego": `package test +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v0 bundle, v1 per-file override, incompatible", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "/policy2.rego": 1 + } +}`, + "policy1.rego": `package test +l1 := {1, 3, 5} +l2[v] { + v := l1[_] +}`, + "policy2.rego": `package test +test_l { + l1 == l2 +}`, + }, + expErr: "rego_parse_error", + }, + + { + note: "v1.0 bundle, no imports", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v1.0 bundle, rego.v1 imported", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test + +import rego.v1 + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v1.0 bundle, future.keywords imported", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test + +import future.keywords + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v1 bundle, v0 per-file override", + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/policy1.rego": 0 + } +}`, + "policy1.rego": `package test +l1 := {1, 3, 5} +l2[v] { + v := l1[_] +}`, + "policy2.rego": `package test +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v1 bundle, v0 per-file override (glob)", + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "*/policy1.rego": 0 + } +}`, + "policy1.rego": `package test +l1 := {1, 3, 5} +l2[v] { + v := l1[_] +}`, + "policy2.rego": `package test +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v1 bundle, v0 per-file override, incompatible", + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/policy1.rego": 0 + } +}`, + "policy1.rego": `package test +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +}`, + "policy2.rego": `package test +test_l if { + l1 == l2 +}`, + }, + expErr: "rego_parse_error", + }, + } + + bundleTypeCases := []struct { + note string + tar bool + }{ + { + "bundle dir", false, + }, + { + "bundle tar", true, + }, + } + + v1CompatibleFlagCases := []struct { + note string + used bool + }{ + { + "no --v1-compatible", false, + }, + { + "--v1-compatible", true, + }, + } + + for _, bundleType := range bundleTypeCases { + for _, v1CompatibleFlag := range v1CompatibleFlagCases { + for _, tc := range tests { + + t.Run(fmt.Sprintf("%s, %s, %s", bundleType.note, v1CompatibleFlag.note, tc.note), func(t *testing.T) { + files := map[string]string{} + if bundleType.tar { + files["bundle.tar.gz"] = "" + } else { + maps.Copy(files, tc.files) + } + + test.WithTempFS(files, func(root string) { + p := root + if bundleType.tar { + p = filepath.Join(root, "bundle.tar.gz") + files := make([][2]string, 0, len(tc.files)) + for k, v := range tc.files { + files = append(files, [2]string{k, v}) + } + buf := archive.MustWriteTarGz(files) + bf, err := os.Create(p) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + _, err = bf.Write(buf.Bytes()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + } + + var buf bytes.Buffer + var errBuf bytes.Buffer + + testParams := newTestCommandParams() + testParams.v1Compatible = v1CompatibleFlag.used + testParams.bundleMode = true + testParams.count = 1 + testParams.output = &buf + testParams.errOutput = &errBuf + + exitCode := opaTest([]string{p}, testParams) + if tc.expErr != "" { + if exitCode == 0 { + t.Fatalf("expected non-zero exit code") + } + + if actual := errBuf.String(); !strings.Contains(actual, tc.expErr) { + t.Fatalf("expected error output to contain:\n\n%q\n\nbut got:\n\n%q", tc.expErr, actual) + } + } else { + if exitCode != 0 { + t.Fatalf("unexpected exit code: %d", exitCode) + } + + if errBuf.Len() > 0 { + t.Fatalf("expected no error output but got:\n\n%q", buf.String()) + } + + expected := "PASS: 1/1" + if actual := buf.String(); !strings.Contains(actual, expected) { + t.Fatalf("expected output to contain:\n\n%s\n\nbut got:\n\n%q", expected, actual) + } + } + }) + }) + } + } + } +} + +// Assert that a failing test doesn't cause a panic. +// https://github.com/open-policy-agent/opa/issues/7205 +func TestTestBenchFailingTest(t *testing.T) { + files := map[string]string{ + "test.rego": `package test + test_fail if false`, + } + + test.WithTempFS(files, func(path string) { + fp := filepath.Join(path, "test.rego") + tp := newTestCommandParams() + tp.benchmark = true + tp.count = 1 + + exitCode := opaTest([]string{fp}, tp) + if exitCode == 0 { + t.Fatalf("Expected exit code != 0, got %d", exitCode) + } + }) +} + +func TestTestRunParallel(t *testing.T) { + tests := []struct { + note string + parallel int + }{ + { + note: "default workers", + parallel: 0, + }, + { + note: "1 workers", + parallel: 1, + }, + { + note: "2 workers", + parallel: 2, + }, + { + note: "100 workers", + parallel: 100, + }, + } + + for _, tc := range tests { + testParams := newTestCommandParams() + testParams.parallel = tc.parallel + + files := map[string]string{ + "policy1.rego": `package test +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +}`, + "policy2.rego": `package test +test_l if { + l1 == l2 +}`} + + var exitCode int + test.WithTempFS(files, func(root string) { + exitCode = opaTest([]string{root}, testParams) + }) + + if exitCode > 0 { + t.Fatalf("unexpected exit code: %d", exitCode) + } + } +} + +func TestWithDefaultRegoPlugin(t *testing.T) { + // We're injecting a default rego plugin that always returns true. + // If it's picked as a default (as intended), the tests run here will also + // yield true. If it wasn't picked, we'd use topdown, and would see a failing + // test. + tp := &testPlugin{} + rego.RegisterPlugin(targetPlugin, tp) + t.Cleanup(func() { tp.target = targetPlugin }) + + t.Run("test", func(t *testing.T) { + test.WithTempFS(map[string]string{"test.rego": "package test\ntest_true if false"}, func(path string) { + fp := filepath.Join(path, "test.rego") + tp := newTestCommandParams() + tp.output = io.Discard + tp.count = 1 + + exitCode := opaTest([]string{fp}, tp) + if exitCode != 0 { + t.Fatalf("Expected exit code 0, got %d", exitCode) + } + }) + }) + t.Run("eval", func(t *testing.T) { + params := newEvalCommandParams() + params.fail = true + query := "2+2 = 5" // unification will fail ("2+2 == 5" would be false, but defined) + + defined, err := eval([]string{query}, params, io.Discard, nil) + if err != nil { + t.Fatal("unexpected error", err) + } + if !defined { + t.Errorf("expected defined result") + } + }) + + t.Run("repl", func(t *testing.T) { + ctx := t.Context() + store := inmem.New() + var buffer bytes.Buffer + repl := repl.New(store, "", &buffer, "", 0, "") + if err := repl.OneShot(ctx, "2+2==5"); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + result := buffer.String() + if result != "true\n" { + t.Errorf("Expected result to be false but got: %v", result) + } + }) +} + +func TestFailOnEmpty(t *testing.T) { + testCases := map[string]struct { + Test string + FailOnEmpty bool + ExpectedExitCode int + }{ + "pass when no tests and fail-on-empty disabled": { + Test: `package foo + + p := 1 + `, + FailOnEmpty: false, + ExpectedExitCode: 0, + }, + "fail when no tests and fail-on-empty enabled": { + Test: `package foo + + p := 1 + `, + FailOnEmpty: true, + ExpectedExitCode: 1, + }, + "pass when tests exist and fail-on-empty enabled": { + Test: `package foo + + test_pass if { true } + `, + FailOnEmpty: true, + ExpectedExitCode: 0, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + exitCode := testExitCodeWithFailOnEmpty(tc.Test, tc.FailOnEmpty) + + if exitCode != tc.ExpectedExitCode { + t.Errorf("Expected exit code to be %d but got %d", tc.ExpectedExitCode, exitCode) + } + }) + } +} + +type testPlugin struct { + target string +} + +func (t *testPlugin) IsTarget(tgt string) bool { + return tgt == t.target // t == "" makes it the global default +} + +func (*testPlugin) PrepareForEval(context.Context, *ir.Policy, ...rego.PrepareOption) (rego.TargetPluginEval, error) { + return &testPlugin{}, nil +} + +func (*testPlugin) Eval(context.Context, *rego.EvalContext, ast.Value) (ast.Value, error) { + return ast.NewSet(ast.NewTerm(ast.NewObject([2]*ast.Term{ast.StringTerm("^term1"), ast.BooleanTerm(true)}))), nil +} + +const targetPlugin = "rego_test_default_plugin" + +var durationJSONRe = regexp.MustCompile(`"duration":\s*\d+`) + +func TestOpaTestJSONOutputBytes(t *testing.T) { + files := map[string]string{ + "test.rego": `package test + +test_p if { true } +`, + } + + var stdout bytes.Buffer + var tempDirPath string + test.WithTempFS(files, func(root string) { + tempDirPath = root + testParams := newTestCommandParams() + testParams.count = 1 + testParams.outputFormat = formats.Flag(formats.JSON, formats.Pretty) + testParams.output = &stdout + testParams.errOutput = io.Discard + + if exitCode := opaTest([]string{root}, testParams); exitCode != 0 { + t.Fatalf("unexpected exit code: %d", exitCode) + } + }) + + normalized := durationJSONRe.ReplaceAll(stdout.Bytes(), []byte(`"duration":0`)) + + expectedOutput := strings.ReplaceAll(`[ + { + "location": { + "file": "TEMPDIR/test.rego", + "row": 3, + "col": 1 + }, + "package": "data.test", + "name": "test_p", + "duration":0 + } +] +`, "TEMPDIR", tempDirPath) + + if diff := cmp.Diff(expectedOutput, string(normalized)); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } +} diff --git a/cmd/test_test.go b/cmd/test_test.go index 4199603d14..a481bd7eda 100644 --- a/cmd/test_test.go +++ b/cmd/test_test.go @@ -1,3 +1,5 @@ +//go:build !go1.27 + package cmd import ( @@ -15,6 +17,9 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" + + "github.com/open-policy-agent/opa/cmd/formats" "github.com/open-policy-agent/opa/internal/file/archive" "github.com/open-policy-agent/opa/v1/ast" "github.com/open-policy-agent/opa/v1/bundle" @@ -3865,3 +3870,49 @@ func (*testPlugin) Eval(context.Context, *rego.EvalContext, ast.Value) (ast.Valu } const targetPlugin = "rego_test_default_plugin" + +var durationJSONRe = regexp.MustCompile(`"duration":\s*\d+`) + +func TestOpaTestJSONOutputBytes(t *testing.T) { + files := map[string]string{ + "test.rego": `package test + +test_p if { true } +`, + } + + var stdout bytes.Buffer + var tempDirPath string + test.WithTempFS(files, func(root string) { + tempDirPath = root + testParams := newTestCommandParams() + testParams.count = 1 + testParams.outputFormat = formats.Flag(formats.JSON, formats.Pretty) + testParams.output = &stdout + testParams.errOutput = io.Discard + + if exitCode := opaTest([]string{root}, testParams); exitCode != 0 { + t.Fatalf("unexpected exit code: %d", exitCode) + } + }) + + normalized := durationJSONRe.ReplaceAll(stdout.Bytes(), []byte(`"duration":0`)) + + expectedOutput := strings.ReplaceAll(`[ + { + "location": { + "file": "TEMPDIR/test.rego", + "row": 3, + "col": 1 + }, + "package": "data.test", + "name": "test_p", + "duration":0 + } +] +`, "TEMPDIR", tempDirPath) + + if diff := cmp.Diff(expectedOutput, string(normalized)); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } +} diff --git a/internal/jsonv2/jsonv2.go b/internal/jsonv2/jsonv2.go new file mode 100644 index 0000000000..95ccf9476b --- /dev/null +++ b/internal/jsonv2/jsonv2.go @@ -0,0 +1,79 @@ +// Copyright 2026 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. + +//go:build go1.27 + +package jsonv2 + +// This migration must not change OPA's JSON output, but json.Marshal defaults +// to v2 semantics (no HTML escaping, non-deterministic map key order). So +// every entry point into v2 here must establish v1 options (see +// [jsonv1.DefaultOptionsV1]); nested encodes inherit them from the caller's +// encoder. MarshalMarshalerTo is the only such entry point today, but that's +// incidental — any new json.Marshal, json.MarshalWrite, or jsontext.NewEncoder +// added here must do the same. + +import ( + jsonv1 "encoding/json" + "encoding/json/jsontext" + "encoding/json/v2" + "reflect" +) + +// WriteMarshalerToArray writes the JSON array of items to the encoder. +func WriteMarshalerToArray[T json.MarshalerTo](e *jsontext.Encoder, items []T) error { + e.WriteToken(jsontext.BeginArray) + for _, item := range items { + if err := item.MarshalJSONTo(e); err != nil { + return err + } + } + return e.WriteToken(jsontext.EndArray) +} + +// WriteField writes the object member name and then v's JSON encoding, so that +// the member is written and checked in one statement. A nil v is written as +// JSON null rather than dispatched to MarshalJSONTo: v1's reflection-based +// encoder already does this for a nil pointer, so writing null here keeps +// output identical to v1, rather than panicking on the types whose +// MarshalJSONTo assumes a non-nil receiver. +func WriteField[T json.MarshalerTo](e *jsontext.Encoder, name string, v T) error { + e.WriteToken(jsontext.String(name)) + if rv := reflect.ValueOf(v); rv.Kind() == reflect.Pointer && rv.IsNil() { + return e.WriteToken(jsontext.Null) + } + return v.MarshalJSONTo(e) +} + +// WriteFieldArray writes the object member name and then the JSON array of items. +func WriteFieldArray[T json.MarshalerTo](e *jsontext.Encoder, name string, items []T) error { + e.WriteToken(jsontext.String(name)) + return WriteMarshalerToArray(e, items) +} + +// WriteFieldValue is [WriteField] for values that don't implement [json.MarshalerTo]. +func WriteFieldValue(e *jsontext.Encoder, name string, v any) error { + e.WriteToken(jsontext.String(name)) + return json.MarshalEncode(e, v) +} + +// WriteMarshalerToArrayOrNull is [WriteMarshalerToArray] but writes null for a nil +// slice, as encoding/json v1 does. Types whose pre-1.27 MarshalJSON returns "[]" +// for an empty value must keep using [WriteMarshalerToArray]. +func WriteMarshalerToArrayOrNull[T json.MarshalerTo](e *jsontext.Encoder, items []T) error { + if items == nil { + return e.WriteToken(jsontext.Null) + } + return WriteMarshalerToArray(e, items) +} + +// MarshalMarshalerTo provides a MarshalJSON implementation for any type that +// implements json.MarshalerTo. json.Marshal dispatches to MarshalJSONTo, so this +// doesn't recurse; the constraint is what guarantees that at compile time. +// +// This is the entry point into v2 that establishes v1 options, per the +// package-level comment above. +func MarshalMarshalerTo[T json.MarshalerTo](v T) ([]byte, error) { + return json.Marshal(v, jsonv1.DefaultOptionsV1()) +} diff --git a/internal/jsonv2/jsonv2_test.go b/internal/jsonv2/jsonv2_test.go new file mode 100644 index 0000000000..92aed5dcb0 --- /dev/null +++ b/internal/jsonv2/jsonv2_test.go @@ -0,0 +1,58 @@ +// Copyright 2026 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. + +//go:build go1.27 + +package jsonv2 + +import ( + "bytes" + "encoding/json/jsontext" + "strings" + "testing" +) + +// widget's MarshalJSONTo assumes a non-nil receiver, mirroring the ast +// package's marshalers, to check that WriteField only calls it when v is +// non-nil. +type widget struct { + Name string +} + +func (w *widget) MarshalJSONTo(e *jsontext.Encoder) error { + e.WriteToken(jsontext.BeginObject) + e.WriteToken(jsontext.String("name")) + e.WriteToken(jsontext.String(w.Name)) + return e.WriteToken(jsontext.EndObject) +} + +func TestWriteFieldNilPointer(t *testing.T) { + var buf bytes.Buffer + enc := jsontext.NewEncoder(&buf) + + enc.WriteToken(jsontext.BeginObject) + if err := WriteField(enc, "widget", (*widget)(nil)); err != nil { + t.Fatalf("WriteField with nil pointer panicked or errored: %v", err) + } + enc.WriteToken(jsontext.EndObject) + + if got, want := strings.TrimSpace(buf.String()), `{"widget":null}`; got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestWriteFieldNonNilPointer(t *testing.T) { + var buf bytes.Buffer + enc := jsontext.NewEncoder(&buf) + + enc.WriteToken(jsontext.BeginObject) + if err := WriteField(enc, "widget", &widget{Name: "foo"}); err != nil { + t.Fatalf("WriteField: %v", err) + } + enc.WriteToken(jsontext.EndObject) + + if got, want := strings.TrimSpace(buf.String()), `{"widget":{"name":"foo"}}`; got != want { + t.Fatalf("expected %s, got %s", want, got) + } +} diff --git a/internal/prometheus/prometheus_test.go b/internal/prometheus/prometheus_test.go index 9fbbb6ca93..2f66fbbd81 100644 --- a/internal/prometheus/prometheus_test.go +++ b/internal/prometheus/prometheus_test.go @@ -3,8 +3,10 @@ // license that can be found in the LICENSE file. // // NOTE: Different go runtime metrics in pretty much -// every Go version. Let's only test these on latest. -//go:build go1.26 +// every Go version. The expected metrics below are those of Go 1.26, so pin +// this test to exactly that version: it fails on both older and newer ones. + +//go:build go1.26 && !go1.27 package prometheus diff --git a/internal/semver/semver.go b/internal/semver/semver.go index 725f86318a..d46f80aeb8 100644 --- a/internal/semver/semver.go +++ b/internal/semver/semver.go @@ -101,10 +101,10 @@ func Compare(a, b string) int { return aV.Compare(bV) } -// AppendText appends the textual representation of the version to b and returns the extended buffer. +// AppendString appends the textual representation of the version to b and returns the extended buffer. // This method conforms to the encoding.TextAppender interface, and is useful for serializing the Version // without allocating, provided the caller has pre-allocated sufficient space in b. -func (v Version) AppendText(b []byte) ([]byte, error) { +func (v Version) AppendString(b []byte) ([]byte, error) { if b == nil { b = make([]byte, 0, length(v)) } @@ -126,7 +126,7 @@ func (v Version) AppendText(b []byte) ([]byte, error) { // String returns the string representation of the version. func (v Version) String() string { bs := make([]byte, 0, length(v)) - bs, _ = v.AppendText(bs) + bs, _ = v.AppendString(bs) return string(bs) } diff --git a/internal/semver/semver_test.go b/internal/semver/semver_test.go index fbf0268c89..718390f0ae 100644 --- a/internal/semver/semver_test.go +++ b/internal/semver/semver_test.go @@ -161,18 +161,18 @@ func BenchmarkString(b *testing.B) { } } -func BenchmarkAppendText(b *testing.B) { +func BenchmarkAppendString(b *testing.B) { v := MustParse("1.2.3-alpha.1+build.123") for b.Loop() { - _, err := v.AppendText(nil) + _, err := v.AppendString(nil) if err != nil { b.Fatal(err) } } } -func BenchmarkAppendTextPreAllocated(b *testing.B) { +func BenchmarkAppendStringPreAllocated(b *testing.B) { v, err := Parse("1.2.3-alpha.1+build.123") if err != nil { b.Fatal(err) @@ -181,7 +181,7 @@ func BenchmarkAppendTextPreAllocated(b *testing.B) { buf := make([]byte, 0, 32) for b.Loop() { - if buf, err = v.AppendText(buf); err != nil { + if buf, err = v.AppendString(buf); err != nil { b.Fatal(err) } if string(buf) != "1.2.3-alpha.1+build.123" { diff --git a/v1/ast/annotations.go b/v1/ast/annotations.go index df2c3e9e98..2b6a83eeeb 100644 --- a/v1/ast/annotations.go +++ b/v1/ast/annotations.go @@ -13,7 +13,6 @@ import ( "strings" "github.com/open-policy-agent/opa/internal/deepcopy" - astJSON "github.com/open-policy-agent/opa/v1/ast/json" "github.com/open-policy-agent/opa/v1/util" ) @@ -192,64 +191,6 @@ func (a *Annotations) GetTargetPath() Ref { } } -func (a *Annotations) MarshalJSON() ([]byte, error) { - if a == nil { - return []byte(`{"scope":""}`), nil - } - - data := map[string]any{ - "scope": a.Scope, - } - - if a.Title != "" { - data["title"] = a.Title - } - - if a.Description != "" { - data["description"] = a.Description - } - - if a.Entrypoint { - data["entrypoint"] = a.Entrypoint - } - - if len(a.Organizations) > 0 { - data["organizations"] = a.Organizations - } - - if len(a.RelatedResources) > 0 { - data["related_resources"] = a.RelatedResources - } - - if len(a.Authors) > 0 { - data["authors"] = a.Authors - } - - if len(a.Schemas) > 0 { - data["schemas"] = a.Schemas - } - - if a.Compile != nil { - data["compile"] = a.Compile - } - - if len(a.Custom) > 0 { - data["custom"] = a.Custom - } - - if len(a.Labels) > 0 { - data["labels"] = a.Labels - } - - if astJSON.GetOptions().MarshalOptions.IncludeLocation.Annotations { - if a.Location != nil { - data["location"] = a.Location - } - } - - return json.Marshal(data) -} - func NewAnnotationsRef(a *Annotations) *AnnotationsRef { var loc *Location if a.node != nil { @@ -284,34 +225,6 @@ func (ar *AnnotationsRef) GetRule() *Rule { } } -func (ar *AnnotationsRef) MarshalJSON() ([]byte, error) { - data := map[string]any{ - "path": ar.Path, - } - - if ar.Annotations != nil { - data["annotations"] = ar.Annotations - } - - if astJSON.GetOptions().MarshalOptions.IncludeLocation.AnnotationsRef { - if ar.Location != nil { - data["location"] = ar.Location - } - - // The location set for the schema ref terms is wrong (always set to - // row 1) and not really useful anyway.. so strip it out before marshalling - for _, schema := range ar.Annotations.Schemas { - if schema.Path != nil { - for _, term := range schema.Path { - term.Location = nil - } - } - } - } - - return json.Marshal(data) -} - func scopeCompare(s1, s2 string) int { o1 := scopeOrder(s1) o2 := scopeOrder(s2) @@ -697,18 +610,6 @@ func (rr *RelatedResourceAnnotation) String() string { return string(bs) } -func (rr *RelatedResourceAnnotation) MarshalJSON() ([]byte, error) { - d := map[string]any{ - "ref": rr.Ref.String(), - } - - if len(rr.Description) > 0 { - d["description"] = rr.Description - } - - return json.Marshal(d) -} - // Copy returns a deep copy of s. func (s *SchemaAnnotation) Copy() *SchemaAnnotation { cpy := *s diff --git a/v1/ast/annotations_json.go b/v1/ast/annotations_json.go new file mode 100644 index 0000000000..420a9f8093 --- /dev/null +++ b/v1/ast/annotations_json.go @@ -0,0 +1,124 @@ +//go:build !go1.27 + +package ast + +import ( + "encoding/json" + + astJSON "github.com/open-policy-agent/opa/v1/ast/json" +) + +func (a *Annotations) MarshalJSON() ([]byte, error) { + if a == nil { + return []byte(`{"scope":""}`), nil + } + + data := map[string]any{ + "scope": a.Scope, + } + + if a.Title != "" { + data["title"] = a.Title + } + + if a.Description != "" { + data["description"] = a.Description + } + + if a.Entrypoint { + data["entrypoint"] = a.Entrypoint + } + + if len(a.Organizations) > 0 { + data["organizations"] = a.Organizations + } + + if len(a.RelatedResources) > 0 { + data["related_resources"] = a.RelatedResources + } + + if len(a.Authors) > 0 { + data["authors"] = a.Authors + } + + if len(a.Schemas) > 0 { + data["schemas"] = a.Schemas + } + + if a.Compile != nil { + data["compile"] = a.Compile + } + + if len(a.Custom) > 0 { + data["custom"] = a.Custom + } + + if len(a.Labels) > 0 { + data["labels"] = a.Labels + } + + if astJSON.GetOptions().MarshalOptions.IncludeLocation.Annotations { + if a.Location != nil { + data["location"] = a.Location + } + } + + return json.Marshal(data) +} + +func (rr *RelatedResourceAnnotation) MarshalJSON() ([]byte, error) { + d := map[string]any{ + "ref": rr.Ref.String(), + } + + if len(rr.Description) > 0 { + d["description"] = rr.Description + } + + return json.Marshal(d) +} + +func (ar *AnnotationsRef) MarshalJSON() ([]byte, error) { + data := map[string]any{ + "path": ar.Path, + } + + if ar.Annotations != nil { + data["annotations"] = ar.Annotations + } + + if astJSON.GetOptions().MarshalOptions.IncludeLocation.AnnotationsRef { + if ar.Location != nil { + data["location"] = ar.Location + } + } + + return json.Marshal(data) +} + +// schemaAnnotationJSON mirrors SchemaAnnotation's JSON tags, with location-free +// path terms. +type schemaAnnotationJSON struct { + Path []termJSON `json:"path"` + Schema Ref `json:"schema,omitempty"` + Definition *any `json:"definition,omitempty"` +} + +func (s *SchemaAnnotation) MarshalJSON() ([]byte, error) { + d := schemaAnnotationJSON{ + Schema: s.Schema, + Definition: s.Definition, + } + + if s.Path != nil { + d.Path = make([]termJSON, len(s.Path)) + for i, t := range s.Path { + // The location is omitted: path terms are parsed on their own from + // the annotation's YAML key, so their locations are offsets into that + // key (always row 1) rather than positions in the module. + d.Path[i] = termJSON{Type: ValueName(t.Value), Value: t.Value} + } + } + + return json.Marshal(d) +} diff --git a/v1/ast/annotations_jsonv2.go b/v1/ast/annotations_jsonv2.go new file mode 100644 index 0000000000..72e28caec4 --- /dev/null +++ b/v1/ast/annotations_jsonv2.go @@ -0,0 +1,195 @@ +//go:build go1.27 + +package ast + +import ( + "encoding/json/jsontext" + "encoding/json/v2" + "fmt" + + "github.com/open-policy-agent/opa/internal/jsonv2" + astJSON "github.com/open-policy-agent/opa/v1/ast/json" +) + +// These are exported types, so losing MarshalJSON here would be a breaking +// API change even though callers should go through json.Marshal, not this +// method directly. +var ( + _ json.Marshaler = &Annotations{} + _ json.Marshaler = &AnnotationsRef{} + _ json.Marshaler = &SchemaAnnotation{} + _ json.Marshaler = &RelatedResourceAnnotation{} +) + +func (a *Annotations) MarshalJSONTo(e *jsontext.Encoder) error { + e.WriteToken(jsontext.BeginObject) + + if a == nil { + e.WriteToken(jsontext.String("scope")) + e.WriteToken(jsontext.String("")) + return e.WriteToken(jsontext.EndObject) + } + + if a.Description != "" { + e.WriteToken(jsontext.String("description")) + e.WriteToken(jsontext.String(a.Description)) + } + + if a.Entrypoint { + e.WriteToken(jsontext.String("entrypoint")) + e.WriteToken(jsontext.True) + } + + if len(a.Organizations) > 0 { + if err := jsonv2.WriteFieldValue(e, "organizations", a.Organizations); err != nil { + return err + } + } + + if len(a.RelatedResources) > 0 { + if err := jsonv2.WriteFieldArray(e, "related_resources", a.RelatedResources); err != nil { + return err + } + } + + if len(a.Authors) > 0 { + if err := jsonv2.WriteFieldValue(e, "authors", a.Authors); err != nil { + return err + } + } + + if len(a.Schemas) > 0 { + if err := jsonv2.WriteFieldArray(e, "schemas", a.Schemas); err != nil { + return err + } + } + + if a.Compile != nil { + if err := jsonv2.WriteFieldValue(e, "compile", a.Compile); err != nil { + return err + } + } + + if len(a.Custom) > 0 { + if err := jsonv2.WriteFieldValue(e, "custom", a.Custom); err != nil { + return err + } + } + + if len(a.Labels) > 0 { + if err := jsonv2.WriteFieldValue(e, "labels", a.Labels); err != nil { + return err + } + } + + e.WriteToken(jsontext.String("scope")) + e.WriteToken(jsontext.String(a.Scope)) + + if a.Title != "" { + e.WriteToken(jsontext.String("title")) + e.WriteToken(jsontext.String(a.Title)) + } + + if a.Location != nil && astJSON.GetOptions().MarshalOptions.IncludeLocation.Annotations { + if err := jsonv2.WriteField(e, "location", a.Location); err != nil { + return err + } + } + + return e.WriteToken(jsontext.EndObject) +} + +func (a *Annotations) MarshalJSON() ([]byte, error) { + return jsonv2.MarshalMarshalerTo(a) +} + +func (ar *AnnotationsRef) MarshalJSONTo(e *jsontext.Encoder) error { + e.WriteToken(jsontext.BeginObject) + + if ar.Annotations != nil { + if err := jsonv2.WriteField(e, "annotations", ar.Annotations); err != nil { + return err + } + } + + if ar.Location != nil && astJSON.GetOptions().MarshalOptions.IncludeLocation.AnnotationsRef { + if err := jsonv2.WriteField(e, "location", ar.Location); err != nil { + return err + } + } + + if err := jsonv2.WriteField(e, "path", ar.Path); err != nil { + return err + } + + return e.WriteToken(jsontext.EndObject) +} + +func (ar *AnnotationsRef) MarshalJSON() ([]byte, error) { + return jsonv2.MarshalMarshalerTo(ar) +} + +func (s *SchemaAnnotation) MarshalJSON() ([]byte, error) { + return jsonv2.MarshalMarshalerTo(s) +} + +func (s *SchemaAnnotation) MarshalJSONTo(e *jsontext.Encoder) error { + // Token write errors are unchecked: an unbalanced value fails at the closing + // token. A marshaller can fail having written a balanced value, so is checked. + e.WriteToken(jsontext.BeginObject) + + // Path has no omitempty tag, so it's always written. A nil ref is written + // as null, matching encoding/json v1's treatment of a nil slice. + e.WriteToken(jsontext.String("path")) + if s.Path == nil { + e.WriteToken(jsontext.Null) + } else { + e.WriteToken(jsontext.BeginArray) + for _, t := range s.Path { + // The location is omitted: path terms are parsed on their own from + // the annotation's YAML key, so their locations are offsets into that + // key (always row 1) rather than positions in the module. + e.WriteToken(jsontext.BeginObject) + e.WriteToken(jsontext.String("type")) + e.WriteToken(jsontext.String(ValueName(t.Value))) + e.WriteToken(jsontext.String("value")) + if err := marshalValueTo(e, t.Value); err != nil { + return fmt.Errorf("failed to marshal schema path term of %s: %w", ValueName(t.Value), err) + } + e.WriteToken(jsontext.EndObject) + } + e.WriteToken(jsontext.EndArray) + } + + if len(s.Schema) > 0 { + if err := jsonv2.WriteField(e, "schema", s.Schema); err != nil { + return err + } + } + + if s.Definition != nil { + if err := jsonv2.WriteFieldValue(e, "definition", s.Definition); err != nil { + return err + } + } + + return e.WriteToken(jsontext.EndObject) +} + +func (rr *RelatedResourceAnnotation) MarshalJSON() ([]byte, error) { + return jsonv2.MarshalMarshalerTo(rr) +} + +func (rr *RelatedResourceAnnotation) MarshalJSONTo(e *jsontext.Encoder) error { + e.WriteToken(jsontext.BeginObject) + + e.WriteToken(jsontext.String("ref")) + e.WriteToken(jsontext.String(rr.Ref.String())) + + if len(rr.Description) > 0 { + e.WriteToken(jsontext.String("description")) + e.WriteToken(jsontext.String(rr.Description)) + } + + return e.WriteToken(jsontext.EndObject) +} diff --git a/v1/ast/annotations_test.go b/v1/ast/annotations_test.go index a7160355ab..6e934cdf79 100644 --- a/v1/ast/annotations_test.go +++ b/v1/ast/annotations_test.go @@ -9,6 +9,7 @@ import ( "fmt" "maps" "runtime" + "strings" "testing" "weak" ) @@ -1347,3 +1348,27 @@ allow if true t.Fatal("AnnotationSet was not garbage-collected: mergedLabels cache likely holds a retaining cycle") } } + +func TestAnnotations_StringDeterministic(t *testing.T) { + a := &Annotations{ + Scope: "rule", + Description: "&", + Custom: map[string]any{ + "zeta": 1, "alpha": 2, "mu": 3, "beta": 4, "omega": 5, + }, + } + + exp := a.String() + for i := range 10 { + if got := a.String(); got != exp { + t.Fatalf("String() is not deterministic across calls:\ncall 0: %s\ncall %d: %s", exp, i+1, got) + } + } + + if raw := "&"; strings.Contains(exp, raw) { + t.Fatalf("expected HTML characters to be escaped, but found raw %s in %s", raw, exp) + } + if escaped := `\u003cb\u003e\u0026\u003c/b\u003e`; !strings.Contains(exp, escaped) { + t.Fatalf("expected HTML characters to be escaped as %s, got %s", escaped, exp) + } +} diff --git a/v1/ast/jsonv1_test.go b/v1/ast/jsonv1_test.go new file mode 100644 index 0000000000..d9653d02f2 --- /dev/null +++ b/v1/ast/jsonv1_test.go @@ -0,0 +1,21 @@ +// Copyright 2026 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. + +//go:build !go1.27 + +package ast + +import ( + "bytes" + "testing" +) + +// assertJsonEqual fails the test unless exp and got are byte-for-byte equal. +func assertJsonEqual[A, B string | []byte](t *testing.T, exp A, got B) { + t.Helper() + + if !bytes.Equal([]byte(exp), []byte(got)) { + t.Errorf("expected JSON to be equal:\n%s\n%s", exp, got) + } +} diff --git a/v1/ast/jsonv2_test.go b/v1/ast/jsonv2_test.go new file mode 100644 index 0000000000..20ffa3cb74 --- /dev/null +++ b/v1/ast/jsonv2_test.go @@ -0,0 +1,30 @@ +// Copyright 2026 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. + +//go:build go1.27 + +package ast + +import ( + "bytes" + "encoding/json/jsontext" + "testing" +) + +// assertJsonEqual fails the test unless the canonical JSON encoding of exp +// and got are equal, meaning that they are compared without regard to +// things like whitespace, key order, etc. For more details, see +// [jsontext.Value.Canonicalize]. +func assertJsonEqual[A, B string | []byte](t *testing.T, exp A, got B) { + t.Helper() + + expVal, gotVal := jsontext.Value(exp), jsontext.Value(got) + + expVal.Canonicalize() + gotVal.Canonicalize() + + if !bytes.Equal(expVal, gotVal) { + t.Errorf("expected JSON to be equal:\n%s\n%s", expVal, gotVal) + } +} diff --git a/v1/ast/location/location.go b/v1/ast/location/location.go index 4e3a080bed..e08088cff1 100644 --- a/v1/ast/location/location.go +++ b/v1/ast/location/location.go @@ -3,12 +3,10 @@ package location import ( "bytes" - "encoding/json" "errors" "fmt" "unicode/utf8" - astJSON "github.com/open-policy-agent/opa/v1/ast/json" "github.com/open-policy-agent/opa/v1/util" ) @@ -150,41 +148,3 @@ func (loc *Location) Compare(other *Location) int { } return 0 } - -func (loc *Location) MarshalJSON() ([]byte, error) { - // structs are used here to preserve the field ordering of the original Location struct - jsonOptions := astJSON.GetOptions().MarshalOptions - if jsonOptions.ExcludeLocationFile { - data := struct { - Row int `json:"row"` - Col int `json:"col"` - Text []byte `json:"text,omitempty"` - }{ - Row: loc.Row, - Col: loc.Col, - } - - if jsonOptions.IncludeLocationText { - data.Text = loc.Text - } - - return json.Marshal(data) - } - - data := struct { - File string `json:"file"` - Row int `json:"row"` - Col int `json:"col"` - Text []byte `json:"text,omitempty"` - }{ - Row: loc.Row, - Col: loc.Col, - File: loc.File, - } - - if jsonOptions.IncludeLocationText { - data.Text = loc.Text - } - - return json.Marshal(data) -} diff --git a/v1/ast/location/location_json.go b/v1/ast/location/location_json.go new file mode 100644 index 0000000000..441ea86e46 --- /dev/null +++ b/v1/ast/location/location_json.go @@ -0,0 +1,47 @@ +//go:build !go1.27 + +package location + +import ( + "encoding/json" + + astJSON "github.com/open-policy-agent/opa/v1/ast/json" +) + +func (loc *Location) MarshalJSON() ([]byte, error) { + // structs are used here to preserve the field ordering of the original Location struct + jsonOptions := astJSON.GetOptions().MarshalOptions + if jsonOptions.ExcludeLocationFile { + data := struct { + Row int `json:"row"` + Col int `json:"col"` + Text []byte `json:"text,omitempty"` + }{ + Row: loc.Row, + Col: loc.Col, + } + + if jsonOptions.IncludeLocationText { + data.Text = loc.Text + } + + return json.Marshal(data) + } + + data := struct { + File string `json:"file"` + Row int `json:"row"` + Col int `json:"col"` + Text []byte `json:"text,omitempty"` + }{ + Row: loc.Row, + Col: loc.Col, + File: loc.File, + } + + if jsonOptions.IncludeLocationText { + data.Text = loc.Text + } + + return json.Marshal(data) +} diff --git a/v1/ast/location/location_jsonv2.go b/v1/ast/location/location_jsonv2.go new file mode 100644 index 0000000000..1897538a14 --- /dev/null +++ b/v1/ast/location/location_jsonv2.go @@ -0,0 +1,50 @@ +// Copyright 2026 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. + +//go:build go1.27 + +package location + +import ( + "encoding/base64" + "encoding/json/jsontext" + "encoding/json/v2" + + "github.com/open-policy-agent/opa/internal/jsonv2" + astJSON "github.com/open-policy-agent/opa/v1/ast/json" +) + +// Location is an exported type, so losing MarshalJSON here would be a +// breaking API change even though callers should go through json.Marshal, +// not this method directly. +var _ json.Marshaler = &Location{} + +// MarshalJSON returns the JSON encoding of loc. +func (loc *Location) MarshalJSON() ([]byte, error) { + return jsonv2.MarshalMarshalerTo(loc) +} + +func (loc *Location) MarshalJSONTo(e *jsontext.Encoder) (err error) { + e.WriteToken(jsontext.BeginObject) + + jsonOptions := astJSON.GetOptions().MarshalOptions + if !jsonOptions.ExcludeLocationFile { + e.WriteToken(jsontext.String("file")) + e.WriteToken(jsontext.String(loc.File)) + } + + e.WriteToken(jsontext.String("row")) + e.WriteToken(jsontext.Int(int64(loc.Row))) + e.WriteToken(jsontext.String("col")) + e.WriteToken(jsontext.Int(int64(loc.Col))) + + // NOTE: len check to match the `json:"text,omitempty"` behaviour of the + // pre-go1.27 marshaller. + if jsonOptions.IncludeLocationText && len(loc.Text) > 0 { + e.WriteToken(jsontext.String("text")) + e.WriteToken(jsontext.String(base64.StdEncoding.EncodeToString(loc.Text))) + } + + return e.WriteToken(jsontext.EndObject) +} diff --git a/v1/ast/location/location_test.go b/v1/ast/location/location_test.go index 269894b997..59f7e572f7 100644 --- a/v1/ast/location/location_test.go +++ b/v1/ast/location/location_test.go @@ -126,6 +126,19 @@ func TestLocationMarshal(t *testing.T) { }, exp: `{"file":"file","row":1,"col":1,"text":"dGV4dA=="}`, }, + "including text, but no text present": { + loc: &Location{ + File: "file", + Row: 1, + Col: 1, + }, + options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocationText: true, + }, + }, + exp: `{"file":"file","row":1,"col":1}`, + }, "excluding file": { loc: &Location{ File: "file", @@ -157,6 +170,34 @@ func TestLocationMarshal(t *testing.T) { } } +func TestLocationUnmarshal(t *testing.T) { + // Location has no custom unmarshaller on any Go version: decoding goes + // through the struct tags, which means the ignored ("-") fields are not + // populated and unknown keys are tolerated. + in := `{"file":"p.rego","row":1,"col":2,"text":"dGVzdA==","tabs":[1],"unexpected":true}` + + var loc Location + if err := util.UnmarshalJSON([]byte(in), &loc); err != nil { + t.Fatal(err) + } + + if exp, act := "p.rego", loc.File; exp != act { + t.Errorf("Expected file %q but got %q", exp, act) + } + if exp, act := 1, loc.Row; exp != act { + t.Errorf("Expected row %v but got %v", exp, act) + } + if exp, act := 2, loc.Col; exp != act { + t.Errorf("Expected col %v but got %v", exp, act) + } + if loc.Text != nil { + t.Errorf("Expected no text but got %q", string(loc.Text)) + } + if loc.Tabs != nil { + t.Errorf("Expected no tabs but got %v", loc.Tabs) + } +} + func TestLocationString(t *testing.T) { tests := []struct { loc *Location diff --git a/v1/ast/marshal_jsonv2_test.go b/v1/ast/marshal_jsonv2_test.go new file mode 100644 index 0000000000..0974a5ae41 --- /dev/null +++ b/v1/ast/marshal_jsonv2_test.go @@ -0,0 +1,1627 @@ +// Copyright 2026 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. + +//go:build go1.27 + +package ast + +import ( + "encoding/json/v2" + "strings" + "testing" + + astJSON "github.com/open-policy-agent/opa/v1/ast/json" + "github.com/open-policy-agent/opa/v1/util" +) + +func resetJSONOptions() { + astJSON.SetOptions(astJSON.Defaults()) +} + +func TestGeneric_MarshalWithLocationJSONOptions(t *testing.T) { + testCases := map[string]struct { + Term *Term + Options astJSON.Options + ExpectedJSON string + }{ + "base case, no location options set": { + Term: func() *Term { + v, _ := InterfaceToValue("example") + return &Term{ + Value: v, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + } + }(), + ExpectedJSON: `{"type":"string","value":"example"}`, + }, + "location included, location text excluded": { + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Term: true, + }, + IncludeLocationText: false, + }, + }, + Term: func() *Term { + v, _ := InterfaceToValue("example") + return &Term{ + Value: v, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + } + }(), + ExpectedJSON: `{"location":{"file":"example.rego","row":1,"col":2},"type":"string","value":"example"}`, + }, + "location included, location text also included": { + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Term: true, + }, + IncludeLocationText: true, + }, + }, + Term: func() *Term { + v, _ := InterfaceToValue("example") + t := &Term{ + Value: v, + Location: NewLocation([]byte("things"), "example.rego", 1, 2), + } + return t + }(), + ExpectedJSON: `{"location":{"file":"example.rego","row":1,"col":2,"text":"dGhpbmdz"},"type":"string","value":"example"}`, + }, + "location included, location text included, file excluded": { + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Term: true, + }, + IncludeLocationText: true, + ExcludeLocationFile: true, + }, + }, + Term: func() *Term { + v, _ := InterfaceToValue("example") + t := &Term{ + Value: v, + Location: NewLocation([]byte("things"), "example.rego", 1, 2), + } + return t + }(), + ExpectedJSON: `{"location":{"row":1,"col":2,"text":"dGhpbmdz"},"type":"string","value":"example"}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Term)) + }) + } +} + +func TestTerm_MarshalJSON(t *testing.T) { + testCases := map[string]struct { + Term *Term + Options astJSON.Options + ExpectedJSON string + }{ + "base case": { + Term: func() *Term { + v, _ := InterfaceToValue("example") + return &Term{ + Value: v, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + } + }(), + ExpectedJSON: `{"type":"string","value":"example"}`, + }, + "ref with no parts": { + Term: RefTerm(), + ExpectedJSON: `{"type":"ref","value":null}`, + }, + "location excluded": { + Term: func() *Term { + v, _ := InterfaceToValue("example") + return &Term{ + Value: v, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + } + }(), + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Term: false, + }, + }, + }, + ExpectedJSON: `{"type":"string","value":"example"}`, + }, + "location included": { + Term: func() *Term { + v, _ := InterfaceToValue("example") + return &Term{ + Value: v, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + } + }(), + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Term: true, + }, + }, + }, + ExpectedJSON: `{"location":{"file":"example.rego","row":1,"col":2},"type":"string","value":"example"}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Term)) + }) + } +} + +func TestTerm_UnmarshalJSON(t *testing.T) { + testCases := map[string]struct { + JSON string + ExpectedTerm *Term + }{ + "base case": { + JSON: `{"type":"string","value":"example"}`, + ExpectedTerm: func() *Term { + v, _ := InterfaceToValue("example") + return &Term{ + Value: v, + } + }(), + }, + "location case": { + JSON: `{"location":{"file":"example.rego","row":1,"col":2},"type":"string","value":"example"}`, + ExpectedTerm: func() *Term { + v, _ := InterfaceToValue("example") + return &Term{ + Value: v, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + } + }(), + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + var term Term + err := json.Unmarshal([]byte(data.JSON), &term) + if err != nil { + t.Fatal(err) + } + + if !term.Equal(data.ExpectedTerm) { + t.Fatalf("expected:\n%#v got\n%#v", data.ExpectedTerm, term) + } + if data.ExpectedTerm.Location != nil { + if !term.Location.Equal(data.ExpectedTerm.Location) { + t.Fatalf("expected location:\n%#v got\n%#v", data.ExpectedTerm, term) + } + } + }) + } +} + +func TestPackage_MarshalJSON(t *testing.T) { + testCases := map[string]struct { + Package *Package + Options astJSON.Options + ExpectedJSON string + }{ + "base case": { + Package: &Package{ + Path: EmptyRef(), + }, + ExpectedJSON: `{"path":[]}`, + }, + "location excluded": { + Package: &Package{ + Path: EmptyRef(), + Location: NewLocation([]byte{}, "example.rego", 1, 2), + }, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Package: false, + }, + }, + }, + ExpectedJSON: `{"path":[]}`, + }, + "location included": { + Package: &Package{ + Path: EmptyRef(), + Location: NewLocation([]byte{}, "example.rego", 1, 2), + }, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Package: true, + }, + }, + }, + ExpectedJSON: `{"location":{"file":"example.rego","row":1,"col":2},"path":[]}`, + }, + "location included, but nil": { + Package: &Package{ + Path: EmptyRef(), + }, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Package: true, + }, + }, + }, + ExpectedJSON: `{"path":[]}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Package)) + }) + } +} + +// TestModule_MarshalJSON_PackageScopedAnnotations asserts that package-scoped +// annotations are only emitted in the module's annotations list, and never +// nested under the package object. +func TestModule_MarshalJSON_PackageScopedAnnotations(t *testing.T) { + module := &Module{ + Package: MustParsePackage("package foo"), + Annotations: []*Annotations{{Scope: "package", Title: "pkg"}}, + } + + exp := `{"package":{"path":[{"type":"var","value":"data"},{"type":"string","value":"foo"}]},` + + `"annotations":[{"scope":"package","title":"pkg"}]}` + + assertJsonEqual(t, exp, util.MustMarshalJSON(module)) +} + +// TODO: Comment has inconsistent JSON field names starting with an upper case letter. Comment Location is +// also always included for legacy reasons +func TestComment_MarshalJSON(t *testing.T) { + testCases := map[string]struct { + Comment *Comment + Options astJSON.Options + ExpectedJSON string + }{ + "base case": { + Comment: &Comment{ + Text: []byte("comment"), + }, + ExpectedJSON: `{"Text":"Y29tbWVudA==","Location":null}`, + }, + "location excluded, still included for legacy reasons": { + Comment: &Comment{ + Text: []byte("comment"), + Location: NewLocation([]byte{}, "example.rego", 1, 2), + }, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Comment: false, // ignored + }, + }, + }, + ExpectedJSON: `{"Text":"Y29tbWVudA==","Location":{"file":"example.rego","row":1,"col":2}}`, + }, + "location included": { + Comment: &Comment{ + Text: []byte("comment"), + Location: NewLocation([]byte{}, "example.rego", 1, 2), + }, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Comment: true, // ignored + }, + }, + }, + ExpectedJSON: `{"Text":"Y29tbWVudA==","Location":{"file":"example.rego","row":1,"col":2}}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Comment)) + }) + } +} + +func TestImport_MarshalJSON(t *testing.T) { + testCases := map[string]struct { + Import *Import + Options astJSON.Options + ExpectedJSON string + }{ + "base case": { + Import: func() *Import { + v, _ := InterfaceToValue("example") + term := Term{ + Value: v, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + } + return &Import{Path: &term} + }(), + ExpectedJSON: `{"path":{"type":"string","value":"example"}}`, + }, + "location excluded": { + Import: func() *Import { + v, _ := InterfaceToValue("example") + term := Term{ + Value: v, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + } + return &Import{ + Path: &term, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + } + }(), + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Import: false, + }, + }, + }, + ExpectedJSON: `{"path":{"type":"string","value":"example"}}`, + }, + "location included": { + Import: func() *Import { + v, _ := InterfaceToValue("example") + term := Term{ + Value: v, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + } + return &Import{ + Path: &term, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + } + }(), + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Import: true, + }, + }, + }, + ExpectedJSON: `{"location":{"file":"example.rego","row":1,"col":2},"path":{"type":"string","value":"example"}}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Import)) + }) + } +} + +func TestRule_MarshalJSON(t *testing.T) { + rawModule := ` + package foo + + # comment + + allow if { true } + ` + + module, err := ParseModuleWithOpts("example.rego", rawModule, ParserOptions{AllFutureKeywords: true}) + if err != nil { + t.Fatal(err) + } + + rule := module.Rules[0] + + testCases := map[string]struct { + Rule *Rule + Options astJSON.Options + ExpectedJSON string + }{ + "base case": { + Rule: rule, + ExpectedJSON: `{"body":[{"index":0,"terms":{"type":"boolean","value":true}}],"head":{"name":"allow","value":{"type":"boolean","value":true},"ref":[{"type":"var","value":"allow"}]}}`, + }, + "location excluded": { + Rule: rule, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Rule: false, + }, + }, + }, + ExpectedJSON: `{"body":[{"index":0,"terms":{"type":"boolean","value":true}}],"head":{"name":"allow","value":{"type":"boolean","value":true},"ref":[{"type":"var","value":"allow"}]}}`, + }, + "location included": { + Rule: rule, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Rule: true, + }, + }, + }, + ExpectedJSON: `{"body":[{"index":0,"terms":{"type":"boolean","value":true}}],"head":{"name":"allow","value":{"type":"boolean","value":true},"ref":[{"type":"var","value":"allow"}]},"location":{"file":"example.rego","row":6,"col":2}}`, + }, + "annotations included": { + Rule: func() *Rule { + r := rule.Copy() + r.Annotations = []*Annotations{{ + Scope: "rule", + Title: "My rule", + Entrypoint: true, + Organizations: []string{"org1"}, + Description: "My desc", + Custom: map[string]any{ + "foo": "bar", + }}} + return r + }(), + ExpectedJSON: `{"annotations":[{"custom":{"foo":"bar"},"description":"My desc","entrypoint":true,"organizations":["org1"],"scope":"rule","title":"My rule"}],"body":[{"index":0,"terms":{"type":"boolean","value":true}}],"head":{"name":"allow","value":{"type":"boolean","value":true},"ref":[{"type":"var","value":"allow"}]}}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Rule)) + }) + } +} + +func TestHead_MarshalJSON(t *testing.T) { + rawModule := ` + package foo + + # comment + + allow if { true } + ` + + module, err := ParseModuleWithOpts("example.rego", rawModule, ParserOptions{AllFutureKeywords: true}) + if err != nil { + t.Fatal(err) + } + + head := module.Rules[0].Head + + testCases := map[string]struct { + Head *Head + Options astJSON.Options + ExpectedJSON string + }{ + "base case": { + Head: head.Copy(), + ExpectedJSON: `{"name":"allow","value":{"type":"boolean","value":true},"ref":[{"type":"var","value":"allow"}]}`, + }, + "location excluded": { + Head: head, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Head: false, + }, + }, + }, + ExpectedJSON: `{"name":"allow","value":{"type":"boolean","value":true},"ref":[{"type":"var","value":"allow"}]}`, + }, + "location included": { + Head: head, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Head: true, + }, + }, + }, + ExpectedJSON: `{"name":"allow","value":{"type":"boolean","value":true},"ref":[{"type":"var","value":"allow"}],"location":{"file":"example.rego","row":6,"col":2}}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Head)) + }) + } +} + +func TestRuleHeadRefWithTermLocations_MarshalJSON(t *testing.T) { + policy := `package test + +import rego.v1 + +ref.head[rule].test contains "value" if { + rule := "rule" +}` + + astJSON.SetOptions(astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Head: true, + Term: true, + }, + }, + }) + t.Cleanup(resetJSONOptions) + + module, err := ParseModuleWithOpts("test.rego", policy, ParserOptions{}) + if err != nil { + t.Fatal(err) + } + + bs, err := json.Marshal(module.Rules[0].Head) + if err != nil { + t.Fatal(err) + } + + // Ensure marshalled JSON includes location for any term + expectedJSON := `{"key":{"location":{"file":"test.rego","row":5,"col":30},"type":"string","value":"value"},"ref":[{"location":{"file":"test.rego","row":5,"col":1},"type":"var","value":"ref"},{"location":{"file":"test.rego","row":5,"col":5},"type":"string","value":"head"},{"location":{"file":"test.rego","row":5,"col":10},"type":"var","value":"rule"},{"location":{"file":"test.rego","row":5,"col":16},"type":"string","value":"test"}],"location":{"file":"test.rego","row":5,"col":1}}` + + assertJsonEqual(t, expectedJSON, bs) +} + +func TestExpr_MarshalJSON(t *testing.T) { + rawModule := ` + package foo + + # comment + + allow if { true } + ` + + module, err := ParseModuleWithOpts("example.rego", rawModule, ParserOptions{AllFutureKeywords: true}) + if err != nil { + t.Fatal(err) + } + + expr := module.Rules[0].Body[0] + + testCases := map[string]struct { + Expr *Expr + Options astJSON.Options + ExpectedJSON string + }{ + "base case": { + Expr: expr, + ExpectedJSON: `{"index":0,"terms":{"type":"boolean","value":true}}`, + }, + "nil terms slice": { + Expr: &Expr{Terms: []*Term(nil)}, + ExpectedJSON: `{"index":0,"terms":null}`, + }, + "location excluded": { + Expr: expr, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Expr: false, + }, + }, + }, + ExpectedJSON: `{"index":0,"terms":{"type":"boolean","value":true}}`, + }, + "location included": { + Expr: expr, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Expr: true, + }, + }, + }, + ExpectedJSON: `{"index":0,"location":{"file":"example.rego","row":6,"col":13},"terms":{"type":"boolean","value":true}}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Expr)) + }) + } +} + +func TestExpr_UnmarshalJSON(t *testing.T) { + rawModule := ` + package foo + + # comment + + allow if { true } + ` + + module, err := ParseModuleWithOpts("example.rego", rawModule, ParserOptions{AllFutureKeywords: true}) + if err != nil { + t.Fatal(err) + } + + expr := module.Rules[0].Body[0] + // text is not marshalled to JSON so we just drop it in our examples + expr.Location.Text = nil + + testCases := map[string]struct { + JSON string + ExpectedExpr *Expr + }{ + "base case": { + JSON: `{"index":0,"terms":{"type":"boolean","value":true}}`, + ExpectedExpr: func() *Expr { + e := expr.Copy() + e.Location = nil + return e + }(), + }, + "location case": { + JSON: `{"index":0,"location":{"file":"example.rego","row":6,"col":13},"terms":{"type":"boolean","value":true}}`, + ExpectedExpr: expr, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + var expr Expr + err := json.Unmarshal([]byte(data.JSON), &expr) + if err != nil { + t.Fatal(err) + } + + if !expr.Equal(data.ExpectedExpr) { + t.Fatalf("expected:\n%#v got\n%#v", data.ExpectedExpr, expr) + } + if data.ExpectedExpr.Location != nil { + if !expr.Location.Equal(data.ExpectedExpr.Location) { + t.Fatalf("expected location:\n%#v got\n%#v", data.ExpectedExpr.Location, expr.Location) + } + } + }) + } +} + +func TestCall_MarshalJSON(t *testing.T) { + testCases := map[string]struct { + Call Call + ExpectedJSON string + }{ + "base case": { + Call: Call{VarTerm("eq"), NumberTerm("1")}, + ExpectedJSON: `[{"type":"var","value":"eq"},{"type":"number","value":1}]`, + }, + "nil call": { + Call: Call(nil), + ExpectedJSON: `null`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Call)) + }) + } +} + +func TestSomeDecl_MarshalJSON(t *testing.T) { + v, _ := InterfaceToValue("example") + term := &Term{ + Value: v, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + } + + testCases := map[string]struct { + SomeDecl *SomeDecl + Options astJSON.Options + ExpectedJSON string + }{ + "base case": { + SomeDecl: &SomeDecl{ + Symbols: []*Term{term}, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + }, + ExpectedJSON: `{"symbols":[{"type":"string","value":"example"}]}`, + }, + "nil symbols": { + SomeDecl: &SomeDecl{}, + ExpectedJSON: `{"symbols":null}`, + }, + "location excluded": { + SomeDecl: &SomeDecl{ + Symbols: []*Term{term}, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + }, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{SomeDecl: false}}, + }, + ExpectedJSON: `{"symbols":[{"type":"string","value":"example"}]}`, + }, + "location included": { + SomeDecl: &SomeDecl{ + Symbols: []*Term{term}, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + }, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{SomeDecl: true}}, + }, + ExpectedJSON: `{"location":{"file":"example.rego","row":1,"col":2},"symbols":[{"type":"string","value":"example"}]}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.SomeDecl)) + }) + } +} + +func TestEvery_MarshalJSON(t *testing.T) { + + rawModule := ` +package foo + +allow if { + every e in [1,2,3] { + e == 1 + } +} +` + + module, err := ParseModuleWithOpts("example.rego", rawModule, ParserOptions{AllFutureKeywords: true}) + if err != nil { + t.Fatal(err) + } + + every, ok := module.Rules[0].Body[0].Terms.(*Every) + if !ok { + t.Fatal("expected every term") + } + + testCases := map[string]struct { + Every *Every + Options astJSON.Options + ExpectedJSON string + }{ + "base case": { + Every: every, + ExpectedJSON: `{"body":[{"index":0,"terms":[{"type":"ref","value":[{"type":"var","value":"equal"}]},{"type":"var","value":"e"},{"type":"number","value":1}]}],"domain":{"type":"array","value":[{"type":"number","value":1},{"type":"number","value":2},{"type":"number","value":3}]},"key":null,"value":{"type":"var","value":"e"}}`, + }, + "location excluded": { + Every: every, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{Every: false}}, + }, + ExpectedJSON: `{"body":[{"index":0,"terms":[{"type":"ref","value":[{"type":"var","value":"equal"}]},{"type":"var","value":"e"},{"type":"number","value":1}]}],"domain":{"type":"array","value":[{"type":"number","value":1},{"type":"number","value":2},{"type":"number","value":3}]},"key":null,"value":{"type":"var","value":"e"}}`, + }, + "location included": { + Every: every, + Options: astJSON.Options{MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{Every: true}}}, + ExpectedJSON: `{"body":[{"index":0,"terms":[{"type":"ref","value":[{"type":"var","value":"equal"}]},{"type":"var","value":"e"},{"type":"number","value":1}]}],"domain":{"type":"array","value":[{"type":"number","value":1},{"type":"number","value":2},{"type":"number","value":3}]},"key":null,"location":{"file":"example.rego","row":5,"col":2},"value":{"type":"var","value":"e"}}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Every)) + }) + } +} + +func TestWith_MarshalJSON(t *testing.T) { + + rawModule := ` +package foo + +a if {input} + +b if { + a with input as 1 +} +` + + module, err := ParseModuleWithOpts("example.rego", rawModule, ParserOptions{AllFutureKeywords: true}) + if err != nil { + t.Fatal(err) + } + + with := module.Rules[1].Body[0].With[0] + + testCases := map[string]struct { + Options astJSON.Options + ExpectedJSON string + }{ + "base case": { + ExpectedJSON: `{"target":{"type":"ref","value":[{"type":"var","value":"input"}]},"value":{"type":"number","value":1}}`, + }, + "location excluded": { + Options: astJSON.Options{MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{With: false}}}, + ExpectedJSON: `{"target":{"type":"ref","value":[{"type":"var","value":"input"}]},"value":{"type":"number","value":1}}`, + }, + "location included": { + Options: astJSON.Options{MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{With: true}}}, + ExpectedJSON: `{"location":{"file":"example.rego","row":7,"col":4},"target":{"type":"ref","value":[{"type":"var","value":"input"}]},"value":{"type":"number","value":1}}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(with)) + }) + } +} + +func TestAnnotations_MarshalJSON(t *testing.T) { + testCases := map[string]struct { + Annotations *Annotations + Options astJSON.Options + ExpectedJSON string + }{ + "base case": { + Annotations: &Annotations{ + Scope: "rule", + Title: "My rule", + Entrypoint: true, + Organizations: []string{"org1"}, + Description: "My desc", + Custom: map[string]any{ + "foo": "bar", + }, + Location: NewLocation([]byte{}, "example.rego", 1, 4), + }, + ExpectedJSON: `{"custom":{"foo":"bar"},"description":"My desc","entrypoint":true,"organizations":["org1"],"scope":"rule","title":"My rule"}`, + }, + "location excluded": { + Annotations: &Annotations{ + Scope: "rule", + Title: "My rule", + Entrypoint: true, + Organizations: []string{"org1"}, + Description: "My desc", + Custom: map[string]any{ + "foo": "bar", + }, + Location: NewLocation([]byte{}, "example.rego", 1, 4), + }, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{Annotations: false}, + }, + }, + ExpectedJSON: `{"custom":{"foo":"bar"},"description":"My desc","entrypoint":true,"organizations":["org1"],"scope":"rule","title":"My rule"}`, + }, + "location included": { + Annotations: &Annotations{ + Scope: "rule", + Title: "My rule", + Entrypoint: true, + Organizations: []string{"org1"}, + Description: "My desc", + Custom: map[string]any{ + "foo": "bar", + }, + Location: NewLocation([]byte{}, "example.rego", 1, 4), + }, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{Annotations: true}, + }, + }, + ExpectedJSON: `{"custom":{"foo":"bar"},"description":"My desc","entrypoint":true,"location":{"file":"example.rego","row":1,"col":4},"organizations":["org1"],"scope":"rule","title":"My rule"}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Annotations)) + }) + } +} + +func TestAnnotations_MarshalJSON_Compile(t *testing.T) { + // Regression: Annotations.MarshalJSON used to silently drop the + // `Compile` field even though the struct tag is `compile,omitempty`. + // Default-reflection unmarshal still reads `compile`, so the round-trip + // was asymmetric until this was fixed. + a := &Annotations{ + Scope: "rule", + Compile: &CompileAnnotation{ + Unknowns: []Ref{MustParseRef("input.x"), MustParseRef("input.y")}, + MaskRule: MustParseRef("data.policy.mask"), + }, + } + + bs, err := json.Marshal(a) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got map[string]any + if err := json.Unmarshal(bs, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + compile, ok := got["compile"].(map[string]any) + if !ok { + t.Fatalf("expected `compile` key in marshaled output, got: %s", bs) + } + if _, ok := compile["unknowns"].([]any); !ok { + t.Errorf("expected compile.unknowns to be a JSON array, got: %v", compile["unknowns"]) + } + if _, ok := compile["mask_rule"].([]any); !ok { + t.Errorf("expected compile.mask_rule to be a JSON array (Ref), got: %v", compile["mask_rule"]) + } + + // nil Compile should not emit the key (`omitempty` semantics). + a.Compile = nil + bs, err = json.Marshal(a) + if err != nil { + t.Fatalf("marshal nil compile: %v", err) + } + if strings.Contains(string(bs), "compile") { + t.Errorf("expected nil Compile to be omitted, got: %s", bs) + } +} + +func TestAnnotationsRef_MarshalJSON(t *testing.T) { + testCases := map[string]struct { + AnnotationsRef *AnnotationsRef + Options astJSON.Options + ExpectedJSON string + }{ + "base case": { + AnnotationsRef: &AnnotationsRef{ + Path: []*Term{}, + // using an empty annotations object here since Annotations marshalling is tested separately + Annotations: &Annotations{}, + Location: NewLocation([]byte{}, "example.rego", 1, 4), + }, + ExpectedJSON: `{"annotations":{"scope":""},"path":[]}`, + }, + "location excluded": { + AnnotationsRef: &AnnotationsRef{ + Path: []*Term{}, + Annotations: &Annotations{}, + Location: NewLocation([]byte{}, "example.rego", 1, 4), + }, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{AnnotationsRef: false}, + }, + }, + ExpectedJSON: `{"annotations":{"scope":""},"path":[]}`, + }, + "location included": { + AnnotationsRef: &AnnotationsRef{ + Path: []*Term{}, + Annotations: &Annotations{}, + Location: NewLocation([]byte{}, "example.rego", 1, 4), + }, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{AnnotationsRef: true}, + }, + }, + ExpectedJSON: `{"annotations":{"scope":""},"location":{"file":"example.rego","row":1,"col":4},"path":[]}`, + }, + "no annotations, location included": { + AnnotationsRef: &AnnotationsRef{ + Path: []*Term{}, + Location: NewLocation([]byte{}, "example.rego", 1, 4), + }, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{AnnotationsRef: true}, + }, + }, + ExpectedJSON: `{"location":{"file":"example.rego","row":1,"col":4},"path":[]}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.AnnotationsRef)) + }) + } +} + +func TestNewAnnotationsRef_JSONOptions(t *testing.T) { + tests := []struct { + note string + module string + expected []string + options ParserOptions + jsonOptions astJSON.Options + }{ + { + note: "all JSON marshaller options set to true", + module: `# METADATA +# title: pkg +# description: pkg +# organizations: +# - pkg +# related_resources: +# - https://pkg +# authors: +# - pkg +# schemas: +# - input.foo: {"type": "boolean"} +# custom: +# pkg: pkg +package test + +# METADATA +# scope: document +# title: doc +# description: doc +# organizations: +# - doc +# related_resources: +# - https://doc +# authors: +# - doc +# schemas: +# - input.bar: {"type": "integer"} +# custom: +# doc: doc + +# METADATA +# title: rule +# description: rule +# organizations: +# - rule +# related_resources: +# - https://rule +# authors: +# - rule +# schemas: +# - input.baz: {"type": "string"} +# custom: +# rule: rule +p = 1`, + options: ParserOptions{ + ProcessAnnotation: true, + }, + jsonOptions: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Term: true, + Package: true, + Comment: true, + Import: true, + Rule: true, + Head: true, + Expr: true, + SomeDecl: true, + Every: true, + With: true, + Annotations: true, + AnnotationsRef: true, + }, + }, + }, + expected: []string{ + `{"annotations":{"authors":[{"name":"pkg"}],"custom":{"pkg":"pkg"},"description":"pkg","location":{"file":"","row":1,"col":1},"organizations":["pkg"],"related_resources":[{"ref":"https://pkg"}],"schemas":[{"path":[{"type":"var","value":"input"},{"type":"string","value":"foo"}],"definition":{"type":"boolean"}}],"scope":"package","title":"pkg"},"location":{"file":"","row":14,"col":1},"path":[{"location":{"file":"","row":14,"col":9},"type":"var","value":"data"},{"location":{"file":"","row":14,"col":9},"type":"string","value":"test"}]}`, + `{"annotations":{"authors":[{"name":"doc"}],"custom":{"doc":"doc"},"description":"doc","location":{"file":"","row":16,"col":1},"organizations":["doc"],"related_resources":[{"ref":"https://doc"}],"schemas":[{"path":[{"type":"var","value":"input"},{"type":"string","value":"bar"}],"definition":{"type":"integer"}}],"scope":"document","title":"doc"},"location":{"file":"","row":44,"col":1},"path":[{"location":{"file":"","row":14,"col":9},"type":"var","value":"data"},{"location":{"file":"","row":14,"col":9},"type":"string","value":"test"},{"location":{"file":"","row":44,"col":1},"type":"string","value":"p"}]}`, + `{"annotations":{"authors":[{"name":"rule"}],"custom":{"rule":"rule"},"description":"rule","location":{"file":"","row":31,"col":1},"organizations":["rule"],"related_resources":[{"ref":"https://rule"}],"schemas":[{"path":[{"type":"var","value":"input"},{"type":"string","value":"baz"}],"definition":{"type":"string"}}],"scope":"rule","title":"rule"},"location":{"file":"","row":44,"col":1},"path":[{"location":{"file":"","row":14,"col":9},"type":"var","value":"data"},{"location":{"file":"","row":14,"col":9},"type":"string","value":"test"},{"location":{"file":"","row":44,"col":1},"type":"string","value":"p"}]}`, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + astJSON.SetOptions(tc.jsonOptions) + t.Cleanup(resetJSONOptions) + + module := MustParseModuleWithOpts(tc.module, tc.options) + + if len(tc.expected) != len(module.Annotations) { + t.Fatalf("expected %d annotations got %d", len(tc.expected), len(module.Annotations)) + } + + for i, a := range module.Annotations { + assertJsonEqual(t, + tc.expected[i], + util.MustMarshalJSON(NewAnnotationsRef(a)), + ) + } + + }) + } +} + +func TestNot_MarshalJSON(t *testing.T) { + rawModule := ` + package test + + import future.keywords.not + + implicit_body if { + not input.x + 2 == 42 + } + + explicit_body if { + not { + x := input.x + y := 2 + z := x + y + z == 42 + } + } + ` + + module, err := ParseModule("example.rego", rawModule) + if err != nil { + t.Fatal(err) + } + + testCases := map[string]struct { + Not *Not + Options astJSON.Options + ExpectedJSON string + }{ + "implicit body: base case": { + Not: module.Rules[0].Body[0].Terms.(*Not), + ExpectedJSON: `{"body":[{"index":0,"terms":[{"type":"ref","value":[{"type":"var","value":"equal"}]},{"type":"call","value":[{"type":"ref","value":[{"type":"var","value":"plus"}]},{"type":"ref","value":[{"type":"var","value":"input"},{"type":"string","value":"x"}]},{"type":"number","value":2}]},{"type":"number","value":42}]}],"explicit_body":false,"type":"not"}`, + }, + "explicit body: base case": { + Not: module.Rules[1].Body[0].Terms.(*Not), + ExpectedJSON: `{"body":[{"index":0,"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"x"},{"type":"ref","value":[{"type":"var","value":"input"},{"type":"string","value":"x"}]}]},{"index":1,"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"y"},{"type":"number","value":2}]},{"index":2,"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"z"},{"type":"call","value":[{"type":"ref","value":[{"type":"var","value":"plus"}]},{"type":"var","value":"x"},{"type":"var","value":"y"}]}]},{"index":3,"terms":[{"type":"ref","value":[{"type":"var","value":"equal"}]},{"type":"var","value":"z"},{"type":"number","value":42}]}],"explicit_body":true,"type":"not"}`, + }, + "implicit body: location excluded": { + Not: module.Rules[0].Body[0].Terms.(*Not), + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{Not: false}}, + }, + ExpectedJSON: `{"body":[{"index":0,"terms":[{"type":"ref","value":[{"type":"var","value":"equal"}]},{"type":"call","value":[{"type":"ref","value":[{"type":"var","value":"plus"}]},{"type":"ref","value":[{"type":"var","value":"input"},{"type":"string","value":"x"}]},{"type":"number","value":2}]},{"type":"number","value":42}]}],"explicit_body":false,"type":"not"}`, + }, + "explicit body: location excluded": { + Not: module.Rules[1].Body[0].Terms.(*Not), + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{Not: false}}, + }, + ExpectedJSON: `{"body":[{"index":0,"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"x"},{"type":"ref","value":[{"type":"var","value":"input"},{"type":"string","value":"x"}]}]},{"index":1,"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"y"},{"type":"number","value":2}]},{"index":2,"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"z"},{"type":"call","value":[{"type":"ref","value":[{"type":"var","value":"plus"}]},{"type":"var","value":"x"},{"type":"var","value":"y"}]}]},{"index":3,"terms":[{"type":"ref","value":[{"type":"var","value":"equal"}]},{"type":"var","value":"z"},{"type":"number","value":42}]}],"explicit_body":true,"type":"not"}`, + }, + "implicit body: location included": { + Not: module.Rules[0].Body[0].Terms.(*Not), + Options: astJSON.Options{MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{Not: true}}}, + ExpectedJSON: `{"body":[{"index":0,"terms":[{"type":"ref","value":[{"type":"var","value":"equal"}]},{"type":"call","value":[{"type":"ref","value":[{"type":"var","value":"plus"}]},{"type":"ref","value":[{"type":"var","value":"input"},{"type":"string","value":"x"}]},{"type":"number","value":2}]},{"type":"number","value":42}]}],"explicit_body":false,"location":{"file":"example.rego","row":7,"col":4},"type":"not"}`, + }, + "explicit body: location included": { + Not: module.Rules[1].Body[0].Terms.(*Not), + Options: astJSON.Options{MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{Not: true}}}, + ExpectedJSON: `{"body":[{"index":0,"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"x"},{"type":"ref","value":[{"type":"var","value":"input"},{"type":"string","value":"x"}]}]},{"index":1,"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"y"},{"type":"number","value":2}]},{"index":2,"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"z"},{"type":"call","value":[{"type":"ref","value":[{"type":"var","value":"plus"}]},{"type":"var","value":"x"},{"type":"var","value":"y"}]}]},{"index":3,"terms":[{"type":"ref","value":[{"type":"var","value":"equal"}]},{"type":"var","value":"z"},{"type":"number","value":42}]}],"explicit_body":true,"location":{"file":"example.rego","row":11,"col":4},"type":"not"}`, + }, + "explicit body: location included, also for nested expressions": { + Not: module.Rules[1].Body[0].Terms.(*Not), + Options: astJSON.Options{MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{Not: true, Expr: true}}}, + ExpectedJSON: `{"body":[{"index":0,"location":{"file":"example.rego","row":12,"col":5},"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"x"},{"type":"ref","value":[{"type":"var","value":"input"},{"type":"string","value":"x"}]}]},{"index":1,"location":{"file":"example.rego","row":13,"col":5},"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"y"},{"type":"number","value":2}]},{"index":2,"location":{"file":"example.rego","row":14,"col":5},"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"z"},{"type":"call","value":[{"type":"ref","value":[{"type":"var","value":"plus"}]},{"type":"var","value":"x"},{"type":"var","value":"y"}]}]},{"index":3,"location":{"file":"example.rego","row":15,"col":5},"terms":[{"type":"ref","value":[{"type":"var","value":"equal"}]},{"type":"var","value":"z"},{"type":"number","value":42}]}],"explicit_body":true,"location":{"file":"example.rego","row":11,"col":4},"type":"not"}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Not)) + }) + } +} + +func TestNot_UnmarshalJSON(t *testing.T) { + rawModule := ` + package test + + import future.keywords.not + + implicit_body if { + not input.x + 2 == 42 + } + + explicit_body if { + not { + x := input.x + y := 2 + z := x + y + z == 42 + } + } + ` + + module, err := ParseModule("example.rego", rawModule) + if err != nil { + t.Fatal(err) + } + + implicitBodyExpr := module.Rules[0].Body[0] + // text is not marshalled to JSON so we just drop it in our examples + implicitBodyExpr.Location.Text = nil + + explicitBodyExpr := module.Rules[1].Body[0] + explicitBodyExpr.Location.Text = nil + + testCases := map[string]struct { + JSON string + ExpectedExpr *Expr + }{ + "implicit body": { + JSON: `{"index":0,"terms":{"type":"not","body":[{"index":0,"terms":[{"type":"ref","value":[{"type":"var","value":"equal"}]},{"type":"call","value":[{"type":"ref","value":[{"type":"var","value":"plus"}]},{"type":"ref","value":[{"type":"var","value":"input"},{"type":"string","value":"x"}]},{"type":"number","value":2}]},{"type":"number","value":42}]}],"explicit_body":false}}`, + ExpectedExpr: func() *Expr { + e := implicitBodyExpr.Copy() + e.Location = nil + return e + }(), + }, + "explicit body": { + JSON: `{"index":0,"terms":{"body":[{"index":0,"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"x"},{"type":"ref","value":[{"type":"var","value":"input"},{"type":"string","value":"x"}]}]},{"index":1,"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"y"},{"type":"number","value":2}]},{"index":2,"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"z"},{"type":"call","value":[{"type":"ref","value":[{"type":"var","value":"plus"}]},{"type":"var","value":"x"},{"type":"var","value":"y"}]}]},{"index":3,"terms":[{"type":"ref","value":[{"type":"var","value":"equal"}]},{"type":"var","value":"z"},{"type":"number","value":42}]}],"explicit_body":true,"type":"not"}}`, + ExpectedExpr: func() *Expr { + e := explicitBodyExpr.Copy() + e.Location = nil + return e + }(), + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + var expr Expr + err := json.Unmarshal([]byte(data.JSON), &expr) + if err != nil { + t.Fatal(err) + } + + if !expr.Equal(data.ExpectedExpr) { + t.Fatalf("expected:\n%#v got\n%#v", data.ExpectedExpr, expr) + } + if data.ExpectedExpr.Location != nil { + if !expr.Location.Equal(data.ExpectedExpr.Location) { + t.Fatalf("expected location:\n%#v got\n%#v", data.ExpectedExpr.Location, expr.Location) + } + } + }) + } +} + +func TestNot_MarshalUnmarshalRoundTrip(t *testing.T) { + rawModule := ` + package test + + import future.keywords.not + + implicit_body if { + not input.x + 2 == 42 + } + + explicit_body if { + not { + x := input.x + y := 2 + z := x + y + z == 42 + } + } + ` + + module, err := ParseModule("example.rego", rawModule) + if err != nil { + t.Fatal(err) + } + + testCases := map[string]struct { + Expr *Expr + }{ + "implicit body": { + Expr: module.Rules[0].Body[0], + }, + "explicit body": { + Expr: module.Rules[1].Body[0], + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + bs := util.MustMarshalJSON(data.Expr) + + var expr Expr + err := json.Unmarshal(bs, &expr) + if err != nil { + t.Fatalf("unmarshal failed: %v\njson: %s", err, string(bs)) + } + + if !expr.Equal(data.Expr) { + t.Fatalf("round-trip mismatch\noriginal: %#v\ngot: %#v", data.Expr, &expr) + } + }) + } +} + +func TestNot_UnmarshalJSON_Errors(t *testing.T) { + testCases := map[string]struct { + JSON string + expErr string + }{ + "body is not an array": { + JSON: `{"index":0,"terms":{"type":"not","body":"invalid","explicit_body":false}}`, + expErr: "invalid body field type", + }, + "body is missing": { + JSON: `{"index":0,"terms":{"type":"not","explicit_body":false}}`, + expErr: "invalid body field type", + }, + "explicit_body is not a bool": { + JSON: `{"index":0,"terms":{"type":"not","body":[],"explicit_body":"yes"}}`, + expErr: "unable to unmarshal explicit_body field", + }, + "body contains invalid expression": { + JSON: `{"index":0,"terms":{"type":"not","body":[{"index":0,"terms":"bad"}],"explicit_body":false}}`, + expErr: "unable to unmarshal not body", + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + var expr Expr + err := json.Unmarshal([]byte(data.JSON), &expr) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), data.expErr) { + t.Fatalf("expected error containing %q, got: %v", data.expErr, err) + } + }) + } +} + +func TestArgs_MarshalJSON(t *testing.T) { + x := VarTerm("x").SetLocation(NewLocation([]byte("x"), "example.rego", 1, 2)) + + testCases := map[string]struct { + Args Args + Options astJSON.Options + ExpectedJSON string + }{ + "nil": { + Args: nil, + ExpectedJSON: `null`, + }, + "empty": { + Args: Args{}, + ExpectedJSON: `[]`, + }, + "base case": { + Args: Args{x, VarTerm("y")}, + ExpectedJSON: `[{"type":"var","value":"x"},{"type":"var","value":"y"}]`, + }, + "term location included": { + Args: Args{x}, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{Term: true}}, + }, + ExpectedJSON: `[{"location":{"file":"example.rego","row":1,"col":2},"type":"var","value":"x"}]`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Args)) + }) + } +} + +func TestVar_MarshalJSON(t *testing.T) { + testCases := map[string]struct { + Var Var + ExpectedJSON string + }{ + "base case": { + Var: Var("x"), + ExpectedJSON: `"x"`, + }, + "empty": { + Var: Var(""), + ExpectedJSON: `""`, + }, + "wildcard": { + Var: Var("$01"), + ExpectedJSON: `"$01"`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Var)) + }) + } +} + +func TestAuthorAnnotation_MarshalJSON(t *testing.T) { + testCases := map[string]struct { + Author *AuthorAnnotation + ExpectedJSON string + }{ + "base case": { + Author: &AuthorAnnotation{Name: "John Doe", Email: "john@example.com"}, + ExpectedJSON: `{"name":"John Doe","email":"john@example.com"}`, + }, + "no email": { + Author: &AuthorAnnotation{Name: "John Doe"}, + ExpectedJSON: `{"name":"John Doe"}`, + }, + "empty": { + Author: &AuthorAnnotation{}, + ExpectedJSON: `{"name":""}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Author)) + }) + } +} + +func TestSchemaAnnotation_MarshalJSON(t *testing.T) { + loc := NewLocation([]byte("input"), "example.rego", 1, 2) + path := Ref{VarTerm("input").SetLocation(loc), StringTerm("foo").SetLocation(loc)} + definition := any(map[string]any{"type": "boolean"}) + + testCases := map[string]struct { + Schema *SchemaAnnotation + Options astJSON.Options + ExpectedJSON string + }{ + "empty": { + Schema: &SchemaAnnotation{}, + ExpectedJSON: `{"path":null}`, + }, + "path and schema": { + Schema: &SchemaAnnotation{Path: path, Schema: MustParseRef("schema.foo")}, + ExpectedJSON: `{"path":[{"type":"var","value":"input"},{"type":"string","value":"foo"}],"schema":[{"type":"var","value":"schema"},{"type":"string","value":"foo"}]}`, + }, + "path and definition": { + Schema: &SchemaAnnotation{Path: path, Definition: &definition}, + ExpectedJSON: `{"path":[{"type":"var","value":"input"},{"type":"string","value":"foo"}],"definition":{"type":"boolean"}}`, + }, + "term location included": { + Schema: &SchemaAnnotation{Path: path}, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{Term: true}}, + }, + ExpectedJSON: `{"path":[{"type":"var","value":"input"},{"type":"string","value":"foo"}]}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Schema)) + }) + } + + t.Run("path terms are not mutated", func(t *testing.T) { + astJSON.SetOptions(astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{Term: true, AnnotationsRef: true}}, + }) + t.Cleanup(resetJSONOptions) + + s := &SchemaAnnotation{Path: Ref{VarTerm("input").SetLocation(loc)}} + util.MustMarshalJSON(s) + + if s.Path[0].Location != loc { + t.Fatalf("expected path term location to be left alone, got %v", s.Path[0].Location) + } + }) +} + +func TestModule_UnmarshalJSON(t *testing.T) { + mod := MustParseModule(`package test + +p if { q } +q := 1 +r := 2 if { input.x } else := 3 if { input.y } +`) + + bs := util.MustMarshalJSON(mod) + + var roundtrip Module + if err := util.UnmarshalJSON(bs, &roundtrip); err != nil { + t.Fatal(err) + } + + if exp, got := len(mod.Rules), len(roundtrip.Rules); exp != got { + t.Fatalf("expected %d rules, got %d", exp, got) + } + + WalkRules(&roundtrip, func(rule *Rule) bool { + if rule.Module != &roundtrip { + t.Errorf("rule %v: expected module pointer to be set, got %v", rule.Head, rule.Module) + } + return false + }) +} + +func TestTemplateString_MarshalJSON(t *testing.T) { + testCases := map[string]struct { + TemplateString *TemplateString + ExpectedJSON string + }{ + "nil parts": { + TemplateString: &TemplateString{}, + ExpectedJSON: `{"parts":null,"multi_line":false}`, + }, + "empty parts": { + TemplateString: &TemplateString{Parts: []Node{}}, + ExpectedJSON: `{"parts":[],"multi_line":false}`, + }, + "base case": { + TemplateString: &TemplateString{Parts: []Node{StringTerm("foo"), VarTerm("x")}, MultiLine: true}, + ExpectedJSON: `{"parts":[{"type":"string","value":"foo"},{"type":"var","value":"x"}],"multi_line":true}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.TemplateString)) + }) + } +} + +func TestSchemaAnnotation_MarshalJSON_InvalidDefinition(t *testing.T) { + definition := any(func() {}) + + _, err := json.Marshal(&SchemaAnnotation{Path: MustParseRef("input.x"), Definition: &definition}) + if err == nil { + t.Fatal("expected error") + } + // The encoder's wording differs between encoding/json v1 and v2. + if exp := "func()"; !strings.Contains(err.Error(), exp) { + t.Fatalf("expected error containing %q, got: %v", exp, err) + } +} diff --git a/v1/ast/marshal_test.go b/v1/ast/marshal_test.go index e6022c99e3..84248cd40d 100644 --- a/v1/ast/marshal_test.go +++ b/v1/ast/marshal_test.go @@ -1,3 +1,5 @@ +//go:build !go1.27 + package ast import ( @@ -121,6 +123,10 @@ func TestTerm_MarshalJSON(t *testing.T) { }(), ExpectedJSON: `{"type":"string","value":"example"}`, }, + "ref with no parts": { + Term: RefTerm(), + ExpectedJSON: `{"type":"ref","value":null}`, + }, "location excluded": { Term: func() *Term { v, _ := InterfaceToValue("example") @@ -259,6 +265,19 @@ func TestPackage_MarshalJSON(t *testing.T) { }, ExpectedJSON: `{"location":{"file":"example.rego","row":1,"col":2},"path":[]}`, }, + "location included, but nil": { + Package: &Package{ + Path: EmptyRef(), + }, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Package: true, + }, + }, + }, + ExpectedJSON: `{"path":[]}`, + }, } for name, data := range testCases { @@ -277,6 +296,23 @@ func TestPackage_MarshalJSON(t *testing.T) { } } +// TestModule_MarshalJSON_PackageScopedAnnotations asserts that package-scoped +// annotations are only emitted in the module's annotations list, and never +// nested under the package object. +func TestModule_MarshalJSON_PackageScopedAnnotations(t *testing.T) { + module := &Module{ + Package: MustParsePackage("package foo"), + Annotations: []*Annotations{{Scope: "package", Title: "pkg"}}, + } + + exp := `{"package":{"path":[{"type":"var","value":"data"},{"type":"string","value":"foo"}]},` + + `"annotations":[{"scope":"package","title":"pkg"}]}` + + if got := string(util.MustMarshalJSON(module)); got != exp { + t.Fatalf("expected:\n%s got\n%s", exp, got) + } +} + // TODO: Comment has inconsistent JSON field names starting with an upper case letter. Comment Location is // also always included for legacy reasons func TestComment_MarshalJSON(t *testing.T) { @@ -622,6 +658,10 @@ func TestExpr_MarshalJSON(t *testing.T) { Expr: expr, ExpectedJSON: `{"index":0,"terms":{"type":"boolean","value":true}}`, }, + "nil terms slice": { + Expr: &Expr{Terms: []*Term(nil)}, + ExpectedJSON: `{"index":0,"terms":null}`, + }, "location excluded": { Expr: expr, Options: astJSON.Options{ @@ -718,6 +758,34 @@ func TestExpr_UnmarshalJSON(t *testing.T) { } } +func TestCall_MarshalJSON(t *testing.T) { + testCases := map[string]struct { + Call Call + ExpectedJSON string + }{ + "base case": { + Call: Call{VarTerm("eq"), NumberTerm("1")}, + ExpectedJSON: `[{"type":"var","value":"eq"},{"type":"number","value":1}]`, + }, + "nil call": { + Call: Call(nil), + ExpectedJSON: `null`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + bs := util.MustMarshalJSON(data.Call) + got := string(bs) + exp := data.ExpectedJSON + + if got != exp { + t.Fatalf("expected:\n%s got\n%s", exp, got) + } + }) + } +} + func TestSomeDecl_MarshalJSON(t *testing.T) { v, _ := InterfaceToValue("example") term := &Term{ @@ -737,6 +805,10 @@ func TestSomeDecl_MarshalJSON(t *testing.T) { }, ExpectedJSON: `{"symbols":[{"type":"string","value":"example"}]}`, }, + "nil symbols": { + SomeDecl: &SomeDecl{}, + ExpectedJSON: `{"symbols":null}`, + }, "location excluded": { SomeDecl: &SomeDecl{ Symbols: []*Term{term}, @@ -1051,6 +1123,18 @@ func TestAnnotationsRef_MarshalJSON(t *testing.T) { }, ExpectedJSON: `{"annotations":{"scope":""},"location":{"file":"example.rego","row":1,"col":4},"path":[]}`, }, + "no annotations, location included": { + AnnotationsRef: &AnnotationsRef{ + Path: []*Term{}, + Location: NewLocation([]byte{}, "example.rego", 1, 4), + }, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{AnnotationsRef: true}, + }, + }, + ExpectedJSON: `{"location":{"file":"example.rego","row":1,"col":4},"path":[]}`, + }, } for name, data := range testCases { @@ -1431,3 +1515,245 @@ func TestNot_UnmarshalJSON_Errors(t *testing.T) { }) } } + +func TestArgs_MarshalJSON(t *testing.T) { + x := VarTerm("x").SetLocation(NewLocation([]byte("x"), "example.rego", 1, 2)) + + testCases := map[string]struct { + Args Args + Options astJSON.Options + ExpectedJSON string + }{ + "nil": { + Args: nil, + ExpectedJSON: `null`, + }, + "empty": { + Args: Args{}, + ExpectedJSON: `[]`, + }, + "base case": { + Args: Args{x, VarTerm("y")}, + ExpectedJSON: `[{"type":"var","value":"x"},{"type":"var","value":"y"}]`, + }, + "term location included": { + Args: Args{x}, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{Term: true}}, + }, + ExpectedJSON: `[{"location":{"file":"example.rego","row":1,"col":2},"type":"var","value":"x"}]`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + bs := util.MustMarshalJSON(data.Args) + got := string(bs) + exp := data.ExpectedJSON + + if got != exp { + t.Fatalf("expected:\n%s got\n%s", exp, got) + } + }) + } +} + +func TestVar_MarshalJSON(t *testing.T) { + testCases := map[string]struct { + Var Var + ExpectedJSON string + }{ + "base case": { + Var: Var("x"), + ExpectedJSON: `"x"`, + }, + "empty": { + Var: Var(""), + ExpectedJSON: `""`, + }, + "wildcard": { + Var: Var("$01"), + ExpectedJSON: `"$01"`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + bs := util.MustMarshalJSON(data.Var) + got := string(bs) + exp := data.ExpectedJSON + + if got != exp { + t.Fatalf("expected:\n%s got\n%s", exp, got) + } + }) + } +} + +func TestAuthorAnnotation_MarshalJSON(t *testing.T) { + testCases := map[string]struct { + Author *AuthorAnnotation + ExpectedJSON string + }{ + "base case": { + Author: &AuthorAnnotation{Name: "John Doe", Email: "john@example.com"}, + ExpectedJSON: `{"name":"John Doe","email":"john@example.com"}`, + }, + "no email": { + Author: &AuthorAnnotation{Name: "John Doe"}, + ExpectedJSON: `{"name":"John Doe"}`, + }, + "empty": { + Author: &AuthorAnnotation{}, + ExpectedJSON: `{"name":""}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + bs := util.MustMarshalJSON(data.Author) + got := string(bs) + exp := data.ExpectedJSON + + if got != exp { + t.Fatalf("expected:\n%s got\n%s", exp, got) + } + }) + } +} + +func TestSchemaAnnotation_MarshalJSON(t *testing.T) { + loc := NewLocation([]byte("input"), "example.rego", 1, 2) + path := Ref{VarTerm("input").SetLocation(loc), StringTerm("foo").SetLocation(loc)} + definition := any(map[string]any{"type": "boolean"}) + + testCases := map[string]struct { + Schema *SchemaAnnotation + Options astJSON.Options + ExpectedJSON string + }{ + "empty": { + Schema: &SchemaAnnotation{}, + ExpectedJSON: `{"path":null}`, + }, + "path and schema": { + Schema: &SchemaAnnotation{Path: path, Schema: MustParseRef("schema.foo")}, + ExpectedJSON: `{"path":[{"type":"var","value":"input"},{"type":"string","value":"foo"}],"schema":[{"type":"var","value":"schema"},{"type":"string","value":"foo"}]}`, + }, + "path and definition": { + Schema: &SchemaAnnotation{Path: path, Definition: &definition}, + ExpectedJSON: `{"path":[{"type":"var","value":"input"},{"type":"string","value":"foo"}],"definition":{"type":"boolean"}}`, + }, + "term location included": { + Schema: &SchemaAnnotation{Path: path}, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{Term: true}}, + }, + ExpectedJSON: `{"path":[{"type":"var","value":"input"},{"type":"string","value":"foo"}]}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + bs := util.MustMarshalJSON(data.Schema) + got := string(bs) + exp := data.ExpectedJSON + + if got != exp { + t.Fatalf("expected:\n%s got\n%s", exp, got) + } + }) + } + + t.Run("path terms are not mutated", func(t *testing.T) { + astJSON.SetOptions(astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{Term: true, AnnotationsRef: true}}, + }) + t.Cleanup(resetJSONOptions) + + s := &SchemaAnnotation{Path: Ref{VarTerm("input").SetLocation(loc)}} + util.MustMarshalJSON(s) + + if s.Path[0].Location != loc { + t.Fatalf("expected path term location to be left alone, got %v", s.Path[0].Location) + } + }) +} + +func TestModule_UnmarshalJSON(t *testing.T) { + mod := MustParseModule(`package test + +p if { q } +q := 1 +r := 2 if { input.x } else := 3 if { input.y } +`) + + bs := util.MustMarshalJSON(mod) + + var roundtrip Module + if err := util.UnmarshalJSON(bs, &roundtrip); err != nil { + t.Fatal(err) + } + + if exp, got := len(mod.Rules), len(roundtrip.Rules); exp != got { + t.Fatalf("expected %d rules, got %d", exp, got) + } + + WalkRules(&roundtrip, func(rule *Rule) bool { + if rule.Module != &roundtrip { + t.Errorf("rule %v: expected module pointer to be set, got %v", rule.Head, rule.Module) + } + return false + }) +} + +func TestTemplateString_MarshalJSON(t *testing.T) { + testCases := map[string]struct { + TemplateString *TemplateString + ExpectedJSON string + }{ + "nil parts": { + TemplateString: &TemplateString{}, + ExpectedJSON: `{"parts":null,"multi_line":false}`, + }, + "empty parts": { + TemplateString: &TemplateString{Parts: []Node{}}, + ExpectedJSON: `{"parts":[],"multi_line":false}`, + }, + "base case": { + TemplateString: &TemplateString{Parts: []Node{StringTerm("foo"), VarTerm("x")}, MultiLine: true}, + ExpectedJSON: `{"parts":[{"type":"string","value":"foo"},{"type":"var","value":"x"}],"multi_line":true}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + bs := util.MustMarshalJSON(data.TemplateString) + got := string(bs) + exp := data.ExpectedJSON + + if got != exp { + t.Fatalf("expected:\n%s got\n%s", exp, got) + } + }) + } +} + +func TestSchemaAnnotation_MarshalJSON_InvalidDefinition(t *testing.T) { + definition := any(func() {}) + + _, err := json.Marshal(&SchemaAnnotation{Path: MustParseRef("input.x"), Definition: &definition}) + if err == nil { + t.Fatal("expected error") + } + // The encoder's wording differs between encoding/json v1 and v2. + if exp := "func()"; !strings.Contains(err.Error(), exp) { + t.Fatalf("expected error containing %q, got: %v", exp, err) + } +} diff --git a/v1/ast/policy.go b/v1/ast/policy.go index 4de585bbb7..c5592d99b8 100644 --- a/v1/ast/policy.go +++ b/v1/ast/policy.go @@ -6,7 +6,6 @@ package ast import ( "bytes" - "encoding/json" "fmt" "slices" "strings" @@ -409,26 +408,6 @@ func (mod *Module) RuleSet(name Var) RuleSet { return rs } -// UnmarshalJSON parses bs and stores the result in mod. The rules in the module -// will have their module pointer set to mod. -func (mod *Module) UnmarshalJSON(bs []byte) error { - - // Declare a new type and use a type conversion to avoid recursively calling - // Module#UnmarshalJSON. - type module Module - - if err := util.UnmarshalJSON(bs, (*module)(mod)); err != nil { - return err - } - - WalkRules(mod, func(rule *Rule) bool { - rule.Module = mod - return false - }) - - return nil -} - func (mod *Module) regoV1Compatible() bool { return mod.regoVersion == RegoV1 || mod.regoVersion == RegoV0CompatV1 } @@ -519,20 +498,6 @@ func (pkg *Package) String() string { return util.ByteSliceToString(buf) } -func (pkg *Package) MarshalJSON() ([]byte, error) { - data := map[string]any{ - "path": pkg.Path, - } - - if astJSON.GetOptions().MarshalOptions.IncludeLocation.Package { - if pkg.Location != nil { - data["location"] = pkg.Location - } - } - - return json.Marshal(data) -} - // IsValidImportPath returns an error indicating if the import path is invalid. // If the import path is valid, err is nil. func IsValidImportPath(v Value) (err error) { @@ -623,24 +588,6 @@ func (imp *Import) String() string { return util.ByteSliceToString(buf) } -func (imp *Import) MarshalJSON() ([]byte, error) { - data := map[string]any{ - "path": imp.Path, - } - - if len(imp.Alias) != 0 { - data["alias"] = imp.Alias - } - - if astJSON.GetOptions().MarshalOptions.IncludeLocation.Import { - if imp.Location != nil { - data["location"] = imp.Location - } - } - - return json.Marshal(data) -} - // Compare returns an integer indicating whether rule is less than, equal to, // or greater than other. func (rule *Rule) Compare(other *Rule) int { @@ -754,42 +701,6 @@ func (rule *Rule) isFunction() bool { return len(rule.Head.Args) > 0 } -// ruleJSON is used for JSON serialization of Rule to avoid map allocation overhead. -// Field order is alphabetical to match previous map-based output. -type ruleJSON struct { - Annotations []*Annotations `json:"annotations,omitempty"` - Body Body `json:"body"` - Default bool `json:"default,omitempty"` - Else *Rule `json:"else,omitempty"` - Head *Head `json:"head"` - Location *Location `json:"location,omitempty"` -} - -func (rule *Rule) MarshalJSON() ([]byte, error) { - data := ruleJSON{ - Head: rule.Head, - Body: rule.Body, - } - - if rule.Default { - data.Default = true - } - - if rule.Else != nil { - data.Else = rule.Else - } - - if astJSON.GetOptions().MarshalOptions.IncludeLocation.Rule { - data.Location = rule.Location - } - - if len(rule.Annotations) != 0 { - data.Annotations = rule.Annotations - } - - return json.Marshal(data) -} - // NewHead returns a new Head object. If args are provided, the first will be // used for the key and the second will be used for the value. func NewHead(name Var, args ...*Term) *Head { @@ -952,27 +863,6 @@ func (head *Head) stringWithOpts(opts toStringOpts) string { return util.ByteSliceToString(buf) } -func (head *Head) MarshalJSON() ([]byte, error) { - var loc *Location - if astJSON.GetOptions().MarshalOptions.IncludeLocation.Head && head.Location != nil { - loc = head.Location - } - - // NOTE(sr): we do this to override the rendering of `head.Reference`. - // It's still what'll be used via the default means of encoding/json - // for unmarshaling a json object into a Head struct! - type h Head - return json.Marshal(struct { - h - Ref Ref `json:"ref"` - Location *Location `json:"location,omitempty"` - }{ - h: h(*head), - Ref: head.Ref(), - Location: loc, - }) -} - // Vars returns a set of vars found in the head. func (head *Head) Vars() VarSet { vis := NewVarVisitor() @@ -1051,17 +941,6 @@ func NewBody(exprs ...*Expr) Body { return Body(exprs) } -// MarshalJSON returns JSON encoded bytes representing body. -func (body Body) MarshalJSON() ([]byte, error) { - // Serialize empty Body to empty array. This handles both the empty case and the - // nil case (whereas by default the result would be null if body was nil.) - if len(body) == 0 { - return []byte(`[]`), nil - } - ret, err := json.Marshal([]*Expr(body)) - return ret, err -} - // Append adds the expr to the body and updates the expr's index accordingly. func (body *Body) Append(expr *Expr) { n := len(*body) @@ -1142,10 +1021,6 @@ func (body Body) String() string { return util.ByteSliceToString(buf) } -func (body Body) AppendText(buf []byte) ([]byte, error) { - return AppendDelimeted(buf, body, "; ") -} - // Vars returns a VarSet containing variables in body. The params can be set to // control which vars are included. func (body Body) Vars(params VarVisitorParams) VarSet { @@ -1521,51 +1396,6 @@ func (expr *Expr) String() string { return util.ByteSliceToString(buf) } -// exprJSON is used for JSON serialization of Expr to avoid map allocation overhead. -// Field order is alphabetical to match previous map-based output. -type exprJSON struct { - Generated bool `json:"generated,omitempty"` - Index int `json:"index"` - Location *Location `json:"location,omitempty"` - Negated bool `json:"negated,omitempty"` - Terms any `json:"terms"` - With []*With `json:"with,omitempty"` -} - -func (expr *Expr) MarshalJSON() ([]byte, error) { - data := exprJSON{ - Index: expr.Index, - Terms: expr.Terms, - } - - if len(expr.With) > 0 { - data.With = expr.With - } - - if expr.Generated { - data.Generated = true - } - - if expr.Negated { - data.Negated = true - } - - if astJSON.GetOptions().MarshalOptions.IncludeLocation.Expr { - data.Location = expr.Location - } - - return json.Marshal(data) -} - -// UnmarshalJSON parses the byte array and stores the result in expr. -func (expr *Expr) UnmarshalJSON(bs []byte) error { - v := map[string]any{} - if err := util.UnmarshalJSON(bs, &v); err != nil { - return err - } - return unmarshalExpr(expr, v) -} - // Vars returns a VarSet containing variables in expr. The params can be set to // control which vars are included. func (expr *Expr) Vars(params VarVisitorParams) VarSet { @@ -1654,20 +1484,6 @@ func (d *SomeDecl) Hash() int { return termSliceHash(d.Symbols) } -func (d *SomeDecl) MarshalJSON() ([]byte, error) { - data := map[string]any{ - "symbols": d.Symbols, - } - - if astJSON.GetOptions().MarshalOptions.IncludeLocation.SomeDecl { - if d.Location != nil { - data["location"] = d.Location - } - } - - return json.Marshal(data) -} - func (q *Every) String() string { if q.Key != nil { return fmt.Sprintf("every %s, %s in %s { %s }", @@ -1724,23 +1540,6 @@ func (q *Every) KeyValueVars() VarSet { return vis.vars } -func (q *Every) MarshalJSON() ([]byte, error) { - data := map[string]any{ - "key": q.Key, - "value": q.Value, - "domain": q.Domain, - "body": q.Body, - } - - if astJSON.GetOptions().MarshalOptions.IncludeLocation.Every { - if q.Location != nil { - data["location"] = q.Location - } - } - - return json.Marshal(data) -} - func (a *LogicalAnd) String() string { return formatBinaryLogical("and", a.Lhs, a.Rhs, a.ExplicitLhs, a.ExplicitRhs) } @@ -1774,36 +1573,6 @@ func (a *LogicalAnd) Hash() int { return a.Lhs.Hash() + a.Rhs.Hash() } -func (a *LogicalAnd) MarshalJSON() ([]byte, error) { - data := map[string]any{ - "type": "and", - "lhs": a.Lhs, - "rhs": a.Rhs, - } - if a.ExplicitLhs { - data["explicit_lhs"] = true - } - if a.ExplicitRhs { - data["explicit_rhs"] = true - } - - if astJSON.GetOptions().MarshalOptions.IncludeLocation.And { - if a.Location != nil { - data["location"] = a.Location - } - } - - return json.Marshal(data) -} - -func (a *LogicalAnd) UnmarshalJSON(bs []byte) error { - v := map[string]any{} - if err := util.UnmarshalJSON(bs, &v); err != nil { - return err - } - return unmarshalLogical("and", &a.Lhs, &a.Rhs, &a.ExplicitLhs, &a.ExplicitRhs, v) -} - func (o *LogicalOr) String() string { return formatBinaryLogical("or", o.Lhs, o.Rhs, o.ExplicitLhs, o.ExplicitRhs) } @@ -1837,75 +1606,6 @@ func (o *LogicalOr) Hash() int { return o.Lhs.Hash() + o.Rhs.Hash() } -func (o *LogicalOr) MarshalJSON() ([]byte, error) { - data := map[string]any{ - "type": "or", - "lhs": o.Lhs, - "rhs": o.Rhs, - } - if o.ExplicitLhs { - data["explicit_lhs"] = true - } - if o.ExplicitRhs { - data["explicit_rhs"] = true - } - - if astJSON.GetOptions().MarshalOptions.IncludeLocation.Or { - if o.Location != nil { - data["location"] = o.Location - } - } - - return json.Marshal(data) -} - -func (o *LogicalOr) UnmarshalJSON(bs []byte) error { - v := map[string]any{} - if err := util.UnmarshalJSON(bs, &v); err != nil { - return err - } - return unmarshalLogical("or", &o.Lhs, &o.Rhs, &o.ExplicitLhs, &o.ExplicitRhs, v) -} - -func unmarshalLogical(typeName string, lhs, rhs *Body, explicitLhs, explicitRhs *bool, v map[string]any) error { - lhsRaw, ok := v["lhs"].([]any) - if !ok { - return fmt.Errorf("ast: unable to unmarshal %s, invalid lhs field type: %T (expected list)", typeName, v["lhs"]) - } - l, err := unmarshalBody(lhsRaw) - if err != nil { - return fmt.Errorf("ast: unable to unmarshal %s lhs: %w", typeName, err) - } - *lhs = l - - rhsRaw, ok := v["rhs"].([]any) - if !ok { - return fmt.Errorf("ast: unable to unmarshal %s, invalid rhs field type: %T (expected list)", typeName, v["rhs"]) - } - r, err := unmarshalBody(rhsRaw) - if err != nil { - return fmt.Errorf("ast: unable to unmarshal %s rhs: %w", typeName, err) - } - *rhs = r - - if x, ok := v["explicit_lhs"]; ok { - b, ok := x.(bool) - if !ok { - return fmt.Errorf("ast: unable to unmarshal %s explicit_lhs field with type: %T (expected true or false)", typeName, x) - } - *explicitLhs = b - } - if x, ok := v["explicit_rhs"]; ok { - b, ok := x.(bool) - if !ok { - return fmt.Errorf("ast: unable to unmarshal %s explicit_rhs field with type: %T (expected true or false)", typeName, x) - } - *explicitRhs = b - } - - return nil -} - func formatBinaryLogical(op string, lhs, rhs Body, explicitLhs, explicitRhs bool) string { return formatLogicalOperand(lhs, explicitLhs, op, false) + " " + op + " " + formatLogicalOperand(rhs, explicitRhs, op, true) } @@ -2023,27 +1723,6 @@ func (w *With) SetLoc(loc *Location) { w.Location = loc } -// withJSON is used for JSON serialization of With to avoid map allocation overhead. -// Field order is alphabetical to match previous map-based output. -type withJSON struct { - Location *Location `json:"location,omitempty"` - Target *Term `json:"target"` - Value *Term `json:"value"` -} - -func (w *With) MarshalJSON() ([]byte, error) { - data := withJSON{ - Target: w.Target, - Value: w.Value, - } - - if astJSON.GetOptions().MarshalOptions.IncludeLocation.With { - data.Location = w.Location - } - - return json.Marshal(data) -} - // Copy returns a deep copy of the AST node x. If x is not an AST node, x is returned unmodified. func Copy(x any) any { switch x := x.(type) { diff --git a/v1/ast/policy_appenders.go b/v1/ast/policy_appenders.go index 2a45e2e2eb..63b260e0a5 100644 --- a/v1/ast/policy_appenders.go +++ b/v1/ast/policy_appenders.go @@ -223,6 +223,10 @@ func (a Args) AppendText(buf []byte) ([]byte, error) { return append(buf, ')'), nil } +func (body Body) AppendText(buf []byte) ([]byte, error) { + return AppendDelimeted(buf, body, "; ") +} + func (expr *Expr) AppendText(buf []byte) ([]byte, error) { if expr.Negated { buf = append(buf, "not "...) diff --git a/v1/ast/policy_appenders_test.go b/v1/ast/policy_appenders_test.go index 572ed62227..aa55565479 100644 --- a/v1/ast/policy_appenders_test.go +++ b/v1/ast/policy_appenders_test.go @@ -253,3 +253,31 @@ func BenchmarkNoNodeTypeAllocatesOnAppend(b *testing.B) { }) } } + +// TestModuleStringAnnotationsDeterministic guards against a regression where +// Module#String, which renders "# METADATA" comments by calling +// Annotations#String (see Module#AppendText), produced a different rendering +// of a metadata annotation's custom/labels map on every call, because the +// underlying marshaler didn't fix the map key order. That would make repeated +// formatting of the same module non-idempotent. +func TestModuleStringAnnotationsDeterministic(t *testing.T) { + module := ast.MustParseModuleWithOpts(`# METADATA +# title: p +# custom: +# zeta: 1 +# alpha: 2 +# mu: 3 +# beta: 4 +# omega: 5 +package p + +r = true`, + ast.ParserOptions{ProcessAnnotation: true}) + + exp := module.String() + for i := range 10 { + if got := module.String(); got != exp { + t.Fatalf("Module#String is not deterministic across calls:\ncall 0: %s\ncall %d: %s", exp, i+1, got) + } + } +} diff --git a/v1/ast/policy_json.go b/v1/ast/policy_json.go new file mode 100644 index 0000000000..b63c035949 --- /dev/null +++ b/v1/ast/policy_json.go @@ -0,0 +1,289 @@ +//go:build !go1.27 + +package ast + +import ( + "encoding/json" + + astJSON "github.com/open-policy-agent/opa/v1/ast/json" + "github.com/open-policy-agent/opa/v1/util" +) + +// ruleJSON is used for JSON serialization of Rule to avoid map allocation overhead. +// Field order is alphabetical to match previous map-based output. +type ruleJSON struct { + Annotations []*Annotations `json:"annotations,omitempty"` + Body Body `json:"body"` + Default bool `json:"default,omitempty"` + Else *Rule `json:"else,omitempty"` + Head *Head `json:"head"` + Location *Location `json:"location,omitempty"` +} + +// exprJSON is used for JSON serialization of Expr to avoid map allocation overhead. +// Field order is alphabetical to match previous map-based output. +type exprJSON struct { + Generated bool `json:"generated,omitempty"` + Index int `json:"index"` + Location *Location `json:"location,omitempty"` + Negated bool `json:"negated,omitempty"` + Terms any `json:"terms"` + With []*With `json:"with,omitempty"` +} + +// withJSON is used for JSON serialization of With to avoid map allocation overhead. +// Field order is alphabetical to match previous map-based output. +type withJSON struct { + Location *Location `json:"location,omitempty"` + Target *Term `json:"target"` + Value *Term `json:"value"` +} + +// UnmarshalJSON parses bs and stores the result in mod. The rules in the module +// will have their module pointer set to mod. +func (mod *Module) UnmarshalJSON(bs []byte) error { + + // Declare a new type and use a type conversion to avoid recursively calling + // Module#UnmarshalJSON. + type module Module + + if err := util.UnmarshalJSON(bs, (*module)(mod)); err != nil { + return err + } + + // The decoded rules have no module pointer, as it isn't part of the JSON + // representation; without this, an unmarshalled module can't be compiled. + WalkRules(mod, func(rule *Rule) bool { + rule.Module = mod + return false + }) + + return nil +} + +func (d *SomeDecl) MarshalJSON() ([]byte, error) { + data := map[string]any{ + "symbols": d.Symbols, + } + + if astJSON.GetOptions().MarshalOptions.IncludeLocation.SomeDecl { + if d.Location != nil { + data["location"] = d.Location + } + } + + return json.Marshal(data) +} + +func (q *Every) MarshalJSON() ([]byte, error) { + data := map[string]any{ + "key": q.Key, + "value": q.Value, + "domain": q.Domain, + "body": q.Body, + } + + if astJSON.GetOptions().MarshalOptions.IncludeLocation.Every { + if q.Location != nil { + data["location"] = q.Location + } + } + + return json.Marshal(data) +} + +func (a *LogicalAnd) MarshalJSON() ([]byte, error) { + data := map[string]any{ + "type": "and", + "lhs": a.Lhs, + "rhs": a.Rhs, + } + if a.ExplicitLhs { + data["explicit_lhs"] = true + } + if a.ExplicitRhs { + data["explicit_rhs"] = true + } + + if astJSON.GetOptions().MarshalOptions.IncludeLocation.And { + if a.Location != nil { + data["location"] = a.Location + } + } + + return json.Marshal(data) +} + +func (a *LogicalAnd) UnmarshalJSON(bs []byte) error { + v := map[string]any{} + if err := util.UnmarshalJSON(bs, &v); err != nil { + return err + } + return unmarshalLogical("and", &a.Lhs, &a.Rhs, &a.ExplicitLhs, &a.ExplicitRhs, v) +} + +func (o *LogicalOr) MarshalJSON() ([]byte, error) { + data := map[string]any{ + "type": "or", + "lhs": o.Lhs, + "rhs": o.Rhs, + } + if o.ExplicitLhs { + data["explicit_lhs"] = true + } + if o.ExplicitRhs { + data["explicit_rhs"] = true + } + + if astJSON.GetOptions().MarshalOptions.IncludeLocation.Or { + if o.Location != nil { + data["location"] = o.Location + } + } + + return json.Marshal(data) +} + +func (o *LogicalOr) UnmarshalJSON(bs []byte) error { + v := map[string]any{} + if err := util.UnmarshalJSON(bs, &v); err != nil { + return err + } + return unmarshalLogical("or", &o.Lhs, &o.Rhs, &o.ExplicitLhs, &o.ExplicitRhs, v) +} + +// UnmarshalJSON parses the byte array and stores the result in expr. +func (expr *Expr) UnmarshalJSON(bs []byte) error { + v := map[string]any{} + if err := util.UnmarshalJSON(bs, &v); err != nil { + return err + } + return unmarshalExpr(expr, v) +} + +func (expr *Expr) MarshalJSON() ([]byte, error) { + data := exprJSON{ + Index: expr.Index, + Terms: expr.Terms, + } + + if len(expr.With) > 0 { + data.With = expr.With + } + + if expr.Generated { + data.Generated = true + } + + if expr.Negated { + data.Negated = true + } + + if astJSON.GetOptions().MarshalOptions.IncludeLocation.Expr { + data.Location = expr.Location + } + + return json.Marshal(data) +} + +func (w *With) MarshalJSON() ([]byte, error) { + data := withJSON{ + Target: w.Target, + Value: w.Value, + } + + if astJSON.GetOptions().MarshalOptions.IncludeLocation.With { + data.Location = w.Location + } + + return json.Marshal(data) +} + +func (pkg *Package) MarshalJSON() ([]byte, error) { + data := map[string]any{ + "path": pkg.Path, + } + + if astJSON.GetOptions().MarshalOptions.IncludeLocation.Package { + if pkg.Location != nil { + data["location"] = pkg.Location + } + } + + return json.Marshal(data) +} + +func (imp *Import) MarshalJSON() ([]byte, error) { + data := map[string]any{ + "path": imp.Path, + } + + if len(imp.Alias) != 0 { + data["alias"] = imp.Alias + } + + if astJSON.GetOptions().MarshalOptions.IncludeLocation.Import { + if imp.Location != nil { + data["location"] = imp.Location + } + } + + return json.Marshal(data) +} + +func (rule *Rule) MarshalJSON() ([]byte, error) { + data := ruleJSON{ + Head: rule.Head, + Body: rule.Body, + } + + if rule.Default { + data.Default = true + } + + if rule.Else != nil { + data.Else = rule.Else + } + + if astJSON.GetOptions().MarshalOptions.IncludeLocation.Rule { + data.Location = rule.Location + } + + if len(rule.Annotations) != 0 { + data.Annotations = rule.Annotations + } + + return json.Marshal(data) +} + +func (head *Head) MarshalJSON() ([]byte, error) { + var loc *Location + if astJSON.GetOptions().MarshalOptions.IncludeLocation.Head && head.Location != nil { + loc = head.Location + } + + // NOTE(sr): we do this to override the rendering of `head.Reference`. + // It's still what'll be used via the default means of encoding/json + // for unmarshaling a json object into a Head struct! + type h Head + return json.Marshal(struct { + h + Ref Ref `json:"ref"` + Location *Location `json:"location,omitempty"` + }{ + h: h(*head), + Ref: head.Ref(), + Location: loc, + }) +} + +// MarshalJSON returns JSON encoded bytes representing body. +func (body Body) MarshalJSON() ([]byte, error) { + // Serialize empty Body to empty array. This handles both the empty case and the + // nil case (whereas by default the result would be null if body was nil.) + if len(body) == 0 { + return []byte(`[]`), nil + } + ret, err := json.Marshal([]*Expr(body)) + return ret, err +} diff --git a/v1/ast/policy_jsonv2.go b/v1/ast/policy_jsonv2.go new file mode 100644 index 0000000000..faf1b55df7 --- /dev/null +++ b/v1/ast/policy_jsonv2.go @@ -0,0 +1,524 @@ +//go:build go1.27 + +package ast + +import ( + "encoding/base64" + "encoding/json/jsontext" + "encoding/json/v2" + "fmt" + + "github.com/open-policy-agent/opa/internal/jsonv2" + astJSON "github.com/open-policy-agent/opa/v1/ast/json" + "github.com/open-policy-agent/opa/v1/util" +) + +var ( + _ json.Unmarshaler = &Module{} + + // These are exported types, so losing MarshalJSON here would be a breaking + // API change even though callers should go through json.Marshal, not this + // method directly. + _ json.Marshaler = Body{} + _ json.Marshaler = &Expr{} + _ json.Marshaler = &Package{} + _ json.Marshaler = &Import{} + _ json.Marshaler = &Rule{} + _ json.Marshaler = &Head{} + _ json.Marshaler = &With{} + _ json.Marshaler = &SomeDecl{} + _ json.Marshaler = &Every{} + _ json.Marshaler = &LogicalAnd{} + _ json.Marshaler = &LogicalOr{} +) + +// UnmarshalJSON parses bs and stores the result in mod. The rules in the module +// will have their module pointer set to mod. +func (mod *Module) UnmarshalJSON(bs []byte) error { + + // Declare a new type and use a type conversion to avoid recursively calling + // Module#UnmarshalJSON. + type module Module + + if err := util.UnmarshalJSON(bs, (*module)(mod)); err != nil { + return err + } + + // The decoded rules have no module pointer, as it isn't part of the JSON + // representation; without this, an unmarshalled module can't be compiled. + WalkRules(mod, func(rule *Rule) bool { + rule.Module = mod + return false + }) + + return nil +} + +// MarshalJSONTo is here to ensure that we do not fall down to TextAppender, +// which Go 1.27's encoding/json would otherwise use, encoding args as the Rego +// representation of the argument list rather than as a JSON array. +func (a Args) MarshalJSONTo(e *jsontext.Encoder) error { + return jsonv2.WriteMarshalerToArrayOrNull(e, a) +} + +// MarshalJSONTo is here to ensure that we do not fall down to TextAppender, +// which Go 1.27's encoding/json would otherwise use, encoding the module as +// Rego source rather than as JSON. Module's own fields are fully described by +// their struct tags, so the encoding is left to them, as it is pre-1.27. The +// field types provide their own MarshalJSONTo where one is needed. +func (m *Module) MarshalJSONTo(e *jsontext.Encoder) error { + // Declare a new type and use a type conversion to avoid recursively calling + // Module#MarshalJSONTo. It's the highest precedence marshaller, so there is + // nothing below it to fall to, and the new type has no methods of its own. + type module Module + + return json.MarshalEncode(e, (*module)(m)) +} + +func (pkg *Package) MarshalJSONTo(e *jsontext.Encoder) error { + e.WriteToken(jsontext.BeginObject) + + if astJSON.GetOptions().MarshalOptions.IncludeLocation.Package && pkg.Location != nil { + if err := jsonv2.WriteField(e, "location", pkg.Location); err != nil { + return err + } + } + + if err := jsonv2.WriteField(e, "path", pkg.Path); err != nil { + return err + } + + return e.WriteToken(jsontext.EndObject) +} + +func (i *Import) MarshalJSONTo(e *jsontext.Encoder) error { + e.WriteToken(jsontext.BeginObject) + + if err := jsonv2.WriteField(e, "path", i.Path); err != nil { + return err + } + + if astJSON.GetOptions().MarshalOptions.IncludeLocation.Import && i.Location != nil { + if err := jsonv2.WriteField(e, "location", i.Location); err != nil { + return err + } + } + + if len(i.Alias) > 0 { + e.WriteToken(jsontext.String("alias")) + e.WriteToken(jsontext.String(string(i.Alias))) + } + + return e.WriteToken(jsontext.EndObject) +} + +func (r *Rule) MarshalJSONTo(e *jsontext.Encoder) error { + e.WriteToken(jsontext.BeginObject) + + if r.Default { + e.WriteToken(jsontext.String("default")) + e.WriteToken(jsontext.True) + } + + if r.Else != nil { + if err := jsonv2.WriteField(e, "else", r.Else); err != nil { + return err + } + } + + if err := jsonv2.WriteField(e, "head", r.Head); err != nil { + return err + } + + if err := jsonv2.WriteField(e, "body", r.Body); err != nil { + return err + } + + if len(r.Annotations) > 0 { + if err := jsonv2.WriteFieldArray(e, "annotations", r.Annotations); err != nil { + return err + } + } + + if astJSON.GetOptions().MarshalOptions.IncludeLocation.Rule && r.Location != nil { + if err := jsonv2.WriteField(e, "location", r.Location); err != nil { + return err + } + } + + return e.WriteToken(jsontext.EndObject) +} + +func (h *Head) MarshalJSONTo(e *jsontext.Encoder) error { + e.WriteToken(jsontext.BeginObject) + + if h.Name != "" { + e.WriteToken(jsontext.String("name")) + e.WriteToken(jsontext.String(string(h.Name))) + } + + if err := jsonv2.WriteField(e, "ref", h.Ref()); err != nil { + return err + } + + if len(h.Args) > 0 { + if err := jsonv2.WriteFieldArray(e, "args", h.Args); err != nil { + return err + } + } + + if h.Key != nil { + if err := jsonv2.WriteField(e, "key", h.Key); err != nil { + return err + } + } + + if h.Value != nil { + if err := jsonv2.WriteField(e, "value", h.Value); err != nil { + return err + } + } + + if h.Assign { + e.WriteToken(jsontext.String("assign")) + e.WriteToken(jsontext.True) + } + + if astJSON.GetOptions().MarshalOptions.IncludeLocation.Head && h.Location != nil { + if err := jsonv2.WriteField(e, "location", h.Location); err != nil { + return err + } + } + + return e.WriteToken(jsontext.EndObject) +} + +func (c Call) MarshalJSONTo(e *jsontext.Encoder) (err error) { + return jsonv2.WriteMarshalerToArrayOrNull(e, c) +} + +func (c *Comment) MarshalJSONTo(e *jsontext.Encoder) error { + // Token write errors are unchecked: an unbalanced value fails at the closing + // token. A marshaller can fail having written a balanced value, so is checked. + e.WriteToken(jsontext.BeginObject) + + // Comment has no JSON tags, hence the capitalised keys, the base64 encoded + // text, and the location being written even when it's nil. + e.WriteToken(jsontext.String("Text")) + + buf := make([]byte, base64.StdEncoding.EncodedLen(len(c.Text))) + base64.StdEncoding.Encode(buf, c.Text) + + e.WriteValue(append(append(append(e.AvailableBuffer(), '"'), buf...), '"')) + + e.WriteToken(jsontext.String("Location")) + if c.Location != nil { + if err := c.Location.MarshalJSONTo(e); err != nil { + return err + } + } else { + e.WriteToken(jsontext.Null) + } + + return e.WriteToken(jsontext.EndObject) +} + +func (q *Every) MarshalJSONTo(e *jsontext.Encoder) error { + // Token write errors are unchecked: an unbalanced value fails at the closing + // token. A marshaller can fail having written a balanced value, so is checked. + e.WriteToken(jsontext.BeginObject) + + e.WriteToken(jsontext.String("key")) + if q.Key == nil { + e.WriteToken(jsontext.Null) + } else { + if err := q.Key.MarshalJSONTo(e); err != nil { + return err + } + } + + if err := jsonv2.WriteField(e, "value", q.Value); err != nil { + return err + } + + if err := jsonv2.WriteField(e, "domain", q.Domain); err != nil { + return err + } + + if err := jsonv2.WriteField(e, "body", q.Body); err != nil { + return err + } + + if astJSON.GetOptions().MarshalOptions.IncludeLocation.Every && q.Location != nil { + if err := jsonv2.WriteField(e, "location", q.Location); err != nil { + return err + } + } + + return e.WriteToken(jsontext.EndObject) +} + +func (b Body) MarshalJSONTo(e *jsontext.Encoder) error { + return jsonv2.WriteMarshalerToArray(e, b) +} + +// MarshalJSON returns JSON encoded bytes representing body. +func (body Body) MarshalJSON() ([]byte, error) { + return jsonv2.MarshalMarshalerTo(body) +} + +func (expr *Expr) MarshalJSON() ([]byte, error) { + return jsonv2.MarshalMarshalerTo(expr) +} + +// UnmarshalJSON parses the byte array and stores the result in expr. +func (expr *Expr) UnmarshalJSON(bs []byte) error { + v := map[string]any{} + if err := util.UnmarshalJSON(bs, &v); err != nil { + return err + } + return unmarshalExpr(expr, v) +} + +func (e *Expr) MarshalJSONTo(enc *jsontext.Encoder) error { + enc.WriteToken(jsontext.BeginObject) + + enc.WriteToken(jsontext.String("index")) + enc.WriteToken(jsontext.Int(int64(e.Index))) + + includeLocation := astJSON.GetOptions().MarshalOptions.IncludeLocation + if e.Location != nil && includeLocation.Expr { + if err := jsonv2.WriteField(enc, "location", e.Location); err != nil { + return err + } + } + + if e.Negated { + enc.WriteToken(jsontext.String("negated")) + enc.WriteToken(jsontext.True) + } + + if e.Generated { + enc.WriteToken(jsontext.String("generated")) + enc.WriteToken(jsontext.True) + } + + enc.WriteToken(jsontext.String("terms")) + var err error + switch t := e.Terms.(type) { + case []*Term: + err = jsonv2.WriteMarshalerToArrayOrNull(enc, t) + case json.MarshalerTo: + err = t.MarshalJSONTo(enc) + default: + return fmt.Errorf("unsupported expr terms type: %T", e.Terms) + } + + if err != nil { + return fmt.Errorf("failed to marshal expr terms: %w", err) + } + + if len(e.With) > 0 { + if err := jsonv2.WriteFieldArray(enc, "with", e.With); err != nil { + return err + } + } + + return enc.WriteToken(jsontext.EndObject) +} + +func (a *LogicalAnd) MarshalJSONTo(e *jsontext.Encoder) error { + e.WriteToken(jsontext.BeginObject) + e.WriteToken(jsontext.String("type")) + e.WriteToken(jsontext.String("and")) + if err := jsonv2.WriteField(e, "lhs", a.Lhs); err != nil { + return err + } + if err := jsonv2.WriteField(e, "rhs", a.Rhs); err != nil { + return err + } + + if a.ExplicitLhs { + e.WriteToken(jsontext.String("explicit_lhs")) + e.WriteToken(jsontext.True) + } + if a.ExplicitRhs { + e.WriteToken(jsontext.String("explicit_rhs")) + e.WriteToken(jsontext.True) + } + + if astJSON.GetOptions().MarshalOptions.IncludeLocation.And && a.Location != nil { + if err := jsonv2.WriteField(e, "location", a.Location); err != nil { + return err + } + } + + return e.WriteToken(jsontext.EndObject) +} + +func (a *LogicalAnd) UnmarshalJSON(bs []byte) error { + v := map[string]any{} + if err := util.UnmarshalJSON(bs, &v); err != nil { + return err + } + return unmarshalLogical("and", &a.Lhs, &a.Rhs, &a.ExplicitLhs, &a.ExplicitRhs, v) +} + +func (o *LogicalOr) MarshalJSONTo(e *jsontext.Encoder) error { + e.WriteToken(jsontext.BeginObject) + + e.WriteToken(jsontext.String("type")) + e.WriteToken(jsontext.String("or")) + + if err := jsonv2.WriteField(e, "lhs", o.Lhs); err != nil { + return err + } + + if err := jsonv2.WriteField(e, "rhs", o.Rhs); err != nil { + return err + } + + if o.ExplicitLhs { + e.WriteToken(jsontext.String("explicit_lhs")) + e.WriteToken(jsontext.True) + } + if o.ExplicitRhs { + e.WriteToken(jsontext.String("explicit_rhs")) + e.WriteToken(jsontext.True) + } + + if astJSON.GetOptions().MarshalOptions.IncludeLocation.Or && o.Location != nil { + if err := jsonv2.WriteField(e, "location", o.Location); err != nil { + return err + } + } + + return e.WriteToken(jsontext.EndObject) +} + +func (o *LogicalOr) UnmarshalJSON(bs []byte) error { + v := map[string]any{} + if err := util.UnmarshalJSON(bs, &v); err != nil { + return err + } + return unmarshalLogical("or", &o.Lhs, &o.Rhs, &o.ExplicitLhs, &o.ExplicitRhs, v) +} + +func (w *With) MarshalJSONTo(e *jsontext.Encoder) error { + e.WriteToken(jsontext.BeginObject) + + if err := jsonv2.WriteField(e, "target", w.Target); err != nil { + return err + } + + if err := jsonv2.WriteField(e, "value", w.Value); err != nil { + return err + } + + if astJSON.GetOptions().MarshalOptions.IncludeLocation.With && w.Location != nil { + if err := jsonv2.WriteField(e, "location", w.Location); err != nil { + return err + } + } + + return e.WriteToken(jsontext.EndObject) +} + +func (d *SomeDecl) MarshalJSONTo(e *jsontext.Encoder) error { + e.WriteToken(jsontext.BeginObject) + + e.WriteToken(jsontext.String("symbols")) + if err := jsonv2.WriteMarshalerToArrayOrNull(e, d.Symbols); err != nil { + return err + } + + if d.Location != nil && astJSON.GetOptions().MarshalOptions.IncludeLocation.SomeDecl { + if err := jsonv2.WriteField(e, "location", d.Location); err != nil { + return err + } + } + + return e.WriteToken(jsontext.EndObject) +} + +func (ac *ArrayComprehension) MarshalJSONTo(e *jsontext.Encoder) error { + e.WriteToken(jsontext.BeginObject) + + if err := jsonv2.WriteField(e, "term", ac.Term); err != nil { + return err + } + + if err := jsonv2.WriteField(e, "body", ac.Body); err != nil { + return err + } + + return e.WriteToken(jsontext.EndObject) +} + +func (sc *SetComprehension) MarshalJSONTo(e *jsontext.Encoder) error { + e.WriteToken(jsontext.BeginObject) + + if err := jsonv2.WriteField(e, "term", sc.Term); err != nil { + return err + } + + if err := jsonv2.WriteField(e, "body", sc.Body); err != nil { + return err + } + + return e.WriteToken(jsontext.EndObject) +} + +func (oc *ObjectComprehension) MarshalJSONTo(e *jsontext.Encoder) error { + e.WriteToken(jsontext.BeginObject) + + if err := jsonv2.WriteField(e, "key", oc.Key); err != nil { + return err + } + + if err := jsonv2.WriteField(e, "value", oc.Value); err != nil { + return err + } + + if err := jsonv2.WriteField(e, "body", oc.Body); err != nil { + return err + } + + return e.WriteToken(jsontext.EndObject) +} + +func (pkg *Package) MarshalJSON() ([]byte, error) { + return jsonv2.MarshalMarshalerTo(pkg) +} + +func (imp *Import) MarshalJSON() ([]byte, error) { + return jsonv2.MarshalMarshalerTo(imp) +} + +func (rule *Rule) MarshalJSON() ([]byte, error) { + return jsonv2.MarshalMarshalerTo(rule) +} + +func (head *Head) MarshalJSON() ([]byte, error) { + return jsonv2.MarshalMarshalerTo(head) +} + +func (w *With) MarshalJSON() ([]byte, error) { + return jsonv2.MarshalMarshalerTo(w) +} + +func (d *SomeDecl) MarshalJSON() ([]byte, error) { + return jsonv2.MarshalMarshalerTo(d) +} + +func (q *Every) MarshalJSON() ([]byte, error) { + return jsonv2.MarshalMarshalerTo(q) +} + +func (a *LogicalAnd) MarshalJSON() ([]byte, error) { + return jsonv2.MarshalMarshalerTo(a) +} + +func (o *LogicalOr) MarshalJSON() ([]byte, error) { + return jsonv2.MarshalMarshalerTo(o) +} diff --git a/v1/ast/policy_logical_test.go b/v1/ast/policy_logical_test.go index 809ebc13df..47f11a1a22 100644 --- a/v1/ast/policy_logical_test.go +++ b/v1/ast/policy_logical_test.go @@ -498,10 +498,8 @@ func TestLogicalAnd_MarshalJSON(t *testing.T) { astJSON.SetOptions(tc.options) t.Cleanup(resetJSONOptions) - got := string(util.MustMarshalJSON(tc.node)) - if got != tc.want { - t.Fatalf("MarshalJSON:\nwant: %s\ngot: %s", tc.want, got) - } + got := util.MustMarshalJSON(tc.node) + assertJsonEqual(t, tc.want, got) }) } } @@ -568,10 +566,8 @@ func TestLogicalOr_MarshalJSON(t *testing.T) { astJSON.SetOptions(tc.options) t.Cleanup(resetJSONOptions) - got := string(util.MustMarshalJSON(tc.node)) - if got != tc.want { - t.Fatalf("MarshalJSON:\nwant: %s\ngot: %s", tc.want, got) - } + got := util.MustMarshalJSON(tc.node) + assertJsonEqual(t, tc.want, got) }) } } diff --git a/v1/ast/policy_test.go b/v1/ast/policy_test.go index a1c2181885..f5161a6acf 100644 --- a/v1/ast/policy_test.go +++ b/v1/ast/policy_test.go @@ -454,9 +454,8 @@ func TestRuleHeadJSON(t *testing.T) { if err != nil { t.Fatal(err) } - if exp, act := `{"body":[],"head":{"name":"allow","ref":[{"type":"var","value":"allow"}]}}`, string(bs); act != exp { - t.Errorf("expected %q, got %q", exp, act) - } + exp := []byte(`{"body":[],"head":{"name":"allow","ref":[{"type":"var","value":"allow"}]}}`) + assertJsonEqual(t, exp, bs) var readRule Rule if err := json.Unmarshal(bs, &readRule); err != nil { @@ -469,9 +468,8 @@ func TestRuleHeadJSON(t *testing.T) { if err != nil { t.Fatal(err) } - if exp, act := string(bs), string(bs0); exp != act { - t.Errorf("expected json repr to match %q, got %q", exp, act) - } + + assertJsonEqual(t, bs, bs0) var readAgainRule Rule if err := json.Unmarshal(bs, &readAgainRule); err != nil { @@ -928,10 +926,7 @@ func TestAnnotationsString(t *testing.T) { // NOTE(tsandall): for now, annotations are represented as JSON objects // which are a subset of YAML. We could improve this in the future. exp := `{"authors":[{"name":"John Doe","email":"john@example.com"},{"name":"Jane Doe"}],"custom":{"flag":true,"list":[1,2,3],"map":{"one":1,"two":{"3":"three"}}},"description":"baz","organizations":["mi","fa"],"related_resources":[{"ref":"https://example.com"},{"description":"Some resource","ref":"https://example.com/2"}],"schemas":[{"path":[{"type":"var","value":"data"},{"type":"string","value":"bar"}],"schema":[{"type":"var","value":"schema"},{"type":"string","value":"baz"}]}],"scope":"foo","title":"bar"}` - - if got := a.String(); exp != got { - t.Fatalf("expected\n%s\nbut got\n%s", exp, got) - } + assertJsonEqual(t, exp, a.String()) } func mustParseURL(str string) url.URL { diff --git a/v1/ast/term.go b/v1/ast/term.go index 7952f028f1..23820f13bb 100644 --- a/v1/ast/term.go +++ b/v1/ast/term.go @@ -19,7 +19,6 @@ import ( "unicode" "github.com/cespare/xxhash/v2" - astJSON "github.com/open-policy-agent/opa/v1/ast/json" "github.com/open-policy-agent/opa/v1/ast/location" "github.com/open-policy-agent/opa/v1/util" ) @@ -420,55 +419,10 @@ func (term *Term) IsGround() bool { return term.Value.IsGround() } -// termJSON is used to serialize Term to JSON without map allocation. -type termJSON struct { - Location *Location `json:"location,omitempty"` - Type string `json:"type"` - Value Value `json:"value"` -} - -// MarshalJSON returns the JSON encoding of the term. -// -// Specialized marshalling logic is required to include a type hint for Value. -func (term *Term) MarshalJSON() ([]byte, error) { - d := termJSON{ - Type: ValueName(term.Value), - Value: term.Value, - } - jsonOptions := astJSON.GetOptions().MarshalOptions - if jsonOptions.IncludeLocation.Term { - d.Location = term.Location - } - return json.Marshal(d) -} - func (term *Term) String() string { return term.Value.String() } -// UnmarshalJSON parses the byte array and stores the result in term. -// Specialized unmarshalling is required to handle Value and Location. -func (term *Term) UnmarshalJSON(bs []byte) error { - v := map[string]any{} - if err := util.UnmarshalJSON(bs, &v); err != nil { - return err - } - val, err := unmarshalValue(v) - if err != nil { - return err - } - term.Value = val - - if loc, ok := v["location"].(map[string]any); ok { - term.Location = &Location{} - err := unmarshalLocation(term.Location, loc) - if err != nil { - return err - } - } - return nil -} - // Vars returns a VarSet with variables contained in this term. func (term *Term) Vars() VarSet { vis := NewVarVisitor() @@ -660,56 +614,6 @@ func (n *Not) String() string { return "not {" + n.Body.String() + "}" } -func (n *Not) MarshalJSON() ([]byte, error) { - data := map[string]any{ - "type": "not", - "body": n.Body, - "explicit_body": n.ExplicitBody, - } - - if astJSON.GetOptions().MarshalOptions.IncludeLocation.Not { - if n.Location != nil { - data["location"] = n.Location - } - } - - return json.Marshal(data) -} - -func (n *Not) UnmarshalJSON(bs []byte) error { - v := map[string]any{} - if err := util.UnmarshalJSON(bs, &v); err != nil { - return err - } - - return unmarshalNot(n, v) -} - -func unmarshalNot(n *Not, v map[string]any) error { - var eb bool - if x, ok := v["explicit_body"]; ok { - eb, ok = x.(bool) - if !ok { - return fmt.Errorf("ast: unable to unmarshal explicit_body field with type: %T (expected true or false)", v["explicit_body"]) - } - } - - b, ok := v["body"].([]any) - if !ok { - return fmt.Errorf("ast: unable to unmarshal not, invalid body field type: %T (expected list)", v["body"]) - } - - body, err := unmarshalBody(b) - if err != nil { - return fmt.Errorf("ast: unable to unmarshal not body: %w", err) - } - - n.ExplicitBody = eb - n.Body = body - - return nil -} - // Null represents the null value defined by JSON. type Null struct{} @@ -909,11 +813,6 @@ func (Number) IsGround() bool { return true } -// MarshalJSON returns JSON encoded bytes representing num. -func (num Number) MarshalJSON() ([]byte, error) { - return json.Marshal(json.Number(num)) -} - func (num Number) String() string { return string(num) } @@ -1692,14 +1591,6 @@ func (arr *Array) IsGround() bool { return arr.ground } -// MarshalJSON returns JSON encoded bytes representing arr. -func (arr *Array) MarshalJSON() ([]byte, error) { - if len(arr.elems) == 0 { - return []byte(`[]`), nil - } - return json.Marshal(arr.elems) -} - func (arr *Array) String() string { buf, _ := arr.AppendText(make([]byte, 0, arr.StringLength())) return util.ByteSliceToString(buf) @@ -2048,14 +1939,6 @@ func (s *set) Len() int { return len(s.keys) } -// MarshalJSON returns JSON encoded bytes representing s. -func (s *set) MarshalJSON() ([]byte, error) { - if s.keys == nil { - return []byte(`[]`), nil - } - return json.Marshal(s.sortedKeys()) -} - // Sorted returns an Array that contains the sorted elements of s. func (s *set) Sorted() *Array { cpy := make([]*Term, len(s.keys)) @@ -2224,10 +2107,6 @@ func (l *lazyObj) Map(f func(*Term, *Term) (*Term, *Term, error)) (Object, error return l.force().Map(f) } -func (l *lazyObj) MarshalJSON() ([]byte, error) { - return l.force().(*object).MarshalJSON() -} - func (l *lazyObj) Merge(other Object) (Object, bool) { return l.force().Merge(other) } @@ -2605,15 +2484,6 @@ func (obj *object) KeysIterator() ObjectKeysIterator { return newobjectKeysIterator(obj) } -// MarshalJSON returns JSON encoded bytes representing obj. -func (obj *object) MarshalJSON() ([]byte, error) { - sl := make([][2]*Term, obj.Len()) - for i, node := range obj.sortedKeys() { - sl[i] = Item(node.key, node.value) - } - return json.Marshal(sl) -} - // Merge returns a new Object containing the non-overlapping keys of obj and other. If there are // overlapping keys between obj and other, the values of associated with the keys are merged. Only // objects can be merged with other objects. If the values cannot be merged, the second turn value @@ -3187,6 +3057,29 @@ func isControlOrBackslash(r rune) bool { // on the happy path and treats all errors the same. If better error // reporting is needed, the error paths will need to be fleshed out. +// UnmarshalJSON parses the byte array and stores the result in term. +// Specialized unmarshalling is required to handle Value and Location. +func (term *Term) UnmarshalJSON(bs []byte) error { + v := map[string]any{} + if err := util.UnmarshalJSON(bs, &v); err != nil { + return err + } + val, err := unmarshalValue(v) + if err != nil { + return err + } + term.Value = val + + if loc, ok := v["location"].(map[string]any); ok { + term.Location = &Location{} + err := unmarshalLocation(term.Location, loc) + if err != nil { + return err + } + } + return nil +} + func unmarshalBody(b []any) (Body, error) { buf := Body{} for _, e := range b { @@ -3391,6 +3284,45 @@ func unmarshalWith(i any) (*With, error) { return nil, errors.New(`ast: unable to unmarshal with modifier (expected {"target": {...}, "value": {...}})`) } +func unmarshalLogical(typeName string, lhs, rhs *Body, explicitLhs, explicitRhs *bool, v map[string]any) error { + lhsRaw, ok := v["lhs"].([]any) + if !ok { + return fmt.Errorf("ast: unable to unmarshal %s, invalid lhs field type: %T (expected list)", typeName, v["lhs"]) + } + l, err := unmarshalBody(lhsRaw) + if err != nil { + return fmt.Errorf("ast: unable to unmarshal %s lhs: %w", typeName, err) + } + *lhs = l + + rhsRaw, ok := v["rhs"].([]any) + if !ok { + return fmt.Errorf("ast: unable to unmarshal %s, invalid rhs field type: %T (expected list)", typeName, v["rhs"]) + } + r, err := unmarshalBody(rhsRaw) + if err != nil { + return fmt.Errorf("ast: unable to unmarshal %s rhs: %w", typeName, err) + } + *rhs = r + + if x, ok := v["explicit_lhs"]; ok { + b, ok := x.(bool) + if !ok { + return fmt.Errorf("ast: unable to unmarshal %s explicit_lhs field with type: %T (expected true or false)", typeName, x) + } + *explicitLhs = b + } + if x, ok := v["explicit_rhs"]; ok { + b, ok := x.(bool) + if !ok { + return fmt.Errorf("ast: unable to unmarshal %s explicit_rhs field with type: %T (expected true or false)", typeName, x) + } + *explicitRhs = b + } + + return nil +} + func unmarshalValue(d map[string]any) (Value, error) { v := d["value"] switch d["type"] { @@ -3508,3 +3440,28 @@ func unmarshalValue(d map[string]any) (Value, error) { unmarshal_error: return nil, errors.New("ast: unable to unmarshal term") } + +func unmarshalNot(n *Not, v map[string]any) error { + var eb bool + if x, ok := v["explicit_body"]; ok { + eb, ok = x.(bool) + if !ok { + return fmt.Errorf("ast: unable to unmarshal explicit_body field with type: %T (expected true or false)", v["explicit_body"]) + } + } + + b, ok := v["body"].([]any) + if !ok { + return fmt.Errorf("ast: unable to unmarshal not, invalid body field type: %T (expected list)", v["body"]) + } + + body, err := unmarshalBody(b) + if err != nil { + return fmt.Errorf("ast: unable to unmarshal not body: %w", err) + } + + n.ExplicitBody = eb + n.Body = body + + return nil +} diff --git a/v1/ast/term_json.go b/v1/ast/term_json.go new file mode 100644 index 0000000000..685801b66d --- /dev/null +++ b/v1/ast/term_json.go @@ -0,0 +1,95 @@ +// Copyright 2026 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. + +//go:build !go1.27 + +package ast + +import ( + "encoding/json" + + astJSON "github.com/open-policy-agent/opa/v1/ast/json" + "github.com/open-policy-agent/opa/v1/util" +) + +// termJSON is used to serialize Term to JSON without map allocation. +type termJSON struct { + Location *Location `json:"location,omitempty"` + Type string `json:"type"` + Value Value `json:"value"` +} + +// MarshalJSON returns the JSON encoding of the term. +// +// Specialized marshalling logic is required to include a type hint for Value. +func (term *Term) MarshalJSON() ([]byte, error) { + d := termJSON{ + Type: ValueName(term.Value), + Value: term.Value, + } + jsonOptions := astJSON.GetOptions().MarshalOptions + if jsonOptions.IncludeLocation.Term { + d.Location = term.Location + } + return json.Marshal(d) +} + +// MarshalJSON returns JSON encoded bytes representing arr. +func (arr *Array) MarshalJSON() ([]byte, error) { + if len(arr.elems) == 0 { + return []byte(`[]`), nil + } + return json.Marshal(arr.elems) +} + +// MarshalJSON returns JSON encoded bytes representing num. +func (num Number) MarshalJSON() ([]byte, error) { + return json.Marshal(json.Number(num)) +} + +// MarshalJSON returns JSON encoded bytes representing obj. +func (obj *object) MarshalJSON() ([]byte, error) { + sl := make([][2]*Term, obj.Len()) + for i, node := range obj.sortedKeys() { + sl[i] = Item(node.key, node.value) + } + return json.Marshal(sl) +} + +// MarshalJSON returns JSON encoded bytes representing s. +func (s *set) MarshalJSON() ([]byte, error) { + if s.keys == nil { + return []byte(`[]`), nil + } + return json.Marshal(s.sortedKeys()) +} + +func (l *lazyObj) MarshalJSON() ([]byte, error) { + return l.force().(*object).MarshalJSON() +} + +func (n *Not) MarshalJSON() ([]byte, error) { + data := map[string]any{ + "type": "not", + "body": n.Body, + "explicit_body": n.ExplicitBody, + } + + if astJSON.GetOptions().MarshalOptions.IncludeLocation.Not { + if n.Location != nil { + data["location"] = n.Location + } + } + + return json.Marshal(data) +} + +func (n *Not) UnmarshalJSON(bs []byte) error { + v := map[string]any{} + if err := util.UnmarshalJSON(bs, &v); err != nil { + return err + } + + return unmarshalNot(n, v) +} diff --git a/v1/ast/term_jsonv2.go b/v1/ast/term_jsonv2.go new file mode 100644 index 0000000000..62189c3729 --- /dev/null +++ b/v1/ast/term_jsonv2.go @@ -0,0 +1,251 @@ +//go:build go1.27 + +package ast + +import ( + "encoding" + "encoding/json/jsontext" + "encoding/json/v2" + "fmt" + + "github.com/open-policy-agent/opa/internal/jsonv2" + astJSON "github.com/open-policy-agent/opa/v1/ast/json" + "github.com/open-policy-agent/opa/v1/util" +) + +var ( + _ json.MarshalerTo = &Term{} + _ json.Unmarshaler = &LogicalOr{} + _ json.MarshalerTo = &LogicalOr{} + _ json.MarshalerTo = &Not{} + _ json.MarshalerTo = &Array{} + _ json.MarshalerTo = &set{} + _ json.MarshalerTo = &object{} + _ json.MarshalerTo = &TemplateString{} + _ json.MarshalerTo = &Ref{} + _ json.MarshalerTo = &lazyObj{} + _ json.MarshalerTo = Args{} + _ json.MarshalerTo = Boolean(false) + _ json.MarshalerTo = Null{} + _ json.MarshalerTo = Number("") + _ json.MarshalerTo = String("") + _ json.MarshalerTo = Var("") + _ json.Unmarshaler = &Not{} + + // These are exported types, so losing MarshalJSON here would be a breaking + // API change even though callers should go through json.Marshal, not this + // method directly. + _ json.Marshaler = Number("") + _ json.Marshaler = &Term{} + _ json.Marshaler = &Not{} + _ json.Marshaler = &lazyObj{} + _ json.Marshaler = &object{} + _ json.Marshaler = &Array{} + _ json.Marshaler = &set{} +) + +// These are here to ensure that we do not fall down to TextAppender, which +// Go 1.27's encoding/json would otherwise use, encoding these as JSON strings. + +func (b Boolean) MarshalJSONTo(e *jsontext.Encoder) error { + return e.WriteToken(jsontext.Bool(bool(b))) +} + +func (Null) MarshalJSONTo(e *jsontext.Encoder) error { + // Encoded as an empty object rather than null, as that's the representation + // callers have come to expect. See also [marshalValueTo]. + return e.WriteValue([]byte("{}")) +} + +func (v Var) MarshalJSONTo(e *jsontext.Encoder) error { + // Must produce the var name as a JSON string, wildcard vars included: that's + // what encoding/json v1 does for a type whose underlying kind is string. + return e.WriteToken(jsontext.String(string(v))) +} + +func (num Number) MarshalJSONTo(e *jsontext.Encoder) error { + if num == "" { + // Matches encoding/json v1, which encodes an empty json.Number as 0. + return e.WriteToken(jsontext.Int(0)) + } + return e.WriteValue(jsontext.Value(num)) +} + +// MarshalJSON returns JSON encoded bytes representing num. +func (num Number) MarshalJSON() ([]byte, error) { + return jsonv2.MarshalMarshalerTo(num) +} + +func (str String) MarshalJSONTo(e *jsontext.Encoder) error { + return e.WriteToken(jsontext.String(string(str))) +} + +func (t *Term) MarshalJSONTo(e *jsontext.Encoder) (err error) { + // Token write errors are unchecked: an unbalanced value fails at the closing + // token. A marshaller can fail having written a balanced value, so is checked. + e.WriteToken(jsontext.BeginObject) + + includeLocation := astJSON.GetOptions().MarshalOptions.IncludeLocation + if t.Location != nil && includeLocation.Term { + if err := jsonv2.WriteField(e, "location", t.Location); err != nil { + return err + } + } + + e.WriteToken(jsontext.String("type")) + e.WriteToken(jsontext.String(ValueName(t.Value))) + + e.WriteToken(jsontext.String("value")) + if err = marshalValueTo(e, t.Value); err != nil { + return fmt.Errorf("failed to marshal term of %s: %w", ValueName(t.Value), err) + } + + return e.WriteToken(jsontext.EndObject) +} + +// MarshalJSON returns the JSON encoding of the term. +func (term *Term) MarshalJSON() ([]byte, error) { + return jsonv2.MarshalMarshalerTo(term) +} + +func (r Ref) MarshalJSONTo(e *jsontext.Encoder) (err error) { + return jsonv2.WriteMarshalerToArrayOrNull(e, r) +} + +func (t *TemplateString) MarshalJSONTo(e *jsontext.Encoder) (err error) { + // Token write errors are unchecked: an unbalanced value fails at the closing + // token. A marshaller can fail having written a balanced value, so is checked. + e.WriteToken(jsontext.BeginObject) + e.WriteToken(jsontext.String("parts")) + if t.Parts == nil { + // Parts has no omitempty tag, so it's always written. Matches + // encoding/json v1, which encodes a nil slice as null rather than as an + // empty array. + e.WriteToken(jsontext.Null) + } else { + e.WriteToken(jsontext.BeginArray) + for _, p := range t.Parts { + switch v := p.(type) { + case *Expr: + if err := v.MarshalJSONTo(e); err != nil { + return err + } + case *Term: + if err := v.MarshalJSONTo(e); err != nil { + return err + } + } + } + e.WriteToken(jsontext.EndArray) + } + + e.WriteToken(jsontext.String("multi_line")) + e.WriteToken(jsontext.Bool(t.MultiLine)) + + return e.WriteToken(jsontext.EndObject) +} + +func (n *Not) MarshalJSONTo(e *jsontext.Encoder) error { + e.WriteToken(jsontext.BeginObject) + e.WriteToken(jsontext.String("type")) + e.WriteToken(jsontext.String("not")) + + if err := jsonv2.WriteField(e, "body", n.Body); err != nil { + return err + } + + e.WriteToken(jsontext.String("explicit_body")) + e.WriteToken(jsontext.Bool(n.ExplicitBody)) + + if astJSON.GetOptions().MarshalOptions.IncludeLocation.Not && n.Location != nil { + if err := jsonv2.WriteField(e, "location", n.Location); err != nil { + return err + } + } + + return e.WriteToken(jsontext.EndObject) +} + +func (n *Not) MarshalJSON() ([]byte, error) { + return jsonv2.MarshalMarshalerTo(n) +} + +func (n *Not) UnmarshalJSON(bs []byte) error { + v := map[string]any{} + if err := util.UnmarshalJSON(bs, &v); err != nil { + return err + } + + return unmarshalNot(n, v) +} + +func (obj *object) MarshalJSONTo(e *jsontext.Encoder) error { + // Token write errors are unchecked: an unbalanced value fails at the closing + // token. A marshaller can fail having written a balanced value, so is checked. + e.WriteToken(jsontext.BeginArray) + + for _, node := range obj.sortedKeys() { + e.WriteToken(jsontext.BeginArray) + if err := node.key.MarshalJSONTo(e); err != nil { + return err + } + if err := node.value.MarshalJSONTo(e); err != nil { + return err + } + e.WriteToken(jsontext.EndArray) + } + return e.WriteToken(jsontext.EndArray) +} + +func (l *lazyObj) MarshalJSONTo(e *jsontext.Encoder) error { + return l.force().(*object).MarshalJSONTo(e) +} + +func (l *lazyObj) MarshalJSON() ([]byte, error) { + return l.force().(*object).MarshalJSON() +} + +// MarshalJSON returns JSON encoded bytes representing obj. +func (obj *object) MarshalJSON() ([]byte, error) { + return jsonv2.MarshalMarshalerTo(obj) +} + +func (a *Array) MarshalJSONTo(e *jsontext.Encoder) error { + return jsonv2.WriteMarshalerToArray(e, a.elems) +} + +// MarshalJSON returns JSON encoded bytes representing arr. +func (arr *Array) MarshalJSON() ([]byte, error) { + return jsonv2.MarshalMarshalerTo(arr) +} + +func (s *set) MarshalJSONTo(e *jsontext.Encoder) error { + return jsonv2.WriteMarshalerToArray(e, s.sortedKeys()) +} + +// MarshalJSON returns JSON encoded bytes representing s. +func (s *set) MarshalJSON() ([]byte, error) { + return jsonv2.MarshalMarshalerTo(s) +} + +func marshalValueTo(e *jsontext.Encoder, val Value) (err error) { + switch v := val.(type) { + case json.MarshalerTo: + err = v.MarshalJSONTo(e) + case encoding.TextAppender: + var text []byte + if text, err = v.AppendText(nil); err != nil { + return err + } + + if text, err = jsontext.AppendQuote(e.AvailableBuffer(), text); err != nil { + return err + } + + err = e.WriteValue(text) + default: + err = json.MarshalEncode(e, v) + } + + return err +} diff --git a/v1/ast/term_jsonv2_test.go b/v1/ast/term_jsonv2_test.go new file mode 100644 index 0000000000..ba7c50ade5 --- /dev/null +++ b/v1/ast/term_jsonv2_test.go @@ -0,0 +1,67 @@ +// Copyright 2026 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. + +//go:build go1.27 + +package ast + +import ( + "bytes" + "encoding/json/jsontext" + "errors" + "strings" + "testing" +) + +// textAppenderValue is a minimal Value that only implements +// encoding.TextAppender (not json.MarshalerTo), to exercise the +// TextAppender fallback branch of marshalValueTo. +type textAppenderValue struct { + text string + err error +} + +func (textAppenderValue) Compare(Value) int { return 0 } +func (textAppenderValue) Find(Ref) (Value, error) { return nil, nil } +func (textAppenderValue) Hash() int { return 0 } +func (textAppenderValue) IsGround() bool { return true } +func (v textAppenderValue) String() string { return v.text } +func (v textAppenderValue) StringLength() int { return len(v.text) } + +func (v textAppenderValue) AppendText(buf []byte) ([]byte, error) { + if v.err != nil { + return buf, v.err + } + return append(buf, v.text...), nil +} + +func TestMarshalValueToTextAppenderError(t *testing.T) { + wantErr := errors.New("boom") + v := textAppenderValue{err: wantErr} + + enc := jsontext.NewEncoder(new(bytes.Buffer)) + err := marshalValueTo(enc, v) + if err == nil { + t.Fatalf("expected error from AppendText to be propagated, got nil") + } + if !errors.Is(err, wantErr) { + t.Fatalf("expected wrapped error %v, got %v", wantErr, err) + } +} + +func TestMarshalValueToTextAppenderQuoting(t *testing.T) { + v := textAppenderValue{text: "2026-01-01T00:00:00Z"} + + var sb bytes.Buffer + enc := jsontext.NewEncoder(&sb) + if err := marshalValueTo(enc, v); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got := strings.TrimSpace(sb.String()) + want := `"2026-01-01T00:00:00Z"` + if got != want { + t.Fatalf("expected quoted JSON string %q, got %q", want, got) + } +} diff --git a/v1/ast/term_test.go b/v1/ast/term_test.go index 1f074b3342..30c918db5f 100644 --- a/v1/ast/term_test.go +++ b/v1/ast/term_test.go @@ -119,7 +119,7 @@ func TestInterfaceToValueStructs(t *testing.T) { var m brokenMarshaller _, err = InterfaceToValue(m) - if err == nil || err.Error() != "ast: interface conversion: json: error calling MarshalJSON for type ast.brokenMarshaller: broken" { + if err == nil || !strings.Contains(err.Error(), "ast: interface conversion: json: error calling MarshalJSON for type") { t.Fatal("expected error but got:", err) } } diff --git a/v1/keys/keys_test.go b/v1/keys/keys_test.go index 1b2b712c06..45f254d77e 100644 --- a/v1/keys/keys_test.go +++ b/v1/keys/keys_test.go @@ -6,6 +6,7 @@ import ( "maps" "os" "path/filepath" + "strings" "testing" "github.com/open-policy-agent/opa/v1/util/test" @@ -56,7 +57,7 @@ func TestParseKeysConfig(t *testing.T) { "invalid_raw_config": { `[1,2,3]`, nil, - true, errors.New("json: cannot unmarshal array into Go value of type map[string]json.RawMessage"), + true, errors.New("json: cannot unmarshal array into Go value of type"), }, } @@ -68,8 +69,13 @@ func TestParseKeysConfig(t *testing.T) { t.Fatal("Expected error but got nil") } - if tc.err != nil && tc.err.Error() != err.Error() { - t.Fatalf("Expected error message %v but got %v", tc.err.Error(), err.Error()) + if tc.err != nil { + exp := tc.err.Error() + got := err.Error() + + if !strings.HasPrefix(got, exp) { + t.Fatalf("Expected error message %v but got %v", exp, got) + } } } else if err != nil { t.Fatalf("Unexpected error %v", err) diff --git a/v1/plugins/logs/encoder.go b/v1/plugins/logs/encoder.go index 13bda6e950..37964b95b1 100644 --- a/v1/plugins/logs/encoder.go +++ b/v1/plugins/logs/encoder.go @@ -53,6 +53,10 @@ type chunkEncoder struct { uncompressedLimit int64 uncompressedLimitScaleUpExponent float64 uncompressedLimitScaleDownExponent float64 + + // scalingDown records that a scaleDown is already in progress further up the + // stack, so a nested one that cannot lower the limit knows it would cycle + scalingDown bool } func newChunkEncoder(limit int64) *chunkEncoder { @@ -286,6 +290,8 @@ func (enc *chunkEncoder) Encode(event EventV1, eventBytes []byte) ([][]byte, err } func (enc *chunkEncoder) scaleDown(events []EventV1) ([][]byte, error) { + reduced := false + if enc.uncompressedLimit > enc.limit { enc.incrMetric(encUncompressedLimitScaleDownCounterName) enc.incrMetric(encSoftLimitScaleDownCounterName) @@ -300,11 +306,23 @@ func (enc *chunkEncoder) scaleDown(events []EventV1) ([][]byte, error) { if enc.uncompressedLimitScaleUpExponent > 0 { enc.uncompressedLimitScaleUpExponent -= uncompressedLimitExponentScaleFactor } + + reduced = true } // The uncompressed limit has grown too large the events need to be split up into multiple chunks enc.initialize() + // A nested call that can't lower the limit further would re-encode the same + // events into the same branch, recursing until the stack is exhausted. + // Closing the chunk per event avoids it, as Encode never then reaches that + // branch. Surfaced by Go 1.27's compress/flate sizes, but not specific to it. + oneChunkPerEvent := enc.scalingDown && !reduced + + wasScalingDown := enc.scalingDown + enc.scalingDown = true + defer func() { enc.scalingDown = wasScalingDown }() + // split the events into multiple chunks var result [][]byte for i := range events { @@ -322,6 +340,16 @@ func (enc *chunkEncoder) scaleDown(events []EventV1) ([][]byte, error) { if chunks != nil { result = append(result, chunks...) } + + if oneChunkPerEvent { + chunk, err := enc.reset() + if err != nil { + return nil, err + } + if chunk != nil { + result = append(result, chunk) + } + } } return result, nil diff --git a/v1/plugins/logs/encoder_test.go b/v1/plugins/logs/encoder_test.go index 943957314c..4d5df1f706 100644 --- a/v1/plugins/logs/encoder_test.go +++ b/v1/plugins/logs/encoder_test.go @@ -215,7 +215,10 @@ func TestChunkEncoder(t *testing.T) { func TestChunkEncoderSizeLimit(t *testing.T) { t.Parallel() - enc := newChunkEncoder(90).WithMetrics(metrics.New()) + // The limit has to sit just above the compressed size of the smallest event + // (87 bytes on Go 1.26, 95 on Go 1.27) for this test to exercise the + // encoder's equilibrium path on every Go version. + enc := newChunkEncoder(96).WithMetrics(metrics.New()) var result any = false var expInput any = map[string]any{"method": "GET"} ts, err := time.Parse(time.RFC3339Nano, "2018-01-01T12:00:00.123456Z") @@ -243,9 +246,9 @@ func TestChunkEncoderSizeLimit(t *testing.T) { t.Fatal(err) } // expect the event to be written because it fits the minimum event size - expectedBufferSize := 78 // the compressed size of an absurd small event - if enc.buf.Len() != expectedBufferSize { - t.Errorf("Expected %v buffer size but got: %v", expectedBufferSize, enc.buf.Len()) + // No exact compressed size here: gzip output differs between Go versions. + if enc.buf.Len() == 0 { + t.Error("Expected the event to have been written to the buffer") } expectedBytesWritten := 69 // the uncompressed size of the event if enc.bytesWritten != expectedBytesWritten { @@ -293,10 +296,8 @@ func TestChunkEncoderSizeLimit(t *testing.T) { if err := enc.w.Flush(); err != nil { t.Fatal(err) } - expectedBufferSize = 15 - if enc.buf.Len() != expectedBufferSize { - t.Errorf("Expected %v buffer size but got: %v", expectedBufferSize, enc.buf.Len()) - } + // Nothing was written to the fresh buffer, so only the gzip header is in it. + // Its exact size isn't asserted, see above. expectedBytesWritten = 0 if enc.bytesWritten != expectedBytesWritten { t.Errorf("Expected %v bytes written but got: %v", expectedBytesWritten, enc.bytesWritten) diff --git a/v1/plugins/logs/eventBuffer_test.go b/v1/plugins/logs/eventBuffer_test.go index c87e35883d..02bce5e5fb 100644 --- a/v1/plugins/logs/eventBuffer_test.go +++ b/v1/plugins/logs/eventBuffer_test.go @@ -231,9 +231,15 @@ func TestEventBuffer_Upload(t *testing.T) { numberOfEvents: 4, uploadSizeLimitBytes: 196, // Each test event is 195 bytes handleFunc: func(w http.ResponseWriter, r *http.Request) { + // No. of events that fit in a chunk depends on how gzip packs + // them, which varies between Go versions. So here we confirm + // the len and that we get at least one event. + if r.ContentLength > 196 { + t.Errorf("uploaded chunk of %d bytes exceeds the limit of 196", r.ContentLength) + } events := decodeLogEvent(t, r.Body) - if len(events) != 1 { - t.Errorf("expected 1 events, got %d", len(events)) + if len(events) == 0 { + t.Error("expected a chunk to hold at least one event") } allEvents = append(allEvents, events...) w.WriteHeader(http.StatusOK) diff --git a/v1/server/handlers/compress_test.go b/v1/server/handlers/compress_test.go index 525c97957b..bd2e566cd3 100644 --- a/v1/server/handlers/compress_test.go +++ b/v1/server/handlers/compress_test.go @@ -154,7 +154,10 @@ func TestHandlerOnEndpointsWithoutCompression(t *testing.T) { func zipString(input string) []byte { var b bytes.Buffer - gz := gzip.NewWriter(&b) + gz, err := gzip.NewWriterLevel(&b, defaultCompressionLevel) + if err != nil { + log.Fatal(err) + } if _, err := gz.Write([]byte(input)); err != nil { log.Fatal(err) } diff --git a/v1/server/server_test.go b/v1/server/server_test.go index 9418ee3a7e..aa3ad0fa3e 100644 --- a/v1/server/server_test.go +++ b/v1/server/server_test.go @@ -5722,7 +5722,7 @@ func newStreamedReqUnversioned(method string, path string, body io.Reader) *http func mustUnmarshalTrace(t types.TraceV1) (trace types.TraceV1Raw) { if err := json.Unmarshal(t, &trace); err != nil { - panic("not reached") + panic(err) } return trace } diff --git a/v1/topdown/errors_jsonv2.go b/v1/topdown/errors_jsonv2.go new file mode 100644 index 0000000000..29b6f3761e --- /dev/null +++ b/v1/topdown/errors_jsonv2.go @@ -0,0 +1,24 @@ +//go:build go1.27 + +package topdown + +import ( + "encoding/json/jsontext" + "errors" + + "github.com/open-policy-agent/opa/internal/jsonv2" +) + +func (e *Error) MarshalJSONTo(enc *jsontext.Encoder) (err error) { + enc.WriteToken(jsontext.BeginObject) + enc.WriteToken(jsontext.String("code")) + enc.WriteToken(jsontext.String(e.Code)) + enc.WriteToken(jsontext.String("message")) + enc.WriteToken(jsontext.String(e.Message)) + + if e.Location != nil { + err = jsonv2.WriteField(enc, "location", e.Location) + } + + return errors.Join(err, enc.WriteToken(jsontext.EndObject)) +} diff --git a/v1/topdown/http_slow_test.go b/v1/topdown/http_slow_test.go index 6df27658d6..640dd95853 100644 --- a/v1/topdown/http_slow_test.go +++ b/v1/topdown/http_slow_test.go @@ -169,12 +169,26 @@ func TestHTTPSendRetryRequest(t *testing.T) { } })) - defer ts.Close() + t.Cleanup(ts.Close) - // delay server start to exercise retry logic + // delay server start to exercise retry logic. Guarded because a + // subtest can finish, and so close the server, before the delay + // elapses: starting a closed server panics on Go 1.27+. Cleanups + // run LIFO, so this marks the server closed before ts.Close runs. + var mu sync.Mutex + closed := false + t.Cleanup(func() { + mu.Lock() + defer mu.Unlock() + closed = true + }) go func() { time.Sleep(time.Second * 5) - ts.Start() + mu.Lock() + defer mu.Unlock() + if !closed { + ts.Start() + } }() ctx := context.Background()