Repository: fundakol/pytest-jira-xray (language: python)

Issue:
add suppport for Xray on Jira Cloud
As many teams use Xray Cloud (i.e. Xray for Jira Cloud), supporting it would increase the range users benefiting from this library.
I can try to make a PR for it, whenever possible.


Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index 405fa02..5fb0b39 100644
--- a/README.md
+++ b/README.md
@@ -30,34 +30,54 @@ def test_one():
     assert True
 ```
 
-Set system environments (Basic authentication):
-```shell
-export XRAY_API_BASE_URL=<jira URL>
-export XRAY_API_USER=<jria username>
-export XRAY_API_PASSWORD=<user password>
+### Jira Xray configuration can be provided via Environment Variables:
+
+* Jira base URL:
+```commandline
+$ export XRAY_API_BASE_URL=<Jira base URL>
+```
+- Basic authentication:
+```commandline
+$ export XRAY_API_USER=<jria username>
+$ export XRAY_API_PASSWORD=<user password>
 ```
 
+- SSL Client Certificate
+
 To disable SSL certificate verification, at the client side (no case-sensitive): 
-```shell
-export XRAY_API_VERIFY_SSL=False
+```commandline
+$ export XRAY_API_VERIFY_SSL=False
 ```
 
 Or you can provide path to certificate file
-```shell
-export XRAY_API_VERIFY_SSL=</path/to/PEM file>
+```commandline
+$ export XRAY_API_VERIFY_SSL=</path/to/PEM file>
+```
+
+* Cloud authentication:
+```commandline
+$ export XRAY_CLIENT_ID=<client id>
+$ export XRAY_CLIENT_SECRET=<client secret>
+```
+
+### Upload results 
+
+* Upload results to new test execution:
+```commandline
+$ pytest --jira-xray
 ```
 
-Upload results to new test execution:
+* Upload results to existing test execution:
 ```commandline
-pytest . --jira-xray
+$ pytest --jira-xray --execution TestExecutionId
 ```
 
-Upload results to existing test execution:
+* Upload results to existing test plan (new test execution will be created):
 ```commandline
-pytest . --jira-xray --execution TestExecutionId
+$ pytest --jira-xray --testplan TestPlanId
 ```
 
-Upload results to existing test plan (new test execution will be created):
+* Use with Jira cloud:
 ```commandline
-pytest . --jira-xray --testplan TestPlanId
+$ pytest --jira-xray --cloud
 ```


Test patch:
diff --git a/src/pytest_xray/constant.py b/src/pytest_xray/constant.py
index 51a1199..5626ad6 100644
--- a/src/pytest_xray/constant.py
+++ b/src/pytest_xray/constant.py
@@ -5,3 +5,4 @@
 JIRA_XRAY_FLAG = "--jira-xray"
 XRAY_TEST_PLAN_ID = '--testplan'
 XRAY_EXECUTION_ID = '--execution'
+JIRA_CLOUD = '--cloud'
diff --git a/src/pytest_xray/helper.py b/src/pytest_xray/helper.py
index 111afe0..bcd379c 100644
--- a/src/pytest_xray/helper.py
+++ b/src/pytest_xray/helper.py
@@ -1,6 +1,6 @@
 import datetime as dt
 import enum
-from typing import List, Dict, Union, Any
+from typing import List, Dict, Union, Any, Type
 
 from pytest_xray.constant import XRAY_MARKER_NAME, DATETIME_FORMAT
 
@@ -8,6 +8,7 @@
 
 
 class Status(str, enum.Enum):
+    """Mapping status to string accepted by Jira DC server"""
     TODO = 'TODO'
     EXECUTING = 'EXECUTING'
     PENDING = 'PENDING'
@@ -17,21 +18,42 @@ class Status(str, enum.Enum):
     BLOCKED = 'BLOCKED'
 
 
+class CloudStatus(str, enum.Enum):
+    """Mapping status to string accepted by Jira cloud"""
+    TODO = 'TODO'
+    EXECUTING = 'EXECUTING'
+    PENDING = 'PENDING'
+    PASS = 'PASSED'
+    FAIL = 'FAILED'
+    ABORTED = 'ABORTED'
+    BLOCKED = 'BLOCKED'
+
+
+class StatusBuilder:
+    """Class helps to get proper status for Jira Server/DC"""
+
+    def __init__(self, status_enum: Type[enum.Enum]):
+        self.status = status_enum
+
+    def __call__(self, status: str) -> enum.Enum:
+        return self.status(getattr(self.status, status))
+
+
 class TestCase:
 
     def __init__(self,
                  test_key: str,
-                 status: str,
+                 status: Union[enum.Enum, str],
                  comment: str = None,
                  duration: float = 0.0):
         self.test_key = test_key
-        self.status = Status(status)
+        self.status = status
         self.comment = comment or ''
         self.duration = duration
 
     def as_dict(self) -> Dict[str, str]:
         return dict(testKey=self.test_key,
-                    status=self.status,
+                    status=str(self.status),
                     comment=self.comment)
 
 
