| // Copyright (c) HashiCorp, Inc. |
| // SPDX-License-Identifier: BUSL-1.1 |
| |
| package funcs |
| |
| import ( |
| "fmt" |
| "strings" |
| "testing" |
| |
| "github.com/hashicorp/hcl/v2" |
| "github.com/hashicorp/hcl/v2/ext/customdecode" |
| "github.com/hashicorp/hcl/v2/hclsyntax" |
| "github.com/zclconf/go-cty/cty" |
| "github.com/zclconf/go-cty/cty/function" |
| |
| "github.com/hashicorp/terraform/internal/collections" |
| ) |
| |
| func TestReplace(t *testing.T) { |
| tests := []struct { |
| String cty.Value |
| Substr cty.Value |
| Replace cty.Value |
| Want cty.Value |
| Err bool |
| }{ |
| { // Regular search and replace |
| cty.StringVal("hello"), |
| cty.StringVal("hel"), |
| cty.StringVal("bel"), |
| cty.StringVal("bello"), |
| false, |
| }, |
| { // Search string doesn't match |
| cty.StringVal("hello"), |
| cty.StringVal("nope"), |
| cty.StringVal("bel"), |
| cty.StringVal("hello"), |
| false, |
| }, |
| { // Regular expression |
| cty.StringVal("hello"), |
| cty.StringVal("/l/"), |
| cty.StringVal("L"), |
| cty.StringVal("heLLo"), |
| false, |
| }, |
| { |
| cty.StringVal("helo"), |
| cty.StringVal("/(l)/"), |
| cty.StringVal("$1$1"), |
| cty.StringVal("hello"), |
| false, |
| }, |
| { // Bad regexp |
| cty.StringVal("hello"), |
| cty.StringVal("/(l/"), |
| cty.StringVal("$1$1"), |
| cty.UnknownVal(cty.String), |
| true, |
| }, |
| } |
| |
| for _, test := range tests { |
| t.Run(fmt.Sprintf("replace(%#v, %#v, %#v)", test.String, test.Substr, test.Replace), func(t *testing.T) { |
| got, err := Replace(test.String, test.Substr, test.Replace) |
| |
| if test.Err { |
| if err == nil { |
| t.Fatal("succeeded; want error") |
| } |
| return |
| } else if err != nil { |
| t.Fatalf("unexpected error: %s", err) |
| } |
| |
| if !got.RawEquals(test.Want) { |
| t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) |
| } |
| }) |
| } |
| } |
| |
| func TestStrContains(t *testing.T) { |
| tests := []struct { |
| String cty.Value |
| Substr cty.Value |
| Want cty.Value |
| Err bool |
| }{ |
| { |
| cty.StringVal("hello"), |
| cty.StringVal("hel"), |
| cty.BoolVal(true), |
| false, |
| }, |
| { |
| cty.StringVal("hello"), |
| cty.StringVal("lo"), |
| cty.BoolVal(true), |
| false, |
| }, |
| { |
| cty.StringVal("hello1"), |
| cty.StringVal("1"), |
| cty.BoolVal(true), |
| false, |
| }, |
| { |
| cty.StringVal("hello1"), |
| cty.StringVal("heo"), |
| cty.BoolVal(false), |
| false, |
| }, |
| { |
| cty.StringVal("hello1"), |
| cty.NumberIntVal(1), |
| cty.UnknownVal(cty.Bool), |
| true, |
| }, |
| } |
| |
| for _, test := range tests { |
| t.Run(fmt.Sprintf("includes(%#v, %#v)", test.String, test.Substr), func(t *testing.T) { |
| got, err := StrContains(test.String, test.Substr) |
| |
| if test.Err { |
| if err == nil { |
| t.Fatal("succeeded; want error") |
| } |
| return |
| } else if err != nil { |
| t.Fatalf("unexpected error: %s", err) |
| } |
| |
| if !got.RawEquals(test.Want) { |
| t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) |
| } |
| }) |
| } |
| } |
| |
| func TestStartsWith(t *testing.T) { |
| tests := []struct { |
| String, Prefix cty.Value |
| Want cty.Value |
| WantError string |
| }{ |
| { |
| cty.StringVal("hello world"), |
| cty.StringVal("hello"), |
| cty.True, |
| ``, |
| }, |
| { |
| cty.StringVal("hey world"), |
| cty.StringVal("hello"), |
| cty.False, |
| ``, |
| }, |
| { |
| cty.StringVal(""), |
| cty.StringVal(""), |
| cty.True, |
| ``, |
| }, |
| { |
| cty.StringVal("a"), |
| cty.StringVal(""), |
| cty.True, |
| ``, |
| }, |
| { |
| cty.StringVal(""), |
| cty.StringVal("a"), |
| cty.False, |
| ``, |
| }, |
| { |
| cty.UnknownVal(cty.String), |
| cty.StringVal("a"), |
| cty.UnknownVal(cty.Bool).RefineNotNull(), |
| ``, |
| }, |
| { |
| cty.UnknownVal(cty.String), |
| cty.StringVal(""), |
| cty.True, |
| ``, |
| }, |
| { |
| cty.UnknownVal(cty.String).Refine().StringPrefix("https:").NewValue(), |
| cty.StringVal(""), |
| cty.True, |
| ``, |
| }, |
| { |
| cty.UnknownVal(cty.String).Refine().StringPrefix("https:").NewValue(), |
| cty.StringVal("a"), |
| cty.False, |
| ``, |
| }, |
| { |
| cty.UnknownVal(cty.String).Refine().StringPrefix("https:").NewValue(), |
| cty.StringVal("ht"), |
| cty.True, |
| ``, |
| }, |
| { |
| cty.UnknownVal(cty.String).Refine().StringPrefix("https:").NewValue(), |
| cty.StringVal("https:"), |
| cty.True, |
| ``, |
| }, |
| { |
| cty.UnknownVal(cty.String).Refine().StringPrefix("https:").NewValue(), |
| cty.StringVal("https-"), |
| cty.False, |
| ``, |
| }, |
| { |
| cty.UnknownVal(cty.String).Refine().StringPrefix("https:").NewValue(), |
| cty.StringVal("https://"), |
| cty.UnknownVal(cty.Bool).RefineNotNull(), |
| ``, |
| }, |
| { |
| // Unicode combining characters edge-case: we match the prefix |
| // in terms of unicode code units rather than grapheme clusters, |
| // which is inconsistent with our string processing elsewhere but |
| // would be a breaking change to fix that bug now. |
| cty.StringVal("\U0001f937\u200d\u2642"), // "Man Shrugging" is encoded as "Person Shrugging" followed by zero-width joiner and then the masculine gender presentation modifier |
| cty.StringVal("\U0001f937"), // Just the "Person Shrugging" character without any modifiers |
| cty.True, |
| ``, |
| }, |
| } |
| |
| for _, test := range tests { |
| t.Run(fmt.Sprintf("StartsWith(%#v, %#v)", test.String, test.Prefix), func(t *testing.T) { |
| got, err := StartsWithFunc.Call([]cty.Value{test.String, test.Prefix}) |
| |
| if test.WantError != "" { |
| gotErr := fmt.Sprintf("%s", err) |
| if gotErr != test.WantError { |
| t.Errorf("wrong error\ngot: %s\nwant: %s", gotErr, test.WantError) |
| } |
| return |
| } else if err != nil { |
| t.Fatalf("unexpected error: %s", err) |
| } |
| |
| if !got.RawEquals(test.Want) { |
| t.Errorf( |
| "wrong result\nstring: %#v\nprefix: %#v\ngot: %#v\nwant: %#v", |
| test.String, test.Prefix, got, test.Want, |
| ) |
| } |
| }) |
| } |
| } |
| |
| func TestTemplateString(t *testing.T) { |
| // This function has some special restrictions on what syntax is valid |
| // in its first argument, so we'll test this one using HCL expressions |
| // as the inputs, rather than direct cty values as we do for most other |
| // functions in this package. |
| tests := []struct { |
| templateExpr string |
| exprScope map[string]cty.Value |
| vars cty.Value |
| want cty.Value |
| wantErr string |
| }{ |
| { // a single string interpolation that evaluates to null should fail |
| `template`, |
| map[string]cty.Value{ |
| "template": cty.StringVal(`${test}`), |
| }, |
| cty.ObjectVal(map[string]cty.Value{ |
| "test": cty.NullVal(cty.String), |
| }), |
| cty.NilVal, |
| `<templatestring argument>:1,1-8: Template result is null; The result of the template is null, which is not a valid result for a templatestring call.`, |
| }, |
| { // a single string interpolation that evaluates to unknown should not fail |
| `template`, |
| map[string]cty.Value{ |
| "template": cty.StringVal(`${test}`), |
| }, |
| cty.ObjectVal(map[string]cty.Value{ |
| "test": cty.UnknownVal(cty.String), |
| }), |
| cty.UnknownVal(cty.String).RefineNotNull(), |
| ``, |
| }, |
| { |
| `template`, |
| map[string]cty.Value{ |
| "template": cty.StringVal(`it's ${a}`), |
| }, |
| cty.ObjectVal(map[string]cty.Value{ |
| "a": cty.StringVal("a value"), |
| }), |
| cty.StringVal(`it's a value`), |
| ``, |
| }, |
| { |
| `template`, |
| map[string]cty.Value{ |
| "template": cty.StringVal(`${a}`), |
| }, |
| cty.ObjectVal(map[string]cty.Value{ |
| "a": cty.True, |
| }), |
| // The special treatment of a template with only a single |
| // interpolation sequence does not apply to templatestring, because |
| // we're expecting to be evaluating templates fetched dynamically |
| // from somewhere else and want to avoid callers needing to deal |
| // with anything other than string results. |
| cty.StringVal(`true`), |
| ``, |
| }, |
| { |
| `template`, |
| map[string]cty.Value{ |
| "template": cty.StringVal(`${a}`), |
| }, |
| cty.ObjectVal(map[string]cty.Value{ |
| "a": cty.EmptyTupleVal, |
| }), |
| // The special treatment of a template with only a single |
| // interpolation sequence does not apply to templatestring, because |
| // we're expecting to be evaluating templates fetched dynamically |
| // from somewhere else and want to avoid callers needing to deal |
| // with anything other than string results. |
| cty.NilVal, |
| `invalid template result: string required`, |
| }, |
| { |
| `data.whatever.whatever["foo"].result`, |
| map[string]cty.Value{ |
| "data": cty.ObjectVal(map[string]cty.Value{ |
| "whatever": cty.ObjectVal(map[string]cty.Value{ |
| "whatever": cty.MapVal(map[string]cty.Value{ |
| "foo": cty.ObjectVal(map[string]cty.Value{ |
| "result": cty.StringVal("it's ${a}"), |
| }), |
| }), |
| }), |
| }), |
| }, |
| cty.ObjectVal(map[string]cty.Value{ |
| "a": cty.StringVal("a value"), |
| }), |
| cty.StringVal(`it's a value`), |
| ``, |
| }, |
| { |
| `data.whatever.whatever[each.key].result`, |
| map[string]cty.Value{ |
| "data": cty.ObjectVal(map[string]cty.Value{ |
| "whatever": cty.ObjectVal(map[string]cty.Value{ |
| "whatever": cty.MapVal(map[string]cty.Value{ |
| "foo": cty.ObjectVal(map[string]cty.Value{ |
| "result": cty.StringVal("it's ${a}"), |
| }), |
| }), |
| }), |
| }), |
| "each": cty.ObjectVal(map[string]cty.Value{ |
| "key": cty.StringVal("foo"), |
| }), |
| }, |
| cty.ObjectVal(map[string]cty.Value{ |
| "a": cty.StringVal("a value"), |
| }), |
| cty.StringVal(`it's a value`), |
| ``, |
| }, |
| { |
| `data.whatever.whatever[*].result`, |
| map[string]cty.Value{ |
| "data": cty.ObjectVal(map[string]cty.Value{ |
| "whatever": cty.ObjectVal(map[string]cty.Value{ |
| "whatever": cty.TupleVal([]cty.Value{ |
| cty.ObjectVal(map[string]cty.Value{ |
| "result": cty.StringVal("it's ${a}"), |
| }), |
| }), |
| }), |
| }), |
| "each": cty.ObjectVal(map[string]cty.Value{ |
| "key": cty.StringVal("foo"), |
| }), |
| }, |
| cty.ObjectVal(map[string]cty.Value{ |
| "a": cty.StringVal("a value"), |
| }), |
| cty.NilVal, |
| // We have an intentional hole in our heuristic for whether the |
| // first argument is a suitable expression which permits splat |
| // expressions just so that we can return the type mismatch error |
| // from the result not being a string, instead of the more general |
| // error about it not being a supported expression type. |
| `invalid template value: a string is required`, |
| }, |
| { |
| `"can't write $${not_allowed}"`, |
| map[string]cty.Value{}, |
| cty.ObjectVal(map[string]cty.Value{ |
| "not_allowed": cty.StringVal("a literal template"), |
| }), |
| cty.NilVal, |
| `invalid template expression: templatestring is only for rendering templates retrieved dynamically from elsewhere, and so does not support providing a literal template; consider using a template string expression instead`, |
| }, |
| { |
| `"can't write ${not_allowed}"`, |
| map[string]cty.Value{}, |
| cty.ObjectVal(map[string]cty.Value{ |
| "not_allowed": cty.StringVal("a literal template"), |
| }), |
| cty.NilVal, |
| `invalid template expression: templatestring is only for rendering templates retrieved dynamically from elsewhere; to render an inline template, consider using a plain template string expression`, |
| }, |
| { |
| `"can't write %%{for x in things}a literal template%%{endfor}"`, |
| map[string]cty.Value{}, |
| cty.ObjectVal(map[string]cty.Value{ |
| "things": cty.ListVal([]cty.Value{cty.True}), |
| }), |
| cty.NilVal, |
| `invalid template expression: templatestring is only for rendering templates retrieved dynamically from elsewhere, and so does not support providing a literal template; consider using a template string expression instead`, |
| }, |
| { |
| `"can't write %{for x in things}a literal template%{endfor}"`, |
| map[string]cty.Value{}, |
| cty.ObjectVal(map[string]cty.Value{ |
| "things": cty.ListVal([]cty.Value{cty.True}), |
| }), |
| cty.NilVal, |
| `invalid template expression: templatestring is only for rendering templates retrieved dynamically from elsewhere; to render an inline template, consider using a plain template string expression`, |
| }, |
| { |
| `"${not_allowed}"`, |
| map[string]cty.Value{}, |
| cty.ObjectVal(map[string]cty.Value{ |
| "not allowed": cty.StringVal("an interp-only template"), |
| }), |
| cty.NilVal, |
| `invalid template expression: templatestring is only for rendering templates retrieved dynamically from elsewhere; to treat the inner expression as template syntax, write the reference expression directly without any template interpolation syntax`, |
| }, |
| { |
| `1 + 1`, |
| map[string]cty.Value{}, |
| cty.ObjectVal(map[string]cty.Value{}), |
| cty.NilVal, |
| `invalid template expression: must be a direct reference to a single string from elsewhere, containing valid Terraform template syntax`, |
| }, |
| { |
| `not_a_string`, |
| map[string]cty.Value{ |
| "not_a_string": cty.True, |
| }, |
| cty.ObjectVal(map[string]cty.Value{}), |
| cty.NilVal, |
| `invalid template value: a string is required`, |
| }, |
| { |
| `with_lower`, |
| map[string]cty.Value{ |
| "with_lower": cty.StringVal(`it's ${lower(a)}`), |
| }, |
| cty.ObjectVal(map[string]cty.Value{ |
| "a": cty.StringVal("A VALUE"), |
| }), |
| cty.StringVal("it's a value"), |
| ``, |
| }, |
| { |
| `with_core_lower`, |
| map[string]cty.Value{ |
| "with_core_lower": cty.StringVal(`it's ${core::lower(a)}`), |
| }, |
| cty.ObjectVal(map[string]cty.Value{ |
| "a": cty.StringVal("A VALUE"), |
| }), |
| cty.StringVal("it's a value"), |
| ``, |
| }, |
| { |
| `with_fsfunc`, |
| map[string]cty.Value{ |
| "with_fsfunc": cty.StringVal(`it's ${fsfunc()}`), |
| }, |
| cty.ObjectVal(map[string]cty.Value{}), |
| cty.NilVal, |
| `<templatestring argument>:1,8-15: Error in function call; Call to function "fsfunc" failed: cannot use filesystem access functions like fsfunc in templatestring templates; consider passing the function result as a template variable instead.`, |
| }, |
| { |
| `with_core_fsfunc`, |
| map[string]cty.Value{ |
| "with_core_fsfunc": cty.StringVal(`it's ${core::fsfunc()}`), |
| }, |
| cty.ObjectVal(map[string]cty.Value{}), |
| cty.NilVal, |
| `<templatestring argument>:1,8-21: Error in function call; Call to function "core::fsfunc" failed: cannot use filesystem access functions like fsfunc in templatestring templates; consider passing the function result as a template variable instead.`, |
| }, |
| { |
| `with_templatefunc`, |
| map[string]cty.Value{ |
| "with_templatefunc": cty.StringVal(`it's ${templatefunc()}`), |
| }, |
| cty.ObjectVal(map[string]cty.Value{}), |
| cty.NilVal, |
| `<templatestring argument>:1,8-21: Error in function call; Call to function "templatefunc" failed: cannot recursively call templatefunc from inside another template function.`, |
| }, |
| { |
| `with_core_templatefunc`, |
| map[string]cty.Value{ |
| "with_core_templatefunc": cty.StringVal(`it's ${core::templatefunc()}`), |
| }, |
| cty.ObjectVal(map[string]cty.Value{}), |
| cty.NilVal, |
| `<templatestring argument>:1,8-27: Error in function call; Call to function "core::templatefunc" failed: cannot recursively call templatefunc from inside another template function.`, |
| }, |
| { |
| `with_fstemplatefunc`, |
| map[string]cty.Value{ |
| "with_fstemplatefunc": cty.StringVal(`it's ${fstemplatefunc()}`), |
| }, |
| cty.ObjectVal(map[string]cty.Value{}), |
| cty.NilVal, |
| // The template function error takes priority over the filesystem |
| // function error if calling a function that's in both categories. |
| `<templatestring argument>:1,8-23: Error in function call; Call to function "fstemplatefunc" failed: cannot recursively call fstemplatefunc from inside another template function.`, |
| }, |
| { |
| `with_core_fstemplatefunc`, |
| map[string]cty.Value{ |
| "with_core_fstemplatefunc": cty.StringVal(`it's ${core::fstemplatefunc()}`), |
| }, |
| cty.ObjectVal(map[string]cty.Value{}), |
| cty.NilVal, |
| // The template function error takes priority over the filesystem |
| // function error if calling a function that's in both categories. |
| `<templatestring argument>:1,8-29: Error in function call; Call to function "core::fstemplatefunc" failed: cannot recursively call fstemplatefunc from inside another template function.`, |
| }, |
| } |
| |
| funcToTest := MakeTemplateStringFunc(func() (funcs map[string]function.Function, fsFuncs collections.Set[string], templateFuncs collections.Set[string]) { |
| // These are the functions available for use inside the nested template |
| // evaluation context. These are here only to test that we can call |
| // functions and that the template/filesystem functions get blocked |
| // with suitable error messages. This is not a realistic set of |
| // functions that would be available in a real call. |
| funcs = map[string]function.Function{ |
| "lower": function.New(&function.Spec{ |
| Params: []function.Parameter{ |
| { |
| Name: "str", |
| Type: cty.String, |
| }, |
| }, |
| Type: function.StaticReturnType(cty.String), |
| Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { |
| s := args[0].AsString() |
| return cty.StringVal(strings.ToLower(s)), nil |
| }, |
| }), |
| "fsfunc": function.New(&function.Spec{ |
| Type: function.StaticReturnType(cty.String), |
| Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { |
| return cty.UnknownVal(retType), fmt.Errorf("should not be able to call fsfunc") |
| }, |
| }), |
| "templatefunc": function.New(&function.Spec{ |
| Type: function.StaticReturnType(cty.String), |
| Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { |
| return cty.UnknownVal(retType), fmt.Errorf("should not be able to call templatefunc") |
| }, |
| }), |
| "fstemplatefunc": function.New(&function.Spec{ |
| Type: function.StaticReturnType(cty.String), |
| Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { |
| return cty.UnknownVal(retType), fmt.Errorf("should not be able to call fstemplatefunc") |
| }, |
| }), |
| } |
| funcs["core::lower"] = funcs["lower"] |
| funcs["core::fsfunc"] = funcs["fsfunc"] |
| funcs["core::templatefunc"] = funcs["templatefunc"] |
| funcs["core::fstemplatefunc"] = funcs["fstemplatefunc"] |
| return funcs, collections.NewSetCmp("fsfunc", "fstemplatefunc"), collections.NewSetCmp("templatefunc", "fstemplatefunc") |
| }) |
| |
| for _, test := range tests { |
| t.Run(test.templateExpr, func(t *testing.T) { |
| // The following mimics what HCL itself would do when preparing |
| // the first argument to this function, since the parameter |
| // uses the special "expression closure type" which causes |
| // HCL to delay evaluation of the expression and let the |
| // function handle it directly itself. |
| expr, diags := hclsyntax.ParseExpression([]byte(test.templateExpr), "", hcl.InitialPos) |
| if diags.HasErrors() { |
| t.Fatalf("unexpected errors: %s", diags.Error()) |
| } |
| exprClosure := &customdecode.ExpressionClosure{ |
| Expression: expr, |
| EvalContext: &hcl.EvalContext{ |
| Variables: test.exprScope, |
| }, |
| } |
| exprClosureVal := customdecode.ExpressionClosureVal(exprClosure) |
| |
| got, gotErr := funcToTest.Call([]cty.Value{exprClosureVal, test.vars}) |
| |
| if test.wantErr != "" { |
| if gotErr == nil { |
| t.Fatalf("unexpected success\ngot: %#v\nwant error: %s", got, test.wantErr) |
| } |
| if got, want := gotErr.Error(), test.wantErr; got != want { |
| t.Fatalf("wrong error\ngot: %s\nwant: %s", got, want) |
| } |
| return |
| } |
| if gotErr != nil { |
| t.Errorf("unexpected error: %s", gotErr.Error()) |
| } |
| if !test.want.RawEquals(got) { |
| t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.want) |
| } |
| }) |
| } |
| } |