From 6666c50dadc5be0887e46743dcde50ce47a52a16 Mon Sep 17 00:00:00 2001 From: VELLANKI SANTHOSH Date: Tue, 13 Jan 2026 15:58:09 +0000 Subject: [PATCH 1/2] feat(jira): Add Bearer Token authentication and SSL certificate support ## Summary Add enterprise-grade authentication options for the JIRA Tool, enabling connections to self-hosted JIRA instances that require Bearer tokens and/or custom SSL certificates. ## Changes ### Added - Bearer Token authentication support for enterprise/self-hosted JIRA - SSL certificate configuration (sslCertPath, sslKeyPath, verifySslCerts) - Comprehensive test suite (unit, integration, regression tests) - JIRA Tool documentation (README.md) - CHANGELOG.md for release notes ### Changed - JiraApi credential updated to v2.0 with auth type selector - BaseJiraTool refactored with buildHeaders() and buildFetchOptions() ### Backward Compatibility - Basic Auth (email + apiToken) continues to work unchanged - Existing users require no configuration changes Closes #XXXX --- CHANGELOG.md | 34 + .../credentials/JiraApi.credential.ts | 65 +- .../components/nodes/tools/Jira/Jira.test.ts | 880 ++++++++++++++++++ packages/components/nodes/tools/Jira/Jira.ts | 45 +- .../components/nodes/tools/Jira/README.md | 249 +++++ packages/components/nodes/tools/Jira/core.ts | 281 +++++- 6 files changed, 1524 insertions(+), 30 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 packages/components/nodes/tools/Jira/Jira.test.ts create mode 100644 packages/components/nodes/tools/Jira/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000000..a901602f25b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [3.0.13] - 2026-01-13 + +### Added +- **JIRA Tool: Bearer Token Authentication** - Support for enterprise/self-hosted JIRA instances requiring Bearer token authentication. +- **JIRA Tool: SSL Certificate Support** - New `sslCertPath` and `sslKeyPath` options for secure connections to JIRA servers with custom certificates. +- **JIRA Tool: SSL Verification Toggle** - `verifySslCerts` option to disable certificate verification for development environments. +- **JIRA Tool: Comprehensive Test Suite** - Unit tests, integration tests, and regression tests for Bearer Token + SSL features. +- **JIRA Tool: Documentation** - Added README.md with authentication guides, SSL configuration, and troubleshooting. + +### Changed +- **JiraApi Credential** - Updated to version 2.0 with authentication type selector (Basic Auth / Bearer Token). +- **JIRA Tool Core** - Refactored `BaseJiraTool` class with `buildHeaders()` and `buildFetchOptions()` methods for cleaner authentication handling. + +### Fixed +- Improved error messages for SSL certificate loading failures. + +### Notes +- **Backward Compatibility**: Basic Auth (`email + apiToken`) continues to work exactly as before. Existing users do not need to change anything. +- **Migration**: Users can switch to Bearer Token by selecting "Bearer Token" in the credential configuration and providing their token. + +--- + +## [3.0.12] - Previous Release + +_See [GitHub Releases](https://github.com/FlowiseAI/Flowise/releases) for previous version history._ diff --git a/packages/components/credentials/JiraApi.credential.ts b/packages/components/credentials/JiraApi.credential.ts index 6638f2e0b40..bfea2058acb 100644 --- a/packages/components/credentials/JiraApi.credential.ts +++ b/packages/components/credentials/JiraApi.credential.ts @@ -10,21 +10,80 @@ class JiraApi implements INodeCredential { constructor() { this.label = 'Jira API' this.name = 'jiraApi' - this.version = 1.0 + this.version = 2.0 this.description = 'Refer to official guide on how to get accessToken on Github' this.inputs = [ + { + label: 'Authentication Type', + name: 'authType', + type: 'options', + default: 'basicAuth', + description: 'Choose between Basic Authentication or Bearer Token', + options: [ + { + label: 'Basic Auth (Email + API Token)', + name: 'basicAuth' + }, + { + label: 'Bearer Token', + name: 'bearerToken' + } + ] + }, { label: 'User Name', name: 'username', type: 'string', - placeholder: 'username@example.com' + placeholder: 'username@example.com', + description: 'Email or username for Basic Auth', + show: { + authType: ['basicAuth'] + } }, { label: 'Access Token', name: 'accessToken', type: 'password', - placeholder: '' + placeholder: '', + description: 'API token for Basic Auth', + show: { + authType: ['basicAuth'] + } + }, + { + label: 'Bearer Token', + name: 'bearerTokenValue', + type: 'password', + placeholder: '', + description: 'Bearer token for token-based authentication', + show: { + authType: ['bearerToken'] + } + }, + { + label: 'SSL Certificate Path (Optional)', + name: 'sslCertPath', + type: 'string', + placeholder: '/path/to/cert.pem', + description: 'Path to SSL certificate file for self-signed certificates', + optional: true + }, + { + label: 'SSL Key Path (Optional)', + name: 'sslKeyPath', + type: 'string', + placeholder: '/path/to/key.pem', + description: 'Path to SSL key file for client certificate authentication', + optional: true + }, + { + label: 'Verify SSL Certificates', + name: 'verifySslCerts', + type: 'boolean', + default: true, + description: 'Whether to verify SSL certificates (disable only for self-signed certs)', + optional: true } ] } diff --git a/packages/components/nodes/tools/Jira/Jira.test.ts b/packages/components/nodes/tools/Jira/Jira.test.ts new file mode 100644 index 00000000000..25f0776e2ce --- /dev/null +++ b/packages/components/nodes/tools/Jira/Jira.test.ts @@ -0,0 +1,880 @@ +/** + * Unit & Integration Tests for JIRA Tool (Bearer Token + SSL Support) + * + * Test Coverage: + * - Unit Tests: Authentication header generation, SSL configuration + * - Integration Tests: Mock JIRA server with Bearer Token and SSL + * - Regression Tests: Basic Auth backward compatibility + */ + +import * as fs from 'fs' +import * as https from 'https' + +// Mock node-fetch +jest.mock('node-fetch', () => { + const mockFetch = jest.fn() + return { + __esModule: true, + default: mockFetch, + __mockFetch: mockFetch + } +}) + +// Mock fs module for SSL certificate tests +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + readFileSync: jest.fn() +})) + +// Mock https module +jest.mock('https', () => ({ + Agent: jest.fn().mockImplementation((options) => ({ + options, + _isHttpsAgent: true + })) +})) + +// Mock utility functions +jest.mock('../../../src/utils', () => ({ + getBaseClasses: jest.fn(() => ['Tool', 'StructuredTool', 'DynamicStructuredTool']) +})) + +jest.mock('../../../src/agents', () => ({ + TOOL_ARGS_PREFIX: '\n---TOOL_ARGS---', + formatToolError: jest.fn((error) => `Error: ${error}`) +})) + +describe('JIRA Tool Authentication & SSL Tests', () => { + let mockFetch: jest.Mock + let mockReadFileSync: jest.Mock + let mockHttpsAgent: jest.Mock + + beforeEach(() => { + jest.clearAllMocks() + + // Get mock functions + const fetchModule = require('node-fetch') + mockFetch = fetchModule.__mockFetch + + mockReadFileSync = fs.readFileSync as jest.Mock + mockHttpsAgent = https.Agent as jest.Mock + + // Default successful response + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + text: () => Promise.resolve(JSON.stringify({ id: 'PROJ-123', key: 'PROJ-123' })) + }) + }) + + /** + * =========================================== + * UNIT TESTS: Authentication Header Generation + * =========================================== + */ + describe('Unit Tests: Authentication Headers', () => { + describe('Bearer Token Authentication', () => { + it('should set Authorization: Bearer when bearerToken is provided', async () => { + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://jira.example.com', + authType: 'bearerToken', + bearerToken: 'my-secret-bearer-token', + username: '', + accessToken: '' + }) + + // Find the getIssue tool and invoke it + const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') + expect(getIssueTool).toBeDefined() + + await getIssueTool!.invoke({ issueKey: 'PROJ-123' }) + + // Verify fetch was called with Bearer token + expect(mockFetch).toHaveBeenCalledTimes(1) + const [url, options] = mockFetch.mock.calls[0] + + expect(url).toBe('https://jira.example.com/rest/api/3/issue/PROJ-123') + expect(options.headers).toBeDefined() + expect(options.headers.Authorization).toBe('Bearer my-secret-bearer-token') + }) + + it('should use Bearer token even when email/apiToken are also provided', async () => { + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://jira.example.com', + authType: 'bearerToken', + bearerToken: 'my-bearer-token', + username: 'user@example.com', + accessToken: 'api-token-should-be-ignored' + }) + + const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') + await getIssueTool!.invoke({ issueKey: 'PROJ-123' }) + + const [, options] = mockFetch.mock.calls[0] + expect(options.headers.Authorization).toBe('Bearer my-bearer-token') + expect(options.headers.Authorization).not.toContain('Basic') + }) + + it('should handle empty bearer token gracefully', async () => { + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://jira.example.com', + authType: 'bearerToken', + bearerToken: '', + username: '', + accessToken: '' + }) + + const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') + await getIssueTool!.invoke({ issueKey: 'PROJ-123' }) + + const [, options] = mockFetch.mock.calls[0] + // Empty bearer token should still create Bearer header (server will reject) + expect(options.headers.Authorization).toBe('Bearer ') + }) + }) + + describe('Basic Authentication', () => { + it('should set Authorization: Basic when email + apiToken are provided', async () => { + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://jira.example.com', + authType: 'basicAuth', + username: 'user@example.com', + accessToken: 'my-api-token', + bearerToken: '' + }) + + const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') + await getIssueTool!.invoke({ issueKey: 'PROJ-123' }) + + const [, options] = mockFetch.mock.calls[0] + + // Calculate expected Basic auth header + const expectedAuth = Buffer.from('user@example.com:my-api-token').toString('base64') + expect(options.headers.Authorization).toBe(`Basic ${expectedAuth}`) + }) + + it('should use Basic auth when authType is not specified (default)', async () => { + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://jira.example.com', + username: 'user@example.com', + accessToken: 'my-api-token' + // authType not specified - should default to basicAuth + }) + + const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') + await getIssueTool!.invoke({ issueKey: 'PROJ-123' }) + + const [, options] = mockFetch.mock.calls[0] + expect(options.headers.Authorization).toContain('Basic ') + }) + + it('should handle special characters in username and password', async () => { + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://jira.example.com', + authType: 'basicAuth', + username: 'user+special@example.com', + accessToken: 'token:with:colons!@#$' + }) + + const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') + await getIssueTool!.invoke({ issueKey: 'PROJ-123' }) + + const [, options] = mockFetch.mock.calls[0] + const expectedAuth = Buffer.from('user+special@example.com:token:with:colons!@#$').toString('base64') + expect(options.headers.Authorization).toBe(`Basic ${expectedAuth}`) + }) + }) + + describe('Required Headers', () => { + it('should always include Content-Type and Accept headers', async () => { + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://jira.example.com', + authType: 'bearerToken', + bearerToken: 'token' + }) + + const createIssueTool = tools.find((t) => t.name === 'jira_create_issue') + await createIssueTool!.invoke({ + projectKey: 'PROJ', + issueType: 'Bug', + summary: 'Test issue' + }) + + const [, options] = mockFetch.mock.calls[0] + expect(options.headers['Content-Type']).toBe('application/json') + expect(options.headers.Accept).toBe('application/json') + }) + }) + }) + + /** + * =========================================== + * UNIT TESTS: SSL Certificate Configuration + * =========================================== + */ + describe('Unit Tests: SSL Certificate Configuration', () => { + describe('Client Certificate and Key', () => { + it('should configure HTTPS agent with certificate and key when both are provided', async () => { + mockReadFileSync + .mockReturnValueOnce('-----BEGIN CERTIFICATE-----\nCERTIFICATE_DATA\n-----END CERTIFICATE-----') + .mockReturnValueOnce('-----BEGIN PRIVATE KEY-----\nKEY_DATA\n-----END PRIVATE KEY-----') + + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://jira.example.com', + authType: 'bearerToken', + bearerToken: 'token', + sslCertPath: '/path/to/cert.pem', + sslKeyPath: '/path/to/key.pem', + verifySslCerts: true + }) + + const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') + await getIssueTool!.invoke({ issueKey: 'PROJ-123' }) + + // Verify fs.readFileSync was called for both cert and key + expect(mockReadFileSync).toHaveBeenCalledWith('/path/to/cert.pem', 'utf-8') + expect(mockReadFileSync).toHaveBeenCalledWith('/path/to/key.pem', 'utf-8') + + // Verify HTTPS Agent was created with correct options + expect(mockHttpsAgent).toHaveBeenCalled() + const agentOptions = mockHttpsAgent.mock.calls[0][0] + expect(agentOptions.cert).toContain('CERTIFICATE_DATA') + expect(agentOptions.key).toContain('KEY_DATA') + expect(agentOptions.rejectUnauthorized).toBe(true) + }) + }) + + describe('CA Certificate Only', () => { + it('should configure HTTPS agent with CA certificate when only sslCertPath is provided', async () => { + mockReadFileSync.mockReturnValueOnce('-----BEGIN CERTIFICATE-----\nCA_CERT_DATA\n-----END CERTIFICATE-----') + + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://jira.example.com', + authType: 'bearerToken', + bearerToken: 'token', + sslCertPath: '/path/to/ca-cert.pem', + // sslKeyPath not provided - indicates CA cert only + verifySslCerts: true + }) + + const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') + await getIssueTool!.invoke({ issueKey: 'PROJ-123' }) + + expect(mockReadFileSync).toHaveBeenCalledWith('/path/to/ca-cert.pem', 'utf-8') + + const agentOptions = mockHttpsAgent.mock.calls[0][0] + expect(agentOptions.ca).toContain('CA_CERT_DATA') + expect(agentOptions.cert).toBeUndefined() + expect(agentOptions.key).toBeUndefined() + }) + }) + + describe('SSL Verification Settings', () => { + it('should disable SSL verification when verifySslCerts is false', async () => { + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://jira.example.com', + authType: 'bearerToken', + bearerToken: 'token', + verifySslCerts: false + }) + + const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') + await getIssueTool!.invoke({ issueKey: 'PROJ-123' }) + + const agentOptions = mockHttpsAgent.mock.calls[0][0] + expect(agentOptions.rejectUnauthorized).toBe(false) + }) + + it('should enable SSL verification by default', async () => { + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://jira.example.com', + authType: 'bearerToken', + bearerToken: 'token' + // verifySslCerts not specified + }) + + const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') + await getIssueTool!.invoke({ issueKey: 'PROJ-123' }) + + const agentOptions = mockHttpsAgent.mock.calls[0][0] + expect(agentOptions.rejectUnauthorized).toBe(true) + }) + }) + + describe('SSL Error Handling', () => { + it('should throw descriptive error when certificate file cannot be read', async () => { + mockReadFileSync.mockImplementation(() => { + throw new Error('ENOENT: no such file or directory') + }) + + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://jira.example.com', + authType: 'bearerToken', + bearerToken: 'token', + sslCertPath: '/nonexistent/cert.pem', + sslKeyPath: '/nonexistent/key.pem' + }) + + const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') + await expect(getIssueTool!.invoke({ issueKey: 'PROJ-123' })).rejects.toThrow('Failed to load SSL certificate') + }) + }) + + describe('HTTP vs HTTPS', () => { + it('should not create HTTPS agent for HTTP connections', async () => { + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'http://jira.example.com', // HTTP, not HTTPS + authType: 'basicAuth', + username: 'user@example.com', + accessToken: 'token' + }) + + const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') + await getIssueTool!.invoke({ issueKey: 'PROJ-123' }) + + // HTTPS Agent should NOT be created for HTTP connections + expect(mockHttpsAgent).not.toHaveBeenCalled() + }) + }) + }) + + /** + * =========================================== + * INTEGRATION TESTS: Mock JIRA Server + * =========================================== + */ + describe('Integration Tests: Mock JIRA Server', () => { + describe('Bearer Token Authentication Flow', () => { + it('should successfully authenticate with Bearer token', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + text: () => + Promise.resolve( + JSON.stringify({ + id: '10001', + key: 'PROJ-123', + fields: { + summary: 'Test Issue', + status: { name: 'Open' } + } + }) + ) + }) + + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://jira.example.com', + authType: 'bearerToken', + bearerToken: 'valid-bearer-token' + }) + + const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') + const result = await getIssueTool!.invoke({ issueKey: 'PROJ-123' }) + + expect(result).toContain('PROJ-123') + expect(result).toContain('Test Issue') + }) + + it('should fail with 401 when Bearer token is invalid', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + text: () => Promise.resolve('{"errorMessages":["Client must be authenticated to access this resource."]}') + }) + + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://jira.example.com', + authType: 'bearerToken', + bearerToken: 'invalid-token' + }) + + const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') + await expect(getIssueTool!.invoke({ issueKey: 'PROJ-123' })).rejects.toThrow('Jira API Error 401') + }) + + it('should fail with 403 when Bearer token lacks permissions', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + statusText: 'Forbidden', + text: () => Promise.resolve('{"errorMessages":["You do not have permission to view this issue."]}') + }) + + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://jira.example.com', + authType: 'bearerToken', + bearerToken: 'limited-token' + }) + + const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') + await expect(getIssueTool!.invoke({ issueKey: 'PROJ-123' })).rejects.toThrow('Jira API Error 403') + }) + }) + + describe('SSL Certificate Validation Flow', () => { + it('should succeed when correct SSL certificates are provided', async () => { + mockReadFileSync + .mockReturnValueOnce('-----BEGIN CERTIFICATE-----\nVALID_CERT\n-----END CERTIFICATE-----') + .mockReturnValueOnce('-----BEGIN PRIVATE KEY-----\nVALID_KEY\n-----END PRIVATE KEY-----') + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + text: () => Promise.resolve(JSON.stringify({ id: '10001', key: 'PROJ-123' })) + }) + + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://secure-jira.example.com', + authType: 'bearerToken', + bearerToken: 'token', + sslCertPath: '/path/to/valid-cert.pem', + sslKeyPath: '/path/to/valid-key.pem' + }) + + const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') + const result = await getIssueTool!.invoke({ issueKey: 'PROJ-123' }) + + expect(result).toContain('PROJ-123') + expect(mockHttpsAgent).toHaveBeenCalled() + }) + + it('should handle SSL certificate expiration errors', async () => { + mockReadFileSync + .mockReturnValueOnce('-----BEGIN CERTIFICATE-----\nEXPIRED_CERT\n-----END CERTIFICATE-----') + .mockReturnValueOnce('-----BEGIN PRIVATE KEY-----\nVALID_KEY\n-----END PRIVATE KEY-----') + + mockFetch.mockRejectedValueOnce(new Error('CERT_HAS_EXPIRED')) + + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://secure-jira.example.com', + authType: 'bearerToken', + bearerToken: 'token', + sslCertPath: '/path/to/expired-cert.pem', + sslKeyPath: '/path/to/key.pem' + }) + + const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') + await expect(getIssueTool!.invoke({ issueKey: 'PROJ-123' })).rejects.toThrow('CERT_HAS_EXPIRED') + }) + + it('should handle self-signed certificate with verifySslCerts disabled', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + text: () => Promise.resolve(JSON.stringify({ id: '10001', key: 'PROJ-123' })) + }) + + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://self-signed-jira.example.com', + authType: 'bearerToken', + bearerToken: 'token', + verifySslCerts: false + }) + + const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') + const result = await getIssueTool!.invoke({ issueKey: 'PROJ-123' }) + + expect(result).toContain('PROJ-123') + + const agentOptions = mockHttpsAgent.mock.calls[0][0] + expect(agentOptions.rejectUnauthorized).toBe(false) + }) + }) + + describe('Full CRUD Operations', () => { + it('should create issue with Bearer token', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + statusText: 'Created', + text: () => + Promise.resolve( + JSON.stringify({ + id: '10002', + key: 'PROJ-124', + self: 'https://jira.example.com/rest/api/3/issue/10002' + }) + ) + }) + + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://jira.example.com', + authType: 'bearerToken', + bearerToken: 'create-token' + }) + + const createIssueTool = tools.find((t) => t.name === 'jira_create_issue') + const result = await createIssueTool!.invoke({ + projectKey: 'PROJ', + issueType: 'Bug', + summary: 'New Bug Report', + description: 'This is a bug description' + }) + + expect(result).toContain('PROJ-124') + + // Verify the request body + const [, options] = mockFetch.mock.calls[0] + expect(options.method).toBe('POST') + const body = JSON.parse(options.body) + expect(body.fields.summary).toBe('New Bug Report') + }) + + it('should update issue with Bearer token', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 204, + statusText: 'No Content', + text: () => Promise.resolve('') + }) + + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://jira.example.com', + authType: 'bearerToken', + bearerToken: 'update-token' + }) + + const updateIssueTool = tools.find((t) => t.name === 'jira_update_issue') + await updateIssueTool!.invoke({ + issueKey: 'PROJ-123', + summary: 'Updated Summary' + }) + + const [url, options] = mockFetch.mock.calls[0] + expect(url).toBe('https://jira.example.com/rest/api/3/issue/PROJ-123') + expect(options.method).toBe('PUT') + }) + }) + }) + + /** + * =========================================== + * REGRESSION TESTS: Basic Auth Backward Compatibility + * =========================================== + */ + describe('Regression Tests: Basic Auth Backward Compatibility', () => { + it('should continue to work with Basic Auth exactly as before', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + text: () => + Promise.resolve( + JSON.stringify({ + issues: [ + { key: 'PROJ-1', fields: { summary: 'Issue 1' } }, + { key: 'PROJ-2', fields: { summary: 'Issue 2' } } + ], + total: 2 + }) + ) + }) + + const { createJiraTools } = await import('./core') + + // Original Basic Auth configuration + const tools = createJiraTools({ + jiraHost: 'https://company.atlassian.net', + authType: 'basicAuth', + username: 'admin@company.com', + accessToken: 'atlassian-api-token-123' + }) + + const listIssuesTool = tools.find((t) => t.name === 'jira_list_issues') + const result = await listIssuesTool!.invoke({ projectKey: 'PROJ' }) + + expect(result).toContain('PROJ-1') + expect(result).toContain('PROJ-2') + + // Verify Basic auth header format + const [, options] = mockFetch.mock.calls[0] + expect(options.headers.Authorization).toMatch(/^Basic [A-Za-z0-9+/=]+$/) + + // Decode and verify credentials + const authHeader = options.headers.Authorization + const base64 = authHeader.replace('Basic ', '') + const decoded = Buffer.from(base64, 'base64').toString('utf-8') + expect(decoded).toBe('admin@company.com:atlassian-api-token-123') + }) + + it('should handle all existing issue operations with Basic Auth', async () => { + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://company.atlassian.net', + authType: 'basicAuth', + username: 'user@company.com', + accessToken: 'api-token' + }) + + // Verify all expected tools are created + const expectedTools = [ + 'jira_list_issues', + 'jira_create_issue', + 'jira_get_issue', + 'jira_update_issue', + 'jira_assign_issue', + 'jira_transition_issue', + 'jira_list_comments', + 'jira_create_comment', + 'jira_get_comment', + 'jira_update_comment', + 'jira_delete_comment', + 'jira_search_users', + 'jira_get_user', + 'jira_create_user' + ] + + for (const toolName of expectedTools) { + const tool = tools.find((t) => t.name === toolName) + expect(tool).toBeDefined() + } + }) + + it('should fetch single issue with Basic Auth (regression)', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + text: () => + Promise.resolve( + JSON.stringify({ + id: '10001', + key: 'PROJ-123', + fields: { + summary: 'Original Issue', + description: 'Original description', + status: { name: 'To Do' }, + priority: { name: 'Medium' } + } + }) + ) + }) + + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://company.atlassian.net', + authType: 'basicAuth', + username: 'user@company.com', + accessToken: 'api-token' + }) + + const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') + const result = await getIssueTool!.invoke({ issueKey: 'PROJ-123' }) + + expect(result).toContain('PROJ-123') + expect(result).toContain('Original Issue') + + // Verify correct endpoint was called + const [url] = mockFetch.mock.calls[0] + expect(url).toBe('https://company.atlassian.net/rest/api/3/issue/PROJ-123') + }) + + it('should add comment with Basic Auth (regression)', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + statusText: 'Created', + text: () => + Promise.resolve( + JSON.stringify({ + id: '10001', + body: { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Test comment' }] }] + } + }) + ) + }) + + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://company.atlassian.net', + authType: 'basicAuth', + username: 'user@company.com', + accessToken: 'api-token' + }) + + const createCommentTool = tools.find((t) => t.name === 'jira_create_comment') + await createCommentTool!.invoke({ + issueKey: 'PROJ-123', + text: 'Test comment' + }) + + const [url, options] = mockFetch.mock.calls[0] + expect(url).toBe('https://company.atlassian.net/rest/api/3/issue/PROJ-123/comment') + expect(options.method).toBe('POST') + }) + + it('should search users with Basic Auth (regression)', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + text: () => + Promise.resolve( + JSON.stringify([ + { accountId: '123', displayName: 'John Doe', emailAddress: 'john@company.com' }, + { accountId: '456', displayName: 'Jane Doe', emailAddress: 'jane@company.com' } + ]) + ) + }) + + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://company.atlassian.net', + authType: 'basicAuth', + username: 'admin@company.com', + accessToken: 'api-token' + }) + + const searchUsersTool = tools.find((t) => t.name === 'jira_search_users') + const result = await searchUsersTool!.invoke({ query: 'doe' }) + + expect(result).toContain('John Doe') + expect(result).toContain('Jane Doe') + }) + }) + + /** + * =========================================== + * ERROR HANDLING TESTS + * =========================================== + */ + describe('Error Handling', () => { + it('should handle network errors gracefully', async () => { + mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')) + + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://jira.example.com', + authType: 'bearerToken', + bearerToken: 'token' + }) + + const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') + await expect(getIssueTool!.invoke({ issueKey: 'PROJ-123' })).rejects.toThrow('Failed to connect to Jira') + }) + + it('should handle timeout errors', async () => { + mockFetch.mockRejectedValueOnce(new Error('ETIMEDOUT')) + + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://jira.example.com', + authType: 'bearerToken', + bearerToken: 'token' + }) + + const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') + await expect(getIssueTool!.invoke({ issueKey: 'PROJ-123' })).rejects.toThrow('ETIMEDOUT') + }) + + it('should handle DNS resolution errors', async () => { + mockFetch.mockRejectedValueOnce(new Error('ENOTFOUND')) + + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://nonexistent-jira.example.com', + authType: 'bearerToken', + bearerToken: 'token' + }) + + const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') + await expect(getIssueTool!.invoke({ issueKey: 'PROJ-123' })).rejects.toThrow('ENOTFOUND') + }) + + it('should handle rate limiting (429) errors', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 429, + statusText: 'Too Many Requests', + text: () => Promise.resolve('{"errorMessages":["Rate limit exceeded. Please retry later."]}') + }) + + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://jira.example.com', + authType: 'bearerToken', + bearerToken: 'token' + }) + + const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') + await expect(getIssueTool!.invoke({ issueKey: 'PROJ-123' })).rejects.toThrow('Jira API Error 429') + }) + + it('should handle server errors (500)', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: () => Promise.resolve('Internal server error occurred') + }) + + const { createJiraTools } = await import('./core') + + const tools = createJiraTools({ + jiraHost: 'https://jira.example.com', + authType: 'bearerToken', + bearerToken: 'token' + }) + + const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') + await expect(getIssueTool!.invoke({ issueKey: 'PROJ-123' })).rejects.toThrow('Jira API Error 500') + }) + }) +}) diff --git a/packages/components/nodes/tools/Jira/Jira.ts b/packages/components/nodes/tools/Jira/Jira.ts index 95c2b8c045b..649cb1ed3fd 100644 --- a/packages/components/nodes/tools/Jira/Jira.ts +++ b/packages/components/nodes/tools/Jira/Jira.ts @@ -372,22 +372,44 @@ class Jira_Tools implements INode { async init(nodeData: INodeData, _: string, options: ICommonObject): Promise { let credentialData = await getCredentialData(nodeData.credential ?? '', options) - const username = getCredentialParam('username', credentialData, nodeData) - const accessToken = getCredentialParam('accessToken', credentialData, nodeData) + const authType = getCredentialParam('authType', credentialData, nodeData) || 'basicAuth' const jiraHost = nodeData.inputs?.jiraHost as string - if (!username) { - throw new Error('No username found in credential') + if (!jiraHost) { + throw new Error('No Jira host provided') } - if (!accessToken) { - throw new Error('No access token found in credential') - } + let username: string | undefined + let accessToken: string | undefined + let bearerToken: string | undefined - if (!jiraHost) { - throw new Error('No Jira host provided') + // Handle authentication based on type + if (authType === 'basicAuth') { + username = getCredentialParam('username', credentialData, nodeData) + accessToken = getCredentialParam('accessToken', credentialData, nodeData) + + if (!username) { + throw new Error('No username found in credential') + } + + if (!accessToken) { + throw new Error('No access token found in credential') + } + } else if (authType === 'bearerToken') { + bearerToken = getCredentialParam('bearerTokenValue', credentialData, nodeData) + + if (!bearerToken) { + throw new Error('No bearer token found in credential') + } + } else { + throw new Error('Invalid authentication type') } + // Get SSL configuration + const sslCertPath = getCredentialParam('sslCertPath', credentialData, nodeData) + const sslKeyPath = getCredentialParam('sslKeyPath', credentialData, nodeData) + const verifySslCerts = getCredentialParam('verifySslCerts', credentialData, nodeData) !== false + // Get all actions based on type const jiraType = nodeData.inputs?.jiraType as string let actions: string[] = [] @@ -407,7 +429,12 @@ class Jira_Tools implements INode { actions, username, accessToken, + bearerToken, + authType, jiraHost, + sslCertPath, + sslKeyPath, + verifySslCerts, defaultParams }) diff --git a/packages/components/nodes/tools/Jira/README.md b/packages/components/nodes/tools/Jira/README.md new file mode 100644 index 00000000000..2e3a18fdbfd --- /dev/null +++ b/packages/components/nodes/tools/Jira/README.md @@ -0,0 +1,249 @@ +# JIRA Tool + +The JIRA Tool allows you to interact with JIRA's REST API for managing issues, comments, and users directly from your Flowise workflows. + +## Features + +- **Issue Management**: Create, read, update, delete, assign, and transition issues +- **Comment Management**: Add, view, update, and delete comments on issues +- **User Management**: Search, view, and manage JIRA users +- **Flexible Authentication**: Supports both Basic Auth and Bearer Token authentication +- **SSL Certificate Support**: Connect to self-hosted JIRA instances with custom SSL certificates + +--- + +## Authentication Options + +### 1. Basic Authentication (Default) + +Use this for **Atlassian Cloud** (e.g., `yourcompany.atlassian.net`) or self-hosted instances that support Basic Auth. + +| Field | Description | +|-------|-------------| +| `Email` | Your JIRA account email address | +| `API Token` | API token generated from [Atlassian Account Settings](https://id.atlassian.com/manage-profile/security/api-tokens) | + +**Example Configuration:** +```json +{ + "baseUrl": "https://yourcompany.atlassian.net", + "email": "user@yourcompany.com", + "apiToken": "YOUR_API_TOKEN" +} +``` + +### 2. Bearer Token Authentication (Enterprise/Self-Hosted) + +Use this for **enterprise or self-hosted JIRA instances** that require Bearer token authentication (e.g., JIRA Data Center, JIRA Server with OAuth). + +| Field | Description | +|-------|-------------| +| `Bearer Token` | Personal Access Token (PAT) or OAuth access token | + +**Example Configuration:** +```json +{ + "baseUrl": "https://jira.mycompany.com", + "bearerToken": "YOUR_BEARER_TOKEN" +} +``` + +> **Note:** When `bearerToken` is provided and `authType` is set to `bearerToken`, Basic Auth credentials are ignored. + +--- + +## SSL Certificate Support + +For self-hosted JIRA instances using custom SSL certificates (self-signed or internal CA), you can configure SSL certificate paths. + +### Configuration Options + +| Field | Description | +|-------|-------------| +| `SSL Certificate Path` | Path to the SSL certificate file (`.pem`, `.crt`) | +| `SSL Key Path` | Path to the private key file (optional, for mutual TLS) | +| `Verify SSL Certificates` | Enable/disable SSL certificate verification (default: `true`) | + +### Usage Scenarios + +#### Scenario 1: Custom CA Certificate + +When your JIRA instance uses a certificate signed by an internal Certificate Authority: + +```json +{ + "baseUrl": "https://jira.mycompany.com", + "bearerToken": "YOUR_BEARER_TOKEN", + "sslCertPath": "/path/to/ca-cert.pem" +} +``` + +#### Scenario 2: Client Certificate (Mutual TLS) + +When your JIRA instance requires client certificate authentication: + +```json +{ + "baseUrl": "https://jira.mycompany.com", + "bearerToken": "YOUR_BEARER_TOKEN", + "sslCertPath": "/path/to/client-cert.pem", + "sslKeyPath": "/path/to/client-key.pem" +} +``` + +#### Scenario 3: Self-Signed Certificate (Development Only) + +⚠️ **Not recommended for production!** Disable SSL verification for testing purposes: + +```json +{ + "baseUrl": "https://jira.dev.local", + "bearerToken": "YOUR_BEARER_TOKEN", + "verifySslCerts": false +} +``` + +--- + +## Backward Compatibility + +### Existing Basic Auth Users + +**No changes required!** If you're currently using Basic Auth (email + API token), your configuration will continue to work without modification. + +| Scenario | Behavior | +|----------|----------| +| `bearerToken` not provided | Falls back to Basic Auth using `email` + `apiToken` | +| `authType` set to `basicAuth` | Uses Basic Auth regardless of other fields | +| `authType` set to `bearerToken` | Uses Bearer Token authentication | + +### Migration Guide + +To migrate from Basic Auth to Bearer Token: + +1. Generate a Personal Access Token (PAT) in your JIRA instance +2. Update your credential configuration: + - Set `authType` to `bearerToken` + - Add your `bearerToken` value +3. (Optional) Add SSL certificate paths if required +4. Test the connection before deploying + +--- + +## Error Handling + +### Common Errors + +| Error | Cause | Solution | +|-------|-------|----------| +| `401 Unauthorized` | Invalid credentials | Verify your API token or Bearer token is correct | +| `403 Forbidden` | Insufficient permissions | Check your JIRA user permissions | +| `Failed to load SSL certificate` | Certificate file not found | Verify the certificate path and file permissions | +| `CERT_HAS_EXPIRED` | SSL certificate expired | Renew your SSL certificate | +| `UNABLE_TO_VERIFY_LEAF_SIGNATURE` | Self-signed certificate | Add the CA cert to `sslCertPath` or set `verifySslCerts: false` | + +### SSL Certificate Troubleshooting + +1. **File Permissions**: Ensure the certificate files are readable by the Flowise process + ```bash + chmod 644 /path/to/cert.pem + chmod 600 /path/to/key.pem # Private key should be more restricted + ``` + +2. **Certificate Format**: Use PEM format for certificates + ```bash + # Convert from DER to PEM if needed + openssl x509 -inform DER -in cert.der -out cert.pem + ``` + +3. **Certificate Chain**: If using intermediate CAs, include the full chain in your certificate file + +--- + +## Testing + +### Pre-Deployment Checklist + +1. ✅ **Verify Connectivity**: Test that your JIRA instance is reachable from the Flowise server +2. ✅ **Validate Credentials**: Ensure your Bearer token or API token is valid and not expired +3. ✅ **Check SSL Configuration**: If using custom certificates, verify they are correctly configured +4. ✅ **Test Operations**: Run a simple read operation (e.g., `Get Issue`) before deploying + +### Regression Testing for Basic Auth + +If you've updated to the new version with Bearer Token support, verify that Basic Auth still works: + +```javascript +// Test Basic Auth configuration +const config = { + baseUrl: "https://yourcompany.atlassian.net", + authType: "basicAuth", + email: "user@yourcompany.com", + apiToken: "YOUR_API_TOKEN" +}; + +// Verify issue retrieval works +// GET /rest/api/3/issue/PROJ-123 +``` + +### Testing Bearer Token + +```javascript +// Test Bearer Token configuration +const config = { + baseUrl: "https://jira.mycompany.com", + authType: "bearerToken", + bearerToken: "YOUR_BEARER_TOKEN", + sslCertPath: "/path/to/cert.pem" +}; + +// Verify issue retrieval works +// GET /rest/api/3/issue/PROJ-123 +``` + +--- + +## Available Actions + +### Issue Actions +- `List Issues` - Query issues using JQL +- `Create Issue` - Create a new issue +- `Get Issue` - Retrieve issue details +- `Update Issue` - Modify issue fields +- `Delete Issue` - Remove an issue +- `Assign Issue` - Assign issue to a user +- `Transition Issue` - Change issue status + +### Comment Actions +- `List Comments` - Get all comments on an issue +- `Create Comment` - Add a comment to an issue +- `Get Comment` - Retrieve a specific comment +- `Update Comment` - Edit a comment +- `Delete Comment` - Remove a comment + +### User Actions +- `Search Users` - Find users by query +- `Get User` - Retrieve user details +- `Create User` - Create a new user + +--- + +## API Reference + +This tool uses the JIRA REST API v3. For detailed API documentation, see: +- [Atlassian JIRA REST API Documentation](https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/) +- [JIRA Server/Data Center REST API](https://docs.atlassian.com/software/jira/docs/api/REST/latest/) + +--- + +## Changelog + +### v2.0 (Current) +- ✨ Added Bearer Token authentication support +- ✨ Added SSL certificate configuration options +- ✨ Added `verifySslCerts` option for development environments +- 🔄 Backward compatible with existing Basic Auth configurations + +### v1.0 +- Initial release with Basic Auth support +- Issue, Comment, and User management operations diff --git a/packages/components/nodes/tools/Jira/core.ts b/packages/components/nodes/tools/Jira/core.ts index 07cb078c539..29528ff6b91 100644 --- a/packages/components/nodes/tools/Jira/core.ts +++ b/packages/components/nodes/tools/Jira/core.ts @@ -1,5 +1,7 @@ import { z } from 'zod' import fetch from 'node-fetch' +import * as fs from 'fs' +import * as https from 'https' import { DynamicStructuredTool } from '../OpenAPIToolkit/core' import { TOOL_ARGS_PREFIX, formatToolError } from '../../../src/agents' @@ -23,7 +25,12 @@ export interface RequestParameters { actions?: string[] username?: string accessToken?: string + bearerToken?: string + authType?: string jiraHost?: string + sslCertPath?: string + sslKeyPath?: string + verifySslCerts?: boolean defaultParams?: any } @@ -134,13 +141,74 @@ const DeleteUserSchema = z.object({ class BaseJiraTool extends DynamicStructuredTool { protected username: string = '' protected accessToken: string = '' + protected bearerToken: string = '' + protected authType: string = 'basicAuth' protected jiraHost: string = '' + protected sslCertPath?: string + protected sslKeyPath?: string + protected verifySslCerts: boolean = true constructor(args: any) { super(args) this.username = args.username ?? '' this.accessToken = args.accessToken ?? '' + this.bearerToken = args.bearerToken ?? '' + this.authType = args.authType ?? 'basicAuth' this.jiraHost = args.jiraHost ?? '' + this.sslCertPath = args.sslCertPath + this.sslKeyPath = args.sslKeyPath + this.verifySslCerts = args.verifySslCerts !== false + } + + private buildHeaders(): Record { + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json' + } + + // Add authentication header based on auth type + if (this.authType === 'basicAuth') { + const auth = Buffer.from(`${this.username}:${this.accessToken}`).toString('base64') + headers.Authorization = `Basic ${auth}` + } else if (this.authType === 'bearerToken') { + headers.Authorization = `Bearer ${this.bearerToken}` + } + + return headers + } + + private buildFetchOptions(): RequestInit { + const options: RequestInit = {} + + // Add SSL configuration if needed + if (this.jiraHost.startsWith('https://')) { + const httpsAgent: any = { + rejectUnauthorized: this.verifySslCerts + } + + // Load client certificate and key if provided + if (this.sslCertPath && this.sslKeyPath) { + try { + httpsAgent.cert = fs.readFileSync(this.sslCertPath, 'utf-8') + httpsAgent.key = fs.readFileSync(this.sslKeyPath, 'utf-8') + } catch (error) { + throw new Error(`Failed to load SSL certificate/key: ${error}`) + } + } + + // Load CA certificate if provided + if (this.sslCertPath && !this.sslKeyPath) { + try { + httpsAgent.ca = fs.readFileSync(this.sslCertPath, 'utf-8') + } catch (error) { + throw new Error(`Failed to load SSL certificate: ${error}`) + } + } + + options.agent = new https.Agent(httpsAgent) + } + + return options } async makeJiraRequest({ @@ -155,28 +223,30 @@ class BaseJiraTool extends DynamicStructuredTool { params?: any }): Promise { const url = `${this.jiraHost}/rest/api/3/${endpoint}` - const auth = Buffer.from(`${this.username}:${this.accessToken}`).toString('base64') + const headers = this.buildHeaders() + const fetchOptions = this.buildFetchOptions() - const headers = { - Authorization: `Basic ${auth}`, - 'Content-Type': 'application/json', - Accept: 'application/json', - ...this.headers - } + try { + const response = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + ...fetchOptions + }) - const response = await fetch(url, { - method, - headers, - body: body ? JSON.stringify(body) : undefined - }) + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Jira API Error ${response.status}: ${response.statusText} - ${errorText}`) + } - if (!response.ok) { - const errorText = await response.text() - throw new Error(`Jira API Error ${response.status}: ${response.statusText} - ${errorText}`) + const data = await response.text() + return data + TOOL_ARGS_PREFIX + JSON.stringify(params) + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to connect to Jira: ${error.message}`) + } + throw error } - - const data = await response.text() - return data + TOOL_ARGS_PREFIX + JSON.stringify(params) } } @@ -197,7 +267,12 @@ class ListIssuesTool extends BaseJiraTool { ...toolInput, username: args.username, accessToken: args.accessToken, + bearerToken: args.bearerToken, + authType: args.authType, jiraHost: args.jiraHost, + sslCertPath: args.sslCertPath, + sslKeyPath: args.sslKeyPath, + verifySslCerts: args.verifySslCerts, maxOutputLength: args.maxOutputLength }) this.defaultParams = args.defaultParams || {} @@ -243,7 +318,12 @@ class CreateIssueTool extends BaseJiraTool { ...toolInput, username: args.username, accessToken: args.accessToken, + bearerToken: args.bearerToken, + authType: args.authType, jiraHost: args.jiraHost, + sslCertPath: args.sslCertPath, + sslKeyPath: args.sslKeyPath, + verifySslCerts: args.verifySslCerts, maxOutputLength: args.maxOutputLength }) this.defaultParams = args.defaultParams || {} @@ -323,7 +403,12 @@ class GetIssueTool extends BaseJiraTool { ...toolInput, username: args.username, accessToken: args.accessToken, + bearerToken: args.bearerToken, + authType: args.authType, jiraHost: args.jiraHost, + sslCertPath: args.sslCertPath, + sslKeyPath: args.sslKeyPath, + verifySslCerts: args.verifySslCerts, maxOutputLength: args.maxOutputLength }) this.defaultParams = args.defaultParams || {} @@ -358,7 +443,12 @@ class UpdateIssueTool extends BaseJiraTool { ...toolInput, username: args.username, accessToken: args.accessToken, + bearerToken: args.bearerToken, + authType: args.authType, jiraHost: args.jiraHost, + sslCertPath: args.sslCertPath, + sslKeyPath: args.sslKeyPath, + verifySslCerts: args.verifySslCerts, maxOutputLength: args.maxOutputLength }) this.defaultParams = args.defaultParams || {} @@ -426,7 +516,12 @@ class DeleteIssueTool extends BaseJiraTool { ...toolInput, username: args.username, accessToken: args.accessToken, + bearerToken: args.bearerToken, + authType: args.authType, jiraHost: args.jiraHost, + sslCertPath: args.sslCertPath, + sslKeyPath: args.sslKeyPath, + verifySslCerts: args.verifySslCerts, maxOutputLength: args.maxOutputLength }) this.defaultParams = args.defaultParams || {} @@ -461,7 +556,12 @@ class AssignIssueTool extends BaseJiraTool { ...toolInput, username: args.username, accessToken: args.accessToken, + bearerToken: args.bearerToken, + authType: args.authType, jiraHost: args.jiraHost, + sslCertPath: args.sslCertPath, + sslKeyPath: args.sslKeyPath, + verifySslCerts: args.verifySslCerts, maxOutputLength: args.maxOutputLength }) this.defaultParams = args.defaultParams || {} @@ -500,7 +600,12 @@ class TransitionIssueTool extends BaseJiraTool { ...toolInput, username: args.username, accessToken: args.accessToken, + bearerToken: args.bearerToken, + authType: args.authType, jiraHost: args.jiraHost, + sslCertPath: args.sslCertPath, + sslKeyPath: args.sslKeyPath, + verifySslCerts: args.verifySslCerts, maxOutputLength: args.maxOutputLength }) this.defaultParams = args.defaultParams || {} @@ -542,7 +647,12 @@ class ListCommentsTool extends BaseJiraTool { ...toolInput, username: args.username, accessToken: args.accessToken, + bearerToken: args.bearerToken, + authType: args.authType, jiraHost: args.jiraHost, + sslCertPath: args.sslCertPath, + sslKeyPath: args.sslKeyPath, + verifySslCerts: args.verifySslCerts, maxOutputLength: args.maxOutputLength }) this.defaultParams = args.defaultParams || {} @@ -582,7 +692,12 @@ class CreateCommentTool extends BaseJiraTool { ...toolInput, username: args.username, accessToken: args.accessToken, + bearerToken: args.bearerToken, + authType: args.authType, jiraHost: args.jiraHost, + sslCertPath: args.sslCertPath, + sslKeyPath: args.sslKeyPath, + verifySslCerts: args.verifySslCerts, maxOutputLength: args.maxOutputLength }) this.defaultParams = args.defaultParams || {} @@ -639,7 +754,12 @@ class GetCommentTool extends BaseJiraTool { ...toolInput, username: args.username, accessToken: args.accessToken, + bearerToken: args.bearerToken, + authType: args.authType, jiraHost: args.jiraHost, + sslCertPath: args.sslCertPath, + sslKeyPath: args.sslKeyPath, + verifySslCerts: args.verifySslCerts, maxOutputLength: args.maxOutputLength }) this.defaultParams = args.defaultParams || {} @@ -674,7 +794,12 @@ class UpdateCommentTool extends BaseJiraTool { ...toolInput, username: args.username, accessToken: args.accessToken, + bearerToken: args.bearerToken, + authType: args.authType, jiraHost: args.jiraHost, + sslCertPath: args.sslCertPath, + sslKeyPath: args.sslKeyPath, + verifySslCerts: args.verifySslCerts, maxOutputLength: args.maxOutputLength }) this.defaultParams = args.defaultParams || {} @@ -727,7 +852,12 @@ class DeleteCommentTool extends BaseJiraTool { ...toolInput, username: args.username, accessToken: args.accessToken, + bearerToken: args.bearerToken, + authType: args.authType, jiraHost: args.jiraHost, + sslCertPath: args.sslCertPath, + sslKeyPath: args.sslKeyPath, + verifySslCerts: args.verifySslCerts, maxOutputLength: args.maxOutputLength }) this.defaultParams = args.defaultParams || {} @@ -763,7 +893,12 @@ class SearchUsersTool extends BaseJiraTool { ...toolInput, username: args.username, accessToken: args.accessToken, + bearerToken: args.bearerToken, + authType: args.authType, jiraHost: args.jiraHost, + sslCertPath: args.sslCertPath, + sslKeyPath: args.sslKeyPath, + verifySslCerts: args.verifySslCerts, maxOutputLength: args.maxOutputLength }) this.defaultParams = args.defaultParams || {} @@ -804,7 +939,12 @@ class GetUserTool extends BaseJiraTool { ...toolInput, username: args.username, accessToken: args.accessToken, + bearerToken: args.bearerToken, + authType: args.authType, jiraHost: args.jiraHost, + sslCertPath: args.sslCertPath, + sslKeyPath: args.sslKeyPath, + verifySslCerts: args.verifySslCerts, maxOutputLength: args.maxOutputLength }) this.defaultParams = args.defaultParams || {} @@ -843,7 +983,12 @@ class CreateUserTool extends BaseJiraTool { ...toolInput, username: args.username, accessToken: args.accessToken, + bearerToken: args.bearerToken, + authType: args.authType, jiraHost: args.jiraHost, + sslCertPath: args.sslCertPath, + sslKeyPath: args.sslKeyPath, + verifySslCerts: args.verifySslCerts, maxOutputLength: args.maxOutputLength }) this.defaultParams = args.defaultParams || {} @@ -887,7 +1032,12 @@ class UpdateUserTool extends BaseJiraTool { ...toolInput, username: args.username, accessToken: args.accessToken, + bearerToken: args.bearerToken, + authType: args.authType, jiraHost: args.jiraHost, + sslCertPath: args.sslCertPath, + sslKeyPath: args.sslKeyPath, + verifySslCerts: args.verifySslCerts, maxOutputLength: args.maxOutputLength }) this.defaultParams = args.defaultParams || {} @@ -930,7 +1080,12 @@ class DeleteUserTool extends BaseJiraTool { ...toolInput, username: args.username, accessToken: args.accessToken, + bearerToken: args.bearerToken, + authType: args.authType, jiraHost: args.jiraHost, + sslCertPath: args.sslCertPath, + sslKeyPath: args.sslKeyPath, + verifySslCerts: args.verifySslCerts, maxOutputLength: args.maxOutputLength }) this.defaultParams = args.defaultParams || {} @@ -957,7 +1112,12 @@ export const createJiraTools = (args?: RequestParameters): DynamicStructuredTool const actions = args?.actions || [] const username = args?.username || '' const accessToken = args?.accessToken || '' + const bearerToken = args?.bearerToken || '' + const authType = args?.authType || 'basicAuth' const jiraHost = args?.jiraHost || '' + const sslCertPath = args?.sslCertPath + const sslKeyPath = args?.sslKeyPath + const verifySslCerts = args?.verifySslCerts !== false const maxOutputLength = args?.maxOutputLength || Infinity const defaultParams = args?.defaultParams || {} @@ -967,7 +1127,12 @@ export const createJiraTools = (args?: RequestParameters): DynamicStructuredTool new ListIssuesTool({ username, accessToken, + bearerToken, + authType, jiraHost, + sslCertPath, + sslKeyPath, + verifySslCerts, maxOutputLength, defaultParams }) @@ -979,7 +1144,12 @@ export const createJiraTools = (args?: RequestParameters): DynamicStructuredTool new CreateIssueTool({ username, accessToken, + bearerToken, + authType, jiraHost, + sslCertPath, + sslKeyPath, + verifySslCerts, maxOutputLength, defaultParams }) @@ -991,7 +1161,12 @@ export const createJiraTools = (args?: RequestParameters): DynamicStructuredTool new GetIssueTool({ username, accessToken, + bearerToken, + authType, jiraHost, + sslCertPath, + sslKeyPath, + verifySslCerts, maxOutputLength, defaultParams }) @@ -1003,7 +1178,12 @@ export const createJiraTools = (args?: RequestParameters): DynamicStructuredTool new UpdateIssueTool({ username, accessToken, + bearerToken, + authType, jiraHost, + sslCertPath, + sslKeyPath, + verifySslCerts, maxOutputLength, defaultParams }) @@ -1015,7 +1195,12 @@ export const createJiraTools = (args?: RequestParameters): DynamicStructuredTool new DeleteIssueTool({ username, accessToken, + bearerToken, + authType, jiraHost, + sslCertPath, + sslKeyPath, + verifySslCerts, maxOutputLength, defaultParams }) @@ -1027,7 +1212,12 @@ export const createJiraTools = (args?: RequestParameters): DynamicStructuredTool new AssignIssueTool({ username, accessToken, + bearerToken, + authType, jiraHost, + sslCertPath, + sslKeyPath, + verifySslCerts, maxOutputLength, defaultParams }) @@ -1039,7 +1229,12 @@ export const createJiraTools = (args?: RequestParameters): DynamicStructuredTool new TransitionIssueTool({ username, accessToken, + bearerToken, + authType, jiraHost, + sslCertPath, + sslKeyPath, + verifySslCerts, maxOutputLength, defaultParams }) @@ -1052,7 +1247,12 @@ export const createJiraTools = (args?: RequestParameters): DynamicStructuredTool new ListCommentsTool({ username, accessToken, + bearerToken, + authType, jiraHost, + sslCertPath, + sslKeyPath, + verifySslCerts, maxOutputLength, defaultParams }) @@ -1064,7 +1264,12 @@ export const createJiraTools = (args?: RequestParameters): DynamicStructuredTool new CreateCommentTool({ username, accessToken, + bearerToken, + authType, jiraHost, + sslCertPath, + sslKeyPath, + verifySslCerts, maxOutputLength, defaultParams }) @@ -1076,7 +1281,12 @@ export const createJiraTools = (args?: RequestParameters): DynamicStructuredTool new GetCommentTool({ username, accessToken, + bearerToken, + authType, jiraHost, + sslCertPath, + sslKeyPath, + verifySslCerts, maxOutputLength, defaultParams }) @@ -1088,7 +1298,12 @@ export const createJiraTools = (args?: RequestParameters): DynamicStructuredTool new UpdateCommentTool({ username, accessToken, + bearerToken, + authType, jiraHost, + sslCertPath, + sslKeyPath, + verifySslCerts, maxOutputLength, defaultParams }) @@ -1100,7 +1315,12 @@ export const createJiraTools = (args?: RequestParameters): DynamicStructuredTool new DeleteCommentTool({ username, accessToken, + bearerToken, + authType, jiraHost, + sslCertPath, + sslKeyPath, + verifySslCerts, maxOutputLength, defaultParams }) @@ -1113,7 +1333,12 @@ export const createJiraTools = (args?: RequestParameters): DynamicStructuredTool new SearchUsersTool({ username, accessToken, + bearerToken, + authType, jiraHost, + sslCertPath, + sslKeyPath, + verifySslCerts, maxOutputLength, defaultParams }) @@ -1125,7 +1350,12 @@ export const createJiraTools = (args?: RequestParameters): DynamicStructuredTool new GetUserTool({ username, accessToken, + bearerToken, + authType, jiraHost, + sslCertPath, + sslKeyPath, + verifySslCerts, maxOutputLength, defaultParams }) @@ -1137,7 +1367,12 @@ export const createJiraTools = (args?: RequestParameters): DynamicStructuredTool new CreateUserTool({ username, accessToken, + bearerToken, + authType, jiraHost, + sslCertPath, + sslKeyPath, + verifySslCerts, maxOutputLength, defaultParams }) @@ -1149,7 +1384,12 @@ export const createJiraTools = (args?: RequestParameters): DynamicStructuredTool new UpdateUserTool({ username, accessToken, + bearerToken, + authType, jiraHost, + sslCertPath, + sslKeyPath, + verifySslCerts, maxOutputLength, defaultParams }) @@ -1161,7 +1401,12 @@ export const createJiraTools = (args?: RequestParameters): DynamicStructuredTool new DeleteUserTool({ username, accessToken, + bearerToken, + authType, jiraHost, + sslCertPath, + sslKeyPath, + verifySslCerts, maxOutputLength, defaultParams }) From 9326b560d2a63b2b6e77901c37f80a48ff9835fe Mon Sep 17 00:00:00 2001 From: VELLANKI SANTHOSH Date: Tue, 13 Jan 2026 16:07:29 +0000 Subject: [PATCH 2/2] fix: Address Gemini code review feedback - Fix test assertions: Change rejects.toThrow() to resolves.toContain() since tool _call methods catch exceptions and return error strings - Update sslCertPath description to be more general and accurate: 'Path to the SSL certificate file (e.g., custom CA, client certificate for mTLS)' - Refine error handling in core.ts: Don't re-wrap API errors, only wrap network/connection errors for better diagnostics --- .../credentials/JiraApi.credential.ts | 2 +- .../components/nodes/tools/Jira/Jira.test.ts | 29 ++++++++++++------- packages/components/nodes/tools/Jira/core.ts | 4 +++ 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/components/credentials/JiraApi.credential.ts b/packages/components/credentials/JiraApi.credential.ts index bfea2058acb..6eff6cd53cb 100644 --- a/packages/components/credentials/JiraApi.credential.ts +++ b/packages/components/credentials/JiraApi.credential.ts @@ -66,7 +66,7 @@ class JiraApi implements INodeCredential { name: 'sslCertPath', type: 'string', placeholder: '/path/to/cert.pem', - description: 'Path to SSL certificate file for self-signed certificates', + description: 'Path to the SSL certificate file (e.g., custom CA, client certificate for mTLS)', optional: true }, { diff --git a/packages/components/nodes/tools/Jira/Jira.test.ts b/packages/components/nodes/tools/Jira/Jira.test.ts index 25f0776e2ce..3b3fe444742 100644 --- a/packages/components/nodes/tools/Jira/Jira.test.ts +++ b/packages/components/nodes/tools/Jira/Jira.test.ts @@ -325,7 +325,7 @@ describe('JIRA Tool Authentication & SSL Tests', () => { }) describe('SSL Error Handling', () => { - it('should throw descriptive error when certificate file cannot be read', async () => { + it('should return descriptive error when certificate file cannot be read', async () => { mockReadFileSync.mockImplementation(() => { throw new Error('ENOENT: no such file or directory') }) @@ -341,7 +341,8 @@ describe('JIRA Tool Authentication & SSL Tests', () => { }) const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') - await expect(getIssueTool!.invoke({ issueKey: 'PROJ-123' })).rejects.toThrow('Failed to load SSL certificate') + await expect(getIssueTool!.invoke({ issueKey: 'PROJ-123' })) + .resolves.toContain('Failed to load SSL certificate') }) }) @@ -422,7 +423,8 @@ describe('JIRA Tool Authentication & SSL Tests', () => { }) const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') - await expect(getIssueTool!.invoke({ issueKey: 'PROJ-123' })).rejects.toThrow('Jira API Error 401') + await expect(getIssueTool!.invoke({ issueKey: 'PROJ-123' })) + .resolves.toContain('Jira API Error 401') }) it('should fail with 403 when Bearer token lacks permissions', async () => { @@ -442,7 +444,8 @@ describe('JIRA Tool Authentication & SSL Tests', () => { }) const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') - await expect(getIssueTool!.invoke({ issueKey: 'PROJ-123' })).rejects.toThrow('Jira API Error 403') + await expect(getIssueTool!.invoke({ issueKey: 'PROJ-123' })) + .resolves.toContain('Jira API Error 403') }) }) @@ -494,7 +497,8 @@ describe('JIRA Tool Authentication & SSL Tests', () => { }) const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') - await expect(getIssueTool!.invoke({ issueKey: 'PROJ-123' })).rejects.toThrow('CERT_HAS_EXPIRED') + await expect(getIssueTool!.invoke({ issueKey: 'PROJ-123' })) + .resolves.toContain('CERT_HAS_EXPIRED') }) it('should handle self-signed certificate with verifySslCerts disabled', async () => { @@ -804,7 +808,8 @@ describe('JIRA Tool Authentication & SSL Tests', () => { }) const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') - await expect(getIssueTool!.invoke({ issueKey: 'PROJ-123' })).rejects.toThrow('Failed to connect to Jira') + await expect(getIssueTool!.invoke({ issueKey: 'PROJ-123' })) + .resolves.toContain('Failed to connect to Jira') }) it('should handle timeout errors', async () => { @@ -819,7 +824,8 @@ describe('JIRA Tool Authentication & SSL Tests', () => { }) const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') - await expect(getIssueTool!.invoke({ issueKey: 'PROJ-123' })).rejects.toThrow('ETIMEDOUT') + await expect(getIssueTool!.invoke({ issueKey: 'PROJ-123' })) + .resolves.toContain('ETIMEDOUT') }) it('should handle DNS resolution errors', async () => { @@ -834,7 +840,8 @@ describe('JIRA Tool Authentication & SSL Tests', () => { }) const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') - await expect(getIssueTool!.invoke({ issueKey: 'PROJ-123' })).rejects.toThrow('ENOTFOUND') + await expect(getIssueTool!.invoke({ issueKey: 'PROJ-123' })) + .resolves.toContain('ENOTFOUND') }) it('should handle rate limiting (429) errors', async () => { @@ -854,7 +861,8 @@ describe('JIRA Tool Authentication & SSL Tests', () => { }) const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') - await expect(getIssueTool!.invoke({ issueKey: 'PROJ-123' })).rejects.toThrow('Jira API Error 429') + await expect(getIssueTool!.invoke({ issueKey: 'PROJ-123' })) + .resolves.toContain('Jira API Error 429') }) it('should handle server errors (500)', async () => { @@ -874,7 +882,8 @@ describe('JIRA Tool Authentication & SSL Tests', () => { }) const getIssueTool = tools.find((t) => t.name === 'jira_get_issue') - await expect(getIssueTool!.invoke({ issueKey: 'PROJ-123' })).rejects.toThrow('Jira API Error 500') + await expect(getIssueTool!.invoke({ issueKey: 'PROJ-123' })) + .resolves.toContain('Jira API Error 500') }) }) }) diff --git a/packages/components/nodes/tools/Jira/core.ts b/packages/components/nodes/tools/Jira/core.ts index 29528ff6b91..b663757102f 100644 --- a/packages/components/nodes/tools/Jira/core.ts +++ b/packages/components/nodes/tools/Jira/core.ts @@ -243,6 +243,10 @@ class BaseJiraTool extends DynamicStructuredTool { return data + TOOL_ARGS_PREFIX + JSON.stringify(params) } catch (error) { if (error instanceof Error) { + // Don't re-wrap API errors, only wrap network/connection errors + if (error.message.startsWith('Jira API Error')) { + throw error + } throw new Error(`Failed to connect to Jira: ${error.message}`) } throw error