diff --git a/backend/plugins/jira/api/connection_api.go b/backend/plugins/jira/api/connection_api.go index 9b6071b15fc..209ab6c5c99 100644 --- a/backend/plugins/jira/api/connection_api.go +++ b/backend/plugins/jira/api/connection_api.go @@ -58,7 +58,10 @@ func testConnection(ctx context.Context, connection models.JiraConn) (*JiraTestC } serverInfoFail := "Failed testing the serverInfo: [ " + res.Request.URL.String() + " ]" // check if `/rest/` was missing - if res.StatusCode == http.StatusNotFound && !strings.HasSuffix(connection.Endpoint, "/rest/") { + // Skip this hint for Atlassian API Gateway endpoints — they already include /rest/ in the path + // and a 404 on serverInfo there indicates a wrong Cloud ID, not a missing /rest/ segment. + isGatewayEndpoint := strings.Contains(connection.Endpoint, "api.atlassian.com") + if res.StatusCode == http.StatusNotFound && !strings.HasSuffix(connection.Endpoint, "/rest/") && !isGatewayEndpoint { endpointUrl, err := url.Parse(connection.Endpoint) if err != nil { return nil, errors.Convert(err) diff --git a/backend/plugins/jira/api/connection_api_test.go b/backend/plugins/jira/api/connection_api_test.go new file mode 100644 index 00000000000..e8ce6c0ad22 --- /dev/null +++ b/backend/plugins/jira/api/connection_api_test.go @@ -0,0 +1,144 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/apache/incubator-devlake/core/config" + implcontext "github.com/apache/incubator-devlake/impls/context" + "github.com/apache/incubator-devlake/impls/logruslog" + "github.com/apache/incubator-devlake/plugins/jira/models" + "github.com/go-playground/validator/v10" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// initTestDeps wires up the package-level basicRes and vld. +// DefaultBasicRes satisfies context.BasicRes and only needs config + logger; +// dal can be nil because NewApiClientFromConnection never calls GetDal. +func initTestDeps(t *testing.T) { + t.Helper() + basicRes = implcontext.NewDefaultBasicRes(config.GetConfig(), logruslog.Global, nil) + vld = validator.New() +} + +// serverInfoResponse is the minimal Jira serverInfo payload used in tests. +type serverInfoResponse struct { + DeploymentType string `json:"deploymentType"` + Version string `json:"version"` + VersionNumbers []int `json:"versionNumbers"` +} + +// newJiraTestServer creates an httptest.Server that responds to Jira REST paths. +// It records the Authorization header sent by the client for later assertion. +func newJiraTestServer(t *testing.T, authHeaderOut *string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if authHeaderOut != nil { + *authHeaderOut = r.Header.Get("Authorization") + } + w.Header().Set("Content-Type", "application/json") + switch { + case strings.HasSuffix(r.URL.Path, "/api/2/serverInfo"): + json.NewEncoder(w).Encode(serverInfoResponse{ + DeploymentType: "Cloud", + Version: "1001.0.0", + VersionNumbers: []int{1001, 0, 0}, + }) + case strings.Contains(r.URL.Path, "/agile/1.0/board"): + json.NewEncoder(w).Encode(map[string]interface{}{"values": []interface{}{}}) + default: + http.NotFound(w, r) + } + })) +} + +// TestTestConnection_Gateway verifies that a gateway-style endpoint with +// authMethod=AccessToken sends "Authorization: Bearer " and succeeds. +func TestTestConnection_Gateway(t *testing.T) { + initTestDeps(t) + + var capturedAuth string + srv := newJiraTestServer(t, &capturedAuth) + defer srv.Close() + + conn := models.JiraConn{} + conn.Endpoint = srv.URL + "/rest/" + conn.AuthMethod = "AccessToken" + conn.Token = "my-scoped-gateway-token" + + resp, err := testConnection(context.Background(), conn) + require.Nil(t, err, "testConnection should succeed for gateway endpoint") + require.NotNil(t, resp) + assert.True(t, resp.Success) + assert.Equal(t, "Bearer my-scoped-gateway-token", capturedAuth, + "gateway mode must send Bearer token, not Basic credentials") +} + +// TestTestConnection_StandardCloud verifies backward compatibility: +// a standard atlassian.net endpoint with BasicAuth still works. +func TestTestConnection_StandardCloud(t *testing.T) { + initTestDeps(t) + + var capturedAuth string + srv := newJiraTestServer(t, &capturedAuth) + defer srv.Close() + + conn := models.JiraConn{} + conn.Endpoint = srv.URL + "/rest/" + conn.AuthMethod = "BasicAuth" + conn.Username = "user@example.com" + conn.Password = "api-token-value" + + resp, err := testConnection(context.Background(), conn) + require.Nil(t, err, "testConnection should succeed for standard cloud endpoint") + require.NotNil(t, resp) + assert.True(t, resp.Success) + assert.True(t, strings.HasPrefix(capturedAuth, "Basic "), + "standard cloud mode must send Basic auth header") +} + +// TestTestConnection_NonGateway404ShowsHint verifies that when a non-gateway endpoint +// is missing /rest/ and returns 404, the helpful hint message is included in the error. +func TestTestConnection_NonGateway404ShowsHint(t *testing.T) { + initTestDeps(t) + + // Server that always returns 404 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer srv.Close() + + // URL intentionally missing /rest/ — should trigger the "please try" hint + connMissingRest := models.JiraConn{} + connMissingRest.Endpoint = srv.URL + "/" + connMissingRest.AuthMethod = "BasicAuth" + connMissingRest.Username = "u" + connMissingRest.Password = "p" + + _, errMissingRest := testConnection(context.Background(), connMissingRest) + require.NotNil(t, errMissingRest) + assert.Contains(t, errMissingRest.Error(), "please try", + "non-gateway 404 should include the /rest/ hint") +} diff --git a/config-ui/src/plugins/register/jira/connection-fields/auth.test.ts b/config-ui/src/plugins/register/jira/connection-fields/auth.test.ts new file mode 100644 index 00000000000..0c43ba45991 --- /dev/null +++ b/config-ui/src/plugins/register/jira/connection-fields/auth.test.ts @@ -0,0 +1,144 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { describe, it, expect } from 'vitest'; + +// --------------------------------------------------------------------------- +// Regex constants (mirror exactly what auth.tsx defines) +// --------------------------------------------------------------------------- +const JIRA_CLOUD_REGEX = /^https:\/\/\w+\.atlassian\.net\/rest\/$/; +const JIRA_GATEWAY_REGEX = /^https:\/\/api\.atlassian\.com\/ex\/jira\/[^/]+\/rest\/$/; + +// --------------------------------------------------------------------------- +// Endpoint builder (mirrors handleChangeCloudId in auth.tsx) +// --------------------------------------------------------------------------- +function buildGatewayEndpoint(cloudId: string): string { + return cloudId ? `https://api.atlassian.com/ex/jira/${cloudId}/rest/` : ''; +} + +// --------------------------------------------------------------------------- +// Cloud ID extractor (mirrors the load-time useEffect in auth.tsx) +// --------------------------------------------------------------------------- +function extractCloudId(endpoint: string): string | null { + const match = endpoint.match(/\/ex\/jira\/([^/]+)\//); + return match ? match[1] : null; +} + +// --------------------------------------------------------------------------- +// JIRA_GATEWAY_REGEX tests +// --------------------------------------------------------------------------- +describe('JIRA_GATEWAY_REGEX', () => { + it('accepts a valid gateway URL with UUID Cloud ID', () => { + expect(JIRA_GATEWAY_REGEX.test('https://api.atlassian.com/ex/jira/a1b2c3d4-e5f6-7890-abcd-ef1234567890/rest/')).toBe(true); + }); + + it('accepts a valid gateway URL with short Cloud ID', () => { + expect(JIRA_GATEWAY_REGEX.test('https://api.atlassian.com/ex/jira/mycloud123/rest/')).toBe(true); + }); + + it('rejects gateway URL missing trailing slash', () => { + expect(JIRA_GATEWAY_REGEX.test('https://api.atlassian.com/ex/jira/abc123/rest')).toBe(false); + }); + + it('rejects gateway URL missing /rest/', () => { + expect(JIRA_GATEWAY_REGEX.test('https://api.atlassian.com/ex/jira/abc123/')).toBe(false); + }); + + it('rejects standard Jira Cloud URL', () => { + expect(JIRA_GATEWAY_REGEX.test('https://mycompany.atlassian.net/rest/')).toBe(false); + }); + + it('rejects Jira Server URL', () => { + expect(JIRA_GATEWAY_REGEX.test('https://jira.mycompany.com/rest/')).toBe(false); + }); + + it('rejects gateway URL with extra path segment after /rest/', () => { + expect(JIRA_GATEWAY_REGEX.test('https://api.atlassian.com/ex/jira/abc123/rest/extra/')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// JIRA_CLOUD_REGEX regression tests — must not break existing behaviour +// --------------------------------------------------------------------------- +describe('JIRA_CLOUD_REGEX (regression)', () => { + it('accepts a standard Jira Cloud URL', () => { + expect(JIRA_CLOUD_REGEX.test('https://mycompany.atlassian.net/rest/')).toBe(true); + }); + + it('rejects gateway URL', () => { + expect(JIRA_CLOUD_REGEX.test('https://api.atlassian.com/ex/jira/abc123/rest/')).toBe(false); + }); + + it('rejects Jira Server URL', () => { + expect(JIRA_CLOUD_REGEX.test('https://jira.mycompany.com/rest/')).toBe(false); + }); + + it('rejects cloud URL missing trailing slash', () => { + expect(JIRA_CLOUD_REGEX.test('https://mycompany.atlassian.net/rest')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// buildGatewayEndpoint tests +// --------------------------------------------------------------------------- +describe('buildGatewayEndpoint', () => { + it('builds the correct URL from a UUID Cloud ID', () => { + expect(buildGatewayEndpoint('a1b2c3d4-e5f6-7890-abcd-ef1234567890')).toBe( + 'https://api.atlassian.com/ex/jira/a1b2c3d4-e5f6-7890-abcd-ef1234567890/rest/', + ); + }); + + it('builds the correct URL from a short Cloud ID', () => { + expect(buildGatewayEndpoint('mycloud123')).toBe( + 'https://api.atlassian.com/ex/jira/mycloud123/rest/', + ); + }); + + it('returns empty string for empty Cloud ID', () => { + expect(buildGatewayEndpoint('')).toBe(''); + }); + + it('produced URL matches JIRA_GATEWAY_REGEX', () => { + const url = buildGatewayEndpoint('test-cloud-id'); + expect(JIRA_GATEWAY_REGEX.test(url)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// extractCloudId tests — verifies the edit-mode useEffect can pre-fill Cloud ID +// --------------------------------------------------------------------------- +describe('extractCloudId', () => { + it('extracts UUID Cloud ID from a saved gateway endpoint', () => { + expect(extractCloudId('https://api.atlassian.com/ex/jira/a1b2c3d4-e5f6-7890-abcd-ef1234567890/rest/')).toBe( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + ); + }); + + it('extracts short Cloud ID', () => { + expect(extractCloudId('https://api.atlassian.com/ex/jira/mycloud123/rest/')).toBe('mycloud123'); + }); + + it('returns null for a standard cloud URL', () => { + expect(extractCloudId('https://mycompany.atlassian.net/rest/')).toBeNull(); + }); + + it('round-trips: build then extract returns original Cloud ID', () => { + const id = 'round-trip-id-123'; + expect(extractCloudId(buildGatewayEndpoint(id))).toBe(id); + }); +}); diff --git a/config-ui/src/plugins/register/jira/connection-fields/auth.tsx b/config-ui/src/plugins/register/jira/connection-fields/auth.tsx index bc43a6acec3..d7f0d625190 100644 --- a/config-ui/src/plugins/register/jira/connection-fields/auth.tsx +++ b/config-ui/src/plugins/register/jira/connection-fields/auth.tsx @@ -23,8 +23,10 @@ import { Radio, Input } from 'antd'; import { Block, ExternalLink } from '@/components'; import { DOC_URL } from '@/release'; -const JIRA_CLOUD_REGEX = /^https:\/\/\w+.atlassian.net\/rest\/$/; +const JIRA_CLOUD_REGEX = /^https:\/\/\w+\.atlassian\.net\/rest\/$/; +const JIRA_GATEWAY_REGEX = /^https:\/\/api\.atlassian\.com\/ex\/jira\/[^/]+\/rest\/$/; +type JiraVersion = 'cloud' | 'gateway' | 'server'; type Method = 'BasicAuth' | 'AccessToken'; interface Props { @@ -37,10 +39,17 @@ interface Props { } export const Auth = ({ type, initialValues, values, setValues, setErrors }: Props) => { - const [version, setVersion] = useState('cloud'); + const [version, setVersion] = useState('cloud'); + const [cloudId, setCloudId] = useState(''); useEffect(() => { - if (initialValues.endpoint && !JIRA_CLOUD_REGEX.test(initialValues.endpoint)) { + if (!initialValues.endpoint) return; + if (JIRA_GATEWAY_REGEX.test(initialValues.endpoint)) { + setVersion('gateway'); + // Extract the Cloud ID from the saved endpoint so the field pre-fills on edit + const match = initialValues.endpoint.match(/\/ex\/jira\/([^/]+)\//); + if (match) setCloudId(match[1]); + } else if (!JIRA_CLOUD_REGEX.test(initialValues.endpoint)) { setVersion('server'); } }, [initialValues.endpoint]); @@ -73,17 +82,17 @@ export const Auth = ({ type, initialValues, values, setValues, setErrors }: Prop }, [values]); const handleChangeVersion = (e: RadioChangeEvent) => { - const version = e.target.value; - + const v = e.target.value as JiraVersion; + setCloudId(''); setValues({ endpoint: '', - authMethod: 'BasicAuth', + // Gateway always uses AccessToken (scoped API token); others default to BasicAuth + authMethod: v === 'gateway' ? 'AccessToken' : 'BasicAuth', username: undefined, password: undefined, token: undefined, }); - - setVersion(version); + setVersion(v); }; const handleChangeEndpoint = (e: React.ChangeEvent) => { @@ -92,6 +101,16 @@ export const Auth = ({ type, initialValues, values, setValues, setErrors }: Prop }); }; + const handleChangeCloudId = (e: React.ChangeEvent) => { + const id = e.target.value.trim(); + setCloudId(id); + // Auto-construct the gateway endpoint from the Cloud ID so the user never + // has to type the full URL manually. + setValues({ + endpoint: id ? `https://api.atlassian.com/ex/jira/${id}/rest/` : '', + }); + }; + const handleChangeMethod = (e: RadioChangeEvent) => { setValues({ authMethod: (e.target as HTMLInputElement).value as Method, @@ -124,33 +143,65 @@ export const Auth = ({ type, initialValues, values, setValues, setErrors }: Prop Jira Cloud + Jira Cloud (Atlassian API Gateway) Jira Server / Jira Data Center - - {version === 'cloud' - ? 'Provide the Jira instance API endpoint. For Jira Cloud, e.g. https://your-company.atlassian.net/rest/. Please note that the endpoint URL should end with /.' - : ''} - {version === 'server' - ? 'Provide the Jira instance API endpoint. For Jira Server / Jira Data Center, e.g. https://jira.your-company.com/rest/. Please note that the endpoint URL should end with /.' - : ''} - - } - required - > - - + {version !== 'gateway' && ( + + {version === 'cloud' + ? 'Provide the Jira instance API endpoint. For Jira Cloud, e.g. https://your-company.atlassian.net/rest/. Please note that the endpoint URL should end with /.' + : ''} + {version === 'server' + ? 'Provide the Jira instance API endpoint. For Jira Server / Jira Data Center, e.g. https://jira.your-company.com/rest/. Please note that the endpoint URL should end with /.' + : ''} + + } + required + > + + + )} + {version === 'gateway' && ( + <> + + + + + + + + )} + {version === 'cloud' && ( <>