From 72d7616d636a981480360f47fd9dfd7edaeac97b Mon Sep 17 00:00:00 2001 From: Mohammad Abdolirad Date: Fri, 9 Oct 2026 16:14:24 +0200 Subject: [PATCH 1/3] add CEL assertions for test checks - evaluate boolean expressions without a cluster - reject missing fields, bad types, and huge integers - leave DiffSubset and the e2e runner unchanged --- flake.nix | 2 +- go.mod | 4 + go.sum | 6 + internal/testing/celassert/doc.go | 27 + internal/testing/celassert/program.go | 350 +++++++++++ internal/testing/celassert/program_test.go | 699 +++++++++++++++++++++ 6 files changed, 1087 insertions(+), 1 deletion(-) create mode 100644 internal/testing/celassert/doc.go create mode 100644 internal/testing/celassert/program.go create mode 100644 internal/testing/celassert/program_test.go diff --git a/flake.nix b/flake.nix index 0b080db..def5ced 100644 --- a/flake.nix +++ b/flake.nix @@ -26,7 +26,7 @@ buildGoModule' = pkgs.buildGoModule.override { inherit go; }; - deployahVendorHash = "sha256-T+jH1DfrvVfMYhKjotGhOEvLrbcQOt4HPQ/UlDC+jhQ="; + deployahVendorHash = "sha256-pNmpi+JBSbbm19Ynya0/tesKcHtt5Lg3VpG5lzPs6P8="; inherit (pkgs) golangci-lint gopls; diff --git a/go.mod b/go.mod index d4e6145..5e9aeb5 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module deployah.dev/deployah go 1.27.0 require ( + cel.dev/cel-go v0.32.0 dario.cat/mergo v1.0.2 github.com/Masterminds/sprig/v3 v3.3.0 github.com/adrg/xdg v0.5.3 @@ -41,6 +42,7 @@ require ( require ( al.essio.dev/pkg/shellescape v1.6.0 // indirect + cel.dev/expr v0.25.3 // indirect charm.land/bubbles/v2 v2.1.1 // indirect charm.land/bubbletea/v2 v2.0.8 // indirect charm.land/glamour/v2 v2.0.1 // indirect @@ -56,6 +58,7 @@ require ( github.com/Microsoft/hcsshim v0.15.0-rc.4 // indirect github.com/ProtonMail/go-crypto v1.5.1 // indirect github.com/alecthomas/chroma/v2 v2.27.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymerick/douceur v0.2.0 // indirect @@ -212,6 +215,7 @@ require ( golang.org/x/sys v0.48.0 // indirect golang.org/x/text v0.42.0 // indirect golang.org/x/time v0.16.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260921155816-b14227669459 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260921155816-b14227669459 // indirect google.golang.org/grpc v1.85.0-dev.0.20260825072537-93e31b48545e // indirect google.golang.org/protobuf v1.36.12 // indirect diff --git a/go.sum b/go.sum index c731a4b..0a2d3b4 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= +cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk= +cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU= +cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0= +cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= charm.land/bubbles/v2 v2.1.1 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60= charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo= charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY= @@ -46,6 +50,8 @@ github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= diff --git a/internal/testing/celassert/doc.go b/internal/testing/celassert/doc.go new file mode 100644 index 0000000..3fd08e2 --- /dev/null +++ b/internal/testing/celassert/doc.go @@ -0,0 +1,27 @@ +// Copyright 2026 The Deployah Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package celassert evaluates Boolean Common Expression Language (CEL) +// assertions against JSON-like values. +// +// Programs are test infrastructure. Production deployment code must not +// import this package. The standard CEL library is the only language +// surface, so an expression cannot read files, run commands, or change +// Kubernetes. +// +// Simple Kubernetes subset checks stay with DiffSubset in the parent +// testing package. celassert does not replace that matcher. +// +// Compile an expression once and call [Program.Eval] for each input. +package celassert diff --git a/internal/testing/celassert/program.go b/internal/testing/celassert/program.go new file mode 100644 index 0000000..016bdae --- /dev/null +++ b/internal/testing/celassert/program.go @@ -0,0 +1,350 @@ +// Copyright 2026 The Deployah Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package celassert + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "cel.dev/cel-go/cel" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +const ( + // DefaultCostLimit is the runtime CEL cost bound for one evaluation. + // It counts estimated CEL operations, not time. + DefaultCostLimit uint64 = 1_000_000 + + // DefaultInterruptCheckFrequency is how many comprehension steps run + // between context checks. + DefaultInterruptCheckFrequency uint = 100 +) + +var ( + // ErrFalse means the expression evaluated to false. + ErrFalse = errors.New("expression evaluated to false") + + // ErrNotBool means the expression result is not a Boolean. + ErrNotBool = errors.New("expression result is not bool") + + // ErrIntegerRange means a digit-only JSON integer does not fit in int64. + ErrIntegerRange = errors.New("integer outside int64") + + errNilContext = errors.New("context is nil") + errNilUnstructured = errors.New("unstructured object is nil") +) + +// Program is a compiled Boolean CEL expression. +// +// Build one with [Compile]. The zero value is not usable. +// [Program.Eval] is safe for concurrent callers. +type Program struct { + expression string + prog cel.Program +} + +// Option configures Compile. +type Option func(*config) + +type config struct { + vars []string + cost uint64 +} + +// WithVariable declares a dynamic root variable. +// +// An expression that uses any other root fails at compile time. Duplicate +// names are rejected. +// +// Example: +// +// Compile(`object.kind == "Pod"`, WithVariable("object")) +func WithVariable(name string) Option { + return func(cfg *config) { + cfg.vars = append(cfg.vars, name) + } +} + +// WithCostLimit sets the runtime cost limit for one evaluation. +// +// The limit must be greater than zero. It replaces [DefaultCostLimit]. +// +// Example: +// +// Compile(`items.all(i, true)`, WithCostLimit(1), WithVariable("items")) +func WithCostLimit(limit uint64) Option { + return func(cfg *config) { + cfg.cost = limit + } +} + +// Compile type-checks expression and returns a program for repeated +// evaluation. +// +// Roots come from [WithVariable]. The cost limit defaults to +// [DefaultCostLimit]. A concrete non-Boolean result is [ErrNotBool]. +// +// Example: +// +// p, err := Compile( +// `object.status.readyReplicas == 1`, +// WithVariable("object"), +// ) +func Compile(expression string, opts ...Option) (*Program, error) { + cfg := config{cost: DefaultCostLimit} + for _, opt := range opts { + opt(&cfg) + } + if err := cfg.validate(expression); err != nil { + return nil, err + } + + envOpts := make([]cel.EnvOption, 0, 1+len(cfg.vars)) + envOpts = append(envOpts, cel.OptionalTypes()) + for _, name := range cfg.vars { + envOpts = append(envOpts, cel.Variable(name, cel.DynType)) + } + env, err := cel.NewEnv(envOpts...) + if err != nil { + return nil, fmt.Errorf("compiling expression: %w", err) + } + ast, iss := env.Compile(expression) + if iss != nil && iss.Err() != nil { + return nil, fmt.Errorf("compiling expression: %w", iss.Err()) + } + out := ast.OutputType() + // dyn is checked again in Eval. A field read has no static type. + if !out.IsExactType(cel.BoolType) && !out.IsExactType(cel.DynType) { + return nil, fmt.Errorf("compiling expression: got %s: %w", out.TypeName(), ErrNotBool) + } + prog, err := env.Program(ast, + cel.CostLimit(cfg.cost), + cel.InterruptCheckFrequency(DefaultInterruptCheckFrequency), + ) + if err != nil { + return nil, fmt.Errorf("compiling expression: %w", err) + } + return &Program{expression: expression, prog: prog}, nil +} + +func (cfg config) validate(expression string) error { + if strings.TrimSpace(expression) == "" { + return errors.New("expression is empty") + } + if cfg.cost == 0 { + return errors.New("cost limit must be greater than zero") + } + seen := make(map[string]struct{}, len(cfg.vars)) + for _, name := range cfg.vars { + if name == "" { + return errors.New("variable name is empty") + } + if _, ok := seen[name]; ok { + return fmt.Errorf("duplicate variable %q", name) + } + seen[name] = struct{}{} + } + return nil +} + +// Eval reports whether the compiled expression is true for vars. +// +// Nil means true. [ErrFalse] means false. [ErrNotBool] means the result +// was not a Boolean. ctx must be non-nil. +// +// Example: +// +// err = p.Eval(ctx, map[string]interface{}{ +// "object": map[string]interface{}{ +// "status": map[string]interface{}{ +// "readyReplicas": 1, +// }, +// }, +// }) +func (p *Program) Eval(ctx context.Context, vars map[string]interface{}) error { + if ctx == nil { + return fmt.Errorf("evaluating %s: %w", p.expression, errNilContext) + } + // An already done context fails here, before CEL, including for an + // expression with no comprehension. [errors.Is] matches + // [context.Canceled] and [context.DeadlineExceeded]. + if err := ctx.Err(); err != nil { + return fmt.Errorf("evaluating %s: %w", p.expression, err) + } + prepared, err := prepare(vars) + if err != nil { + return fmt.Errorf("evaluating %s: %w", p.expression, err) + } + val, _, err := p.prog.ContextEval(ctx, prepared) + if err != nil { + return fmt.Errorf("evaluating %s: %w", p.expression, err) + } + result, ok := val.Value().(bool) + if !ok { + return fmt.Errorf("evaluating %s: got %s: %w", p.expression, val.Type().TypeName(), ErrNotBool) + } + if !result { + return fmt.Errorf("evaluating %s: %w", p.expression, ErrFalse) + } + return nil +} + +func prepare(vars map[string]interface{}) (map[string]interface{}, error) { + if len(vars) == 0 { + return map[string]interface{}{}, nil + } + out := make(map[string]interface{}, len(vars)) + for name, value := range vars { + normalized, err := normalize(value) + if err != nil { + return nil, err + } + if intErr := checkIntegers(normalized); intErr != nil { + return nil, intErr + } + out[name] = normalized + } + return out, nil +} + +func normalize(value interface{}) (interface{}, error) { + switch v := value.(type) { + case json.RawMessage: + return decodeJSON(v) + case unstructured.Unstructured: + return unstructuredObject(v.Object) + case *unstructured.Unstructured: + if v == nil { + return nil, errNilUnstructured + } + return unstructuredObject(v.Object) + case unstructured.UnstructuredList: + return unstructuredObjects(v.Items) + case *unstructured.UnstructuredList: + if v == nil { + return nil, errNilUnstructured + } + return unstructuredObjects(v.Items) + case []unstructured.Unstructured: + return unstructuredObjects(v) + case []*unstructured.Unstructured: + out := make([]interface{}, 0, len(v)) + for i := range v { + item, err := normalize(v[i]) + if err != nil { + return nil, err + } + out = append(out, item) + } + return out, nil + default: + return value, nil + } +} + +func unstructuredObject(obj map[string]interface{}) (interface{}, error) { + if obj == nil { + return nil, errNilUnstructured + } + return obj, nil +} + +func unstructuredObjects(items []unstructured.Unstructured) (interface{}, error) { + out := make([]interface{}, 0, len(items)) + for i := range items { + obj, err := unstructuredObject(items[i].Object) + if err != nil { + return nil, err + } + out = append(out, obj) + } + return out, nil +} + +func decodeJSON(raw json.RawMessage) (interface{}, error) { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var value interface{} + if err := dec.Decode(&value); err != nil { + return nil, fmt.Errorf("decoding JSON input: %w", err) + } + return value, nil +} + +func checkIntegers(value interface{}) error { + switch v := value.(type) { + case json.Number: + return checkInteger(v) + case []json.Number: + for _, n := range v { + if err := checkInteger(n); err != nil { + return err + } + } + case map[string]interface{}: + for _, child := range v { + if err := checkIntegers(child); err != nil { + return err + } + } + case []map[string]interface{}: + for _, child := range v { + if err := checkIntegers(child); err != nil { + return err + } + } + case []interface{}: + for _, child := range v { + if err := checkIntegers(child); err != nil { + return err + } + } + } + return nil +} + +func checkInteger(n json.Number) error { + if _, err := n.Int64(); err == nil { + return nil + } + // Fractions and exponent spellings are not digit-only, so CEL keeps them. + if !decimalInteger(string(n)) { + return nil + } + // Name this number only. Leave the rest of the input out of the error. + return fmt.Errorf("json number %s: %w", n, ErrIntegerRange) +} + +func decimalInteger(s string) bool { + if s == "" { + return false + } + if s[0] == '-' { + s = s[1:] + } + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} diff --git a/internal/testing/celassert/program_test.go b/internal/testing/celassert/program_test.go new file mode 100644 index 0000000..3201dd1 --- /dev/null +++ b/internal/testing/celassert/program_test.go @@ -0,0 +1,699 @@ +// Copyright 2026 The Deployah Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package celassert_test + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/yaml" + + "deployah.dev/deployah/internal/testing/celassert" + + k8sjson "k8s.io/apimachinery/pkg/util/json" +) + +// plantedSecret is an unused input value. Error text must not contain it. +const plantedSecret = "super-secret-value" + +// Fixed fixture UIDs. They are not read from a Kubernetes client. +const ( + migrateUID = "11111111-1111-1111-1111-111111111111" + seedUID = "22222222-2222-2222-2222-222222222222" +) + +func TestCompile_RejectsInvalidExpressions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + expression string + opts []celassert.Option + contains string + }{ + { + name: "empty", + expression: " ", + contains: "empty", + }, + { + name: "syntax", + expression: "object..status", + opts: []celassert.Option{celassert.WithVariable("object")}, + contains: ":1:", + }, + { + name: "unknown root", + expression: "missing.size() == 0", + opts: []celassert.Option{celassert.WithVariable("plan")}, + contains: "undeclared reference", + }, + { + name: "static double equals int", + expression: "1.0 == 1", + contains: "no matching overload", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := celassert.Compile(tt.expression, tt.opts...) + require.Error(t, err) + assert.NotErrorIs(t, err, celassert.ErrNotBool) + assert.ErrorContains(t, err, tt.contains) + }) + } +} + +func TestCompile_NonBool(t *testing.T) { + t.Parallel() + + _, err := celassert.Compile("1 + 1") + require.ErrorIs(t, err, celassert.ErrNotBool) +} + +func TestCompile_RejectsBadOptions(t *testing.T) { + t.Parallel() + + duplicate := []celassert.Option{ + celassert.WithVariable("plan"), + celassert.WithVariable("plan"), + } + tests := []struct { + name string + opts []celassert.Option + contains string + }{ + { + name: "zero cost", + opts: []celassert.Option{celassert.WithCostLimit(0)}, + contains: "cost limit", + }, + { + name: "duplicate variable", + opts: duplicate, + contains: "duplicate variable", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := celassert.Compile("true", tt.opts...) + require.Error(t, err) + assert.ErrorContains(t, err, tt.contains) + }) + } +} + +func TestEval_EmptyPlan(t *testing.T) { + t.Parallel() + + // Source: internal/plan/view/testdata/golden/json_empty.golden + plan := decodeDocument(t, `{ + "helmAction": "none", + "changes": [], + "drift": [], + "summary": {"create": 0, "update": 0, "delete": 0, "total": 0} + }`) + p := mustCompile(t, + `plan.changes.size() == 0 && plan.drift.size() == 0 && plan.helmAction == "none"`, + celassert.WithVariable("plan"), + ) + vars := map[string]interface{}{"plan": plan} + require.NoError(t, p.Eval(t.Context(), vars)) + require.NoError(t, p.Eval(t.Context(), vars)) +} + +func TestEval_NonEmptyPlan(t *testing.T) { + t.Parallel() + + // Source: internal/plan/view/testdata/golden/json_update.golden + plan := decodeDocument(t, `{ + "helmAction": "upgrade", + "changes": [{ + "resource": {"apiVersion": "v1", "kind": "ConfigMap", "namespace": "prod", "name": "app"}, + "action": "update", + "fields": [ + {"path": "/a", "op": "replace", "before": "1", "after": "2"}, + {"path": "/z", "op": "replace", "before": "1", "after": "2"} + ] + }], + "drift": [], + "summary": {"create": 0, "update": 1, "delete": 0, "total": 1} + }`) + p := mustCompile(t, + `plan.changes.size() == 1 && plan.changes[0].action == "update" && plan.summary.update == 1`, + celassert.WithVariable("plan"), + ) + require.NoError(t, p.Eval(t.Context(), map[string]interface{}{"plan": plan})) +} + +func TestEval_DriftCounts(t *testing.T) { + t.Parallel() + + // Shapes from sampleDriftPlan in internal/plan/view/schema_test.go. + plan := decodeDocument(t, `{ + "drift": [ + { + "action": "modified", + "resource": {"apiVersion": "v1", "kind": "ConfigMap", "name": "app"}, + "fields": [{"path": "/data/key", "op": "replace", "before": "old", "after": "new"}] + }, + { + "action": "missing", + "resource": {"apiVersion": "v1", "kind": "ConfigMap", "name": "other"} + }, + { + "action": "unexpected", + "resource": {"apiVersion": "v1", "kind": "ConfigMap", "name": "extra"} + } + ] + }`) + p := mustCompile(t, ` + plan.drift.filter(d, d.action == "modified").size() == 1 + && plan.drift.filter(d, d.action == "missing").size() == 1 + && plan.drift.filter(d, d.action == "unexpected").size() == 1 + && plan.drift.filter(d, d.action == "modified" && d.resource.kind == "ConfigMap" && d.resource.name == "app").size() == 1 + `, celassert.WithVariable("plan")) + require.NoError(t, p.Eval(t.Context(), map[string]interface{}{"plan": plan})) +} + +func TestEval_AbsentAndNull(t *testing.T) { + t.Parallel() + + // Source: scenarios/task-schedule/e2e.yaml + present := decodeDocument(t, ` +apiVersion: batch/v1 +kind: CronJob +metadata: + name: taskcron-dev-cleanup + labels: + deployah.dev/project: taskcron + deployah.dev/component: cleanup + annotations: + helm.sh/hook: null +spec: + schedule: "@every 1h" + startingDeadlineSeconds: null +`) + absent := decodeDocument(t, ` +apiVersion: batch/v1 +kind: CronJob +metadata: + name: taskcron-dev-cleanup +spec: + schedule: "@every 1h" +`) + + nullExpr := mustCompile(t, ` + has(object.spec.startingDeadlineSeconds) + && object.spec.startingDeadlineSeconds == null + && object.metadata.annotations["helm.sh/hook"] == null + `, celassert.WithVariable("object")) + require.NoError(t, nullExpr.Eval(t.Context(), map[string]interface{}{"object": present})) + + absentExpr := mustCompile(t, ` + !has(object.spec.startingDeadlineSeconds) + && !has(object.status) + && object.?status.orValue({}).size() == 0 + `, celassert.WithVariable("object")) + require.NoError(t, absentExpr.Eval(t.Context(), map[string]interface{}{"object": absent})) + err := absentExpr.Eval(t.Context(), map[string]interface{}{"object": present}) + require.ErrorIs(t, err, celassert.ErrFalse) + + readMissing := mustCompile(t, + `object.spec.startingDeadlineSeconds == null`, + celassert.WithVariable("object"), + ) + err = readMissing.Eval(t.Context(), map[string]interface{}{"object": absent}) + require.Error(t, err) + assert.NotErrorIs(t, err, celassert.ErrFalse) + assert.ErrorContains(t, err, "no such key") +} + +func TestEval_OmittedBinding(t *testing.T) { + t.Parallel() + + p := mustCompile(t, `plan.changes.size() == 0`, celassert.WithVariable("plan")) + err := p.Eval(t.Context(), map[string]interface{}{}) + require.Error(t, err) + assert.NotErrorIs(t, err, celassert.ErrFalse) +} + +func TestEval_NonBoolValue(t *testing.T) { + t.Parallel() + + p := mustCompile(t, `object.metadata.name`, celassert.WithVariable("object")) + err := p.Eval(t.Context(), map[string]interface{}{ + "object": map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "basic-web-service-dev", + "note": plantedSecret, + }, + }, + }) + require.ErrorIs(t, err, celassert.ErrNotBool) + assert.ErrorContains(t, err, "string") + assert.NotContains(t, err.Error(), plantedSecret) +} + +func TestEval_MalformedJSON(t *testing.T) { + t.Parallel() + + p := mustCompile(t, `object.kind == "Pod"`, celassert.WithVariable("object")) + err := p.Eval(t.Context(), map[string]interface{}{ + "object": json.RawMessage(`{`), + }) + require.Error(t, err) + assert.ErrorContains(t, err, "decoding JSON input") + assert.NotErrorIs(t, err, celassert.ErrFalse) +} + +func TestEval_DeploymentReadiness(t *testing.T) { + t.Parallel() + + // Source: scenarios/basic-web-service/e2e.yaml + obj := decodeDocument(t, deploymentYAML) + p := mustCompile(t, deploymentExpr, celassert.WithVariable("object")) + require.NoError(t, p.Eval(t.Context(), map[string]interface{}{"object": obj})) + require.NoError(t, p.Eval(t.Context(), map[string]interface{}{ + "object": &unstructured.Unstructured{Object: obj}, + })) +} + +func TestEval_PartialMatch(t *testing.T) { + t.Parallel() + + obj := decodeDocument(t, ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: basic-web-service-dev +spec: + replicas: 1 + selector: + matchLabels: + app: web + template: + spec: + containers: + - name: web + image: docker.io/library/nginx:latest + ports: + - name: http + containerPort: 80 + protocol: TCP + - name: metrics + containerPort: 9090 +status: + readyReplicas: 1 +`) + p := mustCompile(t, deploymentExpr, celassert.WithVariable("object")) + require.NoError(t, p.Eval(t.Context(), map[string]interface{}{"object": obj})) +} + +func TestEval_ExtraObjects(t *testing.T) { + t.Parallel() + + deployment := decodeDocument(t, deploymentYAML) + service := decodeDocument(t, ` +apiVersion: v1 +kind: Service +metadata: + name: basic-web-service-dev +spec: + ports: + - name: http + port: 80 + targetPort: http +`) + p := mustCompile(t, + `resources.filter(r, r.kind == "Deployment" && r.status.readyReplicas == 1).size() == 1`, + celassert.WithVariable("resources"), + ) + require.NoError(t, p.Eval(t.Context(), map[string]interface{}{ + "resources": []interface{}{service, deployment}, + })) +} + +func TestEval_JobConfigMapOwnership(t *testing.T) { + t.Parallel() + + // Name link from internal/helm/generate_env_test.go. + // Owner link from internal/e2e/e2e_wildcard_test.go. + // UIDs are fixed literals, not client-generated values. + migrateCM := "shop-dev-migrate-env" + resources := []interface{}{ + job("shop-dev-migrate", migrateUID, migrateCM), + configMap(migrateCM, "shop-dev-migrate", migrateUID), + job("shop-dev-seed", seedUID, migrateCM), + } + p := mustCompile(t, ownershipExpr("shop-dev-migrate", migrateCM), celassert.WithVariable("resources")) + require.NoError(t, p.Eval(t.Context(), map[string]interface{}{"resources": resources})) + + miss := mustCompile(t, ownershipExpr("shop-dev-seed", migrateCM), celassert.WithVariable("resources")) + err := miss.Eval(t.Context(), map[string]interface{}{"resources": resources}) + require.ErrorIs(t, err, celassert.ErrFalse) +} + +func TestEval_DynamicNumbers(t *testing.T) { + t.Parallel() + + p := mustCompile(t, `n == 1`, celassert.WithVariable("n")) + tests := []struct { + name string + value interface{} + }{ + {name: "int64", value: int64(1)}, + {name: "float64", value: float64(1)}, + {name: "json.Number", value: json.Number("1")}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.NoError(t, p.Eval(t.Context(), map[string]interface{}{"n": tt.value})) + }) + } +} + +func TestEval_JSONNumberInsideInt64(t *testing.T) { + t.Parallel() + + p := mustCompile(t, + `n == 9007199254740992 && n != 9007199254740993`, + celassert.WithVariable("n"), + ) + require.NoError(t, p.Eval(t.Context(), map[string]interface{}{ + "n": json.Number("9007199254740992"), + })) +} + +func TestEval_JSONNumberOutsideInt64(t *testing.T) { + t.Parallel() + + p := mustCompile(t, `true`, celassert.WithVariable("n")) + tests := []struct { + name string + value interface{} + }{ + { + name: "number", + value: json.Number("9223372036854775809"), + }, + { + name: "other number", + value: json.Number("9223372036854775810"), + }, + { + name: "map", + value: map[string]interface{}{ + "value": json.Number("9223372036854775809"), + "note": plantedSecret, + }, + }, + { + name: "slice of numbers", + value: []json.Number{json.Number("9223372036854775810")}, + }, + { + name: "slice of maps", + value: []map[string]interface{}{{ + "n": json.Number("9223372036854775809"), + "note": plantedSecret, + }}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := p.Eval(t.Context(), map[string]interface{}{"n": tt.value}) + require.ErrorIs(t, err, celassert.ErrIntegerRange) + assert.NotContains(t, err.Error(), plantedSecret) + }) + } + + fraction := mustCompile(t, `n == 1.5`, celassert.WithVariable("n")) + require.NoError(t, fraction.Eval(t.Context(), map[string]interface{}{ + "n": json.Number("1.5"), + })) +} + +func TestEval_CostLimit(t *testing.T) { + t.Parallel() + + items := make([]interface{}, 0, 20) + for i := range 20 { + items = append(items, i) + } + p := mustCompile(t, + `items.all(i, items.all(j, i == j || i != j))`, + celassert.WithVariable("items"), + celassert.WithCostLimit(1), + ) + err := p.Eval(t.Context(), map[string]interface{}{"items": items}) + require.Error(t, err) + assert.ErrorContains(t, err, "actual cost limit exceeded") + assert.NotErrorIs(t, err, celassert.ErrFalse) + assert.NotErrorIs(t, err, context.Canceled) + + ok := mustCompile(t, `true`) + require.NoError(t, ok.Eval(t.Context(), nil)) +} + +func TestEval_ContextAlreadyDone(t *testing.T) { + t.Parallel() + + p := mustCompile(t, `true`) + tests := []struct { + name string + newCtx func() (context.Context, context.CancelFunc) + want error + }{ + { + name: "canceled", + newCtx: func() (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx, cancel + }, + want: context.Canceled, + }, + { + name: "deadline exceeded", + newCtx: func() (context.Context, context.CancelFunc) { + return context.WithDeadline(context.Background(), time.Unix(0, 0)) + }, + want: context.DeadlineExceeded, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctx, cancel := tt.newCtx() + defer cancel() + err := p.Eval(ctx, map[string]interface{}{"unused": plantedSecret}) + require.ErrorIs(t, err, tt.want) + assert.NotErrorIs(t, err, celassert.ErrFalse) + assert.NotContains(t, err.Error(), plantedSecret) + assert.Contains(t, err.Error(), "true") + }) + } + + require.NoError(t, p.Eval(t.Context(), map[string]interface{}{"unused": plantedSecret})) +} + +func TestEval_NilContext(t *testing.T) { + t.Parallel() + + p := mustCompile(t, `true`) + //nolint:staticcheck // nil context is the case under test + err := p.Eval(nil, nil) + require.Error(t, err) + assert.ErrorContains(t, err, "context is nil") + assert.NotErrorIs(t, err, context.Canceled) +} + +func TestEval_FalseDoesNotDumpInput(t *testing.T) { + t.Parallel() + + obj := decodeDocument(t, deploymentYAML) + meta, ok := obj["metadata"].(map[string]interface{}) + require.True(t, ok) + meta["annotations"] = map[string]interface{}{ + "secret": plantedSecret, + } + expression := `object.status.readyReplicas == 2` + p := mustCompile(t, expression, celassert.WithVariable("object")) + err := p.Eval(t.Context(), map[string]interface{}{"object": obj}) + require.ErrorIs(t, err, celassert.ErrFalse) + assert.ErrorContains(t, err, expression) + assert.NotContains(t, err.Error(), plantedSecret) +} + +func TestEval_MissingFieldDoesNotDumpInput(t *testing.T) { + t.Parallel() + + obj := decodeDocument(t, deploymentYAML) + status, ok := obj["status"].(map[string]interface{}) + require.True(t, ok) + delete(status, "readyReplicas") + meta, ok := obj["metadata"].(map[string]interface{}) + require.True(t, ok) + meta["annotations"] = map[string]interface{}{ + "secret": plantedSecret, + } + p := mustCompile(t, `object.status.readyReplicas == 1`, celassert.WithVariable("object")) + err := p.Eval(t.Context(), map[string]interface{}{"object": obj}) + require.Error(t, err) + assert.NotErrorIs(t, err, celassert.ErrFalse) + assert.ErrorContains(t, err, "no such key") + assert.NotContains(t, err.Error(), plantedSecret) +} + +func TestEval_ReusesProgram(t *testing.T) { + t.Parallel() + + p := mustCompile(t, `object.status.readyReplicas == 1`, celassert.WithVariable("object")) + ready := map[string]interface{}{ + "object": map[string]interface{}{ + "status": map[string]interface{}{"readyReplicas": 1}, + }, + } + notReady := map[string]interface{}{ + "object": map[string]interface{}{ + "status": map[string]interface{}{"readyReplicas": 2}, + }, + } + require.NoError(t, p.Eval(t.Context(), ready)) + require.ErrorIs(t, p.Eval(t.Context(), notReady), celassert.ErrFalse) +} + +func TestEval_UnsupportedValue(t *testing.T) { + t.Parallel() + + p := mustCompile(t, `bad == null`, celassert.WithVariable("bad")) + err := p.Eval(t.Context(), map[string]interface{}{"bad": func() {}}) + require.Error(t, err) + assert.NotErrorIs(t, err, celassert.ErrFalse) +} + +func mustCompile(t *testing.T, expression string, opts ...celassert.Option) *celassert.Program { + t.Helper() + p, err := celassert.Compile(expression, opts...) + require.NoError(t, err) + return p +} + +func decodeDocument(t *testing.T, src string) map[string]interface{} { + t.Helper() + raw, err := yaml.YAMLToJSON([]byte(src)) + require.NoError(t, err) + var out map[string]interface{} + require.NoError(t, k8sjson.Unmarshal(raw, &out)) + return out +} + +func job(name, uid, cmName string) map[string]interface{} { + return map[string]interface{}{ + "apiVersion": "batch/v1", + "kind": "Job", + "metadata": map[string]interface{}{ + "name": name, + "uid": uid, + }, + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{ + "name": "migrate", + "envFrom": []interface{}{ + map[string]interface{}{ + "configMapRef": map[string]interface{}{ + "name": cmName, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +func configMap(name, ownerName, ownerUID string) map[string]interface{} { + return map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": name, + "ownerReferences": []interface{}{ + map[string]interface{}{ + "apiVersion": "batch/v1", + "kind": "Job", + "name": ownerName, + "uid": ownerUID, + }, + }, + }, + } +} + +func ownershipExpr(jobName, cmName string) string { + return fmt.Sprintf( + `resources.filter(r, r.kind == "Job" && r.metadata.name == %q).exists(j, `+ + `resources.filter(c, c.kind == "ConfigMap" && c.metadata.name == %q).exists(cm, `+ + `j.spec.template.spec.containers[0].envFrom[0].configMapRef.name == cm.metadata.name `+ + `&& cm.metadata.ownerReferences[0].name == j.metadata.name `+ + `&& cm.metadata.ownerReferences[0].uid == j.metadata.uid))`, + jobName, cmName, + ) +} + +const deploymentExpr = `object.kind == "Deployment" +&& object.metadata.name == "basic-web-service-dev" +&& object.spec.replicas == 1 +&& object.spec.template.spec.containers.exists(c, c.name == "web" && c.image == "docker.io/library/nginx:latest") +&& object.status.readyReplicas == 1` + +const deploymentYAML = ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: basic-web-service-dev + labels: + deployah.dev/project: basic-web-service + deployah.dev/environment: dev + deployah.dev/component: web +spec: + replicas: 1 + template: + spec: + containers: + - name: web + image: docker.io/library/nginx:latest + ports: + - name: http + containerPort: 80 +status: + readyReplicas: 1 +` From 4391cdd17c89eedf56bacdf6304af9193fe5c469 Mon Sep 17 00:00:00 2001 From: Mohammad Abdolirad Date: Fri, 9 Oct 2026 16:45:10 +0200 Subject: [PATCH 2/3] reject extra JSON and inexact integers --- internal/testing/celassert/program.go | 97 ++++++++------ internal/testing/celassert/program_test.go | 145 ++++++++++++++++++--- 2 files changed, 186 insertions(+), 56 deletions(-) diff --git a/internal/testing/celassert/program.go b/internal/testing/celassert/program.go index 016bdae..693c42a 100644 --- a/internal/testing/celassert/program.go +++ b/internal/testing/celassert/program.go @@ -20,6 +20,8 @@ import ( "encoding/json" "errors" "fmt" + "io" + "math/big" "strings" "cel.dev/cel-go/cel" @@ -43,7 +45,7 @@ var ( // ErrNotBool means the expression result is not a Boolean. ErrNotBool = errors.New("expression result is not bool") - // ErrIntegerRange means a digit-only JSON integer does not fit in int64. + // ErrIntegerRange means a JSON integer does not fit in int64. ErrIntegerRange = errors.New("integer outside int64") errNilContext = errors.New("context is nil") @@ -215,10 +217,11 @@ func prepare(vars map[string]interface{}) (map[string]interface{}, error) { if err != nil { return nil, err } - if intErr := checkIntegers(normalized); intErr != nil { - return nil, intErr + numbered, err := normalizeNumbers(normalized) + if err != nil { + return nil, err } - out[name] = normalized + out[name] = numbered } return out, nil } @@ -284,67 +287,81 @@ func decodeJSON(raw json.RawMessage) (interface{}, error) { if err := dec.Decode(&value); err != nil { return nil, fmt.Errorf("decoding JSON input: %w", err) } + var extra interface{} + if err := dec.Decode(&extra); !errors.Is(err, io.EOF) { + // The extra value is not included in the error text. + return nil, errors.New("decoding JSON input: trailing data") + } return value, nil } -func checkIntegers(value interface{}) error { +func normalizeNumbers(value interface{}) (interface{}, error) { switch v := value.(type) { case json.Number: - return checkInteger(v) + return normalizeNumber(v) case []json.Number: + out := make([]json.Number, 0, len(v)) for _, n := range v { - if err := checkInteger(n); err != nil { - return err + nn, err := normalizeNumber(n) + if err != nil { + return nil, err } + out = append(out, nn) } + return out, nil case map[string]interface{}: - for _, child := range v { - if err := checkIntegers(child); err != nil { - return err + out := make(map[string]interface{}, len(v)) + for k, child := range v { + nv, err := normalizeNumbers(child) + if err != nil { + return nil, err } + out[k] = nv } + return out, nil case []map[string]interface{}: + out := make([]map[string]interface{}, 0, len(v)) for _, child := range v { - if err := checkIntegers(child); err != nil { - return err + nv, err := normalizeNumbers(child) + if err != nil { + return nil, err + } + m, ok := nv.(map[string]interface{}) + if !ok { + return nil, errors.New("normalizing numbers: expected a map") } + out = append(out, m) } + return out, nil case []interface{}: + out := make([]interface{}, 0, len(v)) for _, child := range v { - if err := checkIntegers(child); err != nil { - return err + nv, err := normalizeNumbers(child) + if err != nil { + return nil, err } + out = append(out, nv) } + return out, nil + default: + return value, nil } - return nil } -func checkInteger(n json.Number) error { +// normalizeNumber keeps integers exact for CEL. +func normalizeNumber(n json.Number) (json.Number, error) { + // A plain int64 needs no rewrite. if _, err := n.Int64(); err == nil { - return nil + return n, nil } - // Fractions and exponent spellings are not digit-only, so CEL keeps them. - if !decimalInteger(string(n)) { - return nil + // Fractions stay as written. Integers that fit are rewritten as + // decimals so CEL does not round them through float64. + r, ok := new(big.Rat).SetString(string(n)) + if !ok || !r.IsInt() { + return n, nil } - // Name this number only. Leave the rest of the input out of the error. - return fmt.Errorf("json number %s: %w", n, ErrIntegerRange) -} - -func decimalInteger(s string) bool { - if s == "" { - return false - } - if s[0] == '-' { - s = s[1:] - } - if s == "" { - return false - } - for i := 0; i < len(s); i++ { - if s[i] < '0' || s[i] > '9' { - return false - } + if !r.Num().IsInt64() { + return "", fmt.Errorf("json number %s: %w", n, ErrIntegerRange) } - return true + return json.Number(r.Num().String()), nil } diff --git a/internal/testing/celassert/program_test.go b/internal/testing/celassert/program_test.go index 3201dd1..400bd3b 100644 --- a/internal/testing/celassert/program_test.go +++ b/internal/testing/celassert/program_test.go @@ -275,16 +275,55 @@ func TestEval_NonBoolValue(t *testing.T) { assert.NotContains(t, err.Error(), plantedSecret) } -func TestEval_MalformedJSON(t *testing.T) { +func TestEval_JSONDocuments(t *testing.T) { t.Parallel() p := mustCompile(t, `object.kind == "Pod"`, celassert.WithVariable("object")) - err := p.Eval(t.Context(), map[string]interface{}{ - "object": json.RawMessage(`{`), - }) - require.Error(t, err) - assert.ErrorContains(t, err, "decoding JSON input") - assert.NotErrorIs(t, err, celassert.ErrFalse) + valid := []struct { + name string + raw string + }{ + {name: "object", raw: `{"kind":"Pod"}`}, + {name: "whitespace", raw: " \n\t{\"kind\":\"Pod\"}\n "}, + } + for _, tt := range valid { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := p.Eval(t.Context(), map[string]interface{}{ + "object": json.RawMessage(tt.raw), + }) + require.NoError(t, err) + }) + } + + items := mustCompile(t, `items.size() == 2`, celassert.WithVariable("items")) + require.NoError(t, items.Eval(t.Context(), map[string]interface{}{ + "items": json.RawMessage(`[1, 2]`), + })) + + invalid := []struct { + name string + raw string + }{ + {name: "two objects", raw: `{"kind":"Pod"}{"kind":"Service"}`}, + {name: "second value", raw: `{"kind":"Pod"} true`}, + {name: "trailing junk", raw: `{"kind":"Pod"} leftover`}, + {name: "incomplete", raw: `{`}, + } + for _, tt := range invalid { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := p.Eval(t.Context(), map[string]interface{}{ + "object": json.RawMessage(tt.raw), + "note": plantedSecret, + }) + require.Error(t, err) + assert.ErrorContains(t, err, "decoding JSON input") + assert.NotErrorIs(t, err, celassert.ErrFalse) + assert.NotContains(t, err.Error(), plantedSecret) + assert.NotContains(t, err.Error(), "Service") + }) + } } func TestEval_DeploymentReadiness(t *testing.T) { @@ -394,21 +433,78 @@ func TestEval_DynamicNumbers(t *testing.T) { } } -func TestEval_JSONNumberInsideInt64(t *testing.T) { +func TestEval_JSONNumberExact(t *testing.T) { t.Parallel() - p := mustCompile(t, - `n == 9007199254740992 && n != 9007199254740993`, - celassert.WithVariable("n"), - ) - require.NoError(t, p.Eval(t.Context(), map[string]interface{}{ - "n": json.Number("9007199254740992"), - })) + // 9007199254740993 is 2^53+1. float64 rounds it to 2^53. + rounded, err := json.Number("9007199254740993e0").Float64() + require.NoError(t, err) + boundary, err := json.Number("9007199254740992").Float64() + require.NoError(t, err) + require.Equal(t, boundary, rounded) + + tests := []struct { + name string + expr string + value interface{} + }{ + { + name: "within int64", + expr: `n == 7`, + value: json.Number("7"), + }, + { + name: "beyond 2^53", + expr: `n == 9007199254740992 && n != 9007199254740993`, + value: json.Number("9007199254740992"), + }, + { + name: "scientific beyond 2^53", + expr: `n == 9007199254740993 && n != 9007199254740992`, + value: json.Number("9007199254740993e0"), + }, + { + name: "1e2", + expr: `n == 100`, + value: json.Number("1e2"), + }, + { + name: "1.5e2", + expr: `n == 150`, + value: json.Number("1.5e2"), + }, + { + name: "fraction", + expr: `n == 1.5`, + value: json.Number("1.5"), + }, + { + name: "nested scientific", + expr: `n.nums[0] == 9007199254740993`, + value: map[string]interface{}{ + "nums": []interface{}{json.Number("9007199254740993e0")}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + p := mustCompile(t, tt.expr, celassert.WithVariable("n")) + require.NoError(t, p.Eval(t.Context(), map[string]interface{}{"n": tt.value})) + }) + } } func TestEval_JSONNumberOutsideInt64(t *testing.T) { t.Parallel() + // These two integers are distinct, but float64 rounds both to 2^63. + low, lowErr := json.Number("9223372036854775809e0").Float64() + require.NoError(t, lowErr) + high, highErr := json.Number("9223372036854775810e0").Float64() + require.NoError(t, highErr) + require.Equal(t, low, high) + p := mustCompile(t, `true`, celassert.WithVariable("n")) tests := []struct { name string @@ -422,13 +518,30 @@ func TestEval_JSONNumberOutsideInt64(t *testing.T) { name: "other number", value: json.Number("9223372036854775810"), }, + { + name: "scientific", + value: json.Number("9223372036854775809e0"), + }, + { + name: "other scientific", + value: json.Number("9223372036854775810e0"), + }, { name: "map", value: map[string]interface{}{ - "value": json.Number("9223372036854775809"), + "value": json.Number("9223372036854775809e0"), "note": plantedSecret, }, }, + { + name: "array", + value: []interface{}{ + map[string]interface{}{ + "n": json.Number("9223372036854775810e0"), + "note": plantedSecret, + }, + }, + }, { name: "slice of numbers", value: []json.Number{json.Number("9223372036854775810")}, From 111aabc4c7f05eb8b3e963632012191635c68155 Mon Sep 17 00:00:00 2001 From: Mohammad Abdolirad Date: Fri, 9 Oct 2026 17:06:31 +0200 Subject: [PATCH 3/3] cover missing CEL assertion cases --- internal/testing/celassert/program_test.go | 100 +++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/internal/testing/celassert/program_test.go b/internal/testing/celassert/program_test.go index 400bd3b..5a71bf6 100644 --- a/internal/testing/celassert/program_test.go +++ b/internal/testing/celassert/program_test.go @@ -112,6 +112,11 @@ func TestCompile_RejectsBadOptions(t *testing.T) { opts: duplicate, contains: "duplicate variable", }, + { + name: "empty variable", + opts: []celassert.Option{celassert.WithVariable("")}, + contains: "variable name is empty", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -123,6 +128,101 @@ func TestCompile_RejectsBadOptions(t *testing.T) { } } +func TestEval_UnstructuredValues(t *testing.T) { + t.Parallel() + + pod := map[string]any{"apiVersion": "v1", "kind": "Pod"} + obj := unstructured.Unstructured{Object: pod} + list := unstructured.UnstructuredList{Items: []unstructured.Unstructured{obj}} + objectExpr := `object.kind == "Pod"` + itemsExpr := `items[0].kind == "Pod"` + tests := []struct { + name string + expr string + key string + value any + }{ + {name: "value", expr: objectExpr, key: "object", value: obj}, + {name: "list", expr: itemsExpr, key: "items", value: list}, + {name: "list pointer", expr: itemsExpr, key: "items", value: &list}, + {name: "slice", expr: itemsExpr, key: "items", value: []unstructured.Unstructured{obj}}, + { + name: "pointer slice", + expr: itemsExpr, + key: "items", + value: []*unstructured.Unstructured{&obj}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + p := mustCompile(t, tt.expr, celassert.WithVariable(tt.key)) + require.NoError(t, p.Eval(t.Context(), map[string]any{tt.key: tt.value})) + }) + } +} + +func TestEval_NilUnstructured(t *testing.T) { + t.Parallel() + + p := mustCompile(t, `true`, celassert.WithVariable("object")) + tests := []struct { + name string + value any + }{ + {name: "nil pointer", value: (*unstructured.Unstructured)(nil)}, + {name: "nil object", value: unstructured.Unstructured{}}, + {name: "nil list pointer", value: (*unstructured.UnstructuredList)(nil)}, + { + name: "nil item object", + value: unstructured.UnstructuredList{Items: []unstructured.Unstructured{{}}}, + }, + {name: "nil pointer in slice", value: []*unstructured.Unstructured{nil}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := p.Eval(t.Context(), map[string]any{ + "object": tt.value, + "note": plantedSecret, + }) + require.Error(t, err) + assert.ErrorContains(t, err, "unstructured object is nil") + assert.NotContains(t, err.Error(), plantedSecret) + }) + } +} + +func TestEval_TypedCollections(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + expr string + value interface{} + }{ + { + name: "numbers", + expr: `n[0] == 7`, + value: []json.Number{json.Number("7")}, + }, + { + name: "maps", + expr: `n[0].kind == "Pod"`, + value: []map[string]interface{}{{ + "kind": "Pod", + }}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + p := mustCompile(t, tt.expr, celassert.WithVariable("n")) + require.NoError(t, p.Eval(t.Context(), map[string]interface{}{"n": tt.value})) + }) + } +} + func TestEval_EmptyPlan(t *testing.T) { t.Parallel()