mirror of
https://github.com/fleetdm/fleet
synced 2026-05-24 09:28:54 +00:00
Make a test request to Jira when saving AppConfig with an enabled jira integration (#4954)
This commit is contained in:
parent
81b8bbe2df
commit
193843a97d
5 changed files with 292 additions and 32 deletions
1
changes/issue-4521-test-jira-settings-on-config-save
Normal file
1
changes/issue-4521-test-jira-settings-on-config-save
Normal file
|
|
@ -0,0 +1 @@
|
|||
* Test the enabled Jira integration settings when saving the configuration.
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}`
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"), "/")
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue