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"