From 6065f16e56930ff99db82627fe385cee0fb0c12e Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Wed, 3 Jun 2026 19:33:02 +0300 Subject: [PATCH 1/2] fix: testops_multi xdist run_id handoff and mask api token in config debug log In testops_multi mode the controller produces a {project_code: run_id} dict. The xdist worker boundary previously called QaseTestOpsMulti.set_run_id() with one positional argument, but the multi setter requires (project_code, run_id), so workers crashed with "missing 1 required positional argument: 'run_id'" and silently fell back to local report, dropping results from TestOps. Add QaseTestOpsMulti.set_run_ids(run_ids: Dict[str, Union[str, int]]) that seeds every known project at once, and route QaseCoreReporter.set_run_id() to dispatch dict input through it. The single-project (project_code, run_id) API is unchanged. Also mask the api token in the QaseCoreReporter debug config dump. str(self.config) serialised testops.api.token in clear text, so any debug log shared with support or pasted into an issue leaked the customer's API key. Add qase.commons.util.token_masker mirroring the qase-javascript-commons/src/utils/token-masker.ts contract: tokens of 8+ chars render as abc****wxyz, tokens of 7 chars or fewer are fully hidden, and a serialisation failure falls back to plain str(config) so the masker can never silence debug output. --- .../src/qase/commons/reporters/core.py | 12 ++- .../qase/commons/reporters/testops_multi.py | 12 +++ .../src/qase/commons/util/token_masker.py | 46 +++++++++ .../tests_qase_commons/test_core_reporter.py | 44 +++++++++ .../tests_qase_commons/test_testops_multi.py | 45 ++++++++- .../tests_qase_commons/test_token_masker.py | 97 +++++++++++++++++++ 6 files changed, 251 insertions(+), 5 deletions(-) create mode 100644 qase-python-commons/src/qase/commons/util/token_masker.py create mode 100644 qase-python-commons/tests/tests_qase_commons/test_token_masker.py 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" From 162aa8a768080d5e97ce2c1166c1ecd802ebdbd8 Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Wed, 3 Jun 2026 19:33:08 +0300 Subject: [PATCH 2/2] chore: bump qase-python-commons to 5.1.2 --- qase-python-commons/changelog.md | 7 +++++++ qase-python-commons/pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) 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"}]