From b151a197db9852743e592824919a091a34d44e38 Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Wed, 3 Jun 2026 17:23:02 +0300 Subject: [PATCH 1/4] fix: testops_multi run_id handoff for pytest-xdist workers In testops_multi mode the controller produces a {project_code: run_id} dict, but the xdist lock file was written via str(dict) and read back as a single positional argument. Workers crashed in QaseTestOpsMulti.set_run_id() with "missing 1 required positional argument: 'run_id'", project_runs stayed empty, and every result was skipped with "No run_id for project ...". Serialise the run id via json.dumps in the controller, parse it back in load_run_from_lock, and add QaseTestOpsMulti.set_run_ids() to seed every project at once. QaseCoreReporter.set_run_id() now dispatches dict input to set_run_ids so the single-project path is unchanged. --- qase-pytest/src/qase/pytest/plugin.py | 23 ++++-- .../tests/tests_qase_pytest/test_plugin.py | 70 +++++++++++++++++++ .../src/qase/commons/reporters/core.py | 9 ++- .../qase/commons/reporters/testops_multi.py | 12 ++++ .../tests_qase_commons/test_core_reporter.py | 44 ++++++++++++ .../tests_qase_commons/test_testops_multi.py | 45 +++++++++++- 6 files changed, 193 insertions(+), 10 deletions(-) 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/src/qase/commons/reporters/core.py b/qase-python-commons/src/qase/commons/reporters/core.py index 96b73df1..81e31281 100644 --- a/qase-python-commons/src/qase/commons/reporters/core.py +++ b/qase-python-commons/src/qase/commons/reporters/core.py @@ -260,10 +260,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/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() From d5bc4ef438f6a1ae507c25dddbaf764a11a7aaed Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Wed, 3 Jun 2026 17:23:07 +0300 Subject: [PATCH 2/4] chore: bump qase-python-commons to 5.1.2 --- qase-python-commons/changelog.md | 6 ++++++ qase-python-commons/pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/qase-python-commons/changelog.md b/qase-python-commons/changelog.md index 9a3b4247..cc02afdc 100644 --- a/qase-python-commons/changelog.md +++ b/qase-python-commons/changelog.md @@ -1,3 +1,9 @@ +# 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. + # 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"}] From 201bab20a42bb0e19ae1aeafc59a5ff43ff60077 Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Wed, 3 Jun 2026 17:23:08 +0300 Subject: [PATCH 3/4] chore: bump qase-pytest to 8.3.1 --- qase-pytest/changelog.md | 6 ++++++ qase-pytest/pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) 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"] From e08396f12a2f55c4def3c5b4745336d2b6a66b7d Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Wed, 3 Jun 2026 17:33:59 +0300 Subject: [PATCH 4/4] fix: mask api token in QaseCoreReporter config debug dump QaseCoreReporter.__init__ logged the full config via str(self.config), which serialised testops.api.token in clear text. Any debug log shared with support or pasted into an issue leaked the customer's API key. Add qase.commons.util.token_masker with mask_token() and sanitize_config_for_log() mirroring the JS token-masker contract (qase-javascript-commons/src/utils/token-masker.ts). Route the debug config dump through sanitize_config_for_log so testops.api.token is shown as "abc****wxyz" and tokens of 7 chars or fewer are fully hidden. --- qase-python-commons/changelog.md | 1 + .../src/qase/commons/reporters/core.py | 3 +- .../src/qase/commons/util/token_masker.py | 46 +++++++++ .../tests_qase_commons/test_token_masker.py | 97 +++++++++++++++++++ 4 files changed, 146 insertions(+), 1 deletion(-) 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/changelog.md b/qase-python-commons/changelog.md index cc02afdc..2f1a8903 100644 --- a/qase-python-commons/changelog.md +++ b/qase-python-commons/changelog.md @@ -3,6 +3,7 @@ ## 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 diff --git a/qase-python-commons/src/qase/commons/reporters/core.py b/qase-python-commons/src/qase/commons/reporters/core.py index 81e31281..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}") 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_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"