Make a test request to Jira when saving AppConfig with an enabled jira integration (#4954)

This commit is contained in:
Martin Angers 2022-04-06 07:55:25 -04:00 committed by GitHub
parent 81b8bbe2df
commit 193843a97d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 292 additions and 32 deletions

View file

@ -0,0 +1 @@
* Test the enabled Jira integration settings when saving the configuration.

View file

@ -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
}

View file

@ -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.

View file

@ -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"
}
}`
)

View file

@ -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"), "/")
}