diff --git a/agent/consul/acl_authmethod.go b/agent/consul/acl_authmethod.go index ded6ae508..6fc20b2d2 100644 --- a/agent/consul/acl_authmethod.go +++ b/agent/consul/acl_authmethod.go @@ -42,7 +42,7 @@ func (s *Server) loadAuthMethodValidator(idx uint64, method *structs.ACLAuthMeth // A list of role links and service identities are returned. func (s *Server) evaluateRoleBindings( validator authmethod.Validator, - verifiedFields map[string]string, + verifiedIdentity *authmethod.Identity, methodMeta *structs.EnterpriseMeta, targetMeta *structs.EnterpriseMeta, ) ([]*structs.ACLServiceIdentity, []structs.ACLTokenRoleLink, error) { @@ -54,13 +54,10 @@ func (s *Server) evaluateRoleBindings( return nil, nil, nil } - // Convert the fields into something suitable for go-bexpr. - selectableVars := validator.MakeFieldMapSelectable(verifiedFields) - // Find all binding rules that match the provided fields. var matchingRules []*structs.ACLBindingRule for _, rule := range rules { - if doesBindingRuleMatch(rule, selectableVars) { + if doesSelectorMatch(rule.Selector, verifiedIdentity.SelectableFields) { matchingRules = append(matchingRules, rule) } } @@ -74,7 +71,7 @@ func (s *Server) evaluateRoleBindings( serviceIdentities []*structs.ACLServiceIdentity ) for _, rule := range matchingRules { - bindName, valid, err := computeBindingRuleBindName(rule.BindType, rule.BindName, verifiedFields) + bindName, valid, err := computeBindingRuleBindName(rule.BindType, rule.BindName, verifiedIdentity.ProjectedVars) if err != nil { return nil, nil, fmt.Errorf("cannot compute %q bind name for bind target: %v", rule.BindType, err) } else if !valid { @@ -107,14 +104,13 @@ func (s *Server) evaluateRoleBindings( return serviceIdentities, roleLinks, nil } -// doesBindingRuleMatch checks that a single binding rule matches the provided -// vars. -func doesBindingRuleMatch(rule *structs.ACLBindingRule, selectableVars interface{}) bool { - if rule.Selector == "" { +// doesSelectorMatch checks that a single selector matches the provided vars. +func doesSelectorMatch(selector string, selectableVars interface{}) bool { + if selector == "" { return true // catch-all } - eval, err := bexpr.CreateEvaluatorForType(rule.Selector, nil, selectableVars) + eval, err := bexpr.CreateEvaluatorForType(selector, nil, selectableVars) if err != nil { return false // fails to match if selector is invalid } diff --git a/agent/consul/acl_authmethod_test.go b/agent/consul/acl_authmethod_test.go index 45e3021e4..61fedf33e 100644 --- a/agent/consul/acl_authmethod_test.go +++ b/agent/consul/acl_authmethod_test.go @@ -3,11 +3,10 @@ package consul import ( "testing" - "github.com/hashicorp/consul/agent/structs" "github.com/stretchr/testify/require" ) -func TestDoesBindingRuleMatch(t *testing.T) { +func TestDoesSelectorMatch(t *testing.T) { type matchable struct { A string `bexpr:"a"` C string `bexpr:"c"` @@ -40,8 +39,7 @@ func TestDoesBindingRuleMatch(t *testing.T) { "", &matchable{A: "b"}, true}, } { t.Run(test.name, func(t *testing.T) { - rule := structs.ACLBindingRule{Selector: test.selector} - ok := doesBindingRuleMatch(&rule, test.details) + ok := doesSelectorMatch(test.selector, test.details) require.Equal(t, test.ok, ok) }) } diff --git a/agent/consul/acl_endpoint.go b/agent/consul/acl_endpoint.go index 9cdb73e16..c0f8725cc 100644 --- a/agent/consul/acl_endpoint.go +++ b/agent/consul/acl_endpoint.go @@ -1,6 +1,7 @@ package consul import ( + "context" "encoding/json" "errors" "fmt" @@ -685,13 +686,13 @@ func validateBindingRuleBindName(bindType, bindName string, availableFields []st } // computeBindingRuleBindName processes the HIL for the provided bind type+name -// using the verified fields. +// using the projected variables. // // - If the HIL is invalid ("", false, AN_ERROR) is returned. // - If the computed name is not valid for the type ("INVALID_NAME", false, nil) is returned. // - If the computed name is valid for the type ("VALID_NAME", true, nil) is returned. -func computeBindingRuleBindName(bindType, bindName string, verifiedFields map[string]string) (string, bool, error) { - bindName, err := InterpolateHIL(bindName, verifiedFields) +func computeBindingRuleBindName(bindType, bindName string, projectedVars map[string]string) (string, bool, error) { + bindName, err := InterpolateHIL(bindName, projectedVars, true) if err != nil { return "", false, err } @@ -1870,10 +1871,11 @@ func (a *ACL) BindingRuleSet(args *structs.ACLBindingRuleSetRequest, reply *stru return err } + // Create a blank placeholder identity for use in validation below. + blankID := validator.NewIdentity() + if rule.Selector != "" { - selectableVars := validator.MakeFieldMapSelectable(map[string]string{}) - _, err := bexpr.CreateEvaluatorForType(rule.Selector, nil, selectableVars) - if err != nil { + if _, err := bexpr.CreateEvaluatorForType(rule.Selector, nil, blankID.SelectableFields); err != nil { return fmt.Errorf("invalid Binding Rule: Selector is invalid: %v", err) } } @@ -1893,7 +1895,7 @@ func (a *ACL) BindingRuleSet(args *structs.ACLBindingRuleSetRequest, reply *stru return fmt.Errorf("Invalid Binding Rule: unknown BindType %q", rule.BindType) } - if valid, err := validateBindingRuleBindName(rule.BindType, rule.BindName, validator.AvailableFields()); err != nil { + if valid, err := validateBindingRuleBindName(rule.BindType, rule.BindName, blankID.ProjectedVarNames()); err != nil { return fmt.Errorf("Invalid Binding Rule: invalid BindName: %v", err) } else if !valid { return fmt.Errorf("Invalid Binding Rule: invalid BindName") @@ -1909,7 +1911,7 @@ func (a *ACL) BindingRuleSet(args *structs.ACLBindingRuleSetRequest, reply *stru } if respErr, ok := resp.(error); ok { - return respErr + return fmt.Errorf("Failed to apply binding rule upsert request: %v", respErr) } if _, rule, err := a.srv.fsm.State().ACLBindingRuleGetByID(nil, rule.ID, &rule.EnterpriseMeta); err == nil && rule != nil { @@ -2283,16 +2285,16 @@ func (a *ACL) Login(args *structs.ACLLoginRequest, reply *structs.ACLToken) erro } // 2. Send args.Data.BearerToken to method validator and get back a fields map - verifiedFields, desiredMeta, err := validator.ValidateLogin(auth.BearerToken) + verifiedIdentity, err := validator.ValidateLogin(context.Background(), auth.BearerToken) if err != nil { return err } // This always will return a valid pointer - targetMeta := method.TargetEnterpriseMeta(desiredMeta) + targetMeta := method.TargetEnterpriseMeta(verifiedIdentity.EnterpriseMeta) // 3. send map through role bindings - serviceIdentities, roleLinks, err := a.srv.evaluateRoleBindings(validator, verifiedFields, &auth.EnterpriseMeta, targetMeta) + serviceIdentities, roleLinks, err := a.srv.evaluateRoleBindings(validator, verifiedIdentity, &auth.EnterpriseMeta, targetMeta) if err != nil { return err } diff --git a/agent/consul/authmethod/authmethods.go b/agent/consul/authmethod/authmethods.go index f2f76a850..41f791fe4 100644 --- a/agent/consul/authmethod/authmethods.go +++ b/agent/consul/authmethod/authmethods.go @@ -1,6 +1,7 @@ package authmethod import ( + "context" "fmt" "sort" "sync" @@ -31,6 +32,9 @@ type Validator interface { // Name returns the name of the auth method backing this validator. Name() string + // NewIdentity creates a blank identity populated with empty values. + NewIdentity() *Identity + // ValidateLogin takes raw user-provided auth method metadata and ensures // it is sane, provably correct, and currently valid. Relevant identifying // data is extracted and returned for immediate use by the role binding @@ -42,16 +46,32 @@ type Validator interface { // Returns auth method specific metadata suitable for the Role Binding // process as well as the desired enterprise meta for the token to be // created. - ValidateLogin(loginToken string) (map[string]string, *structs.EnterpriseMeta, error) + ValidateLogin(ctx context.Context, loginToken string) (*Identity, error) - // AvailableFields returns a slice of all fields that are returned as a - // result of ValidateLogin. These are valid fields for use in any - // BindingRule tied to this auth method. - AvailableFields() []string + // Stop should be called to cease any background activity and free up + // resources. + Stop() +} - // MakeFieldMapSelectable converts a field map as returned by ValidateLogin - // into a structure suitable for selection with a binding rule. - MakeFieldMapSelectable(fieldMap map[string]string) interface{} +type Identity struct { + // SelectableFields is the format of this Identity suitable for selection + // with a binding rule. + SelectableFields interface{} + + // ProjectedVars is the format of this Identity suitable for interpolation + // in a bind name within a binding rule. + ProjectedVars map[string]string + + *structs.EnterpriseMeta +} + +// ProjectedVarNames returns just the keyspace of the ProjectedVars map. +func (i *Identity) ProjectedVarNames() []string { + v := make([]string, 0, len(i.ProjectedVars)) + for k, _ := range i.ProjectedVars { + v = append(v, k) + } + return v } var ( @@ -116,6 +136,7 @@ func (c *authMethodCache) PutValidatorIfNewer(method *structs.ACLAuthMethod, val if prev.ModifyIndex >= idx { return prev.Validator } + prev.Validator.Stop() } c.entries[method.Name] = &authMethodValidatorEntry{ @@ -126,6 +147,9 @@ func (c *authMethodCache) PutValidatorIfNewer(method *structs.ACLAuthMethod, val } func (c *authMethodCache) Purge() { + for _, entry := range c.entries { + entry.Validator.Stop() + } c.entries = make(map[string]*authMethodValidatorEntry) } diff --git a/agent/consul/authmethod/authmethods_oss.go b/agent/consul/authmethod/authmethods_oss.go index e9e7f7609..ca0b73046 100644 --- a/agent/consul/authmethod/authmethods_oss.go +++ b/agent/consul/authmethod/authmethods_oss.go @@ -33,6 +33,6 @@ func (c *syncCache) PutValidatorIfNewer(method *structs.ACLAuthMethod, validator func (c *syncCache) Purge() { c.lock.Lock() + defer c.lock.Unlock() c.cache.Purge() - c.lock.Unlock() } diff --git a/agent/consul/authmethod/kubeauth/k8s.go b/agent/consul/authmethod/kubeauth/k8s.go index bea558f77..c061b9036 100644 --- a/agent/consul/authmethod/kubeauth/k8s.go +++ b/agent/consul/authmethod/kubeauth/k8s.go @@ -1,6 +1,7 @@ package kubeauth import ( + "context" "errors" "fmt" "strings" @@ -21,7 +22,7 @@ import ( func init() { // register this as an available auth method type - authmethod.Register("kubernetes", func(_ hclog.Logger, method *structs.ACLAuthMethod) (authmethod.Validator, error) { + authmethod.Register("kubernetes", func(logger hclog.Logger, method *structs.ACLAuthMethod) (authmethod.Validator, error) { v, err := NewValidator(method) if err != nil { return nil, err @@ -119,9 +120,11 @@ func NewValidator(method *structs.ACLAuthMethod) (*Validator, error) { func (v *Validator) Name() string { return v.name } -func (v *Validator) ValidateLogin(loginToken string) (map[string]string, *structs.EnterpriseMeta, error) { +func (v *Validator) Stop() {} + +func (v *Validator) ValidateLogin(ctx context.Context, loginToken string) (*authmethod.Identity, error) { if _, err := jwt.ParseSigned(loginToken); err != nil { - return nil, nil, fmt.Errorf("failed to parse and validate JWT: %v", err) + return nil, fmt.Errorf("failed to parse and validate JWT: %v", err) } // Check TokenReview for the bulk of the work. @@ -132,24 +135,24 @@ func (v *Validator) ValidateLogin(loginToken string) (map[string]string, *struct }) if err != nil { - return nil, nil, err + return nil, err } else if trResp.Status.Error != "" { - return nil, nil, fmt.Errorf("lookup failed: %s", trResp.Status.Error) + return nil, fmt.Errorf("lookup failed: %s", trResp.Status.Error) } if !trResp.Status.Authenticated { - return nil, nil, errors.New("lookup failed: service account jwt not valid") + return nil, errors.New("lookup failed: service account jwt not valid") } // The username is of format: system:serviceaccount:(NAMESPACE):(SERVICEACCOUNT) parts := strings.Split(trResp.Status.User.Username, ":") if len(parts) != 4 { - return nil, nil, errors.New("lookup failed: unexpected username format") + return nil, errors.New("lookup failed: unexpected username format") } // Validate the user that comes back from token review is a service account if parts[0] != "system" || parts[1] != "serviceaccount" { - return nil, nil, errors.New("lookup failed: username returned is not a service account") + return nil, errors.New("lookup failed: username returned is not a service account") } var ( @@ -161,7 +164,7 @@ func (v *Validator) ValidateLogin(loginToken string) (map[string]string, *struct // Check to see if there is an override name on the ServiceAccount object. sa, err := v.saGetter.ServiceAccounts(saNamespace).Get(saName, client_metav1.GetOptions{}) if err != nil { - return nil, nil, fmt.Errorf("annotation lookup failed: %v", err) + return nil, fmt.Errorf("annotation lookup failed: %v", err) } annotations := sa.GetObjectMeta().GetAnnotations() @@ -175,25 +178,37 @@ func (v *Validator) ValidateLogin(loginToken string) (map[string]string, *struct serviceAccountUIDField: saUID, } - return fields, v.k8sEntMetaFromFields(fields), nil -} - -func (p *Validator) AvailableFields() []string { - return []string{ - serviceAccountNamespaceField, - serviceAccountNameField, - serviceAccountUIDField, - } -} - -func (v *Validator) MakeFieldMapSelectable(fieldMap map[string]string) interface{} { - return &k8sFieldDetails{ + id := v.NewIdentity() + id.SelectableFields = &k8sFieldDetails{ ServiceAccount: k8sFieldDetailsServiceAccount{ - Namespace: fieldMap[serviceAccountNamespaceField], - Name: fieldMap[serviceAccountNameField], - UID: fieldMap[serviceAccountUIDField], + Namespace: fields[serviceAccountNamespaceField], + Name: fields[serviceAccountNameField], + UID: fields[serviceAccountUIDField], }, } + for k, val := range fields { + id.ProjectedVars[k] = val + } + id.EnterpriseMeta = v.k8sEntMetaFromFields(fields) + + return id, nil +} + +func (v *Validator) NewIdentity() *authmethod.Identity { + id := &authmethod.Identity{ + SelectableFields: &k8sFieldDetails{}, + ProjectedVars: map[string]string{}, + } + for _, f := range availableFields { + id.ProjectedVars[f] = "" + } + return id +} + +var availableFields = []string{ + serviceAccountNamespaceField, + serviceAccountNameField, + serviceAccountUIDField, } type k8sFieldDetails struct { diff --git a/agent/consul/authmethod/kubeauth/k8s_oss.go b/agent/consul/authmethod/kubeauth/k8s_oss.go index 5904dd68a..f73069c78 100644 --- a/agent/consul/authmethod/kubeauth/k8s_oss.go +++ b/agent/consul/authmethod/kubeauth/k8s_oss.go @@ -7,5 +7,5 @@ import "github.com/hashicorp/consul/agent/structs" type enterpriseConfig struct{} func (v *Validator) k8sEntMetaFromFields(fields map[string]string) *structs.EnterpriseMeta { - return structs.DefaultEnterpriseMeta() + return nil } diff --git a/agent/consul/authmethod/kubeauth/k8s_test.go b/agent/consul/authmethod/kubeauth/k8s_test.go index 544bd26ee..060959b36 100644 --- a/agent/consul/authmethod/kubeauth/k8s_test.go +++ b/agent/consul/authmethod/kubeauth/k8s_test.go @@ -2,6 +2,7 @@ package kubeauth import ( "bytes" + "context" "testing" "github.com/hashicorp/consul/agent/connect" @@ -74,6 +75,35 @@ func TestStructs_ACLAuthMethod_Kubernetes_MsgpackEncodeDecode(t *testing.T) { }) } +func TestNewIdentity(t *testing.T) { + testSrv := StartTestAPIServer(t) + defer testSrv.Stop() + + method := &structs.ACLAuthMethod{ + Name: "test-k8s", + Description: "k8s test", + Type: "kubernetes", + Config: map[string]interface{}{ + "Host": testSrv.Addr(), + "CACert": testSrv.CACert(), + "ServiceAccountJWT": goodJWT_A, + }, + } + validator, err := NewValidator(method) + require.NoError(t, err) + + id := validator.NewIdentity() + authmethod.RequireIdentityMatch(t, id, map[string]string{ + "serviceaccount.namespace": "", + "serviceaccount.name": "", + "serviceaccount.uid": "", + }, + `serviceaccount.namespace == ""`, + `serviceaccount.name == ""`, + `serviceaccount.uid == ""`, + ) +} + func TestValidateLogin(t *testing.T) { testSrv := StartTestAPIServer(t) defer testSrv.Stop() @@ -101,18 +131,23 @@ func TestValidateLogin(t *testing.T) { require.NoError(t, err) t.Run("invalid bearer token", func(t *testing.T) { - _, _, err := validator.ValidateLogin("invalid") + _, err := validator.ValidateLogin(context.Background(), "invalid") require.Error(t, err) }) t.Run("valid bearer token", func(t *testing.T) { - fields, _, err := validator.ValidateLogin(goodJWT_B) + id, err := validator.ValidateLogin(context.Background(), goodJWT_B) require.NoError(t, err) - require.Equal(t, map[string]string{ + + authmethod.RequireIdentityMatch(t, id, map[string]string{ "serviceaccount.namespace": "default", "serviceaccount.name": "demo", "serviceaccount.uid": "76091af4-4b56-11e9-ac4b-708b11801cbe", - }, fields) + }, + `serviceaccount.namespace == default`, + `serviceaccount.name == "demo"`, + `serviceaccount.uid == "76091af4-4b56-11e9-ac4b-708b11801cbe"`, + ) }) // annotate the account @@ -125,13 +160,18 @@ func TestValidateLogin(t *testing.T) { ) t.Run("valid bearer token with annotation", func(t *testing.T) { - fields, _, err := validator.ValidateLogin(goodJWT_B) + id, err := validator.ValidateLogin(context.Background(), goodJWT_B) require.NoError(t, err) - require.Equal(t, map[string]string{ + + authmethod.RequireIdentityMatch(t, id, map[string]string{ "serviceaccount.namespace": "default", "serviceaccount.name": "alternate-name", "serviceaccount.uid": "76091af4-4b56-11e9-ac4b-708b11801cbe", - }, fields) + }, + `serviceaccount.namespace == default`, + `serviceaccount.name == "alternate-name"`, + `serviceaccount.uid == "76091af4-4b56-11e9-ac4b-708b11801cbe"`, + ) }) } diff --git a/agent/consul/authmethod/testauth/testing.go b/agent/consul/authmethod/testauth/testing.go index 2b397e935..ff0df4467 100644 --- a/agent/consul/authmethod/testauth/testing.go +++ b/agent/consul/authmethod/testauth/testing.go @@ -1,6 +1,7 @@ package testauth import ( + "context" "fmt" "sync" @@ -115,6 +116,8 @@ type Validator struct { func (v *Validator) Name() string { return v.name } +func (v *Validator) Stop() {} + // ValidateLogin takes raw user-provided auth method metadata and ensures it is // sane, provably correct, and currently valid. Relevant identifying data is // extracted and returned for immediate use by the role binding process. @@ -123,16 +126,40 @@ func (v *Validator) Name() string { return v.name } // to extend the life of the underlying token. // // Returns auth method specific metadata suitable for the Role Binding process. -func (v *Validator) ValidateLogin(loginToken string) (map[string]string, *structs.EnterpriseMeta, error) { +func (v *Validator) ValidateLogin(ctx context.Context, loginToken string) (*authmethod.Identity, error) { fields, valid := GetSessionToken(v.config.SessionID, loginToken) if !valid { - return nil, nil, acl.ErrNotFound + return nil, acl.ErrNotFound } - return fields, v.testAuthEntMetaFromFields(fields), nil + id := v.NewIdentity() + id.SelectableFields = &selectableVars{ + ServiceAccount: selectableServiceAccount{ + Namespace: fields[serviceAccountNamespaceField], + Name: fields[serviceAccountNameField], + UID: fields[serviceAccountUIDField], + }, + } + for k, val := range fields { + id.ProjectedVars[k] = val + } + id.EnterpriseMeta = v.testAuthEntMetaFromFields(fields) + + return id, nil } -func (v *Validator) AvailableFields() []string { return availableFields } +func (v *Validator) NewIdentity() *authmethod.Identity { + id := &authmethod.Identity{ + SelectableFields: &selectableVars{}, + ProjectedVars: map[string]string{}, + } + + for _, f := range availableFields { + id.ProjectedVars[f] = "" + } + + return id +} const ( serviceAccountNamespaceField = "serviceaccount.namespace" @@ -146,18 +173,6 @@ var availableFields = []string{ serviceAccountUIDField, } -// MakeFieldMapSelectable converts a field map as returned by ValidateLogin -// into a structure suitable for selection with a binding rule. -func (v *Validator) MakeFieldMapSelectable(fieldMap map[string]string) interface{} { - return &selectableVars{ - ServiceAccount: selectableServiceAccount{ - Namespace: fieldMap[serviceAccountNamespaceField], - Name: fieldMap[serviceAccountNameField], - UID: fieldMap[serviceAccountUIDField], - }, - } -} - type selectableVars struct { ServiceAccount selectableServiceAccount `bexpr:"serviceaccount"` } diff --git a/agent/consul/authmethod/testing.go b/agent/consul/authmethod/testing.go new file mode 100644 index 000000000..e7161c78a --- /dev/null +++ b/agent/consul/authmethod/testing.go @@ -0,0 +1,45 @@ +package authmethod + +import ( + "sort" + + "github.com/hashicorp/go-bexpr" + "github.com/mitchellh/go-testing-interface" + "github.com/stretchr/testify/require" +) + +// RequireIdentityMatch tests to see if the given Identity matches the provided +// projected vars and filters for testing purpose. +func RequireIdentityMatch(t testing.T, id *Identity, projectedVars map[string]string, filters ...string) { + t.Helper() + + gotNames := id.ProjectedVarNames() + + require.Equal(t, projectedVars, id.ProjectedVars) + + expectNames := make([]string, 0, len(projectedVars)) + for k, _ := range projectedVars { + expectNames = append(expectNames, k) + } + sort.Strings(expectNames) + sort.Strings(gotNames) + + require.Equal(t, expectNames, gotNames) + require.Nil(t, id.EnterpriseMeta) + + for _, filter := range filters { + eval, err := bexpr.CreateEvaluatorForType(filter, nil, id.SelectableFields) + if err != nil { + t.Fatalf("filter %q got err: %v", filter, err) + } + + result, err := eval.Evaluate(id.SelectableFields) + if err != nil { + t.Fatalf("filter %q got err: %v", filter, err) + } + + if !result { + t.Fatalf("filter %q did not match", filter) + } + } +} diff --git a/agent/consul/util.go b/agent/consul/util.go index c4e31500e..79b5e935f 100644 --- a/agent/consul/util.go +++ b/agent/consul/util.go @@ -443,7 +443,7 @@ func ServersGetACLMode(provider checkServersProvider, leaderAddr string, datacen // InterpolateHIL processes the string as if it were HIL and interpolates only // the provided string->string map as possible variables. -func InterpolateHIL(s string, vars map[string]string) (string, error) { +func InterpolateHIL(s string, vars map[string]string, lowercase bool) (string, error) { if strings.Index(s, "${") == -1 { // Skip going to the trouble of parsing something that has no HIL. return s, nil @@ -456,6 +456,9 @@ func InterpolateHIL(s string, vars map[string]string) (string, error) { vm := make(map[string]ast.Variable) for k, v := range vars { + if lowercase { + v = strings.ToLower(v) + } vm[k] = ast.Variable{ Type: ast.TypeString, Value: v, diff --git a/agent/consul/util_test.go b/agent/consul/util_test.go index dbd458ad1..84919c5bc 100644 --- a/agent/consul/util_test.go +++ b/agent/consul/util_test.go @@ -441,124 +441,139 @@ func TestServersInDCMeetMinimumVersion(t *testing.T) { } func TestInterpolateHIL(t *testing.T) { - for _, test := range []struct { - name string - in string - vars map[string]string - exp string - ok bool + for name, test := range map[string]struct { + in string + vars map[string]string + exp string // when lower=false + expLower string // when lower=true + ok bool }{ // valid HIL - { - "empty", + "empty": { "", map[string]string{}, "", + "", true, }, - { - "no vars", + "no vars": { "nothing", map[string]string{}, "nothing", + "nothing", true, }, - { - "just var", + "just lowercase var": { "${item}", map[string]string{"item": "value"}, "value", + "value", true, }, - { - "var in middle", + "just uppercase var": { + "${item}", + map[string]string{"item": "VaLuE"}, + "VaLuE", + "value", + true, + }, + "lowercase var in middle": { "before ${item}after", map[string]string{"item": "value"}, "before valueafter", + "before valueafter", true, }, - { - "two vars", + "uppercase var in middle": { + "before ${item}after", + map[string]string{"item": "VaLuE"}, + "before VaLuEafter", + "before valueafter", + true, + }, + "two vars": { "before ${item}after ${more}", map[string]string{"item": "value", "more": "xyz"}, "before valueafter xyz", + "before valueafter xyz", true, }, - { - "missing map val", + "missing map val": { "${item}", map[string]string{"item": ""}, "", + "", true, }, // "weird" HIL, but not technically invalid - { - "just end", + "just end": { "}", map[string]string{}, "}", + "}", true, }, - { - "var without start", + "var without start": { " item }", map[string]string{"item": "value"}, " item }", + " item }", true, }, - { - "two vars missing second start", + "two vars missing second start": { "before ${ item }after more }", map[string]string{"item": "value", "more": "xyz"}, "before valueafter more }", + "before valueafter more }", true, }, // invalid HIL - { - "just start", + "just start": { "${", map[string]string{}, "", + "", false, }, - { - "backwards", + "backwards": { "}${", map[string]string{}, "", + "", false, }, - { - "no varname", + "no varname": { "${}", map[string]string{}, "", + "", false, }, - { - "missing map key", + "missing map key": { "${item}", map[string]string{}, "", - false, - }, - { - "var without end", - "${ item ", - map[string]string{"item": "value"}, "", false, }, - { - "two vars missing first end", + "var without end": { + "${ item ", + map[string]string{"item": "value"}, + "", + "", + false, + }, + "two vars missing first end": { "before ${ item after ${ more }", map[string]string{"item": "value", "more": "xyz"}, "", + "", false, }, } { - t.Run(test.name, func(t *testing.T) { - out, err := InterpolateHIL(test.in, test.vars) + test := test + t.Run(name+" lower=false", func(t *testing.T) { + out, err := InterpolateHIL(test.in, test.vars, false) if test.ok { require.NoError(t, err) require.Equal(t, test.exp, out) @@ -567,6 +582,16 @@ func TestInterpolateHIL(t *testing.T) { require.Equal(t, out, "") } }) + t.Run(name+" lower=true", func(t *testing.T) { + out, err := InterpolateHIL(test.in, test.vars, true) + if test.ok { + require.NoError(t, err) + require.Equal(t, test.expLower, out) + } else { + require.NotNil(t, err) + require.Equal(t, out, "") + } + }) } }