diff --git a/src/pytest_xray/plugin.py b/src/pytest_xray/plugin.py
index 6023fbc..a97f296 100644
--- a/src/pytest_xray/plugin.py
+++ b/src/pytest_xray/plugin.py
@@ -2,6 +2,7 @@
 from os import environ
 from typing import List, Dict, Any
 
+import pytest
 from _pytest.config import Config
 from _pytest.config.argparsing import Parser
 from _pytest.nodes import Item
@@ -11,22 +12,28 @@
     JIRA_XRAY_FLAG,
     XRAY_PLUGIN,
     XRAY_TEST_PLAN_ID,
-    XRAY_EXECUTION_ID
+    XRAY_EXECUTION_ID,
+    JIRA_CLOUD
 )
 from pytest_xray.helper import (
     associate_marker_metadata_for,
     get_test_key_for,
     Status,
     TestCase,
-    TestExecution
+    TestExecution,
+    StatusBuilder,
+    CloudStatus
 )
-from pytest_xray.xray_publisher import XrayPublisher
+from pytest_xray.xray_publisher import XrayPublisher, BearerAuth
 
 
 def get_request_options() -> Dict[str, Any]:
     options = {}
     jira_url = environ['XRAY_API_BASE_URL']
-    user, password = environ['XRAY_API_USER'], environ['XRAY_API_PASSWORD']
+    user = environ.get('XRAY_API_USER', '')
+    password = environ.get('XRAY_API_PASSWORD', '')
+    client_id = environ.get('XRAY_CLIENT_ID', '')
+    client_secret = environ.get('XRAY_CLIENT_SECRET', '')
     verify = os.environ.get('XRAY_API_VERIFY_SSL', 'True')
     if verify.upper() == 'TRUE':
         verify = True
@@ -35,10 +42,13 @@ def get_request_options() -> Dict[str, Any]:
     else:
         if not os.path.exists(verify):
             raise FileNotFoundError(f'Cannot find certificate file "{verify}"')
+
     options['BASE_URL'] = jira_url
     options['USER'] = user
     options['PASSWORD'] = password
     options['VERIFY'] = verify
+    options['CLIENT_ID'] = client_id
+    options['CLIENT_SECRET'] = client_secret
 
     return options
 
@@ -48,12 +58,22 @@ def pytest_configure(config: Config) -> None:
         return
 
     options = get_request_options()
-    plugin = XrayPublisher(base_url=options['BASE_URL'],
-                           auth=(options['USER'], options['PASSWORD']),
-                           verify=options['VERIFY'])
+    if config.getoption(JIRA_CLOUD):
+        auth = BearerAuth(
+            options['BASE_URL'],
+            options['CLIENT_ID'],
+            options['CLIENT_SECRET']
+        )
+    else:
+        auth = (options['USER'], options['PASSWORD'])
 
-    config.pluginmanager.register(plugin, XRAY_PLUGIN)
+    plugin = XrayPublisher(
+        base_url=options['BASE_URL'],
+        auth=auth,
+        verify=options['VERIFY']
+    )
 
+    config.pluginmanager.register(plugin, XRAY_PLUGIN)
     config.addinivalue_line(
         'markers', 'xray(JIRA_ID): mark test with JIRA XRAY test case ID'
     )
@@ -65,6 +85,10 @@ def pytest_addoption(parser: Parser):
                    action='store_true',
                    default=False,
                    help='Upload test results to JIRA XRAY')
