From 79af6c838f4890275ee36d0394f2e47f731a8c96 Mon Sep 17 00:00:00 2001 From: Nevyana Angelova Date: Mon, 10 Aug 2026 16:32:43 +0700 Subject: [PATCH 1/2] MM-69474: normalize Jira base URL to prevent duplicate slash in posted links --- server/instance.go | 5 ++++ server/instance_cloud.go | 6 ++-- server/instance_cloud_oauth.go | 4 +-- server/instance_server.go | 6 ++-- server/instance_test.go | 50 ++++++++++++++++++++++++++++++++++ server/plugin.go | 2 +- 6 files changed, 64 insertions(+), 9 deletions(-) create mode 100644 server/instance_test.go diff --git a/server/instance.go b/server/instance.go index 6286ecea3..6d89a21f5 100644 --- a/server/instance.go +++ b/server/instance.go @@ -5,6 +5,7 @@ package main import ( "net/http" + "strings" "github.com/mattermost/mattermost-plugin-jira/server/utils/types" ) @@ -72,3 +73,7 @@ func (ic *InstanceCommon) Common() *InstanceCommon { func (ic InstanceCommon) IsCloudInstance() bool { return ic.Type == CloudInstanceType || ic.Type == CloudOAuthInstanceType } + +func normalizeJiraBaseURL(baseURL string) string { + return strings.TrimSuffix(baseURL, "/") +} diff --git a/server/instance_cloud.go b/server/instance_cloud.go index a1ff5c94a..66008fa9e 100644 --- a/server/instance_cloud.go +++ b/server/instance_cloud.go @@ -192,15 +192,15 @@ func (ci *cloudInstance) GetURL() string { } func (ci *cloudInstance) GetJiraBaseURL() string { - return ci.GetURL() + return normalizeJiraBaseURL(ci.GetURL()) } func (ci *cloudInstance) GetManageAppsURL() string { - return fmt.Sprintf("%s/plugins/servlet/upm", ci.GetURL()) + return fmt.Sprintf("%s/plugins/servlet/upm", ci.GetJiraBaseURL()) } func (ci *cloudInstance) GetManageWebhooksURL() string { - return cloudManageWebhooksURL(ci.GetURL()) + return cloudManageWebhooksURL(ci.GetJiraBaseURL()) } func cloudManageWebhooksURL(jiraURL string) string { diff --git a/server/instance_cloud_oauth.go b/server/instance_cloud_oauth.go index 34483b38d..95fe39115 100644 --- a/server/instance_cloud_oauth.go +++ b/server/instance_cloud_oauth.go @@ -79,7 +79,7 @@ func (p *Plugin) installCloudOAuthInstance(rawURL string) (string, *cloudOAuthIn newInstance := &cloudOAuthInstance{ InstanceCommon: newInstanceCommon(p, CloudOAuthInstanceType, types.ID(jiraURL)), MattermostKey: p.GetPluginKey(), - JiraBaseURL: rawURL, + JiraBaseURL: jiraURL, CodeVerifier: params.CodeVerifier, CodeChallenge: params.CodeChallenge, } @@ -226,7 +226,7 @@ func (ci *cloudOAuthInstance) GetURL() string { } func (ci *cloudOAuthInstance) GetJiraBaseURL() string { - return ci.JiraBaseURL + return normalizeJiraBaseURL(ci.JiraBaseURL) } func (ci *cloudOAuthInstance) GetManageAppsURL() string { diff --git a/server/instance_server.go b/server/instance_server.go index f59222690..0bf5a04e8 100644 --- a/server/instance_server.go +++ b/server/instance_server.go @@ -53,15 +53,15 @@ func (si *serverInstance) GetURL() string { } func (si *serverInstance) GetJiraBaseURL() string { - return si.GetURL() + return normalizeJiraBaseURL(si.GetURL()) } func (si *serverInstance) GetManageAppsURL() string { - return fmt.Sprintf("%s/plugins/servlet/applinks/listApplicationLinks", si.GetURL()) + return fmt.Sprintf("%s/plugins/servlet/applinks/listApplicationLinks", si.GetJiraBaseURL()) } func (si *serverInstance) GetManageWebhooksURL() string { - return fmt.Sprintf("%s/plugins/servlet/webhooks", si.GetURL()) + return fmt.Sprintf("%s/plugins/servlet/webhooks", si.GetJiraBaseURL()) } func (si *serverInstance) GetMattermostKey() string { diff --git a/server/instance_test.go b/server/instance_test.go new file mode 100644 index 000000000..87322c87d --- /dev/null +++ b/server/instance_test.go @@ -0,0 +1,50 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/mattermost/mattermost-plugin-jira/server/utils/types" +) + +func TestGetJiraBaseURLTrimsTrailingSlash(t *testing.T) { + for name, tc := range map[string]struct { + instance Instance + expected string + }{ + "cloud-oauth, no trailing slash": { + instance: &cloudOAuthInstance{JiraBaseURL: "https://mmtest.atlassian.net"}, + expected: "https://mmtest.atlassian.net", + }, + "cloud-oauth, trailing slash": { + instance: &cloudOAuthInstance{JiraBaseURL: "https://mmtest.atlassian.net/"}, + expected: "https://mmtest.atlassian.net", + }, + "cloud, trailing slash": { + instance: &cloudInstance{ + AtlassianSecurityContext: &AtlassianSecurityContext{BaseURL: "https://mmtest.atlassian.net/"}, + }, + expected: "https://mmtest.atlassian.net", + }, + "server, trailing slash": { + instance: &serverInstance{ + InstanceCommon: &InstanceCommon{InstanceID: types.ID("https://jira.example.com/")}, + }, + expected: "https://jira.example.com", + }, + "server, no trailing slash": { + instance: &serverInstance{ + InstanceCommon: &InstanceCommon{InstanceID: types.ID("https://jira.example.com")}, + }, + expected: "https://jira.example.com", + }, + } { + t.Run(name, func(t *testing.T) { + assert.Equal(t, tc.expected, tc.instance.GetJiraBaseURL()) + }) + } +} diff --git a/server/plugin.go b/server/plugin.go index 24154919c..af922e4ca 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -518,7 +518,7 @@ func (p *Plugin) AddAutolinksForCloudOAuthInstance(coi *cloudOAuthInstance) erro return fmt.Errorf("error getting project list: %w", err) } - return p.AddAutoLinkForProjects(*plist, coi.JiraBaseURL) + return p.AddAutoLinkForProjects(*plist, coi.GetJiraBaseURL()) } func (p *Plugin) AddAutoLinkForProjects(plist jira.ProjectList, baseURL string) error { From 0acb462dcc89e25547254a91d08f5599bd9d76d5 Mon Sep 17 00:00:00 2001 From: Nevyana Angelova Date: Wed, 12 Aug 2026 19:30:17 +0700 Subject: [PATCH 2/2] Coderabbit feeddback --- server/instance.go | 2 +- server/instance_test.go | 4 ++++ server/plugin.go | 2 +- server/plugin_test.go | 46 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/server/instance.go b/server/instance.go index 6d89a21f5..1ff7ece1a 100644 --- a/server/instance.go +++ b/server/instance.go @@ -75,5 +75,5 @@ func (ic InstanceCommon) IsCloudInstance() bool { } func normalizeJiraBaseURL(baseURL string) string { - return strings.TrimSuffix(baseURL, "/") + return strings.TrimRight(baseURL, "/") } diff --git a/server/instance_test.go b/server/instance_test.go index 87322c87d..6e473d5e8 100644 --- a/server/instance_test.go +++ b/server/instance_test.go @@ -24,6 +24,10 @@ func TestGetJiraBaseURLTrimsTrailingSlash(t *testing.T) { instance: &cloudOAuthInstance{JiraBaseURL: "https://mmtest.atlassian.net/"}, expected: "https://mmtest.atlassian.net", }, + "cloud-oauth, repeated trailing slashes": { + instance: &cloudOAuthInstance{JiraBaseURL: "https://mmtest.atlassian.net///"}, + expected: "https://mmtest.atlassian.net", + }, "cloud, trailing slash": { instance: &cloudInstance{ AtlassianSecurityContext: &AtlassianSecurityContext{BaseURL: "https://mmtest.atlassian.net/"}, diff --git a/server/plugin.go b/server/plugin.go index af922e4ca..184ff3ee8 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -509,7 +509,7 @@ func (p *Plugin) AddAutolinksForCloudInstance(ci *cloudInstance) error { return fmt.Errorf("unable to get project keys: %w", err) } - return p.AddAutoLinkForProjects(plist, ci.BaseURL) + return p.AddAutoLinkForProjects(plist, ci.GetJiraBaseURL()) } func (p *Plugin) AddAutolinksForCloudOAuthInstance(coi *cloudOAuthInstance) error { diff --git a/server/plugin_test.go b/server/plugin_test.go index f11680b69..e30c02b0b 100644 --- a/server/plugin_test.go +++ b/server/plugin_test.go @@ -5,18 +5,22 @@ package main import ( "bytes" + "encoding/json" "io" "net/http" "net/http/httptest" "os" "testing" + jira "github.com/andygrunwald/go-jira" + "github.com/mattermost-community/mattermost-plugin-autolink/server/autolink" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin" "github.com/mattermost/mattermost/server/public/plugin/plugintest" "github.com/mattermost/mattermost/server/public/plugin/plugintest/mock" "github.com/mattermost/mattermost/server/public/pluginapi" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) const ( @@ -269,6 +273,48 @@ func TestSetupAutolink(t *testing.T) { } } +func TestAddAutoLinkForProjectsNormalizesBaseURL(t *testing.T) { + rawURL := "https://mmtest.atlassian.net///" + accessorURL := (&cloudInstance{ + AtlassianSecurityContext: &AtlassianSecurityContext{BaseURL: rawURL}, + }).GetJiraBaseURL() + + for name, baseURL := range map[string]string{ + "no trailing slash": "https://mmtest.atlassian.net", + "trailing slash": "https://mmtest.atlassian.net/", + "repeated trailing slashes": rawURL, + "normalized by accessor": accessorURL, + } { + t.Run(name, func(t *testing.T) { + mockAPI := &plugintest.API{} + var installed []autolink.Autolink + mockAPI.On("PluginHTTP", mock.AnythingOfType("*http.Request")).Return( + &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, + ).Run(func(args mock.Arguments) { + req := args.Get(0).(*http.Request) + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + + var link autolink.Autolink + require.NoError(t, json.Unmarshal(body, &link)) + installed = append(installed, link) + }) + + p := &Plugin{} + p.SetAPI(mockAPI) + + require.NoError(t, p.AddAutoLinkForProjects(jira.ProjectList{{Key: "TES"}}, baseURL)) + + require.Len(t, installed, 2) + for _, link := range installed { + assert.NotContains(t, link.Template, "net//browse/") + assert.Contains(t, link.Template, "https://mmtest.atlassian.net/browse/TES-${jira_id}") + } + assert.Contains(t, installed[1].Pattern, `https://mmtest\.atlassian\.net/browse/`) + }) + } +} + func GetConfigSetterFunction() func(*config) { return func(c *config) { c.AdminAPIToken = MockAPIToken