From 193843a97de26a4448b6937518aee9ed1682191f Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Wed, 6 Apr 2022 07:55:25 -0400 Subject: [PATCH] Make a test request to Jira when saving AppConfig with an enabled jira integration (#4954) --- ...sue-4521-test-jira-settings-on-config-save | 1 + server/service/appconfig.go | 29 +- server/service/externalsvc/jira.go | 15 +- server/service/integration_core_test.go | 262 ++++++++++++++++-- server/service/service_appconfig.go | 17 ++ 5 files changed, 292 insertions(+), 32 deletions(-) create mode 100644 changes/issue-4521-test-jira-settings-on-config-save diff --git a/changes/issue-4521-test-jira-settings-on-config-save b/changes/issue-4521-test-jira-settings-on-config-save new file mode 100644 index 0000000000..bb7aa305a1 --- /dev/null +++ b/changes/issue-4521-test-jira-settings-on-config-save @@ -0,0 +1 @@ +* Test the enabled Jira integration settings when saving the configuration. diff --git a/server/service/appconfig.go b/server/service/appconfig.go index e6b5cfcc31..22f58da291 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -158,6 +158,14 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte) (*fleet.AppCo oldSmtpSettings := appConfig.SMTPSettings + var oldEnabledJiraSettings *fleet.JiraIntegration + for _, jiraSettings := range appConfig.Integrations.Jira { + if jiraSettings.EnableSoftwareVulnerabilities { + oldEnabledJiraSettings = jiraSettings + break + } + } + // TODO(mna): this ports the validations from the old validationMiddleware // correctly, but this could be optimized so that we don't unmarshal the // incoming bytes twice. @@ -176,7 +184,7 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte) (*fleet.AppCo decoder := json.NewDecoder(bytes.NewReader(p)) decoder.DisallowUnknownFields() if err := decoder.Decode(&appConfig); err != nil { - return nil, &badRequestError{message: err.Error()} + return nil, ctxerr.Wrap(ctx, &badRequestError{message: err.Error()}) } validateVulnerabilitiesAutomation(appConfig, invalid) @@ -192,7 +200,7 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte) (*fleet.AppCo if appConfig.SMTPSettings.SMTPEnabled { if oldSmtpSettings != appConfig.SMTPSettings || !appConfig.SMTPSettings.SMTPConfigured { if err = svc.sendTestEmail(ctx, appConfig); err != nil { - return nil, err + return nil, ctxerr.Wrap(ctx, err) } } appConfig.SMTPSettings.SMTPConfigured = true @@ -200,6 +208,23 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte) (*fleet.AppCo appConfig.SMTPSettings.SMTPConfigured = false } + // if we enabled a (new or different) Jira integration, then we make a test + // request to see if the settings are valid. + var newEnabledJiraSettings *fleet.JiraIntegration + for _, jiraSettings := range appConfig.Integrations.Jira { + if jiraSettings.EnableSoftwareVulnerabilities { + newEnabledJiraSettings = jiraSettings + break + } + } + if newEnabledJiraSettings != nil { + if oldEnabledJiraSettings == nil || *newEnabledJiraSettings != *oldEnabledJiraSettings { + if err := svc.makeTestJiraRequest(ctx, newEnabledJiraSettings); err != nil { + return nil, ctxerr.Wrap(ctx, err) + } + } + } + if err := svc.ds.SaveAppConfig(ctx, appConfig); err != nil { return nil, err } diff --git a/server/service/externalsvc/jira.go b/server/service/externalsvc/jira.go index 4bf966c218..e72c75cb23 100644 --- a/server/service/externalsvc/jira.go +++ b/server/service/externalsvc/jira.go @@ -52,25 +52,26 @@ func NewJiraClient(opts *JiraOptions) (*Jira, error) { }, nil } -// CurrentUser returns information about the user configured to make Jira API -// requests. It can be used to test authentication and connection parameters -// to the Jira instance. -func (j *Jira) CurrentUser(ctx context.Context) (*jira.User, error) { - var user *jira.User +// GetProject returns the project details for the project key provided in the +// Jira client options. It can be used to test in one request the +// authentication and connection parameters to the Jira instance as well as the +// existence of the project. +func (j *Jira) GetProject(ctx context.Context) (*jira.Project, error) { + var proj *jira.Project op := func() (*jira.Response, error) { var ( err error resp *jira.Response ) - user, resp, err = j.client.User.GetSelfWithContext(ctx) + proj, resp, err = j.client.Project.GetWithContext(ctx, j.projectKey) return resp, err } if err := doWithRetry(op); err != nil { return nil, err } - return user, nil + return proj, nil } // CreateIssue creates an issue on the jira server targeted by the Jira client. diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 78495edd53..897c309774 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -9,6 +9,7 @@ import ( "fmt" "io/ioutil" "net/http" + "net/http/httptest" "net/url" "reflect" "strconv" @@ -2662,56 +2663,77 @@ func (s *integrationTestSuite) TestVulnerabilitiesWebhookConfig() { func (s *integrationTestSuite) TestIntegrationsConfig() { t := s.T() - s.DoRaw("PATCH", "/api/v1/fleet/config", []byte(`{ + // create a test http server to act as the Jira server + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + w.WriteHeader(501) + return + } + if r.URL.Path != "/rest/api/2/project/qux" { + w.WriteHeader(502) + return + } + + switch usr, _, _ := r.BasicAuth(); usr { + case "ok": + w.Write([]byte(jiraProjectResponsePayload)) + + case "fail": + w.WriteHeader(http.StatusUnauthorized) + } + })) + defer srv.Close() + + s.DoRaw("PATCH", "/api/v1/fleet/config", []byte(fmt.Sprintf(`{ "integrations": { "jira": [{ - "url": "http://some/url", - "username": "foo", + "url": %q, + "username": "ok", "password": "bar", "project_key": "qux", "enable_software_vulnerabilities": true }] } - }`), http.StatusOK) + }`, srv.URL)), http.StatusOK) config := s.getConfig() require.Len(t, config.Integrations.Jira, 1) - require.Equal(t, "http://some/url", config.Integrations.Jira[0].URL) - require.Equal(t, "foo", config.Integrations.Jira[0].Username) + require.Equal(t, srv.URL, config.Integrations.Jira[0].URL) + require.Equal(t, "ok", config.Integrations.Jira[0].Username) require.Equal(t, "bar", config.Integrations.Jira[0].Password) require.Equal(t, "qux", config.Integrations.Jira[0].ProjectKey) require.True(t, config.Integrations.Jira[0].EnableSoftwareVulnerabilities) - s.DoRaw("PATCH", "/api/v1/fleet/config", []byte(`{ + s.DoRaw("PATCH", "/api/v1/fleet/config", []byte(fmt.Sprintf(`{ "integrations": { "jira": [{ - "url": "http:/some/url", + "url": %q, "UNKNOWN_FIELD": "foo" }] } - }`), http.StatusBadRequest) + }`, srv.URL)), http.StatusBadRequest) // cannot have two integrations enabled at the same time - s.DoRaw("PATCH", "/api/v1/fleet/config", []byte(`{ + s.DoRaw("PATCH", "/api/v1/fleet/config", []byte(fmt.Sprintf(`{ "integrations": { "jira": [ { - "url": "http://some/url", - "username": "foo", + "url": %q, + "username": "ok", "password": "bar", "project_key": "qux", "enable_software_vulnerabilities": true }, { - "url": "http://some/url/2", - "username": "foo2", + "url": %[1]q, + "username": "ok", "password": "bar2", "project_key": "qux2", "enable_software_vulnerabilities": true } ] } - }`), http.StatusUnprocessableEntity) + }`, srv.URL)), http.StatusUnprocessableEntity) // cannot enable webhook with a jira integration already enabled s.DoRaw("PATCH", "/api/v1/fleet/config", []byte(`{ @@ -2726,11 +2748,11 @@ func (s *integrationTestSuite) TestIntegrationsConfig() { }`), http.StatusUnprocessableEntity) // disable jira, now we can enable webhook - s.DoRaw("PATCH", "/api/v1/fleet/config", []byte(`{ + s.DoRaw("PATCH", "/api/v1/fleet/config", []byte(fmt.Sprintf(`{ "integrations": { "jira": [{ - "url": "http://some/url", - "username": "foo", + "url": %q, + "username": "ok", "password": "bar", "project_key": "qux", "enable_software_vulnerabilities": false @@ -2744,20 +2766,71 @@ func (s *integrationTestSuite) TestIntegrationsConfig() { }, "interval": "1h" } - }`), http.StatusOK) + }`, srv.URL)), http.StatusOK) // cannot enable jira with webhook already enabled - s.DoRaw("PATCH", "/api/v1/fleet/config", []byte(`{ + s.DoRaw("PATCH", "/api/v1/fleet/config", []byte(fmt.Sprintf(`{ "integrations": { "jira": [{ - "url": "http://some/url", - "username": "foo", + "url": %q, + "username": "ok", "password": "bar", "project_key": "qux", "enable_software_vulnerabilities": true }] } - }`), http.StatusUnprocessableEntity) + }`, srv.URL)), http.StatusUnprocessableEntity) + + // disable webhook, enable jira with wrong credentials + s.DoRaw("PATCH", "/api/v1/fleet/config", []byte(fmt.Sprintf(`{ + "integrations": { + "jira": [{ + "url": %q, + "username": "fail", + "password": "bar", + "project_key": "qux", + "enable_software_vulnerabilities": true + }] + }, + "webhook_settings": { + "vulnerabilities_webhook": { + "enable_vulnerabilities_webhook": false, + "destination_url": "http://some/url", + "host_batch_size": 1234 + }, + "interval": "1h" + } + }`, srv.URL)), http.StatusBadRequest) + + // update jira config to correct credentials (need to disable webhook too as + // last request failed) + s.DoRaw("PATCH", "/api/v1/fleet/config", []byte(fmt.Sprintf(`{ + "integrations": { + "jira": [{ + "url": %q, + "username": "ok", + "password": "bar", + "project_key": "qux", + "enable_software_vulnerabilities": true + }] + }, + "webhook_settings": { + "vulnerabilities_webhook": { + "enable_vulnerabilities_webhook": false, + "destination_url": "http://some/url", + "host_batch_size": 1234 + }, + "interval": "1h" + } + }`, srv.URL)), http.StatusOK) + + // remove all integrations on exit, so that other tests can enable the + // webhook as needed + s.DoRaw("PATCH", "/api/v1/fleet/config", []byte(`{ + "integrations": { + "jira": [] + } + }`), http.StatusOK) } func (s *integrationTestSuite) TestQueriesBadRequests() { @@ -3935,3 +4008,146 @@ func jsonMustMarshal(t testing.TB, v interface{}) []byte { require.NoError(t, err) return b } + +const ( + // example response from the Jira docs + jiraProjectResponsePayload = `{ + "self": "https://your-domain.atlassian.net/rest/api/2/project/EX", + "id": "10000", + "key": "EX", + "description": "This project was created as an example for REST.", + "lead": { + "self": "https://your-domain.atlassian.net/rest/api/2/user?accountId=5b10a2844c20165700ede21g", + "key": "", + "accountId": "5b10a2844c20165700ede21g", + "accountType": "atlassian", + "name": "", + "avatarUrls": { + "48x48": "https://avatar-management--avatars.server-location.prod.public.atl-paas.net/initials/MK-5.png?size=48&s=48", + "24x24": "https://avatar-management--avatars.server-location.prod.public.atl-paas.net/initials/MK-5.png?size=24&s=24", + "16x16": "https://avatar-management--avatars.server-location.prod.public.atl-paas.net/initials/MK-5.png?size=16&s=16", + "32x32": "https://avatar-management--avatars.server-location.prod.public.atl-paas.net/initials/MK-5.png?size=32&s=32" + }, + "displayName": "Mia Krystof", + "active": false + }, + "components": [ + { + "self": "https://your-domain.atlassian.net/rest/api/2/component/10000", + "id": "10000", + "name": "Component 1", + "description": "This is a Jira component", + "lead": { + "self": "https://your-domain.atlassian.net/rest/api/2/user?accountId=5b10a2844c20165700ede21g", + "key": "", + "accountId": "5b10a2844c20165700ede21g", + "accountType": "atlassian", + "name": "", + "avatarUrls": { + "48x48": "https://avatar-management--avatars.server-location.prod.public.atl-paas.net/initials/MK-5.png?size=48&s=48", + "24x24": "https://avatar-management--avatars.server-location.prod.public.atl-paas.net/initials/MK-5.png?size=24&s=24", + "16x16": "https://avatar-management--avatars.server-location.prod.public.atl-paas.net/initials/MK-5.png?size=16&s=16", + "32x32": "https://avatar-management--avatars.server-location.prod.public.atl-paas.net/initials/MK-5.png?size=32&s=32" + }, + "displayName": "Mia Krystof", + "active": false + }, + "assigneeType": "PROJECT_LEAD", + "assignee": { + "self": "https://your-domain.atlassian.net/rest/api/2/user?accountId=5b10a2844c20165700ede21g", + "key": "", + "accountId": "5b10a2844c20165700ede21g", + "accountType": "atlassian", + "name": "", + "avatarUrls": { + "48x48": "https://avatar-management--avatars.server-location.prod.public.atl-paas.net/initials/MK-5.png?size=48&s=48", + "24x24": "https://avatar-management--avatars.server-location.prod.public.atl-paas.net/initials/MK-5.png?size=24&s=24", + "16x16": "https://avatar-management--avatars.server-location.prod.public.atl-paas.net/initials/MK-5.png?size=16&s=16", + "32x32": "https://avatar-management--avatars.server-location.prod.public.atl-paas.net/initials/MK-5.png?size=32&s=32" + }, + "displayName": "Mia Krystof", + "active": false + }, + "realAssigneeType": "PROJECT_LEAD", + "realAssignee": { + "self": "https://your-domain.atlassian.net/rest/api/2/user?accountId=5b10a2844c20165700ede21g", + "key": "", + "accountId": "5b10a2844c20165700ede21g", + "accountType": "atlassian", + "name": "", + "avatarUrls": { + "48x48": "https://avatar-management--avatars.server-location.prod.public.atl-paas.net/initials/MK-5.png?size=48&s=48", + "24x24": "https://avatar-management--avatars.server-location.prod.public.atl-paas.net/initials/MK-5.png?size=24&s=24", + "16x16": "https://avatar-management--avatars.server-location.prod.public.atl-paas.net/initials/MK-5.png?size=16&s=16", + "32x32": "https://avatar-management--avatars.server-location.prod.public.atl-paas.net/initials/MK-5.png?size=32&s=32" + }, + "displayName": "Mia Krystof", + "active": false + }, + "isAssigneeTypeValid": false, + "project": "HSP", + "projectId": 10000 + } + ], + "issueTypes": [ + { + "self": "https://your-domain.atlassian.net/rest/api/2/issueType/3", + "id": "3", + "description": "A task that needs to be done.", + "iconUrl": "https://your-domain.atlassian.net/secure/viewavatar?size=xsmall&avatarId=10299&avatarType=issuetype\",", + "name": "Task", + "subtask": false, + "avatarId": 1, + "hierarchyLevel": 0 + }, + { + "self": "https://your-domain.atlassian.net/rest/api/2/issueType/1", + "id": "1", + "description": "A problem with the software.", + "iconUrl": "https://your-domain.atlassian.net/secure/viewavatar?size=xsmall&avatarId=10316&avatarType=issuetype\",", + "name": "Bug", + "subtask": false, + "avatarId": 10002, + "entityId": "9d7dd6f7-e8b6-4247-954b-7b2c9b2a5ba2", + "hierarchyLevel": 0, + "scope": { + "type": "PROJECT", + "project": { + "id": "10000", + "key": "KEY", + "name": "Next Gen Project" + } + } + } + ], + "url": "https://www.example.com", + "email": "from-jira@example.com", + "assigneeType": "PROJECT_LEAD", + "versions": [], + "name": "Example", + "roles": { + "Developers": "https://your-domain.atlassian.net/rest/api/2/project/EX/role/10000" + }, + "avatarUrls": { + "48x48": "https://your-domain.atlassian.net/secure/projectavatar?size=large&pid=10000", + "24x24": "https://your-domain.atlassian.net/secure/projectavatar?size=small&pid=10000", + "16x16": "https://your-domain.atlassian.net/secure/projectavatar?size=xsmall&pid=10000", + "32x32": "https://your-domain.atlassian.net/secure/projectavatar?size=medium&pid=10000" + }, + "projectCategory": { + "self": "https://your-domain.atlassian.net/rest/api/2/projectCategory/10000", + "id": "10000", + "name": "FIRST", + "description": "First Project Category" + }, + "simplified": false, + "style": "classic", + "properties": { + "propertyKey": "propertyValue" + }, + "insight": { + "totalIssueCount": 100, + "lastIssueUpdateTime": "2022-04-05T04:51:35.670+0000" + } +}` +) diff --git a/server/service/service_appconfig.go b/server/service/service_appconfig.go index 44f015a7df..6c790fa42f 100644 --- a/server/service/service_appconfig.go +++ b/server/service/service_appconfig.go @@ -11,6 +11,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mail" + "github.com/fleetdm/fleet/v4/server/service/externalsvc" ) // mailError is set when an error performing mail operations @@ -80,6 +81,22 @@ func (svc *Service) sendTestEmail(ctx context.Context, config *fleet.AppConfig) return nil } +func (svc *Service) makeTestJiraRequest(ctx context.Context, jiraSettings *fleet.JiraIntegration) error { + client, err := externalsvc.NewJiraClient(&externalsvc.JiraOptions{ + BaseURL: jiraSettings.URL, + BasicAuthUsername: jiraSettings.Username, + BasicAuthPassword: jiraSettings.Password, + ProjectKey: jiraSettings.ProjectKey, + }) + if err != nil { + return &badRequestError{message: fmt.Sprintf("jira integration request failed: %s", err.Error())} + } + if _, err := client.GetProject(ctx); err != nil { + return &badRequestError{message: fmt.Sprintf("jira integration request failed: %s", err.Error())} + } + return nil +} + func cleanupURL(url string) string { return strings.TrimRight(strings.Trim(url, " \t\n"), "/") }