// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 package common import ( "encoding/json" "fmt" "os" "strings" "testing" "github.com/hashicorp/boundary/internal/cmd/base" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/wrapperspb" ) // TestPopulateAttrFlags tests common patterns we'll actually be using. Note // that this is not an exhaustive test of the full CombinationSliceVar // functionality, it's a bit higher level test based on what we'll actually need // and what we'll actually have set. func TestPopulateAttrFlags(t *testing.T) { tests := []struct { name string args []string envs [][]string expected []base.CombinedSliceFlagValue expectedErr string }{ { name: "strings-only", args: []string{"-string-attr", "foo=bar", "-string-attr", `bar="baz"`, "-string-attr", "zip=env://zip"}, envs: [][]string{{"zip", "zap"}}, expected: []base.CombinedSliceFlagValue{ { Name: "string-attr", Keys: []string{"foo"}, Value: wrapperspb.String("bar"), }, { Name: "string-attr", Keys: []string{"bar"}, Value: wrapperspb.String(`"baz"`), }, { Name: "string-attr", Keys: []string{"zip"}, Value: wrapperspb.String("zap"), }, }, }, { name: "nums-only", args: []string{"-num-attr", "foo=-1.2", "-num-attr", "bar=5", "-num-attr", "zip=env://zip"}, envs: [][]string{{"zip", "5"}}, expected: []base.CombinedSliceFlagValue{ { Name: "num-attr", Keys: []string{"foo"}, Value: wrapperspb.String("-1.2"), }, { Name: "num-attr", Keys: []string{"bar"}, Value: wrapperspb.String("5"), }, { Name: "num-attr", Keys: []string{"zip"}, Value: wrapperspb.String("5"), }, }, }, { name: "bools-only", args: []string{"-bool-attr", "foo=true", "-bool-attr", "bar=false", "-bool-attr", "zip=env://zip"}, envs: [][]string{{"zip", "true"}}, expected: []base.CombinedSliceFlagValue{ { Name: "bool-attr", Keys: []string{"foo"}, Value: wrapperspb.String("true"), }, { Name: "bool-attr", Keys: []string{"bar"}, Value: wrapperspb.String("false"), }, { Name: "bool-attr", Keys: []string{"zip"}, Value: wrapperspb.String("true"), }, }, }, { name: "key-only", args: []string{"-attr", "foo"}, expected: []base.CombinedSliceFlagValue{ { Name: "attr", Keys: []string{"foo"}, }, }, }, { name: "bad-key-only-bool", args: []string{"-bool-attr", "foo"}, expectedErr: `invalid value "foo" for flag -bool-attr: key-only value provided but not supported for this flag`, }, { name: "bad-key-only-num", args: []string{"-num-attr", "foo"}, expectedErr: `invalid value "foo" for flag -num-attr: key-only value provided but not supported for this flag`, }, { name: "bad-key-only-string", args: []string{"-string-attr", "foo"}, expectedErr: `invalid value "foo" for flag -string-attr: key-only value provided but not supported for this flag`, }, { name: "mixed", args: []string{"-num-attr", "foo=9820", "-string-attr", "bar=9820", "-attr", "baz=9820", "-attr", `zoom="flubber"`}, expected: []base.CombinedSliceFlagValue{ { Name: "num-attr", Keys: []string{"foo"}, Value: wrapperspb.String("9820"), }, { Name: "string-attr", Keys: []string{"bar"}, Value: wrapperspb.String("9820"), }, { Name: "attr", Keys: []string{"baz"}, Value: wrapperspb.String("9820"), }, { Name: "attr", Keys: []string{"zoom"}, Value: wrapperspb.String("\"flubber\""), }, }, }, { name: "mixed-segments", args: []string{"-num-attr", "foo.bar.baz=9820", "-string-attr", "bar.baz.foo=9820", "-attr", "baz.foo.bar=9820", "-attr", "zip=env://zip"}, envs: [][]string{{"zip", "zap"}}, expected: []base.CombinedSliceFlagValue{ { Name: "num-attr", Keys: []string{"foo", "bar", "baz"}, Value: wrapperspb.String("9820"), }, { Name: "string-attr", Keys: []string{"bar", "baz", "foo"}, Value: wrapperspb.String("9820"), }, { Name: "attr", Keys: []string{"baz", "foo", "bar"}, Value: wrapperspb.String("9820"), }, { Name: "attr", Keys: []string{"zip"}, Value: wrapperspb.String("zap"), }, }, }, { name: "bad-key-name", args: []string{"-num-attr", "fo-oo=5"}, expectedErr: "invalid value", }, { name: "bad-key-name-in-segment", args: []string{"-num-attr", "fo.oo-o.o=5"}, expectedErr: "invalid value", }, { name: "colon-in-segment", args: []string{"-attr", "filter=tagName eq 'application:south-seas'"}, expected: []base.CombinedSliceFlagValue{ { Name: "attr", Keys: []string{"filter"}, Value: wrapperspb.String("tagName eq 'application:south-seas'"), }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert, require := assert.New(t), require.New(t) // Note: we do the setup on each run to make sure we aren't carrying // state over; just like in the real CLI where each run would have // pristine state. c := new(base.Command) flagSet := c.FlagSet(base.FlagSetNone) f := flagSet.NewFlagSet("Attribute Options") cmd := "create" flagNames := map[string][]string{ cmd: { "attributes", "attr", "string-attr", "bool-attr", "num-attr", }, } attrsInput := CombinedSliceFlagValuePopulationInput{ FlagSet: f, FlagNames: flagNames[cmd], FullPopulationFlag: &c.FlagAttributes, FullPopulationInputName: "attributes", PiecewisePopulationFlag: &c.FlagAttrs, PiecewisePopulationInputBaseName: "attr", } PopulateCombinedSliceFlagValue(attrsInput) for _, env := range tt.envs { require.NoError(os.Setenv(env[0], env[1])) } err := flagSet.Parse(tt.args) if tt.expectedErr != "" { require.Error(err) assert.Contains(err.Error(), tt.expectedErr) return } require.NoError(err) assert.Equal(tt.expected, c.FlagAttrs) }) } } // TestHandleAttributeFlags tests the function that parses types based on // incoming data. It assumes we're coming in with CombinedSliceFlagValues and // validates what comes out -- whether nil func was called or the map function // was called (and its contents). func TestHandleAttributeFlags(t *testing.T) { tests := []struct { name string args []base.CombinedSliceFlagValue expectedMap map[string]any expectedErr string }{ { name: "strings-only", args: []base.CombinedSliceFlagValue{ { Name: "string-%s", Keys: []string{"foo"}, Value: wrapperspb.String("bar"), }, { Name: "string-%s", Keys: []string{"bar"}, Value: wrapperspb.String(`"baz"`), }, }, expectedMap: map[string]any{ "foo": "bar", "bar": "\"baz\"", }, }, { name: "nums-only", args: []base.CombinedSliceFlagValue{ { Name: "num-%s", Keys: []string{"foo"}, Value: wrapperspb.String("-1.2"), }, { Name: "num-%s", Keys: []string{"bar"}, Value: wrapperspb.String("5"), }, }, expectedMap: map[string]any{ "foo": float64(-1.2), "bar": int64(5), }, }, { name: "bad-float-num", args: []base.CombinedSliceFlagValue{ { Name: "num-%s", Keys: []string{"foo"}, Value: wrapperspb.String("-15d.2"), }, }, expectedErr: "as a float", }, { name: "bad-int-num", args: []base.CombinedSliceFlagValue{ { Name: "num-%s", Keys: []string{"foo"}, Value: wrapperspb.String("-15d3"), }, }, expectedErr: "as an int", }, { name: "bools-only", args: []base.CombinedSliceFlagValue{ { Name: "bool-%s", Keys: []string{"foo"}, Value: wrapperspb.String("true"), }, { Name: "bool-%s", Keys: []string{"bar"}, Value: wrapperspb.String("false"), }, }, expectedMap: map[string]any{ "foo": true, "bar": false, }, }, { name: "bad-bool", args: []base.CombinedSliceFlagValue{ { Name: "bool-%s", Keys: []string{"foo"}, Value: wrapperspb.String("t"), }, }, expectedErr: "as a bool", }, { name: "key-only-bare", args: []base.CombinedSliceFlagValue{ { Name: "%s", Keys: []string{"foo"}, }, }, expectedMap: map[string]any{ "foo": nil, }, }, { name: "bad-key-only-bool", args: []base.CombinedSliceFlagValue{ { Name: "bool-%s", Keys: []string{"foo"}, }, }, expectedErr: `requires a value`, }, { name: "bad-key-only-num", args: []base.CombinedSliceFlagValue{ { Name: "num-%s", Keys: []string{"foo"}, }, }, expectedErr: `requires a value`, }, { name: "bad-key-only-string", args: []base.CombinedSliceFlagValue{ { Name: "string-%s", Keys: []string{"foo"}, }, }, expectedErr: `requires a value`, }, { name: "attr-only", args: []base.CombinedSliceFlagValue{ { Name: "%s", Keys: []string{"b1"}, Value: wrapperspb.String("true"), }, { Name: "%s", Keys: []string{"b2"}, Value: wrapperspb.String("false"), }, { Name: "%s", Keys: []string{"s1"}, Value: wrapperspb.String("scoopde"), }, { Name: "%s", Keys: []string{"s2"}, Value: wrapperspb.String("\"woo\"p"), }, { Name: "%s", Keys: []string{"n1"}, Value: wrapperspb.String("-1.2"), }, { Name: "%s", Keys: []string{"n2"}, Value: wrapperspb.String("5"), }, { Name: "%s", Keys: []string{"a"}, Value: wrapperspb.String(`["foo", 1.5, true, ["bar"], {"hip": "hop"}]`), }, { Name: "%s", Keys: []string{"nil"}, Value: wrapperspb.String("null"), }, { Name: "%s", Keys: []string{"m"}, Value: wrapperspb.String(`{"b": true, "n": 6, "s": "scoopde", "a": ["bar"], "m": {"hip": "hop"}}`), }, }, expectedMap: map[string]any{ "b1": true, "b2": false, "s1": "scoopde", "s2": "\"woo\"p", "n1": float64(-1.2), "n2": int64(5), "a": []any{ "foo", json.Number("1.5"), true, []any{"bar"}, map[string]any{"hip": "hop"}, }, "m": map[string]any{ "b": true, "n": json.Number("6"), "s": "scoopde", "a": []any{"bar"}, "m": map[string]any{"hip": "hop"}, }, "nil": nil, }, }, { name: "map-array-structure", args: []base.CombinedSliceFlagValue{ { Name: "%s", Keys: []string{"bools"}, Value: wrapperspb.String("true"), }, { Name: "%s", Keys: []string{"bools"}, Value: wrapperspb.String("false"), }, { Name: "%s", Keys: []string{"strings", "s1"}, Value: wrapperspb.String("scoopde"), }, { Name: "%s", Keys: []string{"strings", "s2"}, // Overwritten below Value: wrapperspb.String(`"woop"`), }, { Name: "%s", Keys: []string{"numbers", "reps"}, Value: wrapperspb.String("-1.2"), }, { Name: "%s", Keys: []string{"numbers", "reps"}, Value: wrapperspb.String("5"), }, { Name: "%s", Keys: []string{"strings", "s2"}, // This will overwrite above! Value: wrapperspb.String("null"), }, }, expectedMap: map[string]any{ "bools": []any{true, false}, "strings": map[string]any{ "s1": "scoopde", "s2": nil, }, "numbers": map[string]any{ "reps": []any{float64(-1.2), int64(5)}, }, }, }, } for _, tt := range tests { for _, typ := range []string{"attr", "secret"} { t.Run(fmt.Sprintf("%s-%s", tt.name, typ), func(t *testing.T) { assert, require := assert.New(t), require.New(t) // Note: we do the setup on each run to make sure we aren't carrying // state over; just like in the real CLI where each run would have // pristine state. c := new(base.Command) var outMap map[string]any args := make([]base.CombinedSliceFlagValue, 0, len(tt.args)) for _, arg := range tt.args { arg.Name = fmt.Sprintf(arg.Name, typ) args = append(args, arg) } err := HandleAttributeFlags(c, typ, "", args, func() {}, func(in map[string]any) { outMap = in }) if tt.expectedErr != "" { require.Error(err) assert.Contains(err.Error(), tt.expectedErr) return } require.NoError(err) assert.Equal(tt.expectedMap, outMap) }) } } } func TestNullableStringSlice(t *testing.T) { makeStringSlicePointer := func(in ...string) *[]string { return &in } tests := []struct { name string cmd string args []string expected base.StringSliceVar expectedErr string }{ { name: "not-set-no-null", cmd: "add-values", args: []string{"-val", "foobar", "-val", "barfoo", "-val", "boobaz", "-val", "bazboo"}, expected: base.StringSliceVar{ Target: makeStringSlicePointer("foobar", "barfoo", "boobaz", "bazboo"), }, }, { name: "not-set-null", cmd: "add-values", args: []string{"-val", "foobar", "-val", "null", "-val", "boobaz", "-val", "bazboo"}, expectedErr: `"null" is not an allowed value`, }, { name: "set-no-null", cmd: "set-values", args: []string{"-val", "foobar", "-val", "barfoo", "-val", "boobaz", "-val", "bazboo"}, expected: base.StringSliceVar{ Target: makeStringSlicePointer("foobar", "barfoo", "boobaz", "bazboo"), }, }, { name: "set-only-null", cmd: "set-values", args: []string{"-val", "null"}, expected: base.StringSliceVar{ Target: makeStringSlicePointer("null"), }, }, { name: "set-null-and-others-beginning", cmd: "set-values", args: []string{"-val", "null", "-val", "barfoo", "-val", "boobaz", "-val", "bazboo"}, expectedErr: `"null" cannot be combined with other values`, }, { name: "set-null-and-others-middle", cmd: "set-values", args: []string{"-val", "foobar", "-val", "null", "-val", "boobaz", "-val", "bazboo"}, expectedErr: `"null" cannot be combined with other values`, }, { name: "set-null-and-others-end", cmd: "set-values", args: []string{"-val", "foobar", "-val", "barfoo", "-val", "boobaz", "-val", "null"}, expectedErr: `"null" cannot be combined with other values`, }, { name: "set-null-multiple", cmd: "set-values", args: []string{"-val", "null", "-val", "null"}, expected: base.StringSliceVar{ Target: makeStringSlicePointer("null"), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert, require := assert.New(t), require.New(t) // Note: we do the setup on each run to make sure we aren't carrying // state over; just like in the real CLI where each run would have // pristine state. c := new(base.Command) flagSet := c.FlagSet(base.FlagSetNone) f := flagSet.NewFlagSet("Stringsssss") var target []string ssVar := &base.StringSliceVar{ Name: "val", Target: &target, NullCheck: func() bool { return strings.HasPrefix(tt.cmd, "set-") }, } f.StringSliceVar(ssVar) err := flagSet.Parse(tt.args) if tt.expectedErr != "" { require.Error(err) assert.Contains(err.Error(), tt.expectedErr) return } require.NoError(err) assert.Equal(*tt.expected.Target, *ssVar.Target) }) } }