Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions qase-python-commons/changelog.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion qase-python-commons/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}]
Expand Down
12 changes: 9 additions & 3 deletions qase-python-commons/src/qase/commons/reporters/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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')
Expand Down
12 changes: 12 additions & 0 deletions qase-python-commons/src/qase/commons/reporters/testops_multi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
46 changes: 46 additions & 0 deletions qase-python-commons/src/qase/commons/util/token_masker.py
Original file line number Diff line number Diff line change
@@ -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)
44 changes: 44 additions & 0 deletions qase-python-commons/tests/tests_qase_commons/test_core_reporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
45 changes: 43 additions & 2 deletions qase-python-commons/tests/tests_qase_commons/test_testops_multi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
97 changes: 97 additions & 0 deletions qase-python-commons/tests/tests_qase_commons/test_token_masker.py
Original file line number Diff line number Diff line change
@@ -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 "<not json>"

# Must not raise — logging must keep working even on a serialisation bug.
assert sanitize_config_for_log(Weird()) == "<not json>"

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"
Loading