diff --git a/qase-pytest/changelog.md b/qase-pytest/changelog.md index 1e8bc1d5..4f9d926b 100644 --- a/qase-pytest/changelog.md +++ b/qase-pytest/changelog.md @@ -1,3 +1,9 @@ +# qase-pytest 8.3.1 + +## What's fixed + +- Fixed `testops_multi` mode under pytest-xdist. The controller now serialises the run id map as JSON in the lock file and workers reconstruct the dict, dispatching it through `QaseCoreReporter.set_run_id()` so every project's run id is seeded in each worker. Previously workers crashed with `QaseTestOpsMulti.set_run_id() missing 1 required positional argument: 'run_id'` and all results were dropped with `No run_id for project ..., skipping send`. + # qase-pytest 8.3.0 ## What's new diff --git a/qase-pytest/pyproject.toml b/qase-pytest/pyproject.toml index fa2e9842..5621df0f 100644 --- a/qase-pytest/pyproject.toml +++ b/qase-pytest/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "qase-pytest" -version = "8.3.0" +version = "8.3.1" description = "Qase Pytest Plugin for Qase TestOps and Qase Report" readme = "README.md" keywords = ["qase", "pytest", "plugin", "testops", "report", "qase reporting", "test observability"] diff --git a/qase-pytest/src/qase/pytest/plugin.py b/qase-pytest/src/qase/pytest/plugin.py index c0c25d33..eb12e743 100644 --- a/qase-pytest/src/qase/pytest/plugin.py +++ b/qase-pytest/src/qase/pytest/plugin.py @@ -1,3 +1,4 @@ +import json import os import re from typing import Tuple, Union, List @@ -113,7 +114,9 @@ def pytest_sessionstart(self, session): with FileLock("qase.lock"): if self.run_id: with open(self.meta_run_file, "w") as lock_file: - lock_file.write(str(self.run_id)) + # JSON keeps the dict shape used by testops_multi mode + # intact across the xdist controller -> worker boundary. + lock_file.write(json.dumps(self.run_id)) else: self.load_run_from_lock() @@ -351,11 +354,19 @@ def add_param(self, name: str, value: str): def load_run_from_lock(self): if os.path.exists(QasePytestPlugin.meta_run_file): with open(QasePytestPlugin.meta_run_file, "r") as lock_file: - try: - self.run_id = str(lock_file.read()) - self.reporter.set_run_id(self.run_id) - except ValueError: - pass + raw = lock_file.read() + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + parsed = raw + if isinstance(parsed, dict): + self.run_id = parsed + else: + self.run_id = str(parsed) + try: + self.reporter.set_run_id(self.run_id) + except ValueError: + pass else: self.run_id = self.reporter.start_run() diff --git a/qase-pytest/tests/tests_qase_pytest/test_plugin.py b/qase-pytest/tests/tests_qase_pytest/test_plugin.py index a9ebe91c..9dd338cf 100644 --- a/qase-pytest/tests/tests_qase_pytest/test_plugin.py +++ b/qase-pytest/tests/tests_qase_pytest/test_plugin.py @@ -6,6 +6,9 @@ singleton lifecycle. """ +import json +import os + import pytest from unittest.mock import MagicMock, PropertyMock, patch, call @@ -345,6 +348,73 @@ def test_logs_not_attached_when_capture_logs_disabled(self): mock_attach.assert_not_called() +class TestXdistRunIdRoundtrip: + """xdist controller -> worker handoff of run_id via lock file. + + Regression for testops_multi + xdist where the worker crashed with + ``set_run_id() missing 1 required positional argument`` because the + controller wrote ``str(dict)`` into the lock file. + """ + + @pytest.fixture + def isolated_lock_file(self, tmp_path, monkeypatch): + """Redirect meta_run_file to a tmp path so tests don't touch repo CWD.""" + lock_path = tmp_path / "src.run" + monkeypatch.setattr(QasePytestPlugin, "meta_run_file", str(lock_path)) + monkeypatch.chdir(tmp_path) # FileLock writes qase.lock in cwd + return lock_path + + def test_controller_writes_dict_run_id_as_json(self, isolated_lock_file): + plugin = make_plugin() + plugin.reporter.start_run.return_value = {"DW": 1553, "PROJ2": 42} + + with patch("qase.pytest.plugin.is_xdist_controller", return_value=True): + plugin.pytest_sessionstart(session=MagicMock()) + + contents = isolated_lock_file.read_text() + assert json.loads(contents) == {"DW": 1553, "PROJ2": 42} + + def test_controller_writes_scalar_run_id_as_json(self, isolated_lock_file): + """Single-project mode still produces a parseable scalar lock file.""" + plugin = make_plugin() + plugin.reporter.start_run.return_value = 1553 + + with patch("qase.pytest.plugin.is_xdist_controller", return_value=True): + plugin.pytest_sessionstart(session=MagicMock()) + + assert json.loads(isolated_lock_file.read_text()) == 1553 + + def test_worker_loads_dict_run_id_and_seeds_reporter(self, isolated_lock_file): + """Worker reads dict lock file and forwards the dict to the reporter.""" + isolated_lock_file.write_text(json.dumps({"DW": 1553, "PROJ2": 42})) + plugin = make_plugin() + + plugin.load_run_from_lock() + + assert plugin.run_id == {"DW": 1553, "PROJ2": 42} + plugin.reporter.set_run_id.assert_called_once_with({"DW": 1553, "PROJ2": 42}) + + def test_worker_loads_scalar_run_id_as_string(self, isolated_lock_file): + """Single-project workers receive the run_id as a string (legacy contract).""" + isolated_lock_file.write_text(json.dumps(1553)) + plugin = make_plugin() + + plugin.load_run_from_lock() + + assert plugin.run_id == "1553" + plugin.reporter.set_run_id.assert_called_once_with("1553") + + def test_worker_tolerates_legacy_non_json_lock_file(self, isolated_lock_file): + """A pre-existing plain numeric lock file (legacy format) still loads.""" + isolated_lock_file.write_text("1553") + plugin = make_plugin() + + plugin.load_run_from_lock() + + assert plugin.run_id == "1553" + plugin.reporter.set_run_id.assert_called_once_with("1553") + + class TestSetTags: """Test _set_tags instance method.""" diff --git a/qase-python-commons/changelog.md b/qase-python-commons/changelog.md index 9a3b4247..2f1a8903 100644 --- a/qase-python-commons/changelog.md +++ b/qase-python-commons/changelog.md @@ -1,3 +1,10 @@ +# qase-python-commons@5.1.2 + +## What's new + +- Added `QaseTestOpsMulti.set_run_ids()` to seed run ids for several projects in one call, and routed `QaseCoreReporter.set_run_id()` to use it when invoked with a dict. Required for xdist workers in `testops_multi` mode, where the controller produces a `{project_code: run_id}` mapping that workers must replay locally. +- Masked the API token in the debug config dump. `QaseCoreReporter` now logs the config through `sanitize_config_for_log`, which keeps the first three and last four characters of `testops.api.token` and replaces the middle with `****`. Tokens of 7 chars or fewer are fully replaced. Mirrors the `token-masker` contract used in `qase-javascript-commons`. + # qase-python-commons@5.1.1 ## What's new diff --git a/qase-python-commons/pyproject.toml b/qase-python-commons/pyproject.toml index c0968138..f0cdf4cd 100644 --- a/qase-python-commons/pyproject.toml +++ b/qase-python-commons/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "qase-python-commons" -version = "5.1.1" +version = "5.1.2" description = "A library for Qase TestOps and Qase Report" readme = "README.md" authors = [{name = "Qase Team", email = "support@qase.io"}] diff --git a/qase-python-commons/src/qase/commons/reporters/core.py b/qase-python-commons/src/qase/commons/reporters/core.py index 96b73df1..8531892a 100644 --- a/qase-python-commons/src/qase/commons/reporters/core.py +++ b/qase-python-commons/src/qase/commons/reporters/core.py @@ -12,6 +12,7 @@ from typing import Union, List, Dict from ..util import get_host_info +from ..util.token_masker import sanitize_config_for_log from ..status_mapping.status_mapping import StatusMapping from ..exceptions.reporter import ReporterException @@ -68,7 +69,7 @@ def __init__(self, config: ConfigManager, framework: Union[str, None] = None, self.fallback = self._fallback_setup() - self.logger.log_debug(f"Config: {self.config}") + self.logger.log_debug(f"Config: {sanitize_config_for_log(self.config)}") host_data = get_host_info(framework, reporter_name) self.logger.log_debug(f"Host data: {host_data}") @@ -260,10 +261,15 @@ def disable_profilers(self) -> None: for profiler in self.profilers: profiler.disable() - def set_run_id(self, run_id: str) -> None: + def set_run_id(self, run_id) -> None: if self.reporter: try: - self.reporter.set_run_id(run_id) + # Multi-project mode: run_id is a dict of project -> run_id. + # Dispatch to set_run_ids so each project gets seeded. + if isinstance(run_id, dict) and hasattr(self.reporter, 'set_run_ids'): + self.reporter.set_run_ids(run_id) + else: + self.reporter.set_run_id(run_id) except Exception as e: # Log error and run fallback self.logger.log('Failed to set run id', 'info') diff --git a/qase-python-commons/src/qase/commons/reporters/testops_multi.py b/qase-python-commons/src/qase/commons/reporters/testops_multi.py index bcb5c9bf..8138e7dd 100644 --- a/qase-python-commons/src/qase/commons/reporters/testops_multi.py +++ b/qase-python-commons/src/qase/commons/reporters/testops_multi.py @@ -166,6 +166,18 @@ def set_run_id(self, project_code: str, run_id: Union[str, int]) -> None: else: self.logger.log(f"Unknown project code: {project_code}", "warning") + def set_run_ids(self, run_ids: Dict[str, Union[str, int]]) -> None: + """Set run_ids for multiple projects at once. + + Used by xdist workers to seed run ids produced by the controller. + Unknown project codes are skipped with a warning but do not raise. + """ + for project_code, run_id in run_ids.items(): + if project_code in self.project_configs: + self.project_runs[project_code] = int(run_id) if isinstance(run_id, str) else run_id + else: + self.logger.log(f"Unknown project code: {project_code}", "warning") + def start_run(self) -> Dict[str, int]: """ Create or verify test runs for all projects. diff --git a/qase-python-commons/src/qase/commons/util/token_masker.py b/qase-python-commons/src/qase/commons/util/token_masker.py new file mode 100644 index 00000000..0b681433 --- /dev/null +++ b/qase-python-commons/src/qase/commons/util/token_masker.py @@ -0,0 +1,46 @@ +"""Token masking utilities for safe config logging. + +Mirrors the qase-javascript-commons `token-masker.ts` contract so leaked +debug logs cannot expose customer API tokens. The only secret we currently +need to mask sits at ``config.testops.api.token``. +""" + +import json +from typing import Any + + +def mask_token(token: str) -> str: + """Return a masked representation of an API token. + + Tokens of 7 chars or fewer are fully replaced with asterisks. + Longer tokens keep the first 3 and last 4 characters: ``abc****wxyz``. + """ + if not isinstance(token, str): + return token + if len(token) <= 7: + return "*" * len(token) + return f"{token[:3]}****{token[-4:]}" + + +def sanitize_config_for_log(config: Any) -> str: + """Return a JSON string of ``config`` with the API token masked. + + Accepts any object with a JSON-serialisable ``__str__`` (e.g. ``BaseModel``) + or a dict. Falls back to ``str(config)`` if the serialised form cannot be + parsed as JSON, so callers never lose log output to a masking bug. + """ + try: + raw = config if isinstance(config, str) else str(config) + parsed = json.loads(raw) + except (TypeError, ValueError): + return str(config) + + token = ( + parsed.get("testops", {}).get("api", {}).get("token") + if isinstance(parsed, dict) + else None + ) + if token: + parsed["testops"]["api"]["token"] = mask_token(token) + + return json.dumps(parsed, indent=4, sort_keys=True) diff --git a/qase-python-commons/tests/tests_qase_commons/test_core_reporter.py b/qase-python-commons/tests/tests_qase_commons/test_core_reporter.py index b13f6e1b..0eba01bf 100644 --- a/qase-python-commons/tests/tests_qase_commons/test_core_reporter.py +++ b/qase-python-commons/tests/tests_qase_commons/test_core_reporter.py @@ -161,6 +161,50 @@ def test_fallback_on_testops_multi_exception( assert reporter.reporter is reporter.fallback +class TestCoreReporterSetRunId: + """set_run_id dispatch between single and multi reporters.""" + + def _make_reporter_with_inner(self, inner): + """Build a QaseCoreReporter bypassing __init__ to attach any inner reporter.""" + reporter = QaseCoreReporter.__new__(QaseCoreReporter) + reporter.reporter = inner + reporter.fallback = None + reporter.logger = Mock() + reporter.overhead = 0.0 + return reporter + + def test_dict_run_id_routes_to_set_run_ids(self): + """A dict run_id from xdist controller must be applied via set_run_ids.""" + inner = Mock(spec=["set_run_ids", "set_run_id"]) + reporter = self._make_reporter_with_inner(inner) + + reporter.set_run_id({"PROJ1": 111, "PROJ2": 222}) + + inner.set_run_ids.assert_called_once_with({"PROJ1": 111, "PROJ2": 222}) + inner.set_run_id.assert_not_called() + + def test_scalar_run_id_routes_to_set_run_id(self): + """A scalar run_id keeps the single-project code path.""" + inner = Mock(spec=["set_run_id"]) + reporter = self._make_reporter_with_inner(inner) + + reporter.set_run_id("1553") + + inner.set_run_id.assert_called_once_with("1553") + + def test_dict_run_id_falls_back_when_inner_has_no_set_run_ids(self): + """If inner reporter lacks set_run_ids, the call must not raise — caller never crashes.""" + inner = Mock(spec=["set_run_id"]) # no set_run_ids attribute + inner.set_run_id.side_effect = TypeError("missing arg") + reporter = self._make_reporter_with_inner(inner) + + # Should swallow the TypeError just like any other exception + reporter.set_run_id({"PROJ1": 111}) + + inner.set_run_id.assert_called_once() + reporter.logger.log.assert_any_call("Failed to set run id", "info") + + class TestCoreReporterErrorHandling: """TEST-03: Error classification helpers and diagnostic logging.""" diff --git a/qase-python-commons/tests/tests_qase_commons/test_testops_multi.py b/qase-python-commons/tests/tests_qase_commons/test_testops_multi.py index 986a3cba..1a7f917f 100644 --- a/qase-python-commons/tests/tests_qase_commons/test_testops_multi.py +++ b/qase-python-commons/tests/tests_qase_commons/test_testops_multi.py @@ -152,16 +152,57 @@ def test_set_run_id_unknown_project(self): mock_config = self._create_mock_config() mock_logger = Mock() mock_client = self._create_mock_client() - + reporter = QaseTestOpsMulti(mock_config, mock_logger, mock_client) reporter.set_run_id("UNKNOWN_PROJ", "123") - + # Verify warning was logged mock_logger.log.assert_called_once() call_args = mock_logger.log.call_args assert "Unknown project code" in call_args[0][0] assert "warning" in call_args[0] or call_args[1].get("level") == "warning" + def test_set_run_ids_bulk(self): + """set_run_ids stores int run ids for every known project at once.""" + mock_config = self._create_mock_config() + mock_logger = Mock() + mock_client = self._create_mock_client() + + reporter = QaseTestOpsMulti(mock_config, mock_logger, mock_client) + reporter.set_run_ids({"PROJ1": 111, "PROJ2": 222}) + + assert reporter.project_runs["PROJ1"] == 111 + assert reporter.project_runs["PROJ2"] == 222 + mock_logger.log.assert_not_called() + + def test_set_run_ids_converts_strings(self): + """set_run_ids coerces stringified ids to int.""" + mock_config = self._create_mock_config() + mock_logger = Mock() + mock_client = self._create_mock_client() + + reporter = QaseTestOpsMulti(mock_config, mock_logger, mock_client) + reporter.set_run_ids({"PROJ1": "333", "PROJ2": "444"}) + + assert reporter.project_runs["PROJ1"] == 333 + assert reporter.project_runs["PROJ2"] == 444 + assert isinstance(reporter.project_runs["PROJ1"], int) + + def test_set_run_ids_warns_on_unknown_project(self): + """set_run_ids warns about unknown codes but still applies known ones.""" + mock_config = self._create_mock_config() + mock_logger = Mock() + mock_client = self._create_mock_client() + + reporter = QaseTestOpsMulti(mock_config, mock_logger, mock_client) + reporter.set_run_ids({"PROJ1": 555, "UNKNOWN": 999}) + + assert reporter.project_runs["PROJ1"] == 555 + assert "UNKNOWN" not in reporter.project_runs + warn_calls = [c for c in mock_logger.log.call_args_list + if "Unknown project code" in c[0][0]] + assert len(warn_calls) == 1 + def test_add_result_with_project_mapping(self): """Test adding result with project mapping.""" mock_config = self._create_mock_config() diff --git a/qase-python-commons/tests/tests_qase_commons/test_token_masker.py b/qase-python-commons/tests/tests_qase_commons/test_token_masker.py new file mode 100644 index 00000000..7160ea85 --- /dev/null +++ b/qase-python-commons/tests/tests_qase_commons/test_token_masker.py @@ -0,0 +1,97 @@ +"""Tests for token_masker — config logging must never leak the API token.""" + +import json + +import pytest + +from qase.commons.util.token_masker import mask_token, sanitize_config_for_log + + +class TestMaskToken: + def test_long_token_keeps_first_three_and_last_four(self): + token = "daa20e2e5dd9246712faa2ba3767a8f4f20d5060fea696e6a94734f845be93f3" + masked = mask_token(token) + + assert masked.startswith("daa") + assert masked.endswith("93f3") + assert "****" in masked + # The original token must not survive anywhere inside the masked form. + assert token not in masked + # The mask hides the bulk: only 3+4=7 visible characters out of 64. + assert len(masked) == len("daa****93f3") + + def test_eight_char_token_is_masked(self): + assert mask_token("abcd1234") == "abc****1234" + + def test_seven_char_token_fully_hidden(self): + assert mask_token("1234567") == "*******" + + def test_empty_token_returns_empty(self): + assert mask_token("") == "" + + def test_non_string_token_is_returned_as_is(self): + # Defensive: a misconfigured object should not crash the logger. + assert mask_token(None) is None + assert mask_token(12345) == 12345 + + +class TestSanitizeConfigForLog: + def _sample_payload(self, token): + return { + "mode": "testops", + "testops": { + "api": { + "host": "qase.io", + "token": token, + }, + "batch": {"size": 1}, + }, + } + + def test_masks_token_in_serialised_config(self): + payload = self._sample_payload( + "daa20e2e5dd9246712faa2ba3767a8f4f20d5060fea696e6a94734f845be93f3" + ) + out = sanitize_config_for_log(json.dumps(payload)) + + parsed = json.loads(out) + assert parsed["testops"]["api"]["token"] == "daa****93f3" + # Host and other fields must remain intact. + assert parsed["testops"]["api"]["host"] == "qase.io" + assert parsed["mode"] == "testops" + # The original token must not appear anywhere in the log line. + assert payload["testops"]["api"]["token"] not in out + + def test_returns_string_when_token_absent(self): + payload = {"mode": "off", "testops": {"api": {"host": "qase.io"}}} + out = sanitize_config_for_log(json.dumps(payload)) + + # Token missing -> nothing to mask, but still valid JSON output. + parsed = json.loads(out) + assert "token" not in parsed["testops"]["api"] + + def test_handles_null_token_without_mutating(self): + payload = self._sample_payload(None) + out = sanitize_config_for_log(json.dumps(payload)) + + parsed = json.loads(out) + assert parsed["testops"]["api"]["token"] is None + + def test_falls_back_to_str_for_unparseable_input(self): + class Weird: + def __str__(self): + return "" + + # Must not raise — logging must keep working even on a serialisation bug. + assert sanitize_config_for_log(Weird()) == "" + + def test_accepts_object_with_json_str(self): + class Cfg: + def __str__(self): + return json.dumps( + {"testops": {"api": {"token": "abcdefghij", "host": "qase.io"}}} + ) + + out = sanitize_config_for_log(Cfg()) + parsed = json.loads(out) + assert parsed["testops"]["api"]["token"] == "abc****ghij"