+    xray.addoption(JIRA_CLOUD,
+                   action='store_true',
+                   default=False,
+                   help='Upload test results to JIRA XRAY could server')
     xray.addoption(XRAY_EXECUTION_ID,
                    action='store',
                    default=None,
@@ -86,30 +110,39 @@ def pytest_collection_modifyitems(config: Config, items: List[Item]) -> None:
 def pytest_terminal_summary(terminalreporter: TerminalReporter) -> None:
     if not terminalreporter.config.getoption(JIRA_XRAY_FLAG):
         return
+
     test_execution_id = terminalreporter.config.getoption(XRAY_EXECUTION_ID)
     test_plan_id = terminalreporter.config.getoption(XRAY_TEST_PLAN_ID)
+    is_cloud_server = terminalreporter.config.getoption(JIRA_CLOUD)
 
-    test_execution = TestExecution(test_execution_key=test_execution_id,
-                                   test_plan_key=test_plan_id)
+    if is_cloud_server:
+        status_builder = StatusBuilder(CloudStatus)
+    else:
+        status_builder = StatusBuilder(Status)
+
+    test_execution = TestExecution(
+        test_execution_key=test_execution_id,
+        test_plan_key=test_plan_id
+    )
 
     if 'passed' in terminalreporter.stats:
         for each in terminalreporter.stats['passed']:
             test_key = get_test_key_for(each)
             if test_key:
-                tc = TestCase(test_key, Status.PASS)
+                tc = TestCase(test_key, status_builder('PASS'))
                 test_execution.append(tc)
 
     if 'failed' in terminalreporter.stats:
         for each in terminalreporter.stats['failed']:
             test_key = get_test_key_for(each)
             if test_key:
-                tc = TestCase(test_key, Status.FAIL, each.longreprtext)
+                tc = TestCase(test_key, status_builder('FAIL'), each.longreprtext)
                 test_execution.append(tc)
     if 'skipped' in terminalreporter.stats:
         for each in terminalreporter.stats['skipped']:
             test_key = get_test_key_for(each)
             if test_key:
-                tc = TestCase(test_key, Status.ABORTED, each.longreprtext)
+                tc = TestCase(test_key, status_builder('ABORTED'), each.longreprtext)
                 test_execution.append(tc)
 
     publish_results = terminalreporter.config.pluginmanager.get_plugin(XRAY_PLUGIN)
diff --git a/src/pytest_xray/xray_publisher.py b/src/pytest_xray/xray_publisher.py
index 4287baa..9755996 100644
--- a/src/pytest_xray/xray_publisher.py
+++ b/src/pytest_xray/xray_publisher.py
@@ -1,3 +1,4 @@
+import json
 import logging
 from typing import Union
 
@@ -7,7 +8,6 @@
 from pytest_xray.constant import TEST_EXECUTION_ENDPOINT
 from pytest_xray.helper import TestExecution
 
-logging.basicConfig()
 _logger = logging.getLogger(__name__)
 
 
@@ -15,6 +15,38 @@ class XrayError(Exception):
     """Custom exception for Jira XRAY"""
 
 
+class BearerAuth(AuthBase):
+
+    def __init__(self, base_url: str, client_id: str, client_secret: str) -> None:
+        self.base_url = base_url
+        self.client_id = client_id
+        self.client_secret = client_secret
+
+    def __call__(self, r):
+        headers = {
+            'Content-type': 'application/json',
+            'Accept': 'text/plain'
+        }
+        auth_data = {
+            'client_id': self.client_id,
+            'client_secret': self.client_secret
+        }
+
+        try:
+            response = requests.post(
+                f'{self.base_url}/api/v2/authenticate',
+                data=json.dumps(auth_data),
+                headers=headers
+            )
+        except requests.exceptions.ConnectionError as exc:
+            _logger.exception(exc)
+            raise XrayError(f'Connection error for "{self.base_url}/api/v2/authenticate"') from exc
+        else:
+            auth_token = response.text
+            r.headers['Authorization'] = f'Bearer {auth_token}'
+        return r
+
+
 class XrayPublisher:
 
     def __init__(self,
diff --git a/tests/conftest.py b/tests/conftest.py
index 77db25f..45a65dc 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -9,6 +9,9 @@ def http_server():
     server.add_json_response('/rest/raven/2.0/import/execution',
                              {'testExecIssue': {'key': '1000'}},
                              methods=('POST',))
+    server.add_callback_response('/api/v2/authenticate',
+                                 lambda: 'token',
+                                 methods=('POST',))
     server.start()
     yield
     server.shutdown_server()
diff --git a/tests/test_xray.py b/tests/test_xray.py
index e801e47..387373d 100644
--- a/tests/test_xray.py
+++ b/tests/test_xray.py
@@ -1,29 +1,43 @@
 import re
-
-pytest_plugins = 'pytester'
-
-
-test_example_1 = """
-import pytest 
-
-@pytest.mark.xray('JIRA-1')
-def test_pass():
-    assert True, 'Not passed'
-
-@pytest.mark.xray('JIRA-2')
-def test_fail():
-    assert False, 'Not passed'
-
-@pytest.mark.skip('Skipped')
-@pytest.mark.xray('JIRA-5')
-def test_skip():
-    assert True, 'Not passed'
-"""
-
-
-def test_jira_xray_plugin(testdir):
-    testdir.makepyfile(test_example_1)
-    result = testdir.runpytest('--jira-xray')
+import textwrap
+
+import pytest
+
+pytest_plugins = ['pytester']
+
+
+@pytest.fixture(autouse=True)
+def xray_tests(testdir):
+    test_example = textwrap.dedent(
+        """\
+        import pytest 
+        
+        @pytest.mark.xray('JIRA-1')
+        def test_pass():
+            assert True, 'Not passed'
+        
+        @pytest.mark.xray('JIRA-2')
+        def test_fail():
+            assert False, 'Not passed'
+        
+        @pytest.mark.skip('Skipped')
+        @pytest.mark.xray('JIRA-5')
+        def test_skip():
+            assert True, 'Not passed'
+        """)
+    testdir.makepyfile(test_example)
+
+
+@pytest.mark.parametrize(
+    'cli_options',
+    [
+        ('--jira-xray',),
+        ('--jira-xray', '--cloud',)
+    ],
+    ids=['DC Server', 'Cloud']
+)
+def test_jira_xray_plugin(testdir, cli_options):
+    result = testdir.runpytest(*cli_options)
     result.assert_outcomes(passed=1, failed=1, skipped=1)
     assert len(result.errlines) == 0
     assert re.search('Uploaded results to JIRA XRAY', '\n'.join(result.outlines))