Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion backend/plugins/jira/api/connection_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
144 changes: 144 additions & 0 deletions backend/plugins/jira/api/connection_api_test.go
Original file line number Diff line number Diff line change
@@ -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 <token>" 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")
}
144 changes: 144 additions & 0 deletions config-ui/src/plugins/register/jira/connection-fields/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading