|
| 1 | +# type: ignore |
| 2 | +"""Tests for INFO-level diagnostic logging on the resource-overwrites read paths. |
| 3 | +
|
| 4 | +Covers the recent change that surfaces bindings.json content and raw resource |
| 5 | +overwrites (from both uipath.json and the Studio API) at INFO so binding/ |
| 6 | +overwrite mismatches can be diagnosed from logs alone. |
| 7 | +""" |
| 8 | + |
| 9 | +import json |
| 10 | +import logging |
| 11 | +from pathlib import Path |
| 12 | +from unittest.mock import AsyncMock, MagicMock |
| 13 | + |
| 14 | +import pytest |
| 15 | + |
| 16 | +from uipath._cli._utils._common import read_resource_overwrites_from_file |
| 17 | +from uipath._cli._utils._studio_project import StudioClient |
| 18 | +from uipath.platform.common import GenericResourceOverwrite |
| 19 | + |
| 20 | +_VALID_OVERWRITES = { |
| 21 | + "asset.asset_name": { |
| 22 | + "name": "Overwritten Asset Name", |
| 23 | + "folderPath": "Overwritten/Asset/Folder", |
| 24 | + }, |
| 25 | + "bucket.bucket_name": { |
| 26 | + "name": "Overwritten Bucket Name", |
| 27 | + "folderPath": "Overwritten/Bucket/Folder", |
| 28 | + }, |
| 29 | +} |
| 30 | + |
| 31 | + |
| 32 | +_TARGET_LOGGERS = ( |
| 33 | + "uipath._cli._utils._common", |
| 34 | + "uipath._cli._utils._studio_project", |
| 35 | +) |
| 36 | + |
| 37 | + |
| 38 | +@pytest.fixture(autouse=True) |
| 39 | +def _capture_uipath_loggers( |
| 40 | + caplog: pytest.LogCaptureFixture, |
| 41 | +) -> None: |
| 42 | + """Attach caplog's handler directly to the target module loggers. |
| 43 | +
|
| 44 | + Earlier tests in the suite — chiefly anything that invokes the Click CLI |
| 45 | + — call ``setup_logging`` and leave the ``uipath`` logger with |
| 46 | + ``propagate = False``. That breaks the usual caplog flow (handler on |
| 47 | + root, records reach it via propagation). Some intermediate loggers can |
| 48 | + also end up with ``propagate = False`` from other test setups. Attaching |
| 49 | + the handler directly to each module logger we assert against, and |
| 50 | + forcing the level to DEBUG for the duration of the test, side-steps the |
| 51 | + propagation question entirely. |
| 52 | + """ |
| 53 | + snapshots: list[tuple[logging.Logger, int, bool]] = [] |
| 54 | + for name in _TARGET_LOGGERS: |
| 55 | + logger = logging.getLogger(name) |
| 56 | + snapshots.append((logger, logger.level, logger.propagate)) |
| 57 | + logger.setLevel(logging.DEBUG) |
| 58 | + logger.propagate = True |
| 59 | + logger.addHandler(caplog.handler) |
| 60 | + try: |
| 61 | + yield |
| 62 | + finally: |
| 63 | + for logger, level, propagate in snapshots: |
| 64 | + logger.removeHandler(caplog.handler) |
| 65 | + logger.setLevel(level) |
| 66 | + logger.propagate = propagate |
| 67 | + |
| 68 | + |
| 69 | +def _write_uipath_json(directory: Path, overwrites: dict) -> Path: |
| 70 | + config_path = directory / "uipath.json" |
| 71 | + config_path.write_text( |
| 72 | + json.dumps( |
| 73 | + { |
| 74 | + "runtime": {"internalArguments": {"resourceOverwrites": overwrites}}, |
| 75 | + } |
| 76 | + ) |
| 77 | + ) |
| 78 | + return config_path |
| 79 | + |
| 80 | + |
| 81 | +class TestReadResourceOverwritesFromFileLogging: |
| 82 | + """Behavior: read_resource_overwrites_from_file logs diagnostic info at INFO.""" |
| 83 | + |
| 84 | + async def test_logs_raw_overwrites_at_info_when_file_present( |
| 85 | + self, tmp_path: Path, caplog: pytest.LogCaptureFixture |
| 86 | + ) -> None: |
| 87 | + config_path = _write_uipath_json(tmp_path, _VALID_OVERWRITES) |
| 88 | + |
| 89 | + with caplog.at_level(logging.INFO, logger="uipath._cli._utils._common"): |
| 90 | + result = await read_resource_overwrites_from_file(str(tmp_path)) |
| 91 | + |
| 92 | + assert set(result.keys()) == set(_VALID_OVERWRITES.keys()) |
| 93 | + |
| 94 | + info_records = [r for r in caplog.records if r.levelno == logging.INFO] |
| 95 | + assert any( |
| 96 | + "Resource overwrites read from" in r.getMessage() |
| 97 | + and str(config_path) in r.getMessage() |
| 98 | + and f"({len(_VALID_OVERWRITES)} entries)" in r.getMessage() |
| 99 | + for r in info_records |
| 100 | + ), f"expected INFO log with file path and entry count, got: {caplog.text}" |
| 101 | + |
| 102 | + # The raw JSON payload should be present in the log so a developer can |
| 103 | + # diff it against what Studio later returns. |
| 104 | + assert "Overwritten Asset Name" in caplog.text |
| 105 | + assert "Overwritten Bucket Name" in caplog.text |
| 106 | + |
| 107 | + async def test_logs_info_when_config_file_missing( |
| 108 | + self, tmp_path: Path, caplog: pytest.LogCaptureFixture |
| 109 | + ) -> None: |
| 110 | + # tmp_path is empty — no uipath.json present. |
| 111 | + missing_dir = tmp_path / "does-not-exist" |
| 112 | + missing_dir.mkdir() |
| 113 | + |
| 114 | + with caplog.at_level(logging.INFO, logger="uipath._cli._utils._common"): |
| 115 | + result = await read_resource_overwrites_from_file(str(missing_dir)) |
| 116 | + |
| 117 | + assert result == {} |
| 118 | + info_messages = [ |
| 119 | + r.getMessage() for r in caplog.records if r.levelno == logging.INFO |
| 120 | + ] |
| 121 | + assert any( |
| 122 | + "Resource overwrites config file not found" in msg for msg in info_messages |
| 123 | + ), f"expected INFO log for missing config, got: {info_messages}" |
| 124 | + |
| 125 | + async def test_logs_warning_when_json_is_malformed( |
| 126 | + self, tmp_path: Path, caplog: pytest.LogCaptureFixture |
| 127 | + ) -> None: |
| 128 | + (tmp_path / "uipath.json").write_text("{not valid json") |
| 129 | + |
| 130 | + with caplog.at_level(logging.WARNING, logger="uipath._cli._utils._common"): |
| 131 | + result = await read_resource_overwrites_from_file(str(tmp_path)) |
| 132 | + |
| 133 | + assert result == {} |
| 134 | + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] |
| 135 | + assert any( |
| 136 | + "Failed to parse resource overwrites" in r.getMessage() for r in warnings |
| 137 | + ) |
| 138 | + |
| 139 | + async def test_unrecognized_overwrite_key_is_skipped_with_warning( |
| 140 | + self, tmp_path: Path, caplog: pytest.LogCaptureFixture |
| 141 | + ) -> None: |
| 142 | + overwrites = { |
| 143 | + **_VALID_OVERWRITES, |
| 144 | + "totallyUnknownKind.foo": {"name": "x", "folderPath": "y"}, |
| 145 | + } |
| 146 | + _write_uipath_json(tmp_path, overwrites) |
| 147 | + |
| 148 | + with caplog.at_level(logging.WARNING, logger="uipath._cli._utils._common"): |
| 149 | + result = await read_resource_overwrites_from_file(str(tmp_path)) |
| 150 | + |
| 151 | + # Valid entries still parsed; unknown key dropped. |
| 152 | + assert set(result.keys()) == set(_VALID_OVERWRITES.keys()) |
| 153 | + assert any( |
| 154 | + "Skipping unrecognized resource overwrite" in r.getMessage() |
| 155 | + and "totallyUnknownKind.foo" in r.getMessage() |
| 156 | + for r in caplog.records |
| 157 | + if r.levelno == logging.WARNING |
| 158 | + ) |
| 159 | + |
| 160 | + |
| 161 | +class TestStudioClientGetResourceOverwritesLogging: |
| 162 | + """Behavior: StudioClient.get_resource_overwrites logs bindings + raw payload.""" |
| 163 | + |
| 164 | + @pytest.fixture |
| 165 | + def studio_client(self) -> StudioClient: |
| 166 | + # Inject a mock UiPath so no real HTTP setup is required. |
| 167 | + mock_uipath = MagicMock() |
| 168 | + mock_uipath.api_client.request_async = AsyncMock() |
| 169 | + client = StudioClient(project_id="test-project-id", uipath=mock_uipath) |
| 170 | + # Avoid the network call that resolves the solution id. |
| 171 | + client._get_solution_id = AsyncMock(return_value="test-solution-id") # type: ignore[method-assign] |
| 172 | + return client |
| 173 | + |
| 174 | + async def test_warns_and_returns_empty_when_bindings_file_missing( |
| 175 | + self, |
| 176 | + studio_client: StudioClient, |
| 177 | + tmp_path: Path, |
| 178 | + monkeypatch: pytest.MonkeyPatch, |
| 179 | + caplog: pytest.LogCaptureFixture, |
| 180 | + ) -> None: |
| 181 | + from uipath.platform.common._config import ConfigurationManager |
| 182 | + |
| 183 | + missing_path = tmp_path / "bindings.json" |
| 184 | + monkeypatch.setattr( |
| 185 | + ConfigurationManager, |
| 186 | + "bindings_file_path", |
| 187 | + property(lambda self: missing_path), |
| 188 | + ) |
| 189 | + |
| 190 | + with caplog.at_level(logging.WARNING): |
| 191 | + result = await studio_client.get_resource_overwrites() |
| 192 | + |
| 193 | + assert result == {} |
| 194 | + assert any( |
| 195 | + "Bindings file not found" in r.getMessage() |
| 196 | + for r in caplog.records |
| 197 | + if r.levelno == logging.WARNING |
| 198 | + ) |
| 199 | + # No request should have been made when there is nothing to upload. |
| 200 | + studio_client.uipath.api_client.request_async.assert_not_called() |
| 201 | + |
| 202 | + async def test_logs_bindings_content_and_received_overwrites_at_info( |
| 203 | + self, |
| 204 | + studio_client: StudioClient, |
| 205 | + tmp_path: Path, |
| 206 | + monkeypatch: pytest.MonkeyPatch, |
| 207 | + caplog: pytest.LogCaptureFixture, |
| 208 | + ) -> None: |
| 209 | + from uipath.platform.common._config import ConfigurationManager |
| 210 | + |
| 211 | + bindings_path = tmp_path / "bindings.json" |
| 212 | + bindings_content = json.dumps( |
| 213 | + {"version": "2", "resources": [{"name": "my_bucket", "kind": "bucket"}]} |
| 214 | + ) |
| 215 | + bindings_path.write_text(bindings_content) |
| 216 | + monkeypatch.setattr( |
| 217 | + ConfigurationManager, |
| 218 | + "bindings_file_path", |
| 219 | + property(lambda self: bindings_path), |
| 220 | + ) |
| 221 | + monkeypatch.delenv("UIPATH_TENANT_ID", raising=False) |
| 222 | + |
| 223 | + response = MagicMock() |
| 224 | + response.json.return_value = { |
| 225 | + "bucket.my_bucket": { |
| 226 | + "name": "prod_bucket", |
| 227 | + "folderPath": "Prod/Folder", |
| 228 | + } |
| 229 | + } |
| 230 | + studio_client.uipath.api_client.request_async = AsyncMock(return_value=response) |
| 231 | + |
| 232 | + with caplog.at_level(logging.INFO, logger="uipath._cli._utils._studio_project"): |
| 233 | + result = await studio_client.get_resource_overwrites() |
| 234 | + |
| 235 | + # Returned dict is parsed via ResourceOverwriteParser. |
| 236 | + assert set(result.keys()) == {"bucket.my_bucket"} |
| 237 | + |
| 238 | + info_text = "\n".join( |
| 239 | + r.getMessage() for r in caplog.records if r.levelno == logging.INFO |
| 240 | + ) |
| 241 | + # Bindings content is logged so we can compare what was sent to Studio. |
| 242 | + assert "Resource bindings" in info_text |
| 243 | + assert "my_bucket" in info_text |
| 244 | + # Received overwrites payload is logged with the solution id and count. |
| 245 | + assert "Resource overwrites received for solution test-solution-id" in info_text |
| 246 | + assert "(1 entries)" in info_text |
| 247 | + assert "prod_bucket" in info_text |
| 248 | + |
| 249 | + async def test_parses_received_overwrites_into_resource_overwrite_objects( |
| 250 | + self, |
| 251 | + studio_client: StudioClient, |
| 252 | + tmp_path: Path, |
| 253 | + monkeypatch: pytest.MonkeyPatch, |
| 254 | + ) -> None: |
| 255 | + from uipath.platform.common._config import ConfigurationManager |
| 256 | + |
| 257 | + bindings_path = tmp_path / "bindings.json" |
| 258 | + bindings_path.write_text("{}") |
| 259 | + monkeypatch.setattr( |
| 260 | + ConfigurationManager, |
| 261 | + "bindings_file_path", |
| 262 | + property(lambda self: bindings_path), |
| 263 | + ) |
| 264 | + |
| 265 | + response = MagicMock() |
| 266 | + response.json.return_value = { |
| 267 | + "bucket.my_bucket": { |
| 268 | + "name": "prod_bucket", |
| 269 | + "folderPath": "Prod/Folder", |
| 270 | + } |
| 271 | + } |
| 272 | + studio_client.uipath.api_client.request_async = AsyncMock(return_value=response) |
| 273 | + |
| 274 | + result = await studio_client.get_resource_overwrites() |
| 275 | + |
| 276 | + parsed = result["bucket.my_bucket"] |
| 277 | + assert isinstance(parsed, GenericResourceOverwrite) |
| 278 | + assert parsed.resource_identifier == "prod_bucket" |
| 279 | + assert parsed.folder_identifier == "Prod/Folder" |
| 280 | + |
| 281 | + async def test_passes_tenant_id_header_from_environment( |
| 282 | + self, |
| 283 | + studio_client: StudioClient, |
| 284 | + tmp_path: Path, |
| 285 | + monkeypatch: pytest.MonkeyPatch, |
| 286 | + ) -> None: |
| 287 | + from uipath.platform.common._config import ConfigurationManager |
| 288 | + |
| 289 | + bindings_path = tmp_path / "bindings.json" |
| 290 | + bindings_path.write_text("{}") |
| 291 | + monkeypatch.setattr( |
| 292 | + ConfigurationManager, |
| 293 | + "bindings_file_path", |
| 294 | + property(lambda self: bindings_path), |
| 295 | + ) |
| 296 | + monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-from-env") |
| 297 | + |
| 298 | + response = MagicMock() |
| 299 | + response.json.return_value = {} |
| 300 | + request_mock = AsyncMock(return_value=response) |
| 301 | + studio_client.uipath.api_client.request_async = request_mock |
| 302 | + |
| 303 | + await studio_client.get_resource_overwrites() |
| 304 | + |
| 305 | + # The header carrying the tenant id should reflect the env var value. |
| 306 | + call_kwargs = request_mock.await_args.kwargs |
| 307 | + headers = call_kwargs["headers"] |
| 308 | + assert any(value == "tenant-from-env" for value in headers.values()), headers |
0 commit comments