diff --git a/pkg/models/errors.go b/pkg/models/errors.go index 85f7c84e..5a40f44e 100644 --- a/pkg/models/errors.go +++ b/pkg/models/errors.go @@ -1,9 +1,67 @@ package models -import "errors" +import ( + "errors" + "fmt" +) var ( + // Config errors + ErrMissingAllowedHosts error = errors.New("datasource is missing allowed hosts/URLs. Configure it in the datasource settings page for enhanced security") + // Query errors + ErrEmptyPaginationListFieldName error = &QueryValidationError{field: "pagination_param_list_field_name"} + // Misc errors ErrUnsuccessfulHTTPResponseStatus error = errors.New("unsuccessful HTTP response") ErrParsingResponseBodyAsJson error = errors.New("unable to parse response body as JSON") - ErrMissingAllowedHosts error = errors.New("datasource is missing allowed hosts/URLs. Configure it in the datasource settings page for enhanced security") ) + +type ConfigValidationError struct { + Field string +} + +func (e *ConfigValidationError) Error() string { + return fmt.Sprintf("invalid or empty field: %s ", e.Field) +} + +type QueryValidationError struct { + field string +} + +func (e *QueryValidationError) Error() string { + return fmt.Sprintf("invalid or empty field: %s ", e.field) +} + +type macroError struct { + message string + secondaryMessage string + macroName string + field string +} + +func (e *macroError) Error() string { + if e.message == "invalid macro" { + return fmt.Sprintf("invalid %s macro", e.macroName) + } + if e.macroName == "" { + return e.message + } + if e.message == "" { + return fmt.Sprintf("insufficient arguments to %s macro", e.macroName) + } + return fmt.Sprintf("error applying macros to %s field%s. %s", e.field, e.secondaryMessage, e.message) +} + +type queryParsingError struct { + message string + err error +} + +func (e *queryParsingError) Error() string { + if e.err != nil && e.message == "" { + return e.Error() + } + if e.err != nil { + return fmt.Errorf("%s.%w", e.message, e.err).Error() + } + return e.message +} diff --git a/pkg/models/macros.go b/pkg/models/macros.go index 91eaf8bb..fcb33037 100644 --- a/pkg/models/macros.go +++ b/pkg/models/macros.go @@ -2,7 +2,6 @@ package models import ( "context" - "errors" "fmt" "regexp" "strings" @@ -38,7 +37,7 @@ func InterPolateMacros(queryString string, timeRange backend.TimeRange, pluginCo macros := map[string]macroFunc{ "combineValues": func(query string, args []string) (string, error) { if len(args) <= 3 { - return query, errorsource.DownstreamError(errors.New("insufficient arguments to combineValues macro"), false) + return query, errorsource.DownstreamError(¯oError{macroName: "combineValues"}, false) } if len(args) == 4 && args[3] == "*" { return "", nil @@ -57,7 +56,7 @@ func InterPolateMacros(queryString string, timeRange backend.TimeRange, pluginCo }, "customInterval": func(query string, args []string) (string, error) { if len(args) == 0 { - return query, errorsource.DownstreamError(errors.New("insufficient arguments to customInterval macro"), false) + return query, errorsource.DownstreamError(¯oError{macroName: "customInterval"}, false) } for argI := range args { if argI == len(args)-1 { @@ -66,7 +65,7 @@ func InterPolateMacros(queryString string, timeRange backend.TimeRange, pluginCo if argI%2 != 0 { duration, err := gtime.ParseDuration(args[argI-1]) if err != nil { - return query, errorsource.DownstreamError(errors.New("invalid customInterval macro"), false) + return query, errorsource.DownstreamError(¯oError{message: "invalid macro", macroName: "customInterval"}, false) } if timeRangeInMilliSeconds <= duration.Milliseconds() { return args[argI], nil @@ -107,44 +106,44 @@ func InterPolateMacros(queryString string, timeRange backend.TimeRange, pluginCo func ApplyMacros(ctx context.Context, query Query, timeRange backend.TimeRange, pluginContext backend.PluginContext) (Query, error) { url, err := InterPolateMacros(query.URL, timeRange, pluginContext) if err != nil { - return query, fmt.Errorf("error applying macros to url field. %s", err.Error()) + return query, ¯oError{message: err.Error(), field: "url"} } query.URL = url uql, err := InterPolateMacros(query.UQL, timeRange, pluginContext) if err != nil { - return query, fmt.Errorf("error applying macros to uql field. %s", err.Error()) + return query, ¯oError{message: err.Error(), field: "uql"} } query.UQL = uql groq, err := InterPolateMacros(query.GROQ, timeRange, pluginContext) if err != nil { - return query, fmt.Errorf("error applying macros to uql field. %s", err.Error()) + return query, ¯oError{message: err.Error(), field: "groq"} } query.GROQ = groq data, err := InterPolateMacros(query.Data, timeRange, pluginContext) if err != nil { - return query, fmt.Errorf("error applying macros to data field. %s", err.Error()) + return query, ¯oError{message: err.Error(), field: "data"} } query.Data = data body, err := InterPolateMacros(query.URLOptions.Body, timeRange, pluginContext) if err != nil { - return query, fmt.Errorf("error applying macros to body data field. %s", err.Error()) + return query, ¯oError{message: err.Error(), field: "body data"} } query.URLOptions.Body = body graphqlQuery, err := InterPolateMacros(query.URLOptions.BodyGraphQLQuery, timeRange, pluginContext) if err != nil { - return query, fmt.Errorf("error applying macros to body graphql query field. %s", err.Error()) + return query, ¯oError{message: err.Error(), field: "body graphql"} } query.URLOptions.BodyGraphQLQuery = graphqlQuery for idx, p := range query.URLOptions.Params { up, err := InterPolateMacros(p.Value, timeRange, pluginContext) if err != nil { - return query, fmt.Errorf("error applying macros to url parameter field %s. %s", p.Key, err.Error()) + return query, ¯oError{message: err.Error(), field: "url parameter", secondaryMessage: " " + p.Key} } query.URLOptions.Params[idx].Value = up } @@ -152,14 +151,14 @@ func ApplyMacros(ctx context.Context, query Query, timeRange backend.TimeRange, for idx, cc := range query.ComputedColumns { up, err := InterPolateMacros(cc.Selector, timeRange, pluginContext) if err != nil { - return query, fmt.Errorf("error applying macros to computed column %s (alias: %s). %s", cc.Selector, cc.Text, err.Error()) + return query, ¯oError{message: err.Error(), field: "computed column", secondaryMessage: fmt.Sprintf(" %s (alias: %s)", cc.Selector, cc.Text)} } query.ComputedColumns[idx].Selector = up } exp, err := InterPolateMacros(query.FilterExpression, timeRange, pluginContext) if err != nil { - return query, fmt.Errorf("error applying macros to filter expression. %s", err.Error()) + return query, ¯oError{message: err.Error(), field: "filter expression"} } query.FilterExpression = exp diff --git a/pkg/models/macros_test.go b/pkg/models/macros_test.go index 33d59746..71d312be 100644 --- a/pkg/models/macros_test.go +++ b/pkg/models/macros_test.go @@ -41,7 +41,7 @@ func TestInterPolateCombineValueMacros(t *testing.T) { }, tt.pluginContext) if tt.wantError != nil { require.NotNil(t, err) - require.Equal(t, tt.wantError, err) + assert.Equal(t, tt.wantError.Error(), err.Error()) return } require.Nil(t, err) @@ -84,7 +84,7 @@ func TestInterPolateFromToMacros(t *testing.T) { got, err := models.InterPolateMacros(tt.query, *tr, tt.pluginContext) if tt.wantError != nil { require.NotNil(t, err) - require.Equal(t, tt.wantError, err) + assert.Equal(t, tt.wantError.Error(), err.Error()) return } require.Nil(t, err) @@ -126,7 +126,7 @@ func TestInterPolateCustomIntervalMacros(t *testing.T) { got, err := models.InterPolateMacros(tt.query, backend.TimeRange{From: time.UnixMilli(from), To: time.UnixMilli(to)}, tt.pluginContext) if tt.wantError != nil { require.NotNil(t, err) - require.Equal(t, tt.wantError, err) + assert.Equal(t, tt.wantError.Error(), err.Error()) return } require.Nil(t, err) diff --git a/pkg/models/query.go b/pkg/models/query.go index 95b4947f..0011c72d 100644 --- a/pkg/models/query.go +++ b/pkg/models/query.go @@ -3,7 +3,6 @@ package models import ( "context" "encoding/json" - "errors" "fmt" "strings" @@ -303,12 +302,12 @@ func LoadQuery(ctx context.Context, backendQuery backend.DataQuery, pluginContex var query Query err := json.Unmarshal(backendQuery.JSON, &query) if err != nil { - return query, errorsource.DownstreamError(fmt.Errorf("error while parsing the query json. %w", err), false) + return query, errorsource.DownstreamError(&queryParsingError{message: "error while parsing the query json", err: err}, false) } query = ApplyDefaultsToQuery(ctx, query, settings) if query.PageMode == PaginationModeList && strings.TrimSpace(query.PageParamListFieldName) == "" { // Downstream error as user input is not correct - return query, errorsource.DownstreamError(errors.New("pagination_param_list_field_name cannot be empty"), false) + return query, errorsource.DownstreamError(ErrEmptyPaginationListFieldName, false) } return ApplyMacros(ctx, query, backendQuery.TimeRange, pluginContext) } diff --git a/pkg/models/settings.go b/pkg/models/settings.go index 99e65685..16a4015a 100644 --- a/pkg/models/settings.go +++ b/pkg/models/settings.go @@ -3,7 +3,6 @@ package models import ( "context" "encoding/json" - "errors" "fmt" "net/textproto" "strings" @@ -123,29 +122,29 @@ type InfinitySettings struct { func (s *InfinitySettings) Validate() error { if (s.BasicAuthEnabled || s.AuthenticationMethod == AuthenticationMethodBasic || s.AuthenticationMethod == AuthenticationMethodDigestAuth) && s.Password == "" { - return errors.New("invalid or empty password detected") + return &ConfigValidationError{Field: "password"} } if s.AuthenticationMethod == AuthenticationMethodApiKey && (s.ApiKeyValue == "" || s.ApiKeyKey == "") { - return errors.New("invalid API key specified") + return &ConfigValidationError{Field: "API key"} } if s.AuthenticationMethod == AuthenticationMethodBearerToken && s.BearerToken == "" { - return errors.New("invalid or empty bearer token detected") + return &ConfigValidationError{Field: "Bearer token"} } if s.AuthenticationMethod == AuthenticationMethodAzureBlob { if strings.TrimSpace(s.AzureBlobAccountName) == "" { - return errors.New("invalid/empty azure blob account name") + return &ConfigValidationError{Field: "Azure blob account name"} } if strings.TrimSpace(s.AzureBlobAccountKey) == "" { - return errors.New("invalid/empty azure blob key") + return &ConfigValidationError{Field: "Azure blob key"} } return nil } if s.AuthenticationMethod == AuthenticationMethodAWS && s.AWSSettings.AuthType == AWSAuthTypeKeys { if strings.TrimSpace(s.AWSAccessKey) == "" { - return errors.New("invalid/empty AWS access key") + return &ConfigValidationError{Field: "AWS access key"} } if strings.TrimSpace(s.AWSSecretKey) == "" { - return errors.New("invalid/empty AWS secret key") + return &ConfigValidationError{Field: "AWS secret key"} } return nil } @@ -161,7 +160,7 @@ func (s *InfinitySettings) DoesAllowedHostsRequired() bool { return false } // If there is specific authentication mechanism (except none and azure blob), then allowed hosts required - if s.AuthenticationMethod != AuthenticationMethodNone && s.AuthenticationMethod != AuthenticationMethodAzureBlob { + if s.AuthenticationMethod != "" && s.AuthenticationMethod != AuthenticationMethodNone && s.AuthenticationMethod != AuthenticationMethodAzureBlob { return true } // If there are any TLS specific settings enabled, then allowed hosts required diff --git a/pkg/models/settings_test.go b/pkg/models/settings_test.go index f04d5d7f..db3217f8 100644 --- a/pkg/models/settings_test.go +++ b/pkg/models/settings_test.go @@ -2,7 +2,6 @@ package models_test import ( "context" - "errors" "testing" "github.com/grafana/grafana-infinity-datasource/pkg/models" @@ -335,73 +334,95 @@ func TestInfinitySettings_Validate(t *testing.T) { wantErr error }{ { + name: "should not error when custom settings", + settings: models.InfinitySettings{}, + }, + { + name: "Should not error when no authentication method specified", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodNone}, }, { + name: "Should error when missing allowed hosts with custom headers", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodNone, CustomHeaders: map[string]string{"A": "B"}}, wantErr: models.ErrMissingAllowedHosts, }, { + name: "Should error when missing allowed hosts with custom headers and empty Accept", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodNone, CustomHeaders: map[string]string{"A": "B", "Accept": ""}}, wantErr: models.ErrMissingAllowedHosts, }, { + name: "Should error when missing allowed hosts with custom headers and empty Content-Type", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodNone, CustomHeaders: map[string]string{"A": "B", "Content-Type": ""}}, wantErr: models.ErrMissingAllowedHosts, }, { + name: "Should error when missing allowed hosts with custom headers and empty Accept and Content-Type", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodNone, CustomHeaders: map[string]string{"A": "B", "Accept": "", "Content-Type": ""}}, wantErr: models.ErrMissingAllowedHosts, }, { + name: "Should error when missing allowed hosts with multiple custom headers", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodNone, CustomHeaders: map[string]string{"A": "B", "C": "D"}}, wantErr: models.ErrMissingAllowedHosts, }, { + name: "Should error basic authentication without password", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodBasic}, - wantErr: errors.New("invalid or empty password detected"), + wantErr: &models.ConfigValidationError{Field: "password"}, }, { + name: "Should error basic authentication with password but missing allowed hosts", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodBasic, Password: "123"}, wantErr: models.ErrMissingAllowedHosts, }, { + name: "Should error when API key authentication without key", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodApiKey}, - wantErr: errors.New("invalid API key specified"), + wantErr: &models.ConfigValidationError{Field: "API key"}, }, { + name: "Should error when API key authentication with key but without value", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodApiKey, ApiKeyKey: "foo"}, - wantErr: errors.New("invalid API key specified"), + wantErr: &models.ConfigValidationError{Field: "API key"}, }, { + name: "Should error when API key authentication with value but without key", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodApiKey, ApiKeyValue: "bar"}, - wantErr: errors.New("invalid API key specified"), + wantErr: &models.ConfigValidationError{Field: "API key"}, }, { + name: "Should error when API key authentication with key and value but missing allowed hosts", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodApiKey, ApiKeyKey: "foo", ApiKeyValue: "bar"}, wantErr: models.ErrMissingAllowedHosts, }, { + name: "Should error when bearer token authentication without token", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodBearerToken}, - wantErr: errors.New("invalid or empty bearer token detected"), + wantErr: &models.ConfigValidationError{Field: "Bearer token"}, }, { + name: "Should error when there are no allowed hosts and bearer token authentication with token but missing allowed hosts", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodBearerToken, BearerToken: "foo"}, wantErr: models.ErrMissingAllowedHosts, }, { + name: "Should error when there are no allowed hosts and secure query fields", settings: models.InfinitySettings{SecureQueryFields: map[string]string{"foo": "bar"}}, wantErr: models.ErrMissingAllowedHosts, }, { + name: "Should not error when there are no allowed hosts and valid URL", settings: models.InfinitySettings{URL: "https://foo"}, }, { + name: "Should error when there are no allowed hosts and TLS client authentication", settings: models.InfinitySettings{TLSClientAuth: true}, wantErr: models.ErrMissingAllowedHosts, }, { - settings: models.InfinitySettings{TLSClientAuth: true}, + name: "Should error when there are no allowed hosts and TLS cert authentication", + settings: models.InfinitySettings{TLSAuthWithCACert: true}, wantErr: models.ErrMissingAllowedHosts, }, }