diff --git a/decoder/expr_any_ref_origins_test.go b/decoder/expr_any_ref_origins_test.go new file mode 100644 index 00000000..345f417a --- /dev/null +++ b/decoder/expr_any_ref_origins_test.go @@ -0,0 +1,727 @@ +package decoder + +import ( + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl-lang/reference" + "github.com/hashicorp/hcl-lang/schema" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/hcl/v2/json" + "github.com/zclconf/go-cty-debug/ctydebug" + "github.com/zclconf/go-cty/cty" +) + +func TestCollectRefOrigins_exprAny_references_hcl(t *testing.T) { + testCases := []struct { + testName string + attrSchema map[string]*schema.AttributeSchema + cfg string + expectedRefOrigins reference.Origins + }{ + { + "no traversal", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.AnyExpression{ + OfType: cty.String, + }, + IsOptional: true, + }, + }, + `attr = "foo"`, + reference.Origins{}, + }, + { + "wrapped traversal", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.AnyExpression{ + OfType: cty.String, + }, + IsOptional: true, + }, + }, + `attr = "${foo}"`, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "foo"}, + }, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 11, Byte: 10}, + End: hcl.Pos{Line: 1, Column: 14, Byte: 13}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.DynamicPseudoType, + }, + }, + }, + }, + }, + { + "traversal with string", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.AnyExpression{ + OfType: cty.String, + }, + IsOptional: true, + }, + }, + `attr = "${foo}-bar"`, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "foo"}, + }, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 11, Byte: 10}, + End: hcl.Pos{Line: 1, Column: 14, Byte: 13}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.DynamicPseudoType, + }, + }, + }, + }, + }, + { + "simple traversal", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.AnyExpression{ + OfType: cty.String, + }, + IsOptional: true, + }, + }, + `attr = foo`, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "foo"}, + }, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 11, Byte: 10}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.String, + }, + }, + }, + }, + }, + { + "traversal with index steps", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.AnyExpression{ + OfType: cty.String, + }, + IsOptional: true, + }, + }, + `attr = one.two["key"].attr[0]`, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "one"}, + lang.AttrStep{Name: "two"}, + lang.IndexStep{Key: cty.StringVal("key")}, + lang.AttrStep{Name: "attr"}, + lang.IndexStep{Key: cty.NumberIntVal(0)}, + }, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 30, Byte: 29}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.String, + }, + }, + }, + }, + }, + { + "string which happens to match address", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.AnyExpression{ + OfType: cty.String, + }, + IsOptional: true, + }, + }, + `attr = "foo"`, + reference.Origins{ + // This should only work in JSON + }, + }, + } + for i, tc := range testCases { + t.Run(fmt.Sprintf("%d-%s", i, tc.testName), func(t *testing.T) { + bodySchema := &schema.BodySchema{ + Attributes: tc.attrSchema, + } + + f, diags := hclsyntax.ParseConfig([]byte(tc.cfg), "test.tf", hcl.InitialPos) + if len(diags) > 0 { + t.Error(diags) + } + d := testPathDecoder(t, &PathContext{ + Schema: bodySchema, + Files: map[string]*hcl.File{ + "test.tf": f, + }, + }) + + origins, err := d.CollectReferenceOrigins() + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(tc.expectedRefOrigins, origins, ctydebug.CmpOptions); diff != "" { + t.Fatalf("unexpected origins: %s", diff) + } + }) + } +} + +func TestCollectRefOrigins_exprAny_references_json(t *testing.T) { + testCases := []struct { + testName string + attrSchema map[string]*schema.AttributeSchema + cfg string + expectedRefOrigins reference.Origins + }{ + { + "no traversal", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.AnyExpression{ + OfType: cty.String, + }, + IsOptional: true, + }, + }, + `{"attr": 422}`, + reference.Origins{}, + }, + { + "traversal with string", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.AnyExpression{ + OfType: cty.String, + }, + IsOptional: true, + }, + }, + `{"attr": "${foo}-bar"}`, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "foo"}, + }, + Range: hcl.Range{ + Filename: "test.tf.json", + Start: hcl.Pos{Line: 1, Column: 13, Byte: 12}, + End: hcl.Pos{Line: 1, Column: 16, Byte: 15}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.DynamicPseudoType, + }, + }, + }, + }, + }, + { + "simple traversal", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.AnyExpression{ + OfType: cty.String, + }, + IsOptional: true, + }, + }, + `{"attr": "${foo}"}`, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "foo"}, + }, + Range: hcl.Range{ + Filename: "test.tf.json", + Start: hcl.Pos{Line: 1, Column: 13, Byte: 12}, + End: hcl.Pos{Line: 1, Column: 16, Byte: 15}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.DynamicPseudoType, + }, + }, + }, + }, + }, + { + "traversal with numeric index steps", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.AnyExpression{ + OfType: cty.String, + }, + IsOptional: true, + }, + }, + `{"attr": "${one.two[42].attr[0]}"}`, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "one"}, + lang.AttrStep{Name: "two"}, + lang.IndexStep{Key: cty.NumberIntVal(42)}, + lang.AttrStep{Name: "attr"}, + lang.IndexStep{Key: cty.NumberIntVal(0)}, + }, + Range: hcl.Range{ + Filename: "test.tf.json", + Start: hcl.Pos{Line: 1, Column: 13, Byte: 12}, + End: hcl.Pos{Line: 1, Column: 32, Byte: 31}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.DynamicPseudoType, + }, + }, + }, + }, + }, + { + "traversal with string index steps", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.AnyExpression{ + OfType: cty.String, + }, + IsOptional: true, + }, + }, + `{"attr": "${one.two[\"key\"].attr[\"foo\"]}"}`, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "one"}, + lang.AttrStep{Name: "two"}, + lang.IndexStep{Key: cty.StringVal("key")}, + lang.AttrStep{Name: "attr"}, + lang.IndexStep{Key: cty.StringVal("foo")}, + }, + Range: hcl.Range{ + Filename: "test.tf.json", + // HCL misreports traversals' range w/ string keys in JSON + // See https://github.com/hashicorp/hcl/issues/598 + Start: hcl.Pos{Line: 1, Column: 13, Byte: 12}, + End: hcl.Pos{Line: 1, Column: 39, Byte: 38 /* 42 */}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.DynamicPseudoType, + }, + }, + }, + }, + }, + { // Terraform uses this in most places where it expects references only + "legacy style string", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.AnyExpression{ + OfType: cty.String, + }, + IsOptional: true, + }, + }, + `{"attr": "foo.bar"}`, + reference.Origins{}, + }, + } + for i, tc := range testCases { + t.Run(fmt.Sprintf("%d-%s", i, tc.testName), func(t *testing.T) { + bodySchema := &schema.BodySchema{ + Attributes: tc.attrSchema, + } + + f, diags := json.ParseWithStartPos([]byte(tc.cfg), "test.tf.json", hcl.InitialPos) + if len(diags) > 0 { + t.Error(diags) + } + d := testPathDecoder(t, &PathContext{ + Schema: bodySchema, + Files: map[string]*hcl.File{ + "test.tf.json": f, + }, + }) + + origins, err := d.CollectReferenceOrigins() + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(tc.expectedRefOrigins, origins, ctydebug.CmpOptions); diff != "" { + t.Fatalf("unexpected origins: %s", diff) + } + }) + } +} + +func TestCollectRefOrigins_exprAny_functions_hcl(t *testing.T) { + testCases := []struct { + testName string + attrSchema map[string]*schema.AttributeSchema + cfg string + expectedRefOrigins reference.Origins + }{ + { + "unknown function", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.AnyExpression{ + OfType: cty.String, + }, + }, + }, + `attr = unknown(var.foo) +`, + reference.Origins{}, + }, + { + "known function parameter", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.AnyExpression{ + OfType: cty.String, + }, + }, + }, + `attr = lower(var.foo) +`, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "var"}, + lang.AttrStep{Name: "foo"}, + }, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 14, Byte: 13}, + End: hcl.Pos{Line: 1, Column: 21, Byte: 20}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.String, + }, + }, + }, + }, + }, + { + "known function variadic parameter", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.AnyExpression{ + OfType: cty.String, + }, + }, + }, + `attr = join(",", [var.foo]) +`, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "var"}, + lang.AttrStep{Name: "foo"}, + }, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 19, Byte: 18}, + End: hcl.Pos{Line: 1, Column: 26, Byte: 25}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.String, + }, + }, + }, + }, + }, + { + "too many arguments", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.AnyExpression{ + OfType: cty.String, + }, + }, + }, + `attr = lower("FOO", var.foo) +`, + reference.Origins{}, + }, { + "(unsupported) expression in function parameter", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.AnyExpression{ + OfType: cty.String, + }, + }, + }, + `attr = lower("${var.foo}") +`, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "var"}, + lang.AttrStep{Name: "foo"}, + }, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 17, Byte: 16}, + End: hcl.Pos{Line: 1, Column: 24, Byte: 23}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.DynamicPseudoType, + }, + }, + }, + }, + }, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("%d-%s", i, tc.testName), func(t *testing.T) { + bodySchema := &schema.BodySchema{ + Attributes: tc.attrSchema, + } + + f, diags := hclsyntax.ParseConfig([]byte(tc.cfg), "test.tf", hcl.InitialPos) + if len(diags) > 0 { + t.Error(diags) + } + d := testPathDecoder(t, &PathContext{ + Schema: bodySchema, + Files: map[string]*hcl.File{ + "test.tf": f, + }, + Functions: testFunctionSignatures(), + }) + + origins, err := d.CollectReferenceOrigins() + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(tc.expectedRefOrigins, origins, ctydebug.CmpOptions); diff != "" { + t.Fatalf("unexpected origins: %s", diff) + } + }) + } +} + +func TestCollectRefOrigins_exprAny_functions_json(t *testing.T) { + testCases := []struct { + testName string + attrSchema map[string]*schema.AttributeSchema + cfg string + expectedRefOrigins reference.Origins + }{ + { + "unknown function", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.AnyExpression{ + OfType: cty.String, + }, + }, + }, + `{"attr": "${unknown(var.foo)}"}`, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "var"}, + lang.AttrStep{Name: "foo"}, + }, + Range: hcl.Range{ + Filename: "test.tf.json", + Start: hcl.Pos{Line: 1, Column: 21, Byte: 20}, + End: hcl.Pos{Line: 1, Column: 28, Byte: 27}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.DynamicPseudoType, + }, + }, + }, + }, + }, + { + "known function parameter", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.AnyExpression{ + OfType: cty.String, + }, + }, + }, + `{"attr": "${lower(var.foo)}"}`, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "var"}, + lang.AttrStep{Name: "foo"}, + }, + Range: hcl.Range{ + Filename: "test.tf.json", + Start: hcl.Pos{Line: 1, Column: 19, Byte: 18}, + End: hcl.Pos{Line: 1, Column: 26, Byte: 25}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.DynamicPseudoType, + }, + }, + }, + }, + }, + { + "known function variadic parameter", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.AnyExpression{ + OfType: cty.String, + }, + }, + }, + `{"attr": "${join(\",\", [var.foo])}"}`, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "var"}, + lang.AttrStep{Name: "foo"}, + }, + Range: hcl.Range{ + Filename: "test.tf.json", + Start: hcl.Pos{Line: 1, Column: 24, Byte: 23}, + End: hcl.Pos{Line: 1, Column: 31, Byte: 30}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.DynamicPseudoType, + }, + }, + }, + }, + }, + { + "too many arguments", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.AnyExpression{ + OfType: cty.String, + }, + }, + }, + `{"attr": "${lower(\"FOO\", var.foo)}"}`, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "var"}, + lang.AttrStep{Name: "foo"}, + }, + Range: hcl.Range{ + Filename: "test.tf.json", + Start: hcl.Pos{Line: 1, Column: 26, Byte: 25}, + End: hcl.Pos{Line: 1, Column: 33, Byte: 32}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.DynamicPseudoType, + }, + }, + }, + }, + }, { + "(unsupported) expression in function parameter", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.AnyExpression{ + OfType: cty.String, + }, + }, + }, + `{"attr": "${lower(\"${var.foo}\")}"}`, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "var"}, + lang.AttrStep{Name: "foo"}, + }, + Range: hcl.Range{ + Filename: "test.tf.json", + Start: hcl.Pos{Line: 1, Column: 22, Byte: 21}, + End: hcl.Pos{Line: 1, Column: 29, Byte: 28}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.DynamicPseudoType, + }, + }, + }, + }, + }, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("%d-%s", i, tc.testName), func(t *testing.T) { + bodySchema := &schema.BodySchema{ + Attributes: tc.attrSchema, + } + + f, diags := json.ParseWithStartPos([]byte(tc.cfg), "test.tf.json", hcl.InitialPos) + if len(diags) > 0 { + t.Error(diags) + } + d := testPathDecoder(t, &PathContext{ + Schema: bodySchema, + Files: map[string]*hcl.File{ + "test.tf.json": f, + }, + Functions: testFunctionSignatures(), + }) + + origins, err := d.CollectReferenceOrigins() + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(tc.expectedRefOrigins, origins, ctydebug.CmpOptions); diff != "" { + t.Fatalf("unexpected origins: %s", diff) + } + }) + } +}