Skip to content

Commit 14a4e5b

Browse files
feat(jira): add Atlassian API Gateway authentication mode (#9024)
- Add JIRA_GATEWAY_REGEX and a third connection mode 'Jira Cloud (Atlassian API Gateway)' in the connection UI - Auto-build endpoint from Cloud ID input: the user types only the Cloud ID and the full gateway URL is constructed automatically - Force authMethod=AccessToken for gateway connections; existing BasicAuth path for standard cloud and server connections is unchanged - Detect gateway endpoints on edit/load and pre-fill the Cloud ID field - Suppress misleading /rest/ hint in testConnection() for api.atlassian.com endpoints (a 404 there means a bad Cloud ID, not a missing /rest/ path) - Add unit tests for gateway and standard-cloud connection flows - Add frontend test suite for regex patterns and Cloud ID builder No migration script required. No new DB columns. All collectors, extractors, converters and domain models unchanged.
1 parent 705280d commit 14a4e5b

4 files changed

Lines changed: 373 additions & 31 deletions

File tree

backend/plugins/jira/api/connection_api.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,10 @@ func testConnection(ctx context.Context, connection models.JiraConn) (*JiraTestC
5858
}
5959
serverInfoFail := "Failed testing the serverInfo: [ " + res.Request.URL.String() + " ]"
6060
// check if `/rest/` was missing
61-
if res.StatusCode == http.StatusNotFound && !strings.HasSuffix(connection.Endpoint, "/rest/") {
61+
// Skip this hint for Atlassian API Gateway endpoints — they already include /rest/ in the path
62+
// and a 404 on serverInfo there indicates a wrong Cloud ID, not a missing /rest/ segment.
63+
isGatewayEndpoint := strings.Contains(connection.Endpoint, "api.atlassian.com")
64+
if res.StatusCode == http.StatusNotFound && !strings.HasSuffix(connection.Endpoint, "/rest/") && !isGatewayEndpoint {
6265
endpointUrl, err := url.Parse(connection.Endpoint)
6366
if err != nil {
6467
return nil, errors.Convert(err)
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package api
19+
20+
import (
21+
"context"
22+
"encoding/json"
23+
"net/http"
24+
"net/http/httptest"
25+
"strings"
26+
"testing"
27+
28+
"github.com/apache/incubator-devlake/core/config"
29+
implcontext "github.com/apache/incubator-devlake/impls/context"
30+
"github.com/apache/incubator-devlake/impls/logruslog"
31+
"github.com/apache/incubator-devlake/plugins/jira/models"
32+
"github.com/go-playground/validator/v10"
33+
"github.com/stretchr/testify/assert"
34+
"github.com/stretchr/testify/require"
35+
)
36+
37+
// initTestDeps wires up the package-level basicRes and vld.
38+
// DefaultBasicRes satisfies context.BasicRes and only needs config + logger;
39+
// dal can be nil because NewApiClientFromConnection never calls GetDal.
40+
func initTestDeps(t *testing.T) {
41+
t.Helper()
42+
basicRes = implcontext.NewDefaultBasicRes(config.GetConfig(), logruslog.Global, nil)
43+
vld = validator.New()
44+
}
45+
46+
// serverInfoResponse is the minimal Jira serverInfo payload used in tests.
47+
type serverInfoResponse struct {
48+
DeploymentType string `json:"deploymentType"`
49+
Version string `json:"version"`
50+
VersionNumbers []int `json:"versionNumbers"`
51+
}
52+
53+
// newJiraTestServer creates an httptest.Server that responds to Jira REST paths.
54+
// It records the Authorization header sent by the client for later assertion.
55+
func newJiraTestServer(t *testing.T, authHeaderOut *string) *httptest.Server {
56+
t.Helper()
57+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
58+
if authHeaderOut != nil {
59+
*authHeaderOut = r.Header.Get("Authorization")
60+
}
61+
w.Header().Set("Content-Type", "application/json")
62+
switch {
63+
case strings.HasSuffix(r.URL.Path, "/api/2/serverInfo"):
64+
json.NewEncoder(w).Encode(serverInfoResponse{
65+
DeploymentType: "Cloud",
66+
Version: "1001.0.0",
67+
VersionNumbers: []int{1001, 0, 0},
68+
})
69+
case strings.Contains(r.URL.Path, "/agile/1.0/board"):
70+
json.NewEncoder(w).Encode(map[string]interface{}{"values": []interface{}{}})
71+
default:
72+
http.NotFound(w, r)
73+
}
74+
}))
75+
}
76+
77+
// TestTestConnection_Gateway verifies that a gateway-style endpoint with
78+
// authMethod=AccessToken sends "Authorization: Bearer <token>" and succeeds.
79+
func TestTestConnection_Gateway(t *testing.T) {
80+
initTestDeps(t)
81+
82+
var capturedAuth string
83+
srv := newJiraTestServer(t, &capturedAuth)
84+
defer srv.Close()
85+
86+
conn := models.JiraConn{}
87+
conn.Endpoint = srv.URL + "/rest/"
88+
conn.AuthMethod = "AccessToken"
89+
conn.Token = "my-scoped-gateway-token"
90+
91+
resp, err := testConnection(context.Background(), conn)
92+
require.Nil(t, err, "testConnection should succeed for gateway endpoint")
93+
require.NotNil(t, resp)
94+
assert.True(t, resp.Success)
95+
assert.Equal(t, "Bearer my-scoped-gateway-token", capturedAuth,
96+
"gateway mode must send Bearer token, not Basic credentials")
97+
}
98+
99+
// TestTestConnection_StandardCloud verifies backward compatibility:
100+
// a standard atlassian.net endpoint with BasicAuth still works.
101+
func TestTestConnection_StandardCloud(t *testing.T) {
102+
initTestDeps(t)
103+
104+
var capturedAuth string
105+
srv := newJiraTestServer(t, &capturedAuth)
106+
defer srv.Close()
107+
108+
conn := models.JiraConn{}
109+
conn.Endpoint = srv.URL + "/rest/"
110+
conn.AuthMethod = "BasicAuth"
111+
conn.Username = "user@example.com"
112+
conn.Password = "api-token-value"
113+
114+
resp, err := testConnection(context.Background(), conn)
115+
require.Nil(t, err, "testConnection should succeed for standard cloud endpoint")
116+
require.NotNil(t, resp)
117+
assert.True(t, resp.Success)
118+
assert.True(t, strings.HasPrefix(capturedAuth, "Basic "),
119+
"standard cloud mode must send Basic auth header")
120+
}
121+
122+
// TestTestConnection_NonGateway404ShowsHint verifies that when a non-gateway endpoint
123+
// is missing /rest/ and returns 404, the helpful hint message is included in the error.
124+
func TestTestConnection_NonGateway404ShowsHint(t *testing.T) {
125+
initTestDeps(t)
126+
127+
// Server that always returns 404
128+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
129+
http.NotFound(w, r)
130+
}))
131+
defer srv.Close()
132+
133+
// URL intentionally missing /rest/ — should trigger the "please try" hint
134+
connMissingRest := models.JiraConn{}
135+
connMissingRest.Endpoint = srv.URL + "/"
136+
connMissingRest.AuthMethod = "BasicAuth"
137+
connMissingRest.Username = "u"
138+
connMissingRest.Password = "p"
139+
140+
_, errMissingRest := testConnection(context.Background(), connMissingRest)
141+
require.NotNil(t, errMissingRest)
142+
assert.Contains(t, errMissingRest.Error(), "please try",
143+
"non-gateway 404 should include the /rest/ hint")
144+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*
17+
*/
18+
19+
import { describe, it, expect } from 'vitest';
20+
21+
// ---------------------------------------------------------------------------
22+
// Regex constants (mirror exactly what auth.tsx defines)
23+
// ---------------------------------------------------------------------------
24+
const JIRA_CLOUD_REGEX = /^https:\/\/\w+\.atlassian\.net\/rest\/$/;
25+
const JIRA_GATEWAY_REGEX = /^https:\/\/api\.atlassian\.com\/ex\/jira\/[^/]+\/rest\/$/;
26+
27+
// ---------------------------------------------------------------------------
28+
// Endpoint builder (mirrors handleChangeCloudId in auth.tsx)
29+
// ---------------------------------------------------------------------------
30+
function buildGatewayEndpoint(cloudId: string): string {
31+
return cloudId ? `https://api.atlassian.com/ex/jira/${cloudId}/rest/` : '';
32+
}
33+
34+
// ---------------------------------------------------------------------------
35+
// Cloud ID extractor (mirrors the load-time useEffect in auth.tsx)
36+
// ---------------------------------------------------------------------------
37+
function extractCloudId(endpoint: string): string | null {
38+
const match = endpoint.match(/\/ex\/jira\/([^/]+)\//);
39+
return match ? match[1] : null;
40+
}
41+
42+
// ---------------------------------------------------------------------------
43+
// JIRA_GATEWAY_REGEX tests
44+
// ---------------------------------------------------------------------------
45+
describe('JIRA_GATEWAY_REGEX', () => {
46+
it('accepts a valid gateway URL with UUID Cloud ID', () => {
47+
expect(JIRA_GATEWAY_REGEX.test('https://api.atlassian.com/ex/jira/a1b2c3d4-e5f6-7890-abcd-ef1234567890/rest/')).toBe(true);
48+
});
49+
50+
it('accepts a valid gateway URL with short Cloud ID', () => {
51+
expect(JIRA_GATEWAY_REGEX.test('https://api.atlassian.com/ex/jira/mycloud123/rest/')).toBe(true);
52+
});
53+
54+
it('rejects gateway URL missing trailing slash', () => {
55+
expect(JIRA_GATEWAY_REGEX.test('https://api.atlassian.com/ex/jira/abc123/rest')).toBe(false);
56+
});
57+
58+
it('rejects gateway URL missing /rest/', () => {
59+
expect(JIRA_GATEWAY_REGEX.test('https://api.atlassian.com/ex/jira/abc123/')).toBe(false);
60+
});
61+
62+
it('rejects standard Jira Cloud URL', () => {
63+
expect(JIRA_GATEWAY_REGEX.test('https://mycompany.atlassian.net/rest/')).toBe(false);
64+
});
65+
66+
it('rejects Jira Server URL', () => {
67+
expect(JIRA_GATEWAY_REGEX.test('https://jira.mycompany.com/rest/')).toBe(false);
68+
});
69+
70+
it('rejects gateway URL with extra path segment after /rest/', () => {
71+
expect(JIRA_GATEWAY_REGEX.test('https://api.atlassian.com/ex/jira/abc123/rest/extra/')).toBe(false);
72+
});
73+
});
74+
75+
// ---------------------------------------------------------------------------
76+
// JIRA_CLOUD_REGEX regression tests — must not break existing behaviour
77+
// ---------------------------------------------------------------------------
78+
describe('JIRA_CLOUD_REGEX (regression)', () => {
79+
it('accepts a standard Jira Cloud URL', () => {
80+
expect(JIRA_CLOUD_REGEX.test('https://mycompany.atlassian.net/rest/')).toBe(true);
81+
});
82+
83+
it('rejects gateway URL', () => {
84+
expect(JIRA_CLOUD_REGEX.test('https://api.atlassian.com/ex/jira/abc123/rest/')).toBe(false);
85+
});
86+
87+
it('rejects Jira Server URL', () => {
88+
expect(JIRA_CLOUD_REGEX.test('https://jira.mycompany.com/rest/')).toBe(false);
89+
});
90+
91+
it('rejects cloud URL missing trailing slash', () => {
92+
expect(JIRA_CLOUD_REGEX.test('https://mycompany.atlassian.net/rest')).toBe(false);
93+
});
94+
});
95+
96+
// ---------------------------------------------------------------------------
97+
// buildGatewayEndpoint tests
98+
// ---------------------------------------------------------------------------
99+
describe('buildGatewayEndpoint', () => {
100+
it('builds the correct URL from a UUID Cloud ID', () => {
101+
expect(buildGatewayEndpoint('a1b2c3d4-e5f6-7890-abcd-ef1234567890')).toBe(
102+
'https://api.atlassian.com/ex/jira/a1b2c3d4-e5f6-7890-abcd-ef1234567890/rest/',
103+
);
104+
});
105+
106+
it('builds the correct URL from a short Cloud ID', () => {
107+
expect(buildGatewayEndpoint('mycloud123')).toBe(
108+
'https://api.atlassian.com/ex/jira/mycloud123/rest/',
109+
);
110+
});
111+
112+
it('returns empty string for empty Cloud ID', () => {
113+
expect(buildGatewayEndpoint('')).toBe('');
114+
});
115+
116+
it('produced URL matches JIRA_GATEWAY_REGEX', () => {
117+
const url = buildGatewayEndpoint('test-cloud-id');
118+
expect(JIRA_GATEWAY_REGEX.test(url)).toBe(true);
119+
});
120+
});
121+
122+
// ---------------------------------------------------------------------------
123+
// extractCloudId tests — verifies the edit-mode useEffect can pre-fill Cloud ID
124+
// ---------------------------------------------------------------------------
125+
describe('extractCloudId', () => {
126+
it('extracts UUID Cloud ID from a saved gateway endpoint', () => {
127+
expect(extractCloudId('https://api.atlassian.com/ex/jira/a1b2c3d4-e5f6-7890-abcd-ef1234567890/rest/')).toBe(
128+
'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
129+
);
130+
});
131+
132+
it('extracts short Cloud ID', () => {
133+
expect(extractCloudId('https://api.atlassian.com/ex/jira/mycloud123/rest/')).toBe('mycloud123');
134+
});
135+
136+
it('returns null for a standard cloud URL', () => {
137+
expect(extractCloudId('https://mycompany.atlassian.net/rest/')).toBeNull();
138+
});
139+
140+
it('round-trips: build then extract returns original Cloud ID', () => {
141+
const id = 'round-trip-id-123';
142+
expect(extractCloudId(buildGatewayEndpoint(id))).toBe(id);
143+
});
144+
});

0 commit comments

Comments
 (0)